@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 +214 -5
- package/THIRD_PARTY_NOTICES.md +12 -5
- package/package.json +42 -5
- package/src/accessibility.js +51 -11
- package/src/canvas-scene.js +55 -14
- package/src/clock.js +19 -8
- package/src/composition.js +18 -8
- package/src/core-renderers.js +9 -3
- package/src/create-matrix-plot.js +104 -34
- package/src/direct-labels.js +73 -0
- 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/index.js +1 -1
- package/src/layout.js +4 -2
- package/src/line-identity.js +29 -0
- 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 +64 -7
- package/src/responsive-header.js +159 -0
- package/src/schema.js +34 -10
- 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 +60 -16
- package/src/terminal-scene.js +11 -4
- package/src/theme-pack.js +31 -0
- 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 +665 -0
- package/types/theme-json.d.ts +4 -0
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.
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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.
|
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.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": ">=
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
136
|
-
if (
|
|
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;
|
|
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)
|
|
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/composition.js
CHANGED
|
@@ -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(
|
|
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
|
|
87
|
-
const
|
|
88
|
-
const
|
|
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);
|