@scope-profiler/plotly 0.1.0 → 0.2.0

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";
@@ -13,5 +19,42 @@ const figure = buildGanttFigure(payload);
13
19
  await renderFigure(Plotly, document.querySelector("#gantt"), figure);
14
20
  ```
15
21
 
16
- Builders accept both the current scope-profiler payloads and versioned payloads
17
- with `format: "scope-profiler-plot-data"` and `format_version: 1`.
22
+ ## One call for any payload
23
+
24
+ Every document written by `export plot-data` carries the `plot` kind that
25
+ produced it, so `buildFigure` can pick the builder for you:
26
+
27
+ ```js
28
+ import { buildFigure, renderFigure } from "@scope-profiler/plotly";
29
+
30
+ const payload = await fetch("/figures/rank_heatmap_data.json").then((response) => response.json());
31
+ await renderFigure(Plotly, document.querySelector("#chart"), buildFigure(payload));
32
+ ```
33
+
34
+ `buildFigure` rejects a document that is not `scope-profiler-plot-data` and one
35
+ whose `format_version` is newer than this package supports. JSON written before
36
+ scope-profiler stamped that envelope on every kind still works: the kind is then
37
+ inferred from the payload shape, and `{ plot: "gantt" }` settles it by hand.
38
+
39
+ ## Builders
40
+
41
+ `buildGanttFigure`, `buildFlameFigure`, `buildCallgraphFigure`,
42
+ `buildDensityFigure`, `buildDurationsFigure`, `buildDurationTimeseriesFigure`,
43
+ `buildHistogramFigure`, `buildRankHeatmapFigure`, `buildImbalanceFigure`,
44
+ `buildRegionSummaryFigure`, `buildLikwidFigure`, `buildSpeedupFigure`,
45
+ `buildWeakScalingFigure` and `buildScalingEfficiencyFigure` each take
46
+ `(payload, options)` and return a plain `{ data, layout }` figure.
47
+
48
+ Common options: `colors` (region or series name to color), `filterRegion(name,
49
+ row)` to drop rows, `layout` to merge into the generated layout, and `metric`
50
+ where a payload carries several.
51
+
52
+ ## More than one run in a payload
53
+
54
+ `export plot-data` accepts several profiles at once, and the payload then
55
+ carries a `file` column. Builders keep those runs apart: the gantt, density and
56
+ rank heatmap give each run its own lanes, and the histogram, imbalance and
57
+ duration time series give each run its own trace, labelled `run / region`. The
58
+ region keeps its colour across runs, so a run is told apart by marker symbol
59
+ (lines) or bar pattern (bars). A single-run payload is unchanged -- series are
60
+ named by region alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scope-profiler/plotly",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Framework-neutral Plotly figure builders for scope-profiler plot-data JSON.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,5 +17,5 @@
17
17
  "files": ["src", "README.md", "LICENSE.txt"],
18
18
  "publishConfig": { "access": "public" },
19
19
  "scripts": { "test": "node --test" },
20
- "keywords": ["scope-profiler", "plotly", "profiling", "gantt", "flamegraph"]
20
+ "keywords": ["scope-profiler", "plotly", "profiling", "gantt", "flamegraph", "callgraph", "hpc", "mpi"]
21
21
  }
package/src/index.d.ts CHANGED
@@ -1,8 +1,24 @@
1
1
  export interface Figure { data: object[]; layout: object }
2
2
  export interface PlotlyLike { newPlot(element: Element | string, data: object[], layout: object, config?: object): unknown }
3
- export interface BuildOptions { colors?: Record<string, string>; filterRegion?: (region: string, row: object) => boolean; layout?: object; metric?: string; xField?: string; ideal?: boolean; rootLabel?: string }
3
+ export type PlotKind = "gantt" | "density" | "flame" | "flame_chart" | "flame_graph" | "callgraph" | "durations" | "timeseries" | "speedup" | "weak_scaling" | "scaling_efficiency" | "rank_heatmap" | "histogram" | "imbalance" | "likwid" | "region_statistics";
4
+ export interface BuildOptions { colors?: Record<string, string>; filterRegion?: (region: string, row: object) => boolean; layout?: object; metric?: string; xField?: string; ideal?: boolean; rootLabel?: string; plot?: PlotKind }
4
5
  export function buildGanttFigure(payload: object, options?: BuildOptions): Figure;
5
6
  export function buildFlameFigure(payload: object, options?: BuildOptions): Figure;
6
7
  export function buildDurationsFigure(payload: object, options?: BuildOptions): Figure;
7
- export function buildSpeedupFigure(payload: object, options?: BuildOptions): Figure;
8
+ export function buildSpeedupFigure(payload: object, options?: BuildOptions & { yField?: string }): Figure;
9
+ export function buildWeakScalingFigure(payload: object, options?: BuildOptions): Figure;
10
+ export function buildScalingEfficiencyFigure(payload: object, options?: BuildOptions): Figure;
11
+ export function buildDurationTimeseriesFigure(payload: object, options?: BuildOptions): Figure;
12
+ export function buildHistogramFigure(payload: object, options?: BuildOptions): Figure;
13
+ export function buildRankHeatmapFigure(payload: object, options?: BuildOptions & { valueKey?: string; colorscale?: string }): Figure;
14
+ export function buildDensityFigure(payload: object, options?: BuildOptions & { valueKey?: "occupancy" | "occupied_seconds"; colorscale?: string }): Figure;
15
+ export function buildImbalanceFigure(payload: object, options?: BuildOptions): Figure;
16
+ export function buildRegionSummaryFigure(payload: object, options?: BuildOptions & { topN?: number }): Figure;
17
+ export function buildCallgraphFigure(payload: object, options?: BuildOptions & { valueKey?: string }): Figure;
18
+ export function buildLikwidFigure(payload: object, options?: BuildOptions & { logScale?: boolean }): Figure;
19
+ export const PLOT_DATA_FORMAT: string;
20
+ export const SUPPORTED_FORMAT_VERSION: number;
21
+ export const PLOT_BUILDERS: Record<PlotKind, (payload: object, options?: BuildOptions) => Figure>;
22
+ export function inferPlotKind(payload: object): PlotKind | undefined;
23
+ export function buildFigure(payload: object, options?: BuildOptions): Figure;
8
24
  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
 
@@ -29,42 +44,109 @@ function filtered(rows, options) {
29
44
  return typeof options?.filterRegion === "function" ? rows.filter((row) => options.filterRegion(row.region, row)) : rows;
30
45
  }
31
46
 
32
- /** Build a multi-run, multi-rank timeline. Regions are colored; file/rank are lanes. */
47
+ // One pass instead of a filter per series. A large trace has both many rows and
48
+ // many regions, so filtering the whole array once per series is quadratic: on
49
+ // 200k intervals over 400 regions that is the difference between ~690 ms and
50
+ // ~8 ms. Map preserves first-appearance order, which is the order the series
51
+ // are drawn and listed in.
52
+ function groupBy(rows, key) {
53
+ const groups = new Map();
54
+ for (const row of rows) {
55
+ const name = key(row);
56
+ const bucket = groups.get(name);
57
+ if (bucket) bucket.push(row);
58
+ else groups.set(name, [row]);
59
+ }
60
+ return groups;
61
+ }
62
+
63
+ // Several payloads carry rows from more than one run. Dropping that column
64
+ // merges runs into one series -- silently, and wrongly -- so every builder that
65
+ // can see two runs keys its series by file as well, and says so in the label.
66
+ function runAware(rows) {
67
+ const files = new Set(rows.map((row) => row.file ?? "run"));
68
+ const multi = files.size > 1;
69
+ return {
70
+ multi,
71
+ files: [...files],
72
+ label: (row) => (multi ? `${row.file ?? "run"} / ${row.region}` : row.region),
73
+ key: (row) => (multi ? `${row.file ?? "run"}\u0000${row.region}` : row.region),
74
+ };
75
+ }
76
+
77
+ // Region colour stays with the region across runs (and honours the payload's
78
+ // own colours), so a run is told apart by marker shape or bar pattern instead.
79
+ const FILE_SYMBOLS = ["circle", "square", "diamond", "triangle-up", "cross"];
80
+ const FILE_PATTERNS = ["", "/", "\\", "x", "-"];
81
+
82
+ /** Build a multi-run, multi-rank timeline: a lane per region and rank.
83
+ *
84
+ * A lane per rank alone cannot show a nested profile: every region of a rank
85
+ * lands on one row, and the outermost region -- the session, typically -- is
86
+ * drawn over everything inside it. One lane per region and rank is also what
87
+ * `scope-profiler plot gantt` draws, so the two agree. Pass
88
+ * `{ laneBy: "rank" }` for the compact one-row-per-rank view, which suits a
89
+ * flat profile compared across many ranks.
90
+ */
33
91
  export function buildGanttFigure(payload, options = {}) {
34
92
  const intervals = filtered(values(payload, "intervals"), options);
35
- const regions = [...new Set(intervals.map((row) => row.region))];
36
- const colors = colorMap(regions, options.colors ?? payload.colors);
37
- const lanes = [...new Set(intervals.map((row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`))];
38
- const data = regions.map((region) => {
39
- const rows = intervals.filter((row) => row.region === region);
93
+ const byRegion = groupBy(intervals, (row) => row.region);
94
+ const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
95
+ const multi = new Set(intervals.map((row) => row.file ?? "run")).size > 1;
96
+ const rankLane = (row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`;
97
+ // Matching `plot gantt`'s own lane label, extended by the run only when the
98
+ // payload holds more than one.
99
+ const regionLane = (row) => `${multi ? `${row.file ?? "run"} / ` : ""}${row.region} (rank ${row.rank ?? 0})`;
100
+ const laneOf = options.laneBy === "rank" ? rankLane : regionLane;
101
+ const lanes = [...new Set(intervals.map(laneOf))];
102
+ const data = [...byRegion].map(([region, rows]) => {
40
103
  return { type: "bar", orientation: "h", name: region,
41
- y: rows.map((row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`),
104
+ y: rows.map(laneOf),
42
105
  x: rows.map((row) => row.end_seconds - row.start_seconds), base: rows.map((row) => row.start_seconds),
43
- marker: { color: colors.get(region) },
44
- customdata: rows.map((row) => [row.file, row.rank]),
106
+ marker: { color: colors.get(region), line: { color: "rgba(0, 0, 0, 0.28)", width: 0.5 } },
107
+ customdata: rows.map((row) => [row.file ?? "run", row.rank ?? 0]),
45
108
  hovertemplate: `<b>${region}</b><br>%{customdata[0]} / rank %{customdata[1]}<br>start: %{base:.6g} s<br>duration: %{x:.6g} s<extra></extra>`,
46
109
  };
47
110
  });
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 }) };
111
+ const byRank = options.laneBy === "rank";
112
+ const perLane = byRank ? 48 : 26;
113
+ // Region lanes read bottom-up, so the first region -- the enclosing one --
114
+ // sits at the bottom, as `scope-profiler plot gantt` draws it. Rank lanes
115
+ // keep rank 0 on top, like the rank heatmap.
116
+ const layout = baseLayout({ barmode: "overlay", height: Math.max(280, perLane * lanes.length + 150), showlegend: byRegion.size > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ categoryorder: "array", categoryarray: lanes, ...(byRank ? { autorange: "reversed" } : {}), showgrid: false }), ...options.layout });
117
+ return { data, layout: withEmptyState(layout, intervals.length > 0) };
49
118
  }
