@scope-profiler/plotly 0.1.1 → 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,179 +1,1985 @@
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);
22
347
  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 },
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()) {
356
+ return {
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;
447
+ }
448
+
449
+ // One pass instead of a filter per series. A large trace has both many rows and
450
+ // many regions, so filtering the whole array once per series is quadratic: on
451
+ // 200k intervals over 400 regions that is the difference between ~690 ms and
452
+ // ~8 ms. Map preserves first-appearance order, which is the order the series
453
+ // are drawn and listed in.
454
+ function groupBy(rows, key) {
455
+ const groups = new Map();
456
+ for (const row of rows) {
457
+ const name = key(row);
458
+ const bucket = groups.get(name);
459
+ if (bucket) bucket.push(row);
460
+ else groups.set(name, [row]);
461
+ }
462
+ return groups;
45
463
  }
46
464
 
47
- /** Build a multi-run, multi-rank timeline. Regions are colored; file/rank are lanes. */
465
+ // Several payloads carry rows from more than one run. Dropping that column
466
+ // merges runs into one series -- silently, and wrongly -- so every builder that
467
+ // can see two runs keys its series by file as well, and says so in the label.
468
+ function runAware(rows) {
469
+ const files = new Set(rows.map((row) => row.file ?? "run"));
470
+ const multi = files.size > 1;
471
+ return {
472
+ multi,
473
+ files: [...files],
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,
478
+ };
479
+ }
480
+
481
+ // Region colour stays with the region across runs (and honours the payload's
482
+ // own colours), so a run is told apart by marker shape or bar pattern instead.
483
+ const FILE_SYMBOLS = ["circle", "square", "diamond", "triangle-up", "cross"];
484
+ const FILE_PATTERNS = ["", "/", "\\", "x", "-"];
485
+
486
+ /** Build a multi-run, multi-rank timeline: a lane per region and rank.
487
+ *
488
+ * A lane per rank alone cannot show a nested profile: every region of a rank
489
+ * lands on one row, and the outermost region -- the session, typically -- is
490
+ * drawn over everything inside it. One lane per region and rank is also what
491
+ * `scope-profiler plot gantt` draws, so the two agree. Pass
492
+ * `{ laneBy: "rank" }` for the compact one-row-per-rank view, which suits a
493
+ * flat profile compared across many ranks.
494
+ */
48
495
  export function buildGanttFigure(payload, options = {}) {
49
- const intervals = filtered(values(payload, "intervals"), options);
50
- const regions = [...new Set(intervals.map((row) => row.region))];
51
- const colors = colorMap(regions, options.colors ?? payload.colors);
52
- const lanes = [...new Set(intervals.map((row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`))];
53
- const data = regions.map((region) => {
54
- const rows = intervals.filter((row) => row.region === region);
55
- return { type: "bar", orientation: "h", name: region,
56
- y: rows.map((row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`),
57
- x: rows.map((row) => row.end_seconds - row.start_seconds), base: rows.map((row) => row.start_seconds),
58
- marker: { color: colors.get(region), line: { color: "rgba(0, 0, 0, 0.28)", width: 0.5 } },
59
- customdata: rows.map((row) => [row.file, row.rank]),
60
- hovertemplate: `<b>${region}</b><br>%{customdata[0]} / rank %{customdata[1]}<br>start: %{base:.6g} s<br>duration: %{x:.6g} s<extra></extra>`,
496
+ const { baseLayout, axis } = palette(options);
497
+ const intervals = filtered(values(payload, "intervals", "gantt"), options);
498
+ const byRegion = groupBy(intervals, (row) => row.region);
499
+ const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
500
+ const multi = new Set(intervals.map((row) => row.file ?? "run")).size > 1;
501
+ const rankLane = (row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`;
502
+ // Matching `plot gantt`'s own lane label, extended by the run only when the
503
+ // payload holds more than one.
504
+ const regionLane = (row) =>
505
+ `${multi ? `${row.file ?? "run"} / ` : ""}${row.region} (rank ${row.rank ?? 0})`;
506
+ const laneOf = options.laneBy === "rank" ? rankLane : regionLane;
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]));
513
+ const data = [...byRegion].map(([region, rows]) => {
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>`,
61
533
  };
62
534
  });
63
- const layout = baseLayout({ barmode: "overlay", height: Math.max(280, 48 * lanes.length + 150), showlegend: regions.length > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ categoryorder: "array", categoryarray: lanes, autorange: "reversed", showgrid: false }), ...options.layout });
535
+ const byRank = options.laneBy === "rank";
536
+ const perLane = byRank ? 48 : 26;
537
+ // Region lanes read bottom-up, so the first region -- the enclosing one --
538
+ // sits at the bottom, as `scope-profiler plot gantt` draws it. Rank lanes
539
+ // keep rank 0 on top, like the rank heatmap.
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
+ });
64
553
  return { data, layout: withEmptyState(layout, intervals.length > 0) };
