@scope-profiler/plotly 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,12 @@ Pure, framework-neutral Plotly figure builders for JSON written by
4
4
  `scope-profiler export plot-data --format json`. The package does not import
5
5
  Plotly; applications choose their own Plotly bundle.
6
6
 
7
+ Install the builder and a Plotly bundle:
8
+
9
+ ```sh
10
+ npm install @scope-profiler/plotly plotly.js-dist-min
11
+ ```
12
+
7
13
  ```js
8
14
  import Plotly from "plotly.js-dist-min";
9
15
  import { buildGanttFigure, renderFigure } from "@scope-profiler/plotly";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scope-profiler/plotly",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Framework-neutral Plotly figure builders for scope-profiler plot-data JSON.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.d.ts CHANGED
@@ -5,4 +5,8 @@ export function buildGanttFigure(payload: object, options?: BuildOptions): Figur
5
5
  export function buildFlameFigure(payload: object, options?: BuildOptions): Figure;
6
6
  export function buildDurationsFigure(payload: object, options?: BuildOptions): Figure;
7
7
  export function buildSpeedupFigure(payload: object, options?: BuildOptions): Figure;
8
+ export function buildDurationTimeseriesFigure(payload: object, options?: BuildOptions): Figure;
9
+ export function buildHistogramFigure(payload: object, options?: BuildOptions): Figure;
10
+ export function buildRankHeatmapFigure(payload: object, options?: BuildOptions & { valueKey?: string; colorscale?: string }): Figure;
11
+ export function buildImbalanceFigure(payload: object, options?: BuildOptions): Figure;
8
12
  export function renderFigure(plotly: PlotlyLike, element: Element | string, figure: Figure, config?: object): unknown;
package/src/index.js CHANGED
@@ -20,8 +20,23 @@ function values(payload, key) {
20
20
 
21
21
  function baseLayout(overrides = {}) {
22
22
  return {
23
- paper_bgcolor: "transparent", plot_bgcolor: "transparent", font: { family: "system-ui, sans-serif" },
24
- margin: { l: 100, r: 24, t: 32, b: 56 }, legend: { orientation: "h" }, ...overrides,
23
+ paper_bgcolor: "transparent", plot_bgcolor: "transparent",
24
+ font: { family: "Inter, ui-sans-serif, system-ui, sans-serif", size: 12 },
25
+ hovermode: "closest", hoverlabel: { namelength: -1 },
26
+ margin: { l: 100, r: 24, t: 32, b: 64 },
27
+ legend: { orientation: "h", y: -0.2, x: 0 }, ...overrides,
28
+ };
29
+ }
30
+
31
+ function axis(overrides = {}) {
32
+ return { automargin: true, gridcolor: "rgba(128, 128, 128, 0.2)", zeroline: false, ...overrides };
33
+ }
34
+
35
+ function withEmptyState(layout, hasData) {
36
+ if (hasData) return layout;
37
+ return {
38
+ ...layout,
39
+ annotations: [{ text: "No data to display.", showarrow: false, xref: "paper", yref: "paper", x: 0.5, y: 0.5 }],
25
40
  };
26
41
  }
27
42
 
@@ -40,12 +55,13 @@ export function buildGanttFigure(payload, options = {}) {
40
55
  return { type: "bar", orientation: "h", name: region,
41
56
  y: rows.map((row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`),
42
57
  x: rows.map((row) => row.end_seconds - row.start_seconds), base: rows.map((row) => row.start_seconds),
43
- marker: { color: colors.get(region) },
58
+ marker: { color: colors.get(region), line: { color: "rgba(0, 0, 0, 0.28)", width: 0.5 } },
44
59
  customdata: rows.map((row) => [row.file, row.rank]),
45
60
  hovertemplate: `<b>${region}</b><br>%{customdata[0]} / rank %{customdata[1]}<br>start: %{base:.6g} s<br>duration: %{x:.6g} s<extra></extra>`,
