@scope-profiler/plotly 0.2.0 → 0.4.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/src/index.js CHANGED
@@ -1,47 +1,449 @@
1
1
  /** Framework-neutral Plotly specifications for scope-profiler plot-data. */
2
+ /** Runtime checks shared by direct builders and the envelope validator. */
3
+ function validateRecords(kind, payload, options = {}) {
4
+ const fields = {
5
+ gantt: ["intervals", "start_seconds", "end_seconds"],
6
+ flame: ["calls", "start_seconds"],
7
+ durations: ["bars", "value_seconds"],
8
+ histogram: [
9
+ "bins",
10
+ "bin_low_seconds",
11
+ "bin_high_seconds",
12
+ "bin_center_seconds",
13
+ "count",
14
+ ],
15
+ timeseries: ["points", "time_seconds", "mean_duration_seconds"],
16
+ imbalance: ["points", "rank", "value_seconds", "mean_over_ranks_seconds"],
17
+ density: [
18
+ "points",
19
+ "bin_start_seconds",
20
+ "bin_end_seconds",
21
+ "occupied_seconds",
22
+ ],
23
+ rank_heatmap: ["points"],
24
+ scaling: ["points"],
25
+ likwid: ["bars", "value"],
26
+ roofline: [
27
+ "points",
28
+ "arithmetic_intensity_flops_per_byte",
29
+ "performance_gflops",
30
+ ],
31
+ callgraph: [Array.isArray(payload.regions) ? "regions" : "calls", "depth"],
32
+ region_statistics: ["files"],
33
+ };
34
+ const [key, ...required] = fields[kind] ?? [];
35
+ if (!key) return;
36
+ const rows = payload[key];
37
+ if (!Array.isArray(rows)) throw new TypeError(`Expected a ${key} array.`);
38
+ const fail = (index, field, reason) => {
39
+ throw new TypeError(`${key}[${index}].${field}: ${reason}`);
40
+ };
41
+ rows.forEach((row, index) => {
42
+ if (!row || typeof row !== "object" || Array.isArray(row))
43
+ fail(index, "record", "must be an object");
44
+ if (kind !== "region_statistics") {
45
+ const name = kind === "callgraph" ? "name" : "region";
46
+ if (typeof row[name] !== "string") fail(index, name, "must be a string");
47
+ }
48
+ if ((kind === "flame" || kind === "callgraph") && key === "calls") {
49
+ for (const field of [
50
+ "call_id",
51
+ kind === "flame" ? "parent_call_id" : "parent_id",
52
+ ]) {
53
+ if (field !== "call_id" && row[field] == null) continue;
54
+ if (typeof row[field] !== "string" && !Number.isFinite(row[field]))
55
+ fail(index, field, "must be a string or finite number");
56
+ }
57
+ }
58
+ for (const field of required) {
59
+ if (!Number.isFinite(row[field]))
60
+ fail(index, field, "must be a finite number");
61
+ }
62
+ for (const [field, value] of Object.entries(row)) {
63
+ if (typeof value === "number" && !Number.isFinite(value))
64
+ fail(index, field, "must be finite");
65
+ if (
66
+ value != null &&
67
+ (field.endsWith("_seconds") || field === "rank") &&
68
+ !Number.isFinite(value)
69
+ )
70
+ fail(index, field, "must be a finite number");
71
+ }
72
+ for (const [start, end] of [
73
+ ["start_seconds", "end_seconds"],
74
+ ["bin_low_seconds", "bin_high_seconds"],
75
+ ["bin_start_seconds", "bin_end_seconds"],
76
+ ]) {
77
+ if (row[start] != null && row[end] != null && row[end] < row[start])
78
+ fail(index, end, `must be >= ${start}`);
79
+ }
80
+ if (kind === "flame") {
81
+ if (
82
+ !Number.isFinite(row.inclusive_duration_seconds) &&
83
+ !Number.isFinite(row.end_seconds)
84
+ )
85
+ fail(index, "end_seconds", "or inclusive_duration_seconds is required");
86
+ if (row.inclusive_duration_seconds < 0)
87
+ fail(index, "inclusive_duration_seconds", "must be nonnegative");
88
+ }
89
+ if (kind === "timeseries") {
90
+ if (
91
+ row.min_duration_seconds > row.mean_duration_seconds ||
92
+ row.max_duration_seconds < row.mean_duration_seconds
93
+ )
94
+ fail(index, "bounds", "must bracket mean_duration_seconds");
95
+ }
96
+ if (kind === "rank_heatmap") {
97
+ const field =
98
+ options.valueKey ??
99
+ Object.keys(row).find((name) => name.endsWith("_duration_seconds")) ??
100
+ "total_duration_seconds";
101
+ if (!Number.isFinite(row[field]))
102
+ fail(index, field, "must be a finite number");
103
+ }
104
+ if (kind === "scaling") {
105
+ const x = options.xField ?? payload.options?.x_field ?? "num_ranks";
106
+ const y = options.yField;
107
+ if (typeof row[x] !== "string" && !Number.isFinite(row[x]))
108
+ fail(index, x, "must be a string or finite number");
109
+ if (y && !Number.isFinite(row[y]))
110
+ fail(index, y, "must be a finite number");
111
+ }
112
+ if (
113
+ kind === "roofline" &&
114
+ (row.arithmetic_intensity_flops_per_byte <= 0 ||
115
+ row.performance_gflops <= 0)
116
+ )
117
+ fail(index, "rates", "must be positive on logarithmic axes");
118
+ if (kind === "region_statistics") {
119
+ if (
120
+ !row.region_statistics ||
121
+ typeof row.region_statistics !== "object" ||
122
+ Array.isArray(row.region_statistics)
123
+ )
124
+ fail(index, "region_statistics", "must be an object");
125
+ for (const [region, stats] of Object.entries(row.region_statistics)) {
126
+ if (!stats || typeof stats !== "object")
127
+ fail(index, region, "statistics must be an object");
128
+ for (const [field, value] of Object.entries(stats)) {
129
+ if (
130
+ (field === "count" || field.endsWith("_seconds")) &&
131
+ value != null &&
132
+ !Number.isFinite(value)
133
+ )
134
+ fail(
135
+ index,
136
+ `${region}.${field}`,
137
+ "must be a finite number or null",
138
+ );
139
+ }
140
+ }
141
+ }
142
+ });
143
+ if (kind === "callgraph" && payload.edges != null) {
144
+ if (!Array.isArray(payload.edges))
145
+ throw new TypeError("edges must be an array.");
146
+ payload.edges.forEach((edge, index) => {
147
+ if (
148
+ !edge ||
149
+ typeof edge.parent !== "string" ||
150
+ typeof edge.child !== "string"
151
+ )
152
+ throw new TypeError(
153
+ `edges[${index}]: parent and child must be strings.`,
154
+ );
155
+ });
156
+ }
157
+ }
2
158
 
3
- const DEFAULT_COLORS = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"];
4
- const NEUTRAL = "#898781";
159
+ /** Refuse last-write-wins data loss. Keys are serialized tuples, not labels. */
160
+ function uniqueMap(entries, context) {
161
+ const result = new Map();
162
+ for (const [key, value] of entries) {
163
+ if (result.has(key))
164
+ throw new TypeError(`${context}: duplicate cell ${JSON.stringify(key)}`);
165
+ result.set(key, value);
166
+ }
167
+ return result;
168
+ }
169
+
170
+ /** Iterative traversal also handles deeply nested profiles without stack overflow. */
171
+ function validateParents(calls, keyOf, parentOf) {
172
+ const byKey = uniqueMap(
173
+ calls.map((call) => [keyOf(call), call]),
174
+ "calls (duplicate call ID)",
175
+ );
176
+ const done = new Set();
177
+ for (const start of byKey.keys()) {
178
+ const path = new Set();
179
+ let key = start;
180
+ while (key != null && byKey.has(key) && !done.has(key)) {
181
+ if (path.has(key)) throw new TypeError(`calls: ancestor cycle at ${key}`);
182
+ path.add(key);
183
+ key = parentOf(byKey.get(key));
184
+ }
185
+ for (const visited of path) done.add(visited);
186
+ }
187
+ return byKey;
188
+ }
189
+
190
+ const DEFAULT_COLORS = [
191
+ "#2a78d6",
192
+ "#eb6834",
193
+ "#1baf7a",
194
+ "#eda100",
195
+ "#e87ba4",
196
+ "#008300",
197
+ "#4a3aa7",
198
+ "#e34948",
199
+ ];
5
200
 
6
201
  function colorMap(names, supplied = {}) {
7
202
  const map = new Map();
8
- let index = 0;
9
203
  for (const name of names) {
10
204
  if (map.has(name)) continue;
11
- map.set(name, supplied[name] ?? DEFAULT_COLORS[index++ % DEFAULT_COLORS.length]);
205
+ map.set(
206
+ name,
207
+ (Object.hasOwn(supplied, name) ? supplied[name] : undefined) ??
208
+ stableColor(name),
209
+ );
12
210
  }
13
211
  return map;
14
212
  }
15
213
 
