@figurestead/web 0.9.0-alpha.1

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/THIRD_PARTY_NOTICES.md +39 -0
  4. package/TRADEMARKS.md +13 -0
  5. package/package.json +31 -0
  6. package/src/accessibility.js +33 -0
  7. package/src/appearance.js +25 -0
  8. package/src/application-profiles.js +76 -0
  9. package/src/atmosphere.js +71 -0
  10. package/src/canvas-scene.js +323 -0
  11. package/src/clock.js +27 -0
  12. package/src/color-space.js +114 -0
  13. package/src/composition.js +201 -0
  14. package/src/core-renderers.js +33 -0
  15. package/src/create-matrix-plot.js +97 -0
  16. package/src/evidence-coverage.js +80 -0
  17. package/src/export-bundle.js +83 -0
  18. package/src/extensions/temporal/coverage.js +96 -0
  19. package/src/extensions/temporal/index.js +43 -0
  20. package/src/extensions/temporal/observations.js +87 -0
  21. package/src/extensions/temporal/shared.js +172 -0
  22. package/src/figure-layout.js +70 -0
  23. package/src/figure.js +42 -0
  24. package/src/index.js +30 -0
  25. package/src/layout.js +41 -0
  26. package/src/marks.js +218 -0
  27. package/src/motion-plan.js +66 -0
  28. package/src/motion-recipes.js +39 -0
  29. package/src/paper-profile.js +78 -0
  30. package/src/physical-export.js +34 -0
  31. package/src/presentation.js +127 -0
  32. package/src/primitives.js +24 -0
  33. package/src/random.js +26 -0
  34. package/src/registry.js +29 -0
  35. package/src/render-layers.js +35 -0
  36. package/src/renderer-test-kit.js +29 -0
  37. package/src/renderers/line.js +133 -0
  38. package/src/renderers/scatter.js +24 -0
  39. package/src/renderers/shared.js +21 -0
  40. package/src/renderers/strip-summary.js +40 -0
  41. package/src/resolved-scene.js +249 -0
  42. package/src/scales.js +68 -0
  43. package/src/schema.js +356 -0
  44. package/src/series-style.js +59 -0
  45. package/src/svg-export.js +232 -0
  46. package/src/terminal-scene.js +215 -0
  47. package/src/theme-catalog.js +34 -0
  48. package/src/theme-pack.js +268 -0
