@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.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/THIRD_PARTY_NOTICES.md +39 -0
- package/TRADEMARKS.md +13 -0
- package/package.json +31 -0
- package/src/accessibility.js +33 -0
- package/src/appearance.js +25 -0
- package/src/application-profiles.js +76 -0
- package/src/atmosphere.js +71 -0
- package/src/canvas-scene.js +323 -0
- package/src/clock.js +27 -0
- package/src/color-space.js +114 -0
- package/src/composition.js +201 -0
- package/src/core-renderers.js +33 -0
- package/src/create-matrix-plot.js +97 -0
- package/src/evidence-coverage.js +80 -0
- package/src/export-bundle.js +83 -0
- package/src/extensions/temporal/coverage.js +96 -0
- package/src/extensions/temporal/index.js +43 -0
- package/src/extensions/temporal/observations.js +87 -0
- package/src/extensions/temporal/shared.js +172 -0
- package/src/figure-layout.js +70 -0
- package/src/figure.js +42 -0
- package/src/index.js +30 -0
- package/src/layout.js +41 -0
- package/src/marks.js +218 -0
- package/src/motion-plan.js +66 -0
- package/src/motion-recipes.js +39 -0
- package/src/paper-profile.js +78 -0
- package/src/physical-export.js +34 -0
- package/src/presentation.js +127 -0
- package/src/primitives.js +24 -0
- package/src/random.js +26 -0
- package/src/registry.js +29 -0
- package/src/render-layers.js +35 -0
- package/src/renderer-test-kit.js +29 -0
- package/src/renderers/line.js +133 -0
- package/src/renderers/scatter.js +24 -0
- package/src/renderers/shared.js +21 -0
- package/src/renderers/strip-summary.js +40 -0
- package/src/resolved-scene.js +249 -0
- package/src/scales.js +68 -0
- package/src/schema.js +356 -0
- package/src/series-style.js +59 -0
- package/src/svg-export.js +232 -0
- package/src/terminal-scene.js +215 -0
- package/src/theme-catalog.js +34 -0
- 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
+
}
|