16
- function values(payload, key) {
17
- if (!payload || !Array.isArray(payload[key])) throw new TypeError(`Expected a scope-profiler plot-data payload with a ${key} array.`);
214
+ function stableColor(name) {
215
+ let hash = 2166136261;
216
+ for (const char of String(name))
217
+ hash = Math.imul(hash ^ char.codePointAt(0), 16777619);
218
+ return DEFAULT_COLORS[(hash >>> 0) % DEFAULT_COLORS.length];
219
+ }
220
+
221
+ function interactionData(rows, metrics = () => []) {
222
+ return rows.map((row) => ({
223
+ ...metrics(row),
224
+ identity: {
225
+ region: row.region ?? row.name ?? null,
226
+ file: row.file ?? "run",
227
+ rank: row.rank ?? null,
228
+ call_id: row.call_id ?? null,
229
+ },
230
+ }));
231
+ }
232
+
233
+ /** Read an identity from a Plotly click/hover/selection point. */
234
+ export function getPointIdentity(point) {
235
+ return point?.customdata?.identity ?? null;
236
+ }
237
+
238
+ /** Share an explicit palette across independently built figures. */
239
+ export function createColorRegistry(names = [], supplied = {}) {
240
+ return Object.freeze(Object.fromEntries(colorMap(names, supplied)));
241
+ }
242
+
243
+ function mergeLayout(base, overrides = {}) {
244
+ const result = { ...base };
245
+ for (const [key, value] of Object.entries(overrides)) {
246
+ if (["__proto__", "constructor", "prototype"].includes(key)) continue;
247
+ result[key] =
248
+ value && Object.getPrototypeOf(value) === Object.prototype
249
+ ? mergeLayout(
250
+ result[key] &&
251
+ typeof result[key] === "object" &&
252
+ !Array.isArray(result[key])
253
+ ? result[key]
254
+ : {},
255
+ value,
256
+ )
257
+ : cloneLayoutValue(value);
258
+ }
259
+ return result;
260
+ }
261
+
262
+ function cloneLayoutValue(value) {
263
+ if (Array.isArray(value)) return value.map(cloneLayoutValue);
264
+ if (value && Object.getPrototypeOf(value) === Object.prototype)
265
+ return mergeLayout({}, value);
266
+ return value;
267
+ }
268
+
269
+ // Region, file and series names come from the profiled application, so a
270
+ // hovertemplate that interpolates one is interpolating untrusted text. Plotly
271
+ // substitutes %{...} inside a template and renders what is left as HTML, so a
272
+ // region named "%{y}" rewrote every hover label that mentioned it, and one
273
+ // carrying a tag injected markup into them. Escape both spellings; the result
274
+ // is only ever used as literal text.
275
+ function label(name) {
276
+ return String(name)
277
+ .replace(/&/g, "&amp;")
278
+ .replace(/</g, "&lt;")
279
+ .replace(/>/g, "&gt;")
280
+ .replace(/%\{/g, "%&#123;");
281
+ }
282
+
283
+ function values(payload, key, kind, options) {
284
+ if (!payload || !Array.isArray(payload[key]))
285
+ throw new TypeError(
286
+ `Expected a scope-profiler plot-data payload with a ${key} array.`,
287
+ );
288
+ if (kind) validateRecords(kind, payload, options);
18
289
  return payload[key];
19
290
  }
20
291
 
21
- function baseLayout(overrides = {}) {
292
+ // Chrome colours (text, gridlines, hover surface, the dashed ideal lines)
293
+ // for each theme a host page can be in. "auto" commits to nothing: it leaves
294
+ // the text colour unset and paints gridlines in a half-transparent grey that
295
+ // reads on either background, which is what every figure did before themes
296
+ // existed and so stays the default. A host that knows which theme it is in
297
+ // passes "light" or "dark" -- or its own token object -- and gets chrome that
298
+ // matches, since a grey that works on both is never the best on either.
299
+ const THEMES = {
300
+ auto: {
301
+ text: undefined,
302
+ muted: undefined,
303
+ grid: "rgba(128, 128, 128, 0.2)",
304
+ hoverBg: undefined,
305
+ neutral: "#777",
306
+ },
307
+ light: {
308
+ text: "#111827",
309
+ muted: "#6b7280",
310
+ grid: "#e1e0d9",
311
+ hoverBg: "#ffffff",
312
+ neutral: "#777",
313
+ },
314
+ dark: {
315
+ text: "#e5e7eb",
316
+ muted: "#9ca3af",
317
+ grid: "#2a2f3a",
318
+ hoverBg: "#171a21",
319
+ neutral: "#8b8b8b",
320
+ },
321
+ };
322
+
323
+ let defaultTheme = "auto";
324
+
325
+ /** Set the theme every builder uses when its options do not name one.
326
+ *
327
+ * A page with a dark-mode toggle sets this once per toggle and rebuilds its
328
+ * figures, instead of threading the theme through every build call.
329
+ * Accepts a theme name or an object of token overrides.
330
+ */
331
+ export function setTheme(theme) {
332
+ defaultTheme =
333
+ theme && typeof theme === "object" ? { ...theme } : (theme ?? "auto");
334
+ }
335
+
336
+ /** The theme tokens currently in effect, or those a build option resolves to. */
337
+ export function resolveTheme(theme = defaultTheme) {
338
+ if (theme && typeof theme === "object") return { ...THEMES.auto, ...theme };
339
+ return { ...(Object.hasOwn(THEMES, theme) ? THEMES[theme] : THEMES.auto) };
340
+ }
341
+
342
+ // Builders take their layout helpers from here rather than calling the
343
+ // module-level ones, so a theme reaches every layout and axis in a figure
344
+ // without being passed down to each call.
345
+ function palette(options) {
346
+ const theme = resolveTheme(options?.theme);
347
+ return {
348
+ theme,
349
+ baseLayout: (overrides = {}) =>
350
+ mergeLayout(baseLayout(overrides, theme), options.layout),
351
+ axis: (overrides = {}) => axis(overrides, theme),
352
+ };
353
+ }
354
+
355
+ function baseLayout(overrides = {}, theme = resolveTheme()) {
22
356
  return {
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 },
357
+ paper_bgcolor: "transparent",
358
+ plot_bgcolor: "transparent",
359
+ font: {
360
+ family: "Inter, ui-sans-serif, system-ui, sans-serif",
361
+ size: 12,
362
+ ...(theme.text ? { color: theme.text } : {}),
363
+ },
364
+ hovermode: "closest",
365
+ hoverlabel: {
366
+ namelength: -1,
367
+ ...(theme.hoverBg
368
+ ? {
369
+ bgcolor: theme.hoverBg,
370
+ bordercolor: theme.grid,
371
+ font: { color: theme.text },
372
+ }
373
+ : {}),
374
+ },
26
375
  margin: { l: 100, r: 24, t: 32, b: 64 },
27
- legend: { orientation: "h", y: -0.2, x: 0 }, ...overrides,
376
+ legend: {
377
+ orientation: "h",
378
+ y: -0.2,
379
+ x: 0,
380
+ ...(theme.muted ? { font: { color: theme.muted } } : {}),
381
+ },
382
+ ...overrides,
28
383
  };
29
384
  }
30
385
 
31
- function axis(overrides = {}) {
32
- return { automargin: true, gridcolor: "rgba(128, 128, 128, 0.2)", zeroline: false, ...overrides };
386
+ function axis(overrides = {}, theme = resolveTheme()) {
387
+ const styled = {
388
+ automargin: true,
389
+ gridcolor: theme.grid,
390
+ zeroline: false,
391
+ ...(theme.text ? { zerolinecolor: theme.grid, linecolor: theme.grid } : {}),
392
+ ...(theme.muted ? { tickfont: { color: theme.muted } } : {}),
393
+ ...overrides,
394
+ };
395
+ // Builders pass an axis title as a bare string. Keep that spelling but give
396
+ // it the theme's muted colour, which a plain string cannot carry.
397
+ if (theme.muted && styled.title != null)
398
+ styled.title =
399
+ typeof styled.title === "string"
400
+ ? { text: styled.title, font: { color: theme.muted } }
401
+ : {
402
+ ...styled.title,
403
+ font: { color: theme.muted, ...styled.title.font },
404
+ };
405
+ return styled;
33
406
  }
34
407
 
35
408
  function withEmptyState(layout, hasData) {
36
409
  if (hasData) return layout;
37
410
  return {
38
411
  ...layout,
39
- annotations: [{ text: "No data to display.", showarrow: false, xref: "paper", yref: "paper", x: 0.5, y: 0.5 }],
412
+ // Append rather than assign: an empty payload used to discard whatever
413
+ // annotations the caller's own `layout` override had put here.
414
+ annotations: [
415
+ ...(Array.isArray(layout.annotations) ? layout.annotations : []),
416
+ {
417
+ text: "No data to display.",
418
+ showarrow: false,
419
+ xref: "paper",
420
+ yref: "paper",
421
+ x: 0.5,
422
+ y: 0.5,
423
+ },
424
+ ],
425
+ // A full grid with axis titles and no marks on it reads as a broken chart
426
+ // rather than an empty one, so the message stands on its own. Figures
427
+ // without cartesian axes (icicle, sankey) ignore these.
428
+ xaxis: { ...layout.xaxis, visible: false },
429
+ yaxis: { ...layout.yaxis, visible: false },
40
430
  };
41
431
  }
42
432
 
433
+ // Pooled magnitude per region, largest first -- the order both the durations
434
+ // bars and the heatmap columns are laid out in, and the one
435
+ // `buildRegionSummaryFigure` has always ranked by.
436
+ function totalsByRegion(rows, value) {
437
+ const totals = new Map();
438
+ for (const row of rows)
439
+ totals.set(row.region, (totals.get(row.region) ?? 0) + (value(row) ?? 0));
440
+ return new Map([...totals].sort((a, b) => b[1] - a[1]));
441
+ }
442
+
43
443
  function filtered(rows, options) {
44
- return typeof options?.filterRegion === "function" ? rows.filter((row) => options.filterRegion(row.region, row)) : rows;
444
+ return typeof options?.filterRegion === "function"
445
+ ? rows.filter((row) => options.filterRegion(row.region, row))
446
+ : rows;
45
447
  }
46
448
 
47
449
  // One pass instead of a filter per series. A large trace has both many rows and
@@ -69,8 +471,10 @@ function runAware(rows) {
69
471
  return {
70
472
  multi,
71
473
  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),
474
+ label: (row) =>
475
+ multi ? `${row.file ?? "run"} / ${row.region}` : row.region,
476
+ key: (row) =>
477
+ multi ? `${row.file ?? "run"}\u0000${row.region}` : row.region,
74
478
  };
75
479
  }
76
480
 
@@ -89,23 +493,43 @@ const FILE_PATTERNS = ["", "/", "\\", "x", "-"];
89
493
  * flat profile compared across many ranks.
90
494
  */
91
495
  export function buildGanttFigure(payload, options = {}) {
92
- const intervals = filtered(values(payload, "intervals"), options);
496
+ const { baseLayout, axis } = palette(options);
497
+ const intervals = filtered(values(payload, "intervals", "gantt"), options);
93
498
  const byRegion = groupBy(intervals, (row) => row.region);
94
499
  const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
95
500
  const multi = new Set(intervals.map((row) => row.file ?? "run")).size > 1;
96
501
  const rankLane = (row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`;
97
502
  // Matching `plot gantt`'s own lane label, extended by the run only when the
98
503
  // payload holds more than one.
99
- const regionLane = (row) => `${multi ? `${row.file ?? "run"} / ` : ""}${row.region} (rank ${row.rank ?? 0})`;
504
+ const regionLane = (row) =>
505
+ `${multi ? `${row.file ?? "run"} / ` : ""}${row.region} (rank ${row.rank ?? 0})`;
100
506
  const laneOf = options.laneBy === "rank" ? rankLane : regionLane;
101
507
  const lanes = [...new Set(intervals.map(laneOf))];
508
+ // A lane index rather than the lane string on every bar. The label is
509
+ // already in `lanes`; repeating it per interval costs one string per row and
510
+ // makes Plotly resolve a category for each of them, which a 200k-interval
511
+ // trace feels. The axis carries the names back via ticktext.
512
+ const laneIndex = new Map(lanes.map((lane, position) => [lane, position]));
102
513
  const data = [...byRegion].map(([region, rows]) => {
103
- return { type: "bar", orientation: "h", name: region,
104
- y: rows.map(laneOf),
105
- x: rows.map((row) => row.end_seconds - row.start_seconds), base: rows.map((row) => row.start_seconds),
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]),
108
- hovertemplate: `<b>${region}</b><br>%{customdata[0]} / rank %{customdata[1]}<br>start: %{base:.6g} s<br>duration: %{x:.6g} s<extra></extra>`,
514
+ return {
515
+ type: "bar",
516
+ orientation: "h",
517
+ name: region,
518
+ y: rows.map((row) => laneIndex.get(laneOf(row))),
519
+ x: rows.map((row) => row.end_seconds - row.start_seconds),
520
+ base: rows.map((row) => row.start_seconds),
521
+ // A categorical axis gave every bar its slot; a linear one sizes bars
522
+ // from the data, so the thickness has to be said out loud.
523
+ width: 0.8,
524
+ marker: {
525
+ color: colors.get(region),
526
+ line: { color: "rgba(0, 0, 0, 0.28)", width: 0.5 },
527
+ },
528
+ customdata: interactionData(rows, (row) => [
529
+ label(row.file ?? "run"),
530
+ row.rank ?? 0,
531
+ ]),
532
+ hovertemplate: `<b>${label(region)}</b><br>%{customdata[0]} / rank %{customdata[1]}<br>start: %{base:.6g} s<br>duration: %{x:.6g} s<extra></extra>`,
109
533
  };
110
534
  });
111
535
  const byRank = options.laneBy === "rank";
@@ -113,92 +537,283 @@ export function buildGanttFigure(payload, options = {}) {
113
537
  // Region lanes read bottom-up, so the first region -- the enclosing one --
114
538
  // sits at the bottom, as `scope-profiler plot gantt` draws it. Rank lanes
115
539
  // 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 });
540
+ const layout = baseLayout({
541
+ barmode: "overlay",
542
+ height: Math.max(280, perLane * lanes.length + 150),
543
+ showlegend: byRegion.size > 1,
544
+ xaxis: axis({ title: "Time (s)" }),
545
+ yaxis: axis({
546
+ tickmode: "array",
547
+ tickvals: lanes.map((_, position) => position),
548
+ ticktext: lanes,
549
+ range: byRank ? [lanes.length - 0.5, -0.5] : [-0.5, lanes.length - 0.5],
550
+ showgrid: false,
551
+ }),
552
+ });
117
553
  return { data, layout: withEmptyState(layout, intervals.length > 0) };
118
554
  }
119
555
 
120
556
  /** Build an icicle flame chart using scope-profiler's explicit call IDs. */
121
557
  export function buildFlameFigure(payload, options = {}) {
122
- const allCalls = values(payload, "calls");
558
+ const { theme, baseLayout } = palette(options);
559
+ const allCalls = values(payload, "calls", "flame");
123
560
  const calls = filtered(allCalls, options);
124
561
  const regions = [...new Set(calls.map((call) => call.region))];
125
562
  const colors = colorMap(regions, options.colors ?? payload.colors);
126
563
  const root = "scope-profiler-root";
127
- const callKey = (call) => `${call.file ?? "run"}:${call.rank ?? 0}:${call.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;
564
+ const callKey = (call) =>
565
+ `${call.file ?? "run"}:${call.rank ?? 0}:${call.call_id}`;
566
+ const parentKey = (call) =>
567
+ call.parent_call_id == null
568
+ ? null
569
+ : `${call.file ?? "run"}:${call.rank ?? 0}:${call.parent_call_id}`;
570
+ const duration = (call) =>
571
+ call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds;
130
572
  // A filter can remove a call whose children survive; re-parent each survivor
131
573
  // onto its nearest surviving ancestor so the icicle stays a single tree
132
574
  // instead of silently dropping the orphans.
133
- const byKey = new Map(allCalls.map((call) => [callKey(call), call]));
575
+ const byKey = validateParents(allCalls, callKey, parentKey);
134
576
  const kept = new Set(calls.map(callKey));
135
577
  const anchor = (call) => {
136
578
  let key = parentKey(call);
137
- while (key != null && !kept.has(key)) key = byKey.has(key) ? parentKey(byKey.get(key)) : null;
579
+ while (key != null && !kept.has(key))
580
+ key = byKey.has(key) ? parentKey(byKey.get(key)) : null;
138
581
  return key ?? root;
139
582
  };
140
583
  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"];
584
+ const rootDuration = calls.reduce(
585
+ (sum, call, index) =>
586
+ anchors[index] === root ? sum + duration(call) : sum,
587
+ 0,
588
+ );
589
+ const ids = [root],
590
+ labels = [options.rootLabel ?? "All calls"],
591
+ parents = [""],
592
+ markerColors = [theme.neutral],
593
+ hovertext = ["All calls"];
143
594
  calls.forEach((call, index) => {
144
- ids.push(callKey(call)); labels.push(call.region); parents.push(anchors[index]);
595
+ ids.push(callKey(call));
596
+ labels.push(call.region);
597
+ parents.push(anchors[index]);
145
598
  markerColors.push(colors.get(call.region));
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`);
599
+ hovertext.push(
600
+ `<b>${label(call.region)}</b><br>${label(call.file ?? "run")} / rank ${call.rank ?? 0}<br>start: ${call.start_seconds.toPrecision(6)} s<br>inclusive: ${duration(call).toPrecision(6)} s`,
601
+ );
602
+ });
603
+ const layout = baseLayout({
604
+ height: 500,
605
+ margin: { l: 24, r: 24, t: 24, b: 24 },
147
606
  });
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) };
607
+ return {
608
+ data: [
609
+ {
610
+ type: "icicle",
611
+ ids,
612
+ labels,
613
+ parents,
614
+ values: [rootDuration, ...calls.map(duration)],
615
+ branchvalues: "total",
616
+ tiling: { orientation: "h" },
617
+ marker: {
618
+ colors: markerColors,
619
+ line: { color: "rgba(255, 255, 255, 0.55)", width: 1 },
620
+ },
621
+ hovertext,
622
+ customdata: interactionData([{ region: null }, ...calls]),
623
+ hoverinfo: "text",
624
+ },
625
+ ],
626
+ layout: withEmptyState(layout, calls.length > 0),
627
+ };
150
628
  }
151
629
 
152
630
  export function buildDurationsFigure(payload, options = {}) {
153
- const metric = options.metric ?? payload.options?.metric ?? payload.metrics?.[0] ?? "total";
154
- const bars = filtered(values(payload, "bars"), options).filter((bar) => bar.metric === metric);
631
+ const { baseLayout, axis } = palette(options);
632
+ const metric =
633
+ options.metric ??
634
+ payload.options?.metric ??
635
+ payload.metrics?.[0] ??
636
+ "total";
637
+ const bars = filtered(values(payload, "bars", "durations"), options).filter(
638
+ (bar) => bar.metric === metric,
639
+ );
155
640
  // A stacked-children export is already decomposed into segments. Preserve
156
641
  // that decomposition instead of letting duplicate region rows overwrite.
157
642
  const stacked = bars.some((bar) => bar.segment != null);
158
- const groups = groupBy(bars, (bar) => (stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`));
159
- const regions = [...new Set(bars.map((bar) => bar.region))];
160
- const colors = colorMap(groups.keys(), options.colors ?? payload.colors);
643
+ const groups = groupBy(bars, (bar) =>
644
+ stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`,
645
+ );
646
+ // Ranked by the pooled metric, so the costly regions lead. Appearance order
647
+ // is whatever the exporter happened to walk, which with two runs of
648
+ // different region sets interleaves them unpredictably.
649
+ const regions = [...totalsByRegion(bars, (bar) => bar.value_seconds).keys()];
650
+ // A group is a rank, a run, or a stacked child region depending on the
651
+ // export, but a caller -- and the payload's own `colors` -- keys colours by
652
+ // the name it knows. "rank 3" is a label this builder invents, so accept a
653
+ // colour supplied under the bare rank rather than silently dropping to the
654
+ // default cycle.
655
+ const supplied = options.colors ?? payload.colors ?? {};
656
+ const byGroup = {};
657
+ for (const [group, rows] of groups) {
658
+ const color = supplied[group] ?? supplied[rows[0].rank];
659
+ if (color != null) byGroup[group] = color;
660
+ }
661
+ const colors = colorMap(groups.keys(), byGroup);
161
662
  const data = [...groups].map(([group, rows]) => {
162
- const byRegion = new Map(rows.map((bar) => [bar.region, bar.value_seconds]));
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>` };
663
+ const byRegion = uniqueMap(
664
+ rows.map((bar) => [bar.region, bar.value_seconds]),
665
+ "durations",
666
+ );
667
+ return {
668
+ type: "bar",
669
+ name: group,
670
+ x: regions,
671
+ y: regions.map((region) => byRegion.get(region) ?? null),
672
+ customdata: interactionData(
673
+ regions.map((region) => ({
674
+ ...rows.find((row) => row.region === region),
675
+ region,
676
+ })),
677
+ ),
678
+ marker: {
679
+ color: colors.get(group),
680
+ line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 },
681
+ },
682
+ hovertemplate: `<b>%{x}</b><br>${label(group)}: %{y:.6g} s<extra></extra>`,
683
+ };
684
+ });
685
+ const layout = baseLayout({
686
+ barmode: stacked ? "stack" : "group",
687
+ height: Math.max(360, 34 * regions.length + 180),
688
+ showlegend: groups.size > 1,
689
+ xaxis: axis({ tickangle: -35 }),
690
+ yaxis: axis({ title: `${metric} duration (s)` }),
164
691
  });
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
692
  return { data, layout: withEmptyState(layout, bars.length > 0) };
167
693
  }
168
694
 
169
695
  // The three scaling exports differ only in the y column they carry and the
170
696
  // shape of their ideal line, so one builder serves all of them.
171
697
  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 },
