@figurestead/web 0.9.0-alpha.1 → 0.9.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +214 -5
- package/THIRD_PARTY_NOTICES.md +12 -5
- package/package.json +42 -5
- package/src/accessibility.js +51 -11
- package/src/canvas-scene.js +55 -14
- package/src/clock.js +19 -8
- package/src/composition.js +18 -8
- package/src/core-renderers.js +9 -3
- package/src/create-matrix-plot.js +104 -34
- package/src/direct-labels.js +73 -0
- package/src/export-bundle.js +1 -1
- package/src/extensions/temporal/shared.js +1 -1
- package/src/figure-layout.js +3 -2
- package/src/height-negotiation.js +77 -0
- package/src/index.js +1 -1
- package/src/layout.js +4 -2
- package/src/line-identity.js +29 -0
- package/src/marks.js +2 -2
- package/src/paper-profile.js +5 -1
- package/src/renderers/scatter.js +3 -2
- package/src/resolved-scene.js +64 -7
- package/src/responsive-header.js +159 -0
- package/src/schema.js +34 -10
- package/src/scientific-layout.js +148 -0
- package/src/screen-legibility.js +30 -0
- package/src/statistics.js +21 -0
- package/src/svg-export.js +60 -16
- package/src/terminal-scene.js +11 -4
- package/src/theme-pack.js +31 -0
- package/themes/deep_observatory_sage_core.json +28 -0
- package/themes/lavender_fog_notebook.json +29 -0
- package/themes/midnight_transit_signal_slate.json +28 -0
- package/themes/registration_ink.json +28 -0
- package/themes/slipware.json +28 -0
- package/themes/ultraviolet_laboratory.json +29 -0
- package/types/extensions/temporal.d.ts +45 -0
- package/types/index.d.ts +665 -0
- package/types/theme-json.d.ts +4 -0
package/src/svg-export.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import { DIRECT_FONT } from "./direct-labels.js";
|
|
2
|
+
import { lineMarkerGeometry } from "./line-identity.js";
|
|
1
3
|
import { CORE_REGISTRY } from "./core-renderers.js";
|
|
2
4
|
import { compileTerminalScene, evidenceFingerprint } from "./terminal-scene.js";
|
|
3
5
|
import { resolveTerminalScene } from "./resolved-scene.js";
|
|
4
6
|
import { composeResolvedScene } from "./composition.js";
|
|
5
7
|
import { partitionPanelMarks, plotClipRect } from "./render-layers.js";
|
|
6
8
|
import { resolveExportSize } from "./physical-export.js";
|
|
9
|
+
import { validateThemeColors } from "./schema.js";
|
|
10
|
+
import { fixedResponsiveHeader, RESPONSIVE_HEADER_MAX_WIDTH } from "./responsive-header.js";
|
|
7
11
|
|
|
8
|
-
const
|
|
12
|
+
const xmlValue = (value) => String(value).replace(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu, "\uFFFD");
|
|
13
|
+
const esc = (value) => xmlValue(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
|
9
14
|
const attrs = (value) => Object.entries(value).filter(([, item]) => item != null).map(([key, item]) => `${key}="${esc(item)}"`).join(" ");
|
|
10
15
|
const FONT = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
|
|
11
16
|
|
|
@@ -25,17 +30,29 @@ function svgNamespace(composed, scene, options) {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
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 };
|
|
33
|
+
function marker(mark, appearance = null) {
|
|
34
|
+
const { cx, cy, radius } = mark.geometry, common = { "data-mark-id": mark.id, fill: "none", stroke: mark.style.color, "stroke-width": mark.lineIdentity ? mark.geometry.outlineWidth : 1.5, ...appearance };
|
|
30
35
|
if (mark.style.glyph === "square") return `<rect ${attrs({ ...common, x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 })}/>`;
|
|
31
36
|
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
37
|
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
38
|
return `<circle ${attrs({ ...common, cx, cy, r: radius })}/>`;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
function segment(mark) {
|
|
41
|
+
function segment(mark, extra = {}) {
|
|
37
42
|
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" })}/>`;
|
|
43
|
+
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", ...extra })}/>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function linePoint(mark) {
|
|
47
|
+
const edge = mark.style.edge ? marker(mark, { stroke: mark.style.edge, "stroke-width": mark.geometry.outlineWidth + 1.3 }) : "";
|
|
48
|
+
return edge + marker(mark);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function maskedLine(mark, points, bounds, id) {
|
|
52
|
+
// Luminance mask is applied only to this series' line (never the panel/grid).
|
|
53
|
+
const mask = `<mask ${attrs({ id, maskUnits: "userSpaceOnUse", x: bounds.left, y: bounds.top, width: bounds.right - bounds.left, height: bounds.bottom - bounds.top, "mask-type": "luminance" })}><rect ${attrs({ x: bounds.left, y: bounds.top, width: bounds.right - bounds.left, height: bounds.bottom - bounds.top, fill: "white" })}/>${points.map(p => marker(p, { fill: "black", stroke: "none", "data-mark-id": null })).join("")}</mask>`;
|
|
54
|
+
const edge = mark.style.edge ? segment(mark, { stroke: mark.style.edge, "stroke-width": mark.style.lineWidth + 1.3 }) : "";
|
|
55
|
+
return `<defs>${mask}</defs><g mask="url(#${esc(id)})">${edge}${segment(mark)}</g>`;
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
function bar(mark, theme) {
|
|
@@ -145,15 +162,20 @@ function panelSurface(panel, theme) {
|
|
|
145
162
|
function axes(panel, theme) {
|
|
146
163
|
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
164
|
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>`); });
|
|
165
|
+
const rotate = panel.layout.text?.rotateX ?? (panel.axes.xType === "band" && panel.axes.xTicks.some((tick) => String(tick.label).length * font.axis * 0.62 > slot * 0.92));
|
|
166
|
+
panel.axes.xTicks.forEach((tick) => { const x = tickPosition(panel.axes.x, tick), y = panel.layout.text?.xTickBaselineY ?? 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
167
|
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)
|
|
168
|
+
if (panel.spec.xLabel) pieces.push(`<text ${attrs({ x: (plot.left + plot.right) / 2, y: panel.layout.text?.xLabelBaselineY ?? panel.layout.rect.bottom - 6 * panel.layout.scale, fill: theme.label, "font-size": font.axis, "text-anchor": "middle" })}>${esc(panel.spec.xLabel)}</text>`);
|
|
169
|
+
if (panel.spec.yLabel) { const x = panel.layout.text?.yLabelX ?? panel.layout.rect.left + 12 * panel.layout.scale; pieces.push(`<text ${attrs({ x, y: (plot.top + plot.bottom) / 2, fill: theme.label, "font-size": font.axis, transform: `rotate(-90 ${x} ${(plot.top + plot.bottom) / 2})`, "text-anchor": "middle", "dominant-baseline": "hanging" })}>${esc(panel.spec.yLabel)}</text>`); }
|
|
153
170
|
return pieces.join("");
|
|
154
171
|
}
|
|
155
172
|
|
|
156
|
-
function legend(panel, theme) {
|
|
173
|
+
function legend(panel, theme, namespace) {
|
|
174
|
+
if (panel.directLabelPlan?.status === "placed") return panel.directLabelPlan.entries.map(e => {
|
|
175
|
+
const l=e.leader;
|
|
176
|
+
return (l ? `<path ${attrs({d:`M ${l.x1} ${l.y1} L ${l.x2} ${l.y2}`,fill:"none",stroke:l.color,"stroke-width":.7})}/>` : "")
|
|
177
|
+
+ linePoint(e.marker) + `<text ${attrs({x:e.textX,y:e.textY,fill:theme.label,"font-family":DIRECT_FONT,"font-size":panel.directLabelPlan.font,"xml:space":"preserve"})}>${esc(e.label)}</text>`;
|
|
178
|
+
}).join("");
|
|
157
179
|
if (panel.presentation?.legend === "none") return "";
|
|
158
180
|
const insideTop = panel.layout.plot.bottom - Math.max(14, 14 + (panel.legend.length - 1) * 20) * panel.layout.scale;
|
|
159
181
|
return panel.legend.map((item, index) => {
|
|
@@ -161,7 +183,14 @@ function legend(panel, theme) {
|
|
|
161
183
|
const x = entry?.markerX ?? (panel.layout.legend.outside ? panel.layout.legend.left : panel.layout.plot.right - 24 * panel.layout.scale);
|
|
162
184
|
const textX = entry?.textX ?? (panel.layout.legend.outside ? x + 12 * panel.layout.scale : x - 10 * panel.layout.scale);
|
|
163
185
|
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
|
-
|
|
186
|
+
let point = `<circle ${attrs({ cx: x, cy: y, r: 4 * panel.layout.scale, fill: "none", stroke: style.color ?? theme.series[item.colorIndex % theme.series.length] })}/>`;
|
|
187
|
+
if (panel.renderer === "line") {
|
|
188
|
+
const mark = { id: `${panel.id}-legend-${index}`, lineIdentity: true, style,
|
|
189
|
+
geometry: { cx: x, cy: y, ...lineMarkerGeometry(style, panel.layout.scale, panel.presentation?.markerScale ?? 1) } };
|
|
190
|
+
const half = 12 * Math.max(1, panel.layout.scale);
|
|
191
|
+
point = maskedLine({ ...mark, geometry: { x1: x - half, y1: y, x2: x + half, y2: y } }, [mark],
|
|
192
|
+
{ left: x - half - 3, right: x + half + 3, top: y - 10, bottom: y + 10 }, `${namespace}-${safeId(panel.id)}-legend-${index}`) + linePoint(mark);
|
|
193
|
+
}
|
|
165
194
|
const label = entry?.displayLabel ?? item.label;
|
|
166
195
|
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
196
|
}).join("");
|
|
@@ -183,9 +212,22 @@ function matrixLegend(panel, theme, namespace) {
|
|
|
183
212
|
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
213
|
}
|
|
185
214
|
|
|
186
|
-
function
|
|
215
|
+
function panelHeaderSvg(panel, theme, responsive) {
|
|
216
|
+
const titleFill = theme.mode === "paper" ? theme.label : theme.primary;
|
|
217
|
+
if (!responsive) {
|
|
218
|
+
return `<text ${attrs({ x: panel.layout.plot.left, y: panel.layout.text?.titleY ?? panel.layout.rect.top + 20, fill: titleFill, "font-size": panel.layout.font.title })}>${esc(panel.spec.title || panel.renderer)}</text>`;
|
|
219
|
+
}
|
|
220
|
+
const title = `<text ${attrs({ fill: titleFill, "font-size": panel.layout.font.title, "data-header-part": "title", "data-full-text": panel.spec.title || panel.renderer })}>${responsive.title.lines.map((line, index) => `<tspan ${attrs({ x: panel.layout.plot.left, y: responsive.title.baselines[index] })}>${esc(line)}</tspan>`).join("")}</text>`;
|
|
221
|
+
const subtitle = panel.spec.subtitle ? `<text ${attrs({ fill: theme.secondary, "font-size": panel.layout.font.subtitle, "font-style": "italic", "data-header-part": "subtitle", "data-full-text": panel.spec.subtitle })}>${responsive.subtitle.lines.map((line, index) => `<tspan ${attrs({ x: panel.layout.plot.left, y: responsive.subtitle.baselines[index] })}>${esc(line)}</tspan>`).join("")}</text>` : "";
|
|
222
|
+
return `<g ${attrs({ "data-responsive-header": responsive.policy })}>${title}${subtitle}</g>`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function panelSvg(panel, theme, profile, namespace, responsive = null) {
|
|
187
226
|
if (!panel.resolved) throw new TypeError(`SVG export requires a scene-aware renderer; ${panel.renderer} remains on the compatibility path`);
|
|
188
|
-
const render = (mark) =>
|
|
227
|
+
const render = (mark) => panel.renderer === "line" && mark.kind === "segment"
|
|
228
|
+
? maskedLine(mark, panel.marks.filter(p => p.lineIdentity && p.series === mark.series), plotClipRect(panel), `${namespace}-${safeId(panel.id)}-${hashText(mark.id)}-identity`)
|
|
229
|
+
: mark.lineIdentity ? linePoint(mark)
|
|
230
|
+
: mark.kind === "point" ? marker(mark)
|
|
189
231
|
: ["segment", "summary-line"].includes(mark.kind) ? segment(mark)
|
|
190
232
|
: mark.kind === "median-rule" ? segment(mark)
|
|
191
233
|
: mark.kind === "bar" ? bar(mark, theme)
|
|
@@ -204,20 +246,22 @@ function panelSvg(panel, theme, profile, namespace) {
|
|
|
204
246
|
: mark.kind === "baseline-rule" ? baselineLabel(mark, panel)
|
|
205
247
|
: mark.kind === "temporal-bar" ? temporalBarLabel(mark, panel, theme) : "").join("");
|
|
206
248
|
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 =
|
|
249
|
+
const title = panelHeaderSvg(panel, theme, responsive);
|
|
208
250
|
const provenance = theme.mode !== "paper" && panel.spec.signature && (panel.layout.panelIndex ?? 0) === 0
|
|
209
251
|
? `<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>`;
|
|
252
|
+
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, namespace)}${matrixLegend(panel, theme, namespace)}${denominator}</g></g>`;
|
|
211
253
|
}
|
|
212
254
|
|
|
213
255
|
export function resolvedSceneToSvg(resolved, options = {}) {
|
|
214
256
|
const composed = resolved.schemaVersion === "figurestead.composed-scene/1" ? resolved : composeResolvedScene(resolved);
|
|
257
|
+
validateThemeColors(composed.theme, "scene.theme");
|
|
215
258
|
const scene = options.sourceScene, namespace = svgNamespace(composed, scene, options);
|
|
216
259
|
const exportSize = options.exportSize ?? resolveExportSize({ ...options, width: composed.width, height: composed.height });
|
|
217
260
|
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
261
|
const titleId = `${namespace}-title`, descId = `${namespace}-desc`;
|
|
219
262
|
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
|
-
|
|
263
|
+
const fixedResponsive = composed.width <= RESPONSIVE_HEADER_MAX_WIDTH && composed.panels.length === 1 && composed.theme.mode !== "paper";
|
|
264
|
+
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 ${attrs({ width: "100%", height: "100%", fill: composed.theme.field })}/>${header}${composed.panels.map((panel) => panelSvg(panel, composed.theme, composed.profile, namespace, panel.layout.headerText ?? (fixedResponsive ? fixedResponsiveHeader(panel) : null))).join("")}</svg>`;
|
|
221
265
|
}
|
|
222
266
|
|
|
223
267
|
export function sceneToSvg(scene, options = {}) {
|
package/src/terminal-scene.js
CHANGED
|
@@ -3,10 +3,11 @@ import { CORE_REGISTRY } from "./core-renderers.js";
|
|
|
3
3
|
import { panelContract } from "./figure.js";
|
|
4
4
|
import { resolvePanelDomains } from "./figure.js";
|
|
5
5
|
import { resolveApplicationProfile } from "./application-profiles.js";
|
|
6
|
-
import { legendWithStyles, resolveSeriesStyles } from "./series-style.js";
|
|
6
|
+
import { collectSeriesKeys, legendWithStyles, resolveSeriesStyles } from "./series-style.js";
|
|
7
7
|
import { compileMotionPlan, assertTerminalMotionIdentity } from "./motion-plan.js";
|
|
8
8
|
import { auditPaperTheme, themeResolutionForProfile } from "./paper-profile.js";
|
|
9
9
|
import { validateEvidenceCoverage } from "./evidence-coverage.js";
|
|
10
|
+
import { linearFit } from "./statistics.js";
|
|
10
11
|
|
|
11
12
|
export const TERMINAL_SCENE_VERSION = "figurestead.scene/1";
|
|
12
13
|
|
|
@@ -42,9 +43,7 @@ function scatterMarks(panel, contract, prepared, styles) {
|
|
|
42
43
|
x: point.x, y: point.y, style: styles[point.series],
|
|
43
44
|
}));
|
|
44
45
|
if (contract.data.summary === "linear_fit") {
|
|
45
|
-
const
|
|
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;
|
|
46
|
+
const { slope, intercept } = linearFit(prepared.points.map((point) => point.x), prepared.points.map((point) => point.y));
|
|
48
47
|
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
48
|
}
|
|
50
49
|
return marks;
|
|
@@ -118,6 +117,9 @@ export function compileFigureModel(input, options = {}) {
|
|
|
118
117
|
...panel,
|
|
119
118
|
presentation: { ...profilePresentation(applicationProfile), ...(panel.presentation ?? {}) },
|
|
120
119
|
}));
|
|
120
|
+
if (contract.style.directLabels && contract.panels.some(p => p.presentation.legend === "none")) throw new TypeError("directLabels requires the ordinary legend for fallback");
|
|
121
|
+
const directRanks = new Map(options.directRanks ?? []);
|
|
122
|
+
if (contract.style.directLabels) for (const key of collectSeriesKeys(contract)) if (!directRanks.has(key)) directRanks.set(key, directRanks.size);
|
|
121
123
|
const styles = resolveSeriesStyles(contract);
|
|
122
124
|
contract.seriesStyles = styles;
|
|
123
125
|
contract.appearanceReport = applicationProfile.key === "paper" ? {
|
|
@@ -150,10 +152,15 @@ export function compileFigureModel(input, options = {}) {
|
|
|
150
152
|
notes: [child.spec.note, ...(child.annotations ?? []).filter((item) => item?.type === "scientific_note").map((item) => item.text)].filter(Boolean),
|
|
151
153
|
legend: compiled.legend ?? legendWithStyles(legend, keys, styles),
|
|
152
154
|
meta: compiled.meta ?? null,
|
|
155
|
+
...(contract.style.directLabels ? { directLabelsInput: { data: panel.renderer === "line" ? { ...child.data, series: child.data.series.map((s,i) => {
|
|
156
|
+
const authored = (input.panels?.[panelIndex]?.data ?? input.data)?.series?.[i];
|
|
157
|
+
return { ...s, label: authored && Object.hasOwn(authored,"label") ? authored.label : s.label };
|
|
158
|
+
}) } : child.data, ranks: Object.fromEntries(directRanks), pose: !!panel.presentation.curve && panel.presentation.curve !== "linear" || !!panel.presentation.seriesMarkers } } : {}),
|
|
153
159
|
marks: defaultMarks,
|
|
154
160
|
};
|
|
155
161
|
});
|
|
156
162
|
const scene = {
|
|
163
|
+
...(contract.style.directLabels ? { directLabels: true, directRanks: Object.fromEntries(directRanks) } : {}),
|
|
157
164
|
schemaVersion: TERMINAL_SCENE_VERSION,
|
|
158
165
|
contractSchemaVersion: contract.schemaVersion,
|
|
159
166
|
rendererApiVersion: contract.rendererApiVersion,
|
package/src/theme-pack.js
CHANGED
|
@@ -251,6 +251,7 @@ export function contrastRatio(left, right) {
|
|
|
251
251
|
return colorContrast(left, right);
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/** Authored palette inspection; does not model renderer compositing. */
|
|
254
255
|
export function contrastAudit(theme) {
|
|
255
256
|
if (theme.mode === "paper") return auditPaperTheme(theme).findings;
|
|
256
257
|
const findings = [];
|
|
@@ -266,3 +267,33 @@ export function contrastAudit(theme) {
|
|
|
266
267
|
});
|
|
267
268
|
return findings;
|
|
268
269
|
}
|
|
270
|
+
|
|
271
|
+
/** Supplied theme series colors in a caller-supplied single-layer context.
|
|
272
|
+
* Theme-level seriesEdges reject; contract/style overrides are not inspected.
|
|
273
|
+
* Callers must account for effective mark colors/layers before claiming rendered
|
|
274
|
+
* contrast. Verified line segments do not include companion markers/points.
|
|
275
|
+
* No defaults: encoded-sRGB blend, then luminance; passes = ratio >= 3 exactly.
|
|
276
|
+
* Final ULPs may differ across runtimes; this is not physical certification.
|
|
277
|
+
*/
|
|
278
|
+
export function renderedSeriesAudit(theme, context) {
|
|
279
|
+
object(context, "renderContext");
|
|
280
|
+
const fields = ["substrate", "opacity", "compositing"];
|
|
281
|
+
if (Object.keys(context).length !== fields.length || fields.some(key => !hasOwn(context, key))) {
|
|
282
|
+
throw new FiguresteadConfigError("requires exactly substrate, opacity, compositing", "renderContext");
|
|
283
|
+
}
|
|
284
|
+
const { substrate, opacity, compositing } = context;
|
|
285
|
+
if (typeof substrate !== "string" || substrate.length !== 7 || !COLOR.test(substrate)) throw new FiguresteadConfigError("must be an opaque #RRGGBB color", "renderContext.substrate");
|
|
286
|
+
if (typeof opacity !== "number" || !Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new FiguresteadConfigError("must be a finite number in [0, 1]", "renderContext.opacity");
|
|
287
|
+
if (compositing !== "srgb-source-over") throw new FiguresteadConfigError("must be srgb-source-over", "renderContext.compositing");
|
|
288
|
+
if (theme.seriesEdges?.length) throw new FiguresteadConfigError("layered series edges are not supported by this single-layer audit", "theme.seriesEdges");
|
|
289
|
+
if (!Array.isArray(theme.series) || !theme.series.length || theme.series.some(c => typeof c !== "string" || c.length !== 7 || !COLOR.test(c))) throw new FiguresteadConfigError("must contain opaque #RRGGBB colors", "theme.series");
|
|
290
|
+
const background = hexChannels(substrate).map(c => c / 255);
|
|
291
|
+
const luminance = channels => channels.map(c => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
|
|
292
|
+
.reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
|
|
293
|
+
const back = luminance(background);
|
|
294
|
+
return theme.series.map((color, index) => {
|
|
295
|
+
const effectiveColor = hexChannels(color).map((c, i) => opacity * c / 255 + (1 - opacity) * background[i]);
|
|
296
|
+
const front = luminance(effectiveColor), ratio = (Math.max(front, back) + 0.05) / (Math.min(front, back) + 0.05);
|
|
297
|
+
return { token: `series[${index}]`, color, substrate, opacity, compositing, effectiveColor, ratio, minimum: 3, passes: ratio >= 3 };
|
|
298
|
+
});
|
|
299
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"drafts": {},
|
|
3
|
+
"name": "Deep Observatory / Sage Core",
|
|
4
|
+
"schemaVersion": "figurestead.theme-pack/1",
|
|
5
|
+
"themes": {
|
|
6
|
+
"deep_observatory_sage_core": {
|
|
7
|
+
"faint": "#71817C",
|
|
8
|
+
"field": "#10171D",
|
|
9
|
+
"grid": "#29393A",
|
|
10
|
+
"key": "deep_observatory_sage_core",
|
|
11
|
+
"label": "#E3ECE8",
|
|
12
|
+
"name": "Deep Observatory / Sage Core",
|
|
13
|
+
"panel": "#19252A",
|
|
14
|
+
"primary": "#5A9FA4",
|
|
15
|
+
"secondary": "#A8B5AE",
|
|
16
|
+
"series": [
|
|
17
|
+
"#5A9FA4",
|
|
18
|
+
"#C59C3E",
|
|
19
|
+
"#B44D4F",
|
|
20
|
+
"#8A76C4",
|
|
21
|
+
"#7BB888"
|
|
22
|
+
],
|
|
23
|
+
"spine": "#748780",
|
|
24
|
+
"summaryCore": "#C1C7BC",
|
|
25
|
+
"warm": "#D45A34"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"drafts": {},
|
|
3
|
+
"name": "Lavender Fog Notebook",
|
|
4
|
+
"schemaVersion": "figurestead.theme-pack/1",
|
|
5
|
+
"themes": {
|
|
6
|
+
"lavender_fog_notebook": {
|
|
7
|
+
"faint": "#7B718B",
|
|
8
|
+
"field": "#F4F1F8",
|
|
9
|
+
"grid": "#DDD7E7",
|
|
10
|
+
"key": "lavender_fog_notebook",
|
|
11
|
+
"label": "#201B2B",
|
|
12
|
+
"name": "Lavender Fog Notebook",
|
|
13
|
+
"panel": "#FCFAFF",
|
|
14
|
+
"primary": "#6855A8",
|
|
15
|
+
"secondary": "#51495F",
|
|
16
|
+
"series": [
|
|
17
|
+
"#6855A8",
|
|
18
|
+
"#18776D",
|
|
19
|
+
"#9B5B16",
|
|
20
|
+
"#A44E5E",
|
|
21
|
+
"#326D9B",
|
|
22
|
+
"#52752C"
|
|
23
|
+
],
|
|
24
|
+
"spine": "#9087A1",
|
|
25
|
+
"summaryCore": "#18776D",
|
|
26
|
+
"warm": "#A44E5E"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"drafts": {},
|
|
3
|
+
"name": "Midnight Transit / Signal Slate",
|
|
4
|
+
"schemaVersion": "figurestead.theme-pack/1",
|
|
5
|
+
"themes": {
|
|
6
|
+
"midnight_transit_signal_slate": {
|
|
7
|
+
"faint": "#637980",
|
|
8
|
+
"field": "#0A1522",
|
|
9
|
+
"grid": "#263D4E",
|
|
10
|
+
"key": "midnight_transit_signal_slate",
|
|
11
|
+
"label": "#DDE7E5",
|
|
12
|
+
"name": "Midnight Transit / Signal Slate",
|
|
13
|
+
"panel": "#13243A",
|
|
14
|
+
"primary": "#5EA5C8",
|
|
15
|
+
"secondary": "#9EAFB3",
|
|
16
|
+
"series": [
|
|
17
|
+
"#5EA5C8",
|
|
18
|
+
"#8BAF81",
|
|
19
|
+
"#B6696B",
|
|
20
|
+
"#8B69A3",
|
|
21
|
+
"#CDAD33"
|
|
22
|
+
],
|
|
23
|
+
"spine": "#708A93",
|
|
24
|
+
"summaryCore": "#C3C8BC",
|
|
25
|
+
"warm": "#E06018"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"drafts": {},
|
|
3
|
+
"name": "Registration Ink",
|
|
4
|
+
"schemaVersion": "figurestead.theme-pack/1",
|
|
5
|
+
"themes": {
|
|
6
|
+
"registration_ink": {
|
|
7
|
+
"faint": "#94877B",
|
|
8
|
+
"field": "#E7DFD2",
|
|
9
|
+
"grid": "#D7CCC0",
|
|
10
|
+
"key": "registration_ink",
|
|
11
|
+
"label": "#271E1B",
|
|
12
|
+
"name": "Registration Ink",
|
|
13
|
+
"panel": "#F5EFE4",
|
|
14
|
+
"primary": "#9C3038",
|
|
15
|
+
"secondary": "#62564F",
|
|
16
|
+
"series": [
|
|
17
|
+
"#9C3038",
|
|
18
|
+
"#1C6673",
|
|
19
|
+
"#4E3E78",
|
|
20
|
+
"#B16D28",
|
|
21
|
+
"#6C2948"
|
|
22
|
+
],
|
|
23
|
+
"spine": "#806F64",
|
|
24
|
+
"summaryCore": "#241B1C",
|
|
25
|
+
"warm": "#A83A9A"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"drafts": {},
|
|
3
|
+
"name": "Slipware",
|
|
4
|
+
"schemaVersion": "figurestead.theme-pack/1",
|
|
5
|
+
"themes": {
|
|
6
|
+
"slipware": {
|
|
7
|
+
"faint": "#9C8F84",
|
|
8
|
+
"field": "#E0D3C4",
|
|
9
|
+
"grid": "#DCD1C2",
|
|
10
|
+
"key": "slipware",
|
|
11
|
+
"label": "#2B2320",
|
|
12
|
+
"name": "Slipware",
|
|
13
|
+
"panel": "#FAF5EE",
|
|
14
|
+
"primary": "#1B4C8A",
|
|
15
|
+
"secondary": "#6B5D53",
|
|
16
|
+
"series": [
|
|
17
|
+
"#1B4C8A",
|
|
18
|
+
"#143F33",
|
|
19
|
+
"#0E766E",
|
|
20
|
+
"#8B7A16",
|
|
21
|
+
"#3C97AC"
|
|
22
|
+
],
|
|
23
|
+
"spine": "#6E6055",
|
|
24
|
+
"summaryCore": "#0E0A08",
|
|
25
|
+
"warm": "#B4552A"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"drafts": {},
|
|
3
|
+
"name": "Ultraviolet Laboratory",
|
|
4
|
+
"schemaVersion": "figurestead.theme-pack/1",
|
|
5
|
+
"themes": {
|
|
6
|
+
"ultraviolet_laboratory": {
|
|
7
|
+
"faint": "#8D83B2",
|
|
8
|
+
"field": "#0D0B18",
|
|
9
|
+
"grid": "#302A4E",
|
|
10
|
+
"key": "ultraviolet_laboratory",
|
|
11
|
+
"label": "#F6F2FF",
|
|
12
|
+
"name": "Ultraviolet Laboratory",
|
|
13
|
+
"panel": "#171329",
|
|
14
|
+
"primary": "#B59BFF",
|
|
15
|
+
"secondary": "#C9C0E5",
|
|
16
|
+
"series": [
|
|
17
|
+
"#B59BFF",
|
|
18
|
+
"#67D7C4",
|
|
19
|
+
"#F2A65A",
|
|
20
|
+
"#E57FA6",
|
|
21
|
+
"#8DB7FF",
|
|
22
|
+
"#BBD66B"
|
|
23
|
+
],
|
|
24
|
+
"spine": "#4D4672",
|
|
25
|
+
"summaryCore": "#67D7C4",
|
|
26
|
+
"warm": "#F2A65A"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FiguresteadContract,
|
|
3
|
+
FiguresteadPanelBase,
|
|
4
|
+
RendererDefinition,
|
|
5
|
+
UnknownRecord,
|
|
6
|
+
} from "@figurestead/web";
|
|
7
|
+
|
|
8
|
+
export type FiguresteadDate = string;
|
|
9
|
+
|
|
10
|
+
export interface TemporalCoverageData {
|
|
11
|
+
dates: FiguresteadDate[];
|
|
12
|
+
sites: string[];
|
|
13
|
+
siteOrder: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TemporalReferenceBand {
|
|
17
|
+
type: "reference_band";
|
|
18
|
+
from: number;
|
|
19
|
+
to: number;
|
|
20
|
+
label: string;
|
|
21
|
+
status: "provisional_project_constant";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TemporalObservationData {
|
|
25
|
+
dates: FiguresteadDate[];
|
|
26
|
+
values: number[];
|
|
27
|
+
site: string;
|
|
28
|
+
referenceBands?: TemporalReferenceBand[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type TemporalCoveragePanel = FiguresteadPanelBase<"temporal_coverage", TemporalCoverageData>;
|
|
32
|
+
export type TemporalObservationsPanel = FiguresteadPanelBase<"temporal_observations", TemporalObservationData>;
|
|
33
|
+
export type TemporalPanel = TemporalCoveragePanel | TemporalObservationsPanel;
|
|
34
|
+
export type TemporalFiguresteadContract = FiguresteadContract<TemporalPanel>;
|
|
35
|
+
|
|
36
|
+
export const TEMPORAL_COVERAGE_RENDERER: Readonly<RendererDefinition<TemporalCoverageData>>;
|
|
37
|
+
export const TEMPORAL_OBSERVATIONS_RENDERER: Readonly<RendererDefinition<TemporalObservationData>>;
|
|
38
|
+
export const TEMPORAL_RENDERERS: readonly [typeof TEMPORAL_COVERAGE_RENDERER, typeof TEMPORAL_OBSERVATIONS_RENDERER];
|
|
39
|
+
export const PROVISIONAL_LABEL: "Provisional project constant; not a regulatory threshold";
|
|
40
|
+
export const PROVISIONAL_STATUS: "provisional_project_constant";
|
|
41
|
+
|
|
42
|
+
export function validateCoverageData(data: unknown, path?: string): TemporalCoverageData;
|
|
43
|
+
export function validateObservationData(data: unknown, path?: string): TemporalObservationData;
|
|
44
|
+
export function compileCoverageScene(context: UnknownRecord): UnknownRecord;
|
|
45
|
+
export function compileObservationsScene(context: UnknownRecord): UnknownRecord;
|