@@ -0,0 +1,201 @@
1
+ export const COMPOSED_SCENE_VERSION = "figurestead.composed-scene/1";
2
+
3
+ const BACKGROUND_KINDS = new Set(["reference-band", "row-band"]);
4
+ const CORNERS = Object.freeze(["top-right", "top-left", "bottom-right", "bottom-left"]);
5
+
6
+ const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
7
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
8
+
9
+ function deepFreeze(value) {
10
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
11
+ Object.values(value).forEach(deepFreeze);
12
+ return Object.freeze(value);
13
+ }
14
+
15
+ function rect(left, top, right, bottom) {
16
+ return { left, top, right, bottom, width: Math.max(0, right - left), height: Math.max(0, bottom - top) };
17
+ }
18
+
19
+ function overlap(left, right) {
20
+ if (!left || !right) return 0;
21
+ return Math.max(0, Math.min(left.right, right.right) - Math.max(left.left, right.left))
22
+ * Math.max(0, Math.min(left.bottom, right.bottom) - Math.max(left.top, right.top));
23
+ }
24
+
25
+ function markBounds(mark) {
26
+ const g = mark.geometry;
27
+ if (!g) return null;
28
+ if (finite(g.cx) && finite(g.cy)) return rect(g.cx - (g.radius ?? 3), g.cy - (g.radius ?? 3), g.cx + (g.radius ?? 3), g.cy + (g.radius ?? 3));
29
+ if ([g.left, g.top, g.right, g.bottom].every(finite)) return rect(g.left, g.top, g.right, g.bottom);
30
+ if ([g.x1, g.y1, g.x2, g.y2].every(finite)) return rect(Math.min(g.x1, g.x2) - 2, Math.min(g.y1, g.y2) - 2, Math.max(g.x1, g.x2) + 2, Math.max(g.y1, g.y2) + 2);
31
+ if ([g.x1, g.x2, g.y].every(finite)) return rect(Math.min(g.x1, g.x2) - 2, g.y - (g.cap ?? 3), Math.max(g.x1, g.x2) + 2, g.y + (g.cap ?? 3));
32
+ if (finite(g.x) && finite(g.y)) return rect(g.x - 3, g.y - (g.halfHeight ?? 3), g.x + 3, g.y + (g.halfHeight ?? 3));
33
+ if (finite(g.x) && finite(g.top) && finite(g.bottom)) return rect(g.x - 2, g.top, g.x + 2, g.bottom);
34
+ return null;
35
+ }
36
+
37
+ function markAnchor(mark) {
38
+ const g = mark?.geometry;
39
+ if (!g) return null;
40
+ if (finite(g.cx) && finite(g.cy)) return { x: g.cx, y: g.cy };
41
+ if ([g.left, g.top, g.right, g.bottom].every(finite)) return { x: (g.left + g.right) / 2, y: (g.top + g.bottom) / 2 };
42
+ if ([g.x1, g.y1, g.x2, g.y2].every(finite)) return { x: g.x2, y: g.y2 };
43
+ if ([g.x1, g.x2, g.y].every(finite)) return { x: (g.x1 + g.x2) / 2, y: g.y };
44
+ if (finite(g.x) && finite(g.y)) return { x: g.x, y: g.y };
45
+ if (finite(g.x) && finite(g.top) && finite(g.bottom)) return { x: g.x, y: (g.top + g.bottom) / 2 };
46
+ return null;
47
+ }
48
+
49
+ function fitText(value, maxWidth, fontSize) {
50
+ const text = String(value);
51
+ const averageGlyph = Math.max(1, fontSize * 0.62);
52
+ const maxChars = Math.max(2, Math.floor(maxWidth / averageGlyph));
53
+ if (text.length <= maxChars) return text;
54
+ return `${text.slice(0, Math.max(1, maxChars - 1))}…`;
55
+ }
56
+
57
+ function legendDimensions(panel) {
58
+ const scale = panel.layout.scale;
59
+ const font = panel.layout.font.legend;
60
+ const plot = panel.axes.plot ?? panel.layout.plot;
61
+ const labelWidth = panel.legend.reduce((width, item) => Math.max(width, String(item.label).length * font * 0.62), 0);
62
+ 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)),
65
+ };
66
+ }
67
+
68
+ function legendCandidate(panel, position, dimensions) {
69
+ const plot = panel.layout.plot, pad = 12 * panel.layout.scale;
70
+ const left = position.endsWith("right") ? plot.right - pad - dimensions.width : plot.left + pad;
71
+ const top = position.startsWith("bottom") ? plot.bottom - pad - dimensions.height : plot.top + pad;
72
+ return rect(left, top, left + dimensions.width, top + dimensions.height);
73
+ }
74
+
75
+ function legendScore(panel, box) {
76
+ const area = Math.max(1, box.width * box.height);
77
+ return panel.marks.reduce((score, mark) => {
78
+ if (BACKGROUND_KINDS.has(mark.kind)) return score;
79
+ return score + overlap(box, markBounds(mark)) / area;
80
+ }, 0);
81
+ }
82
+
83
+ function legendEntries(panel, box, position, outside) {
84
+ const scale = panel.layout.scale, right = position.endsWith("right"), markerInset = 8 * scale;
85
+ 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;
89
+ return panel.legend.map((item, index) => {
90
+ 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;
94
+ const maxTextWidth = Math.max(8, textAnchor === "start" ? box.right - textX : textX - box.left);
95
+ return { markerX, textX, y, textAnchor, displayLabel: fitText(item.label, maxTextWidth, panel.layout.font.legend) };
96
+ });
97
+ }
98
+
99
+ function composeLegend(panel) {
100
+ if (panel.presentation?.legend === "none" || !panel.legend.length) return { ...panel.layout.legend, position: "none", box: null, entries: [] };
101
+ if (panel.layout.legend.outside || panel.presentation?.legend === "outside-right") {
102
+ const source = panel.layout.legend;
103
+ const box = rect(source.left, source.top, source.right, source.bottom);
104
+ return { ...source, position: "outside-right", box, entries: legendEntries(panel, box, "top-left", true) };
105
+ }
106
+ const dimensions = legendDimensions(panel);
107
+ const requested = panel.presentation?.legend ?? "auto";
108
+ const candidates = (requested === "auto" ? CORNERS : [requested]).map((position, order) => {
109
+ const box = legendCandidate(panel, position, dimensions);
110
+ return { position, box, score: legendScore(panel, box), order };
111
+ }).sort((left, right) => left.score - right.score || left.order - right.order);
112
+ const winner = candidates[0];
113
+ return { ...panel.layout.legend, outside: false, position: winner.position, box: winner.box, score: winner.score, entries: legendEntries(panel, winner.box, winner.position, false) };
114
+ }
115
+
116
+ function coordinateAnchor(panel, annotation) {
117
+ const plot = panel.axes.plot ?? panel.layout.plot;
118
+ if (annotation.space === "plot" && finite(annotation.x) && finite(annotation.y)) {
119
+ return { x: plot.left + clamp(annotation.x, 0, 1) * (plot.right - plot.left), y: plot.bottom - clamp(annotation.y, 0, 1) * (plot.bottom - plot.top) };
120
+ }
121
+ if (typeof panel.axes?.x !== "function" || typeof panel.axes?.y !== "function") return null;
122
+ const x = panel.axes.x(annotation.x), y = panel.axes.y(annotation.y);
123
+ return finite(x) && finite(y) ? { x, y } : null;
124
+ }
125
+
126
+ function labelCandidates(panel, annotation, anchor, legendBox, occupied = []) {
127
+ const plot = panel.axes.plot ?? panel.layout.plot, scale = panel.layout.scale;
128
+ const font = Math.max(9, panel.layout.font.legend * 1.06);
129
+ const pad = Math.max(4, 8 * scale);
130
+ const maxWidth = Math.max(12, plot.right - plot.left - pad * 2);
131
+ const width = Math.min(maxWidth, Math.max(42 * scale, String(annotation.label).length * font * 0.62 + 10 * scale));
132
+ const displayLabel = fitText(annotation.label, width - 4 * scale, font), height = Math.min(20 * scale, Math.max(12, plot.bottom - plot.top));
133
+ const dx = finite(annotation.dx) ? annotation.dx * scale : 68 * scale;
134
+ const dy = finite(annotation.dy) ? annotation.dy * scale : 28 * scale;
135
+ const offsets = [[dx, dy], [-dx, dy], [dx, -dy], [-dx, -dy]];
136
+ const dataBounds = panel.marks.filter((mark) => !BACKGROUND_KINDS.has(mark.kind)).map(markBounds).filter(Boolean);
137
+ return offsets.map(([offsetX, offsetY], order) => {
138
+ const rightAligned = offsetX < 0;
139
+ const labelX = rightAligned
140
+ ? clamp(anchor.x + offsetX, plot.left + width + pad, plot.right - pad)
141
+ : clamp(anchor.x + offsetX, plot.left + pad, plot.right - width - pad);
142
+ const labelY = clamp(anchor.y + offsetY, plot.top + height / 2, plot.bottom - height / 2);
143
+ const box = rightAligned
144
+ ? rect(labelX - width, labelY - height / 2, labelX, labelY + height / 2)
145
+ : rect(labelX, labelY - height / 2, labelX + width, labelY + height / 2);
146
+ const leaderLength = Math.hypot(labelX - anchor.x, labelY - anchor.y);
147
+ const score = overlap(box, legendBox) * 12
148
+ + occupied.reduce((sum, item) => sum + overlap(box, item) * 16, 0)
149
+ + dataBounds.reduce((sum, item) => sum + overlap(box, item), 0)
150
+ + leaderLength * 0.002;
151
+ return { labelX, labelY, box, displayLabel, textAnchor: rightAligned ? "end" : "start", score, order };
152
+ }).sort((left, right) => left.score - right.score || left.order - right.order);
153
+ }
154
+
155
+ function composeAnnotations(panel, legendBox) {
156
+ const occupied = [];
157
+ return (panel.annotations ?? []).filter((item) => item?.type === "focus" && typeof item.label === "string" && item.label.trim()).map((annotation, index) => {
158
+ const boundMark = annotation.anchorId ? panel.marks.find((mark) => mark.id === annotation.anchorId) : null;
159
+ const anchor = boundMark ? markAnchor(boundMark) : annotation.anchorId ? null : coordinateAnchor(panel, annotation);
160
+ const status = boundMark && anchor ? "evidence-bound" : annotation.anchorId ? "missing-anchor" : anchor ? "authored-coordinate" : "unresolved";
161
+ if (!anchor) return { id: `${panel.id}/focus/${index}`, type: "focus", label: annotation.label, status, boundMarkId: null, geometry: null };
162
+ const label = labelCandidates(panel, annotation, anchor, legendBox, occupied)[0];
163
+ occupied.push(label.box);
164
+ return {
165
+ id: `${panel.id}/focus/${index}`, type: "focus", label: annotation.label, displayLabel: label.displayLabel, status,
166
+ boundMarkId: boundMark?.id ?? null,
167
+ geometry: { anchorX: anchor.x, anchorY: anchor.y, labelX: label.labelX, labelY: label.labelY, labelBox: label.box, textAnchor: label.textAnchor, radius: Math.max(6.5, 8.4 * panel.layout.scale) },
168
+ };
169
+ });
170
+ }
171
+
172
+ export function auditComposition(scene) {
173
+ const annotations = scene.panels.flatMap((panel) => panel.composedAnnotations ?? []);
174
+ const count = (status) => annotations.filter((item) => item.status === status).length;
175
+ return Object.freeze({
176
+ annotations: annotations.length,
177
+ evidenceBound: count("evidence-bound"),
178
+ authoredCoordinates: count("authored-coordinate"),
179
+ missingAnchors: count("missing-anchor"),
180
+ unresolved: count("unresolved"),
181
+ clean: count("missing-anchor") + count("unresolved") === 0,
182
+ });
183
+ }
184
+
185
+ export function composeResolvedScene(resolvedScene) {
186
+ if (!resolvedScene || resolvedScene.schemaVersion !== "figurestead.resolved-scene/1") throw new TypeError("composeResolvedScene requires a resolved Figurestead scene");
187
+ const panels = resolvedScene.panels.map((panel) => {
188
+ const legend = composeLegend(panel);
189
+ const layout = { ...panel.layout, legend };
190
+ const composedPanel = { ...panel, layout };
191
+ return { ...composedPanel, composedAnnotations: composeAnnotations(composedPanel, legend.box) };
192
+ });
193
+ const result = {
194
+ ...resolvedScene,
195
+ schemaVersion: COMPOSED_SCENE_VERSION,
196
+ resolvedSceneVersion: resolvedScene.schemaVersion,
197
+ panels,
198
+ };
199
+ result.compositionAudit = auditComposition(result);
200
+ return deepFreeze(result);
201
+ }
@@ -0,0 +1,33 @@
1
+ import { createRendererRegistry, RENDERER_API_VERSION } from "./registry.js";
2
+ import { normalizeLineData, normalizeScatterData, normalizeStripData } from "./schema.js";
3
+ import { extent } from "./scales.js";
4
+ import { prepareLine, drawLine } from "./renderers/line.js";
5
+ import { prepareScatter, drawScatter } from "./renderers/scatter.js";
6
+ import { prepareStrip, drawStrip, compileStripScene } from "./renderers/strip-summary.js";
7
+
8
+ const pointDomains = (contract, prepared) => ({
9
+ x: contract.data.xDomain || extent(prepared.points.map((point) => point.x)),
10
+ y: contract.data.yDomain || extent(prepared.points.map((point) => point.y)),
11
+ });
12
+
13
+ export const LINE_RENDERER = {
14
+ key: "line", family: "trend", apiVersion: RENDERER_API_VERSION,
15
+ validateData: normalizeLineData, prepare: prepareLine, draw: drawLine, domains: pointDomains,
16
+ describe(contract) { return { summary: `${contract.data.series.length} connected series.`, headers: [contract.spec.xLabel || "x", ...contract.data.series.map((series) => series.label)], rows: contract.data.x.map((x, index) => [x, ...contract.data.series.map((series) => series.y[index])]) }; },
17
+ };
18
+
19
+ export const SCATTER_RENDERER = {
20
+ key: "scatter", family: "relationship", apiVersion: RENDERER_API_VERSION,
21
+ validateData: normalizeScatterData, prepare: prepareScatter, draw: drawScatter, domains: pointDomains,
22
+ describe(contract) { return { summary: `${contract.data.x.length} unconnected observations.`, headers: [contract.spec.xLabel || "x", contract.spec.yLabel || "y", "series"], rows: contract.data.x.map((x, index) => [x, contract.data.y[index], contract.data.seriesLabels[contract.data.series[index]]]) }; },
23
+ };
24
+
25
+ export const STRIP_RENDERER = {
26
+ key: "strip_summary", family: "distribution", apiVersion: RENDERER_API_VERSION,
27
+ validateData: normalizeStripData, prepare: prepareStrip, compileScene: compileStripScene, draw: drawStrip,
28
+ domains(contract, prepared) { return { x: [-0.5, contract.data.groups.length - 0.5], y: contract.data.yDomain || extent(prepared.points.map((point) => point.y)) }; },
29
+ 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
+ };
31
+
32
+ export const CORE_RENDERERS = Object.freeze([LINE_RENDERER, SCATTER_RENDERER, STRIP_RENDERER]);
33
+ export const CORE_REGISTRY = createRendererRegistry(CORE_RENDERERS);
@@ -0,0 +1,97 @@
1
+ import { cloneValue } from "./schema.js";
2
+ import { resizeCanvas } from "./layout.js";
3
+ import { deriveFigureLayout } from "./figure-layout.js";
4
+ import { prepareAtmosphere, drawAtmosphere } from "./atmosphere.js";
5
+ import { drawBackground, drawFigureHeader } from "./marks.js";
6
+ import { CORE_REGISTRY } from "./core-renderers.js";
7
+ import { AnimationClock } from "./clock.js";
8
+ import { createAccessibilityCompanion } from "./accessibility.js";
9
+ import { drawPanelSurface, drawPresentationAnnotations } from "./presentation.js";
10
+ import { compileFigureModel } from "./terminal-scene.js";
11
+ import { isResolvedRenderer, resolveSceneFrame, resolveTerminalScene } from "./resolved-scene.js";
12
+ import { drawResolvedPanel } from "./canvas-scene.js";
13
+ import { composeResolvedScene } from "./composition.js";
14
+
15
+ export function createFigurestead(canvas, input, options = {}) {
16
+ if (!(canvas instanceof HTMLCanvasElement)) throw new TypeError("createFigurestead requires an HTMLCanvasElement");
17
+ const registry = options.registry ?? CORE_REGISTRY;
18
+ if (registry.apiVersion !== "1") throw new TypeError("Figurestead requires renderer registry API 1");
19
+ let contract = input, scene = null, preparedPanels = [], domains = [], atmosphere, surface, resolvedScene = null, composedScene = null, clock = null, destroyed = false;
20
+ let reducedOverride = options.reducedMotion ?? null, companion = null;
21
+ const media = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)");
22
+ const isReduced = () => reducedOverride == null ? Boolean(media?.matches) : Boolean(reducedOverride);
23
+
24
+ const prepare = () => {
25
+ const model = compileFigureModel(contract, { registry });
26
+ contract = model.contract; scene = model.scene; preparedPanels = model.preparedPanels; domains = model.domains;
27
+ atmosphere = contract.view.ambient === "matrix" ? prepareAtmosphere(contract.motion) : [];
28
+ };
29
+ const layoutFactory = (width, height) => deriveFigureLayout(width, height, contract);
30
+ const resize = () => {
31
+ if (destroyed) return;
32
+ surface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory });
33
+ resolvedScene = resolveTerminalScene(scene, { width: surface.layout.width, height: surface.layout.height });
34
+ composedScene = composeResolvedScene(resolvedScene);
35
+ if (clock) draw(clock.progress);
36
+ };
37
+ const draw = (progress) => {
38
+ if (!surface || destroyed) return;
39
+ const p = isReduced() ? 1 : progress, settled = p >= 1;
40
+ drawBackground(surface.context, surface.layout, contract.theme);
41
+ drawAtmosphere(surface.context, { config: contract, layout: surface.layout, streams: atmosphere, progress: p, reducedMotion: isReduced() || settled });
42
+ drawFigureHeader(surface.context, { config: contract, layout: surface.layout });
43
+ const frame = resolveSceneFrame(composedScene, p);
44
+ preparedPanels.forEach((state, index) => {
45
+ const resolved = isResolvedRenderer(state.panel.renderer);
46
+ const env = { contract: state.contract, prepared: state.prepared, layout: resolved ? composedScene.panels[index].layout : surface.layout.panels[index], domains: domains[index], progress: p, settled, panel: state.panel, figure: contract, scenePanel: scene.panels[index], motionPlan: scene.motionPlan.panels[index], reducedMotion: isReduced() };
47
+ drawPanelSurface(surface.context, env);
48
+ const scales = resolved ? drawResolvedPanel(surface.context, frame, index) : state.definition.draw(surface.context, env);
49
+ if (!resolved) drawPresentationAnnotations(surface.context, { ...env, scales });
50
+ });
51
+ options.onProgress?.(p);
52
+ };
53
+
54
+ prepare(); surface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory });
55
+ resolvedScene = resolveTerminalScene(scene, { width: surface.layout.width, height: surface.layout.height });
56
+ composedScene = composeResolvedScene(resolvedScene);
57
+ clock = new AnimationClock({ durationMs: contract.motion.durationMs, draw, onState: options.onState });
58
+ companion = createAccessibilityCompanion(canvas, contract, registry, options.accessibility);
59
+ clock.render(isReduced() ? 1 : 0);
60
+
61
+ let autoplayUsed = false, wasPlayingBeforeHidden = false;
62
+ const resizeObserver = globalThis.ResizeObserver ? new ResizeObserver(resize) : null; resizeObserver?.observe(canvas);
63
+ const intersectionObserver = globalThis.IntersectionObserver ? new IntersectionObserver((entries) => {
64
+ if (!document.hidden && !autoplayUsed && entries.some((entry) => entry.isIntersecting && entry.intersectionRatio >= .35)) { autoplayUsed = true; intersectionObserver.disconnect(); isReduced() ? clock.settle() : clock.play(); }
65
+ }, { threshold: [.35] }) : null;
66
+ if (options.autoplay !== false) { if (intersectionObserver) intersectionObserver.observe(canvas); else { autoplayUsed = true; isReduced() ? clock.settle() : clock.play(); } }
67
+ const visibility = () => { if (document.hidden) { wasPlayingBeforeHidden = clock.playing; clock.pause(); } else if (wasPlayingBeforeHidden) { wasPlayingBeforeHidden = false; clock.play(); } };
68
+ const mediaChange = () => { if (reducedOverride == null) isReduced() ? clock.settle() : draw(clock.progress); };
69
+ document.addEventListener("visibilitychange", visibility); media?.addEventListener?.("change", mediaChange);
70
+
71
+ const replace = (next) => {
72
+ clock.pause(); contract = next; prepare(); clock.durationMs = contract.motion.durationMs;
73
+ surface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory });
74
+ resolvedScene = resolveTerminalScene(scene, { width: surface.layout.width, height: surface.layout.height });
75
+ composedScene = composeResolvedScene(resolvedScene);
76
+ companion.destroy(); companion = createAccessibilityCompanion(canvas, contract, registry, options.accessibility); clock.settle();
77
+ };
78
+ return Object.freeze({
79
+ play() { if (isReduced()) clock.settle(); else clock.play(); }, pause() { clock.pause(); }, replay() { if (isReduced()) clock.settle(); else clock.replay(); },
80
+ setData(data) { if (contract.panels.length !== 1) throw new TypeError("setData is available only for single-panel figures; use setConfig for multi-panel figures"); const next = cloneValue(contract); next.panels[0].data = cloneValue(data); replace(next); },
81
+ setConfig(next) { replace(next); },
82
+ setReducedMotion(value) { if (value !== null && typeof value !== "boolean") throw new TypeError("reduced motion must be true, false, or null"); reducedOverride = value; isReduced() ? clock.settle() : draw(clock.progress); },
83
+ resize,
84
+ destroy() { if (destroyed) return; destroyed = true; clock.destroy(); resizeObserver?.disconnect(); intersectionObserver?.disconnect(); document.removeEventListener("visibilitychange", visibility); media?.removeEventListener?.("change", mediaChange); companion.destroy(); },
85
+ getState() { return { progress: clock.progress, playing: clock.playing, reducedMotion: isReduced(), renderers: contract.panels.map((panel) => panel.renderer), sceneVersion: scene.schemaVersion, resolvedSceneVersion: resolvedScene.schemaVersion, composedSceneVersion: composedScene.schemaVersion, profile: contract.view.profile, destroyed }; },
86
+ getScene() { return scene; },
87
+ getResolvedScene() { return resolvedScene; },
88
+ getComposedScene() { return composedScene; },
89
+ getFinalCoordinates() {
90
+ return preparedPanels.map((state, index) => {
91
+ if (isResolvedRenderer(state.panel.renderer)) return { panelId: state.panel.id, points: resolvedScene.panels[index].marks.filter((mark) => mark.kind === "point").map((mark) => ({ x: mark.geometry.cx, y: mark.geometry.cy, dataX: mark.x ?? mark.group, dataY: mark.y ?? mark.yCategory })) };
92
+ const scales = state.definition.draw(surface.context, { contract: state.contract, prepared: state.prepared, layout: surface.layout.panels[index], domains: domains[index], progress: 1, settled: true, panel: state.panel, figure: contract });
93
+ return { panelId: state.panel.id, points: (state.prepared.points ?? []).map((point) => ({ x: scales?.x?.(point.x), y: scales?.y?.(point.y), dataX: point.x, dataY: point.y })) };
94
+ });
95
+ },
96
+ });
97
+ }
@@ -0,0 +1,80 @@
1
+ import { FiguresteadConfigError } from "./schema.js";
2
+
3
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
4
+
5
+ function numericCoordinate(axis, value, path, values) {
6
+ if (finite(value)) values.push({ axis, value, path });
7
+ }
8
+
9
+ function timeCoordinate(axis, value, path, values) {
10
+ const parsed = finite(value) ? value : Date.parse(value);
11
+ if (Number.isFinite(parsed)) values.push({ axis, value: parsed, path });
12
+ }
13
+
14
+ function coordinate(axis, value, path, panel, values) {
15
+ if (panel.scales?.[axis]?.type === "time") timeCoordinate(axis, value, path, values);
16
+ else numericCoordinate(axis, value, path, values);
17
+ }
18
+
19
+ function markCoordinates(panel, mark) {
20
+ const values = [], base = `panels.${panel.id}.marks.${mark.id}`;
21
+ if (mark.kind === "point") {
22
+ coordinate("x", mark.x, `${base}.x`, panel, values);
23
+ coordinate("y", mark.y, `${base}.y`, panel, values);
24
+ } else if (mark.kind === "bar") {
25
+ const axis = mark.orientation === "horizontal" ? "x" : "y";
26
+ numericCoordinate(axis, 0, `${base}.baseline`, values);
27
+ if (!mark.missing) numericCoordinate(axis, mark.value, `${base}.value`, values);
28
+ } else if (mark.kind === "interval") {
29
+ numericCoordinate("x", mark.low, `${base}.low`, values);
30
+ numericCoordinate("x", mark.high, `${base}.high`, values);
31
+ numericCoordinate("x", mark.observed, `${base}.observed`, values);
32
+ } else if (mark.kind === "connector") {
33
+ numericCoordinate("x", mark.x1, `${base}.x1`, values);
34
+ numericCoordinate("x", mark.x2, `${base}.x2`, values);
35
+ } else if (mark.kind === "reference-band") {
36
+ numericCoordinate("y", mark.from, `${base}.from`, values);
37
+ numericCoordinate("y", mark.to, `${base}.to`, values);
38
+ } else if (mark.kind === "baseline-rule") {
39
+ numericCoordinate("x", mark.x, `${base}.x`, values);
40
+ } else if (mark.kind === "rug") {
41
+ coordinate("x", mark.x, `${base}.x`, panel, values);
42
+ } else if (mark.kind === "temporal-bar") {
43
+ coordinate("x", mark.xFrom, `${base}.xFrom`, panel, values);
44
+ coordinate("x", mark.xTo, `${base}.xTo`, panel, values);
45
+ } else if (mark.kind === "median-rule") {
46
+ numericCoordinate("y", mark.y, `${base}.y`, values);
47
+ }
48
+ // Segments repeat validated point evidence. Summary/model geometry and curve
49
+ // control points are renderer output and may honestly require clipping.
50
+ return values;
51
+ }
52
+
53
+ function normalizedDomain(panel, axis) {
54
+ const value = panel.domain?.[axis];
55
+ if (!Array.isArray(value) || value.length !== 2) return null;
56
+ if (panel.scales?.[axis]?.type !== "time") return value;
57
+ return value.map((item) => finite(item) ? item : Date.parse(item));
58
+ }
59
+
60
+ export function validateEvidenceCoverage(panels) {
61
+ const findings = [];
62
+ panels.forEach((panel) => {
63
+ panel.marks.forEach((mark) => markCoordinates(panel, mark).forEach((item) => {
64
+ const domain = normalizedDomain(panel, item.axis);
65
+ if (!domain || !domain.every(Number.isFinite)) return;
66
+ if (item.value < domain[0] || item.value > domain[1]) findings.push({
67
+ panelId: panel.id, markId: mark.id, axis: item.axis, value: item.value,
68
+ domain: [...domain], path: item.path,
69
+ });
70
+ }));
71
+ });
72
+ if (findings.length) {
73
+ const first = findings[0];
74
+ throw new FiguresteadConfigError(
75
+ `evidence value ${first.value} falls outside ${first.axis} domain [${first.domain.join(", ")}]; clipping may not hide evidence`,
76
+ first.path,
77
+ );
78
+ }
79
+ return Object.freeze({ clean: true, checkedPanels: panels.length, findings: Object.freeze(findings) });
80
+ }
@@ -0,0 +1,83 @@
1
+ import { CORE_REGISTRY } from "./core-renderers.js";
2
+ import { composeResolvedScene } from "./composition.js";
3
+ import { resolveTerminalScene, resolvedTerminalGeometry } from "./resolved-scene.js";
4
+ import { resolvedSceneToSvg } from "./svg-export.js";
5
+ import { compileTerminalScene, evidenceFingerprint } from "./terminal-scene.js";
6
+ import { auditPhysicalTypography, resolveExportSize } from "./physical-export.js";
7
+
8
+ export const EXPORT_MANIFEST_VERSION = "figurestead.export-manifest/1";
9
+ export const FIGURESTEAD_PACKAGE_VERSION = "0.9.0-alpha.1";
10
+
11
+ function sorted(value) {
12
+ if (Array.isArray(value)) return value.map(sorted);
13
+ if (!value || typeof value !== "object") return value;
14
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
15
+ }
16
+
17
+ export function stableStringify(value, space = 2) {
18
+ return JSON.stringify(sorted(value), null, space);
19
+ }
20
+
21
+ function contentFingerprint(value) {
22
+ let hash = 0x811c9dc5;
23
+ for (let index = 0; index < value.length; index += 1) {
24
+ hash ^= value.charCodeAt(index);
25
+ hash = Math.imul(hash, 0x01000193);
26
+ }
27
+ return `fnv1a32:${(hash >>> 0).toString(16).padStart(8, "0")}`;
28
+ }
29
+
30
+ function dimension(value, name) {
31
+ if (!Number.isFinite(value) || value <= 0) throw new TypeError(`${name} must be a positive finite number`);
32
+ return value;
33
+ }
34
+
35
+ export function exportFigureArtifacts(input, options = {}) {
36
+ const registry = options.registry ?? CORE_REGISTRY;
37
+ const exportSize = resolveExportSize(options), width = dimension(exportSize.width, "width"), height = dimension(exportSize.height, "height");
38
+ const scene = input?.schemaVersion === "figurestead.scene/1" ? input : compileTerminalScene(input, { registry });
39
+ const resolved = resolveTerminalScene(scene, { width, height }), composed = composeResolvedScene(resolved);
40
+ const svg = resolvedSceneToSvg(composed, { ...options, exportSize, sourceScene: scene });
41
+ const sceneJson = stableStringify(scene);
42
+ const geometryJson = stableStringify({ schemaVersion: "figurestead.geometry-export/1", width, height, panels: resolvedTerminalGeometry(composed), composition: composed.panels.map((panel) => ({ panelId: panel.id, legend: panel.layout.legend, annotations: panel.composedAnnotations })) });
43
+ const manifest = {
44
+ formatVersion: EXPORT_MANIFEST_VERSION,
45
+ packageVersion: FIGURESTEAD_PACKAGE_VERSION,
46
+ sourceSceneVersion: scene.schemaVersion,
47
+ resolvedSceneVersion: resolved.schemaVersion,
48
+ composedSceneVersion: composed.schemaVersion,
49
+ contractSchemaVersion: scene.contractSchemaVersion,
50
+ rendererApiVersion: scene.rendererApiVersion,
51
+ evidenceFingerprint: evidenceFingerprint(scene),
52
+ dimensions: { width, height },
53
+ physical: exportSize.physical ? { ...exportSize.physical, typographyAudit: auditPhysicalTypography(composed, exportSize.physical) } : null,
54
+ profile: scene.applicationProfile?.key ?? scene.view?.profile ?? null,
55
+ theme: { key: scene.theme.key, name: scene.theme.name },
56
+ appearanceAudit: scene.appearanceReport,
57
+ evidenceCoverage: scene.evidenceCoverage,
58
+ renderers: scene.panels.map((panel) => ({ panelId: panel.id, renderer: panel.renderer, marks: panel.marks.length })),
59
+ annotationAudit: composed.compositionAudit,
60
+ contentFingerprints: {
61
+ algorithm: "fnv1a32",
62
+ svg: contentFingerprint(svg),
63
+ scene: contentFingerprint(sceneJson),
64
+ geometry: contentFingerprint(geometryJson),
65
+ },
66
+ artifacts: { svg: "figure.svg", scene: "scene.json", geometry: "geometry.json", manifest: "manifest.json", png: "figure.png" },
67
+ };
68
+ return Object.freeze({
69
+ scene,
70
+ resolved,
71
+ composed,
72
+ svg,
73
+ sceneJson,
74
+ geometryJson,
75
+ manifest: Object.freeze(manifest),
76
+ manifestJson: stableStringify(manifest),
77
+ });
78
+ }
79
+
80
+ export function canvasToPngBlob(canvas, options = {}) {
81
+ if (!canvas || typeof canvas.toBlob !== "function") return Promise.reject(new TypeError("canvasToPngBlob requires a canvas with toBlob"));
82
+ return new Promise((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("PNG encoding returned no data")), "image/png", options.quality));
83
+ }
@@ -0,0 +1,96 @@
1
+ import { arrival } from "../../renderers/shared.js";
2
+ import { drawText, pointMotionState } from "../../marks.js";
3
+ import { drawBar } from "../../primitives.js";
4
+ import { linearScale } from "../../scales.js";
5
+ import { drawTemporalAxes, makeTemporalScales, postMarksProgress, temporalXDomain, yearStart } from "./shared.js";
6
+
7
+ export function prepareCoverage(contract) {
8
+ const points = contract.data.dates.map((date, index) => ({
9
+ date, x: Date.parse(`${date}T00:00:00Z`), y: contract.data.siteOrder.indexOf(contract.data.sites[index]),
10
+ site: contract.data.sites[index], colorIndex: contract.data.siteOrder.indexOf(contract.data.sites[index]), index,
11
+ }));
12
+ const arrived = arrival(points, contract, "x");
13
+ const years = new Map();
14
+ points.forEach((point) => {
15
+ const year = Number(point.date.slice(0, 4));
16
+ if (!years.has(year)) years.set(year, { year, sites: new Set(), observations: 0 });
17
+ years.get(year).sites.add(point.site); years.get(year).observations += 1;
18
+ });
19
+ const annual = [...years.values()].sort((a, b) => a.year - b.year).map((item) => ({ year: item.year, siteCount: item.sites.size, observationCount: item.observations }));
20
+ return { points: arrived, annual, settledAt: Math.max(...arrived.map((point) => point.delay + point.duration)) };
21
+ }
22
+
23
+ export function compileCoverageScene({ panel, contract, prepared, markId }) {
24
+ const marks = prepared.annual.map((item) => ({
25
+ id: markId(panel, "annual-count", item.year), kind: "temporal-bar", role: "summary",
26
+ year: item.year, xFrom: yearStart(item.year), xTo: yearStart(item.year + 1),
27
+ value: item.siteCount, observationCount: item.observationCount,
28
+ maximum: contract.data.siteOrder.length,
29
+ style: { color: contract.theme.secondary, edge: contract.theme.spine, lineStyle: "solid", lineWidth: 1 },
30
+ }));
31
+ prepared.points.forEach((point) => marks.push({
32
+ id: markId(panel, "rug", point.date, point.site, point.index), kind: "rug", series: point.site,
33
+ x: point.x, yCategory: point.site, date: point.date,
34
+ style: {
35
+ key: point.site, colorIndex: point.colorIndex,
36
+ color: contract.theme.series[point.colorIndex % contract.theme.series.length],
37
+ edge: contract.theme.seriesEdges?.[point.colorIndex % (contract.theme.seriesEdges?.length || 1)] ?? null,
38
+ glyph: "ring", lineStyle: "solid", lineWidth: 1.35,
39
+ },
40
+ }));
41
+ return {
42
+ marks,
43
+ categories: { x: null, y: [...contract.data.siteOrder] },
44
+ scales: { x: contract.xScale, y: { ...contract.yScale, type: "band" } },
45
+ legend: [],
46
+ meta: { coverage: true, maximumSites: contract.data.siteOrder.length },
47
+ };
48
+ }
49
+
50
+ function drawRugMark(context, state, { color, scale, settled, trailAlpha }) {
51
+ if (state.visibility <= 0) return;
52
+ context.save(); context.strokeStyle = color;
53
+ if (!settled) {
54
+ context.globalAlpha = trailAlpha * state.visibility * (1 - state.eased); context.lineWidth = Math.max(0.6, 0.8 * scale);
55
+ context.beginPath(); context.moveTo(state.x, state.y - 3 * scale); context.lineTo(state.x, state.y - (8 + 20 * (1 - state.eased)) * scale); context.stroke();
56
+ }
57
+ context.globalAlpha = 0.82 * state.visibility; context.lineWidth = Math.max(1, 1.35 * scale);
58
+ context.beginPath(); context.moveTo(state.x, state.y - 5 * scale); context.lineTo(state.x, state.y + 5 * scale); context.stroke(); context.restore();
59
+ }
60
+
61
+ export function coverageDomains(contract, prepared) {
62
+ const evidenceExtent = [
63
+ ...prepared.points.map((point) => point.x),
64
+ ...prepared.annual.flatMap((item) => [yearStart(item.year), yearStart(item.year + 1)]),
65
+ ];
66
+ return { x: temporalXDomain(contract, evidenceExtent), y: [-0.5, contract.data.siteOrder.length - 0.5] };
67
+ }
68
+
69
+ export function drawCoverage(context, env) {
70
+ const { contract, prepared, layout, progress, settled } = env;
71
+ const height = layout.plot.bottom - layout.plot.top;
72
+ const countPlot = { ...layout.plot, bottom: layout.plot.top + Math.min(48 * layout.scale, height * 0.22) };
73
+ const rugPlot = { ...layout.plot, top: countPlot.bottom + Math.max(8, 10 * layout.scale) };
74
+ const xDomain = env.domains.x, yDomain = [-0.5, contract.data.siteOrder.length - 0.5];
75
+ const scales = makeTemporalScales(xDomain, yDomain, rugPlot);
76
+ scales.y = linearScale(yDomain, [rugPlot.top, rugPlot.bottom]);
77
+ drawTemporalAxes(context, { contract, layout, scales, plot: rugPlot, yLabels: contract.data.siteOrder });
78
+ const cp = postMarksProgress(progress, prepared, contract.timeline);
79
+ if (cp > 0 && prepared.annual.length) {
80
+ const maximum = Math.max(1, contract.data.siteOrder.length);
81
+ prepared.annual.forEach((item) => {
82
+ const left = Math.max(countPlot.left, scales.x(yearStart(item.year)) + 1);
83
+ const right = Math.min(countPlot.right, scales.x(yearStart(item.year + 1)) - 1);
84
+ if (right <= left) return;
85
+ const barHeight = (countPlot.bottom - countPlot.top - 12 * layout.scale) * item.siteCount / maximum * cp;
86
+ drawBar(context, { left, right, top: countPlot.bottom - barHeight, bottom: countPlot.bottom, color: contract.theme.secondary, alpha: 0.24 * cp });
87
+ context.save(); context.globalAlpha = cp; context.fillStyle = contract.theme.secondary; context.font = `${Math.max(7, layout.font.axis * 0.75)}px ui-monospace, monospace`;
88
+ context.textAlign = "center"; context.textBaseline = "bottom"; context.fillText(String(item.siteCount), (left + right) / 2, countPlot.bottom - barHeight - 2); context.restore();
89
+ });
90
+ }
91
+ prepared.points.forEach((point) => drawRugMark(context, pointMotionState(point, progress, scales, rugPlot), {
92
+ color: contract.theme.series[point.colorIndex % contract.theme.series.length], scale: layout.scale, settled, trailAlpha: contract.motion.trailAlpha,
93
+ }));
94
+ drawText(context, { config: contract, layout, legend: [] });
95
+ return scales;
96
+ }
@@ -0,0 +1,43 @@
1
+ import { defineRenderer, RENDERER_API_VERSION } from "../../registry.js";
2
+ import { prepareCoverage, coverageDomains, drawCoverage, compileCoverageScene } from "./coverage.js";
3
+ import { prepareObservations, observationDomains, drawObservations, compileObservationsScene } from "./observations.js";
4
+ import { normalizeReferenceBands, PROVISIONAL_LABEL, validateCoverageData, validateObservationData } from "./shared.js";
5
+
6
+ export const TEMPORAL_COVERAGE_RENDERER = defineRenderer({
7
+ key: "temporal_coverage", family: "temporal", apiVersion: RENDERER_API_VERSION,
8
+ validateData: validateCoverageData, prepare: prepareCoverage, compileScene: compileCoverageScene, domains: coverageDomains, draw: drawCoverage,
9
+ describe(contract) {
10
+ const annual = new Map();
11
+ contract.data.dates.forEach((date, index) => {
12
+ const year = date.slice(0, 4); if (!annual.has(year)) annual.set(year, new Set()); annual.get(year).add(contract.data.sites[index]);
13
+ });
14
+ return {
15
+ summary: `${contract.data.dates.length} exact, sparse dated observations across ${contract.data.siteOrder.length} ordered sites. Annual counts are distinct sampled sites, not continuous coverage.`,
16
+ headers: ["date", "site", "distinct sites sampled that year"],
17
+ rows: contract.data.dates.map((date, index) => [date, contract.data.sites[index], annual.get(date.slice(0, 4)).size]),
18
+ };
19
+ },
20
+ });
21
+
22
+ export const TEMPORAL_OBSERVATIONS_RENDERER = defineRenderer({
23
+ key: "temporal_observations", family: "temporal", apiVersion: RENDERER_API_VERSION,
24
+ validateData: validateObservationData, prepare: prepareObservations, compileScene: compileObservationsScene, domains: observationDomains, draw: drawObservations,
25
+ describe(contract) {
26
+ const bands = normalizeReferenceBands(contract.data.referenceBands ?? [], "config.data.referenceBands");
27
+ const bandFor = (value) => bands.find((band, index) => value >= band.from && (value < band.to || (index === bands.length - 1 && value <= band.to)));
28
+ return {
29
+ summary: `${contract.data.values.length} sparse, unconnected observations for ${contract.data.site}.${bands.length ? ` Reference bands are ${PROVISIONAL_LABEL.toLowerCase()}.` : ""}`,
30
+ headers: ["date", "site", contract.spec.yLabel || "value", "project reference band"],
31
+ rows: contract.data.dates.map((date, index) => {
32
+ const band = bandFor(contract.data.values[index]);
33
+ return [date, contract.data.site, contract.data.values[index], band ? `${band.label} — ${PROVISIONAL_LABEL}` : "None"];
34
+ }),
35
+ };
36
+ },
37
+ });
38
+
39
+ export const TEMPORAL_RENDERERS = Object.freeze([TEMPORAL_COVERAGE_RENDERER, TEMPORAL_OBSERVATIONS_RENDERER]);
40
+
41
+ export { PROVISIONAL_LABEL, PROVISIONAL_STATUS, validateCoverageData, validateObservationData } from "./shared.js";
42
+ export { compileCoverageScene } from "./coverage.js";
43
+ export { compileObservationsScene } from "./observations.js";