@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/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 esc = (value) => String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" }[char]));
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" }[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) 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>`);
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
- 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] })}/>`;
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 panelSvg(panel, theme, profile, namespace) {
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) => mark.kind === "point" ? marker(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 = `<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>`;
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
- 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>`;
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 = {}) {
@@ -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 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;
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;