46
61
  };
47
62
  });
48
- return { data, layout: baseLayout({ barmode: "overlay", height: Math.max(280, 48 * lanes.length + 150), xaxis: { title: "Time (s)" }, yaxis: { categoryorder: "array", categoryarray: lanes, autorange: "reversed" }, ...options.layout }) };
63
+ const layout = baseLayout({ barmode: "overlay", height: Math.max(280, 48 * lanes.length + 150), showlegend: regions.length > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ categoryorder: "array", categoryarray: lanes, autorange: "reversed", showgrid: false }), ...options.layout });
64
+ return { data, layout: withEmptyState(layout, intervals.length > 0) };
49
65
  }
50
66
 
51
67
  /** Build an icicle flame chart using scope-profiler's explicit call IDs. */
@@ -64,7 +80,8 @@ export function buildFlameFigure(payload, options = {}) {
64
80
  markerColors.push(colors.get(call.region));
65
81
  hovertext.push(`<b>${call.region}</b><br>${call.file ?? "run"} / rank ${call.rank ?? 0}<br>start: ${call.start_seconds.toPrecision(6)} s<br>inclusive: ${(call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds).toPrecision(6)} s`);
66
82
  }
67
- return { data: [{ type: "icicle", ids, labels, parents, values: [rootDuration, ...calls.map((call) => call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds)], branchvalues: "total", tiling: { orientation: "h" }, marker: { colors: markerColors }, hovertext, hoverinfo: "text" }], layout: baseLayout({ height: 500, margin: { l: 24, r: 24, t: 24, b: 24 }, ...options.layout }) };
83
+ const layout = baseLayout({ height: 500, margin: { l: 24, r: 24, t: 24, b: 24 }, ...options.layout });
84
+ return { data: [{ type: "icicle", ids, labels, parents, values: [rootDuration, ...calls.map((call) => call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds)], branchvalues: "total", tiling: { orientation: "h" }, marker: { colors: markerColors, line: { color: "rgba(255, 255, 255, 0.55)", width: 1 } }, hovertext, hoverinfo: "text" }], layout: withEmptyState(layout, calls.length > 0) };
68
85
  }
69
86
 
