@scope-profiler/plotly 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +166 -8
- package/package.json +49 -6
- package/src/dashboard.d.ts +51 -0
- package/src/dashboard.js +177 -0
- package/src/index.d.ts +382 -23
- package/src/index.js +1664 -147
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Validate row fields, format versions, call ancestry, and duplicate cells with
|
|
6
|
+
actionable errors. Preserve nested layout defaults and isolate theme presets.
|
|
7
|
+
- Use stable colors; add `createColorRegistry()` and scoped `createFigureBuilder()`.
|
|
8
|
+
- Add time-series variability bands/error bars and absolute/percent comparison
|
|
9
|
+
deltas, including diagnostics for missing or zero baselines.
|
|
10
|
+
- Add `customdata.identity`, `getPointIdentity()`, linked legend groups, and
|
|
11
|
+
`disposeFigure()`. Custom data now uses objects with numeric metric keys;
|
|
12
|
+
consumers accessing raw custom data should migrate to `getPointIdentity()`.
|
|
13
|
+
- Preserve unequal density-bin boundaries, reject overlaps, and retain gaps.
|
|
14
|
+
- Detect cycles after Sankey aggregation, prefer measured edge weights, expose
|
|
15
|
+
omitted/inferred edges, and hide traces with no usable links.
|
|
16
|
+
- Ship typed payloads and a discriminated `PlotData` union with consumer checks.
|
|
17
|
+
- Add browser rendering/interaction tests, JavaScript coverage enforcement,
|
|
18
|
+
deterministic benchmark configuration, and bundled-asset synchronization.
|
|
19
|
+
|
|
20
|
+
## 0.4.0
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Region, file and series names are escaped before they reach a hover label. A
|
|
25
|
+
name containing `%{...}` rewrote every hover template that mentioned it, and
|
|
26
|
+
one containing markup injected it into the label.
|
|
27
|
+
- An empty payload no longer discards the `annotations` a caller passed through
|
|
28
|
+
`options.layout`, and hides the axes behind the "No data to display." message
|
|
29
|
+
instead of leaving an empty grid that reads as a broken chart.
|
|
30
|
+
- `buildDensityFigure` places each cell at its own bin centre. A single bin
|
|
31
|
+
width taken from the first point misplaced the second run's cells whenever
|
|
32
|
+
two runs of different length were binned into the same number of bins.
|
|
33
|
+
- `buildRooflineFigure` draws its ceiling in the theme's text colour rather than
|
|
34
|
+
a fixed near-black, which was invisible on the dark theme, and leaves room at
|
|
35
|
+
the top of the figure for the title it is alone in carrying.
|
|
36
|
+
- `buildDurationsFigure` accepts a colour keyed by the bare rank behind a
|
|
37
|
+
`rank N` series, which it previously ignored in favour of the default cycle.
|
|
38
|
+
- `buildRegionSummaryFigure` and `buildComparisonFigure` reject an unknown
|
|
39
|
+
`metric` instead of drawing a full chart of nulls.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- `buildDurationsFigure` and `buildRankHeatmapFigure` order their categories by
|
|
44
|
+
pooled cost rather than by the order the exporter happened to walk, which
|
|
45
|
+
interleaved two runs with different region sets unpredictably.
|
|
46
|
+
- `buildGanttFigure` puts a lane index on each bar and the lane names on the
|
|
47
|
+
axis (`tickvals`/`ticktext`) instead of repeating the lane string per
|
|
48
|
+
interval, which a large trace felt. `layout.yaxis.categoryarray` is gone;
|
|
49
|
+
read `layout.yaxis.ticktext` for the lane order.
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- `updateFigure(plotly, element, figure, config)` redraws through Plotly's
|
|
54
|
+
`react`, keeping the viewer's zoom and pan across a rebuild.
|
|
55
|
+
- `SUMMARY_METRICS`, the short metric names the region_statistics builders
|
|
56
|
+
accept, so a page can offer the list rather than guess it.
|
|
57
|
+
- `laneBy`, `updateFigure`, `SUMMARY_METRICS` and `PlotlyLike.react`/`purge` are
|
|
58
|
+
declared in `index.d.ts`, which is now type-checked in CI, as is a
|
|
59
|
+
compile-only usage fixture. `package.json` gained a top-level `types` entry
|
|
60
|
+
so classic `node` module resolution finds the declarations at all.
|
|
61
|
+
- ESLint and Prettier configuration, and a `lint` script, gated in CI.
|
package/README.md
CHANGED
|
@@ -1,5 +1,103 @@
|
|
|
1
1
|
# @scope-profiler/plotly
|
|
2
2
|
|
|
3
|
+
## Validation, updates, and linked charts
|
|
4
|
+
|
|
5
|
+
Builders validate consumed records, including finite numbers and interval order.
|
|
6
|
+
Errors identify the array, row index, and field. `validatePlotData()` also checks
|
|
7
|
+
the envelope; unknown additional fields remain compatible. Present format
|
|
8
|
+
versions must be positive integers. Duplicate heatmap/bar cells and call IDs
|
|
9
|
+
are errors; aggregate repeated measurements before building a figure.
|
|
10
|
+
|
|
11
|
+
`createColorRegistry(names, overrides)` returns an immutable name-to-color map.
|
|
12
|
+
Default colors are also stable across filtering and row ordering. Use explicit
|
|
13
|
+
colors when more categories need distinct hues than the eight-color palette.
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import {
|
|
17
|
+
createFigureBuilder,
|
|
18
|
+
createColorRegistry,
|
|
19
|
+
getPointIdentity,
|
|
20
|
+
updateFigure,
|
|
21
|
+
disposeFigure,
|
|
22
|
+
} from "@scope-profiler/plotly";
|
|
23
|
+
|
|
24
|
+
const build = createFigureBuilder({
|
|
25
|
+
theme: "dark",
|
|
26
|
+
colors: createColorRegistry(["solve", "assemble"]),
|
|
27
|
+
layout: { uirevision: "profile-123" },
|
|
28
|
+
});
|
|
29
|
+
await updateFigure(Plotly, element, build(payload));
|
|
30
|
+
element.on("plotly_click", ({ points }) => {
|
|
31
|
+
const identity = getPointIdentity(points[0]);
|
|
32
|
+
// identity: { region, file, rank, call_id }; unavailable fields are null.
|
|
33
|
+
if (identity) console.log(identity.region);
|
|
34
|
+
});
|
|
35
|
+
// On unmount:
|
|
36
|
+
disposeFigure(Plotly, element);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`createFigureBuilder()` snapshots defaults without modifying global theme state.
|
|
40
|
+
Nested layout objects merge, while arrays replace. Keep `layout.uirevision`
|
|
41
|
+
constant to retain zoom during `updateFigure()`; change it to reset the view.
|
|
42
|
+
The fallback to `newPlot()` for bundles without `react()` cannot preserve zoom.
|
|
43
|
+
`disposeFigure()` requires a bundle with `purge()`.
|
|
44
|
+
|
|
45
|
+
Data-bearing traces expose `customdata.identity`; existing numeric hover
|
|
46
|
+
fields are available as numeric keys on the same object. Reference/ideal lines
|
|
47
|
+
have no selection identity. Imbalance means and time-series variability traces
|
|
48
|
+
share their measured series' legend group.
|
|
49
|
+
|
|
50
|
+
## Variability and comparisons
|
|
51
|
+
|
|
52
|
+
`buildDurationTimeseriesFigure(payload, { variability: "band" })` displays a
|
|
53
|
+
min/max band; `"error"` displays asymmetric error bars. Missing bounds produce
|
|
54
|
+
gaps, and the default `"none"` retains the mean curve alone.
|
|
55
|
+
|
|
56
|
+
`buildComparisonFigure(payload, { comparison: "absolute" | "percent" })`
|
|
57
|
+
subtracts the first selected run from the second. `files` selects exactly two
|
|
58
|
+
runs, `metric` selects a statistic, and `sortBy: "regression"` (default) sorts
|
|
59
|
+
largest increases first; `"name"` sorts alphabetically. Percentage changes use
|
|
60
|
+
the first run as denominator. Missing measurements and zero percentage
|
|
61
|
+
baselines produce null values and `figure.diagnostics`, never invented zeros.
|
|
62
|
+
The default `"side-by-side"` mode retains grouped bars. Higher values are not
|
|
63
|
+
necessarily worse for every metric; interpret the sign for your chosen metric.
|
|
64
|
+
|
|
65
|
+
## Graph and density semantics
|
|
66
|
+
|
|
67
|
+
Flame figures reject ancestry cycles. Sankey figures remove edges that would
|
|
68
|
+
introduce a cycle after region aggregation and report them in
|
|
69
|
+
`figure.diagnostics`. Compact graph edges prefer `edge[valueKey]`, then
|
|
70
|
+
`edge.value`. A child total is used only when the original graph has exactly
|
|
71
|
+
one incoming non-self edge, with an explicit inference diagnostic. Unattributable
|
|
72
|
+
weights are omitted with a diagnostic. Zero measured weights stay zero.
|
|
73
|
+
|
|
74
|
+
Density figures with unequal grids use separate lane traces and exact bin
|
|
75
|
+
edges; gaps remain empty and overlapping bins are rejected. Raw-seconds traces
|
|
76
|
+
share one color range across lanes.
|
|
77
|
+
|
|
78
|
+
## Development checks
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npm ci
|
|
82
|
+
npm run lint
|
|
83
|
+
npm run check:types
|
|
84
|
+
npm run test:coverage # Node 24 (CI runtime)
|
|
85
|
+
npx playwright install chromium
|
|
86
|
+
npm run test:browser
|
|
87
|
+
npm run sync:asset
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The browser suite renders exporter fixtures in light/dark themes and checks
|
|
91
|
+
hover text, zoom retention, teardown, and a deterministic large timeline. It
|
|
92
|
+
writes screenshots and build/render/heap measurements to `test-results`.
|
|
93
|
+
The broad timing limits detect catastrophic regressions, not small speedups.
|
|
94
|
+
|
|
95
|
+
For measured optimization, run the repository benchmark workflow using
|
|
96
|
+
`packages/plotly/benchmarks/benchmark.toml`. It performs warmups and five runs,
|
|
97
|
+
records medians and variation, and runs the package tests as correctness checks.
|
|
98
|
+
`npm run benchmark` also prints per-build timings and process memory for the
|
|
99
|
+
fixed 8,192-cell workload. Keep optimizations only after comparison says `keep`.
|
|
100
|
+
|
|
3
101
|
Pure, framework-neutral Plotly figure builders for JSON written by
|
|
4
102
|
`scope-profiler export plot-data --format json`. The package does not import
|
|
5
103
|
Plotly; applications choose their own Plotly bundle.
|
|
@@ -14,7 +112,9 @@ npm install @scope-profiler/plotly plotly.js-dist-min
|
|
|
14
112
|
import Plotly from "plotly.js-dist-min";
|
|
15
113
|
import { buildGanttFigure, renderFigure } from "@scope-profiler/plotly";
|
|
16
114
|
|
|
17
|
-
const payload = await fetch("/figures/gantt_data.json").then((response) =>
|
|
115
|
+
const payload = await fetch("/figures/gantt_data.json").then((response) =>
|
|
116
|
+
response.json(),
|
|
117
|
+
);
|
|
18
118
|
const figure = buildGanttFigure(payload);
|
|
19
119
|
await renderFigure(Plotly, document.querySelector("#gantt"), figure);
|
|
20
120
|
```
|
|
@@ -27,10 +127,20 @@ produced it, so `buildFigure` can pick the builder for you:
|
|
|
27
127
|
```js
|
|
28
128
|
import { buildFigure, renderFigure } from "@scope-profiler/plotly";
|
|
29
129
|
|
|
30
|
-
const payload = await fetch("/figures/rank_heatmap_data.json").then(
|
|
31
|
-
|
|
130
|
+
const payload = await fetch("/figures/rank_heatmap_data.json").then(
|
|
131
|
+
(response) => response.json(),
|
|
132
|
+
);
|
|
133
|
+
await renderFigure(
|
|
134
|
+
Plotly,
|
|
135
|
+
document.querySelector("#chart"),
|
|
136
|
+
buildFigure(payload),
|
|
137
|
+
);
|
|
32
138
|
```
|
|
33
139
|
|
|
140
|
+
`validatePlotData(payload, options)` performs the same checks on its own and
|
|
141
|
+
returns the resolved kind, for a page that wants to report a bad file rather
|
|
142
|
+
than catch a build.
|
|
143
|
+
|
|
34
144
|
`buildFigure` rejects a document that is not `scope-profiler-plot-data` and one
|
|
35
145
|
whose `format_version` is newer than this package supports. JSON written before
|
|
36
146
|
scope-profiler stamped that envelope on every kind still works: the kind is then
|
|
@@ -41,13 +151,61 @@ inferred from the payload shape, and `{ plot: "gantt" }` settles it by hand.
|
|
|
41
151
|
`buildGanttFigure`, `buildFlameFigure`, `buildCallgraphFigure`,
|
|
42
152
|
`buildDensityFigure`, `buildDurationsFigure`, `buildDurationTimeseriesFigure`,
|
|
43
153
|
`buildHistogramFigure`, `buildRankHeatmapFigure`, `buildImbalanceFigure`,
|
|
44
|
-
`buildRegionSummaryFigure`, `
|
|
45
|
-
`
|
|
46
|
-
`
|
|
154
|
+
`buildRegionSummaryFigure`, `buildComparisonFigure`, `buildLikwidFigure`,
|
|
155
|
+
`buildSpeedupFigure`, `buildWeakScalingFigure`,
|
|
156
|
+
`buildScalingEfficiencyFigure` and `buildWeakScalingEfficiencyFigure` each
|
|
157
|
+
take `(payload, options)` and return a plain `{ data, layout }` figure.
|
|
47
158
|
|
|
48
159
|
Common options: `colors` (region or series name to color), `filterRegion(name,
|
|
49
|
-
row)` to drop rows, `layout` to merge into the generated layout,
|
|
50
|
-
|
|
160
|
+
row)` to drop rows, `layout` to merge into the generated layout, `metric` where
|
|
161
|
+
a payload carries several, and `theme` (see below).
|
|
162
|
+
|
|
163
|
+
`filterRegion` always receives the name being filtered first, but its second
|
|
164
|
+
argument is the record the builder is walking, and that differs by figure: a
|
|
165
|
+
row of the payload's own array for most of them, a node or a call for
|
|
166
|
+
`buildCallgraphFigure`, and the region's statistics object for
|
|
167
|
+
`buildRegionSummaryFigure` and `buildComparisonFigure`.
|
|
168
|
+
|
|
169
|
+
`buildGanttFigure` also takes `laneBy`: the default `"region"` gives each
|
|
170
|
+
region and rank its own lane, which is the only way a nested profile stays
|
|
171
|
+
legible; `"rank"` gives the compact one-row-per-rank view, which suits a flat
|
|
172
|
+
profile compared across many ranks.
|
|
173
|
+
|
|
174
|
+
The region_statistics builders take `metric` as either a short name or the
|
|
175
|
+
stored field: `SUMMARY_METRICS` maps `avg`, `min`, `max`, `total`, `first`,
|
|
176
|
+
`last`, `std` and `count` to the fields a document stores them under, and a
|
|
177
|
+
name in neither spelling is rejected rather than drawn as an empty chart.
|
|
178
|
+
|
|
179
|
+
The two efficiency curves store their y column under the same name, so which
|
|
180
|
+
reading a payload gets comes from the document's own `plot` field rather than
|
|
181
|
+
its rows. `buildScalingEfficiencyFigure` is for a _strong_-scaling study, where
|
|
182
|
+
the problem size is fixed and the ideal is a speedup proportional to the rank
|
|
183
|
+
count; `buildWeakScalingEfficiencyFigure` is for one that grows the problem
|
|
184
|
+
with the machine, where the ideal is constant runtime.
|
|
185
|
+
|
|
186
|
+
## Rendering and redrawing
|
|
187
|
+
|
|
188
|
+
`renderFigure(plotly, element, figure, config)` draws a figure with any
|
|
189
|
+
Plotly-compatible bundle. `updateFigure(...)` takes the same arguments but
|
|
190
|
+
redraws into an element that already holds a plot, using Plotly's `react`, so
|
|
191
|
+
the viewer's zoom and pan survive. Use it for anything that rebuilds a figure
|
|
192
|
+
the viewer is already looking at -- a theme toggle, a changed filter, a new
|
|
193
|
+
metric -- and keep `renderFigure` for the first draw.
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
setTheme(darkMode ? "dark" : "light");
|
|
197
|
+
await updateFigure(Plotly, element, buildFigure(payload));
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Themes
|
|
201
|
+
|
|
202
|
+
Chrome -- text, gridlines, hover surface, the dashed ideal lines -- comes from
|
|
203
|
+
a theme. The default `auto` sets no text colour and uses a half-transparent
|
|
204
|
+
grey grid that reads on any background, so a figure inherits the host page.
|
|
205
|
+
Pass `theme: "light" | "dark"`, or your own token object, to a builder, or call
|
|
206
|
+
`setTheme(...)` once to change the default for every later build. Plotly bakes
|
|
207
|
+
colours into the layout, so a theme change means rebuilding the figures. The
|
|
208
|
+
categorical series palette is not themed: those hues read on both backgrounds.
|
|
51
209
|
|
|
52
210
|
## More than one run in a payload
|
|
53
211
|
|
package/package.json
CHANGED
|
@@ -1,21 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scope-profiler/plotly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Framework-neutral Plotly figure builders for scope-profiler plot-data JSON.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/max-models/scope-profiler.git"
|
|
8
|
+
"url": "git+https://github.com/max-models/scope-profiler.git",
|
|
9
|
+
"directory": "packages/plotly"
|
|
9
10
|
},
|
|
10
11
|
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"types": "./src/index.d.ts",
|
|
11
14
|
"exports": {
|
|
12
15
|
".": {
|
|
13
16
|
"types": "./src/index.d.ts",
|
|
14
17
|
"default": "./src/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./dashboard": {
|
|
20
|
+
"types": "./src/dashboard.d.ts",
|
|
21
|
+
"default": "./src/dashboard.js"
|
|
15
22
|
}
|
|
16
23
|
},
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20.11"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"LICENSE.txt"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test test/*.test.js",
|
|
38
|
+
"lint": "eslint src test && prettier --check src test",
|
|
39
|
+
"format": "prettier --write src test",
|
|
40
|
+
"check:types": "tsc --noEmit -p tsconfig.json",
|
|
41
|
+
"test:browser": "playwright test",
|
|
42
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include=src/index.js --test-coverage-lines=99 test/*.test.js",
|
|
43
|
+
"benchmark": "node benchmarks/build.mjs",
|
|
44
|
+
"sync:asset": "node scripts/sync-asset.js"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@playwright/test": "1.63.0",
|
|
48
|
+
"eslint": "^9.13.0",
|
|
49
|
+
"plotly.js-dist-min": "4.1.0",
|
|
50
|
+
"plotly3": "npm:plotly.js-dist-min@3.7.0",
|
|
51
|
+
"prettier": "^3.3.3",
|
|
52
|
+
"typescript": "^5.6.0"
|
|
53
|
+
},
|
|
54
|
+
"keywords": [
|
|
55
|
+
"scope-profiler",
|
|
56
|
+
"plotly",
|
|
57
|
+
"profiling",
|
|
58
|
+
"gantt",
|
|
59
|
+
"flamegraph",
|
|
60
|
+
"callgraph",
|
|
61
|
+
"hpc",
|
|
62
|
+
"mpi"
|
|
63
|
+
]
|
|
21
64
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BuildOptions,
|
|
3
|
+
Figure,
|
|
4
|
+
PlotlyLike,
|
|
5
|
+
ThemeName,
|
|
6
|
+
ThemeTokens,
|
|
7
|
+
} from "./index.js";
|
|
8
|
+
|
|
9
|
+
export function parseRegionFilter(text: string | null | undefined): string[];
|
|
10
|
+
export function matchesRegionFilter(
|
|
11
|
+
region: string | null | undefined,
|
|
12
|
+
terms: string[],
|
|
13
|
+
): boolean;
|
|
14
|
+
export function regionFilter(
|
|
15
|
+
text: string | null | undefined,
|
|
16
|
+
): ((region: string) => boolean) | undefined;
|
|
17
|
+
export function documentTheme(): ThemeName;
|
|
18
|
+
|
|
19
|
+
export interface FigureRegistryOptions {
|
|
20
|
+
/** A theme, or a function returning one, read at each render. */
|
|
21
|
+
theme?: ThemeName | ThemeTokens | (() => ThemeName | ThemeTokens);
|
|
22
|
+
/** Plotly config merged into every render. */
|
|
23
|
+
config?: object;
|
|
24
|
+
/** Figure builder, defaulting to `buildFigure`. */
|
|
25
|
+
build?: (payload: object, options?: BuildOptions) => Figure;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RenderOptions extends BuildOptions {
|
|
29
|
+
/** A filter box's contents, turned into `filterRegion` for this figure. */
|
|
30
|
+
regionFilter?: string;
|
|
31
|
+
/** Builder for this figure only, overriding the registry's. */
|
|
32
|
+
build?: (payload: object, options?: BuildOptions) => Figure;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FigureRegistry {
|
|
36
|
+
render(
|
|
37
|
+
container: Element | string | null | undefined,
|
|
38
|
+
payload: object,
|
|
39
|
+
options?: RenderOptions,
|
|
40
|
+
): Promise<unknown> | undefined;
|
|
41
|
+
refresh(): Promise<unknown[]>;
|
|
42
|
+
forget(container: Element | string): boolean;
|
|
43
|
+
clear(): void;
|
|
44
|
+
readonly size: number;
|
|
45
|
+
watch(target?: EventTarget, event?: string): () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createFigureRegistry(
|
|
49
|
+
plotly: PlotlyLike,
|
|
50
|
+
options?: FigureRegistryOptions,
|
|
51
|
+
): FigureRegistry;
|
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** Browser glue for a page that renders scope-profiler figures.
|
|
2
|
+
*
|
|
3
|
+
* `index.js` is deliberately framework-neutral and pure: it turns a plot-data
|
|
4
|
+
* document into `{ data, layout }` and touches nothing else. A page built on
|
|
5
|
+
* it still needs two things every time, and both were being written from
|
|
6
|
+
* scratch by each deployment:
|
|
7
|
+
*
|
|
8
|
+
* * **Somewhere to keep the figures.** Plotly bakes colours into the layout,
|
|
9
|
+
* so a theme toggle means rebuilding every figure on the page -- which means
|
|
10
|
+
* remembering which containers hold one and what each was built from.
|
|
11
|
+
* * **A filter syntax.** `filterRegion` takes a predicate, and should: a
|
|
12
|
+
* library has no business deciding how a user spells a filter. But every
|
|
13
|
+
* dashboard grows the same comma-separated substring box, so here is one.
|
|
14
|
+
*
|
|
15
|
+
* This module is a separate entry point because it reaches for `document` and
|
|
16
|
+
* `window`; importing `@scope-profiler/plotly` never does.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { buildFigure, renderFigure, setTheme, updateFigure } from "./index.js";
|
|
20
|
+
|
|
21
|
+
/** Split a filter box's contents into terms.
|
|
22
|
+
*
|
|
23
|
+
* A filter is a comma-separated list, each term matched case-insensitively as
|
|
24
|
+
* a substring of the region name. Empty terms -- a trailing comma while
|
|
25
|
+
* someone is still typing -- are dropped.
|
|
26
|
+
*/
|
|
27
|
+
export function parseRegionFilter(text) {
|
|
28
|
+
return String(text ?? "")
|
|
29
|
+
.split(",")
|
|
30
|
+
.map((term) => term.trim().toLowerCase())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether a region name matches any of `terms`.
|
|
35
|
+
*
|
|
36
|
+
* A leading "^" anchors a term to the start of the name, which is how a
|
|
37
|
+
* dashboard tells a group of regions apart from the regions that merely
|
|
38
|
+
* mention it: "prop:" also matches "setup prop: X", "^prop:" does not.
|
|
39
|
+
*/
|
|
40
|
+
export function matchesRegionFilter(region, terms) {
|
|
41
|
+
const name = String(region ?? "").toLowerCase();
|
|
42
|
+
return terms.some((term) =>
|
|
43
|
+
term.startsWith("^") ? name.startsWith(term.slice(1)) : name.includes(term),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A `filterRegion` predicate for a filter box, or undefined when it is empty.
|
|
48
|
+
*
|
|
49
|
+
* Undefined rather than a predicate that accepts everything: an empty box
|
|
50
|
+
* means every region, and passing no filter at all is both faster and what
|
|
51
|
+
* the builders document.
|
|
52
|
+
*/
|
|
53
|
+
export function regionFilter(text) {
|
|
54
|
+
const terms = parseRegionFilter(text);
|
|
55
|
+
return terms.length
|
|
56
|
+
? (region) => matchesRegionFilter(region, terms)
|
|
57
|
+
: undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The theme the host page is in, as `setTheme` spells it.
|
|
61
|
+
*
|
|
62
|
+
* Reads `data-theme` from the document element, the attribute a theme toggle
|
|
63
|
+
* conventionally stamps there. With neither value set the page is following
|
|
64
|
+
* the system preference, which "auto" already handles by committing to no
|
|
65
|
+
* text colour.
|
|
66
|
+
*/
|
|
67
|
+
export function documentTheme() {
|
|
68
|
+
if (typeof document === "undefined") return "auto";
|
|
69
|
+
const theme = document.documentElement?.dataset?.theme;
|
|
70
|
+
return theme === "dark" || theme === "light" ? theme : "auto";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Track the figures on a page so they can all be rebuilt at once.
|
|
74
|
+
*
|
|
75
|
+
* Each container remembers the payload and options it was last drawn from, so
|
|
76
|
+
* `refresh()` can rebuild it under whatever the theme now is. A container that
|
|
77
|
+
* has already been drawn is redrawn through `updateFigure`, which keeps the
|
|
78
|
+
* viewer's zoom and pan across a theme toggle or a changed filter.
|
|
79
|
+
*
|
|
80
|
+
* ```js
|
|
81
|
+
* const figures = createFigureRegistry(Plotly);
|
|
82
|
+
* figures.watch(); // rebuild on a "themechanged" event
|
|
83
|
+
* await figures.render(el, payload, { regionFilter: box.value });
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* @param plotly A Plotly-compatible bundle.
|
|
87
|
+
* @param options.theme A theme, or a function returning one, read at each
|
|
88
|
+
* render. Defaults to `documentTheme`.
|
|
89
|
+
* @param options.config Plotly config, merged into every render.
|
|
90
|
+
* @param options.build Figure builder, defaulting to `buildFigure`. Override
|
|
91
|
+
* for a figure no plot kind names, such as `buildComparisonFigure`.
|
|
92
|
+
*/
|
|
93
|
+
export function createFigureRegistry(plotly, options = {}) {
|
|
94
|
+
const {
|
|
95
|
+
theme = documentTheme,
|
|
96
|
+
config = {},
|
|
97
|
+
build: defaultBuild = buildFigure,
|
|
98
|
+
} = options;
|
|
99
|
+
// The spec lives here rather than on the container, so a page cannot be
|
|
100
|
+
// left holding a detached node through a property nobody remembers setting.
|
|
101
|
+
const specs = new Map();
|
|
102
|
+
const currentTheme = () => (typeof theme === "function" ? theme() : theme);
|
|
103
|
+
|
|
104
|
+
// A container that has left the document is dead weight: the page swapped
|
|
105
|
+
// it out, and redrawing into it paints nothing. Anything without the DOM
|
|
106
|
+
// property -- a test double, a server-side stub -- counts as live.
|
|
107
|
+
const isLive = (container) => container?.isConnected !== false;
|
|
108
|
+
|
|
109
|
+
async function draw(container, spec) {
|
|
110
|
+
const { payload, options: buildOptions = {}, drawn } = spec;
|
|
111
|
+
const build = buildOptions.build ?? defaultBuild;
|
|
112
|
+
const { build: _build, regionFilter: filterText, ...rest } = buildOptions;
|
|
113
|
+
const figure = build(payload, {
|
|
114
|
+
theme: currentTheme(),
|
|
115
|
+
...(filterText != null ? { filterRegion: regionFilter(filterText) } : {}),
|
|
116
|
+
...rest,
|
|
117
|
+
});
|
|
118
|
+
// First draw builds the plot; later ones react into it, so a theme toggle
|
|
119
|
+
// does not reset the view the reader had scrolled to.
|
|
120
|
+
const paint = drawn ? updateFigure : renderFigure;
|
|
121
|
+
spec.drawn = true;
|
|
122
|
+
return paint(plotly, container, figure, config);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
/** Draw `payload` into `container` and remember how, for `refresh()`. */
|
|
127
|
+
render(container, payload, buildOptions = {}) {
|
|
128
|
+
if (!container) return undefined;
|
|
129
|
+
const spec = specs.get(container) ?? {};
|
|
130
|
+
Object.assign(spec, { payload, options: buildOptions });
|
|
131
|
+
specs.set(container, spec);
|
|
132
|
+
return draw(container, spec);
|
|
133
|
+
},
|
|
134
|
+
/** Rebuild every live figure under the current theme. */
|
|
135
|
+
refresh() {
|
|
136
|
+
setTheme(currentTheme());
|
|
137
|
+
const drawing = [];
|
|
138
|
+
for (const [container, spec] of specs) {
|
|
139
|
+
if (!isLive(container)) specs.delete(container);
|
|
140
|
+
else drawing.push(draw(container, spec));
|
|
141
|
+
}
|
|
142
|
+
return Promise.all(drawing);
|
|
143
|
+
},
|
|
144
|
+
/** Stop tracking one container, and tear its plot down if it has one.
|
|
145
|
+
*
|
|
146
|
+
* Dropping the spec alone leaves Plotly's own event handlers attached to
|
|
147
|
+
* a node the page is finished with, so purge as well where the bundle
|
|
148
|
+
* offers it.
|
|
149
|
+
*/
|
|
150
|
+
forget(container) {
|
|
151
|
+
const tracked = specs.get(container);
|
|
152
|
+
if (tracked?.drawn && typeof plotly?.purge === "function")
|
|
153
|
+
plotly.purge(container);
|
|
154
|
+
return specs.delete(container);
|
|
155
|
+
},
|
|
156
|
+
/** Stop tracking every container, tearing down each plot. */
|
|
157
|
+
clear() {
|
|
158
|
+
for (const container of [...specs.keys()]) this.forget(container);
|
|
159
|
+
},
|
|
160
|
+
/** Figures currently tracked, live or not. */
|
|
161
|
+
get size() {
|
|
162
|
+
return specs.size;
|
|
163
|
+
},
|
|
164
|
+
/**
|
|
165
|
+
* Rebuild on an event, and return the function that stops listening.
|
|
166
|
+
* Defaults to `window` and the "themechanged" event a toggle can dispatch.
|
|
167
|
+
*/
|
|
168
|
+
watch(target, event = "themechanged") {
|
|
169
|
+
const source =
|
|
170
|
+
target ?? (typeof window === "undefined" ? undefined : window);
|
|
171
|
+
if (!source?.addEventListener) return () => {};
|
|
172
|
+
const listener = () => this.refresh();
|
|
173
|
+
source.addEventListener(event, listener);
|
|
174
|
+
return () => source.removeEventListener(event, listener);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|