50
119
 
51
120
  /** Build an icicle flame chart using scope-profiler's explicit call IDs. */
52
121
  export function buildFlameFigure(payload, options = {}) {
53
- const calls = values(payload, "calls");
122
+ const allCalls = values(payload, "calls");
123
+ const calls = filtered(allCalls, options);
54
124
  const regions = [...new Set(calls.map((call) => call.region))];
55
125
  const colors = colorMap(regions, options.colors ?? payload.colors);
56
126
  const root = "scope-profiler-root";
57
- const ids = [root], labels = [options.rootLabel ?? "All calls"], parents = [""], markerColors = [NEUTRAL], hovertext = ["All calls"];
58
- const roots = calls.filter((call) => call.parent_call_id == null);
59
- const rootDuration = roots.reduce((sum, call) => sum + (call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds), 0);
60
127
  const callKey = (call) => `${call.file ?? "run"}:${call.rank ?? 0}:${call.call_id}`;
61
- for (const call of calls) {
62
- ids.push(callKey(call)); labels.push(call.region);
63
- parents.push(call.parent_call_id == null ? root : `${call.file ?? "run"}:${call.rank ?? 0}:${call.parent_call_id}`);
128
+ const parentKey = (call) => (call.parent_call_id == null ? null : `${call.file ?? "run"}:${call.rank ?? 0}:${call.parent_call_id}`);
129
+ const duration = (call) => call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds;
130
+ // A filter can remove a call whose children survive; re-parent each survivor
131
+ // onto its nearest surviving ancestor so the icicle stays a single tree
132
+ // instead of silently dropping the orphans.
133
+ const byKey = new Map(allCalls.map((call) => [callKey(call), call]));
134
+ const kept = new Set(calls.map(callKey));
135
+ const anchor = (call) => {
136
+ let key = parentKey(call);
137
+ while (key != null && !kept.has(key)) key = byKey.has(key) ? parentKey(byKey.get(key)) : null;
138
+ return key ?? root;
139
+ };
140
+ const anchors = calls.map(anchor);
141
+ const rootDuration = calls.reduce((sum, call, index) => (anchors[index] === root ? sum + duration(call) : sum), 0);
142
+ const ids = [root], labels = [options.rootLabel ?? "All calls"], parents = [""], markerColors = [NEUTRAL], hovertext = ["All calls"];
143
+ calls.forEach((call, index) => {
144
+ ids.push(callKey(call)); labels.push(call.region); parents.push(anchors[index]);
64
145
  markerColors.push(colors.get(call.region));
65
- 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
- }
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 }) };
146
+ hovertext.push(`<b>${call.region}</b><br>${call.file ?? "run"} / rank ${call.rank ?? 0}<br>start: ${call.start_seconds.toPrecision(6)} s<br>inclusive: ${duration(call).toPrecision(6)} s`);
147
+ });
148
+ const layout = baseLayout({ height: 500, margin: { l: 24, r: 24, t: 24, b: 24 }, ...options.layout });
149
+ return { data: [{ type: "icicle", ids, labels, parents, values: [rootDuration, ...calls.map(duration)], 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
150
  }
69
151
 
70
152
  export function buildDurationsFigure(payload, options = {}) {
@@ -73,28 +155,310 @@ export function buildDurationsFigure(payload, options = {}) {
73
155
  // A stacked-children export is already decomposed into segments. Preserve
74
156
  // that decomposition instead of letting duplicate region rows overwrite.
75
157
  const stacked = bars.some((bar) => bar.segment != null);
76
- const groups = [...new Set(bars.map((bar) => stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`))];
158
+ const groups = groupBy(bars, (bar) => (stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`));
77
159
  const regions = [...new Set(bars.map((bar) => bar.region))];
78
- const colors = colorMap(groups, options.colors ?? payload.colors);
79
- const data = groups.map((group) => {
80
- const rows = bars.filter((bar) => (stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`) === group);
160
+ const colors = colorMap(groups.keys(), options.colors ?? payload.colors);
161
+ const data = [...groups].map(([group, rows]) => {
81
162
  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>` };
163
+ 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
164
  });
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 }) };
165
+ const layout = baseLayout({ barmode: stacked ? "stack" : "group", height: Math.max(360, 34 * regions.length + 180), showlegend: groups.size > 1, xaxis: axis({ tickangle: -35 }), yaxis: axis({ title: `${metric} duration (s)` }), ...options.layout });
166
+ return { data, layout: withEmptyState(layout, bars.length > 0) };
167
+ }
168
+
169
+ // The three scaling exports differ only in the y column they carry and the
170
+ // shape of their ideal line, so one builder serves all of them.
171
+ const SCALING_KINDS = {
172
+ speedup: { yKey: "speedup", title: "Speedup", suffix: "×", idealName: "Ideal speedup", ideal: (value, baseline) => value / baseline },
173
+ weak_scaling: { yKey: "normalized_runtime", title: "Normalized runtime", suffix: "×", idealName: "Ideal weak scaling", ideal: () => 1 },
174
+ scaling_efficiency: { yKey: "efficiency", title: "Scaling efficiency", suffix: "", idealName: "Ideal efficiency", ideal: () => 1 },
175
+ };
176
+
177
+ function scalingKind(payload, options) {
178
+ const named = options.plot ?? payload?.plot;
179
+ if (named && SCALING_KINDS[named]) return SCALING_KINDS[named];
180
+ if (options.yField) {
181
+ const match = Object.values(SCALING_KINDS).find((kind) => kind.yKey === options.yField);
182
+ return match ?? { ...SCALING_KINDS.speedup, yKey: options.yField, title: options.yField };
183
+ }
184
+ const row = payload?.points?.[0];
185
+ return (row && Object.values(SCALING_KINDS).find((kind) => row[kind.yKey] != null)) ?? SCALING_KINDS.speedup;
85
186
  }
86
187
 
188
+ /** Build a scaling curve: speedup, weak scaling, or parallel efficiency. */
87
189
  export function buildSpeedupFigure(payload, options = {}) {
190
+ const kind = scalingKind(payload, options);
88
191
  const xField = options.xField ?? payload.options?.x_field ?? "num_ranks";
89
192
  const points = filtered(values(payload, "points"), options);
90
- const regions = [...new Set(points.map((point) => point.region))];
91
- const colors = colorMap(regions, options.colors ?? payload.colors);
193
+ const byRegion = groupBy(points, (point) => point.region);
194
+ const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
92
195
  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)));