698
+ speedup: {
699
+ yKey: "speedup",
700
+ title: "Speedup",
701
+ suffix: "×",
702
+ idealName: "Ideal speedup",
703
+ ideal: (value, baseline) => value / baseline,
704
+ },
705
+ weak_scaling: {
706
+ yKey: "normalized_runtime",
707
+ title: "Normalized runtime",
708
+ suffix: "×",
709
+ idealName: "Ideal weak scaling",
710
+ ideal: () => 1,
711
+ },
712
+ scaling_efficiency: {
713
+ yKey: "efficiency",
714
+ title: "Scaling efficiency",
715
+ suffix: "",
716
+ idealName: "Ideal efficiency",
717
+ ideal: () => 1,
718
+ },
719
+ // Shares its y column with scaling_efficiency, so it has to be named --
720
+ // by the document's own `plot` field, or options.plot -- rather than
721
+ // recognised from the rows. Listed after it so a payload with neither
722
+ // still infers the strong-scaling reading it always did.
723
+ weak_scaling_efficiency: {
724
+ yKey: "efficiency",
725
+ title: "Weak-scaling efficiency",
726
+ suffix: "",
727
+ idealName: "Ideal efficiency",
728
+ ideal: () => 1,
729
+ },
175
730
  };
176
731
 
177
732
  function scalingKind(payload, options) {
178
733
  const named = options.plot ?? payload?.plot;
179
734
  if (named && SCALING_KINDS[named]) return SCALING_KINDS[named];
180
735
  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 };
736
+ const match = Object.values(SCALING_KINDS).find(
737
+ (kind) => kind.yKey === options.yField,
738
+ );
739
+ return (
740
+ match ?? {
741
+ ...SCALING_KINDS.speedup,
742
+ yKey: options.yField,
743
+ title: options.yField,
744
+ }
745
+ );
183
746
  }
184
747
  const row = payload?.points?.[0];
185
- return (row && Object.values(SCALING_KINDS).find((kind) => row[kind.yKey] != null)) ?? SCALING_KINDS.speedup;
748
+ return (
749
+ (row &&
750
+ Object.values(SCALING_KINDS).find((kind) => row[kind.yKey] != null)) ??
751
+ SCALING_KINDS.speedup
752
+ );
186
753
  }
187
754
 
188
755
  /** Build a scaling curve: speedup, weak scaling, or parallel efficiency. */
