@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 +179 -5
- package/THIRD_PARTY_NOTICES.md +12 -5
- package/package.json +42 -5
- package/src/accessibility.js +51 -11
- package/src/canvas-scene.js +12 -5
- package/src/clock.js +19 -8
- package/src/core-renderers.js +9 -3
- package/src/create-matrix-plot.js +86 -30
- package/src/export-bundle.js +1 -1
- package/src/extensions/temporal/shared.js +1 -1
- package/src/figure-layout.js +3 -2
- package/src/height-negotiation.js +77 -0
- package/src/layout.js +4 -2
- package/src/marks.js +2 -2
- package/src/paper-profile.js +5 -1
- package/src/renderers/scatter.js +3 -2
- package/src/resolved-scene.js +42 -3
- package/src/responsive-header.js +159 -0
- package/src/schema.js +32 -9
- package/src/scientific-layout.js +148 -0
- package/src/screen-legibility.js +30 -0
- package/src/statistics.js +21 -0
- package/src/svg-export.js +23 -8
- package/src/terminal-scene.js +2 -3
- package/themes/deep_observatory_sage_core.json +28 -0
- package/themes/lavender_fog_notebook.json +29 -0
- package/themes/midnight_transit_signal_slate.json +28 -0
- package/themes/registration_ink.json +28 -0
- package/themes/slipware.json +28 -0
- package/themes/ultraviolet_laboratory.json +29 -0
- package/types/extensions/temporal.d.ts +45 -0
- package/types/index.d.ts +640 -0
- package/types/theme-json.d.ts +4 -0
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.
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
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).
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
29
|
-
playwright-core 1.
|
|
30
|
-
Apache-2.0
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
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": ">=
|
|
18
|
+
"node": ">=22.22.0"
|
|
19
19
|
},
|
|
20
|
+
"types": "./types/index.d.ts",
|
|
20
21
|
"exports": {
|
|
21
|
-
".":
|
|
22
|
-
|
|
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
|
}
|
package/src/accessibility.js
CHANGED
|
@@ -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
|
|
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);
|
|
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
|
-
|
|
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"),
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
}
|
package/src/canvas-scene.js
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
136
|
-
if (
|
|
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)
|
|
25
|
-
settle() { this.pause(); this.render(1)
|
|
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
|
}
|
package/src/core-renderers.js
CHANGED
|
@@ -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
|
|
10
|
-
y: contract
|
|
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
|
|
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
|
|