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

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,218 @@
1
1
  # @figurestead/web experimental public alpha
2
2
 
3
+ This README targets `0.9.0-alpha.3`. Registry commands require that exact version to be available; review candidates are installed from their retained tarball. To follow the prerelease channel intentionally, use `npm install @figurestead/web@alpha`; an unqualified install follows `latest`, not necessarily this alpha.
4
+
5
+ ```sh
6
+ npm install @figurestead/web@0.9.0-alpha.3
7
+ ```
8
+
3
9
  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`.
10
+ renderer-registry APIs. The complete temporal extension is available from
11
+ `@figurestead/web/extensions/temporal`; it is intentionally not a root export.
12
+
13
+ This package ships first-party TypeScript declarations for the root and
14
+ temporal surfaces. Import contract, controller, option, state, error, theme,
15
+ and temporal types directly from those package entrypoints—no downstream
16
+ declaration shim or shadow schema is needed. TypeScript provides structural
17
+ guidance; runtime validation remains authoritative for semantic constraints
18
+ such as finite data, ordered domains, canonical colors, and matching
19
+ cardinalities.
20
+
21
+ Rendering requires a complete normalized figure contract. The repository's
22
+ [runnable browser first-success example](https://github.com/CharlesMish/figurestead/tree/main/examples/browser-first-success)
23
+ remains useful for source development. An installed-package consumer can render
24
+ the same small scientific line without cloning the repository. Save these two
25
+ files after installing `@figurestead/web` and run them through a normal ESM
26
+ browser tool such as Vite.
27
+
28
+ ```sh
29
+ npm install @figurestead/web@0.9.0-alpha.3
30
+ ```
31
+
32
+ <!-- figurestead-npm-first-success:index.html -->
33
+ ```html
34
+ <!doctype html>
35
+ <html lang="en">
36
+ <head>
37
+ <meta charset="utf-8">
38
+ <meta name="viewport" content="width=device-width, initial-scale=1">
39
+ <title>Figurestead first success</title>
40
+ <style>
41
+ body { margin: 24px; background: #f1eee5; }
42
+ canvas { display: block; width: min(100%, 760px); height: auto; background: #fffdfa; }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <canvas id="figure" width="760" height="520" aria-label="Three-point Figurestead line example"></canvas>
47
+ <script type="module" src="./main.js"></script>
48
+ </body>
49
+ </html>
50
+ ```
51
+
52
+ <!-- figurestead-npm-first-success:main.js -->
53
+ ```js
54
+ import {
55
+ createFigurestead,
56
+ resolveTheme,
57
+ validateThemePack,
58
+ } from "@figurestead/web";
59
+ import slipwarePack from "@figurestead/web/themes/slipware" with { type: "json" };
60
+
61
+ const theme = resolveTheme(validateThemePack(slipwarePack), "slipware");
62
+ const contract = {
63
+ schemaVersion: "0.4",
64
+ rendererApiVersion: "1",
65
+ theme,
66
+ 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 },
67
+ timeline: { rainIn: [0, 0], marksEnter: [0, 1], summaryCompiles: [0.8, 1], rainOut: [0, 0], settle: [0.9, 1] },
68
+ motion: { frames: 1, fps: 1, rainStreams: 0, rainGlyphs: 0, lightingPeak: 0, trailAlpha: 0, seed: 1, durationMs: 1 },
69
+ style: { glyphs: ["ring", "square", "triangle", "diamond"], lineStyles: ["solid", "dash", "dot", "dash-dot"], series: {} },
70
+ 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." },
71
+ layout: { type: "grid", columns: 1, gap: 18, sharedX: false, sharedY: false },
72
+ view: { profile: "atlas", motion: "none", ambient: "none", strategy: "none" },
73
+ 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] }] } }],
74
+ };
75
+
76
+ const canvas = document.querySelector("#figure");
77
+ const figure = createFigurestead(canvas, contract, { autoplay: false, reducedMotion: true });
78
+ figure.resize();
79
+ document.documentElement.dataset.figuresteadReady = "true";
80
+ ```
81
+
82
+ This package also ships six curated theme subpaths generated byte-for-byte from
83
+ Figurestead's canonical theme JSON rather than retyped as JavaScript constants.
84
+ For example, in Node or TypeScript:
85
+
86
+ ```ts
87
+ import {
88
+ applyTheme,
89
+ resolveTheme,
90
+ type FiguresteadContract,
91
+ } from "@figurestead/web";
92
+ import slipwarePack from "@figurestead/web/themes/slipware" with { type: "json" };
93
+
94
+ export function withSlipware(contract: FiguresteadContract): FiguresteadContract {
95
+ return applyTheme(contract, resolveTheme(slipwarePack, "slipware"));
96
+ }
97
+ ```
98
+
99
+ The same explicit subpath works through the tested Vite browser route. Other
100
+ curated subpaths are `registration-ink`, `ultraviolet-laboratory`,
101
+ `lavender-fog-notebook`, `midnight-transit-signal-slate`, and
102
+ `deep-observatory-sage-core` under `@figurestead/web/themes/`.
103
+
104
+ The declarations and all six curated-theme subpaths above are included in
105
+ `@figurestead/web@0.9.0-alpha.3`.
106
+
107
+ Python and browser surfaces share normalized contract vocabulary and selected
108
+ theme definitions. Shared semantics do not imply pixel-identical output or
109
+ identical renderer coverage. In particular, the populated categorical matrix
110
+ is currently Python-rendered; this package does not claim a browser categorical
111
+ matrix renderer.
112
+
113
+ ## Line identity and direct labels
114
+
115
+ Default line series combine color with persistent open **circle (S1), square (S2), and upright triangle (S3)** markers and matching line-and-marker legends. Line rhythm remains an independent semantic channel. `setData` retains established keyed identity through supported filtering/reordering and partial style overrides. `setConfig` replaces the contract.
116
+
117
+ Lavender Fog Notebook is the reference light theme; Ultraviolet Laboratory is the reference dark theme for the [bounded three-series line profile](https://github.com/CharlesMish/figurestead/blob/main/docs/reference-themes.md), not universal accessibility, CVD or print qualification. Product defaults are unchanged.
118
+
119
+ Opt in with `style: { ...contract.style, directLabels: true }` (`style.directLabels: true`). Direct labels reuse the actual body marker identity beside the traces. V1 covers ordinary one-panel, 2–3-series line figures with single-line printable ASCII labels. Unsupported or insufficient layouts fall back atomically to the ordinary legend. This treatment does not support Unicode, multiline or math interpretation; printable ASCII math punctuation is literal. Active browser transitions use ordinary treatment until settled, and exports without trustworthy measurement retain the ordinary legend.
120
+
121
+ See the [ordinary/direct-label example](https://github.com/CharlesMish/figurestead/tree/main/examples/direct-series-labels) and [detailed direct-label contract](https://github.com/CharlesMish/figurestead/blob/main/docs/direct-series-labels.md). The [explicit rendered-series contrast audit](https://github.com/CharlesMish/figurestead/blob/main/docs/rendered-series-contrast.md) measures caller-specified rendering facts separately from the static palette audit.
122
+
123
+ ## Controller failures
124
+
125
+ `setConfig(next)` is transactional: a validation, compilation, renderer
126
+ preparation, scene-resolution, composition, or accessibility-description error
127
+ leaves the last accepted contract, scenes, pixels, companion, and motion state
128
+ active. The original configuration error is thrown to the caller.
129
+
130
+ Hosts may observe asynchronous renderer failures with the existing creation
131
+ options pattern:
132
+
133
+ ```js
134
+ const figure = createFigurestead(canvas, contract, {
135
+ onError(error, context) {
136
+ // context.phase is "draw" or "height-negotiation".
137
+ // error is the original renderer or host-callback error object.
138
+ },
139
+ });
140
+ ```
141
+
142
+ After a draw failure the controller is stopped, reports `playing: false` and
143
+ `runtimeFailed: true`, and will not redraw through play, replay, resize, or
144
+ reduced-motion changes. A later valid `setConfig()` clears that state and
145
+ renders normally. Lifecycle notifications remain on `onState`; runtime failures
146
+ are reported only through `onError`.
147
+
148
+ ## Responsive headers and host-owned height
149
+
150
+ At compact live-Canvas widths, Figurestead preserves the established plot
151
+ geometry and uses a bounded two-line title plus a one-line ellipsized subtitle
152
+ when the host does not opt in to more height. Complete strings remain in the
153
+ associated accessibility companion. Figurestead never changes host CSS by
154
+ default.
155
+
156
+ An auto-height host may opt in with one mount-scoped adapter. Figurestead's
157
+ current measurement convention is the CSS-pixel border box reported by
158
+ `canvas.getBoundingClientRect()` (including any reflected CSS transform). Retain
159
+ the baseline independently in that same measurement space; do not derive it
160
+ from the canvas after applying an earlier request:
161
+
162
+ ```js
163
+ const baselineHeight = 196;
164
+ const figure = createFigurestead(canvas, contract, {
165
+ heightNegotiation: {
166
+ getBaselineHeight() { return baselineHeight; },
167
+ requestPreferredHeight({ preferredHeight, signal }) {
168
+ if (!signal.aborted) canvas.style.height = `${preferredHeight}px`;
169
+ },
170
+ },
171
+ });
172
+ ```
173
+
174
+ State-driven or delayed hosts may apply on a later frame. The return value is
175
+ not an acknowledgement; the host applies the absolute height and Figurestead's
176
+ existing resize observation resolves the granted layout:
177
+
178
+ ```js
179
+ requestPreferredHeight({ preferredHeight, signal }) {
180
+ requestAnimationFrame(() => {
181
+ if (!signal.aborted) canvas.style.height = `${preferredHeight}px`;
182
+ });
183
+ }
184
+ ```
185
+
186
+ Accepted contract, width, baseline, remount, and destroy transitions abort the
187
+ prior signal. A host may decline or clamp a request; the fixed-height fallback
188
+ then remains active without automatic retry in that generation. Host-applied
189
+ CSS remains host state after `destroy()`. Before remount, the host either keeps
190
+ that height intentionally or restores its independent baseline source; residual
191
+ preferred height is never inferred as the next baseline by Figurestead.
192
+ Height negotiation applies only to live Canvas rendering. SVG, paper, and
193
+ explicitly dimensioned exports retain their requested dimensions.
194
+
195
+ Version 0.9.0-alpha.3. [Source and full project documentation](https://github.com/CharlesMish/figurestead).
196
+
197
+ ## Rendered series contrast
198
+
199
+ `contrastAudit(theme)` remains a static authored-palette inspection.
200
+ `renderedSeriesAudit(theme, { substrate, opacity, compositing: "srgb-source-over" })`
201
+ measures the supplied theme's series colors in the caller-supplied single-layer
202
+ context. The opaque `#RRGGBB` substrate and opacity in [0,1] are required; no
203
+ renderer/surface defaults are inferred. Results retain unrounded effective sRGB
204
+ channels and ratio. The exact policy is `passes = ratio >= 3`, without an epsilon
205
+ or display-rounding rule. Values effectively on the floating-point threshold may
206
+ classify differently across Python/JavaScript final ULPs; this is not physical
207
+ certification. Figures and colors are never changed.
208
+
209
+ Nonempty **theme-level** `seriesEdges` are conservatively rejected. Per-series
210
+ contract/style overrides can independently change the rendered color or add an
211
+ edge; they are not inspected by that theme-level check. The caller must account
212
+ for those overrides before interpreting the result as a rendered-mark measurement.
7
213
 