189
756
  export function buildSpeedupFigure(payload, options = {}) {
757
+ const { theme, baseLayout, axis } = palette(options);
190
758
  const kind = scalingKind(payload, options);
191
759
  const xField = options.xField ?? payload.options?.x_field ?? "num_ranks";
192
- const points = filtered(values(payload, "points"), options);
760
+ const points = filtered(
761
+ values(payload, "points", "scaling", { ...options, yField: kind.yKey }),
762
+ options,
763
+ );
193
764
  const byRegion = groupBy(points, (point) => point.region);
194
765
  const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
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)));
766
+ const xValues = [...new Set(points.map((point) => point[xField]))].sort(
767
+ (a, b) =>
768
+ typeof a === "number" && typeof b === "number"
769
+ ? a - b
770
+ : String(a).localeCompare(String(b)),
771
+ );
196
772
  const order = new Map(xValues.map((value, position) => [value, position]));
197
773
  const numeric = xValues.every((value) => typeof value === "number");
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>` }; });
774
+ const data = [...byRegion].map(([region, unsorted]) => {
775
+ const rows = [...unsorted].sort(
776
+ (a, b) => order.get(a[xField]) - order.get(b[xField]),
777
+ );
778
+ return {
779
+ type: "scatter",
780
+ mode: "lines+markers",
781
+ name: region,
782
+ x: rows.map((row) => row[xField]),
783
+ y: rows.map((row) => row[kind.yKey]),
784
+ customdata: interactionData(rows),
785
+ line: { color: colors.get(region), width: 2.4 },
786
+ marker: { color: colors.get(region), size: 7 },
787
+ hovertemplate: `<b>%{x}</b><br>${label(region)}: %{y:.3g}${kind.suffix}<extra></extra>`,
788
+ };
789
+ });
199
790
  const baseline = payload.options?.baseline ?? xValues[0];
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 });
791
+ if (
792
+ numeric &&
793
+ points.length &&
794
+ options.ideal !== false &&
795
+ (!Number.isFinite(baseline) || baseline <= 0)
796
+ )
797
+ throw new TypeError("Scaling baseline must be a positive finite number.");
798
+ if (numeric && options.ideal !== false)
799
+ data.push({
800
+ type: "scatter",
801
+ mode: "lines",
802
+ name: kind.idealName,
803
+ x: xValues,
804
+ y: xValues.map((value) => kind.ideal(value, baseline)),
805
+ line: { color: theme.neutral, dash: "dash" },
806
+ hoverinfo: "skip",
807
+ });
808
+ const layout = baseLayout({
809
+ height: 420,
810
+ showlegend: data.length > 1,
811
+ xaxis: axis({
812
+ title: payload.options?.x_label ?? xField,
813
+ tickvals: xValues,
814
+ }),
815
+ yaxis: axis({ title: kind.title, rangemode: "tozero" }),
816
+ });
202
817
  return { data, layout: withEmptyState(layout, points.length > 0) };
203
818
  }
204
819
 
@@ -209,136 +824,710 @@ export function buildWeakScalingFigure(payload, options = {}) {
209
824
 
210
825
  /** Build a parallel-efficiency curve (measured speedup over ideal speedup). */
211
826
  export function buildScalingEfficiencyFigure(payload, options = {}) {
212
- return buildSpeedupFigure(payload, { ...options, plot: "scaling_efficiency" });
827
+ return buildSpeedupFigure(payload, {
828
+ ...options,
829
+ plot: "scaling_efficiency",
830
+ });
831
+ }
832
+
833
+ /** Build a weak-scaling efficiency curve (baseline runtime over runtime).
834
+ *
835
+ * For a study that grows the problem with the machine, where the ideal is
836
+ * constant runtime -- not the rank-proportional speedup
837
+ * `buildScalingEfficiencyFigure` measures against.
838
+ */
839
+ export function buildWeakScalingEfficiencyFigure(payload, options = {}) {
840
+ return buildSpeedupFigure(payload, {
841
+ ...options,
842
+ plot: "weak_scaling_efficiency",
843
+ });
213
844
  }
214
845
 
215
846
  /** Build mean call duration over time, one trace per region. */
216
847
  export function buildDurationTimeseriesFigure(payload, options = {}) {
217
- const points = filtered(values(payload, "points"), options);
848
+ const { baseLayout, axis } = palette(options);
849
+ const points = filtered(values(payload, "points", "timeseries"), options);
218
850
  const runs = runAware(points);
219
- const colors = colorMap(points.map((point) => point.region), options.colors ?? payload.colors);
851
+ const colors = colorMap(
852
+ points.map((point) => point.region),
853
+ options.colors ?? payload.colors,
854
+ );
220
855
  const series = groupBy(points, runs.key);
221
856
  const data = [...series].map(([, unsorted]) => {
222
857
  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>` };
858
+ const region = rows[0].region,
859
+ name = runs.label(rows[0]);
860
+ return {
861
+ type: "scatter",
862
+ mode: "lines+markers",
863
+ name,
864
+ legendgroup: JSON.stringify([rows[0].file ?? "run", region]),
865
+ x: rows.map((row) => row.time_seconds),
866
+ y: rows.map((row) => row.mean_duration_seconds),
867
+ ...(options.variability === "error"
868
+ ? {
869
+ error_y: {
870
+ type: "data",
871
+ symmetric: false,
872
+ array: rows.map((row) =>
873
+ row.max_duration_seconds == null
874
+ ? null
875
+ : row.max_duration_seconds - row.mean_duration_seconds,
876
+ ),
877
+ arrayminus: rows.map((row) =>
878
+ row.min_duration_seconds == null
879
+ ? null
880
+ : row.mean_duration_seconds - row.min_duration_seconds,
881
+ ),
882
+ },
883
+ }
884
+ : {}),
885
+ line: { color: colors.get(region), width: 2.2 },
886
+ marker: {
887
+ color: colors.get(region),
888
+ size: 5,
889
+ symbol:
890
+ FILE_SYMBOLS[
891
+ runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length
892
+ ],
893
+ },
894
+ customdata: interactionData(rows, (row) => [
895
+ row.min_duration_seconds,
896
+ row.max_duration_seconds,
897
+ row.call_index,
898
+ ]),
899
+ hovertemplate: `<b>${label(name)}</b><br>time: %{x:.6g} s<br>mean: %{y:.6g} s<br>min–max: %{customdata[0]:.4g}–%{customdata[1]:.4g} s<extra></extra>`,
900
+ };
901
+ });
902
+ if (options.variability === "band") {
903
+ const bands = [...series].flatMap(([, unsorted]) => {
904
+ const rows = [...unsorted].sort(
905
+ (a, b) => a.time_seconds - b.time_seconds,
906
+ );
907
+ const common = {
908
+ type: "scatter",
909
+ mode: "lines",
910
+ x: rows.map((row) => row.time_seconds),
911
+ legendgroup: JSON.stringify([rows[0].file ?? "run", rows[0].region]),
912
+ showlegend: false,
913
+ hoverinfo: "skip",
914
+ line: { width: 0, color: colors.get(rows[0].region) },
915
+ };
916
+ return [
917
+ { ...common, y: rows.map((row) => row.min_duration_seconds ?? null) },
918
+ {
919
+ ...common,
920
+ y: rows.map((row) => row.max_duration_seconds ?? null),
921
+ fill: "tonexty",
922
+ opacity: 0.2,
923
+ },
924
+ ];
925
+ });
926
+ data.unshift(...bands);
927
+ }
928
+ const layout = baseLayout({
929
+ height: 420,
930
+ showlegend: series.size > 1,
931
+ xaxis: axis({ title: "Time (s)" }),
932
+ yaxis: axis({ title: "Mean call duration (s)" }),
225
933
  });
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
934
  return { data, layout: withEmptyState(layout, points.length > 0) };
228
935
  }
229
936
 
230
937
  /** Build duration distributions from histogram bin records. */
