@figurestead/web 0.9.0-alpha.1 → 0.9.0-alpha.2

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 CHANGED
@@ -1,9 +1,183 @@
1
1
  # @figurestead/web experimental public alpha
2
2
 
3
+ ```sh
4
+ npm install @figurestead/web@0.9.0-alpha.2
5
+ ```
6
+
3
7
  The package root provides Figurestead's accepted core rendering and custom
4
- renderer-registry APIs. `EXTENSION_RENDERERS` and `FIGURESTEAD_REGISTRY` are
5
- not root exports. The complete temporal extension is available from
6
- `@figurestead/web/extensions/temporal`.
8
+ renderer-registry APIs. The complete temporal extension is available from
9
+ `@figurestead/web/extensions/temporal`; it is intentionally not a root export.
10
+
11
+ This package ships first-party TypeScript declarations for the root and
12
+ temporal surfaces. Import contract, controller, option, state, error, theme,
13
+ and temporal types directly from those package entrypoints—no downstream
14
+ declaration shim or shadow schema is needed. TypeScript provides structural
15
+ guidance; runtime validation remains authoritative for semantic constraints
16
+ such as finite data, ordered domains, canonical colors, and matching
17
+ cardinalities.
18
+
19
+ Rendering requires a complete normalized figure contract. The repository's
20
+ [runnable browser first-success example](https://github.com/CharlesMish/figurestead/tree/main/examples/browser-first-success)
21
+ remains useful for source development. An installed-package consumer can render
22
+ the same small scientific line without cloning the repository. Save these two
23
+ files after installing `@figurestead/web` and run them through a normal ESM
24
+ browser tool such as Vite.
25
+
26
+ ```sh
27
+ npm install @figurestead/web@0.9.0-alpha.2
28
+ ```
29
+
30
+ <!-- figurestead-npm-first-success:index.html -->
31
+ ```html
32
+ <!doctype html>
33
+ <html lang="en">
34
+ <head>
35
+ <meta charset="utf-8">
36
+ <meta name="viewport" content="width=device-width, initial-scale=1">
37
+ <title>Figurestead first success</title>
38
+ <style>
39
+ body { margin: 24px; background: #f1eee5; }
40
+ canvas { display: block; width: min(100%, 760px); height: auto; background: #fffdfa; }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <canvas id="figure" width="760" height="520" aria-label="Three-point Figurestead line example"></canvas>
45
+ <script type="module" src="./main.js"></script>
46
+ </body>
47
+ </html>
48
+ ```
49
+
50
+ <!-- figurestead-npm-first-success:main.js -->
51
+ ```js
52
+ import {
53
+ createFigurestead,
54
+ resolveTheme,
55
+ validateThemePack,
56
+ } from "@figurestead/web";
57
+ import slipwarePack from "@figurestead/web/themes/slipware" with { type: "json" };
58
+
59
+ const theme = resolveTheme(validateThemePack(slipwarePack), "slipware");
60
+ const contract = {
61
+ schemaVersion: "0.4",
62
+ rendererApiVersion: "1",
63
+ theme,
64
+ profile: { key: "first_success", name: "First success", marker: "ring_core", markerSize: 42, markerAlpha: 0.84, edgeWidth: 1.05, coreFraction: 0.12, pointGlow: false, gridX: true, gridY: true, gridAlpha: 0.4, summaryGlow: false },
65
+ timeline: { rainIn: [0, 0], marksEnter: [0, 1], summaryCompiles: [0.8, 1], rainOut: [0, 0], settle: [0.9, 1] },
66
+ motion: { frames: 1, fps: 1, rainStreams: 0, rainGlyphs: 0, lightingPeak: 0, trailAlpha: 0, seed: 1, durationMs: 1 },
67
+ style: { glyphs: ["ring", "square", "triangle", "diamond"], lineStyles: ["solid", "dash", "dot", "dash-dot"], series: {} },
68
+ spec: { title: "Figurestead first success", subtitle: "One deterministic normalized line contract", xLabel: "observation", yLabel: "response", signature: "figurestead · first success", description: "One line with three observations." },
69
+ layout: { type: "grid", columns: 1, gap: 18, sharedX: false, sharedY: false },
70
+ view: { profile: "atlas", motion: "none", ambient: "none", strategy: "none" },
71
+ panels: [{ id: "line", renderer: "line", spec: { title: "Figurestead first success", xLabel: "observation", yLabel: "response", description: "One line with three observations." }, xScale: { type: "linear" }, yScale: { type: "linear" }, annotations: [], encoding: { interpolation: "linear" }, presentation: { panelSurface: true, frame: true, legend: "none", lineWidth: 2, markerScale: 1 }, data: { x: [0, 1, 2], revealOrder: "x", series: [{ key: "series-1", label: "Series 1", y: [0, 1, 0] }] } }],
72
+ };
73
+
74
+ const canvas = document.querySelector("#figure");
75
+ const figure = createFigurestead(canvas, contract, { autoplay: false, reducedMotion: true });
76
+ figure.resize();
77
+ document.documentElement.dataset.figuresteadReady = "true";
78
+ ```
79
+
80
+ This package also ships six curated theme subpaths generated byte-for-byte from
81
+ Figurestead's canonical theme JSON rather than retyped as JavaScript constants.
82
+ For example, in Node or TypeScript:
83
+
84
+ ```ts
85
+ import {
86
+ applyTheme,
87
+ resolveTheme,
88
+ type FiguresteadContract,
89
+ } from "@figurestead/web";
90
+ import slipwarePack from "@figurestead/web/themes/slipware" with { type: "json" };
91
+
92
+ export function withSlipware(contract: FiguresteadContract): FiguresteadContract {
93
+ return applyTheme(contract, resolveTheme(slipwarePack, "slipware"));
94
+ }
95
+ ```
96
+
97
+ The same explicit subpath works through the tested Vite browser route. Other
98
+ curated subpaths are `registration-ink`, `ultraviolet-laboratory`,
99
+ `lavender-fog-notebook`, `midnight-transit-signal-slate`, and
100
+ `deep-observatory-sage-core` under `@figurestead/web/themes/`.
101
+
102
+ The declarations and all six curated-theme subpaths above are included in
103
+ `@figurestead/web@0.9.0-alpha.2`.
104
+
105
+ Python and browser surfaces share normalized contract vocabulary and selected
106
+ theme definitions. Shared semantics do not imply pixel-identical output or
107
+ identical renderer coverage. In particular, the populated categorical matrix
108
+ is currently Python-rendered; this package does not claim a browser categorical
109
+ matrix renderer.
110
+
111
+ ## Controller failures
112
+
113
+ `setConfig(next)` is transactional: a validation, compilation, renderer
114
+ preparation, scene-resolution, composition, or accessibility-description error
115
+ leaves the last accepted contract, scenes, pixels, companion, and motion state
116
+ active. The original configuration error is thrown to the caller.
117
+
118
+ Hosts may observe asynchronous renderer failures with the existing creation
119
+ options pattern:
120
+
121
+ ```js
122
+ const figure = createFigurestead(canvas, contract, {
123
+ onError(error, context) {
124
+ // context.phase is "draw" or "height-negotiation".
125
+ // error is the original renderer or host-callback error object.
126
+ },
127
+ });
128
+ ```
129
+
130
+ After a draw failure the controller is stopped, reports `playing: false` and
131
+ `runtimeFailed: true`, and will not redraw through play, replay, resize, or
132
+ reduced-motion changes. A later valid `setConfig()` clears that state and
133
+ renders normally. Lifecycle notifications remain on `onState`; runtime failures
134
+ are reported only through `onError`.
135
+
136
+ ## Responsive headers and host-owned height
137
+
138
+ At compact live-Canvas widths, Figurestead preserves the established plot
139
+ geometry and uses a bounded two-line title plus a one-line ellipsized subtitle
140
+ when the host does not opt in to more height. Complete strings remain in the
141
+ associated accessibility companion. Figurestead never changes host CSS by
142
+ default.
143
+
144
+ An auto-height host may opt in with one mount-scoped adapter. Figurestead's
145
+ current measurement convention is the CSS-pixel border box reported by
146
+ `canvas.getBoundingClientRect()` (including any reflected CSS transform). Retain
147
+ the baseline independently in that same measurement space; do not derive it
148
+ from the canvas after applying an earlier request:
149
+
150
+ ```js
151
+ const baselineHeight = 196;
152
+ const figure = createFigurestead(canvas, contract, {
153
+ heightNegotiation: {
154
+ getBaselineHeight() { return baselineHeight; },
155
+ requestPreferredHeight({ preferredHeight, signal }) {
156
+ if (!signal.aborted) canvas.style.height = `${preferredHeight}px`;
157
+ },
158
+ },
159
+ });
160
+ ```
161
+
162
+ State-driven or delayed hosts may apply on a later frame. The return value is
163
+ not an acknowledgement; the host applies the absolute height and Figurestead's
164
+ existing resize observation resolves the granted layout:
165
+
166
+ ```js
167
+ requestPreferredHeight({ preferredHeight, signal }) {
168
+ requestAnimationFrame(() => {
169
+ if (!signal.aborted) canvas.style.height = `${preferredHeight}px`;
170
+ });
171
+ }
172
+ ```
173
+
174
+ Accepted contract, width, baseline, remount, and destroy transitions abort the
175
+ prior signal. A host may decline or clamp a request; the fixed-height fallback
176
+ then remains active without automatic retry in that generation. Host-applied
177
+ CSS remains host state after `destroy()`. Before remount, the host either keeps
178
+ that height intentionally or restores its independent baseline source; residual
179
+ preferred height is never inferred as the next baseline by Figurestead.
180
+ Height negotiation applies only to live Canvas rendering. SVG, paper, and
181
+ explicitly dimensioned exports retain their requested dimensions.
7
182
 
8
- Version 0.9.0-alpha.1. Source:
9
- <https://github.com/CharlesMish/figurestead/tree/main/web>.
183
+ Version 0.9.0-alpha.2. [Source and full project documentation](https://github.com/CharlesMish/figurestead).
@@ -25,11 +25,18 @@ only system font families and carries no downloaded font bytes or remote URL.
25
25
 
26
26
  ## Browser tooling observations
27
27
 
28
- The alpha verification environment used Playwright 1.61.1 and
29
- playwright-core 1.61.1 as
30
- Apache-2.0 development/test tooling and optional fsevents 2.3.2 as MIT. These
31
- packages and downloaded browsers are not intended npm-tarball members or
32
- top-level archive members. They remain test-tool provenance only.
28
+ The alpha verification environment uses Playwright 1.62.0 and
29
+ playwright-core 1.62.0 as Apache-2.0 development/test tooling, TypeScript 7.0.2
30
+ as Apache-2.0 declaration-conformance tooling, and Vite 8.2.1 as MIT browser
31
+ package-consumer tooling. Vite's resolved test-only tree includes rolldown
32
+ 1.2.4, @rolldown/pluginutils 1.0.1, @oxc-project/types 0.144.0, fdir 6.5.0,
33
+ nanoid 3.3.18, picocolors 1.1.1, picomatch 4.0.5, postcss 8.5.26,
34
+ source-map-js 1.2.1, tinyglobby 0.2.17, detect-libc 2.1.2, and optional native
35
+ bindings recorded exactly in `package-lock.json`; lightningcss 1.33.0 is
36
+ MPL-2.0 and the remaining listed Vite-tree packages use the licenses recorded
37
+ in that lock. Optional fsevents resolves as 2.3.2 and 2.3.3 in the applicable
38
+ tool trees. These packages and downloaded browsers are not intended npm-tarball
39
+ members or top-level archive members. They remain test-tool provenance only.
33
40
 
34
41
  ## Placement and maintenance
35
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figurestead/web",
3
- "version": "0.9.0-alpha.1",
3
+ "version": "0.9.0-alpha.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,17 +15,54 @@
15
15
  "url": "https://github.com/CharlesMish/figurestead/issues"
16
16
  },
17
17
  "engines": {
18
- "node": ">=24 <25"
18
+ "node": ">=22.22.0"
19
19
  },
20
+ "types": "./types/index.d.ts",
20
21
  "exports": {
21
- ".": "./src/index.js",
22
- "./extensions/temporal": "./src/extensions/temporal/index.js"
22
+ ".": {
23
+ "types": "./types/index.d.ts",
24
+ "import": "./src/index.js"
25
+ },
26
+ "./extensions/temporal": {
27
+ "types": "./types/extensions/temporal.d.ts",
28
+ "import": "./src/extensions/temporal/index.js"
29
+ },
30
+ "./themes/slipware": {
31
+ "types": "./types/theme-json.d.ts",
32
+ "default": "./themes/slipware.json"
33
+ },
34
+ "./themes/registration-ink": {
35
+ "types": "./types/theme-json.d.ts",
36
+ "default": "./themes/registration_ink.json"
37
+ },
38
+ "./themes/ultraviolet-laboratory": {
39
+ "types": "./types/theme-json.d.ts",
40
+ "default": "./themes/ultraviolet_laboratory.json"
41
+ },
42
+ "./themes/lavender-fog-notebook": {
43
+ "types": "./types/theme-json.d.ts",
44
+ "default": "./themes/lavender_fog_notebook.json"
45
+ },
46
+ "./themes/midnight-transit-signal-slate": {
47
+ "types": "./types/theme-json.d.ts",
48
+ "default": "./themes/midnight_transit_signal_slate.json"
49
+ },
50
+ "./themes/deep-observatory-sage-core": {
51
+ "types": "./types/theme-json.d.ts",
52
+ "default": "./themes/deep_observatory_sage_core.json"
53
+ }
23
54
  },
24
55
  "files": [
25
56
  "src",
57
+ "types",
58
+ "themes",
26
59
  "LICENSE",
27
60
  "README.md",
28
61
  "THIRD_PARTY_NOTICES.md",
29
62
  "TRADEMARKS.md"
30
- ]
63
+ ],
64
+ "scripts": {
65
+ "prepack": "node scripts/curated-themes.mjs stage",
66
+ "postpack": "node scripts/curated-themes.mjs clean"
67
+ }
31
68
  }