8
- Version 0.9.0-alpha.1. Source:
9
- <https://github.com/CharlesMish/figurestead/tree/main/web>.
214
+ Verified Canvas line **segments** use panel or field with effective .78 (Firefox
215
+ exposes its float32 value); SVG line **segments** use panel or field at 1. Python's
216
+ verified default line **polyline** uses field/.88. Companion markers/points may
217
+ have different opacity and are outside those contexts. This is not an assessment
218
+ of glow, antialias boundaries, every layered mark or whole-figure accessibility.
@@ -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.3",
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
  }
@@ -1,13 +1,12 @@
1
+ import { DIRECT_FONT } from "./direct-labels.js";
2
+ import { appendMarkerPath, clipOwnLine, lineMarkerGeometry } from "./line-identity.js";
1
3
  import { partitionPanelMarks, withCanvasPlotClip } from "./render-layers.js";
2
4
 
3
5
  const FONT_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
4
6
 
5
7
  function markerPath(context, glyph, x, y, radius) {
6
8
  context.beginPath();
7
- if (glyph === "square") context.rect(x - radius, y - radius, radius * 2, radius * 2);
8
- else if (glyph === "triangle") { context.moveTo(x, y - radius); context.lineTo(x + radius, y + radius); context.lineTo(x - radius, y + radius); context.closePath(); }
9
- else if (glyph === "diamond") { context.moveTo(x, y - radius); context.lineTo(x + radius, y); context.lineTo(x, y + radius); context.lineTo(x - radius, y); context.closePath(); }
10
- else context.arc(x, y, radius, 0, Math.PI * 2);
9
+ appendMarkerPath(context, glyph, x, y, radius);
11
10
  }
