@figurestead/web 0.9.0-alpha.1 → 0.9.0-alpha.2

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/schema.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { linearFit } from "./statistics.js";
2
+
1
3
  export const SCHEMA_VERSION = "0.4";
2
4
  export const LEGACY_SCHEMA_VERSION = "0.3";
3
5
  export const RENDERER_API_VERSION = "1";
@@ -29,6 +31,14 @@ export function requiredString(value, path) {
29
31
  }
30
32
  }
31
33
 
34
+ const CANONICAL_COLOR = /^#[0-9a-fA-F]{6}$/;
35
+
36
+ export function requiredColor(value, path) {
37
+ if (typeof value !== "string" || !CANONICAL_COLOR.test(value)) {
38
+ throw new FiguresteadConfigError("must be a canonical #RRGGBB color", path);
39
+ }
40
+ }
41
+
32
42
  export function numberArray(value, path, { allowEmpty = false } = {}) {
33
43
  if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) {
34
44
  throw new FiguresteadConfigError("must be a non-empty numeric array", path);
@@ -62,21 +72,27 @@ function timelineWindow(value, path) {
62
72
  }
63
73
  }
64
74
 
65
- function validateTheme(theme) {
66
- requiredObject(theme, "config.theme");
67
- ["key", "name", "field", "panel", "grid", "spine", "label", "secondary", "faint", "primary", "summaryCore", "warm"]
68
- .forEach((key) => requiredString(theme[key], `config.theme.${key}`));
75
+ export function validateThemeColors(theme, path = "config.theme") {
76
+ requiredObject(theme, path);
77
+ ["field", "panel", "grid", "spine", "label", "secondary", "faint", "primary", "summaryCore", "warm"]
78
+ .forEach((key) => requiredColor(theme[key], `${path}.${key}`));
69
79
  if (!Array.isArray(theme.series) || !theme.series.length) {
70
- throw new FiguresteadConfigError("must be a non-empty color array", "config.theme.series");
80
+ throw new FiguresteadConfigError("must be a non-empty color array", `${path}.series`);
71
81
  }
72
- theme.series.forEach((color, index) => requiredString(color, `config.theme.series[${index}]`));
73
- for (const key of ["primaryEdge", "summaryEdge"]) if (theme[key] != null) requiredString(theme[key], `config.theme.${key}`);
82
+ theme.series.forEach((color, index) => requiredColor(color, `${path}.series[${index}]`));
83
+ for (const key of ["primaryEdge", "summaryEdge"]) if (theme[key] != null) requiredColor(theme[key], `${path}.${key}`);
74
84
  if (theme.seriesEdges != null) {
75
- if (!Array.isArray(theme.seriesEdges) || theme.seriesEdges.length !== theme.series.length) throw new FiguresteadConfigError(`must contain exactly ${theme.series.length} colors`, "config.theme.seriesEdges");
76
- theme.seriesEdges.forEach((color, index) => requiredString(color, `config.theme.seriesEdges[${index}]`));
85
+ if (!Array.isArray(theme.seriesEdges) || theme.seriesEdges.length !== theme.series.length) throw new FiguresteadConfigError(`must contain exactly ${theme.series.length} colors`, `${path}.seriesEdges`);
86
+ theme.seriesEdges.forEach((color, index) => requiredColor(color, `${path}.seriesEdges[${index}]`));
77
87
  }
78
88
  }
79
89
 
90
+ function validateTheme(theme) {
91
+ requiredObject(theme, "config.theme");
92
+ ["key", "name"].forEach((key) => requiredString(theme[key], `config.theme.${key}`));
93
+ validateThemeColors(theme);
94
+ }
95
+
80
96
  function validateProfile(profile) {
81
97
  requiredObject(profile, "config.profile");
82
98
  ["key", "name", "marker"].forEach((key) => requiredString(profile[key], `config.profile.${key}`));
@@ -170,6 +186,13 @@ export function normalizeScatterData(data, basePath = "config.data") {
170
186
  if (summary !== null && summary !== "linear_fit") {
171
187
  throw new FiguresteadConfigError("must be null or 'linear_fit'", `${basePath}.summary`);
172
188
  }
189
+ if (summary === "linear_fit") {
190
+ try {
191
+ linearFit(x, y);
192
+ } catch (error) {
193
+ throw new FiguresteadConfigError(error.message, `${basePath}.summary`);
194
+ }
195
+ }
173
196
  return {
174
197
  x, y, series: series.map(String), seriesLabels: normalizeSeriesLabels(keys, data.seriesLabels), summary,
175
198
  xDomain: domain(data.xDomain, `${basePath}.xDomain`), yDomain: domain(data.yDomain, `${basePath}.yDomain`),
@@ -0,0 +1,148 @@
1
+ const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value));
2
+
3
+ const DEFAULT_FONT_WIDTH = 0.602;
4
+
5
+ function fallbackMetric(text, fontSize) {
6
+ return {
7
+ width: String(text).length * fontSize * DEFAULT_FONT_WIDTH,
8
+ ascent: fontSize * 0.78,
9
+ descent: fontSize * 0.22,
10
+ };
11
+ }
12
+
13
+ function normalizedMetric(measureText, text, fontSize) {
14
+ const measured = measureText?.(String(text), fontSize) ?? fallbackMetric(text, fontSize);
15
+ const ascent = measured.ascent ?? measured.actualBoundingBoxAscent ?? fontSize * 0.78;
16
+ const descent = measured.descent ?? measured.actualBoundingBoxDescent ?? fontSize * 0.22;
17
+ return { width: measured.width, ascent, descent, height: ascent + descent };
18
+ }
19
+
20
+ function annotationGaps(scale) {
21
+ return {
22
+ outer: clamp(7 * scale, 4, 10),
23
+ plotTick: clamp(7 * scale, 4, 9),
24
+ tickTitle: clamp(7 * scale, 4, 9),
25
+ titleFooter: clamp(8 * scale, 5, 10),
26
+ yTitleTick: clamp(8 * scale, 5, 10),
27
+ };
28
+ }
29
+
30
+ function xTickDepth(axes, plot, metric) {
31
+ const slot = axes.x.step?.() ?? Math.max(40, (plot.right - plot.left) / Math.max(1, axes.xTicks.length));
32
+ const metrics = axes.xTicks.map((tick) => metric(tick.label));
33
+ const rotate = axes.xType === "band" && metrics.some((value) => value.width > slot * 0.92);
34
+ const depth = metrics.length
35
+ ? Math.max(...metrics.map((value) => rotate ? (value.width + value.height) / Math.sqrt(2) : value.height))
36
+ : 0;
37
+ return { rotate, depth, metrics };
38
+ }
39
+
40
+ /**
41
+ * Internal annotation layout pass. It deliberately consumes resolved tick strings
42
+ * rather than guessing from canvas size. No contract or renderer API is exposed.
43
+ */
44
+ export function refineScientificLayout(source, panel, axes, options = {}) {
45
+ const layout = {
46
+ ...source,
47
+ rect: { ...source.rect }, plot: { ...source.plot }, font: { ...source.font },
48
+ text: { ...(source.text ?? {}) }, provenance: source.provenance ? { ...source.provenance } : null,
49
+ legend: source.legend ? { ...source.legend } : null,
50
+ };
51
+ const { rect, font, scale } = layout, gaps = annotationGaps(scale);
52
+ const axisMetric = (text) => normalizedMetric(options.measureText, text, font.axis);
53
+ const signatureMetric = (text) => normalizedMetric(options.measureText, text, font.signature);
54
+ const xTitleMetric = panel.spec.xLabel ? axisMetric(panel.spec.xLabel) : null;
55
+ const yTitleMetric = panel.spec.yLabel ? axisMetric(panel.spec.yLabel) : null;
56
+ const footerMetric = panel.spec.signature ? signatureMetric(panel.spec.signature) : null;
57
+ const hasLocalFooter = Boolean(
58
+ footerMetric && options.themeMode !== "paper" && (layout.panelIndex ?? 0) === 0
59
+ && (!layout.provenance || layout.provenance.y <= rect.bottom)
60
+ );
61
+
62
+ const provisionalPlot = { ...layout.plot };
63
+ const xTick = xTickDepth(axes, provisionalPlot, axisMetric);
64
+ const yTickMetrics = axes.yTicks.map((tick) => axisMetric(tick.label));
65
+ const yTickWidth = yTickMetrics.length ? Math.max(...yTickMetrics.map((value) => value.width)) : 0;
66
+
67
+ let cursorBottom = rect.bottom - gaps.outer;
68
+ let provenance = null;
69
+ if (hasLocalFooter) {
70
+ const baseline = cursorBottom - footerMetric.descent;
71
+ provenance = {
72
+ left: 0, right: rect.right - gaps.outer, y: baseline,
73
+ bounds: { left: 0, right: 0, top: baseline - footerMetric.ascent, bottom: baseline + footerMetric.descent },
74
+ };
75
+ cursorBottom = provenance.bounds.top - gaps.titleFooter;
76
+ }
77
+ let xTitle = null;
78
+ if (xTitleMetric) {
79
+ xTitle = { left: 0, right: 0, top: cursorBottom - xTitleMetric.height, bottom: cursorBottom };
80
+ cursorBottom = xTitle.top - gaps.tickTitle;
81
+ }
82
+ const xTicks = {
83
+ left: 0, right: 0,
84
+ top: cursorBottom - xTick.depth,
85
+ bottom: cursorBottom,
86
+ };
87
+ const requestedPlotBottom = xTicks.top - gaps.plotTick;
88
+ const availablePlotHeight = Math.max(0, requestedPlotBottom - layout.plot.top);
89
+ const minimumPlotHeight = Math.min(120, availablePlotHeight);
90
+ layout.plot.bottom = Math.max(layout.plot.top + minimumPlotHeight, requestedPlotBottom);
91
+
92
+ const yTitleThickness = yTitleMetric?.height ?? 0;
93
+ const requestedPlotLeft = rect.left + gaps.outer + yTitleThickness
94
+ + (yTitleMetric && yTickWidth ? gaps.yTitleTick : 0) + yTickWidth + gaps.plotTick;
95
+ layout.plot.left = Math.min(layout.plot.right - 160, requestedPlotLeft);
96
+
97
+ const xCenter = (layout.plot.left + layout.plot.right) / 2;
98
+ xTicks.left = layout.plot.left; xTicks.right = layout.plot.right;
99
+ if (xTitle) {
100
+ xTitle.left = xCenter - xTitleMetric.width / 2;
101
+ xTitle.right = xCenter + xTitleMetric.width / 2;
102
+ layout.text.xLabelY = xTitle.bottom;
103
+ layout.text.xLabelBaselineY = xTitle.top + xTitleMetric.ascent;
104
+ }
105
+ layout.text.xTickY = xTicks.top;
106
+ layout.text.xTickBaselineY = xTicks.top + (xTick.metrics[0]?.ascent ?? font.axis * 0.78);
107
+ layout.text.rotateX = xTick.rotate;
108
+
109
+ const yTicks = {
110
+ left: layout.plot.left - gaps.plotTick - yTickWidth,
111
+ right: layout.plot.left - gaps.plotTick,
112
+ top: layout.plot.top, bottom: layout.plot.bottom,
113
+ };
114
+ let yTitle = null;
115
+ if (yTitleMetric) {
116
+ const right = yTicks.left - (yTickWidth ? gaps.yTitleTick : 0);
117
+ yTitle = { left: right - yTitleMetric.height, right, top: layout.plot.top, bottom: layout.plot.bottom };
118
+ layout.text.yLabelX = yTitle.left;
119
+ }
120
+
121
+ if (provenance) {
122
+ provenance.left = layout.plot.left;
123
+ provenance.bounds.left = provenance.left;
124
+ provenance.bounds.right = provenance.left + footerMetric.width;
125
+ layout.provenance = { left: provenance.left, right: provenance.right, y: provenance.y };
126
+ } else if (layout.provenance?.y <= rect.bottom) {
127
+ layout.provenance = null;
128
+ }
129
+ if (layout.legend) {
130
+ layout.legend = layout.legend.outside
131
+ ? { ...layout.legend, top: layout.plot.top, bottom: layout.plot.bottom }
132
+ : { ...layout.legend, left: layout.plot.left, right: layout.plot.right, top: layout.plot.top, bottom: layout.plot.bottom };
133
+ }
134
+
135
+ layout.annotationBounds = {
136
+ plot: { ...layout.plot }, xTicks, xTitle, provenance: provenance?.bounds ?? null, yTicks, yTitle,
137
+ gaps: {
138
+ plotToXTicks: xTicks.top - layout.plot.bottom,
139
+ xTicksToTitle: xTitle ? xTitle.top - xTicks.bottom : null,
140
+ xTitleToProvenance: xTitle && provenance ? provenance.bounds.top - xTitle.bottom : null,
141
+ xTicksToProvenance: !xTitle && provenance ? provenance.bounds.top - xTicks.bottom : null,
142
+ yTitleToTicks: yTitle ? yTicks.left - yTitle.right : null,
143
+ yTicksToPlot: layout.plot.left - yTicks.right,
144
+ },
145
+ rotateX: xTick.rotate,
146
+ };
147
+ return layout;
148
+ }
@@ -0,0 +1,30 @@
1
+ import { resolveContrastColor } from "./color-space.js";
2
+
3
+ export const SCREEN_LEGIBILITY_VERSION = "figurestead.screen-legibility/1";
4
+
5
+ // Figurestead project floors for browser-rendered evidence. These are not
6
+ // assertions of conformance with an external accessibility standard.
7
+ export const SCREEN_PROJECT_LEGIBILITY_FLOORS = Object.freeze({
8
+ provenanceContrast: 3.4,
9
+ compactProvenancePx: 9,
10
+ });
11
+
12
+ export function resolveScreenTheme(source) {
13
+ const resolution = resolveContrastColor(
14
+ source.faint,
15
+ source.field,
16
+ SCREEN_PROJECT_LEGIBILITY_FLOORS.provenanceContrast,
17
+ );
18
+ const theme = { ...source, faint: resolution.color };
19
+ return Object.freeze({
20
+ theme: Object.freeze(theme),
21
+ report: Object.freeze({
22
+ schemaVersion: SCREEN_LEGIBILITY_VERSION,
23
+ policy: "Figurestead screen/project legibility floor",
24
+ token: "faint",
25
+ surface: "field",
26
+ minimum: SCREEN_PROJECT_LEGIBILITY_FLOORS.provenanceContrast,
27
+ resolution: Object.freeze({ ...resolution }),
28
+ }),
29
+ });
30
+ }
@@ -0,0 +1,21 @@
1
+ export function linearFit(x, y) {
2
+ if (!Array.isArray(x) || !Array.isArray(y) || x.length !== y.length) {
3
+ throw new TypeError("linear_fit requires x and y arrays of equal length");
4
+ }
5
+ if (x.length < 2) throw new TypeError("linear_fit requires at least two finite observations");
6
+ if (!x.every(Number.isFinite) || !y.every(Number.isFinite)) {
7
+ throw new TypeError("linear_fit requires finite x and y values");
8
+ }
9
+ if (new Set(x).size < 2) throw new TypeError("linear_fit requires at least two distinct finite x values");
10
+
11
+ const meanX = x.reduce((sum, value) => sum + value, 0) / x.length;
12
+ const meanY = y.reduce((sum, value) => sum + value, 0) / y.length;
13
+ let numerator = 0;
14
+ let denominator = 0;
15
+ for (let index = 0; index < x.length; index += 1) {
16
+ const centeredX = x[index] - meanX;
17
+ numerator += centeredX * (y[index] - meanY);
18
+ denominator += centeredX * centeredX;
19
+ }
20
+ return { slope: numerator / denominator, intercept: meanY - (numerator / denominator) * meanX };
21
+ }
package/src/svg-export.js CHANGED
@@ -4,8 +4,11 @@ import { resolveTerminalScene } from "./resolved-scene.js";
4
4
  import { composeResolvedScene } from "./composition.js";
5
5
  import { partitionPanelMarks, plotClipRect } from "./render-layers.js";
6
6
  import { resolveExportSize } from "./physical-export.js";
7
+ import { validateThemeColors } from "./schema.js";
8
+ import { fixedResponsiveHeader, RESPONSIVE_HEADER_MAX_WIDTH } from "./responsive-header.js";
7
9
 
8
- const esc = (value) => String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" }[char]));
10
+ const xmlValue = (value) => String(value).replace(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu, "\uFFFD");
11
+ const esc = (value) => xmlValue(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" }[char]));
9
12
  const attrs = (value) => Object.entries(value).filter(([, item]) => item != null).map(([key, item]) => `${key}="${esc(item)}"`).join(" ");
10
13
  const FONT = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
11
14
 
@@ -145,11 +148,11 @@ function panelSurface(panel, theme) {
145
148
  function axes(panel, theme) {
146
149
  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
150
  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>`); });
151
+ 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));
152
+ 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
153
  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>`);
154
+ 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>`);
155
+ 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
156
  return pieces.join("");
154
157
  }
155
158
 
@@ -183,7 +186,17 @@ function matrixLegend(panel, theme, namespace) {
183
186
  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
187
  }
185
188
 
186
- function panelSvg(panel, theme, profile, namespace) {
189
+ function panelHeaderSvg(panel, theme, responsive) {
190
+ const titleFill = theme.mode === "paper" ? theme.label : theme.primary;
191
+ if (!responsive) {
192
+ 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>`;
193
+ }
194
+ 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>`;
195
+ 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>` : "";
196
+ return `<g ${attrs({ "data-responsive-header": responsive.policy })}>${title}${subtitle}</g>`;
197
+ }
198
+
199
+ function panelSvg(panel, theme, profile, namespace, responsive = null) {
187
200
  if (!panel.resolved) throw new TypeError(`SVG export requires a scene-aware renderer; ${panel.renderer} remains on the compatibility path`);
188
201
  const render = (mark) => mark.kind === "point" ? marker(mark)
189
202
  : ["segment", "summary-line"].includes(mark.kind) ? segment(mark)
@@ -204,7 +217,7 @@ function panelSvg(panel, theme, profile, namespace) {
204
217
  : mark.kind === "baseline-rule" ? baselineLabel(mark, panel)
205
218
  : mark.kind === "temporal-bar" ? temporalBarLabel(mark, panel, theme) : "").join("");
206
219
  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>`;
220
+ const title = panelHeaderSvg(panel, theme, responsive);
208
221
  const provenance = theme.mode !== "paper" && panel.spec.signature && (panel.layout.panelIndex ?? 0) === 0
209
222
  ? `<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
223
  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>`;
@@ -212,12 +225,14 @@ function panelSvg(panel, theme, profile, namespace) {
212
225
 
213
226
  export function resolvedSceneToSvg(resolved, options = {}) {
214
227
  const composed = resolved.schemaVersion === "figurestead.composed-scene/1" ? resolved : composeResolvedScene(resolved);
228
+ validateThemeColors(composed.theme, "scene.theme");
215
229
  const scene = options.sourceScene, namespace = svgNamespace(composed, scene, options);
216
230
  const exportSize = options.exportSize ?? resolveExportSize({ ...options, width: composed.width, height: composed.height });
217
231
  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
232
  const titleId = `${namespace}-title`, descId = `${namespace}-desc`;
219
233
  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>`;
234
+ const fixedResponsive = composed.width <= RESPONSIVE_HEADER_MAX_WIDTH && composed.panels.length === 1 && composed.theme.mode !== "paper";
235
+ 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
236
  }
222
237
 
223
238
  export function sceneToSvg(scene, options = {}) {
@@ -7,6 +7,7 @@ import { 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;
@@ -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;