@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,232 @@
1
+ import { CORE_REGISTRY } from "./core-renderers.js";
2
+ import { compileTerminalScene, evidenceFingerprint } from "./terminal-scene.js";
3
+ import { resolveTerminalScene } from "./resolved-scene.js";
4
+ import { composeResolvedScene } from "./composition.js";
5
+ import { partitionPanelMarks, plotClipRect } from "./render-layers.js";
6
+ import { resolveExportSize } from "./physical-export.js";
7
+
8
+ const esc = (value) => String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" }[char]));
9
+ const attrs = (value) => Object.entries(value).filter(([, item]) => item != null).map(([key, item]) => `${key}="${esc(item)}"`).join(" ");
10
+ const FONT = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
11
+
12
+ function hashText(value) {
13
+ let hash = 0x811c9dc5;
14
+ for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 0x01000193); }
15
+ return (hash >>> 0).toString(16).padStart(8, "0");
16
+ }
17
+
18
+ function safeId(value) { return String(value).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "figurestead"; }
19
+
20
+ function svgNamespace(composed, scene, options) {
21
+ if (options.idPrefix != null && !String(options.idPrefix).trim()) throw new TypeError("idPrefix must be a non-empty string");
22
+ if (options.idPrefix != null) return safeId(options.idPrefix);
23
+ const evidence = scene ? evidenceFingerprint(scene) : `fnv1a32-${hashText(JSON.stringify({ title: composed.spec.title, panels: composed.panels.map((panel) => panel.id) }))}`;
24
+ return safeId(`figurestead-${evidence}-${composed.width}x${composed.height}`);
25
+ }
26
+
27
+ function dash(style) { return style === "dash" ? "7 4" : style === "dot" ? "2 4" : style === "dash-dot" ? "8 3 2 3" : null; }
28
+ function marker(mark) {
29
+ const { cx, cy, radius } = mark.geometry, common = { "data-mark-id": mark.id, fill: "none", stroke: mark.style.color, "stroke-width": 1.5 };
30
+ if (mark.style.glyph === "square") return `<rect ${attrs({ ...common, x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 })}/>`;
31
+ if (mark.style.glyph === "triangle") return `<path ${attrs({ ...common, d: `M ${cx} ${cy - radius} L ${cx + radius} ${cy + radius} L ${cx - radius} ${cy + radius} Z` })}/>`;
32
+ if (mark.style.glyph === "diamond") return `<path ${attrs({ ...common, d: `M ${cx} ${cy - radius} L ${cx + radius} ${cy} L ${cx} ${cy + radius} L ${cx - radius} ${cy} Z` })}/>`;
33
+ return `<circle ${attrs({ ...common, cx, cy, r: radius })}/>`;
34
+ }
35
+
36
+ function segment(mark) {
37
+ const g = mark.geometry, d = g.c1x == null ? `M ${g.x1} ${g.y1} L ${g.x2} ${g.y2}` : `M ${g.x1} ${g.y1} C ${g.c1x} ${g.c1y} ${g.c2x} ${g.c2y} ${g.x2} ${g.y2}`;
38
+ return `<path ${attrs({ "data-mark-id": mark.id, d, fill: "none", stroke: mark.style.color, "stroke-width": mark.style.lineWidth ?? 1.6, "stroke-dasharray": dash(mark.style.lineStyle), "stroke-linecap": "round" })}/>`;
39
+ }
40
+
41
+ function bar(mark, theme) {
42
+ const g = mark.geometry;
43
+ if (mark.missing) return `<text ${attrs({ "data-mark-id": mark.id, x: (g.left + g.right) / 2, y: (g.top + g.bottom) / 2, fill: theme.warm, "text-anchor": "middle" })}>×</text>`;
44
+ const fillOpacity = theme.mode === "paper" ? 1 : g.alpha;
45
+ return `<g data-mark-id="${esc(mark.id)}"><rect ${attrs({ x: g.left, y: g.top, width: g.right - g.left, height: g.bottom - g.top, fill: mark.style.color, "fill-opacity": fillOpacity, stroke: mark.style.edge ?? mark.style.color })}/>${theme.mode === "paper" ? barHatch(g, mark.style.hatch, mark.style.edge ?? theme.label) : ""}</g>`;
46
+ }
47
+
48
+ function barHatch(g, hatch, color) {
49
+ if (!hatch || hatch === "none") return "";
50
+ const spacing = 6, height = g.bottom - g.top, pieces = [];
51
+ const line = (x1, y1, x2, y2) => pieces.push(`M ${x1} ${y1} L ${x2} ${y2}`);
52
+ if (["diag", "cross"].includes(hatch)) for (let x = g.left - height; x <= g.right; x += spacing) {
53
+ const fromX = Math.max(g.left, x), toX = Math.min(g.right, x + height);
54
+ if (toX >= fromX) line(fromX, g.bottom - (fromX - x), toX, g.bottom - (toX - x));
55
+ }
56
+ if (hatch === "cross") for (let x = g.left; x <= g.right + height; x += spacing) {
57
+ const fromX = Math.min(g.right, x), toX = Math.max(g.left, x - height);
58
+ if (fromX >= toX) line(fromX, g.bottom - (x - fromX), toX, g.bottom - (x - toX));
59
+ }
60
+ if (hatch === "vertical") for (let x = g.left + spacing / 2; x < g.right; x += spacing) line(x, g.top, x, g.bottom);
61
+ return pieces.length ? `<path ${attrs({ d: pieces.join(" "), fill: "none", stroke: color, "stroke-width": 0.75, "stroke-opacity": 0.34 })}/>` : "";
62
+ }
63
+
64
+ function cell(mark, theme, fontSize) {
65
+ const g = mark.geometry, label = mark.label ? `<text ${attrs({ x: (g.left + g.right) / 2, y: (g.top + g.bottom) / 2, fill: g.labelColor ?? theme.label, "font-size": fontSize, "text-anchor": "middle", "dominant-baseline": "middle" })}>${esc(mark.label)}</text>` : "";
66
+ const status = mark.status === "insufficient" ? `<path ${attrs({ d: `M ${g.left} ${g.bottom} L ${g.right} ${g.top}`, stroke: theme.warm })}/>` : "";
67
+ return `<g data-mark-id="${esc(mark.id)}"><rect ${attrs({ x: g.left, y: g.top, width: g.right - g.left, height: g.bottom - g.top, fill: g.fill, stroke: mark.style.edge, "data-status": mark.status })}/>${status}${label}</g>`;
68
+ }
69
+
70
+ function interval(mark, theme) {
71
+ const g = mark.geometry;
72
+ return `<g ${attrs({ "data-mark-id": mark.id, stroke: mark.style.color, "stroke-width": mark.style.lineWidth ?? 1.6, "stroke-dasharray": dash(mark.style.lineStyle), "stroke-opacity": theme.mode === "paper" ? 1 : (mark.role === "context" ? 0.62 : 0.9), fill: "none" })}><path d="M ${g.x1} ${g.y} L ${g.x2} ${g.y}"/><path d="M ${g.x1} ${g.y - g.cap} L ${g.x1} ${g.y + g.cap} M ${g.x2} ${g.y - g.cap} L ${g.x2} ${g.y + g.cap}"/></g>`;
73
+ }
74
+
75
+ function connector(mark, theme) {
76
+ const g = mark.geometry;
77
+ return `<path ${attrs({ "data-mark-id": mark.id, d: `M ${g.x1} ${g.y} L ${g.x2} ${g.y}`, fill: "none", stroke: mark.style.color, "stroke-width": mark.style.lineWidth ?? 1.35, "stroke-opacity": theme.mode === "paper" ? 1 : 0.72 })}/>`;
78
+ }
79
+
80
+ function connectorLabel(mark, panel, theme) {
81
+ const g = mark.geometry, leftFirst = g.x1 <= g.x2, pad = 6 * panel.layout.scale;
82
+ const delta = `${mark.delta >= 0 ? "+" : ""}${Number(mark.delta.toPrecision(4))}`;
83
+ return `<g data-label-for="${esc(mark.id)}"><text ${attrs({ x: g.x1 + (leftFirst ? -pad : pad), y: g.y - 7 * panel.layout.scale, fill: theme.series[0], "font-size": panel.layout.font.legend, "text-anchor": leftFirst ? "end" : "start" })}>${esc(mark.endpointALabel)}</text><text ${attrs({ x: g.x2 + (leftFirst ? pad : -pad), y: g.y + 7 * panel.layout.scale, fill: theme.series[1 % theme.series.length], "font-size": panel.layout.font.legend, "text-anchor": leftFirst ? "start" : "end" })}>${esc(mark.endpointBLabel)}</text><text ${attrs({ x: (g.x1 + g.x2) / 2, y: g.y - 18 * panel.layout.scale, fill: theme.label, "font-size": panel.layout.font.legend, "text-anchor": "middle" })}>${esc(delta)}</text></g>`;
84
+ }
85
+
86
+ function referenceBand(mark) {
87
+ const g = mark.geometry;
88
+ return `<g data-mark-id="${esc(mark.id)}"><rect ${attrs({ x: g.left, y: g.top, width: g.right - g.left, height: g.bottom - g.top, fill: mark.style.color, "fill-opacity": 0.1, "data-status": mark.status, "data-label": mark.label })}/><path ${attrs({ d: `M ${g.left} ${g.bottom} L ${g.right} ${g.bottom}`, stroke: mark.style.color, "stroke-opacity": 0.45 })}/></g>`;
89
+ }
90
+
91
+ function baseline(mark, panel) {
92
+ const g = mark.geometry;
93
+ return `<path ${attrs({ "data-mark-id": mark.id, d: `M ${g.x} ${g.top} L ${g.x} ${g.bottom}`, stroke: mark.style.color, "stroke-width": mark.style.lineWidth ?? 1.2, "stroke-dasharray": dash(mark.style.lineStyle) })}/>`;
94
+ }
95
+
96
+ function baselineLabel(mark, panel) {
97
+ const g = mark.geometry;
98
+ return `<text ${attrs({ "data-label-for": mark.id, x: g.x, y: g.top - 5 * panel.layout.scale, fill: mark.style.color, "font-size": panel.layout.font.legend, "text-anchor": "middle" })}>${esc(mark.label)}</text>`;
99
+ }
100
+
101
+ function rowBand(mark) {
102
+ const g = mark.geometry;
103
+ return `<rect ${attrs({ "data-mark-id": mark.id, x: g.left, y: g.top, width: g.right - g.left, height: g.bottom - g.top, fill: mark.style.color, "fill-opacity": 0.28 })}/>`;
104
+ }
105
+
106
+ function rug(mark) {
107
+ const g = mark.geometry;
108
+ return `<path ${attrs({ "data-mark-id": mark.id, d: `M ${g.x} ${g.y - g.halfHeight} L ${g.x} ${g.y + g.halfHeight}`, stroke: mark.style.color, "stroke-width": mark.style.lineWidth ?? 1.35 })}/>`;
109
+ }
110
+
111
+ function temporalBar(mark, panel, theme) {
112
+ const g = mark.geometry;
113
+ return `<rect ${attrs({ "data-mark-id": mark.id, x: g.left, y: g.top, width: Math.max(0, g.right - g.left), height: g.bottom - g.top, fill: mark.style.color, "fill-opacity": theme.mode === "paper" ? 1 : 0.24, stroke: mark.style.edge ?? mark.style.color })}/>`;
114
+ }
115
+
116
+ function temporalBarLabel(mark, panel, theme) {
117
+ const g = mark.geometry;
118
+ return `<text ${attrs({ "data-label-for": mark.id, x: g.labelX, y: g.labelY, fill: theme.secondary, "font-size": Math.max(7, panel.layout.font.axis * 0.75), "text-anchor": "middle" })}>${esc(mark.value)}</text>`;
119
+ }
120
+
121
+ function tickPosition(axis, tick) { const value = axis(tick.value); return axis.bandwidth ? value + axis.bandwidth() / 2 : value; }
122
+ function grid(panel, theme, profile) {
123
+ const plot = panel.axes.plot ?? panel.layout.plot, pieces = [];
124
+ if (panel.axes.xType !== "band" && profile.gridX) panel.axes.xTicks.forEach((tick) => {
125
+ const x = tickPosition(panel.axes.x, tick); pieces.push(`<path ${attrs({ d: `M ${x} ${plot.top} L ${x} ${plot.bottom}`, stroke: theme.grid, "stroke-width": Math.max(0.6, 0.85 * panel.layout.scale), "stroke-opacity": profile.gridAlpha * 0.5 })}/>`);
126
+ });
127
+ if (panel.axes.yType !== "band" && profile.gridY) panel.axes.yTicks.forEach((tick) => {
128
+ const y = tickPosition(panel.axes.y, tick); pieces.push(`<path ${attrs({ d: `M ${plot.left} ${y} L ${plot.right} ${y}`, stroke: theme.grid, "stroke-width": Math.max(0.6, 0.85 * panel.layout.scale), "stroke-opacity": profile.gridAlpha * 0.5 })}/>`);
129
+ });
130
+ return pieces.join("");
131
+ }
132
+
133
+ function panelSurface(panel, theme) {
134
+ if (!panel.presentation?.panelSurface) return "";
135
+ const plot = panel.layout.plot;
136
+ return `<rect ${attrs({
137
+ x: plot.left, y: plot.top, width: plot.right - plot.left, height: plot.bottom - plot.top,
138
+ fill: theme.panel,
139
+ stroke: panel.presentation.frame ? theme.spine : null,
140
+ "stroke-opacity": panel.presentation.frame ? 0.48 : null,
141
+ "stroke-width": panel.presentation.frame ? Math.max(0.6, 0.75 * panel.layout.scale) : null,
142
+ })}/>`;
143
+ }
144
+
145
+ function axes(panel, theme) {
146
+ const plot = panel.axes.plot ?? panel.layout.plot, { font } = panel.layout, pieces = [`<path ${attrs({ d: `M ${plot.left} ${plot.top} L ${plot.left} ${plot.bottom} L ${plot.right} ${plot.bottom}`, fill: "none", stroke: theme.spine })}/>`];
147
+ const slot = panel.axes.x.step?.() ?? Math.max(40, (plot.right - plot.left) / Math.max(1, panel.axes.xTicks.length));
148
+ const rotate = panel.axes.xType === "band" && panel.axes.xTicks.some((tick) => String(tick.label).length * font.axis * 0.62 > slot * 0.92);
149
+ panel.axes.xTicks.forEach((tick) => { const x = tickPosition(panel.axes.x, tick), y = plot.bottom + 16 * panel.layout.scale; pieces.push(`<text ${attrs({ x, y, fill: theme.secondary, "font-size": font.axis, "text-anchor": rotate ? "end" : "middle", transform: rotate ? `rotate(-45 ${x} ${y})` : null })}>${esc(tick.label)}</text>`); });
150
+ panel.axes.yTicks.forEach((tick) => pieces.push(`<text ${attrs({ x: plot.left - 7 * panel.layout.scale, y: tickPosition(panel.axes.y, tick), fill: theme.secondary, "font-size": font.axis, "text-anchor": "end", "dominant-baseline": "middle" })}>${esc(tick.label)}</text>`));
151
+ if (panel.spec.xLabel) pieces.push(`<text ${attrs({ x: (plot.left + plot.right) / 2, y: panel.layout.rect.bottom - 6 * panel.layout.scale, fill: theme.label, "font-size": font.axis, "text-anchor": "middle" })}>${esc(panel.spec.xLabel)}</text>`);
152
+ if (panel.spec.yLabel) pieces.push(`<text ${attrs({ x: panel.layout.rect.left + 12 * panel.layout.scale, y: (plot.top + plot.bottom) / 2, fill: theme.label, "font-size": font.axis, transform: `rotate(-90 ${panel.layout.rect.left + 12 * panel.layout.scale} ${(plot.top + plot.bottom) / 2})`, "text-anchor": "middle" })}>${esc(panel.spec.yLabel)}</text>`);
153
+ return pieces.join("");
154
+ }
155
+
156
+ function legend(panel, theme) {
157
+ if (panel.presentation?.legend === "none") return "";
158
+ const insideTop = panel.layout.plot.bottom - Math.max(14, 14 + (panel.legend.length - 1) * 20) * panel.layout.scale;
159
+ return panel.legend.map((item, index) => {
160
+ const entry = panel.layout.legend.entries?.[index];
161
+ const x = entry?.markerX ?? (panel.layout.legend.outside ? panel.layout.legend.left : panel.layout.plot.right - 24 * panel.layout.scale);
162
+ const textX = entry?.textX ?? (panel.layout.legend.outside ? x + 12 * panel.layout.scale : x - 10 * panel.layout.scale);
163
+ const y = entry?.y ?? (panel.layout.legend.outside ? panel.layout.legend.top + (14 + index * 20) * panel.layout.scale : insideTop + index * 20 * panel.layout.scale), style = item.style ?? {};
164
+ const point = `<circle ${attrs({ cx: x, cy: y, r: 4 * panel.layout.scale, fill: "none", stroke: style.color ?? theme.series[item.colorIndex % theme.series.length] })}/>`;
165
+ const label = entry?.displayLabel ?? item.label;
166
+ return `${point}<text ${attrs({ x: textX, y, fill: theme.label, "font-size": panel.layout.font.legend, "text-anchor": entry?.textAnchor ?? (panel.layout.legend.outside ? "start" : "end"), "dominant-baseline": "middle", "data-full-label": item.label })}><title>${esc(item.label)}</title>${esc(label)}</text>`;
167
+ }).join("");
168
+ }
169
+
170
+ function annotations(panel, theme) {
171
+ return (panel.composedAnnotations ?? []).filter((annotation) => annotation.geometry).map((annotation) => {
172
+ const g = annotation.geometry, direction = g.labelX < g.anchorX ? -1 : 1;
173
+ const leader = `M ${g.anchorX + direction * g.radius * 0.7} ${g.anchorY} L ${g.labelX + (g.textAnchor === "end" ? 8 : -8) * panel.layout.scale} ${g.labelY - 5 * panel.layout.scale}`;
174
+ const label = annotation.displayLabel ?? annotation.label;
175
+ return `<g ${attrs({ "data-focus-id": annotation.id, "data-focus-status": annotation.status, "data-anchor-mark-id": annotation.boundMarkId, "data-full-label": annotation.label })}><title>${esc(annotation.label)}</title><circle ${attrs({ cx: g.anchorX, cy: g.anchorY, r: g.radius * 1.45, fill: "none", stroke: theme.primary, "stroke-width": Math.max(3, g.radius * 0.62), "stroke-opacity": 0.13 })}/><path ${attrs({ d: leader, fill: "none", stroke: theme.summaryCore, "stroke-width": Math.max(1, 1.25 * panel.layout.scale) })}/><circle ${attrs({ cx: g.anchorX, cy: g.anchorY, r: g.radius, fill: theme.summaryCore, stroke: theme.seriesEdges?.[0] ?? theme.primaryEdge ?? theme.field, "stroke-width": Math.max(1.4, 1.9 * panel.layout.scale) })}/><text ${attrs({ x: g.labelX, y: g.labelY, fill: theme.summaryCore, stroke: theme.field, "stroke-width": Math.max(1.6, 2.2 * panel.layout.scale), "paint-order": "stroke", "font-size": Math.max(9, panel.layout.font.legend * 1.06), "font-weight": 600, "text-anchor": g.textAnchor, "dominant-baseline": "middle" })}>${esc(label)}</text></g>`;
176
+ }).join("");
177
+ }
178
+
179
+ function matrixLegend(panel, theme, namespace) {
180
+ if (panel.renderer !== "categorical_matrix" || !panel.valueScale || !panel.marks.length) return "";
181
+ const width = Math.min(190 * panel.layout.scale, (panel.layout.plot.right - panel.layout.plot.left) * 0.42), left = panel.layout.plot.right - width;
182
+ const top = panel.layout.plot.top - 28 * panel.layout.scale, height = Math.max(5, 7 * panel.layout.scale), style = panel.marks[0].style, id = `${namespace}-${safeId(panel.id)}-matrix-gradient`;
183
+ return `<defs><linearGradient id="${esc(id)}"><stop offset="0%" stop-color="${esc(style.low)}"/><stop offset="68%" stop-color="${esc(style.color)}"/><stop offset="100%" stop-color="${esc(style.high)}"/></linearGradient></defs><text ${attrs({ x: left, y: top - 3 * panel.layout.scale, fill: theme.label, "font-size": panel.layout.font.legend })}>${esc(panel.valueScale.label)}</text><rect ${attrs({ x: left, y: top, width, height, fill: `url(#${id})`, stroke: theme.spine })}/><text ${attrs({ x: left, y: top + height + 12 * panel.layout.scale, fill: theme.secondary, "font-size": panel.layout.font.legend })}>${esc(panel.valueScale.domain[0])}</text><text ${attrs({ x: left + width, y: top + height + 12 * panel.layout.scale, fill: theme.secondary, "font-size": panel.layout.font.legend, "text-anchor": "end" })}>${esc(panel.valueScale.domain[1])}</text>`;
184
+ }
185
+
186
+ function panelSvg(panel, theme, profile, namespace) {
187
+ if (!panel.resolved) throw new TypeError(`SVG export requires a scene-aware renderer; ${panel.renderer} remains on the compatibility path`);
188
+ const render = (mark) => mark.kind === "point" ? marker(mark)
189
+ : ["segment", "summary-line"].includes(mark.kind) ? segment(mark)
190
+ : mark.kind === "median-rule" ? segment(mark)
191
+ : mark.kind === "bar" ? bar(mark, theme)
192
+ : mark.kind === "cell" ? cell(mark, theme, panel.layout.font.axis)
193
+ : mark.kind === "interval" ? interval(mark, theme)
194
+ : mark.kind === "connector" ? connector(mark, theme)
195
+ : mark.kind === "reference-band" ? referenceBand(mark)
196
+ : mark.kind === "baseline-rule" ? baseline(mark, panel)
197
+ : mark.kind === "row-band" ? rowBand(mark)
198
+ : mark.kind === "rug" ? rug(mark)
199
+ : mark.kind === "temporal-bar" ? temporalBar(mark, panel, theme) : "";
200
+ const layers = partitionPanelMarks(panel.marks), plot = plotClipRect(panel), clipId = `${namespace}-${safeId(panel.id)}-evidence-clip`;
201
+ const renderLayer = (name, marks) => `<g data-layer="${name}" clip-path="url(#${clipId})">${marks.map(render).join("")}</g>`;
202
+ const dataMarks = [...layers.data.filter((mark) => mark.kind !== "point"), ...layers.data.filter((mark) => mark.kind === "point")];
203
+ const dataLabels = panel.marks.map((mark) => mark.kind === "connector" ? connectorLabel(mark, panel, theme)
204
+ : mark.kind === "baseline-rule" ? baselineLabel(mark, panel)
205
+ : mark.kind === "temporal-bar" ? temporalBarLabel(mark, panel, theme) : "").join("");
206
+ const denominator = panel.meta?.denominator ? `<text ${attrs({ x: panel.layout.plot.right, y: panel.layout.plot.top - 5 * panel.layout.scale, fill: theme.warm, "font-size": panel.layout.font.signature, "text-anchor": "end" })}>${esc(`${panel.meta.denominator.label}: ${panel.meta.denominator.value}`)}</text>` : "";
207
+ const title = `<text ${attrs({ x: panel.layout.plot.left, y: panel.layout.text?.titleY ?? panel.layout.rect.top + 20, fill: theme.mode === "paper" ? theme.label : theme.primary, "font-size": panel.layout.font.title })}>${esc(panel.spec.title || panel.renderer)}</text>`;
208
+ const provenance = theme.mode !== "paper" && panel.spec.signature && (panel.layout.panelIndex ?? 0) === 0
209
+ ? `<text ${attrs({ x: panel.layout.provenance?.left ?? panel.layout.plot.left, y: panel.layout.provenance?.y ?? panel.layout.rect.bottom - 8 * panel.layout.scale, fill: theme.faint, "font-size": panel.layout.font.signature, "text-anchor": "start", "data-layer": "provenance" })}>${esc(panel.spec.signature)}</text>` : "";
210
+ return `<g ${attrs({ "data-panel-id": panel.id, "data-renderer": panel.renderer, "data-denominator": panel.denominator == null ? null : JSON.stringify(panel.denominator), "data-x-category-order": panel.categories.x?.join("|"), "data-y-category-order": panel.categories.y?.join("|") })}><defs><clipPath id="${esc(clipId)}" clipPathUnits="userSpaceOnUse"><rect ${attrs({ x: plot.left, y: plot.top, width: plot.right - plot.left, height: plot.bottom - plot.top })}/></clipPath></defs><g data-layer="surface">${panelSurface(panel, theme)}</g><g data-layer="grid">${grid(panel, theme, profile)}</g>${renderLayer("reference", layers.reference)}${renderLayer("data", dataMarks)}${renderLayer("summary", layers.summary)}<g data-layer="axes">${title}${axes(panel, theme)}${provenance}</g><g data-layer="annotations">${dataLabels}${annotations(panel, theme)}</g><g data-layer="legend">${legend(panel, theme)}${matrixLegend(panel, theme, namespace)}${denominator}</g></g>`;
211
+ }
212
+
213
+ export function resolvedSceneToSvg(resolved, options = {}) {
214
+ const composed = resolved.schemaVersion === "figurestead.composed-scene/1" ? resolved : composeResolvedScene(resolved);
215
+ const scene = options.sourceScene, namespace = svgNamespace(composed, scene, options);
216
+ const exportSize = options.exportSize ?? resolveExportSize({ ...options, width: composed.width, height: composed.height });
217
+ const title = composed.spec.title, description = [composed.spec.description || composed.spec.subtitle || "Scientific figure", composed.spec.note, ...composed.panels.flatMap((panel) => panel.notes ?? [])].filter(Boolean).join(" ");
218
+ const titleId = `${namespace}-title`, descId = `${namespace}-desc`;
219
+ const header = composed.layout.header ? `<text ${attrs({ x: composed.layout.header.left, y: composed.layout.header.titleY, fill: composed.theme.mode === "paper" ? composed.theme.label : composed.theme.primary, "font-size": composed.layout.font.title })}>${esc(title)}</text>` : "";
220
+ return `<svg xmlns="http://www.w3.org/2000/svg" ${attrs({ width: exportSize.widthAttribute, height: exportSize.heightAttribute, viewBox: `0 0 ${composed.width} ${composed.height}`, role: "img", "aria-labelledby": `${titleId} ${descId}`, "data-scene-version": composed.sourceSceneVersion, "data-resolved-scene-version": composed.resolvedSceneVersion, "data-composed-scene-version": composed.schemaVersion, "data-evidence-fingerprint": scene ? evidenceFingerprint(scene) : null, "data-physical-width-mm": exportSize.physical?.widthMm })}><title id="${titleId}">${esc(title)}</title><desc id="${descId}">${esc(description)}</desc><rect width="100%" height="100%" fill="${composed.theme.field}"/>${header}${composed.panels.map((panel) => panelSvg(panel, composed.theme, composed.profile, namespace)).join("")}</svg>`;
221
+ }
222
+
223
+ export function sceneToSvg(scene, options = {}) {
224
+ const exportSize = resolveExportSize(options);
225
+ const resolved = resolveTerminalScene(scene, { width: exportSize.width, height: exportSize.height });
226
+ return resolvedSceneToSvg(composeResolvedScene(resolved), { ...options, exportSize, sourceScene: scene });
227
+ }
228
+
229
+ export function exportFigureSvg(input, options = {}) {
230
+ const scene = input?.schemaVersion === "figurestead.scene/1" ? input : compileTerminalScene(input, { registry: options.registry ?? CORE_REGISTRY });
231
+ return sceneToSvg(scene, options);
232
+ }
@@ -0,0 +1,215 @@
1
+ import { validateContract } from "./schema.js";
2
+ import { CORE_REGISTRY } from "./core-renderers.js";
3
+ import { panelContract } from "./figure.js";
4
+ import { resolvePanelDomains } from "./figure.js";
5
+ import { resolveApplicationProfile } from "./application-profiles.js";
6
+ import { legendWithStyles, resolveSeriesStyles } from "./series-style.js";
7
+ import { compileMotionPlan, assertTerminalMotionIdentity } from "./motion-plan.js";
8
+ import { auditPaperTheme, themeResolutionForProfile } from "./paper-profile.js";
9
+ import { validateEvidenceCoverage } from "./evidence-coverage.js";
10
+
11
+ export const TERMINAL_SCENE_VERSION = "figurestead.scene/1";
12
+
13
+ function deepFreeze(value) {
14
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
15
+ Object.values(value).forEach(deepFreeze);
16
+ return Object.freeze(value);
17
+ }
18
+
19
+ const markId = (panel, kind, ...parts) => [panel.id, kind, ...parts].map((item) => String(item).replace(/[^a-zA-Z0-9_.-]+/g, "-")).join("/");
20
+
21
+ function lineMarks(panel, contract, prepared, styles) {
22
+ const marks = [];
23
+ contract.data.series.forEach((series) => {
24
+ const style = styles[series.key], points = prepared.points.filter((point) => point.series === series.key).sort((a, b) => a.index - b.index);
25
+ points.forEach((point) => marks.push({
26
+ id: markId(panel, "point", series.key, point.index), kind: "point", series: series.key,
27
+ x: point.x, y: point.y, style,
28
+ }));
29
+ for (let index = 1; index < points.length; index += 1) marks.push({
30
+ id: markId(panel, "segment", series.key, index - 1, index), kind: "segment", series: series.key,
31
+ interpolation: contract.encoding.interpolation,
32
+ from: { x: points[index - 1].x, y: points[index - 1].y },
33
+ to: { x: points[index].x, y: points[index].y }, style,
34
+ });
35
+ });
36
+ return marks;
37
+ }
38
+
39
+ function scatterMarks(panel, contract, prepared, styles) {
40
+ const marks = prepared.points.map((point) => ({
41
+ id: markId(panel, "point", point.series, point.index), kind: "point", series: point.series,
42
+ x: point.x, y: point.y, style: styles[point.series],
43
+ }));
44
+ if (contract.data.summary === "linear_fit") {
45
+ const n = prepared.points.length, sx = prepared.points.reduce((a, p) => a + p.x, 0), sy = prepared.points.reduce((a, p) => a + p.y, 0);
46
+ const sxx = prepared.points.reduce((a, p) => a + p.x * p.x, 0), sxy = prepared.points.reduce((a, p) => a + p.x * p.y, 0);
47
+ const slope = (n * sxy - sx * sy) / (n * sxx - sx * sx || 1), intercept = (sy - slope * sx) / n;
48
+ marks.push({ id: markId(panel, "summary", "linear-fit"), kind: "summary-line", role: "model", slope, intercept, style: { color: contract.theme.summaryCore, edge: contract.theme.summaryEdge ?? null, lineStyle: "solid" } });
49
+ }
50
+ return marks;
51
+ }
52
+
53
+ function barMarks(panel, contract, prepared, styles) {
54
+ return prepared.entries.map((entry) => ({
55
+ id: markId(panel, "bar", entry.series, entry.category), kind: "bar", series: entry.series,
56
+ category: entry.category, value: entry.value, missing: entry.missing,
57
+ orientation: contract.data.orientation, layer: entry.layer, seriesIndex: entry.seriesIndex,
58
+ categoryIndex: entry.categoryIndex, style: styles[entry.series],
59
+ }));
60
+ }
61
+
62
+ function matrixMarks(panel, contract, prepared) {
63
+ const style = { color: contract.theme.primary, low: contract.theme.panel, high: contract.theme.summaryCore, edge: contract.theme.spine };
64
+ return prepared.points.map((point) => ({
65
+ id: markId(panel, "cell", point.yCategory, point.xCategory), kind: "cell",
66
+ xCategory: point.xCategory, yCategory: point.yCategory, value: point.value,
67
+ status: point.status, label: point.label, diagonalMode: contract.data.diagonalMode, style,
68
+ }));
69
+ }
70
+
71
+ function fallbackMarks(panel, prepared, styles) {
72
+ return (prepared.points ?? []).map((point, index) => {
73
+ const series = String(point.series ?? "series");
74
+ return { id: markId(panel, "mark", series, point.id ?? point.index ?? index), kind: "renderer-mark", series, evidence: { ...point }, style: styles[series] ?? null };
75
+ });
76
+ }
77
+
78
+ function compileMarks(panel, child, prepared, styles, definition, context = {}) {
79
+ if (typeof definition.compileScene === "function") {
80
+ const compiled = definition.compileScene({ panel, contract: child, prepared, styles, markId, ...context });
81
+ if (!compiled || !Array.isArray(compiled.marks)) throw new TypeError(`renderer ${panel.renderer}.compileScene must return { marks }`);
82
+ return compiled;
83
+ }
84
+ if (panel.renderer === "line") return lineMarks(panel, child, prepared, styles);
85
+ if (panel.renderer === "scatter") return scatterMarks(panel, child, prepared, styles);
86
+ if (["categorical_bar", "categorical_layered_bar"].includes(panel.renderer)) return barMarks(panel, child, prepared, styles);
87
+ if (panel.renderer === "categorical_matrix") return matrixMarks(panel, child, prepared);
88
+ return { marks: fallbackMarks(panel, prepared, styles) };
89
+ }
90
+
91
+ function seriesKeys(child) {
92
+ if (Array.isArray(child.data.series) && child.data.series.length && typeof child.data.series[0] === "object") return child.data.series.map((item) => item.key);
93
+ if (Array.isArray(child.data.series)) return [...new Set(child.data.series.map(String))];
94
+ return [];
95
+ }
96
+
97
+ function profilePresentation(profile) {
98
+ return {
99
+ panelSurface: profile.panelSurface,
100
+ frame: profile.frame,
101
+ legend: profile.legend === "outside" ? "outside-right" : profile.legend === "auto" ? "auto" : "bottom-right",
102
+ lineWidth: profile.lineWidth,
103
+ markerScale: profile.markerScale,
104
+ };
105
+ }
106
+
107
+ export function compileFigureModel(input, options = {}) {
108
+ const registry = options.registry ?? CORE_REGISTRY;
109
+ const contract = validateContract(input, registry);
110
+ const applicationProfile = resolveApplicationProfile(contract.view.profile);
111
+ contract.applicationProfile = applicationProfile;
112
+ const themeResolution = themeResolutionForProfile(contract.theme, applicationProfile);
113
+ contract.theme = themeResolution.theme;
114
+ if (input.view?.profile && input.view.motion == null) contract.view.motion = applicationProfile.motion;
115
+ if (input.view?.profile && input.view.ambient == null) contract.view.ambient = applicationProfile.ambient;
116
+ if (input.view?.profile && input.view.strategy == null) contract.view.strategy = contract.view.motion === "semantic" ? "auto" : "none";
117
+ contract.panels = contract.panels.map((panel) => ({
118
+ ...panel,
119
+ presentation: { ...profilePresentation(applicationProfile), ...(panel.presentation ?? {}) },
120
+ }));
121
+ const styles = resolveSeriesStyles(contract);
122
+ contract.seriesStyles = styles;
123
+ contract.appearanceReport = applicationProfile.key === "paper" ? {
124
+ resolution: themeResolution.report,
125
+ audit: auditPaperTheme(contract.theme, styles),
126
+ } : null;
127
+ const preparedPanels = contract.panels.map((panel) => {
128
+ const definition = registry.get(panel.renderer), child = panelContract(contract, panel);
129
+ return { panel, definition, contract: child, prepared: definition.prepare(child) };
130
+ });
131
+ const domains = resolvePanelDomains(contract, preparedPanels);
132
+ const panels = preparedPanels.map(({ panel, definition, contract: child, prepared }, panelIndex) => {
133
+ const keys = seriesKeys(child), legend = prepared.legend ?? keys.map((key, colorIndex) => ({ key, label: key, colorIndex }));
134
+ const compiled = compileMarks(panel, child, prepared, styles, definition, { panelIndex, figure: contract });
135
+ const defaultMarks = Array.isArray(compiled) ? compiled : compiled.marks;
136
+ const defaultCategories = {
137
+ x: child.data.xCategories ?? (child.data.orientation === "vertical" ? child.data.categories : null),
138
+ y: child.data.yCategories ?? (child.data.orientation === "horizontal" ? child.data.categories : null),
139
+ };
140
+ return {
141
+ id: panel.id, renderer: panel.renderer, family: definition.family,
142
+ spec: child.spec, scales: compiled.scales ?? { x: child.xScale, y: child.yScale }, encoding: child.encoding,
143
+ domain: domains[panelIndex], presentation: child.presentation,
144
+ categories: compiled.categories ?? defaultCategories,
145
+ categoryLabels: compiled.categoryLabels ?? null,
146
+ orientation: compiled.orientation ?? child.data.orientation ?? null,
147
+ valueScale: child.data.valueScale ?? null,
148
+ denominator: child.data.denominator ?? child.data.n ?? null,
149
+ annotations: child.annotations ?? [],
150
+ notes: [child.spec.note, ...(child.annotations ?? []).filter((item) => item?.type === "scientific_note").map((item) => item.text)].filter(Boolean),
151
+ legend: compiled.legend ?? legendWithStyles(legend, keys, styles),
152
+ meta: compiled.meta ?? null,
153
+ marks: defaultMarks,
154
+ };
155
+ });
156
+ const scene = {
157
+ schemaVersion: TERMINAL_SCENE_VERSION,
158
+ contractSchemaVersion: contract.schemaVersion,
159
+ rendererApiVersion: contract.rendererApiVersion,
160
+ spec: contract.spec,
161
+ applicationProfile,
162
+ view: contract.view, layout: contract.layout, profile: contract.profile,
163
+ timeline: contract.timeline, motion: contract.motion, style: contract.style,
164
+ theme: contract.theme,
165
+ appearanceReport: contract.appearanceReport,
166
+ seriesStyles: styles,
167
+ panels,
168
+ };
169
+ scene.evidenceCoverage = validateEvidenceCoverage(panels);
170
+ scene.motionPlan = compileMotionPlan(scene, contract.view);
171
+ assertTerminalMotionIdentity(scene.motionPlan, scene);
172
+ deepFreeze(scene);
173
+ return Object.freeze({ contract, scene, preparedPanels, domains });
174
+ }
175
+
176
+ export function compileTerminalScene(input, options = {}) {
177
+ return compileFigureModel(input, options).scene;
178
+ }
179
+
180
+ export function terminalEvidence(scene) {
181
+ return scene.panels.map((panel) => ({ panelId: panel.id, marks: panel.marks.map((mark) => ({ ...mark })) }));
182
+ }
183
+
184
+ function sorted(value) {
185
+ if (Array.isArray(value)) return value.map(sorted);
186
+ if (!value || typeof value !== "object") return value;
187
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
188
+ }
189
+
190
+ export function canonicalTerminalEvidence(scene) {
191
+ return sorted({
192
+ schemaVersion: scene.schemaVersion,
193
+ contractSchemaVersion: scene.contractSchemaVersion,
194
+ spec: { title: scene.spec.title, description: scene.spec.description, note: scene.spec.note },
195
+ seriesIdentity: Object.fromEntries(Object.entries(scene.seriesStyles).map(([key, style]) => [key, {
196
+ key, glyph: style.glyph, lineStyle: style.lineStyle,
197
+ }])),
198
+ panels: scene.panels.map((panel) => ({
199
+ id: panel.id, renderer: panel.renderer, encoding: panel.encoding, domain: panel.domain,
200
+ categories: panel.categories, denominator: panel.denominator, notes: panel.notes,
201
+ ...(panel.categoryLabels ? { categoryLabels: panel.categoryLabels } : {}),
202
+ marks: panel.marks.map(({ style: _style, ...mark }) => mark),
203
+ })),
204
+ });
205
+ }
206
+
207
+ export function evidenceFingerprint(scene) {
208
+ const text = JSON.stringify(canonicalTerminalEvidence(scene));
209
+ let hash = 0x811c9dc5;
210
+ for (let index = 0; index < text.length; index += 1) {
211
+ hash ^= text.charCodeAt(index);
212
+ hash = Math.imul(hash, 0x01000193);
213
+ }
214
+ return `fnv1a32-${(hash >>> 0).toString(16).padStart(8, "0")}`;
215
+ }
@@ -0,0 +1,34 @@
1
+ import { contrastAudit, validateThemePack } from "./theme-pack.js";
2
+ import { cloneValue } from "./schema.js";
3
+
4
+ export const THEME_CATALOG_VERSION = "figurestead.theme-catalog/1";
5
+
6
+ function deepFreeze(value) {
7
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
8
+ Object.values(value).forEach(deepFreeze);
9
+ return Object.freeze(value);
10
+ }
11
+
12
+ export function mergeThemePacks(packs, options = {}) {
13
+ if (!Array.isArray(packs) || !packs.length) throw new TypeError("mergeThemePacks requires one or more theme packs");
14
+ const themes = {}, sources = {};
15
+ packs.forEach((source, index) => {
16
+ const pack = validateThemePack(source);
17
+ Object.entries(pack.themes).forEach(([key, theme]) => {
18
+ if (themes[key]) throw new TypeError(`duplicate theme key ${key} in ${pack.name} and ${sources[key]}`);
19
+ themes[key] = theme; sources[key] = pack.name || `pack-${index + 1}`;
20
+ });
21
+ });
22
+ return deepFreeze({ schemaVersion: THEME_CATALOG_VERSION, name: options.name ?? "Figurestead theme catalog", themes, sources });
23
+ }
24
+
25
+ export function auditThemeCatalog(catalog) {
26
+ if (!catalog || catalog.schemaVersion !== THEME_CATALOG_VERSION) throw new TypeError("auditThemeCatalog requires a Figurestead theme catalog");
27
+ const themes = Object.entries(catalog.themes).map(([key, theme]) => ({ key, source: catalog.sources[key], findings: contrastAudit(theme) }));
28
+ return deepFreeze({ themes, total: themes.length, warnings: themes.reduce((count, item) => count + item.findings.length, 0), clean: themes.every((item) => item.findings.length === 0) });
29
+ }
30
+
31
+ export function catalogThemePack(catalog) {
32
+ if (!catalog || catalog.schemaVersion !== THEME_CATALOG_VERSION) throw new TypeError("catalogThemePack requires a Figurestead theme catalog");
33
+ return { schemaVersion: "figurestead.theme-pack/1", name: catalog.name, themes: cloneValue(catalog.themes), drafts: {} };
34
+ }