@@ -1,16 +1,31 @@
1
1
  const el = (name, text) => { const node = document.createElement(name); if (text != null) node.textContent = text; return node; };
2
2
 
3
- function appendTable(root, panel, description) {
3
+ function appendTable(root, panel, description, { interactive = false } = {}) {
4
4
  if (!description?.headers?.length || !Array.isArray(description.rows)) return;
5
- const details = el("details"), summary = el("summary", `View ${panel.spec.title || panel.renderer} data`), table = el("table");
5
+ const table = el("table");
6
6
  table.append(el("caption", `${panel.spec.title || panel.renderer} data`));
7
7
  const head = el("thead"), body = el("tbody"), headingRow = el("tr");
8
8
  description.headers.forEach((value) => headingRow.append(el("th", value))); head.append(headingRow);
9
9
  description.rows.forEach((row) => { const tr = el("tr"); row.forEach((value, index) => { const cell = el(index === 0 ? "th" : "td", String(value)); if (index === 0) cell.scope = "row"; tr.append(cell); }); body.append(tr); });
10
- table.append(head, body); details.append(summary, table); root.append(details);
10
+ table.append(head, body);
11
+ if (!interactive) { root.append(table); return; }
12
+ const details = el("details"), summary = el("summary", `View ${panel.spec.title || panel.renderer} data`);
13
+ details.append(summary, table); root.append(details);
11
14
  }
12
15
 
13
- export function createAccessibilityCompanion(canvas, contract, registry, { visible = false, table = true } = {}) {
16
+ function annotationCoordinates(item, composedPanel, focusIndex) {
17
+ const composed = composedPanel?.composedAnnotations?.[focusIndex];
18
+ if (!item.anchorId) return composed?.status === "authored-coordinate" ? { x: item.x, y: item.y } : null;
19
+ if (!composed?.boundMarkId) return null;
20
+ const mark = composedPanel.marks?.find((candidate) => candidate.id === composed.boundMarkId);
21
+ if (!mark) return null;
22
+ const x = mark.x ?? mark.group ?? mark.xCategory ?? mark.evidence?.x ?? mark.evidence?.group;
23
+ const y = mark.y ?? mark.yCategory ?? mark.evidence?.y ?? mark.evidence?.yCategory;
24
+ const usable = (value) => (typeof value === "number" && Number.isFinite(value)) || (typeof value === "string" && value.length > 0);
25
+ return usable(x) && usable(y) ? { x, y } : null;
26
+ }
27
+
28
+ export function prepareAccessibilityCompanion(canvas, contract, registry, { visible = false, table = true, composedScene = null } = {}) {
14
29
  const id = `figurestead-${Math.random().toString(36).slice(2)}`, root = el("section");
15
30
  root.className = visible ? "figurestead-accessibility" : "figurestead-accessibility figurestead-sr-only";
16
31
  if (!visible) Object.assign(root.style, { position: "absolute", width: "1px", height: "1px", padding: "0", margin: "-1px", overflow: "hidden", clip: "rect(0, 0, 0, 0)", whiteSpace: "nowrap", border: "0" });
@@ -18,16 +33,41 @@ export function createAccessibilityCompanion(canvas, contract, registry, { visib
18
33
  if (contract.spec.subtitle) root.append(el("p", contract.spec.subtitle));
19
34
  const description = el("p", contract.spec.description || `${contract.panels.length}-panel scientific figure.`); description.id = `${id}-description`; root.append(description);
20
35
  contract.panels.forEach((panel, index) => {
21
- const section = el("section"), heading = el("h3", panel.spec.title || `${panel.renderer.replaceAll("_", " ")} panel`), definition = registry.get(panel.renderer), described = definition.describe({ ...contract, renderer: panel.renderer, spec: { ...contract.spec, ...panel.spec }, data: panel.data });
22
- heading.id = `${id}-panel-${index + 1}`; section.setAttribute("aria-labelledby", heading.id); section.append(heading);
36
+ const section = el("section"), panelTitle = panel.spec.title || `${panel.renderer.replaceAll("_", " ")} panel`, definition = registry.get(panel.renderer), described = definition.describe({ ...contract, renderer: panel.renderer, spec: { ...contract.spec, ...panel.spec }, data: panel.data }), composedPanel = composedScene?.panels?.[index];
37
+ const repeatsContractTitle = contract.panels.length === 1 && panelTitle.trim() === contract.spec.title.trim();
38
+ if (repeatsContractTitle) section.setAttribute("aria-labelledby", title.id);
39
+ else {
40
+ const heading = el("h3", panelTitle); heading.id = `${id}-panel-${index + 1}`;
41
+ section.setAttribute("aria-labelledby", heading.id); section.append(heading);
42
+ }
23
43
  if (panel.spec.description || described.summary) section.append(el("p", panel.spec.description || described.summary));
24
- panel.annotations.filter((item) => item?.type === "focus" && typeof item.label === "string" && item.label.trim()).forEach((item) => {
25
- section.append(el("p", `Focus annotation: ${item.label} at x ${item.x}, y ${item.y}.`));
44
+ panel.annotations.filter((item) => item?.type === "focus" && typeof item.label === "string" && item.label.trim()).forEach((item, focusIndex) => {
45
+ const coordinates = annotationCoordinates(item, composedPanel, focusIndex);
46
+ const suffix = coordinates ? ` at x ${coordinates.x}, y ${coordinates.y}.` : ".";
47
+ section.append(el("p", `Focus annotation: ${item.label}${suffix}`));
26
48
  });
27
49
  const dl = el("dl");
28
50
  [["Horizontal axis", panel.spec.xLabel || panel.xScale.label || "Unlabelled"], ["Vertical axis", panel.spec.yLabel || panel.yScale.label || "Unlabelled"], ["Renderer", panel.renderer.replaceAll("_", " ")]].forEach(([term, value]) => dl.append(el("dt", term), el("dd", value)));
29
- section.append(dl); if (table) appendTable(section, panel, described); root.append(section);
51
+ section.append(dl); if (table) appendTable(section, panel, described, { interactive: visible }); root.append(section);
30
52
  });
31
- canvas.setAttribute("role", "img"); canvas.setAttribute("aria-labelledby", `${title.id} ${description.id}`); canvas.after(root);
32
- return { root, destroy() { root.remove(); canvas.removeAttribute("role"); canvas.removeAttribute("aria-labelledby"); } };
53
+ const labelledBy = `${title.id} ${description.id}`;
54
+ let attached = false;
55
+ return {
56
+ root,
57
+ attach() {
58
+ if (attached) return;
59
+ canvas.setAttribute("role", "img"); canvas.setAttribute("aria-labelledby", labelledBy); canvas.after(root); attached = true;
60
+ },
61
+ destroy() {
62
+ root.remove();
63
+ if (canvas.getAttribute("aria-labelledby") === labelledBy) { canvas.removeAttribute("role"); canvas.removeAttribute("aria-labelledby"); }
64
+ attached = false;
65
+ },
66
+ };
67
+ }
68
+
69
+ export function createAccessibilityCompanion(canvas, contract, registry, options) {
70
+ const companion = prepareAccessibilityCompanion(canvas, contract, registry, options);
71
+ companion.attach();
72
+ return companion;
33
73
  }
@@ -57,12 +57,13 @@ function drawAxes(context, panel, theme) {
57
57
  context.lineWidth = Math.max(0.6, 0.85 * layout.scale);
58
58
  context.strokeStyle = theme.spine; context.beginPath(); context.moveTo(plot.left, plot.top); context.lineTo(plot.left, plot.bottom); context.lineTo(plot.right, plot.bottom); context.stroke();
59
59
  const xSlot = axes.x.step?.() ?? Math.max(40, (plot.right - plot.left) / Math.max(1, axes.xTicks.length));
60
- const rotateX = axes.xType === "band" && axes.xTicks.some((tick) => context.measureText(tick.label).width > xSlot * 0.92);
60
+ const rotateX = layout.text?.rotateX ?? (axes.xType === "band" && axes.xTicks.some((tick) => context.measureText(tick.label).width > xSlot * 0.92));
61
61
  context.textAlign = rotateX ? "right" : "center"; context.textBaseline = "top";
62
62
  axes.xTicks.forEach((tick) => {
63
63
  const x = tickPosition(axes.x, tick), label = fitLabel(context, tick.label, rotateX ? xSlot * 1.75 : xSlot * 0.92);
64
- if (rotateX) { context.save(); context.translate(x, plot.bottom + 7 * layout.scale); context.rotate(-Math.PI / 4); context.fillText(label, 0, 0); context.restore(); }
65
- else context.fillText(label, x, plot.bottom + 7 * layout.scale);
64
+ const y = layout.text?.xTickY ?? plot.bottom + 7 * layout.scale;
65
+ if (rotateX) { context.save(); context.translate(x, y); context.rotate(-Math.PI / 4); context.fillText(label, 0, 0); context.restore(); }
66
+ else context.fillText(label, x, y);
66
67
  });
67
68
  context.textAlign = "right"; context.textBaseline = "middle";
68
69
  axes.yTicks.forEach((tick) => context.fillText(fitLabel(context, tick.label, Math.max(26, plot.left - layout.rect.left - 12 * layout.scale)), plot.left - 7 * layout.scale, tickPosition(axes.y, tick)));
@@ -132,8 +133,14 @@ function drawPanelText(context, panel, theme) {
132
133
  const { layout, spec } = panel;
133
134
  context.save(); context.textAlign = "left"; context.textBaseline = "alphabetic";
134
135
  context.fillStyle = theme.mode === "paper" ? theme.label : theme.primary; context.font = `500 ${layout.font.title}px ${FONT_STACK}`;
135
- context.fillText(spec.title || panel.renderer, layout.plot.left, layout.text?.titleY ?? layout.rect.top + 20 * layout.scale);
136
- if (spec.subtitle) { context.fillStyle = theme.secondary; context.font = `italic ${layout.font.subtitle}px ${FONT_STACK}`; context.fillText(spec.subtitle, layout.plot.left, layout.text?.subtitleY ?? layout.rect.top + 39 * layout.scale); }
136
+ const responsive = layout.headerText;
137
+ if (responsive) responsive.title.lines.forEach((line, index) => context.fillText(line, layout.plot.left, responsive.title.baselines[index]));
138
+ else context.fillText(spec.title || panel.renderer, layout.plot.left, layout.text?.titleY ?? layout.rect.top + 20 * layout.scale);
139
+ if (spec.subtitle) {
140
+ context.fillStyle = theme.secondary; context.font = `italic ${layout.font.subtitle}px ${FONT_STACK}`;
141
+ if (responsive) responsive.subtitle.lines.forEach((line, index) => context.fillText(line, layout.plot.left, responsive.subtitle.baselines[index]));
142
+ else context.fillText(spec.subtitle, layout.plot.left, layout.text?.subtitleY ?? layout.rect.top + 39 * layout.scale);
143
+ }
137
144
  if (theme.mode !== "paper" && spec.signature && (layout.panelIndex ?? 0) === 0) {
138
145
  const provenance = layout.provenance ?? { left: layout.plot.left, y: layout.rect.bottom - 8 * layout.scale };
139
146
  context.fillStyle = theme.faint; context.font = `${layout.font.signature}px ${FONT_STACK}`; context.textAlign = "left";
package/src/clock.js CHANGED
@@ -1,27 +1,38 @@
1
1
  const clamp = (value) => Math.max(0, Math.min(1, value));
2
2
 
3
3
  export class AnimationClock {
4
- constructor({ durationMs, draw, onState = () => {}, raf = globalThis.requestAnimationFrame?.bind(globalThis), cancel = globalThis.cancelAnimationFrame?.bind(globalThis) }) {
5
- this.durationMs = durationMs; this.draw = draw; this.onState = onState;
4
+ constructor({ durationMs, draw, onState = () => {}, onError = null, raf = globalThis.requestAnimationFrame?.bind(globalThis), cancel = globalThis.cancelAnimationFrame?.bind(globalThis) }) {
5
+ this.durationMs = durationMs; this.draw = draw; this.onState = onState; this.onError = typeof onError === "function" ? onError : null;
6
6
  this.raf = raf || ((fn) => setTimeout(() => fn(performance.now()), 16));
7
7
  this.cancel = cancel || clearTimeout; this.progress = 0; this.playing = false;
8
- this.frame = null; this.startedAt = null; this.destroyed = false;
8
+ this.frame = null; this.startedAt = null; this.destroyed = false; this.failed = false;
9
+ }
10
+ fail(error) {
11
+ this.playing = false; this.frame = null; this.startedAt = null; this.failed = true;
12
+ if (!this.onError) throw error;
13
+ try { this.onError(error, Object.freeze({ phase: "draw", progress: this.progress })); } catch { /* Error reporting must not reopen or destabilize the draw loop. */ }
14
+ }
15
+ render(progress = this.progress) {
16
+ if (this.destroyed || this.failed) return false;
17
+ this.progress = clamp(progress);
18
+ try { this.draw(this.progress); } catch (error) { this.fail(error); return false; }
19
+ return true;
9
20
  }
10
- render(progress = this.progress) { this.progress = clamp(progress); this.draw(this.progress); }
11
21
  play() {
12
- if (this.destroyed || this.playing || this.progress >= 1) return;
22
+ if (this.destroyed || this.failed || this.playing || this.progress >= 1) return;
13
23
  this.playing = true; this.startedAt = null; this.onState("playing");
14
24
  const tick = (now) => {
15
25
  if (!this.playing || this.destroyed) return;
16
26
  if (this.startedAt == null) this.startedAt = now - this.progress * this.durationMs;
17
- this.render((now - this.startedAt) / this.durationMs);
27
+ if (!this.render((now - this.startedAt) / this.durationMs)) return;
18
28
  if (this.progress >= 1) { this.playing = false; this.frame = null; this.onState("complete"); return; }
19
29
  this.frame = this.raf(tick);
20
30
  };
21
31
  this.frame = this.raf(tick);
22
32
  }
23
33
  pause() { if (!this.playing) return; this.playing = false; if (this.frame != null) this.cancel(this.frame); this.frame = null; this.onState("paused"); }
24
- replay() { this.pause(); this.render(0); this.play(); }
25
- settle() { this.pause(); this.render(1); this.onState("complete"); }
34
+ replay() { this.pause(); if (this.render(0)) this.play(); }
35
+ settle() { this.pause(); if (this.render(1)) this.onState("complete"); }
36
+ resetFailure() { this.failed = false; }
26
37
  destroy() { this.pause(); this.destroyed = true; this.draw = () => {}; }
27
38
  }
@@ -5,9 +5,15 @@ import { prepareLine, drawLine } from "./renderers/line.js";
5
5
  import { prepareScatter, drawScatter } from "./renderers/scatter.js";
6
6
  import { prepareStrip, drawStrip, compileStripScene } from "./renderers/strip-summary.js";
7
7
 
8
+ // Core numeric axes have one precedence rule: the authored scale constraint,
9
+ // then the retained renderer-data override, then the finite data extent.
10
+ export const resolveNumericDomain = (contract, axis, automaticDomain) => (
11
+ contract[`${axis}Scale`]?.domain ?? contract.data[`${axis}Domain`] ?? automaticDomain
12
+ );
13
+
8
14
  const pointDomains = (contract, prepared) => ({
9
- x: contract.data.xDomain || extent(prepared.points.map((point) => point.x)),
10
- y: contract.data.yDomain || extent(prepared.points.map((point) => point.y)),
15
+ x: resolveNumericDomain(contract, "x", extent(prepared.points.map((point) => point.x))),
16
+ y: resolveNumericDomain(contract, "y", extent(prepared.points.map((point) => point.y))),
11
17
  });
12
18
 
13
19
  export const LINE_RENDERER = {
@@ -25,7 +31,7 @@ export const SCATTER_RENDERER = {
25
31
  export const STRIP_RENDERER = {
26
32
  key: "strip_summary", family: "distribution", apiVersion: RENDERER_API_VERSION,
27
33
  validateData: normalizeStripData, prepare: prepareStrip, compileScene: compileStripScene, draw: drawStrip,
28
- domains(contract, prepared) { return { x: [-0.5, contract.data.groups.length - 0.5], y: contract.data.yDomain || extent(prepared.points.map((point) => point.y)) }; },
34
+ domains(contract, prepared) { return { x: [-0.5, contract.data.groups.length - 0.5], y: resolveNumericDomain(contract, "y", extent(prepared.points.map((point) => point.y))) }; },
29
35
  describe(contract) { return { summary: `${contract.data.values.length} observations across ${contract.data.groups.length} ordered groups.`, headers: ["group", contract.spec.yLabel || "value", "series"], rows: contract.data.values.map((value, index) => [contract.data.group[index], value, contract.data.seriesLabels[contract.data.series[index]]]) }; },
30
36
  };
31
37