65
554
  }
66
555
 
67
556
  /** Build an icicle flame chart using scope-profiler's explicit call IDs. */
68
557
  export function buildFlameFigure(payload, options = {}) {
69
- const calls = values(payload, "calls");
558
+ const { theme, baseLayout } = palette(options);
559
+ const allCalls = values(payload, "calls", "flame");
560
+ const calls = filtered(allCalls, options);
70
561
  const regions = [...new Set(calls.map((call) => call.region))];
71
562
  const colors = colorMap(regions, options.colors ?? payload.colors);
72
563
  const root = "scope-profiler-root";
73
- const ids = [root], labels = [options.rootLabel ?? "All calls"], parents = [""], markerColors = [NEUTRAL], hovertext = ["All calls"];
74
- const roots = calls.filter((call) => call.parent_call_id == null);
75
- const rootDuration = roots.reduce((sum, call) => sum + (call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds), 0);
76
- const callKey = (call) => `${call.file ?? "run"}:${call.rank ?? 0}:${call.call_id}`;
77
- for (const call of calls) {
78
- ids.push(callKey(call)); labels.push(call.region);
79
- parents.push(call.parent_call_id == null ? root : `${call.file ?? "run"}:${call.rank ?? 0}:${call.parent_call_id}`);
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;
572
+ // A filter can remove a call whose children survive; re-parent each survivor
573
+ // onto its nearest surviving ancestor so the icicle stays a single tree
574
+ // instead of silently dropping the orphans.
575
+ const byKey = validateParents(allCalls, callKey, parentKey);
576
+ const kept = new Set(calls.map(callKey));
577
+ const anchor = (call) => {
578
+ let key = parentKey(call);
579
+ while (key != null && !kept.has(key))
580
+ key = byKey.has(key) ? parentKey(byKey.get(key)) : null;
581
+ return key ?? root;
582
+ };
583
+ const anchors = calls.map(anchor);
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"];
594
+ calls.forEach((call, index) => {
595
+ ids.push(callKey(call));
596
+ labels.push(call.region);
597
+ parents.push(anchors[index]);
80
598
  markerColors.push(colors.get(call.region));
81
- hovertext.push(`<b>${call.region}</b><br>${call.file ?? "run"} / rank ${call.rank ?? 0}<br>start: ${call.start_seconds.toPrecision(6)} s<br>inclusive: ${(call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds).toPrecision(6)} s`);
82
- }
83
- const layout = baseLayout({ height: 500, margin: { l: 24, r: 24, t: 24, b: 24 }, ...options.layout });
84
- return { data: [{ type: "icicle", ids, labels, parents, values: [rootDuration, ...calls.map((call) => call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds)], branchvalues: "total", tiling: { orientation: "h" }, marker: { colors: markerColors, line: { color: "rgba(255, 255, 255, 0.55)", width: 1 } }, hovertext, hoverinfo: "text" }], layout: withEmptyState(layout, calls.length > 0) };
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 },
606
+ });
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
+ };
85
628
  }
86
629
 
87
630
  export function buildDurationsFigure(payload, options = {}) {
88
- const metric = options.metric ?? payload.options?.metric ?? payload.metrics?.[0] ?? "total";
89
- 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
+ );
90
640
  // A stacked-children export is already decomposed into segments. Preserve
91
641
  // that decomposition instead of letting duplicate region rows overwrite.
92
642
  const stacked = bars.some((bar) => bar.segment != null);
