@figurestead/web 0.9.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/THIRD_PARTY_NOTICES.md +39 -0
  4. package/TRADEMARKS.md +13 -0
  5. package/package.json +31 -0
  6. package/src/accessibility.js +33 -0
  7. package/src/appearance.js +25 -0
  8. package/src/application-profiles.js +76 -0
  9. package/src/atmosphere.js +71 -0
  10. package/src/canvas-scene.js +323 -0
  11. package/src/clock.js +27 -0
  12. package/src/color-space.js +114 -0
  13. package/src/composition.js +201 -0
  14. package/src/core-renderers.js +33 -0
  15. package/src/create-matrix-plot.js +97 -0
  16. package/src/evidence-coverage.js +80 -0
  17. package/src/export-bundle.js +83 -0
  18. package/src/extensions/temporal/coverage.js +96 -0
  19. package/src/extensions/temporal/index.js +43 -0
  20. package/src/extensions/temporal/observations.js +87 -0
  21. package/src/extensions/temporal/shared.js +172 -0
  22. package/src/figure-layout.js +70 -0
  23. package/src/figure.js +42 -0
  24. package/src/index.js +30 -0
  25. package/src/layout.js +41 -0
  26. package/src/marks.js +218 -0
  27. package/src/motion-plan.js +66 -0
  28. package/src/motion-recipes.js +39 -0
  29. package/src/paper-profile.js +78 -0
  30. package/src/physical-export.js +34 -0
  31. package/src/presentation.js +127 -0
  32. package/src/primitives.js +24 -0
  33. package/src/random.js +26 -0
  34. package/src/registry.js +29 -0
  35. package/src/render-layers.js +35 -0
  36. package/src/renderer-test-kit.js +29 -0
  37. package/src/renderers/line.js +133 -0
  38. package/src/renderers/scatter.js +24 -0
  39. package/src/renderers/shared.js +21 -0
  40. package/src/renderers/strip-summary.js +40 -0
  41. package/src/resolved-scene.js +249 -0
  42. package/src/scales.js +68 -0
  43. package/src/schema.js +356 -0
  44. package/src/series-style.js +59 -0
  45. package/src/svg-export.js +232 -0
  46. package/src/terminal-scene.js +215 -0
  47. package/src/theme-catalog.js +34 -0
  48. package/src/theme-pack.js +268 -0