231
938
  export function buildHistogramFigure(payload, options = {}) {
232
- const bins = filtered(values(payload, "bins"), options);
939
+ const { baseLayout, axis } = palette(options);
940
+ const bins = filtered(values(payload, "bins", "histogram"), options);
233
941
  const runs = runAware(bins);
234
- const colors = colorMap(bins.map((bin) => bin.region), options.colors ?? payload.colors);
942
+ const colors = colorMap(
943
+ bins.map((bin) => bin.region),
944
+ options.colors ?? payload.colors,
945
+ );
235
946
  const series = groupBy(bins, runs.key);
236
947
  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>` };
948
+ const rows = [...unsorted].sort(
949
+ (a, b) => a.bin_center_seconds - b.bin_center_seconds,
950
+ );
951
+ const region = rows[0].region,
952
+ name = runs.label(rows[0]);
953
+ const pattern =
954
+ FILE_PATTERNS[
955
+ runs.files.indexOf(rows[0].file ?? "run") % FILE_PATTERNS.length
956
+ ];
957
+ return {
958
+ type: "bar",
959
+ name,
960
+ x: rows.map((bin) => bin.bin_center_seconds),
961
+ y: rows.map((bin) => bin.count),
962
+ customdata: interactionData(rows),
963
+ width: rows.map((bin) => bin.bin_high_seconds - bin.bin_low_seconds),
964
+ marker: {
965
+ color: colors.get(region),
966
+ line: { color: "rgba(0, 0, 0, 0.2)", width: 0.5 },
967
+ ...(runs.multi ? { pattern: { shape: pattern, solidity: 0.35 } } : {}),
968
+ },
969
+ hovertemplate: `<b>${label(name)}</b><br>%{x:.6g} s: %{y} calls<extra></extra>`,
970
+ };
971
+ });
972
+ const layout = baseLayout({
973
+ barmode: "overlay",
974
+ height: 400,
975
+ showlegend: series.size > 1,
976
+ xaxis: axis({ title: "Call duration (s)" }),
977
+ yaxis: axis({ title: "Calls" }),
241
978
  });
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
979
  return { data, layout: withEmptyState(layout, bins.length > 0) };
244
980
  }
245
981
 
246
982
  /** Build a rank × region heatmap from duration records. */
247
983
  export function buildRankHeatmapFigure(payload, options = {}) {
248
- const points = filtered(values(payload, "points"), options);
249
- const regions = [...new Set(points.map((point) => point.region))];
984
+ const { baseLayout, axis } = palette(options);
985
+ const points = filtered(
986
+ values(payload, "points", "rank_heatmap", options),
987
+ options,
988
+ );
250
989
  const multi = new Set(points.map((point) => point.file ?? "run")).size > 1;
251
990
  // A lane per run and rank. Keying cells by rank alone silently let a second
252
991
  // 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 });
992
+ const laneOf = (point) =>
993
+ multi
994
+ ? `${point.file ?? "run"} / rank ${point.rank ?? 0}`
995
+ : String(point.rank ?? 0);
996
+ const lanes = [...new Set(points.map(laneOf))].sort((a, b) =>
997
+ a.localeCompare(b, undefined, { numeric: true }),
998
+ );
999
+ const inferredValueKey = points[0]
1000
+ ? Object.keys(points[0]).find((key) => key.endsWith("_duration_seconds"))
1001
+ : undefined;
1002
+ const valueKey =
1003
+ options.valueKey ?? inferredValueKey ?? "total_duration_seconds";
1004
+ // Columns ranked by pooled duration rather than by the order the exporter
1005
+ // happened to walk, which with two runs of different region sets interleaves
1006
+ // them differently every time.
1007
+ const regions = [
1008
+ ...totalsByRegion(points, (point) => point[valueKey]).keys(),
1009
+ ];
1010
+ const byCell = uniqueMap(
1011
+ points.map((point) => [`${laneOf(point)}\u0000${point.region}`, point]),
1012
+ "rank_heatmap",
1013
+ );
1014
+ const data = [
1015
+ {
1016
+ type: "heatmap",
1017
+ x: regions,
1018
+ y: lanes,
1019
+ z: lanes.map((lane) =>
1020
+ regions.map(
1021
+ (region) => byCell.get(`${lane}\u0000${region}`)?.[valueKey] ?? null,
1022
+ ),
1023
+ ),
1024
+ customdata: lanes.map((lane) =>
1025
+ regions.map(
1026
+ (region) =>
1027
+ interactionData([
1028
+ { ...byCell.get(`${lane}\u0000${region}`), region },
1029
+ ])[0],
1030
+ ),
1031
+ ),
1032
+ colorscale: options.colorscale ?? "Viridis",
1033
+ colorbar: { title: "Seconds" },
1034
+ hovertemplate: `${multi ? "%{y}" : "rank %{y}"}<br>%{x}: %{z:.6g} s<extra></extra>`,
1035
+ },
1036
+ ];
1037
+ const layout = baseLayout({
1038
+ height: Math.max(320, 44 * lanes.length + 150),
1039
+ xaxis: axis({ title: "Region" }),
1040
+ yaxis: axis({
1041
+ title: multi ? "Run / rank" : "Rank",
1042
+ autorange: "reversed",
1043
+ showgrid: false,
1044
+ }),
1045
+ });
260
1046
  return { data, layout: withEmptyState(layout, points.length > 0) };
261
1047
  }
262
1048
 
263
1049
  /** Build per-rank duration lines, with a dashed rank mean for each region. */
264
1050
  export function buildImbalanceFigure(payload, options = {}) {
265
- const points = filtered(values(payload, "points"), options);
1051
+ const { baseLayout, axis } = palette(options);
1052
+ const points = filtered(values(payload, "points", "imbalance"), options);
266
1053
  const runs = runAware(points);
267
- const colors = colorMap(points.map((point) => point.region), options.colors ?? payload.colors);
1054
+ const colors = colorMap(
1055
+ points.map((point) => point.region),
1056
+ options.colors ?? payload.colors,
1057
+ );
268
1058
  // The mean is computed per run, so a run gets its own line and its own mean;
269
1059
  // pooling them drew one zig-zagging series that revisited every rank.
270
1060
  const series = groupBy(points, runs.key);
271
1061
  const data = [...series].flatMap(([, unsorted]) => {
272
1062
  const rows = [...unsorted].sort((a, b) => a.rank - b.rank);
273
- const region = rows[0].region, name = runs.label(rows[0]);
1063
+ const region = rows[0].region,
1064
+ name = runs.label(rows[0]);
274
1065
  const color = colors.get(region);
275
- const symbol = FILE_SYMBOLS[runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length];
1066
+ const symbol =
1067
+ FILE_SYMBOLS[
1068
+ runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length
1069
+ ];
276
1070
  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 },
1071
+ {
1072
+ type: "scatter",
1073
+ mode: "lines+markers",
1074
+ name,
1075
+ legendgroup: JSON.stringify([rows[0].file ?? "run", region]),
1076
+ x: rows.map((row) => row.rank),
1077
+ y: rows.map((row) => row.value_seconds),
1078
+ customdata: interactionData(rows),
1079
+ line: { color, width: 2.2 },
1080
+ marker: { color, size: 7, symbol },
1081
+ hovertemplate: `<b>${label(name)}</b><br>rank %{x}: %{y:.6g} s<extra></extra>`,
1082
+ },
1083
+ {
1084
+ type: "scatter",
1085
+ mode: "lines",
1086
+ name: `${name} mean`,
1087
+ legendgroup: JSON.stringify([rows[0].file ?? "run", region]),
1088
+ x: rows.map((row) => row.rank),
1089
+ y: rows.map((row) => row.mean_over_ranks_seconds),
1090
+ customdata: interactionData(rows),
1091
+ line: { color, dash: "dot", width: 1.3 },
1092
+ hoverinfo: "skip",
1093
+ showlegend: false,
1094
+ },
279
1095
  ];
280
1096
  });
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 });
1097
+ const layout = baseLayout({
1098
+ height: 420,
1099
+ showlegend: series.size > 1,
1100
+ xaxis: axis({ title: "Rank", dtick: 1 }),
1101
+ yaxis: axis({ title: `${payload.metric ?? "Duration"} (s)` }),
1102
+ });
282
1103
  return { data, layout: withEmptyState(layout, points.length > 0) };
283
1104
  }
284
1105
 
285
1106
  /** Build a timeline-occupancy heatmap from binned density records. */
286
1107
  export function buildDensityFigure(payload, options = {}) {
287
- const points = filtered(values(payload, "points"), options);
1108
+ const { baseLayout, axis } = palette(options);
1109
+ const points = filtered(values(payload, "points", "density"), options);
288
1110
  const lane = (point) => `${point.file ?? "run"} / ${point.region}`;
289
1111
  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;
1112
+ // Each cell sits at the centre of its own bin. Deriving one bin width from
1113
+ // the first point and applying it to every lane put the second run's cells
1114
+ // at the wrong times whenever two runs of different length were binned into
1115
+ // the same number of bins -- which is exactly what the exporter does.
1116
+ const centre = (point) =>
1117
+ (point.bin_start_seconds + point.bin_end_seconds) / 2;
1118
+ const centres = [...new Set(points.map(centre))].sort((a, b) => a - b);
292
1119
  // Occupancy is the share of the bin the region was inside, which compares
293
1120
  // across runs of different length; raw seconds stay available via valueKey.
294
1121
  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; };
1122
+ // NUL-joined: a lane name is a file and a region, either of which may hold
1123
+ // the ":" that used to separate the two halves of this key.
1124
+ const byCell = uniqueMap(
1125
+ points.map((point) => [`${lane(point)}\u0000${centre(point)}`, point]),
1126
+ "density",
1127
+ );
1128
+ const cell = (laneName, at, pick) => {
1129
+ const point = byCell.get(`${laneName}\u0000${at}`);
1130
+ return point ? pick(point) : null;
1131
+ };
297
1132
  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 });
1133
+ const data = [
1134
+ {
1135
+ type: "heatmap",
1136
+ x: centres,
1137
+ y: lanes,
1138
+ z: lanes.map((laneName) =>
1139
+ centres.map((at) =>
1140
+ cell(laneName, at, (point) =>
1141
+ asFraction
1142
+ ? span(point) > 0
1143
+ ? point.occupied_seconds / span(point)
1144
+ : null
1145
+ : point.occupied_seconds,
1146
+ ),
1147
+ ),
1148
+ ),
1149
+ customdata: lanes.map((laneName) =>
1150
+ centres.map((at) =>
1151
+ cell(
1152
+ laneName,
1153
+ at,
1154
+ (point) =>
1155
+ interactionData([point], (row) => [row.occupied_seconds])[0],
1156
+ ),
1157
+ ),
1158
+ ),
1159
+ colorscale: options.colorscale ?? "Viridis",
1160
+ ...(asFraction ? { zmin: 0, zmax: 1 } : {}),
1161
+ colorbar: { title: asFraction ? "Occupancy" : "Seconds" },
1162
+ hovertemplate: `%{y}<br>t = %{x:.6g} s<br>${asFraction ? "occupancy: %{z:.3f}<br>" : ""}occupied: %{customdata[0]:.4g} s<extra></extra>`,
1163
+ },
1164
+ ];
1165
+ // Plotly infers edges from centers. That is only accurate on one uniform
1166
+ // grid. For unequal grids use one trace per lane with explicit bin edges;
1167
+ // this preserves measured boundaries and never invents interpolated data.
1168
+ const grids = [...groupBy(points, lane)].map(([name, rows]) => {
1169
+ rows = [...rows].sort((a, b) => a.bin_start_seconds - b.bin_start_seconds);
1170
+ for (let i = 1; i < rows.length; i++) {
1171
+ if (rows[i].bin_start_seconds < rows[i - 1].bin_end_seconds)
1172
+ throw new TypeError(`density: overlapping bins in ${name}`);
1173
+ }
1174
+ return [name, rows];
1175
+ });
1176
+ const widths = new Set(points.map(span));
1177
+ const sameGrid =
1178
+ new Set(
1179
+ grids.map(([, rows]) =>
1180
+ JSON.stringify(
1181
+ rows.map((row) => [row.bin_start_seconds, row.bin_end_seconds]),
1182
+ ),
1183
+ ),
1184
+ ).size <= 1;
1185
+ const explicitEdges =
1186
+ !sameGrid ||
1187
+ widths.size > 1 ||
1188
+ grids.some(([, rows]) =>
1189
+ rows.some(
1190
+ (row, index) =>
1191
+ index > 0 &&
1192
+ row.bin_start_seconds !== rows[index - 1].bin_end_seconds,
1193
+ ),
1194
+ );
1195
+ if (explicitEdges) {
1196
+ const template = data[0];
1197
+ const maximumSeconds = points.reduce(
1198
+ (maximum, row) => Math.max(maximum, row.occupied_seconds),
1199
+ 0,
1200
+ );
1201
+ data.splice(
1202
+ 0,
1203
+ data.length,
1204
+ ...grids.map(([name, rows], index) => {
1205
+ const edges = [
1206
+ ...new Set(
1207
+ rows.flatMap((row) => [row.bin_start_seconds, row.bin_end_seconds]),
1208
+ ),
1209
+ ].sort((a, b) => a - b);
1210
+ const byStart = new Map(
1211
+ rows.map((row) => [row.bin_start_seconds, row]),
1212
+ );
1213
+ const cells = edges.slice(0, -1).map((start) => byStart.get(start));
1214
+ return {
1215
+ ...template,
1216
+ x: edges,
1217
+ y: [index - 0.5, index + 0.5],
1218
+ showscale: index === 0,
1219
+ z: [
1220
+ cells.map((row) =>
1221
+ row
1222
+ ? asFraction
1223
+ ? span(row) > 0
1224
+ ? row.occupied_seconds / span(row)
1225
+ : null
1226
+ : row.occupied_seconds
1227
+ : null,
1228
+ ),
1229
+ ],
1230
+ customdata: [
1231
+ cells.map((row) =>
1232
+ row
1233
+ ? interactionData([row], (point) => [point.occupied_seconds])[0]
1234
+ : null,
1235
+ ),
1236
+ ],
1237
+ hovertemplate: `<b>${label(name)}</b><br>t = %{x:.6g} s<br>${asFraction ? "occupancy: %{z:.3f}<br>" : ""}occupied: %{customdata[0]:.4g} s<extra></extra>`,
1238
+ ...(!asFraction
1239
+ ? {
1240
+ zmin: 0,
1241
+ zmax: maximumSeconds,
1242
+ }
1243
+ : {}),
1244
+ };
1245
+ }),
1246
+ );
1247
+ }
1248
+ const layout = baseLayout({
1249
+ height: Math.max(320, 34 * lanes.length + 150),
1250
+ xaxis: axis({ title: "Time (s)" }),
1251
+ yaxis: axis({
1252
+ categoryorder: "array",
1253
+ categoryarray: lanes,
1254
+ autorange: "reversed",
1255
+ showgrid: false,
1256
+ ...(explicitEdges
1257
+ ? {
1258
+ type: "linear",
1259
+ tickmode: "array",
1260
+ tickvals: lanes.map((_, index) => index),
1261
+ ticktext: lanes,
1262
+ range: [lanes.length - 0.5, -0.5],
1263
+ autorange: false,
1264
+ }
1265
+ : {}),
1266
+ }),
1267
+ });
307
1268
  return { data, layout: withEmptyState(layout, points.length > 0) };
308
1269
  }
309
1270
 
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)" };
1271
+ /** Axis label for each field a region_statistics document stores. */
1272
+ const SUMMARY_LABELS = {
1273
+ count: "Calls",
1274
+ average_duration_seconds: "Average duration (s)",
1275
+ min_duration_seconds: "Minimum duration (s)",
1276
+ max_duration_seconds: "Maximum duration (s)",
1277
+ first_duration_seconds: "First call duration (s)",
1278
+ last_duration_seconds: "Last call duration (s)",
1279
+ std_duration_seconds: "Duration std. dev. (s)",
1280
+ total_duration_seconds: "Total duration (s)",
1281
+ };
1282
+
1283
+ /** Short metric names accepted by the region_statistics builders.
1284
+ *
1285
+ * The names the CLI and the durations export use, mapped to the field they are
1286
+ * stored under in a region_statistics document, so a caller can say "total"
1287
+ * wherever it says "total" everywhere else. The stored field names are
1288
+ * accepted too; exported so a page can offer the list rather than guess it.
1289
+ */
1290
+ export const SUMMARY_METRICS = Object.freeze({
1291
+ avg: "average_duration_seconds",
1292
+ min: "min_duration_seconds",
1293
+ max: "max_duration_seconds",
1294
+ total: "total_duration_seconds",
1295
+ first: "first_duration_seconds",
1296
+ last: "last_duration_seconds",
1297
+ std: "std_duration_seconds",
1298
+ count: "count",
1299
+ });
1300
+
1301
+ // Pick the runs to draw, in the order asked for. `files` names them by label
1302
+ // (or by index), which is how a page lets a viewer compare two runs out of a
1303
+ // document that holds many.
1304
+ function selectFiles(files, selection) {
1305
+ if (!Array.isArray(selection)) return files;
1306
+ return selection
1307
+ .map((wanted) =>
1308
+ typeof wanted === "number"
1309
+ ? files[wanted]
1310
+ : files.find((file) => (file.label ?? "run") === wanted),
1311
+ )
1312
+ .filter(Boolean);
1313
+ }
311
1314
 
312
1315
  /** Build a ranked region bar chart from a region_statistics document. */
313
1316
  export function buildRegionSummaryFigure(payload, options = {}) {
314
- const files = values(payload, "files");
315
- const metric = options.metric ?? "total_duration_seconds";
1317
+ const { baseLayout, axis } = palette(options);
1318
+ const files = selectFiles(
1319
+ values(payload, "files", "region_statistics"),
1320
+ options.files,
1321
+ );
1322
+ const metric =
1323
+ SUMMARY_METRICS[options.metric] ??
1324
+ options.metric ??
1325
+ "total_duration_seconds";
1326
+ // An unrecognised metric used to draw a full chart of nulls, which reads as
1327
+ // "this run recorded nothing" rather than "that is not a metric".
1328
+ if (!(metric in SUMMARY_LABELS))
1329
+ throw new TypeError(
1330
+ `Unknown region-statistics metric ${JSON.stringify(options.metric)}; expected one of ${[
1331
+ ...Object.keys(SUMMARY_METRICS),
1332
+ ...Object.keys(SUMMARY_LABELS),
1333
+ ]
1334
+ .map((name) => JSON.stringify(name))
1335
+ .join(", ")}.`,
1336
+ );
316
1337
  const limit = options.topN ?? 20;
317
- const keep = typeof options.filterRegion === "function" ? options.filterRegion : () => true;
1338
+ const horizontal = (options.orientation ?? "h") === "h";
1339
+ const keep =
1340
+ typeof options.filterRegion === "function"
1341
+ ? options.filterRegion
1342
+ : () => true;
1343
+ // Comparing runs is only meaningful over the regions they share: a region
1344
+ // one run never entered would otherwise draw as a bar of zero against a
1345
+ // real one, which reads as "got faster" rather than "not measured here".
1346
+ const common =
1347
+ options.commonRegionsOnly && files.length > 1
1348
+ ? (region) =>
1349
+ files.every((file) => (file.region_statistics ?? {})[region] != null)
1350
+ : () => true;
318
1351
  const totals = new Map();
319
1352
  for (const file of files) {
320
- for (const [region, stats] of Object.entries(file.region_statistics ?? {})) {
321
- if (!keep(region, stats)) continue;
1353
+ for (const [region, stats] of Object.entries(
1354
+ file.region_statistics ?? {},
1355
+ )) {
1356
+ if (!keep(region, stats) || !common(region)) continue;
322
1357
  totals.set(region, (totals.get(region) ?? 0) + (stats[metric] ?? 0));
323
1358
  }
324
1359
  }
325
1360
  // Rank by the pooled metric so the slowest regions lead, then keep the head
326
1361
  // 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);
1362
+ const regions = [...totals.entries()]
1363
+ .sort((a, b) => b[1] - a[1])
1364
+ .slice(0, limit)
1365
+ .map(([region]) => region);
328
1366
  const labels = files.map((file) => file.label ?? "run");
329
1367
  const colors = colorMap(labels, options.colors ?? payload.colors);
330
1368
  const unit = metric === "count" ? "" : " s";
1369
+ const values_ = (stats) =>
1370
+ regions.map((region) => stats[region]?.[metric] ?? null);
331
1371
  const data = files.map((file, index) => {
332
1372
  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>` };