12
11
 
13
12
  function mixPoint(a, b, t) { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; }
@@ -57,12 +56,13 @@ function drawAxes(context, panel, theme) {
57
56
  context.lineWidth = Math.max(0.6, 0.85 * layout.scale);
58
57
  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
58
  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);
59
+ const rotateX = layout.text?.rotateX ?? (axes.xType === "band" && axes.xTicks.some((tick) => context.measureText(tick.label).width > xSlot * 0.92));
61
60
  context.textAlign = rotateX ? "right" : "center"; context.textBaseline = "top";
62
61
  axes.xTicks.forEach((tick) => {
63
62
  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);
63
+ const y = layout.text?.xTickY ?? plot.bottom + 7 * layout.scale;
64
+ if (rotateX) { context.save(); context.translate(x, y); context.rotate(-Math.PI / 4); context.fillText(label, 0, 0); context.restore(); }
65
+ else context.fillText(label, x, y);
66
66
  });
67
67
  context.textAlign = "right"; context.textBaseline = "middle";
68
68
  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)));
@@ -71,7 +71,18 @@ function drawAxes(context, panel, theme) {
71
71
  context.restore();
72
72
  }
73
73
 
74
+ function drawDirectLabels(context, plan, theme) {
75
+ context.save(); context.font = `${plan.font}px ${DIRECT_FONT}`; context.textAlign = "left"; context.textBaseline = "alphabetic"; context.direction = "ltr";
76
+ for (const e of plan.entries) {
77
+ if (e.leader) { const l=e.leader;context.globalAlpha=1;context.strokeStyle=l.color;context.lineWidth=.7;context.setLineDash([]);context.beginPath();context.moveTo(l.x1,l.y1);context.lineTo(l.x2,l.y2);context.stroke(); }
78
+ drawPoint(context, {...e.marker, motion:{opacity:1,scaleX:1,scaleY:1,translateX:0,translateY:0}});
79
+ context.globalAlpha=1;context.fillStyle=theme.label;context.fillText(e.label,e.textX,e.textY);
80
+ }
81
+ context.restore();
82
+ }
83
+
74
84
  function drawLegend(context, panel, theme) {
85
+ if (panel.directLabelPlan?.status === "placed") return drawDirectLabels(context, panel.directLabelPlan, theme);
75
86
  if (panel.presentation?.legend === "none" || !panel.legend.length) return;
76
87
  const { layout } = panel, outside = layout.legend.outside;
77
88
  context.save(); context.font = `${layout.font.legend}px ${FONT_STACK}`; context.textBaseline = "middle";
@@ -81,8 +92,17 @@ function drawLegend(context, panel, theme) {
81
92
  const x = entry?.markerX ?? (outside ? layout.legend.left : layout.plot.right - 24 * layout.scale);
82
93
  const textX = entry?.textX ?? (outside ? x + 12 * layout.scale : x - 10 * layout.scale);
83
94
  const y = entry?.y ?? (outside ? layout.legend.top + (14 + index * 20) * layout.scale : insideTop + index * 20 * layout.scale);
84
- context.strokeStyle = style.edge ?? style.color ?? theme.series[item.colorIndex % theme.series.length]; context.lineWidth = Math.max(1, 1.2 * layout.scale);
85
- markerPath(context, style.glyph ?? "ring", x, y, 4 * layout.scale); context.stroke();
95
+ if (panel.renderer === "line") {
96
+ const geometry = { cx: x, cy: y, ...lineMarkerGeometry(style, layout.scale, panel.presentation?.markerScale ?? 1) };
97
+ const point = { lineIdentity: true, style, geometry, motion: { opacity: 1, scaleX: 1, scaleY: 1, translateX: 0, translateY: 0 } };
98
+ const half = 12 * Math.max(1, layout.scale);
99
+ drawLine(context, { style, geometry: { x1: x - half, y1: y, x2: x + half, y2: y }, motion: { opacity: 1, clip: 1 } }, theme, [point],
100
+ { left: x - half - 3, right: x + half + 3, top: y - 10, bottom: y + 10 });
101
+ drawPoint(context, point);
102
+ } else {
103
+ context.strokeStyle = style.edge ?? style.color ?? theme.series[item.colorIndex % theme.series.length]; context.lineWidth = Math.max(1, 1.2 * layout.scale);
104
+ markerPath(context, style.glyph ?? "ring", x, y, 4 * layout.scale); context.stroke();
105
+ }
86
106
  context.fillStyle = theme.label;
87
107
  context.textAlign = entry?.textAnchor ?? (outside ? "left" : "right"); context.fillText(entry?.displayLabel ?? item.label, textX, y);
88
108
  });
