@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/README.md +179 -5
- package/THIRD_PARTY_NOTICES.md +12 -5
- package/package.json +42 -5
- package/src/accessibility.js +51 -11
- package/src/canvas-scene.js +12 -5
- package/src/clock.js +19 -8
- package/src/core-renderers.js +9 -3
- package/src/create-matrix-plot.js +86 -30
- 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/layout.js +4 -2
- 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 +42 -3
- package/src/responsive-header.js +159 -0
- package/src/schema.js +32 -9
- 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 +23 -8
- package/src/terminal-scene.js +2 -3
- 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 +640 -0
- package/types/theme-json.d.ts +4 -0
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
|
|
66
|
-
requiredObject(theme,
|
|
67
|
-
["
|
|
68
|
-
.forEach((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",
|
|
80
|
+
throw new FiguresteadConfigError("must be a non-empty color array", `${path}.series`);
|
|
71
81
|
}
|
|
72
|
-
theme.series.forEach((color, index) =>
|
|
73
|
-
for (const key of ["primaryEdge", "summaryEdge"]) if (theme[key] != null)
|
|
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`,
|
|
76
|
-
theme.seriesEdges.forEach((color, 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
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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)
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 = {}) {
|
package/src/terminal-scene.js
CHANGED
|
@@ -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
|
|
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;
|