@@ -0,0 +1,87 @@
1
+ import { arrival } from "../../renderers/shared.js";
2
+ import { drawScopePoint, drawText, pointMotionState } from "../../marks.js";
3
+ import { drawBand, drawRule } from "../../primitives.js";
4
+ import { contractReferenceBands, drawTemporalAxes, makeTemporalScales, postMarksProgress, temporalXDomain, temporalYDomain } from "./shared.js";
5
+
6
+ export function prepareObservations(contract) {
7
+ const bands = contractReferenceBands(contract);
8
+ const points = contract.data.dates.map((date, index) => ({
9
+ date, x: Date.parse(`${date}T00:00:00Z`), y: contract.data.values[index], site: contract.data.site, colorIndex: 0, index,
10
+ }));
11
+ const arrived = arrival(points, contract, "x");
12
+ return { points: arrived, bands, settledAt: Math.max(...arrived.map((point) => point.delay + point.duration)) };
13
+ }
14
+
15
+ export function compileObservationsScene({ panel, contract, prepared, styles, markId, panelIndex, figure }) {
16
+ const marks = prepared.bands.map((band, index) => ({
17
+ id: markId(panel, "reference-band", index), kind: "reference-band", role: "context",
18
+ from: band.from, to: band.to, label: band.label, status: band.status,
19
+ style: {
20
+ colorIndex: (index + 1) % contract.theme.series.length,
21
+ color: contract.theme.series[(index + 1) % contract.theme.series.length],
22
+ edge: contract.theme.seriesEdges?.[(index + 1) % (contract.theme.seriesEdges?.length || 1)] ?? null,
23
+ lineStyle: "solid", lineWidth: 1,
24
+ },
25
+ }));
26
+ const pointStyle = styles.series ?? {
27
+ key: "series", colorIndex: 0, color: contract.theme.series[0],
28
+ edge: contract.theme.seriesEdges?.[0] ?? null, glyph: "ring", lineStyle: "solid", lineWidth: 1.6,
29
+ };
30
+ prepared.points.forEach((point) => marks.push({
31
+ id: markId(panel, "point", point.date, point.index), kind: "point", series: contract.data.site,
32
+ x: point.x, y: point.y, date: point.date, style: pointStyle,
33
+ }));
34
+ return {
35
+ marks,
36
+ categories: { x: null, y: null },
37
+ legend: [],
38
+ meta: {
39
+ showBandKey: !figure.layout.sharedY || panelIndex === 0,
40
+ provisionalReferenceBands: prepared.bands.length > 0,
41
+ },
42
+ };
43
+ }
44
+
45
+ export function observationDomains(contract, prepared) {
46
+ return {
47
+ x: temporalXDomain(contract, prepared.points.map((point) => point.x)),
48
+ y: temporalYDomain(contract, prepared.points.map((point) => point.y), prepared.bands),
49
+ };
50
+ }
51
+
52
+ export function drawObservations(context, env) {
53
+ const { contract, prepared, layout, progress, settled } = env;
54
+ const scales = makeTemporalScales(env.domains.x, env.domains.y, layout.plot);
55
+ const cp = postMarksProgress(progress, prepared, contract.timeline);
56
+ const panelIndex = env.figure && env.panel ? env.figure.panels.findIndex((panel) => panel.id === env.panel.id) : 0;
57
+ const columns = Math.max(1, env.figure?.layout.columns ?? 1);
58
+ const bottomRowStart = env.figure ? Math.floor((env.figure.panels.length - 1) / columns) * columns : 0;
59
+ const showX = !env.figure?.layout.sharedX || panelIndex >= bottomRowStart;
60
+ const showBandLabels = !env.figure?.layout.sharedY || panelIndex === 0;
61
+ prepared.bands.forEach((band, index) => {
62
+ if (cp <= 0) return;
63
+ const top = scales.y(band.to), bottom = scales.y(band.from);
64
+ const color = contract.theme.series[(index + 1) % contract.theme.series.length];
65
+ drawBand(context, { left: layout.plot.left, right: layout.plot.right, top, bottom, color, alpha: (0.045 + index * 0.04) * cp });
66
+ if (index > 0) drawRule(context, { x1: layout.plot.left, y1: bottom, x2: layout.plot.right, y2: bottom, color, alpha: 0.48 * cp, width: Math.max(0.7, layout.scale) });
67
+ });
68
+ if (showBandLabels && prepared.bands.length && cp > 0) {
69
+ const available = layout.plot.right - layout.plot.left;
70
+ context.save(); context.globalAlpha = 0.86 * cp; context.font = `${Math.max(7, layout.font.legend * 0.86)}px ui-monospace, monospace`;
71
+ context.textAlign = "left"; context.textBaseline = "middle"; let x = layout.plot.left;
72
+ prepared.bands.forEach((band, index) => {
73
+ const color = contract.theme.series[(index + 1) % contract.theme.series.length], label = `${band.from}–${band.to}`, swatch = Math.max(10, 13 * layout.scale);
74
+ context.fillStyle = color; context.globalAlpha = (0.32 + index * 0.15) * cp; context.fillRect(x, layout.plot.top - 14 * layout.scale, swatch, 5 * layout.scale);
75
+ context.globalAlpha = 0.86 * cp; context.fillStyle = contract.theme.secondary; context.fillText(label, x + swatch + 4 * layout.scale, layout.plot.top - 11 * layout.scale);
76
+ x += swatch + context.measureText(label).width + 17 * layout.scale;
77
+ });
78
+ if (x + 115 * layout.scale < layout.plot.left + available) { context.fillStyle = contract.theme.faint; context.fillText("Reference bands · provisional", x, layout.plot.top - 11 * layout.scale); }
79
+ context.restore();
80
+ }
81
+ drawTemporalAxes(context, { contract, layout, scales, showX });
82
+ prepared.points.forEach((point) => drawScopePoint(context, pointMotionState(point, progress, scales, layout.plot), {
83
+ color: contract.theme.series[0], radius: Math.max(3.2, Math.sqrt(contract.profile.markerSize) * 0.58 * layout.scale), trailAlpha: contract.motion.trailAlpha, settled,
84
+ }));
85
+ drawText(context, { config: contract, layout, legend: [] });
86
+ return scales;
87
+ }
@@ -0,0 +1,172 @@
1
+ import { FiguresteadConfigError, requiredObject, requiredString, sameLength } from "../../schema.js";
2
+ import { extent, formatTick, formatTimeTick, linearScale, parseTime, ticks, timeScale, timeTicks } from "../../scales.js";
3
+ import { smooth } from "../../marks.js";
4
+
5
+ export const DAY = 24 * 60 * 60 * 1000;
6
+ export const PROVISIONAL_STATUS = "provisional_project_constant";
7
+ export const PROVISIONAL_LABEL = "Provisional project constant; not a regulatory threshold";
8
+
9
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
10
+ const FONT_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
11
+
12
+ export function normalizeDate(value, path) {
13
+ if (typeof value !== "string" || !DATE_ONLY.test(value)) {
14
+ throw new FiguresteadConfigError("must be a UTC date in YYYY-MM-DD form", path);
15
+ }
16
+ const milliseconds = Date.parse(`${value}T00:00:00Z`);
17
+ if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString().slice(0, 10) !== value) {
18
+ throw new FiguresteadConfigError("must be a valid calendar date", path);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ export function normalizeDates(value, path) {
24
+ if (!Array.isArray(value) || value.length === 0) {
25
+ throw new FiguresteadConfigError("must be a non-empty date array", path);
26
+ }
27
+ return value.map((item, index) => normalizeDate(item, `${path}[${index}]`));
28
+ }
29
+
30
+ export function normalizeSiteOrder(value, path) {
31
+ if (!Array.isArray(value) || value.length === 0) {
32
+ throw new FiguresteadConfigError("must be a non-empty site-order array", path);
33
+ }
34
+ const sites = value.map((item, index) => {
35
+ requiredString(item, `${path}[${index}]`);
36
+ return item;
37
+ });
38
+ if (new Set(sites).size !== sites.length) throw new FiguresteadConfigError("must contain unique sites", path);
39
+ return sites;
40
+ }
41
+
42
+ export function validateCoverageData(data, path = "config.data") {
43
+ requiredObject(data, path);
44
+ const dates = normalizeDates(data.dates, `${path}.dates`);
45
+ sameLength(data.sites, dates.length, `${path}.sites`);
46
+ const sites = data.sites.map((site, index) => {
47
+ requiredString(site, `${path}.sites[${index}]`);
48
+ return site;
49
+ });
50
+ const siteOrder = normalizeSiteOrder(data.siteOrder, `${path}.siteOrder`);
51
+ sites.forEach((site, index) => {
52
+ if (!siteOrder.includes(site)) throw new FiguresteadConfigError(`unknown site ${JSON.stringify(site)}`, `${path}.sites[${index}]`);
53
+ });
54
+ return { dates, sites, siteOrder };
55
+ }
56
+
57
+ export function validateObservationData(data, path = "config.data") {
58
+ requiredObject(data, path);
59
+ const dates = normalizeDates(data.dates, `${path}.dates`);
60
+ if (!Array.isArray(data.values) || data.values.length !== dates.length) {
61
+ throw new FiguresteadConfigError(`must contain exactly ${dates.length} items`, `${path}.values`);
62
+ }
63
+ const values = data.values.map((value, index) => {
64
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new FiguresteadConfigError("must be a finite number", `${path}.values[${index}]`);
65
+ return value;
66
+ });
67
+ requiredString(data.site, `${path}.site`);
68
+ const referenceBands = normalizeReferenceBands(data.referenceBands ?? [], `${path}.referenceBands`);
69
+ return { dates, values, site: data.site, referenceBands };
70
+ }
71
+
72
+ export function normalizeReferenceBands(annotations, path = "config.annotations") {
73
+ if (!Array.isArray(annotations)) throw new FiguresteadConfigError("must be an array", path);
74
+ const bands = annotations.map((annotation, index) => {
75
+ const itemPath = `${path}[${index}]`;
76
+ requiredObject(annotation, itemPath);
77
+ if (annotation.type !== "reference_band") throw new FiguresteadConfigError("type must be reference_band", `${itemPath}.type`);
78
+ for (const key of ["from", "to"]) {
79
+ if (typeof annotation[key] !== "number" || !Number.isFinite(annotation[key])) throw new FiguresteadConfigError("must be a finite number", `${itemPath}.${key}`);
80
+ }
81
+ if (annotation.from >= annotation.to) throw new FiguresteadConfigError("from must be less than to", itemPath);
82
+ requiredString(annotation.label, `${itemPath}.label`);
83
+ if (annotation.status !== PROVISIONAL_STATUS) {
84
+ throw new FiguresteadConfigError(`must be ${JSON.stringify(PROVISIONAL_STATUS)}`, `${itemPath}.status`);
85
+ }
86
+ return { type: "reference_band", from: annotation.from, to: annotation.to, label: annotation.label, status: PROVISIONAL_STATUS };
87
+ });
88
+ bands.forEach((band, index) => {
89
+ if (index && band.from < bands[index - 1].to) throw new FiguresteadConfigError("reference bands must be ordered and non-overlapping", `${path}[${index}]`);
90
+ });
91
+ return bands;
92
+ }
93
+
94
+ export function contractReferenceBands(contract) {
95
+ const dataBands = normalizeReferenceBands(contract.data.referenceBands ?? [], "config.data.referenceBands");
96
+ if (!Array.isArray(contract.annotations) || contract.annotations.length === 0) return dataBands;
97
+ const annotationBands = normalizeReferenceBands(contract.annotations, "config.annotations");
98
+ if (JSON.stringify(annotationBands) !== JSON.stringify(dataBands)) {
99
+ throw new FiguresteadConfigError("must exactly match config.data.referenceBands so drawing and accessibility agree", "config.annotations");
100
+ }
101
+ return dataBands;
102
+ }
103
+
104
+ export function temporalXDomain(contract, milliseconds) {
105
+ if (contract.xScale?.domain) return contract.xScale.domain.map((value, index) => parseTime(value, `config.xScale.domain[${index}]`));
106
+ const low = Math.min(...milliseconds), high = Math.max(...milliseconds);
107
+ if (low === high) return [low - DAY, high + DAY];
108
+ const padding = Math.max(DAY, (high - low) * 0.025);
109
+ return [low - padding, high + padding];
110
+ }
111
+
112
+ export function temporalYDomain(contract, values, bands = []) {
113
+ if (contract.yScale?.domain) return [...contract.yScale.domain];
114
+ return extent([...values, ...bands.flatMap((band) => [band.from, band.to])]);
115
+ }
116
+
117
+ export function makeTemporalScales(xDomain, yDomain, plot) {
118
+ return {
119
+ x: timeScale(xDomain, [plot.left, plot.right]),
120
+ y: linearScale(yDomain, [plot.bottom, plot.top]),
121
+ xDomain,
122
+ yDomain,
123
+ xTicks: timeTicks(xDomain, 6),
124
+ yTicks: ticks(yDomain, 5),
125
+ };
126
+ }
127
+
128
+ export function postMarksProgress(progress, prepared, timeline) {
129
+ const settledAt = prepared.settledAt ?? 0;
130
+ if (progress <= settledAt) return 0;
131
+ const end = Math.max(settledAt + 1e-6, timeline.summaryCompiles[1]);
132
+ return smooth((progress - settledAt) / (end - settledAt));
133
+ }
134
+
135
+ export function drawTemporalAxes(context, { contract, layout, scales, plot = layout.plot, yLabels = null, showX = true, showY = true }) {
136
+ context.save();
137
+ context.lineWidth = Math.max(0.5, 0.6 * layout.scale);
138
+ context.strokeStyle = contract.theme.grid;
139
+ if (contract.profile.gridY) {
140
+ context.globalAlpha = contract.profile.gridAlpha;
141
+ (yLabels ? yLabels.map((_, index) => index) : scales.yTicks).forEach((value) => {
142
+ const y = scales.y(value); context.beginPath(); context.moveTo(plot.left, y); context.lineTo(plot.right, y); context.stroke();
143
+ });
144
+ }
145
+ if (contract.profile.gridX) {
146
+ context.globalAlpha = contract.profile.gridAlpha * 0.55;
147
+ scales.xTicks.forEach((value) => { const x = scales.x(value); context.beginPath(); context.moveTo(x, plot.top); context.lineTo(x, plot.bottom); context.stroke(); });
148
+ }
149
+ context.globalAlpha = 1; context.strokeStyle = contract.theme.spine; context.lineWidth = Math.max(0.7, 0.9 * layout.scale);
150
+ context.beginPath(); context.moveTo(plot.left, plot.top); context.lineTo(plot.left, plot.bottom); context.lineTo(plot.right, plot.bottom); context.stroke();
151
+ context.fillStyle = contract.theme.secondary; context.font = `${layout.font.axis}px ${FONT_STACK}`;
152
+ if (showX) {
153
+ context.textAlign = "center"; context.textBaseline = "top";
154
+ scales.xTicks.forEach((value) => context.fillText(formatTimeTick(value, scales.xDomain), scales.x(value), plot.bottom + 9 * layout.scale));
155
+ }
156
+ if (showY) {
157
+ context.textAlign = "right"; context.textBaseline = "middle";
158
+ if (yLabels) yLabels.forEach((label, index) => context.fillText(label, plot.left - 9 * layout.scale, scales.y(index), Math.max(30, plot.left - 14 * layout.scale)));
159
+ else scales.yTicks.forEach((value) => context.fillText(formatTick(value), plot.left - 9 * layout.scale, scales.y(value)));
160
+ }
161
+ if (showX && contract.spec.xLabel) {
162
+ context.textAlign = "center"; context.textBaseline = "bottom"; context.fillStyle = contract.theme.label;
163
+ context.fillText(contract.spec.xLabel, (plot.left + plot.right) / 2, layout.text?.xLabelY ?? layout.height - 9 * layout.scale);
164
+ }
165
+ if (contract.spec.yLabel) {
166
+ context.save(); context.translate(layout.text?.yLabelX ?? 18 * layout.scale, (plot.top + plot.bottom) / 2); context.rotate(-Math.PI / 2);
167
+ context.textAlign = "center"; context.textBaseline = "middle"; context.fillStyle = contract.theme.label; context.fillText(contract.spec.yLabel, 0, 0); context.restore();
168
+ }
169
+ context.restore();
170
+ }
171
+
172
+ export function yearStart(year) { return Date.parse(`${year}-01-01T00:00:00Z`); }
@@ -0,0 +1,70 @@
1
+ import { deriveLayout } from "./layout.js";
2
+
3
+ const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value));
4
+
5
+ const MINIMUM_PLOT_WIDTH = 160;
6
+ const MINIMUM_PLOT_HEIGHT = 120;
7
+
8
+ function deriveMultiPanelLayout(width, height, contract, columns, scale, outer, headerHeight, narrow, provenanceHeight) {
9
+ const rows = Math.ceil(contract.panels.length / columns);
10
+ const gap = clamp(contract.layout.gap * scale, narrow ? 8 : 10, narrow ? 20 : 38);
11
+ const contentTop = headerHeight;
12
+ const contentBottom = height - clamp(18 * scale, 10, 24) - provenanceHeight;
13
+ const availableWidth = width - outer * 2 - gap * (columns - 1);
14
+ const availableHeight = contentBottom - contentTop - gap * (rows - 1);
15
+ const cellWidth = availableWidth / columns;
16
+ const cellHeight = availableHeight / rows;
17
+ const panels = contract.panels.map((_, index) => {
18
+ const column = index % columns, row = Math.floor(index / columns);
19
+ const rect = {
20
+ left: outer + column * (cellWidth + gap),
21
+ top: contentTop + row * (cellHeight + gap),
22
+ right: outer + column * (cellWidth + gap) + cellWidth,
23
+ bottom: contentTop + row * (cellHeight + gap) + cellHeight,
24
+ };
25
+ const leftPad = clamp(cellWidth * 0.105, 48, 82), rightPad = clamp(cellWidth * 0.035, 16, 34);
26
+ const topPad = clamp(cellHeight * 0.16, 42, 68), bottomPad = clamp(cellHeight * 0.15, 42, 68);
27
+ return {
28
+ width, height, scale, rect, panelIndex: index,
29
+ plot: { left: rect.left + leftPad, right: rect.right - rightPad, top: rect.top + topPad, bottom: rect.bottom - bottomPad },
30
+ text: { titleY: rect.top + clamp(19 * scale, 14, 24), subtitleY: rect.top + clamp(37 * scale, 28, 46), xLabelY: rect.bottom - 7 * scale, yLabelX: rect.left + 12 * scale },
31
+ font: { title: clamp(14 * scale, 11, 17), subtitle: clamp(10 * scale, 8, 12), axis: clamp(10.5 * scale, narrow ? 10 : 8, 12), legend: clamp(9.5 * scale, narrow ? 9.5 : 8, 11), signature: clamp(8.5 * scale, narrow ? 8 : 7, 10) },
32
+ provenance: provenanceHeight ? { left: outer, right: width - outer, y: height - clamp(8 * scale, 7, 12) } : null,
33
+ };
34
+ });
35
+ return {
36
+ width, height, scale, panels,
37
+ plot: { left: outer, right: width - outer, top: contentTop, bottom: contentBottom },
38
+ header: { left: outer, titleY: clamp(30 * scale, 22, 38), subtitleY: clamp(53 * scale, 42, 66) },
39
+ font: { title: clamp(19 * scale, 14, 23), subtitle: clamp(11.5 * scale, 9, 14), axis: clamp(10.5 * scale, narrow ? 10 : 8, 12), legend: clamp(10 * scale, narrow ? 9.5 : 8, 12), signature: clamp(9 * scale, narrow ? 8 : 7, 10) },
40
+ provenance: provenanceHeight ? { left: outer, right: width - outer, y: height - clamp(8 * scale, 7, 12) } : null,
41
+ };
42
+ }
43
+
44
+ function hasSafePanelPlots(layout) {
45
+ return layout.panels.every((panel) =>
46
+ panel.plot.right - panel.plot.left >= MINIMUM_PLOT_WIDTH
47
+ && panel.plot.bottom - panel.plot.top >= MINIMUM_PLOT_HEIGHT);
48
+ }
49
+
50
+ export function deriveFigureLayout(width, height, contract) {
51
+ if (!contract || contract.panels.length === 1) {
52
+ const single = deriveLayout(width, height);
53
+ single.panels = [{ ...single, rect: { left: 0, top: 0, right: width, bottom: height }, panelIndex: 0 }];
54
+ single.header = null;
55
+ return single;
56
+ }
57
+ const scale = clamp(Math.min(width / 1160, height / 700), 0.55, 1.35);
58
+ const narrow = width <= 480 && height >= width * 1.5;
59
+ const outer = clamp(width * 0.035, 16, 42);
60
+ const headerHeight = narrow ? clamp(height * 0.085, 52, 68) : clamp(height * 0.13, 70, 108);
61
+ const provenanceHeight = contract.theme?.mode === "paper" || !contract.spec?.signature ? 0 : clamp(18 * scale, 14, 22);
62
+ const requestedColumns = Math.min(contract.layout.columns, contract.panels.length);
63
+ const legacyLayout = deriveMultiPanelLayout(width, height, contract, requestedColumns, scale, outer, headerHeight, narrow, provenanceHeight);
64
+ if (hasSafePanelPlots(legacyLayout)) return legacyLayout;
65
+ for (let columns = requestedColumns - 1; columns >= 1; columns -= 1) {
66
+ const candidate = deriveMultiPanelLayout(width, height, contract, columns, scale, outer, headerHeight, narrow, provenanceHeight);
67
+ if (hasSafePanelPlots(candidate)) return candidate;
68
+ }
69
+ return legacyLayout;
70
+ }
package/src/figure.js ADDED
@@ -0,0 +1,42 @@
1
+ export function panelContract(figure, panel) {
2
+ return {
3
+ schemaVersion: figure.schemaVersion,
4
+ rendererApiVersion: figure.rendererApiVersion,
5
+ theme: figure.theme,
6
+ profile: figure.profile,
7
+ timeline: figure.timeline,
8
+ motion: figure.motion,
9
+ view: figure.view,
10
+ style: figure.style,
11
+ applicationProfile: figure.applicationProfile,
12
+ seriesStyles: figure.seriesStyles,
13
+ renderer: panel.renderer,
14
+ spec: {
15
+ ...figure.spec,
16
+ ...panel.spec,
17
+ title: panel.spec.title || figure.spec.title,
18
+ subtitle: panel.spec.subtitle || (figure.panels.length === 1 ? figure.spec.subtitle : ""),
19
+ xLabel: panel.spec.xLabel || panel.xScale?.label || figure.spec.xLabel,
20
+ yLabel: panel.spec.yLabel || panel.yScale?.label || figure.spec.yLabel,
21
+ },
22
+ data: panel.data,
23
+ xScale: panel.xScale,
24
+ yScale: panel.yScale,
25
+ annotations: panel.annotations,
26
+ presentation: panel.presentation ?? null,
27
+ encoding: panel.encoding ?? { interpolation: panel.presentation?.curve ?? "linear" },
28
+ };
29
+ }
30
+
31
+ export function unionDomains(domains) {
32
+ const present = domains.filter((value) => Array.isArray(value) && value.length === 2 && value.every(Number.isFinite));
33
+ if (!present.length) return null;
34
+ return [Math.min(...present.map((value) => value[0])), Math.max(...present.map((value) => value[1]))];
35
+ }
36
+
37
+ export function resolvePanelDomains(figure, preparedPanels) {
38
+ const raw = preparedPanels.map(({ definition, contract, prepared }) => definition.domains(contract, prepared) || {});
39
+ const sharedX = figure.layout.sharedX ? unionDomains(raw.map((value) => value.x)) : null;
40
+ const sharedY = figure.layout.sharedY ? unionDomains(raw.map((value) => value.y)) : null;
41
+ return raw.map((value) => ({ ...value, x: sharedX || value.x, y: sharedY || value.y }));
42
+ }
package/src/index.js ADDED
@@ -0,0 +1,30 @@
1
+ export { createFigurestead } from "./create-matrix-plot.js";
2
+ export { validateContract, FiguresteadConfigError, SCHEMA_VERSION, LEGACY_SCHEMA_VERSION } from "./schema.js";
3
+ export { CORE_REGISTRY, CORE_RENDERERS } from "./core-renderers.js";
4
+ export { createRendererRegistry, defineRenderer, RENDERER_API_VERSION } from "./registry.js";
5
+ export { deriveFigureLayout } from "./figure-layout.js";
6
+ export { linearScale, timeScale, bandScale, parseTime, timeTicks, formatTimeTick } from "./scales.js";
7
+ export { drawRule, drawBand, drawBar, drawInterval, drawCell } from "./primitives.js";
8
+ export { rendererConformance, assertRendererConformance } from "./renderer-test-kit.js";
9
+ export { prepareLine, lineSegmentState } from "./renderers/line.js";
10
+ export { SCIENTIFIC_POSE, applyEvidencePose, applyScientificPose } from "./presentation.js";
11
+ export { APPLICATION_PROFILE_VERSION, APPLICATION_PROFILES, applyApplicationProfile, resolveApplicationProfile } from "./application-profiles.js";
12
+ export { APPEARANCE_VERSION, composeAppearance } from "./appearance.js";
13
+ export { SERIES_STYLE_VERSION, GLYPH_CYCLE, LINE_STYLE_CYCLE, HATCH_CYCLE, collectSeriesKeys, resolveSeriesStyles, styleForSeries } from "./series-style.js";
14
+ export { TERMINAL_SCENE_VERSION, canonicalTerminalEvidence, compileFigureModel, compileTerminalScene, evidenceFingerprint, terminalEvidence } from "./terminal-scene.js";
15
+ export { RESOLVED_SCENE_VERSION, RESOLVED_RENDERERS, isResolvedRenderer, resolveSceneFrame, resolveTerminalScene, resolvedTerminalGeometry } from "./resolved-scene.js";
16
+ export { COMPOSED_SCENE_VERSION, auditComposition, composeResolvedScene } from "./composition.js";
17
+ export { MOTION_PLAN_VERSION, ALLOWED_MOTION_CHANNELS, TERMINAL_MOTION_STATE, assertTerminalMotionIdentity, compileMotionPlan, markMotionState, strategyForRenderer } from "./motion-plan.js";
18
+ export { exportFigureSvg, resolvedSceneToSvg, sceneToSvg } from "./svg-export.js";
19
+ export { EXPORT_MANIFEST_VERSION, FIGURESTEAD_PACKAGE_VERSION, canvasToPngBlob, exportFigureArtifacts, stableStringify } from "./export-bundle.js";
20
+ export { MOTION_RECIPE_VERSION, MOTION_RECIPES, applyMotionRecipe, resolveMotionRecipe } from "./motion-recipes.js";
21
+ export { THEME_CATALOG_VERSION, auditThemeCatalog, catalogThemePack, mergeThemePacks } from "./theme-catalog.js";
22
+ export { prepareScatter } from "./renderers/scatter.js";
23
+ export { prepareStrip } from "./renderers/strip-summary.js";
24
+ export { AnimationClock } from "./clock.js";
25
+ export { THEME_PACK_VERSION, PALETTE_PACK_VERSION, applyTheme, contrastAudit, contrastRatio, loadThemePack, normalizeThemePackLenient, resolveTheme, resolvePalette, themeForProfile, validateAuthoredThemePack, validateThemePack, validatePalettePack } from "./theme-pack.js";
26
+ export { RENDER_LAYER_ORDER, partitionPanelMarks, plotClipRect, renderLayerForMark, withCanvasPlotClip } from "./render-layers.js";
27
+ export { validateEvidenceCoverage } from "./evidence-coverage.js";
28
+ export { colorContrast, hexToOklab, hexToOklch, oklabDistance, oklchToHex, resolveContrastColor } from "./color-space.js";
29
+ export { PAPER_FLOORS, PAPER_PROFILE_VERSION, PAPER_SURFACE, auditPaperTheme, resolvePaperTheme, themeResolutionForProfile } from "./paper-profile.js";
30
+ export { PAPER_SIZE_PRESETS, auditPhysicalTypography, resolveExportSize } from "./physical-export.js";
package/src/layout.js ADDED
@@ -0,0 +1,41 @@
1
+ const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value));
2
+
3
+ export const MIN_CANVAS_WIDTH = 320;
4
+ export const MIN_CANVAS_HEIGHT = 240;
5
+
6
+ export function deriveLayout(width, height) {
7
+ const scale = clamp(Math.min(width / 1160, height / 700), 0.55, 1.35);
8
+ const narrow = width <= 480;
9
+ const left = clamp(width * 0.083, 54, 100);
10
+ const right = clamp(width * 0.035, 22, 44);
11
+ const top = clamp(height * 0.154, 72, 112);
12
+ const bottom = clamp(height * 0.137, 58, 98);
13
+ return {
14
+ width,
15
+ height,
16
+ scale,
17
+ plot: { left, right: width - right, top, bottom: height - bottom },
18
+ font: {
19
+ title: clamp(19 * scale, 13, 22),
20
+ subtitle: clamp(12.5 * scale, 9, 14),
21
+ axis: clamp(12 * scale, narrow ? 10 : 9, 13),
22
+ legend: clamp(11.5 * scale, narrow ? 9.5 : 8.5, 13),
23
+ signature: clamp(10 * scale, narrow ? 8 : 7.5, 11),
24
+ },
25
+ provenance: { left, right: width - right, y: height - clamp(12 * scale, 10, 16) },
26
+ };
27
+ }
28
+
29
+ export function resizeCanvas(canvas, { dprCap = 2, layoutFactory = deriveLayout } = {}) {
30
+ const rect = canvas.getBoundingClientRect();
31
+ const width = Math.max(1, rect.width || canvas.clientWidth || MIN_CANVAS_WIDTH);
32
+ const height = Math.max(1, rect.height || canvas.clientHeight || width * 700 / 1160);
33
+ const dpr = Math.min(dprCap, globalThis.devicePixelRatio || 1);
34
+ const backingWidth = Math.max(1, Math.round(width * dpr));
35
+ const backingHeight = Math.max(1, Math.round(height * dpr));
36
+ if (canvas.width !== backingWidth) canvas.width = backingWidth;
37
+ if (canvas.height !== backingHeight) canvas.height = backingHeight;
38
+ const context = canvas.getContext("2d");
39
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
40
+ return { context, layout: layoutFactory(width, height), dpr };
41
+ }
package/src/marks.js ADDED
@@ -0,0 +1,218 @@
1
+ import { formatTick } from "./scales.js";
2
+ import { windowProgress } from "./schema.js";
3
+
4
+ export const clamp01 = (value) => Math.max(0, Math.min(1, value));
5
+ export const smooth = (value) => {
6
+ const x = clamp01(value);
7
+ return x * x * (3 - 2 * x);
8
+ };
9
+ export const easeOut = (value) => 1 - (1 - clamp01(value)) ** 3;
10
+
11
+ const FONT_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
12
+
13
+ export function compileProgress(progress, timeline) {
14
+ return smooth(windowProgress(progress, timeline.summaryCompiles));
15
+ }
16
+
17
+ export function pointMotionState(point, progress, scales, plot) {
18
+ const local = clamp01((progress - point.delay) / point.duration);
19
+ if (local >= 1) {
20
+ const finalX = scales.x(point.x), finalY = scales.y(point.y);
21
+ return { x: finalX, y: finalY, finalX, finalY, local: 1, eased: 1, visibility: 1 };
22
+ }
23
+ const eased = easeOut(local);
24
+ const finalX = scales.x(point.x);
25
+ const finalY = scales.y(point.y);
26
+ const startY = plot.top - point.startOffset * (plot.bottom - plot.top) * 0.35;
27
+ return {
28
+ x: finalX,
29
+ y: startY + (finalY - startY) * eased,
30
+ finalX,
31
+ finalY,
32
+ local,
33
+ eased,
34
+ visibility: smooth(clamp01(local / 0.15)),
35
+ };
36
+ }
37
+
38
+ function markerPath(context, x, y, radius, shape) {
39
+ context.beginPath();
40
+ if (shape === "square") context.rect(x - radius * 0.72, y - radius * 0.72, radius * 1.44, radius * 1.44);
41
+ else if (shape === "triangle") { context.moveTo(x, y - radius); context.lineTo(x + radius * 0.9, y + radius * 0.72); context.lineTo(x - radius * 0.9, y + radius * 0.72); context.closePath(); }
42
+ else if (shape === "diamond") { context.moveTo(x, y - radius); context.lineTo(x + radius, y); context.lineTo(x, y + radius); context.lineTo(x - radius, y); context.closePath(); }
43
+ else context.arc(x, y, radius, 0, Math.PI * 2);
44
+ }
45
+
46
+ export function drawScopePoint(context, state, { color, edge = null, radius, trailAlpha, settled, shape = "ring" }) {
47
+ if (state.visibility <= 0) return;
48
+ if (!settled) {
49
+ const trailLength = (0.05 + 0.34 * (1 - state.eased)) * radius * 12;
50
+ context.globalAlpha = trailAlpha * state.visibility * (1 - state.eased);
51
+ context.strokeStyle = color;
52
+ context.lineWidth = Math.max(0.6, radius * 0.13);
53
+ context.beginPath();
54
+ context.moveTo(state.x, state.y - 2);
55
+ context.lineTo(state.x, state.y - 2 - trailLength);
56
+ context.stroke();
57
+ }
58
+ context.globalAlpha = 0.065 * state.visibility * (0.4 + 0.6 * (1 - state.eased));
59
+ context.strokeStyle = color;
60
+ context.lineWidth = Math.max(1.5, radius * 0.3);
61
+ markerPath(context, state.x, state.y, radius * 1.7, shape);
62
+ context.stroke();
63
+ context.globalAlpha = 0.8 * state.visibility;
64
+ if (edge) {
65
+ context.strokeStyle = edge;
66
+ context.lineWidth = Math.max(1.5, radius * 0.34);
67
+ markerPath(context, state.x, state.y, radius, shape);
68
+ context.stroke();
69
+ }
70
+ context.strokeStyle = color;
71
+ context.lineWidth = Math.max(0.8, radius * 0.16);
72
+ markerPath(context, state.x, state.y, radius, shape);
73
+ context.stroke();
74
+ context.globalAlpha = 0.46 * state.visibility;
75
+ context.fillStyle = color;
76
+ markerPath(context, state.x, state.y, radius * 0.28, shape);
77
+ context.fill();
78
+ context.globalAlpha = 1;
79
+ }
80
+
81
+ export function drawAxes(context, { config, layout, scales, xTicks, yTicks, xCategories = null }) {
82
+ const { theme, profile, spec } = config;
83
+ const { plot, font } = layout;
84
+ context.save();
85
+ context.lineWidth = Math.max(0.5, 0.6 * layout.scale);
86
+ if (profile.gridY) {
87
+ context.strokeStyle = theme.grid;
88
+ context.globalAlpha = profile.gridAlpha;
89
+ yTicks.forEach((value) => {
90
+ const y = scales.y(value);
91
+ context.beginPath(); context.moveTo(plot.left, y); context.lineTo(plot.right, y); context.stroke();
92
+ });
93
+ }
94
+ if (profile.gridX) {
95
+ context.strokeStyle = theme.grid;
96
+ context.globalAlpha = profile.gridAlpha * 0.55;
97
+ xTicks.forEach((value) => {
98
+ const x = scales.x(value);
99
+ context.beginPath(); context.moveTo(x, plot.top); context.lineTo(x, plot.bottom); context.stroke();
100
+ });
101
+ }
102
+ context.globalAlpha = 1;
103
+ context.strokeStyle = theme.spine;
104
+ context.lineWidth = Math.max(0.7, 0.9 * layout.scale);
105
+ context.beginPath();
106
+ context.moveTo(plot.left, plot.top);
107
+ context.lineTo(plot.left, plot.bottom);
108
+ context.lineTo(plot.right, plot.bottom);
109
+ context.stroke();
110
+
111
+ context.fillStyle = theme.secondary;
112
+ context.font = `${font.axis}px ${FONT_STACK}`;
113
+ context.textAlign = "right";
114
+ context.textBaseline = "middle";
115
+ yTicks.forEach((value) => context.fillText(formatTick(value), plot.left - 9 * layout.scale, scales.y(value)));
116
+ context.textAlign = "center";
117
+ context.textBaseline = "top";
118
+ if (xCategories) {
119
+ xCategories.forEach((label, index) => context.fillText(label, scales.x(index), plot.bottom + 10 * layout.scale));
120
+ } else {
121
+ xTicks.forEach((value) => context.fillText(formatTick(value), scales.x(value), plot.bottom + 10 * layout.scale));
122
+ }
123
+
124
+ if (spec.xLabel) {
125
+ context.fillStyle = theme.label;
126
+ context.textBaseline = "bottom";
127
+ context.fillText(spec.xLabel, (plot.left + plot.right) / 2, layout.text?.xLabelY ?? layout.height - 9 * layout.scale);
128
+ }
129
+ if (spec.yLabel) {
130
+ context.save();
131
+ context.translate(layout.text?.yLabelX ?? 18 * layout.scale, (plot.top + plot.bottom) / 2);
132
+ context.rotate(-Math.PI / 2);
133
+ context.fillStyle = theme.label;
134
+ context.textBaseline = "middle";
135
+ context.fillText(spec.yLabel, 0, 0);
136
+ context.restore();
137
+ }
138
+ context.restore();
139
+ }
140
+
141
+ export function drawText(context, { config, layout, legend, legendPosition = "top-right", legendStyle = "point", seriesMarkers = [] }) {
142
+ const { theme, spec } = config;
143
+ const { plot, font } = layout;
144
+ context.save();
145
+ context.textAlign = "left";
146
+ context.textBaseline = "alphabetic";
147
+ context.fillStyle = theme.primary;
148
+ context.font = `500 ${font.title}px ${FONT_STACK}`;
149
+ if (theme.primaryEdge) {
150
+ context.strokeStyle = theme.primaryEdge;
151
+ context.lineWidth = Math.max(1, 1.5 * layout.scale);
152
+ context.strokeText(spec.title, plot.left, layout.text?.titleY ?? Math.max(font.title + 8, plot.top * 0.52));
153
+ }
154
+ context.fillText(spec.title, plot.left, layout.text?.titleY ?? Math.max(font.title + 8, plot.top * 0.52));
155
+ if (spec.subtitle) {
156
+ context.fillStyle = theme.secondary;
157
+ context.font = `italic ${font.subtitle}px ${FONT_STACK}`;
158
+ context.fillText(spec.subtitle, plot.left, layout.text?.subtitleY ?? Math.max(font.title + font.subtitle + 14, plot.top * 0.73));
159
+ }
160
+ context.fillStyle = theme.faint;
161
+ context.font = `${font.signature}px ${FONT_STACK}`;
162
+ const signatureLeft = legendPosition === "bottom-right";
163
+ context.textAlign = signatureLeft ? "left" : "right";
164
+ context.fillText(spec.signature, signatureLeft ? plot.left + 5 : plot.right - 5, plot.bottom - 8 * layout.scale);
165
+
166
+ context.font = `${font.legend}px ${FONT_STACK}`;
167
+ context.textBaseline = "middle";
168
+ if (legendPosition === "none") { context.restore(); return; }
169
+ legend.forEach((item, index) => {
170
+ const bottom = legendPosition.startsWith("bottom");
171
+ const left = legendPosition.endsWith("left");
172
+ const y = bottom
173
+ ? plot.bottom - (legend.length - index) * 20 * layout.scale
174
+ : plot.top + 14 * layout.scale + index * 20 * layout.scale;
175
+ const color = item.style?.color ?? theme.series[item.colorIndex % theme.series.length];
176
+ const edge = item.style?.edge ?? theme.seriesEdges?.[item.colorIndex % theme.series.length] ?? null;
177
+ const shape = item.style?.glyph ?? seriesMarkers[item.colorIndex % Math.max(1, seriesMarkers.length)] ?? "ring";
178
+ if (left) {
179
+ const x = plot.left + 14 * layout.scale;
180
+ if (legendStyle === "line") {
181
+ if (edge) { context.strokeStyle = edge; context.lineWidth = Math.max(2, 2.8 * layout.scale); context.beginPath(); context.moveTo(x - 3 * layout.scale, y); context.lineTo(x + 27 * layout.scale, y); context.stroke(); }
182
+ context.strokeStyle = color; context.lineWidth = Math.max(1.2, 1.65 * layout.scale); context.beginPath(); context.moveTo(x - 3 * layout.scale, y); context.lineTo(x + 27 * layout.scale, y); context.stroke();
183
+ markerPath(context, x + 12 * layout.scale, y, 4 * layout.scale, shape); context.stroke();
184
+ } else { context.strokeStyle = color; context.lineWidth = Math.max(0.8, 1.15 * layout.scale); markerPath(context, x, y, 5 * layout.scale, shape); context.stroke(); }
185
+ context.textAlign = "left"; context.fillStyle = theme.label;
186
+ context.fillText(item.label, x + (legendStyle === "line" ? 38 : 14) * layout.scale, y);
187
+ } else {
188
+ const tx = plot.right - 14 * layout.scale;
189
+ context.textAlign = "right"; context.fillStyle = theme.label;
190
+ context.fillText(item.label, tx, y);
191
+ const width = context.measureText(item.label).width;
192
+ const x = tx - width - 15 * layout.scale;
193
+ if (legendStyle === "line") {
194
+ if (edge) { context.strokeStyle = edge; context.lineWidth = Math.max(2, 2.8 * layout.scale); context.beginPath(); context.moveTo(x - 27 * layout.scale, y); context.lineTo(x + 3 * layout.scale, y); context.stroke(); }
195
+ context.strokeStyle = color; context.lineWidth = Math.max(1.2, 1.65 * layout.scale); context.beginPath(); context.moveTo(x - 27 * layout.scale, y); context.lineTo(x + 3 * layout.scale, y); context.stroke();
196
+ markerPath(context, x - 12 * layout.scale, y, 4 * layout.scale, shape); context.stroke();
197
+ } else { context.strokeStyle = color; context.lineWidth = Math.max(0.8, 1.15 * layout.scale); markerPath(context, x, y, 5 * layout.scale, shape); context.stroke(); }
198
+ }
199
+ });
200
+ context.restore();
201
+ }
202
+
203
+ export function drawFigureHeader(context, { config, layout }) {
204
+ if (!layout.header) return;
205
+ const fontStack = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
206
+ context.save(); context.textAlign = "left"; context.textBaseline = "alphabetic";
207
+ context.fillStyle = config.theme.primary; context.font = `500 ${layout.font.title}px ${fontStack}`;
208
+ if (config.theme.primaryEdge) { context.strokeStyle = config.theme.primaryEdge; context.lineWidth = Math.max(1, 1.5 * layout.scale); context.strokeText(config.spec.title, layout.header.left, layout.header.titleY); }
209
+ context.fillText(config.spec.title, layout.header.left, layout.header.titleY);
210
+ if (config.spec.subtitle) { context.fillStyle = config.theme.secondary; context.font = `italic ${layout.font.subtitle}px ${fontStack}`; context.fillText(config.spec.subtitle, layout.header.left, layout.header.subtitleY); }
211
+ context.restore();
212
+ }
213
+
214
+ export function drawBackground(context, layout, theme) {
215
+ context.clearRect(0, 0, layout.width, layout.height);
216
+ context.fillStyle = theme.field;
217
+ context.fillRect(0, 0, layout.width, layout.height);
218
+ }