1373
+ const magnitudes = values_(stats);
1374
+ return {
1375
+ type: "bar",
1376
+ ...(horizontal
1377
+ ? { orientation: "h", y: regions, x: magnitudes }
1378
+ : { x: regions, y: magnitudes }),
1379
+ name: labels[index],
1380
+ marker: {
1381
+ color: colors.get(labels[index]),
1382
+ line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 },
1383
+ },
1384
+ customdata: interactionData(
1385
+ regions.map((region) => ({ region, file: file.label ?? "run" })),
1386
+ (row) => [stats[row.region]?.count ?? null],
1387
+ ),
1388
+ hovertemplate: horizontal
1389
+ ? `<b>%{y}</b><br>${label(labels[index])}: %{x:.6g}${unit}<br>calls: %{customdata[0]}<extra></extra>`
1390
+ : `<b>%{x}</b><br>${label(labels[index])}: %{y:.6g}${unit}<br>calls: %{customdata[0]}<extra></extra>`,
1391
+ };
1392
+ });
1393
+ const magnitudeAxis = axis({ title: SUMMARY_LABELS[metric] ?? metric });
1394
+ const categoryAxis = axis({
1395
+ categoryorder: "array",
1396
+ // A horizontal bar chart fills from the bottom up, so the ranking has to
1397
+ // be reversed to read top-down; a vertical one already reads left to right.
1398
+ categoryarray: horizontal ? [...regions].reverse() : regions,
1399
+ showgrid: false,
1400
+ ...(horizontal ? {} : { tickangle: -35 }),
1401
+ });
1402
+ const layout = baseLayout({
1403
+ barmode: "group",
1404
+ ...(horizontal
1405
+ ? {
1406
+ height: Math.max(320, 26 * regions.length + 160),
1407
+ xaxis: magnitudeAxis,
1408
+ yaxis: categoryAxis,
1409
+ }
1410
+ : {
1411
+ height: Math.max(360, 26 * regions.length + 200),
1412
+ margin: { l: 100, r: 24, t: 32, b: 140 },
1413
+ xaxis: categoryAxis,
1414
+ yaxis: magnitudeAxis,
1415
+ }),
1416
+ showlegend: files.length > 1,
337
1417
  });
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
1418
  return { data, layout: withEmptyState(layout, regions.length > 0) };
340
1419
  }
341
1420
 
1421
+ /** Build a side-by-side comparison of runs in a region_statistics document.
1422
+ *
1423
+ * The same bars as `buildRegionSummaryFigure`, narrowed to the runs named in
1424
+ * `options.files` (every run in the document by default, not only the first
1425
+ * two) and to the regions all of them recorded, and drawn vertically with
1426
+ * every shared region kept rather than a ranked top slice -- the reading for
1427
+ * "what changed between these runs?" rather than "where did this run spend
1428
+ * its time?". `options.comparison: "absolute"` or `"percent"` instead draws a
1429
+ * single delta bar per region, which needs exactly two runs.
1430
+ */
1431
+ export function buildComparisonFigure(payload, options = {}) {
1432
+ if (options.comparison && options.comparison !== "side-by-side") {
1433
+ const files = selectFiles(
1434
+ values(payload, "files", "region_statistics"),
1435
+ options.files ?? [0, 1],
1436
+ );
1437
+ if (files.length !== 2)
1438
+ throw new TypeError("Delta comparison requires exactly two runs.");
1439
+ const metric =
1440
+ SUMMARY_METRICS[options.metric] ??
1441
+ options.metric ??
1442
+ "total_duration_seconds";
1443
+ if (!Object.hasOwn(SUMMARY_LABELS, metric))
1444
+ throw new TypeError(`Unknown region-statistics metric ${metric}`);
1445
+ if (!["absolute", "percent"].includes(options.comparison))
1446
+ throw new TypeError(
1447
+ "comparison must be side-by-side, absolute, or percent.",
1448
+ );
1449
+ const [before, after] = files.map((file) => file.region_statistics);
1450
+ const regions = [
1451
+ ...new Set([...Object.keys(before), ...Object.keys(after)]),
1452
+ ].filter(
1453
+ (region) =>
1454
+ !options.filterRegion ||
1455
+ options.filterRegion(region, after[region] ?? before[region]),
1456
+ );
1457
+ const rows = regions.map((region) => {
1458
+ const baseline = before[region]?.[metric],
1459
+ candidate = after[region]?.[metric];
1460
+ const missing = baseline == null || candidate == null;
1461
+ const delta = missing ? null : candidate - baseline;
1462
+ const value =
1463
+ missing || (options.comparison === "percent" && baseline === 0)
1464
+ ? null
1465
+ : options.comparison === "percent"
1466
+ ? (delta / baseline) * 100
1467
+ : delta;
1468
+ return {
1469
+ region,
1470
+ file: files[1].label ?? "run",
1471
+ baseline,
1472
+ candidate,
1473
+ value,
1474
+ reason: missing
1475
+ ? "Not measured in both runs"
1476
+ : value == null
1477
+ ? "Zero baseline: percentage undefined"
1478
+ : "",
1479
+ };
1480
+ });
1481
+ if (options.sortBy !== "name")
1482
+ rows.sort((a, b) => (b.value ?? -Infinity) - (a.value ?? -Infinity));
1483
+ else rows.sort((a, b) => a.region.localeCompare(b.region));
1484
+ const selected = rows.slice(0, options.topN ?? Infinity);
1485
+ const { baseLayout, axis } = palette(options);
1486
+ return {
1487
+ data: [
1488
+ {
1489
+ type: "bar",
1490
+ name: "Change",
1491
+ x: selected.map((row) => row.region),
1492
+ y: selected.map((row) => row.value),
1493
+ customdata: interactionData(selected, (row) => [
1494
+ row.baseline ?? null,
1495
+ row.candidate ?? null,
1496
+ row.reason,
1497
+ ]),
1498
+ hovertemplate:
1499
+ "%{x}<br>change: %{y:.6g}<br>baseline: %{customdata[0]}<br>candidate: %{customdata[1]}<br>%{customdata[2]}<extra></extra>",
1500
+ },
1501
+ ],
1502
+ diagnostics: selected
1503
+ .filter((row) => row.value == null)
1504
+ .map((row) => ({
1505
+ code: "undefined-comparison",
1506
+ region: row.region,
1507
+ message: row.reason,
1508
+ })),
1509
+ layout: withEmptyState(
1510
+ baseLayout({
1511
+ xaxis: axis({ title: "Region" }),
1512
+ yaxis: axis({
1513
+ title:
1514
+ options.comparison === "percent"
1515
+ ? "Change (%)"
1516
+ : `Change in ${SUMMARY_LABELS[metric]}`,
1517
+ }),
1518
+ }),
1519
+ selected.some((row) => row.value != null),
1520
+ ),
1521
+ };
1522
+ }
1523
+ return buildRegionSummaryFigure(payload, {
1524
+ orientation: "v",
1525
+ topN: Infinity,
1526
+ commonRegionsOnly: true,
1527
+ ...options,
1528
+ });
1529
+ }
1530
+
342
1531
  /** Build a Sankey call graph from either callgraph export shape.
343
1532
  *
344
1533
  * The compact export collapses repeated invocations into one node per region,
@@ -347,60 +1536,266 @@ export function buildRegionSummaryFigure(payload, options = {}) {
347
1536
  * recursion in full.
348
1537
  */
