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