93
- const groups = [...new Set(bars.map((bar) => stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`))];
94
- const regions = [...new Set(bars.map((bar) => bar.region))];
95
- const colors = colorMap(groups, options.colors ?? payload.colors);
96
- const data = groups.map((group) => {
97
- const rows = bars.filter((bar) => (stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`) === group);
98
- const byRegion = new Map(rows.map((bar) => [bar.region, bar.value_seconds]));
99
- return { type: "bar", name: group, x: regions, y: regions.map((region) => byRegion.get(region) ?? null), marker: { color: colors.get(group), line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 } }, hovertemplate: `<b>%{x}</b><br>${group}: %{y:.6g} s<extra></extra>` };
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);
662
+ const data = [...groups].map(([group, rows]) => {
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)` }),
100
691
  });
101
- const layout = baseLayout({ barmode: stacked ? "stack" : "group", height: Math.max(360, 34 * regions.length + 180), showlegend: groups.length > 1, xaxis: axis({ tickangle: -35 }), yaxis: axis({ title: `${metric} duration (s)` }), ...options.layout });
102
692
  return { data, layout: withEmptyState(layout, bars.length > 0) };
103
693
  }
104
694
 
695
+ // The three scaling exports differ only in the y column they carry and the
696
+ // shape of their ideal line, so one builder serves all of them.
697
+ const SCALING_KINDS = {
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
+ },
730
+ };
731
+
732
+ function scalingKind(payload, options) {
733
+ const named = options.plot ?? payload?.plot;
734
+ if (named && SCALING_KINDS[named]) return SCALING_KINDS[named];
735
+ if (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
+ );
746
+ }
747
+ const row = payload?.points?.[0];
748
+ return (
749
+ (row &&
750
+ Object.values(SCALING_KINDS).find((kind) => row[kind.yKey] != null)) ??
751
+ SCALING_KINDS.speedup
752
+ );
753
+ }
754
+
755
+ /** Build a scaling curve: speedup, weak scaling, or parallel efficiency. */
105
756
  export function buildSpeedupFigure(payload, options = {}) {
757
+ const { theme, baseLayout, axis } = palette(options);
758
+ const kind = scalingKind(payload, options);
106
759
  const xField = options.xField ?? payload.options?.x_field ?? "num_ranks";
107
- const points = filtered(values(payload, "points"), options);
108
- const regions = [...new Set(points.map((point) => point.region))];
109
- const colors = colorMap(regions, options.colors ?? payload.colors);
110
- const xValues = [...new Set(points.map((point) => point[xField]))].sort((a, b) => typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b)));
760
+ const points = filtered(
761
+ values(payload, "points", "scaling", { ...options, yField: kind.yKey }),
762
+ options,
763
+ );
764
+ const byRegion = groupBy(points, (point) => point.region);
765
+ const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
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
+ );
772
+ const order = new Map(xValues.map((value, position) => [value, position]));
111
773
  const numeric = xValues.every((value) => typeof value === "number");
112
- const data = regions.map((region) => { const rows = points.filter((point) => point.region === region).sort((a, b) => xValues.indexOf(a[xField]) - xValues.indexOf(b[xField])); return { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row[xField]), y: rows.map((row) => row.speedup), line: { color: colors.get(region), width: 2.4 }, marker: { color: colors.get(region), size: 7 }, hovertemplate: `<b>%{x}</b><br>${region}: %{y:.3g}×<extra></extra>` }; });
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
+ });
113
790
  const baseline = payload.options?.baseline ?? xValues[0];
114
- if (numeric && options.ideal !== false) data.push({ type: "scatter", mode: "lines", name: "Ideal speedup", x: xValues, y: xValues.map((value) => value / baseline), line: { color: "#777", dash: "dash" }, hoverinfo: "skip" });
115
- const layout = baseLayout({ height: 420, showlegend: data.length > 1, xaxis: axis({ title: payload.options?.x_label ?? xField, tickvals: xValues }), yaxis: axis({ title: "Speedup", rangemode: "tozero" }), ...options.layout });
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
+ });
116
817
  return { data, layout: withEmptyState(layout, points.length > 0) };
117
818
  }
118
819
 
820
+ /** Build a weak-scaling curve (runtime normalized to the baseline scale). */
821
+ export function buildWeakScalingFigure(payload, options = {}) {
822
+ return buildSpeedupFigure(payload, { ...options, plot: "weak_scaling" });
823
+ }
824
+
825
+ /** Build a parallel-efficiency curve (measured speedup over ideal speedup). */
826
+ export function buildScalingEfficiencyFigure(payload, options = {}) {
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
+ });
844
+ }
845
+
119
846
  /** Build mean call duration over time, one trace per region. */
120
847
  export function buildDurationTimeseriesFigure(payload, options = {}) {
121
- const points = filtered(values(payload, "points"), options);
122
- const regions = [...new Set(points.map((point) => point.region))];
123
- const colors = colorMap(regions, options.colors ?? payload.colors);
124
- const data = regions.map((region) => {
125
- const rows = points.filter((point) => point.region === region).sort((a, b) => a.time_seconds - b.time_seconds);
126
- return { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row.time_seconds), y: rows.map((row) => row.mean_duration_seconds), line: { color: colors.get(region), width: 2.2 }, marker: { color: colors.get(region), size: 5 }, customdata: rows.map((row) => [row.min_duration_seconds, row.max_duration_seconds, row.call_index]), hovertemplate: `<b>${region}</b><br>time: %{x:.6g} s<br>mean: %{y:.6g} s<br>min–max: %{customdata[0]:.4g}–%{customdata[1]:.4g} s<extra></extra>` };
848
+ const { baseLayout, axis } = palette(options);
849
+ const points = filtered(values(payload, "points", "timeseries"), options);
850
+ const runs = runAware(points);
851
+ const colors = colorMap(
852
+ points.map((point) => point.region),
853
+ options.colors ?? payload.colors,
854
+ );
855
+ const series = groupBy(points, runs.key);
856
+ const data = [...series].map(([, unsorted]) => {
857
+ const rows = [...unsorted].sort((a, b) => a.time_seconds - b.time_seconds);
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)" }),
127
933
  });
128
- const layout = baseLayout({ height: 420, showlegend: regions.length > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ title: "Mean call duration (s)" }), ...options.layout });
129
934
  return { data, layout: withEmptyState(layout, points.length > 0) };
130
935
  }
131
936
 
132
937
  /** Build duration distributions from histogram bin records. */
133
938
  export function buildHistogramFigure(payload, options = {}) {
134
- const bins = filtered(values(payload, "bins"), options);
135
- const regions = [...new Set(bins.map((bin) => bin.region))];
136
- const colors = colorMap(regions, options.colors ?? payload.colors);
137
- const data = regions.map((region) => {
138
- const rows = bins.filter((bin) => bin.region === region).sort((a, b) => a.bin_center_seconds - b.bin_center_seconds);
139
- return { type: "bar", name: region, x: rows.map((bin) => bin.bin_center_seconds), y: rows.map((bin) => bin.count), width: rows.map((bin) => bin.bin_high_seconds - bin.bin_low_seconds), marker: { color: colors.get(region), line: { color: "rgba(0, 0, 0, 0.2)", width: 0.5 } }, hovertemplate: `<b>${region}</b><br>%{x:.6g} s: %{y} calls<extra></extra>` };
939
+ const { baseLayout, axis } = palette(options);
940
+ const bins = filtered(values(payload, "bins", "histogram"), options);
941
+ const runs = runAware(bins);
942
+ const colors = colorMap(
943
+ bins.map((bin) => bin.region),
944
+ options.colors ?? payload.colors,
945
+ );
946
+ const series = groupBy(bins, runs.key);
947
+ const data = [...series].map(([, unsorted]) => {
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" }),
140
978
  });
141
- const layout = baseLayout({ barmode: "overlay", height: 400, showlegend: regions.length > 1, xaxis: axis({ title: "Call duration (s)" }), yaxis: axis({ title: "Calls" }), ...options.layout });
142
979
  return { data, layout: withEmptyState(layout, bins.length > 0) };
143
980
  }
144
981
 
145
982
  /** Build a rank × region heatmap from duration records. */
146
983
  export function buildRankHeatmapFigure(payload, options = {}) {
147
- const points = filtered(values(payload, "points"), options);
148
- const regions = [...new Set(points.map((point) => point.region))];
149
- const ranks = [...new Set(points.map((point) => point.rank))].sort((a, b) => a - b);
150
- const inferredValueKey = points[0] ? Object.keys(points[0]).find((key) => key.endsWith("_duration_seconds")) : undefined;
151
- const valueKey = options.valueKey ?? inferredValueKey ?? "total_duration_seconds";
152
- const byCell = new Map(points.map((point) => [`${point.rank}:${point.region}`, point[valueKey]]));
153
- const data = [{ type: "heatmap", x: regions, y: ranks.map(String), z: ranks.map((rank) => regions.map((region) => byCell.get(`${rank}:${region}`) ?? null)), colorscale: options.colorscale ?? "Viridis", colorbar: { title: "Seconds" }, hovertemplate: "rank %{y}<br>%{x}: %{z:.6g} s<extra></extra>" }];
154
- const layout = baseLayout({ height: Math.max(320, 44 * ranks.length + 150), xaxis: axis({ title: "Region" }), yaxis: axis({ title: "Rank", autorange: "reversed", showgrid: false }), ...options.layout });
984
+ const { baseLayout, axis } = palette(options);
985
+ const points = filtered(
986
+ values(payload, "points", "rank_heatmap", options),
987
+ options,
988
+ );
989
+ const multi = new Set(points.map((point) => point.file ?? "run")).size > 1;
990
+ // A lane per run and rank. Keying cells by rank alone silently let a second
991
+ // run overwrite the first, showing one run's numbers under both labels.
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
+ });
155
1046
  return { data, layout: withEmptyState(layout, points.length > 0) };
156
1047
  }
157
1048
 
158
1049
  /** Build per-rank duration lines, with a dashed rank mean for each region. */
159
1050
  export function buildImbalanceFigure(payload, options = {}) {
160
- const points = filtered(values(payload, "points"), options);
161
- const regions = [...new Set(points.map((point) => point.region))];
162
- const colors = colorMap(regions, options.colors ?? payload.colors);
163
- const data = regions.flatMap((region) => {
164
- const rows = points.filter((point) => point.region === region).sort((a, b) => a.rank - b.rank);
1051
+ const { baseLayout, axis } = palette(options);
1052
+ const points = filtered(values(payload, "points", "imbalance"), options);
1053
+ const runs = runAware(points);
1054
+ const colors = colorMap(
1055
+ points.map((point) => point.region),
1056
+ options.colors ?? payload.colors,
1057
+ );
1058
+ // The mean is computed per run, so a run gets its own line and its own mean;
1059
+ // pooling them drew one zig-zagging series that revisited every rank.
1060
+ const series = groupBy(points, runs.key);
1061
+ const data = [...series].flatMap(([, unsorted]) => {
1062
+ const rows = [...unsorted].sort((a, b) => a.rank - b.rank);
1063
+ const region = rows[0].region,
1064
+ name = runs.label(rows[0]);
165
1065
  const color = colors.get(region);
1066
+ const symbol =
1067
+ FILE_SYMBOLS[
1068
+ runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length
1069
+ ];
166
1070
  return [
167
- { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row.rank), y: rows.map((row) => row.value_seconds), line: { color, width: 2.2 }, marker: { color, size: 7 }, hovertemplate: `<b>${region}</b><br>rank %{x}: %{y:.6g} s<extra></extra>` },
168
- { type: "scatter", mode: "lines", name: `${region} mean`, x: rows.map((row) => row.rank), y: rows.map((row) => row.mean_over_ranks_seconds), line: { color, dash: "dot", width: 1.3 }, hoverinfo: "skip", showlegend: false },
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
+ },
169
1095
  ];
170
1096
  });
171
- const layout = baseLayout({ height: 420, showlegend: regions.length > 1, xaxis: axis({ title: "Rank", dtick: 1 }), yaxis: axis({ title: `${payload.metric ?? "Duration"} (s)` }), ...options.layout });
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
+ });
1103
+ return { data, layout: withEmptyState(layout, points.length > 0) };
1104
+ }
1105
+
1106
+ /** Build a timeline-occupancy heatmap from binned density records. */
1107
+ export function buildDensityFigure(payload, options = {}) {
1108
+ const { baseLayout, axis } = palette(options);
1109
+ const points = filtered(values(payload, "points", "density"), options);
1110
+ const lane = (point) => `${point.file ?? "run"} / ${point.region}`;
1111
+ const lanes = [...new Set(points.map(lane))];
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);
1119
+ // Occupancy is the share of the bin the region was inside, which compares
1120
+ // across runs of different length; raw seconds stay available via valueKey.
1121
+ const asFraction = (options.valueKey ?? "occupancy") === "occupancy";
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
+ };
1132
+ const span = (point) => point.bin_end_seconds - point.bin_start_seconds;
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
+ });
1268
+ return { data, layout: withEmptyState(layout, points.length > 0) };
1269
+ }
1270
+
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
+ }
1314
+
1315
+ /** Build a ranked region bar chart from a region_statistics document. */
1316
+ export function buildRegionSummaryFigure(payload, options = {}) {
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
+ );
1337
+ const limit = options.topN ?? 20;
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;
1351
+ const totals = new Map();
1352
+ for (const file of files) {
1353
+ for (const [region, stats] of Object.entries(
1354
+ file.region_statistics ?? {},
1355
+ )) {
1356
+ if (!keep(region, stats) || !common(region)) continue;
1357
+ totals.set(region, (totals.get(region) ?? 0) + (stats[metric] ?? 0));
1358
+ }
1359
+ }
1360
+ // Rank by the pooled metric so the slowest regions lead, then keep the head
1361
+ // of the list: a long run has more regions than a bar chart can carry.
1362
+ const regions = [...totals.entries()]
1363
+ .sort((a, b) => b[1] - a[1])
1364
+ .slice(0, limit)
1365
+ .map(([region]) => region);
1366
+ const labels = files.map((file) => file.label ?? "run");
1367
+ const colors = colorMap(labels, options.colors ?? payload.colors);
1368
+ const unit = metric === "count" ? "" : " s";
1369
+ const values_ = (stats) =>
1370
+ regions.map((region) => stats[region]?.[metric] ?? null);
1371
+ const data = files.map((file, index) => {
1372
+ const stats = file.region_statistics ?? {};
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,
1417
+ });
1418
+ return { data, layout: withEmptyState(layout, regions.length > 0) };
1419
+ }
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
+
1531
+ /** Build a Sankey call graph from either callgraph export shape.
1532
+ *
1533
+ * The compact export collapses repeated invocations into one node per region,
1534
+ * which can turn recursion into a cycle; a Sankey cannot draw one, so links
1535
+ * that do not increase call depth are dropped. Use the flame chart to see
1536
+ * recursion in full.
1537
+ */
1538
+ export function buildCallgraphFigure(payload, options = {}) {
1539
+ const { baseLayout } = palette(options);
1540
+ const compact = Array.isArray(payload?.regions);
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;
1551
+ const weightKey = options.valueKey ?? "total_duration";
1552
+ let nodes, links, unit;
1553
+ if (compact) {
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
+ );
1564
+ nodes = regions.map((region) => region.name);
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);
1601
+ unit = weightKey.endsWith("duration") ? " s" : "";
1602
+ } else {
1603
+ const calls = payload.calls.filter((call) => keep(call.name, 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]));
1612
+ const counts = new Map();
1613
+ for (const call of calls) {
1614
+ const parent = byId.get(parentKey(call));
1615
+ if (!parent || call.depth <= parent.depth) continue;
1616
+ const key = JSON.stringify([parent.name, call.name]);
1617
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1618
+ }
1619
+ nodes = [...new Set(calls.map((call) => call.name))];
1620
+ links = [...counts.entries()].map(([key, value]) => {
1621
+ const [source, target] = JSON.parse(key);
1622
+ return { source, target, value };
1623
+ });
1624
+ unit = " calls";
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
+ });
1649
+ const index = new Map(nodes.map((name, position) => [name, position]));
1650
+ const colors = colorMap(nodes, options.colors ?? payload.colors);
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
+ };
1693
+ }
1694
+
1695
+ /** Build a grouped bar chart of one LIKWID hardware-counter metric. */
1696
+ export function buildLikwidFigure(payload, options = {}) {
1697
+ const { baseLayout, axis } = palette(options);
1698
+ const bars = filtered(values(payload, "bars", "likwid"), options);
1699
+ const series = groupBy(bars, (bar) => bar.series);
1700
+ const regions = [...new Set(bars.map((bar) => bar.region))];
1701
+ const colors = colorMap(series.keys(), options.colors ?? payload.colors);
1702
+ const metric = options.metric ?? payload.metric ?? "value";
1703
+ const data = [...series].map(([name, rows]) => {
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
+ }),
1735
+ });
1736
+ return { data, layout: withEmptyState(layout, bars.length > 0) };
1737
+ }
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
+ });
172
1796
  return { data, layout: withEmptyState(layout, points.length > 0) };
173
1797
  }
174
1798
 
1799
+ export const PLOT_DATA_FORMAT = "scope-profiler-plot-data";
1800
+ export const SUPPORTED_FORMAT_VERSION = 1;
1801
+
1802
+ /** Builder for each `plot` kind written by `export plot-data --format json`. */
1803
+ export const PLOT_BUILDERS = {
1804
+ gantt: buildGanttFigure,
1805
+ density: buildDensityFigure,
1806
+ flame: buildFlameFigure,
1807
+ flame_chart: buildFlameFigure,
1808
+ flame_graph: buildFlameFigure,
1809
+ callgraph: buildCallgraphFigure,
1810
+ durations: buildDurationsFigure,
1811
+ timeseries: buildDurationTimeseriesFigure,
1812
+ speedup: buildSpeedupFigure,
1813
+ weak_scaling: buildSpeedupFigure,
1814
+ scaling_efficiency: buildSpeedupFigure,
1815
+ weak_scaling_efficiency: buildSpeedupFigure,
1816
+ rank_heatmap: buildRankHeatmapFigure,
1817
+ histogram: buildHistogramFigure,
1818
+ imbalance: buildImbalanceFigure,
1819
+ likwid: buildLikwidFigure,
1820
+ roofline: buildRooflineFigure,
1821
+ region_statistics: buildRegionSummaryFigure,
1822
+ };
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
+
1844
+ /** Guess the plot kind of a payload written before the envelope existed. */
1845
+ export function inferPlotKind(payload) {
1846
+ if (!payload || typeof payload !== "object") return undefined;
1847
+ if (Array.isArray(payload.intervals)) return "gantt";
1848
+ if (Array.isArray(payload.bins)) return "histogram";
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";
1857
+ const point = Array.isArray(payload.points) ? payload.points[0] : undefined;
1858
+ if (!point) return undefined;
1859
+ if (point.bin_start_seconds != null) return "density";
1860
+ if (point.mean_duration_seconds != null) return "timeseries";
1861
+ if (point.mean_over_ranks_seconds != null) return "imbalance";
1862
+ if (point.speedup != null) return "speedup";
1863
+ if (point.normalized_runtime != null) return "weak_scaling";
1864
+ if (point.efficiency != null) return "scaling_efficiency";
1865
+ if (point.arithmetic_intensity_flops_per_byte != null) return "roofline";
1866
+ if (point.rank != null) return "rank_heatmap";
1867
+ return undefined;
1868
+ }
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
+
1925
+ /** Build the right figure for any plot-data document, without naming a builder.
1926
+ *
1927
+ * Dispatches on the document's own `plot` field, falling back to the payload
1928
+ * shape for files written before scope-profiler stamped the envelope on every
1929
+ * kind.
1930
+ */
1931
+ export function buildFigure(payload, options = {}) {
1932
+ const kind = validatePlotData(payload, options);
1933
+ const builder = PLOT_BUILDERS[kind];
1934
+ return builder(payload, { plot: kind, ...options });
1935
+ }
1936
+
1937
+ const RENDER_CONFIG = { responsive: true, displaylogo: false };
1938
+
175
1939
  /** Render a figure with any Plotly-compatible bundle. */
176
1940
  export function renderFigure(plotly, element, figure, config = {}) {
177
- if (!plotly || typeof plotly.newPlot !== "function") throw new TypeError("renderFigure requires a Plotly-compatible object with newPlot().");
178
- 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));
179
1985
  }