@@ -132,8 +152,14 @@ function drawPanelText(context, panel, theme) {
132
152
  const { layout, spec } = panel;
133
153
  context.save(); context.textAlign = "left"; context.textBaseline = "alphabetic";
134
154
  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); }
155
+ const responsive = layout.headerText;
156
+ if (responsive) responsive.title.lines.forEach((line, index) => context.fillText(line, layout.plot.left, responsive.title.baselines[index]));
157
+ else context.fillText(spec.title || panel.renderer, layout.plot.left, layout.text?.titleY ?? layout.rect.top + 20 * layout.scale);
158
+ if (spec.subtitle) {
159
+ context.fillStyle = theme.secondary; context.font = `italic ${layout.font.subtitle}px ${FONT_STACK}`;
160
+ if (responsive) responsive.subtitle.lines.forEach((line, index) => context.fillText(line, layout.plot.left, responsive.subtitle.baselines[index]));
161
+ else context.fillText(spec.subtitle, layout.plot.left, layout.text?.subtitleY ?? layout.rect.top + 39 * layout.scale);
162
+ }
137
163
  if (theme.mode !== "paper" && spec.signature && (layout.panelIndex ?? 0) === 0) {
138
164
  const provenance = layout.provenance ?? { left: layout.plot.left, y: layout.rect.bottom - 8 * layout.scale };
139
165
  context.fillStyle = theme.faint; context.font = `${layout.font.signature}px ${FONT_STACK}`; context.textAlign = "left";
@@ -159,16 +185,29 @@ function drawBandKey(context, panel, theme) {
159
185
 
160
186
  function drawPoint(context, mark) {
161
187
  const motion = mark.motion, g = mark.geometry, x = g.cx + motion.translateX, y = g.cy + motion.translateY, radius = g.radius * Math.min(motion.scaleX, motion.scaleY);
162
- context.save(); context.globalAlpha = motion.opacity; context.strokeStyle = mark.style.edge ?? mark.style.color; context.lineWidth = Math.max(1.6, radius * 0.58);
188
+ context.save(); context.globalAlpha = motion.opacity;
189
+ if (mark.lineIdentity) {
190
+ context.setLineDash([]);
191
+ context.strokeStyle = mark.style.edge ?? mark.style.color;
192
+ context.lineWidth = g.outlineWidth + (mark.style.edge ? 1.3 : 0);
193
+ markerPath(context, mark.style.glyph, x, y, radius); context.stroke();
194
+ if (mark.style.edge) {
195
+ context.strokeStyle = mark.style.color; context.lineWidth = g.outlineWidth;
196
+ markerPath(context, mark.style.glyph, x, y, radius); context.stroke();
197
+ }
198
+ context.restore(); return;
199
+ }
200
+ context.strokeStyle = mark.style.edge ?? mark.style.color; context.lineWidth = Math.max(1.6, radius * 0.58);
163
201
  markerPath(context, mark.style.glyph, x, y, radius); context.stroke();
164
202
  context.strokeStyle = mark.style.color; context.lineWidth = Math.max(0.9, radius * 0.24); markerPath(context, mark.style.glyph, x, y, radius); context.stroke();
165
203
  if (motion.glow > 0) { context.globalAlpha = motion.glow; context.lineWidth = radius; markerPath(context, mark.style.glyph, x, y, radius * 1.45); context.stroke(); }
166
204
  context.restore();
167
205
  }
168
206
 
169
- function drawLine(context, mark, theme) {
207
+ function drawLine(context, mark, theme, markers = [], plot = null) {
170
208
  const motion = mark.motion;
171
209
  context.save(); context.globalAlpha = motion.opacity * (theme.mode === "paper" ? 1 : 0.78); context.strokeStyle = mark.style.edge ?? mark.style.color;
210
+ if (markers.length) clipOwnLine(context, markers, plot);
172
211
  context.lineWidth = Math.max(1, (mark.style.lineWidth ?? 1.6) + (mark.style.edge ? 1.3 : 0));
173
212
  context.setLineDash?.(mark.style.lineStyle === "dash" ? [7, 4] : mark.style.lineStyle === "dot" ? [2, 4] : mark.style.lineStyle === "dash-dot" ? [8, 3, 2, 3] : []);
174
213
  strokeSegment(context, mark.geometry, motion.clip);
@@ -291,7 +330,9 @@ export function drawResolvedPanel(context, frame, panelIndex) {
291
330
  const drawMark = (mark) => {
292
331
  if (!mark.geometry || mark.motion.opacity <= 0) return;
293
332
  if (mark.kind === "point") drawPoint(context, mark);
294
- else if (["segment", "summary-line"].includes(mark.kind)) drawLine(context, mark, theme);
333
+ else if (["segment", "summary-line"].includes(mark.kind)) drawLine(context, mark, theme,
334
+ panel.renderer === "line" ? panel.marks.filter(p => p.lineIdentity && p.series === mark.series) : [],
335
+ panel.axes.plot ?? panel.layout.plot);
295
336
  else if (mark.kind === "median-rule") drawLine(context, mark, theme);
296
337
  else if (mark.kind === "bar") drawBar(context, mark, theme);
297
338
  else if (mark.kind === "cell") drawCell(context, mark, theme, panel.layout.font.axis);
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
  }
@@ -54,14 +54,22 @@ function fitText(value, maxWidth, fontSize) {
54
54
  return `${text.slice(0, Math.max(1, maxChars - 1))}…`;
55
55
  }
56
56
 
57
+ function lineLegendStep(panel) {
58
+ // Body-sized markers need unscaled clearance even in compact layouts.
59
+ const halfHeight = panel.marks.filter(mark => mark.lineIdentity)
60
+ .reduce((size, mark) => Math.max(size, mark.geometry.radius + mark.geometry.outlineWidth / 2), 0);
61
+ return 2 * halfHeight + 4;
62
+ }
63
+
57
64
  function legendDimensions(panel) {
58
65
  const scale = panel.layout.scale;
59
66
  const font = panel.layout.font.legend;
60
67
  const plot = panel.axes.plot ?? panel.layout.plot;
61
68
  const labelWidth = panel.legend.reduce((width, item) => Math.max(width, String(item.label).length * font * 0.62), 0);
62
69
  return {
63
- width: Math.min(Math.max(72 * scale, labelWidth + 34 * scale), Math.max(24, plot.right - plot.left - 24 * scale)),
64
- height: Math.min(Math.max(18 * scale, (12 + Math.max(0, panel.legend.length - 1) * 20) * scale), Math.max(18, plot.bottom - plot.top - 24 * scale)),
70
+ width: Math.min(Math.max(72 * scale, labelWidth + (panel.renderer === "line" ? 56 * Math.max(1, scale) : 34 * scale)), Math.max(24, plot.right - plot.left - 24 * scale)),
71
+ height: Math.min(panel.renderer === "line" ? lineLegendStep(panel) * panel.legend.length
72
+ : Math.max(18 * scale, (12 + Math.max(0, panel.legend.length - 1) * 20) * scale), Math.max(18, plot.bottom - plot.top - 24 * scale)),
65
73
  };
66
74
  }
67
75
 
@@ -83,14 +91,15 @@ function legendScore(panel, box) {
83
91
  function legendEntries(panel, box, position, outside) {
84
92
  const scale = panel.layout.scale, right = position.endsWith("right"), markerInset = 8 * scale;
85
93
  const count = panel.legend.length;
86
- const topInset = Math.min(12 * scale, box.height / 2);
87
- const bottomInset = Math.min(8 * scale, box.height / 2);
88
- const step = count > 1 ? Math.min(20 * scale, Math.max(1, (box.height - topInset - bottomInset) / (count - 1))) : 0;
94
+ const lineStep = panel.renderer === "line" ? lineLegendStep(panel) : null;
95
+ const topInset = Math.min(lineStep == null ? 12 * scale : lineStep / 2, box.height / 2);
96
+ const bottomInset = Math.min(lineStep == null ? 8 * scale : lineStep / 2, box.height / 2);
97
+ const step = count > 1 ? Math.min(lineStep ?? 20 * scale, Math.max(1, (box.height - topInset - bottomInset) / (count - 1))) : 0;
89
98
  return panel.legend.map((item, index) => {
90
99
  const y = clamp(box.top + topInset + index * step, box.top, box.bottom);
91
- const textAnchor = outside || !right ? "start" : "end";
92
- const markerX = textAnchor === "start" ? box.left + markerInset : box.right - markerInset;
93
- const textX = textAnchor === "start" ? box.left + 20 * scale : box.right - 20 * scale;
100
+ const textAnchor = panel.renderer === "line" || outside || !right ? "start" : "end";
101
+ const markerX = panel.renderer === "line" ? box.left + 18 * Math.max(1, scale) : textAnchor === "start" ? box.left + markerInset : box.right - markerInset;
102
+ const textX = panel.renderer === "line" ? box.left + 36 * Math.max(1, scale) : textAnchor === "start" ? box.left + 20 * scale : box.right - 20 * scale;
94
103
  const maxTextWidth = Math.max(8, textAnchor === "start" ? box.right - textX : textX - box.left);
95
104
  return { markerX, textX, y, textAnchor, displayLabel: fitText(item.label, maxTextWidth, panel.layout.font.legend) };
96
105
  });
@@ -194,6 +203,7 @@ export function composeResolvedScene(resolvedScene) {
194
203
  ...resolvedScene,
195
204
  schemaVersion: COMPOSED_SCENE_VERSION,
196
205
  resolvedSceneVersion: resolvedScene.schemaVersion,
206
+ ...(resolvedScene.fallbackScene ? { fallbackScene: composeResolvedScene(resolvedScene.fallbackScene) } : {}),
197
207
  panels,
198
208
  };
199
209
  result.compositionAudit = auditComposition(result);