70
87
  export function buildDurationsFigure(payload, options = {}) {
@@ -79,9 +96,10 @@ export function buildDurationsFigure(payload, options = {}) {
79
96
  const data = groups.map((group) => {
80
97
  const rows = bars.filter((bar) => (stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`) === group);
81
98
  const byRegion = new Map(rows.map((bar) => [bar.region, bar.value_seconds]));
82
- return { type: "bar", name: group, x: regions, y: regions.map((region) => byRegion.get(region) ?? null), marker: { color: colors.get(group) }, hovertemplate: `<b>%{x}</b><br>${group}: %{y:.6g} s<extra></extra>` };
99
+ return { type: "bar", name: group, x: regions, y: regions.map((region) => byRegion.get(region) ?? null), marker: { color: colors.get(group), line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 } }, hovertemplate: `<b>%{x}</b><br>${group}: %{y:.6g} s<extra></extra>` };
83
100
  });
84
- return { data, layout: baseLayout({ barmode: stacked ? "stack" : "group", height: Math.max(360, 34 * regions.length + 180), xaxis: { tickangle: -35 }, yaxis: { title: `${metric} duration (s)` }, ...options.layout }) };
101
+ const layout = baseLayout({ barmode: stacked ? "stack" : "group", height: Math.max(360, 34 * regions.length + 180), showlegend: groups.length > 1, xaxis: axis({ tickangle: -35 }), yaxis: axis({ title: `${metric} duration (s)` }), ...options.layout });
102
+ return { data, layout: withEmptyState(layout, bars.length > 0) };
85
103
  }
86
104
 
87
105
  export function buildSpeedupFigure(payload, options = {}) {
@@ -91,10 +109,67 @@ export function buildSpeedupFigure(payload, options = {}) {
91
109
  const colors = colorMap(regions, options.colors ?? payload.colors);
92
110
  const xValues = [...new Set(points.map((point) => point[xField]))].sort((a, b) => typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b)));
93
111
  const numeric = xValues.every((value) => typeof value === "number");
94
- const data = regions.map((region) => { const rows = points.filter((point) => point.region === region).sort((a, b) => xValues.indexOf(a[xField]) - xValues.indexOf(b[xField])); return { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row[xField]), y: rows.map((row) => row.speedup), line: { color: colors.get(region) }, marker: { color: colors.get(region) }, hovertemplate: `<b>%{x}</b><br>${region}: %{y:.3g}×<extra></extra>` }; });
112
+ const data = regions.map((region) => { const rows = points.filter((point) => point.region === region).sort((a, b) => xValues.indexOf(a[xField]) - xValues.indexOf(b[xField])); return { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row[xField]), y: rows.map((row) => row.speedup), line: { color: colors.get(region), width: 2.4 }, marker: { color: colors.get(region), size: 7 }, hovertemplate: `<b>%{x}</b><br>${region}: %{y:.3g}×<extra></extra>` }; });
95
113
  const baseline = payload.options?.baseline ?? xValues[0];
96
114
  if (numeric && options.ideal !== false) data.push({ type: "scatter", mode: "lines", name: "Ideal speedup", x: xValues, y: xValues.map((value) => value / baseline), line: { color: "#777", dash: "dash" }, hoverinfo: "skip" });
97
- return { data, layout: baseLayout({ height: 420, xaxis: { title: payload.options?.x_label ?? xField, tickvals: xValues }, yaxis: { title: "Speedup" }, ...options.layout }) };
115
+ const layout = baseLayout({ height: 420, showlegend: data.length > 1, xaxis: axis({ title: payload.options?.x_label ?? xField, tickvals: xValues }), yaxis: axis({ title: "Speedup", rangemode: "tozero" }), ...options.layout });
116
+ return { data, layout: withEmptyState(layout, points.length > 0) };
117
+ }
118
+
119
+ /** Build mean call duration over time, one trace per region. */
120
+ export function buildDurationTimeseriesFigure(payload, options = {}) {
121
+ const points = filtered(values(payload, "points"), options);
122
+ const regions = [...new Set(points.map((point) => point.region))];
123
+ const colors = colorMap(regions, options.colors ?? payload.colors);
124
+ const data = regions.map((region) => {
125
+ const rows = points.filter((point) => point.region === region).sort((a, b) => a.time_seconds - b.time_seconds);
126
+ return { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row.time_seconds), y: rows.map((row) => row.mean_duration_seconds), line: { color: colors.get(region), width: 2.2 }, marker: { color: colors.get(region), size: 5 }, customdata: rows.map((row) => [row.min_duration_seconds, row.max_duration_seconds, row.call_index]), hovertemplate: `<b>${region}</b><br>time: %{x:.6g} s<br>mean: %{y:.6g} s<br>min–max: %{customdata[0]:.4g}–%{customdata[1]:.4g} s<extra></extra>` };
127
+ });
128
+ const layout = baseLayout({ height: 420, showlegend: regions.length > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ title: "Mean call duration (s)" }), ...options.layout });
129
+ return { data, layout: withEmptyState(layout, points.length > 0) };
130
+ }
131
+
132
+ /** Build duration distributions from histogram bin records. */
133
+ export function buildHistogramFigure(payload, options = {}) {
134
+ const bins = filtered(values(payload, "bins"), options);
135
+ const regions = [...new Set(bins.map((bin) => bin.region))];
136
+ const colors = colorMap(regions, options.colors ?? payload.colors);
137
+ const data = regions.map((region) => {
138
+ const rows = bins.filter((bin) => bin.region === region).sort((a, b) => a.bin_center_seconds - b.bin_center_seconds);
139
+ return { type: "bar", name: region, x: rows.map((bin) => bin.bin_center_seconds), y: rows.map((bin) => bin.count), width: rows.map((bin) => bin.bin_high_seconds - bin.bin_low_seconds), marker: { color: colors.get(region), line: { color: "rgba(0, 0, 0, 0.2)", width: 0.5 } }, hovertemplate: `<b>${region}</b><br>%{x:.6g} s: %{y} calls<extra></extra>` };
140
+ });
141
+ const layout = baseLayout({ barmode: "overlay", height: 400, showlegend: regions.length > 1, xaxis: axis({ title: "Call duration (s)" }), yaxis: axis({ title: "Calls" }), ...options.layout });
142
+ return { data, layout: withEmptyState(layout, bins.length > 0) };
143
+ }
144
+
145
+ /** Build a rank × region heatmap from duration records. */
146
+ export function buildRankHeatmapFigure(payload, options = {}) {
147
+ const points = filtered(values(payload, "points"), options);
148
+ const regions = [...new Set(points.map((point) => point.region))];
149
+ const ranks = [...new Set(points.map((point) => point.rank))].sort((a, b) => a - b);
150
+ const inferredValueKey = points[0] ? Object.keys(points[0]).find((key) => key.endsWith("_duration_seconds")) : undefined;
151
+ const valueKey = options.valueKey ?? inferredValueKey ?? "total_duration_seconds";
152
+ const byCell = new Map(points.map((point) => [`${point.rank}:${point.region}`, point[valueKey]]));
153
+ const data = [{ type: "heatmap", x: regions, y: ranks.map(String), z: ranks.map((rank) => regions.map((region) => byCell.get(`${rank}:${region}`) ?? null)), colorscale: options.colorscale ?? "Viridis", colorbar: { title: "Seconds" }, hovertemplate: "rank %{y}<br>%{x}: %{z:.6g} s<extra></extra>" }];
154
+ const layout = baseLayout({ height: Math.max(320, 44 * ranks.length + 150), xaxis: axis({ title: "Region" }), yaxis: axis({ title: "Rank", autorange: "reversed", showgrid: false }), ...options.layout });
155
+ return { data, layout: withEmptyState(layout, points.length > 0) };
156
+ }
157
+
158
+ /** Build per-rank duration lines, with a dashed rank mean for each region. */
159
+ export function buildImbalanceFigure(payload, options = {}) {
160
+ const points = filtered(values(payload, "points"), options);
161
+ const regions = [...new Set(points.map((point) => point.region))];
162
+ const colors = colorMap(regions, options.colors ?? payload.colors);
163
+ const data = regions.flatMap((region) => {
164
+ const rows = points.filter((point) => point.region === region).sort((a, b) => a.rank - b.rank);
165
+ const color = colors.get(region);
166
+ return [
167
+ { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row.rank), y: rows.map((row) => row.value_seconds), line: { color, width: 2.2 }, marker: { color, size: 7 }, hovertemplate: `<b>${region}</b><br>rank %{x}: %{y:.6g} s<extra></extra>` },
168
+ { type: "scatter", mode: "lines", name: `${region} mean`, x: rows.map((row) => row.rank), y: rows.map((row) => row.mean_over_ranks_seconds), line: { color, dash: "dot", width: 1.3 }, hoverinfo: "skip", showlegend: false },
169
+ ];
170
+ });
171
+ const layout = baseLayout({ height: 420, showlegend: regions.length > 1, xaxis: axis({ title: "Rank", dtick: 1 }), yaxis: axis({ title: `${payload.metric ?? "Duration"} (s)` }), ...options.layout });
172
+ return { data, layout: withEmptyState(layout, points.length > 0) };
98
173
  }
99
174
 
100
175
  /** Render a figure with any Plotly-compatible bundle. */