196
+ const order = new Map(xValues.map((value, position) => [value, position]));
93
197
  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>` }; });
198
+ const data = [...byRegion].map(([region, unsorted]) => { const rows = [...unsorted].sort((a, b) => order.get(a[xField]) - order.get(b[xField])); return { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row[xField]), y: rows.map((row) => row[kind.yKey]), line: { color: colors.get(region), width: 2.4 }, marker: { color: colors.get(region), size: 7 }, hovertemplate: `<b>%{x}</b><br>${region}: %{y:.3g}${kind.suffix}<extra></extra>` }; });
95
199
  const baseline = payload.options?.baseline ?? xValues[0];
96
- 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 }) };
200
+ if (numeric && options.ideal !== false) data.push({ type: "scatter", mode: "lines", name: kind.idealName, x: xValues, y: xValues.map((value) => kind.ideal(value, baseline)), line: { color: "#777", dash: "dash" }, hoverinfo: "skip" });
201
+ const layout = baseLayout({ height: 420, showlegend: data.length > 1, xaxis: axis({ title: payload.options?.x_label ?? xField, tickvals: xValues }), yaxis: axis({ title: kind.title, rangemode: "tozero" }), ...options.layout });
202
+ return { data, layout: withEmptyState(layout, points.length > 0) };
203
+ }
204
+
205
+ /** Build a weak-scaling curve (runtime normalized to the baseline scale). */
206
+ export function buildWeakScalingFigure(payload, options = {}) {
207
+ return buildSpeedupFigure(payload, { ...options, plot: "weak_scaling" });
208
+ }
209
+
210
+ /** Build a parallel-efficiency curve (measured speedup over ideal speedup). */
211
+ export function buildScalingEfficiencyFigure(payload, options = {}) {
212
+ return buildSpeedupFigure(payload, { ...options, plot: "scaling_efficiency" });
213
+ }
214
+
215
+ /** Build mean call duration over time, one trace per region. */
216
+ export function buildDurationTimeseriesFigure(payload, options = {}) {
217
+ const points = filtered(values(payload, "points"), options);
218
+ const runs = runAware(points);
219
+ const colors = colorMap(points.map((point) => point.region), options.colors ?? payload.colors);
220
+ const series = groupBy(points, runs.key);
221
+ const data = [...series].map(([, unsorted]) => {
222
+ const rows = [...unsorted].sort((a, b) => a.time_seconds - b.time_seconds);
223
+ const region = rows[0].region, name = runs.label(rows[0]);
224
+ return { type: "scatter", mode: "lines+markers", name, 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, symbol: FILE_SYMBOLS[runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length] }, customdata: rows.map((row) => [row.min_duration_seconds, row.max_duration_seconds, row.call_index]), hovertemplate: `<b>${name}</b><br>time: %{x:.6g} s<br>mean: %{y:.6g} s<br>min–max: %{customdata[0]:.4g}–%{customdata[1]:.4g} s<extra></extra>` };
225
+ });
226
+ const layout = baseLayout({ height: 420, showlegend: series.size > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ title: "Mean call duration (s)" }), ...options.layout });
227
+ return { data, layout: withEmptyState(layout, points.length > 0) };
228
+ }
229
+
230
+ /** Build duration distributions from histogram bin records. */
231
+ export function buildHistogramFigure(payload, options = {}) {
232
+ const bins = filtered(values(payload, "bins"), options);
233
+ const runs = runAware(bins);
234
+ const colors = colorMap(bins.map((bin) => bin.region), options.colors ?? payload.colors);
235
+ const series = groupBy(bins, runs.key);
236
+ const data = [...series].map(([, unsorted]) => {
237
+ const rows = [...unsorted].sort((a, b) => a.bin_center_seconds - b.bin_center_seconds);
238
+ const region = rows[0].region, name = runs.label(rows[0]);
239
+ const pattern = FILE_PATTERNS[runs.files.indexOf(rows[0].file ?? "run") % FILE_PATTERNS.length];
240
+ return { type: "bar", name, 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 }, ...(runs.multi ? { pattern: { shape: pattern, solidity: 0.35 } } : {}) }, hovertemplate: `<b>${name}</b><br>%{x:.6g} s: %{y} calls<extra></extra>` };
241
+ });
242
+ const layout = baseLayout({ barmode: "overlay", height: 400, showlegend: series.size > 1, xaxis: axis({ title: "Call duration (s)" }), yaxis: axis({ title: "Calls" }), ...options.layout });
243
+ return { data, layout: withEmptyState(layout, bins.length > 0) };
244
+ }
245
+
246
+ /** Build a rank × region heatmap from duration records. */
247
+ export function buildRankHeatmapFigure(payload, options = {}) {
248
+ const points = filtered(values(payload, "points"), options);
249
+ const regions = [...new Set(points.map((point) => point.region))];
250
+ const multi = new Set(points.map((point) => point.file ?? "run")).size > 1;
251
+ // A lane per run and rank. Keying cells by rank alone silently let a second
252
+ // run overwrite the first, showing one run's numbers under both labels.
253
+ const laneOf = (point) => (multi ? `${point.file ?? "run"} / rank ${point.rank ?? 0}` : String(point.rank ?? 0));
254
+ const lanes = [...new Set(points.map(laneOf))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
255
+ const inferredValueKey = points[0] ? Object.keys(points[0]).find((key) => key.endsWith("_duration_seconds")) : undefined;
256
+ const valueKey = options.valueKey ?? inferredValueKey ?? "total_duration_seconds";
257
+ const byCell = new Map(points.map((point) => [`${laneOf(point)}\u0000${point.region}`, point[valueKey]]));
258
+ const data = [{ type: "heatmap", x: regions, y: lanes, z: lanes.map((lane) => regions.map((region) => byCell.get(`${lane}\u0000${region}`) ?? null)), colorscale: options.colorscale ?? "Viridis", colorbar: { title: "Seconds" }, hovertemplate: `${multi ? "%{y}" : "rank %{y}"}<br>%{x}: %{z:.6g} s<extra></extra>` }];
259
+ const layout = baseLayout({ height: Math.max(320, 44 * lanes.length + 150), xaxis: axis({ title: "Region" }), yaxis: axis({ title: multi ? "Run / rank" : "Rank", autorange: "reversed", showgrid: false }), ...options.layout });
260
+ return { data, layout: withEmptyState(layout, points.length > 0) };
261
+ }
262
+
263
+ /** Build per-rank duration lines, with a dashed rank mean for each region. */
264
+ export function buildImbalanceFigure(payload, options = {}) {
265
+ const points = filtered(values(payload, "points"), options);
266
+ const runs = runAware(points);
267
+ const colors = colorMap(points.map((point) => point.region), options.colors ?? payload.colors);
268
+ // The mean is computed per run, so a run gets its own line and its own mean;
269
+ // pooling them drew one zig-zagging series that revisited every rank.
270
+ const series = groupBy(points, runs.key);
271
+ const data = [...series].flatMap(([, unsorted]) => {
272
+ const rows = [...unsorted].sort((a, b) => a.rank - b.rank);
273
+ const region = rows[0].region, name = runs.label(rows[0]);
274
+ const color = colors.get(region);
275
+ const symbol = FILE_SYMBOLS[runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length];
276
+ return [
277
+ { type: "scatter", mode: "lines+markers", name, x: rows.map((row) => row.rank), y: rows.map((row) => row.value_seconds), line: { color, width: 2.2 }, marker: { color, size: 7, symbol }, hovertemplate: `<b>${name}</b><br>rank %{x}: %{y:.6g} s<extra></extra>` },
278
+ { type: "scatter", mode: "lines", name: `${name} 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 },
279
+ ];
280
+ });
281
+ const layout = baseLayout({ height: 420, showlegend: series.size > 1, xaxis: axis({ title: "Rank", dtick: 1 }), yaxis: axis({ title: `${payload.metric ?? "Duration"} (s)` }), ...options.layout });
282
+ return { data, layout: withEmptyState(layout, points.length > 0) };
283
+ }
284
+
285
+ /** Build a timeline-occupancy heatmap from binned density records. */
286
+ export function buildDensityFigure(payload, options = {}) {
287
+ const points = filtered(values(payload, "points"), options);
288
+ const lane = (point) => `${point.file ?? "run"} / ${point.region}`;
289
+ const lanes = [...new Set(points.map(lane))];
290
+ const starts = [...new Set(points.map((point) => point.bin_start_seconds))].sort((a, b) => a - b);
291
+ const width = points.length ? points[0].bin_end_seconds - points[0].bin_start_seconds : 0;
292
+ // Occupancy is the share of the bin the region was inside, which compares
293
+ // across runs of different length; raw seconds stay available via valueKey.
294
+ const asFraction = (options.valueKey ?? "occupancy") === "occupancy";
295
+ const byCell = new Map(points.map((point) => [`${lane(point)}:${point.bin_start_seconds}`, point]));
296
+ const cell = (laneName, start, pick) => { const point = byCell.get(`${laneName}:${start}`); return point ? pick(point) : null; };
297
+ const span = (point) => point.bin_end_seconds - point.bin_start_seconds;
298
+ const data = [{
299
+ type: "heatmap", x: starts.map((start) => start + width / 2), y: lanes,
300
+ z: lanes.map((laneName) => starts.map((start) => cell(laneName, start, (point) => (asFraction ? (span(point) > 0 ? point.occupied_seconds / span(point) : null) : point.occupied_seconds)))),
301
+ customdata: lanes.map((laneName) => starts.map((start) => cell(laneName, start, (point) => point.occupied_seconds))),
302
+ colorscale: options.colorscale ?? "Viridis", ...(asFraction ? { zmin: 0, zmax: 1 } : {}),
303
+ colorbar: { title: asFraction ? "Occupancy" : "Seconds" },
304
+ hovertemplate: `%{y}<br>t = %{x:.6g} s<br>${asFraction ? "occupancy: %{z:.3f}<br>" : ""}occupied: %{customdata:.4g} s<extra></extra>`,
305
+ }];
306
+ const layout = baseLayout({ height: Math.max(320, 34 * lanes.length + 150), xaxis: axis({ title: "Time (s)" }), yaxis: axis({ categoryorder: "array", categoryarray: lanes, autorange: "reversed", showgrid: false }), ...options.layout });
307
+ return { data, layout: withEmptyState(layout, points.length > 0) };
308
+ }
309
+
310
+ const SUMMARY_LABELS = { count: "Calls", average_duration_seconds: "Average duration (s)", min_duration_seconds: "Minimum duration (s)", max_duration_seconds: "Maximum duration (s)", first_duration_seconds: "First call duration (s)", last_duration_seconds: "Last call duration (s)", std_duration_seconds: "Duration std. dev. (s)", total_duration_seconds: "Total duration (s)" };
311
+
312
+ /** Build a ranked region bar chart from a region_statistics document. */
313
+ export function buildRegionSummaryFigure(payload, options = {}) {
314
+ const files = values(payload, "files");
315
+ const metric = options.metric ?? "total_duration_seconds";
316
+ const limit = options.topN ?? 20;
317
+ const keep = typeof options.filterRegion === "function" ? options.filterRegion : () => true;
318
+ const totals = new Map();
319
+ for (const file of files) {
320
+ for (const [region, stats] of Object.entries(file.region_statistics ?? {})) {
321
+ if (!keep(region, stats)) continue;
322
+ totals.set(region, (totals.get(region) ?? 0) + (stats[metric] ?? 0));
323
+ }
324
+ }
325
+ // Rank by the pooled metric so the slowest regions lead, then keep the head
326
+ // of the list: a long run has more regions than a bar chart can carry.
327
+ const regions = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit).map(([region]) => region);
328
+ const labels = files.map((file) => file.label ?? "run");
329
+ const colors = colorMap(labels, options.colors ?? payload.colors);
330
+ const unit = metric === "count" ? "" : " s";
331
+ const data = files.map((file, index) => {
332
+ const stats = file.region_statistics ?? {};
333
+ return { type: "bar", orientation: "h", name: labels[index], y: regions, x: regions.map((region) => stats[region]?.[metric] ?? null),
334
+ marker: { color: colors.get(labels[index]), line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 } },
335
+ customdata: regions.map((region) => stats[region]?.count ?? null),
336
+ hovertemplate: `<b>%{y}</b><br>${labels[index]}: %{x:.6g}${unit}<br>calls: %{customdata}<extra></extra>` };
337
+ });
338
+ const layout = baseLayout({ barmode: "group", height: Math.max(320, 26 * regions.length + 160), showlegend: files.length > 1, xaxis: axis({ title: SUMMARY_LABELS[metric] ?? metric }), yaxis: axis({ categoryorder: "array", categoryarray: [...regions].reverse(), showgrid: false }), ...options.layout });
339
+ return { data, layout: withEmptyState(layout, regions.length > 0) };
340
+ }
341
+
342
+ /** Build a Sankey call graph from either callgraph export shape.
343
+ *
344
+ * The compact export collapses repeated invocations into one node per region,
345
+ * which can turn recursion into a cycle; a Sankey cannot draw one, so links
346
+ * that do not increase call depth are dropped. Use the flame chart to see
347
+ * recursion in full.
348
+ */
349
+ export function buildCallgraphFigure(payload, options = {}) {
350
+ const compact = Array.isArray(payload?.regions);
351
+ if (!compact && !Array.isArray(payload?.calls)) throw new TypeError("Expected a scope-profiler plot-data payload with a regions or calls array.");
352
+ const keep = typeof options.filterRegion === "function" ? options.filterRegion : () => true;
353
+ const weightKey = options.valueKey ?? "total_duration";
354
+ let nodes, links, unit;
355
+ if (compact) {
356
+ const regions = payload.regions.filter((region) => keep(region.name, region));
357
+ const depths = new Map(regions.map((region) => [region.name, region.depth]));
358
+ const weights = new Map(regions.map((region) => [region.name, region[weightKey] ?? 0]));
359
+ nodes = regions.map((region) => region.name);
360
+ links = (payload.edges ?? []).filter(({ parent, child }) => depths.has(parent) && depths.has(child) && depths.get(child) > depths.get(parent))
361
+ .map(({ parent, child }) => ({ source: parent, target: child, value: weights.get(child) || 1 }));
362
+ unit = weightKey.endsWith("duration") ? " s" : "";
363
+ } else {
364
+ const calls = payload.calls.filter((call) => keep(call.name, call));
365
+ const byId = new Map(calls.map((call) => [call.call_id, call]));
366
+ const counts = new Map();
367
+ for (const call of calls) {
368
+ const parent = byId.get(call.parent_id);
369
+ if (!parent || call.depth <= parent.depth) continue;
370
+ const key = `${parent.name}\u0000${call.name}`;
371
+ counts.set(key, (counts.get(key) ?? 0) + 1);
372
+ }
373
+ nodes = [...new Set(calls.map((call) => call.name))];
374
+ links = [...counts.entries()].map(([key, value]) => { const [source, target] = key.split("\u0000"); return { source, target, value }; });
375
+ unit = " calls";
376
+ }
377
+ const index = new Map(nodes.map((name, position) => [name, position]));
378
+ const colors = colorMap(nodes, options.colors ?? payload.colors);
379
+ const data = [{
380
+ type: "sankey", orientation: "h",
381
+ node: { label: nodes, color: nodes.map((name) => colors.get(name)), pad: 14, thickness: 16, line: { color: "rgba(0, 0, 0, 0.25)", width: 0.5 } },
382
+ link: { source: links.map((link) => index.get(link.source)), target: links.map((link) => index.get(link.target)), value: links.map((link) => link.value),
383
+ hovertemplate: `%{source.label} \u2192 %{target.label}<br>%{value:.6g}${unit}<extra></extra>` },
384
+ }];
385
+ const layout = baseLayout({ height: Math.max(320, 26 * nodes.length + 160), margin: { l: 24, r: 24, t: 24, b: 24 }, ...options.layout });
386
+ return { data, layout: withEmptyState(layout, links.length > 0) };
387
+ }
388
+
389
+ /** Build a grouped bar chart of one LIKWID hardware-counter metric. */
390
+ export function buildLikwidFigure(payload, options = {}) {
391
+ const bars = filtered(values(payload, "bars"), options);
392
+ const series = groupBy(bars, (bar) => bar.series);
393
+ const regions = [...new Set(bars.map((bar) => bar.region))];
394
+ const colors = colorMap(series.keys(), options.colors ?? payload.colors);
395
+ const metric = options.metric ?? payload.metric ?? "value";
396
+ const data = [...series].map(([name, rows]) => {
397
+ const byRegion = new Map(rows.map((bar) => [bar.region, bar.value]));
398
+ return { type: "bar", name, x: regions, y: regions.map((region) => byRegion.get(region) ?? null), marker: { color: colors.get(name), line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 } }, hovertemplate: `<b>%{x}</b><br>${name}: %{y:.6g}<extra></extra>` };
399
+ });
400
+ const layout = baseLayout({ barmode: "group", height: Math.max(360, 34 * regions.length + 180), showlegend: series.size > 1, xaxis: axis({ tickangle: -35 }), yaxis: axis({ title: metric, ...(options.logScale ? { type: "log" } : {}) }), ...options.layout });
401
+ return { data, layout: withEmptyState(layout, bars.length > 0) };
402
+ }
403
+
404
+ export const PLOT_DATA_FORMAT = "scope-profiler-plot-data";
405
+ export const SUPPORTED_FORMAT_VERSION = 1;
406
+
407
+ /** Builder for each `plot` kind written by `export plot-data --format json`. */
408
+ export const PLOT_BUILDERS = {
409
+ gantt: buildGanttFigure,
410
+ density: buildDensityFigure,
411
+ flame: buildFlameFigure,
412
+ flame_chart: buildFlameFigure,
413
+ flame_graph: buildFlameFigure,
414
+ callgraph: buildCallgraphFigure,
415
+ durations: buildDurationsFigure,
416
+ timeseries: buildDurationTimeseriesFigure,
417
+ speedup: buildSpeedupFigure,
418
+ weak_scaling: buildSpeedupFigure,
419
+ scaling_efficiency: buildSpeedupFigure,
420
+ rank_heatmap: buildRankHeatmapFigure,
421
+ histogram: buildHistogramFigure,
422
+ imbalance: buildImbalanceFigure,
423
+ likwid: buildLikwidFigure,
424
+ region_statistics: buildRegionSummaryFigure,
425
+ };
426
+
427
+ /** Guess the plot kind of a payload written before the envelope existed. */
428
+ export function inferPlotKind(payload) {
429
+ if (!payload || typeof payload !== "object") return undefined;
430
+ if (Array.isArray(payload.intervals)) return "gantt";
431
+ if (Array.isArray(payload.bins)) return "histogram";
432
+ if (Array.isArray(payload.files) && payload.files[0]?.region_statistics) return "region_statistics";
433
+ if (Array.isArray(payload.regions) && Array.isArray(payload.edges)) return "callgraph";
434
+ if (Array.isArray(payload.bars)) return payload.bars[0]?.series != null ? "likwid" : "durations";
435
+ if (Array.isArray(payload.calls)) return payload.calls[0]?.parent_id !== undefined ? "callgraph" : "flame";
436
+ const point = Array.isArray(payload.points) ? payload.points[0] : undefined;
437
+ if (!point) return undefined;
438
+ if (point.bin_start_seconds != null) return "density";
439
+ if (point.mean_duration_seconds != null) return "timeseries";
440
+ if (point.mean_over_ranks_seconds != null) return "imbalance";
441
+ if (point.speedup != null) return "speedup";
442
+ if (point.normalized_runtime != null) return "weak_scaling";
443
+ if (point.efficiency != null) return "scaling_efficiency";
444
+ if (point.rank != null) return "rank_heatmap";
445
+ return undefined;
446
+ }
447
+
448
+ /** Build the right figure for any plot-data document, without naming a builder.
449
+ *
450
+ * Dispatches on the document's own `plot` field, falling back to the payload
451
+ * shape for files written before scope-profiler stamped the envelope on every
452
+ * kind.
453
+ */
454
+ export function buildFigure(payload, options = {}) {
455
+ if (payload?.format != null && payload.format !== PLOT_DATA_FORMAT) throw new TypeError(`Expected a ${PLOT_DATA_FORMAT} document, got ${JSON.stringify(payload.format)}.`);
456
+ const version = payload?.format_version;
457
+ if (typeof version === "number" && version > SUPPORTED_FORMAT_VERSION) throw new TypeError(`Plot-data format version ${version} is newer than this package supports (${SUPPORTED_FORMAT_VERSION}); upgrade @scope-profiler/plotly.`);
458
+ const kind = options.plot ?? payload?.plot ?? inferPlotKind(payload);
459
+ const builder = kind && PLOT_BUILDERS[kind];
460
+ if (!builder) throw new TypeError(kind ? `No figure builder for plot kind ${JSON.stringify(kind)}.` : "Could not determine the plot kind; pass options.plot.");
461
+ return builder(payload, { plot: kind, ...options });
98
462
  }
99
463
 
100
464
  /** Render a figure with any Plotly-compatible bundle. */