349
1538
  export function buildCallgraphFigure(payload, options = {}) {
1539
+ const { baseLayout } = palette(options);
350
1540
  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;
1541
+ if (!compact && !Array.isArray(payload?.calls))
1542
+ throw new TypeError(
1543
+ "Expected a scope-profiler plot-data payload with a regions or calls array.",
1544
+ );
1545
+ validateRecords("callgraph", payload, options);
1546
+ const diagnostics = [];
1547
+ const keep =
1548
+ typeof options.filterRegion === "function"
1549
+ ? options.filterRegion
1550
+ : () => true;
353
1551
  const weightKey = options.valueKey ?? "total_duration";
354
1552
  let nodes, links, unit;
355
1553
  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]));
1554
+ const regions = payload.regions.filter((region) =>
1555
+ keep(region.name, region),
1556
+ );
1557
+ const depths = uniqueMap(
1558
+ regions.map((region) => [region.name, region.depth]),
1559
+ "callgraph nodes",
1560
+ );
1561
+ const weights = new Map(
1562
+ regions.map((region) => [region.name, region[weightKey]]),
1563
+ );
359
1564
  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 }));
1565
+ links = (payload.edges ?? [])
1566
+ .filter(({ parent, child }) => depths.has(parent) && depths.has(child))
1567
+ .map((edge) => {
1568
+ const { parent, child } = edge;
1569
+ const incoming = (payload.edges ?? []).filter(
1570
+ (other) => other.child === child && other.parent !== child,
1571
+ ).length;
1572
+ let value = edge[weightKey] ?? edge.value;
1573
+ if (value == null && incoming === 1) {
1574
+ value = weights.get(child);
1575
+ if (value != null)
1576
+ diagnostics.push({
1577
+ code: "inferred-edge-weight",
1578
+ source: parent,
1579
+ target: child,
1580
+ message:
1581
+ "Weight inferred from child total with one incoming edge.",
1582
+ });
1583
+ }
1584
+ if (value == null) {
1585
+ diagnostics.push({
1586
+ code: "missing-edge-weight",
1587
+ source: parent,
1588
+ target: child,
1589
+ message:
1590
+ "Edge omitted: no measured weight and child total cannot be attributed.",
1591
+ });
1592
+ return null;
1593
+ }
1594
+ if (!Number.isFinite(value) || value < 0)
1595
+ throw new TypeError(
1596
+ `callgraph edge ${parent} -> ${child}: weight must be finite and nonnegative`,
1597
+ );
1598
+ return { source: parent, target: child, value };
1599
+ })
1600
+ .filter(Boolean);
362
1601
  unit = weightKey.endsWith("duration") ? " s" : "";
363
1602
  } else {
364
1603
  const calls = payload.calls.filter((call) => keep(call.name, call));
365
- const byId = new Map(calls.map((call) => [call.call_id, call]));
1604
+ const callKey = (call) =>
1605
+ JSON.stringify([call.file ?? "run", call.rank ?? 0, call.call_id]);
1606
+ const parentKey = (call) =>
1607
+ call.parent_id == null
1608
+ ? null
1609
+ : JSON.stringify([call.file ?? "run", call.rank ?? 0, call.parent_id]);
1610
+ validateParents(payload.calls, callKey, parentKey);
1611
+ const byId = new Map(calls.map((call) => [callKey(call), call]));
366
1612
  const counts = new Map();
367
1613
  for (const call of calls) {
368
- const parent = byId.get(call.parent_id);
1614
+ const parent = byId.get(parentKey(call));
369
1615
  if (!parent || call.depth <= parent.depth) continue;
370
- const key = `${parent.name}\u0000${call.name}`;
1616
+ const key = JSON.stringify([parent.name, call.name]);
371
1617
  counts.set(key, (counts.get(key) ?? 0) + 1);
372
1618
  }
373
1619
  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 }; });
1620
+ links = [...counts.entries()].map(([key, value]) => {
1621
+ const [source, target] = JSON.parse(key);
1622
+ return { source, target, value };
1623
+ });
375
1624
  unit = " calls";
376
1625
  }
1626
+ const adjacency = new Map(nodes.map((name) => [name, []]));
1627
+ links = links.filter((link) => {
1628
+ const pending = [link.target],
1629
+ visited = new Set();
1630
+ while (pending.length) {
1631
+ const node = pending.pop();
1632
+ if (node === link.source) {
1633
+ diagnostics.push({
1634
+ code: "cycle-edge-omitted",
1635
+ source: link.source,
1636
+ target: link.target,
1637
+ message:
1638
+ "Recursive relationship omitted from the Sankey; use the flame chart for full ancestry.",
1639
+ });
1640
+ return false;
1641
+ }
1642
+ if (visited.has(node)) continue;
1643
+ visited.add(node);
1644
+ pending.push(...adjacency.get(node));
1645
+ }
1646
+ adjacency.get(link.source).push(link.target);
1647
+ return true;
1648
+ });
377
1649
  const index = new Map(nodes.map((name, position) => [name, position]));
378
1650
  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) };
1651
+ const data = [
1652
+ {
1653
+ type: "sankey",
1654
+ visible: links.some((link) => link.value > 0),
1655
+ orientation: "h",
1656
+ node: {
1657
+ label: nodes,
1658
+ customdata: interactionData(nodes.map((name) => ({ name }))),
1659
+ color: nodes.map((name) => colors.get(name)),
1660
+ pad: 14,
1661
+ thickness: 16,
1662
+ line: { color: "rgba(0, 0, 0, 0.25)", width: 0.5 },
1663
+ },
1664
+ link: {
1665
+ customdata: links.map((link) => ({
1666
+ identity: {
1667
+ region: link.target,
1668
+ source: link.source,
1669
+ file: null,
1670
+ rank: null,
1671
+ call_id: null,
1672
+ },
1673
+ })),
1674
+ source: links.map((link) => index.get(link.source)),
1675
+ target: links.map((link) => index.get(link.target)),
1676
+ value: links.map((link) => link.value),
1677
+ hovertemplate: `%{source.label} \u2192 %{target.label}<br>%{value:.6g}${unit}<extra></extra>`,
1678
+ },
1679
+ },
1680
+ ];
1681
+ const layout = baseLayout({
1682
+ height: Math.max(320, 26 * nodes.length + 160),
1683
+ margin: { l: 24, r: 24, t: 24, b: 24 },
1684
+ });
1685
+ return {
1686
+ data,
1687
+ layout: withEmptyState(
1688
+ layout,
1689
+ links.some((link) => link.value > 0),
1690
+ ),
1691
+ diagnostics,
1692
+ };
387
1693
  }
388
1694
 
389
1695
  /** Build a grouped bar chart of one LIKWID hardware-counter metric. */
