@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 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) => response.json());
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((response) => response.json());
31
- await renderFigure(Plotly, document.querySelector("#chart"), buildFigure(payload));
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`, `buildLikwidFigure`, `buildSpeedupFigure`,
45
- `buildWeakScalingFigure` and `buildScalingEfficiencyFigure` each take
46
- `(payload, options)` and return a plain `{ data, layout }` figure.
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, and `metric`
50
- where a payload carries several.
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.2.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
- "files": ["src", "README.md", "LICENSE.txt"],
18
- "publishConfig": { "access": "public" },
19
- "scripts": { "test": "node --test" },
20
- "keywords": ["scope-profiler", "plotly", "profiling", "gantt", "flamegraph", "callgraph", "hpc", "mpi"]
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;
@@ -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
+ }