390
1696
  export function buildLikwidFigure(payload, options = {}) {
391
- const bars = filtered(values(payload, "bars"), options);
1697
+ const { baseLayout, axis } = palette(options);
1698
+ const bars = filtered(values(payload, "bars", "likwid"), options);
392
1699
  const series = groupBy(bars, (bar) => bar.series);
393
1700
  const regions = [...new Set(bars.map((bar) => bar.region))];
394
1701
  const colors = colorMap(series.keys(), options.colors ?? payload.colors);
395
1702
  const metric = options.metric ?? payload.metric ?? "value";
396
1703
  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>` };
1704
+ const byRegion = uniqueMap(
1705
+ rows.map((bar) => [bar.region, bar.value]),
1706
+ "likwid",
1707
+ );
1708
+ return {
1709
+ type: "bar",
1710
+ name,
1711
+ x: regions,
1712
+ y: regions.map((region) => byRegion.get(region) ?? null),
1713
+ customdata: interactionData(
1714
+ regions.map((region) => ({
1715
+ ...rows.find((row) => row.region === region),
1716
+ region,
1717
+ })),
1718
+ ),
1719
+ marker: {
1720
+ color: colors.get(name),
1721
+ line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 },
1722
+ },
1723
+ hovertemplate: `<b>%{x}</b><br>${label(name)}: %{y:.6g}<extra></extra>`,
1724
+ };
1725
+ });
1726
+ const layout = baseLayout({
1727
+ barmode: "group",
1728
+ height: Math.max(360, 34 * regions.length + 180),
1729
+ showlegend: series.size > 1,
1730
+ xaxis: axis({ tickangle: -35 }),
1731
+ yaxis: axis({
1732
+ title: metric,
1733
+ ...(options.logScale ? { type: "log" } : {}),
1734
+ }),
399
1735
  });
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
1736
  return { data, layout: withEmptyState(layout, bars.length > 0) };
402
1737
  }
403
1738
 
1739
+ /** Build a log-log roofline plot from per-region LIKWID-derived rates. */
1740
+ export function buildRooflineFigure(payload, options = {}) {
1741
+ const { theme, baseLayout, axis } = palette(options);
1742
+ const points = filtered(values(payload, "points", "roofline"), options);
1743
+ const series = groupBy(points, (point) => point.file ?? "run");
1744
+ const colors = colorMap(series.keys(), options.colors ?? payload.colors);
1745
+ const data = [...series].map(([name, rows]) => ({
1746
+ type: "scatter",
1747
+ mode: "markers",
1748
+ name,
1749
+ x: rows.map((point) => point.arithmetic_intensity_flops_per_byte),
1750
+ y: rows.map((point) => point.performance_gflops),
1751
+ marker: { color: colors.get(name), size: 9 },
1752
+ customdata: interactionData(rows, (point) => [
1753
+ label(point.region),
1754
+ point.rank,
1755
+ point.bandwidth_gbs,
1756
+ ]),
1757
+ hovertemplate:
1758
+ "<b>%{customdata[0]}</b> (rank %{customdata[1]})" +
1759
+ "<br>intensity: %{x:.6g} FLOP/byte" +
1760
+ "<br>performance: %{y:.6g} GFLOP/s" +
1761
+ "<br>bandwidth: %{customdata[2]:.6g} GB/s<extra></extra>",
1762
+ }));
1763
+ const roof = payload.roofline ?? [];
1764
+ if (!Array.isArray(roof)) throw new TypeError("roofline must be an array.");
1765
+ validateRecords("roofline", {
1766
+ points: roof.map((point) => ({ ...point, region: "ceiling" })),
1767
+ });
1768
+ if (roof.length) {
1769
+ data.push({
1770
+ type: "scatter",
1771
+ mode: "lines",
1772
+ name: payload.empirical_ceilings ? "empirical roof" : "roofline",
1773
+ x: roof.map((point) => point.arithmetic_intensity_flops_per_byte),
1774
+ y: roof.map((point) => point.performance_gflops),
1775
+ // The one line in the package that used to be a fixed near-black, which
1776
+ // is invisible on the dark theme this builder otherwise honours.
1777
+ line: { color: theme.text ?? theme.neutral, width: 2, dash: "dash" },
1778
+ hovertemplate:
1779
+ "intensity: %{x:.6g} FLOP/byte<br>ceiling: %{y:.6g} GFLOP/s<extra></extra>",
1780
+ });
1781
+ }
1782
+ const layout = baseLayout({
1783
+ title: {
1784
+ text: payload.empirical_ceilings
1785
+ ? "Roofline analysis (empirical ceilings)"
1786
+ : "Roofline analysis",
1787
+ ...(theme.text ? { font: { color: theme.text } } : {}),
1788
+ },
1789
+ // This is the one figure that carries a title, and baseLayout's 32px top
1790
+ // margin is sized for the seventeen that do not.
1791
+ margin: { l: 100, r: 24, t: 56, b: 64 },
1792
+ xaxis: axis({ title: "Arithmetic intensity [FLOP/byte]", type: "log" }),
1793
+ yaxis: axis({ title: "Attained performance [GFLOP/s]", type: "log" }),
1794
+ showlegend: series.size > 1 || roof.length > 0,
1795
+ });
1796
+ return { data, layout: withEmptyState(layout, points.length > 0) };
1797
+ }
1798
+
404
1799
  export const PLOT_DATA_FORMAT = "scope-profiler-plot-data";
405
1800
  export const SUPPORTED_FORMAT_VERSION = 1;
406
1801
 
@@ -417,22 +1812,48 @@ export const PLOT_BUILDERS = {
417
1812
  speedup: buildSpeedupFigure,
418
1813
  weak_scaling: buildSpeedupFigure,
419
1814
  scaling_efficiency: buildSpeedupFigure,
1815
+ weak_scaling_efficiency: buildSpeedupFigure,
420
1816
  rank_heatmap: buildRankHeatmapFigure,
421
1817
  histogram: buildHistogramFigure,
422
1818
  imbalance: buildImbalanceFigure,
423
1819
  likwid: buildLikwidFigure,
1820
+ roofline: buildRooflineFigure,
424
1821
  region_statistics: buildRegionSummaryFigure,
425
1822
  };
426
1823
 
1824
+ const PLOT_ARRAYS = {
1825
+ gantt: "intervals",
1826
+ density: "points",
1827
+ flame: "calls",
1828
+ flame_chart: "calls",
1829
+ flame_graph: "calls",
1830
+ durations: "bars",
1831
+ timeseries: "points",
1832
+ speedup: "points",
1833
+ weak_scaling: "points",
1834
+ scaling_efficiency: "points",
1835
+ weak_scaling_efficiency: "points",
1836
+ rank_heatmap: "points",
1837
+ histogram: "bins",
1838
+ imbalance: "points",
1839
+ likwid: "bars",
1840
+ roofline: "points",
1841
+ region_statistics: "files",
1842
+ };
1843
+
427
1844
  /** Guess the plot kind of a payload written before the envelope existed. */
428
1845
  export function inferPlotKind(payload) {
429
1846
  if (!payload || typeof payload !== "object") return undefined;
430
1847
  if (Array.isArray(payload.intervals)) return "gantt";
431
1848
  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";
1849
+ if (Array.isArray(payload.files) && payload.files[0]?.region_statistics)
1850
+ return "region_statistics";
1851
+ if (Array.isArray(payload.regions) && Array.isArray(payload.edges))
1852
+ return "callgraph";
1853
+ if (Array.isArray(payload.bars))
1854
+ return payload.bars[0]?.series != null ? "likwid" : "durations";
1855
+ if (Array.isArray(payload.calls))
1856
+ return payload.calls[0]?.parent_id !== undefined ? "callgraph" : "flame";
436
1857
  const point = Array.isArray(payload.points) ? payload.points[0] : undefined;
437
1858
  if (!point) return undefined;
438
1859
  if (point.bin_start_seconds != null) return "density";
@@ -441,10 +1862,66 @@ export function inferPlotKind(payload) {
441
1862
  if (point.speedup != null) return "speedup";
442
1863
  if (point.normalized_runtime != null) return "weak_scaling";
443
1864
  if (point.efficiency != null) return "scaling_efficiency";
1865
+ if (point.arithmetic_intensity_flops_per_byte != null) return "roofline";
444
1866
  if (point.rank != null) return "rank_heatmap";
445
1867
  return undefined;
446
1868
  }
447
1869
 
1870
+ /** Validate a plot-data envelope and return its resolved plot kind.
1871
+ *
1872
+ * The validator deliberately requires only the array each builder consumes:
1873
+ * exporter versions may add fields without breaking existing dashboards.
1874
+ */
1875
+ export function validatePlotData(payload, options = {}) {
1876
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
1877
+ throw new TypeError("plot-data must be an object.");
1878
+ if (payload.format != null && payload.format !== PLOT_DATA_FORMAT)
1879
+ throw new TypeError(
1880
+ `Expected a ${PLOT_DATA_FORMAT} document, got ${JSON.stringify(payload.format)}.`,
1881
+ );
1882
+ if (
1883
+ Object.hasOwn(payload, "format_version") &&
1884
+ (!Number.isInteger(payload.format_version) || payload.format_version < 1)
1885
+ )
1886
+ throw new TypeError("format_version must be a positive integer.");
1887
+ if (
1888
+ typeof payload.format_version === "number" &&
1889
+ payload.format_version > SUPPORTED_FORMAT_VERSION
1890
+ )
1891
+ throw new TypeError(
1892
+ `Plot-data format version ${payload.format_version} is newer than this package supports (${SUPPORTED_FORMAT_VERSION}); upgrade @scope-profiler/plotly.`,
1893
+ );
1894
+ const kind = options.plot ?? payload.plot ?? inferPlotKind(payload);
1895
+ if (!kind || !Object.hasOwn(PLOT_BUILDERS, kind))
1896
+ throw new TypeError(
1897
+ kind
1898
+ ? `No figure builder for plot kind ${JSON.stringify(kind)}.`
1899
+ : "Could not determine the plot kind; pass options.plot.",
1900
+ );
1901
+ if (kind === "callgraph") {
1902
+ if (!Array.isArray(payload.calls) && !Array.isArray(payload.regions))
1903
+ throw new TypeError(
1904
+ 'Plot kind "callgraph" requires calls or regions data.',
1905
+ );
1906
+ } else if (!Array.isArray(payload[PLOT_ARRAYS[kind]])) {
1907
+ throw new TypeError(
1908
+ `Plot kind ${JSON.stringify(kind)} requires a ${PLOT_ARRAYS[kind]} array.`,
1909
+ );
1910
+ }
1911
+ const recordKind = kind.startsWith("flame")
1912
+ ? "flame"
1913
+ : Object.hasOwn(SCALING_KINDS, kind)
1914
+ ? "scaling"
1915
+ : kind;
1916
+ validateRecords(recordKind, payload, {
1917
+ ...options,
1918
+ ...(recordKind === "scaling"
1919
+ ? { yField: scalingKind(payload, { ...options, plot: kind }).yKey }
1920
+ : {}),
1921
+ });
1922
+ return kind;
1923
+ }
1924
+
448
1925
  /** Build the right figure for any plot-data document, without naming a builder.
449
1926
  *
450
1927
  * Dispatches on the document's own `plot` field, falling back to the payload
@@ -452,17 +1929,57 @@ export function inferPlotKind(payload) {
452
1929
  * kind.
453
1930
  */
454
1931
  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.");
1932
+ const kind = validatePlotData(payload, options);
1933
+ const builder = PLOT_BUILDERS[kind];
461
1934
  return builder(payload, { plot: kind, ...options });
462
1935
  }
463
1936
 
1937
+ const RENDER_CONFIG = { responsive: true, displaylogo: false };
1938
+
464
1939
  /** Render a figure with any Plotly-compatible bundle. */
465
1940
  export function renderFigure(plotly, element, figure, config = {}) {
466
- if (!plotly || typeof plotly.newPlot !== "function") throw new TypeError("renderFigure requires a Plotly-compatible object with newPlot().");
467
- return plotly.newPlot(element, figure.data, figure.layout, { responsive: true, displaylogo: false, ...config });
1941
+ if (!plotly || typeof plotly.newPlot !== "function")
1942
+ throw new TypeError(
1943
+ "renderFigure requires a Plotly-compatible object with newPlot().",
1944
+ );
1945
+ return plotly.newPlot(element, figure.data, figure.layout, {
1946
+ ...RENDER_CONFIG,
1947
+ ...config,
1948
+ });
1949
+ }
1950
+
1951
+ /** Redraw a figure into an element that already holds one.
1952
+ *
1953
+ * `renderFigure` builds the plot from scratch, which throws away the viewer's
1954
+ * zoom and pan. A theme toggle, a changed filter or a new metric rebuilds the
1955
+ * figure but should not move the view, so route those through here: it uses
1956
+ * Plotly's `react`, and falls back to `newPlot` for a bundle without one.
1957
+ */
1958
+ export function updateFigure(plotly, element, figure, config = {}) {
1959
+ const draw =
1960
+ plotly && typeof plotly.react === "function"
1961
+ ? plotly.react
1962
+ : plotly?.newPlot;
1963
+ if (typeof draw !== "function")
1964
+ throw new TypeError(
1965
+ "updateFigure requires a Plotly-compatible object with react() or newPlot().",
1966
+ );
1967
+ return draw.call(plotly, element, figure.data, figure.layout, {
1968
+ ...RENDER_CONFIG,
1969
+ ...config,
1970
+ });
1971
+ }
1972
+
1973
+ /** Release Plotly event handlers and rendering resources on unmount. */
1974
+ export function disposeFigure(plotly, element) {
1975
+ if (typeof plotly?.purge !== "function")
1976
+ throw new TypeError("disposeFigure requires purge().");
1977
+ return plotly.purge(element);
1978
+ }
1979
+
1980
+ /** Isolated defaults for a dashboard, without changing the global theme. */
1981
+ export function createFigureBuilder(defaults = {}) {
1982
+ const snapshot = mergeLayout({}, { theme: resolveTheme(), ...defaults });
1983
+ return (payload, options = {}) =>
1984
+ buildFigure(payload, mergeLayout(snapshot, options));
468
1985
  }