@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,24 @@
1
+ import { arrival, numericScales } from "./shared.js";
2
+ import { compileProgress, drawAxes, drawScopePoint, drawText, pointMotionState } from "../marks.js";
3
+ import { styleForSeries } from "../series-style.js";
4
+
5
+ export function prepareScatter(contract) {
6
+ const keys = [...new Set(contract.data.series)];
7
+ const points = contract.data.x.map((x, i) => ({ x, y: contract.data.y[i], series: contract.data.series[i], colorIndex: keys.indexOf(contract.data.series[i]), index: i }));
8
+ return { points: arrival(points, contract), legend: keys.map((key, colorIndex) => ({ label: contract.data.seriesLabels[key], colorIndex })) };
9
+ }
10
+
11
+ export function drawScatter(context, env) {
12
+ const { contract, prepared, layout, progress, settled } = env, presentation = contract.presentation ?? {}; const scales = numericScales(prepared.points, contract.data, layout, { domains: env.domains });
13
+ drawAxes(context, { config: contract, layout, scales, xTicks: scales.xTicks, yTicks: scales.yTicks });
14
+ prepared.points.forEach((point) => { const style = styleForSeries(env, point.series, point.colorIndex), state = pointMotionState(point, progress, scales, layout.plot); if (contract.view?.motion === "semantic") { state.x = state.finalX; state.y = state.finalY; } drawScopePoint(context, state, { color: style.color, edge: style.edge, radius: Math.max(3.5, Math.sqrt(contract.profile.markerSize) * 0.64 * layout.scale) * (presentation.markerScale ?? 1), trailAlpha: contract.motion.trailAlpha, settled: settled || contract.view?.motion === "semantic", shape: style.glyph }); });
15
+ if (contract.data.summary === "linear_fit") {
16
+ 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), sxx = prepared.points.reduce((a,p)=>a+p.x*p.x,0), sxy = prepared.points.reduce((a,p)=>a+p.x*p.y,0);
17
+ const slope = (n*sxy-sx*sy)/(n*sxx-sx*sx || 1), intercept=(sy-slope*sx)/n, cp=compileProgress(progress, contract.timeline), x0=scales.xDomain[0], x1=x0+(scales.xDomain[1]-x0)*cp;
18
+ context.save();
19
+ if (contract.theme.summaryEdge) { context.strokeStyle=contract.theme.summaryEdge; context.globalAlpha=.62*cp; context.lineWidth=Math.max(2,2.8*layout.scale); context.beginPath(); context.moveTo(scales.x(x0),scales.y(intercept+slope*x0)); context.lineTo(scales.x(x1),scales.y(intercept+slope*x1)); context.stroke(); }
20
+ context.strokeStyle=contract.theme.summaryCore; context.globalAlpha=.72*cp; context.lineWidth=Math.max(1,1.5*layout.scale); context.beginPath(); context.moveTo(scales.x(x0),scales.y(intercept+slope*x0)); context.lineTo(scales.x(x1),scales.y(intercept+slope*x1)); context.stroke(); context.restore();
21
+ }
22
+ const keys = [...new Set(contract.data.series)], legend = prepared.legend.map((item, index) => ({ ...item, style: styleForSeries(env, keys[index], index) }));
23
+ drawText(context, { config: contract, layout, legend, legendPosition: presentation.legend ?? "top-right", seriesMarkers: presentation.seriesMarkers ?? [] }); return scales;
24
+ }
@@ -0,0 +1,21 @@
1
+ import { extent, linearScale, ticks } from "../scales.js";
2
+ import { deriveSeed, mulberry32 } from "../random.js";
3
+
4
+ export function numericScales(points, data, layout, { categorical = false, domains = {} } = {}) {
5
+ const xs = points.map((p) => p.x), ys = points.map((p) => p.y);
6
+ const xd = domains.x || data.xDomain || (categorical ? [0, Math.max(1, Math.max(...xs))] : extent(xs));
7
+ const yd = domains.y || data.yDomain || extent(ys);
8
+ return { x: linearScale(xd, [layout.plot.left, layout.plot.right]), y: linearScale(yd, [layout.plot.bottom, layout.plot.top]), xTicks: categorical ? xs : ticks(xd, 6), yTicks: ticks(yd, 6), xDomain: xd, yDomain: yd };
9
+ }
10
+
11
+ export function arrival(points, contract, order = "random") {
12
+ const random = mulberry32(deriveSeed(contract.motion.seed, `${contract.renderer}:marks`));
13
+ const [start, end] = contract.timeline.marksEnter; const span = end - start;
14
+ const ranks = points.map((_, i) => i);
15
+ if (order === "x") ranks.sort((a, b) => points[a].x - points[b].x || points[a].colorIndex - points[b].colorIndex || a - b);
16
+ else for (let i = ranks.length - 1; i > 0; i -= 1) { const j = Math.floor(random() * (i + 1)); [ranks[i], ranks[j]] = [ranks[j], ranks[i]]; }
17
+ const rank = new Map(ranks.map((index, i) => [index, i]));
18
+ return points.map((point, index) => ({ ...point, delay: start + (rank.get(index) / Math.max(1, points.length - 1)) * span * 0.62, duration: Math.max(0.08, span * 0.52), startOffset: 0.15 + random() * 0.85 }));
19
+ }
20
+
21
+ export const median = (values) => { const a = [...values].sort((x, y) => x - y); const m = Math.floor(a.length / 2); return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2; };
@@ -0,0 +1,40 @@
1
+ import { arrival, median, numericScales } from "./shared.js";
2
+ import { deriveSeed, mulberry32 } from "../random.js";
3
+ import { compileProgress, drawAxes, drawScopePoint, drawText, pointMotionState } from "../marks.js";
4
+
5
+ export function prepareStrip(contract) {
6
+ const keys=[...new Set(contract.data.series)], random=mulberry32(deriveSeed(contract.motion.seed,"strip:jitter"));
7
+ const points=contract.data.values.map((y,i)=>({ x:contract.data.groups.indexOf(contract.data.group[i])+(random()-.5)*.28, y, group:contract.data.group[i], series:contract.data.series[i], colorIndex:keys.indexOf(contract.data.series[i]), index:i }));
8
+ return { points:arrival(points,contract), legend:keys.map((key,colorIndex)=>({label:contract.data.seriesLabels[key],colorIndex})), medians:contract.data.groups.map((group,index)=>({x:index,y:median(contract.data.values.filter((_,i)=>contract.data.group[i]===group))})) };
9
+ }
10
+
11
+ export function compileStripScene({ panel, contract, prepared, styles, markId }) {
12
+ const keys = [...new Set(contract.data.series)];
13
+ const marks = prepared.points.map((point) => {
14
+ const groupIndex = contract.data.groups.indexOf(point.group);
15
+ return {
16
+ id: markId(panel, "point", point.index), kind: "point", series: point.series,
17
+ group: point.group, xOffset: point.x - groupIndex, y: point.y,
18
+ style: styles[point.series],
19
+ };
20
+ });
21
+ if (contract.data.summary === "median") prepared.medians.forEach((median, index) => marks.push({
22
+ id: markId(panel, "median", contract.data.groups[index]), kind: "median-rule", role: "summary",
23
+ group: contract.data.groups[index], y: median.y, xOffset1: -0.22, xOffset2: 0.22,
24
+ style: { color: contract.theme.summaryCore, edge: contract.theme.summaryEdge ?? null, lineStyle: "solid", lineWidth: 2 },
25
+ }));
26
+ return {
27
+ marks,
28
+ categories: { x: [...contract.data.groups], y: null },
29
+ scales: { x: { ...contract.xScale, type: "band" }, y: contract.yScale },
30
+ legend: keys.map((key, colorIndex) => ({ key, label: contract.data.seriesLabels[key], colorIndex, style: styles[key] })),
31
+ };
32
+ }
33
+
34
+ export function drawStrip(context, env) {
35
+ const {contract,prepared,layout,progress,settled}=env, presentation=contract.presentation??{}, scales=numericScales(prepared.points,{...contract.data,xDomain:[-.5,contract.data.groups.length-.5]},layout,{categorical:true,domains:env.domains});
36
+ drawAxes(context,{config:contract,layout,scales,xTicks:contract.data.groups.map((_,i)=>i),yTicks:scales.yTicks,xCategories:contract.data.groups});
37
+ prepared.points.forEach((point)=>drawScopePoint(context,pointMotionState(point,progress,scales,layout.plot),{color:contract.theme.series[point.colorIndex%contract.theme.series.length],edge:contract.theme.seriesEdges?.[point.colorIndex%contract.theme.series.length],radius:Math.max(3.4,Math.sqrt(contract.profile.markerSize)*.62*layout.scale)*(presentation.markerScale??1),trailAlpha:contract.motion.trailAlpha,settled,shape:presentation.seriesMarkers?.[point.colorIndex%presentation.seriesMarkers.length]??"ring"}));
38
+ if(contract.data.summary==="median") { const cp=compileProgress(progress,contract.timeline); context.save(); context.strokeStyle=contract.theme.summaryCore; context.lineWidth=Math.max(1.2,2*layout.scale); context.globalAlpha=.84*cp; prepared.medians.forEach((m)=>{context.beginPath();context.moveTo(scales.x(m.x-.22*cp),scales.y(m.y));context.lineTo(scales.x(m.x+.22*cp),scales.y(m.y));context.stroke();});context.restore(); }
39
+ drawText(context,{config:contract,layout,legend:prepared.legend,legendPosition:presentation.legend??"top-right",seriesMarkers:presentation.seriesMarkers??[]}); return scales;
40
+ }
@@ -0,0 +1,249 @@
1
+ import { deriveFigureLayout } from "./figure-layout.js";
2
+ import { markMotionState } from "./motion-plan.js";
3
+ import { monotoneSegmentControls } from "./renderers/line.js";
4
+ import { bandScale, formatTick, formatTimeTick, linearScale, timeScale, ticks, timeTicks } from "./scales.js";
5
+
6
+ export const RESOLVED_SCENE_VERSION = "figurestead.resolved-scene/1";
7
+ export const RESOLVED_RENDERERS = Object.freeze([
8
+ "line", "scatter", "categorical_bar", "categorical_layered_bar", "categorical_matrix",
9
+ "interval_comparison", "strip_summary", "temporal_coverage", "temporal_observations",
10
+ "paired_points", "reference_improvement",
11
+ ]);
12
+
13
+ const clamp01 = (value) => Math.max(0, Math.min(1, value));
14
+ const cloneRect = (value) => ({ ...value });
15
+ function deepFreeze(value) { if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; Object.values(value).forEach(deepFreeze); return Object.freeze(value); }
16
+
17
+ function parseHex(color) {
18
+ const match = /^#([0-9a-f]{6})$/i.exec(color);
19
+ if (match) return [0, 2, 4].map((offset) => Number.parseInt(match[1].slice(offset, offset + 2), 16));
20
+ const rgb = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(color);
21
+ return rgb ? rgb.slice(1).map(Number) : [47, 185, 143];
22
+ }
23
+
24
+ function mix(left, right, amount) {
25
+ const a = parseHex(left), b = parseHex(right), t = clamp01(amount);
26
+ return `rgb(${a.map((value, index) => Math.round(value + (b[index] - value) * t)).join(",")})`;
27
+ }
28
+
29
+ function luminance(color) {
30
+ const values = parseHex(color).map((value) => { const item = value / 255; return item <= 0.04045 ? item / 12.92 : ((item + 0.055) / 1.055) ** 2.4; });
31
+ return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2];
32
+ }
33
+ function contrast(left, right) { const values = [luminance(left), luminance(right)].sort((a, b) => b - a); return (values[0] + 0.05) / (values[1] + 0.05); }
34
+
35
+ function panelLayout(source, panel) {
36
+ const layout = {
37
+ ...source,
38
+ rect: source.rect ? cloneRect(source.rect) : { left: 0, top: 0, right: source.width, bottom: source.height },
39
+ plot: cloneRect(source.plot), text: source.text ? { ...source.text } : null, font: { ...source.font },
40
+ };
41
+ if (panel.presentation?.legend === "outside-right" && panel.legend.length) {
42
+ const width = Math.min(190 * layout.scale, Math.max(110, (layout.rect.right - layout.rect.left) * 0.28));
43
+ layout.plot.right = Math.max(layout.plot.left + 80, layout.plot.right - width);
44
+ layout.legend = {
45
+ left: layout.plot.right + 18 * layout.scale,
46
+ right: layout.rect.right - 8 * layout.scale,
47
+ top: layout.plot.top,
48
+ bottom: layout.plot.bottom,
49
+ outside: true,
50
+ };
51
+ } else {
52
+ layout.legend = { left: layout.plot.left, right: layout.plot.right, top: layout.plot.top, bottom: layout.plot.bottom, outside: false };
53
+ }
54
+ return layout;
55
+ }
56
+
57
+ function numericScale(type, domain, range) {
58
+ return type === "time" ? timeScale(domain, range) : linearScale(domain, range);
59
+ }
60
+
61
+ function numericTicks(type, domain) {
62
+ const values = type === "time" ? timeTicks(domain, 5) : ticks(domain, 5);
63
+ return values.map((value) => ({ value, label: type === "time" ? formatTimeTick(value, domain) : formatTick(value) }));
64
+ }
65
+
66
+ function resolveAxes(panel, layout, plotOverride = null) {
67
+ const plot = plotOverride ?? layout.plot, xType = panel.scales.x.type, yType = panel.scales.y.type;
68
+ const xCategories = panel.categories.x, yCategories = panel.categories.y;
69
+ const x = xType === "band" ? bandScale(xCategories ?? [], [plot.left, plot.right], { padding: panel.scales.x.padding }) : numericScale(xType, panel.domain.x, [plot.left, plot.right]);
70
+ const y = yType === "band" ? bandScale(yCategories ?? [], [plot.top, plot.bottom], { padding: panel.scales.y.padding }) : numericScale(yType, panel.domain.y, [plot.bottom, plot.top]);
71
+ const xTicks = xType === "band" ? (xCategories ?? []).map((value) => ({ value, label: panel.categoryLabels?.x?.[value] ?? value })) : numericTicks(xType, panel.domain.x);
72
+ const yTicks = yType === "band" ? (yCategories ?? []).map((value) => ({ value, label: panel.categoryLabels?.y?.[value] ?? value })) : numericTicks(yType, panel.domain.y);
73
+ return { x, y, xTicks, yTicks, xType, yType, plot };
74
+ }
75
+
76
+ function pointGeometry(mark, axes, radius) {
77
+ let cx;
78
+ if (mark.group != null && axes.x.bandwidth) cx = axes.x(mark.group) + axes.x.bandwidth() / 2 + (mark.xOffset ?? 0) * (axes.x.step?.() ?? axes.x.bandwidth());
79
+ else cx = axes.x(mark.x);
80
+ const cy = mark.yCategory != null && axes.y.bandwidth ? axes.y(mark.yCategory) + axes.y.bandwidth() / 2 : axes.y(mark.y);
81
+ return { cx, cy, radius };
82
+ }
83
+
84
+ function lineGeometry(panel, axes, radius) {
85
+ const controls = new Map();
86
+ const series = [...new Set(panel.marks.filter((mark) => mark.kind === "point").map((mark) => mark.series))];
87
+ series.forEach((key) => {
88
+ const points = panel.marks.filter((mark) => mark.kind === "point" && mark.series === key);
89
+ const values = panel.encoding.interpolation === "monotone" ? monotoneSegmentControls(points) : null;
90
+ values?.forEach((value, index) => controls.set(`${key}\u0000${index}`, value));
91
+ });
92
+ const segmentIndex = new Map();
93
+ return panel.marks.map((mark) => {
94
+ if (mark.kind === "point") return { ...mark, geometry: pointGeometry(mark, axes, radius) };
95
+ if (mark.kind !== "segment") return { ...mark };
96
+ const index = segmentIndex.get(mark.series) ?? 0; segmentIndex.set(mark.series, index + 1);
97
+ const control = controls.get(`${mark.series}\u0000${index}`);
98
+ return { ...mark, geometry: {
99
+ x1: axes.x(mark.from.x), y1: axes.y(mark.from.y), x2: axes.x(mark.to.x), y2: axes.y(mark.to.y),
100
+ ...(control ? { c1x: axes.x(control.c1.x), c1y: axes.y(control.c1.y), c2x: axes.x(control.c2.x), c2y: axes.y(control.c2.y) } : {}),
101
+ } };
102
+ });
103
+ }
104
+
105
+ function scatterGeometry(panel, axes, radius) {
106
+ return panel.marks.map((mark) => {
107
+ if (mark.kind === "point") return { ...mark, geometry: pointGeometry(mark, axes, radius) };
108
+ if (mark.kind === "summary-line") {
109
+ const [x0, x1] = panel.domain.x;
110
+ return { ...mark, geometry: { x1: axes.x(x0), y1: axes.y(mark.intercept + mark.slope * x0), x2: axes.x(x1), y2: axes.y(mark.intercept + mark.slope * x1) } };
111
+ }
112
+ return { ...mark };
113
+ });
114
+ }
115
+
116
+ function barGeometry(panel, axes, layout) {
117
+ const horizontal = panel.orientation === "horizontal", categories = horizontal ? panel.categories.y : panel.categories.x;
118
+ const category = horizontal ? axes.y : axes.x, value = horizontal ? axes.x : axes.y;
119
+ const series = [...new Set(panel.marks.map((mark) => mark.series))], layered = panel.renderer === "categorical_layered_bar";
120
+ return panel.marks.map((mark) => {
121
+ const start = category(mark.category), bandwidth = category.bandwidth();
122
+ let left, right, top, bottom;
123
+ if (layered) {
124
+ const ratio = Math.max(0.36, 1 - mark.seriesIndex * 0.24), inset = bandwidth * (1 - ratio) / 2;
125
+ if (horizontal) { left = value(0); right = value(mark.value ?? 0); top = start + inset; bottom = start + bandwidth - inset; }
126
+ else { left = start + inset; right = start + bandwidth - inset; top = value(mark.value ?? 0); bottom = value(0); }
127
+ } else {
128
+ const slot = bandwidth / Math.max(1, series.length), inset = slot * 0.12, seriesIndex = series.indexOf(mark.series);
129
+ if (horizontal) { left = value(0); right = value(mark.value ?? 0); top = start + slot * seriesIndex + inset; bottom = start + slot * (seriesIndex + 1) - inset; }
130
+ else { left = start + slot * seriesIndex + inset; right = start + slot * (seriesIndex + 1) - inset; top = value(mark.value ?? 0); bottom = value(0); }
131
+ }
132
+ return { ...mark, geometry: {
133
+ left: Math.min(left, right), right: Math.max(left, right), top: Math.min(top, bottom), bottom: Math.max(top, bottom),
134
+ baselineX: horizontal ? value(0) : null, baselineY: horizontal ? null : value(0),
135
+ alpha: layered && mark.layer === 0 ? 0.3 : 0.68,
136
+ } };
137
+ });
138
+ }
139
+
140
+ function matrixGeometry(panel, layout, theme) {
141
+ const { plot } = layout, xs = panel.categories.x, ys = panel.categories.y;
142
+ const x = bandScale(xs, [plot.left, plot.right], { padding: 0.06 }), y = bandScale(ys, [plot.top, plot.bottom], { padding: 0.06 });
143
+ const domain = panel.valueScale?.domain ?? [0, 1];
144
+ const marks = panel.marks.map((mark) => {
145
+ const t = Number.isFinite(mark.value) ? clamp01((mark.value - domain[0]) / (domain[1] - domain[0])) : 0;
146
+ const diagonal = mark.diagonalMode === "context" && mark.xCategory === mark.yCategory;
147
+ const fill = mark.status !== "observed" ? mark.style.low : diagonal ? mix(mark.style.low, mark.style.color, 0.12)
148
+ : t < 0.68 ? mix(mark.style.low, mark.style.color, t / 0.68) : mix(mark.style.color, mark.style.high, (t - 0.68) / 0.32);
149
+ const labelColor = contrast(theme.label, fill) >= contrast(theme.field, fill) ? theme.label : theme.field;
150
+ return { ...mark, geometry: { left: x(mark.xCategory), top: y(mark.yCategory), right: x(mark.xCategory) + x.bandwidth(), bottom: y(mark.yCategory) + y.bandwidth(), fill, labelColor } };
151
+ });
152
+ return { marks, axes: {
153
+ x, y, xType: "band", yType: "band",
154
+ xTicks: xs.map((value) => ({ value, label: value })), yTicks: ys.map((value) => ({ value, label: value })),
155
+ } };
156
+ }
157
+
158
+ function categoryCenter(scale, value) { return scale(value) + scale.bandwidth() / 2; }
159
+
160
+ function extensionGeometry(panel, axes, layout, radius) {
161
+ const { plot } = axes;
162
+ return panel.marks.map((mark) => {
163
+ if (mark.kind === "point") return { ...mark, geometry: pointGeometry(mark, axes, radius) };
164
+ if (mark.kind === "interval") return { ...mark, geometry: {
165
+ x1: axes.x(mark.low), x2: axes.x(mark.high),
166
+ y: categoryCenter(axes.y, mark.category), cap: Math.max(2.5, radius * 0.72),
167
+ } };
168
+ if (mark.kind === "median-rule") {
169
+ const center = categoryCenter(axes.x, mark.group), step = axes.x.step?.() ?? axes.x.bandwidth();
170
+ return { ...mark, geometry: { x1: center + mark.xOffset1 * step, x2: center + mark.xOffset2 * step, y1: axes.y(mark.y), y2: axes.y(mark.y) } };
171
+ }
172
+ if (mark.kind === "connector") return { ...mark, geometry: {
173
+ x1: axes.x(mark.x1), x2: axes.x(mark.x2), y: categoryCenter(axes.y, mark.yCategory),
174
+ } };
175
+ if (mark.kind === "reference-band") return { ...mark, geometry: {
176
+ left: plot.left, right: plot.right,
177
+ top: Math.min(axes.y(mark.to), axes.y(mark.from)), bottom: Math.max(axes.y(mark.to), axes.y(mark.from)),
178
+ } };
179
+ if (mark.kind === "baseline-rule") return { ...mark, geometry: { x: axes.x(mark.x), top: plot.top, bottom: plot.bottom } };
180
+ if (mark.kind === "row-band") {
181
+ const first = axes.y(mark.categoryFrom), last = axes.y(mark.categoryTo), bandwidth = axes.y.bandwidth();
182
+ return { ...mark, geometry: { left: plot.left, right: plot.right, top: Math.min(first, last), bottom: Math.max(first, last) + bandwidth } };
183
+ }
184
+ if (mark.kind === "rug") return { ...mark, geometry: {
185
+ x: axes.x(mark.x), y: categoryCenter(axes.y, mark.yCategory), halfHeight: Math.max(4, 5 * layout.scale),
186
+ } };
187
+ return { ...mark, geometry: null };
188
+ });
189
+ }
190
+
191
+ function coverageGeometry(panel, layout, radius) {
192
+ const height = layout.plot.bottom - layout.plot.top;
193
+ const countPlot = { ...layout.plot, bottom: layout.plot.top + Math.min(48 * layout.scale, height * 0.22) };
194
+ const rugPlot = { ...layout.plot, top: countPlot.bottom + Math.max(8, 10 * layout.scale) };
195
+ const axes = resolveAxes(panel, layout, rugPlot);
196
+ const marks = panel.marks.map((mark) => {
197
+ if (mark.kind === "rug") return { ...mark, geometry: {
198
+ x: axes.x(mark.x), y: categoryCenter(axes.y, mark.yCategory), halfHeight: Math.max(4, 5 * layout.scale),
199
+ } };
200
+ if (mark.kind === "temporal-bar") {
201
+ const left = Math.max(countPlot.left, axes.x(mark.xFrom) + 1), right = Math.min(countPlot.right, axes.x(mark.xTo) - 1);
202
+ const usable = Math.max(0, countPlot.bottom - countPlot.top - 12 * layout.scale), barHeight = usable * mark.value / Math.max(1, mark.maximum);
203
+ return { ...mark, geometry: { left, right: Math.max(left, right), top: countPlot.bottom - barHeight, bottom: countPlot.bottom, labelX: (left + Math.max(left, right)) / 2, labelY: countPlot.bottom - barHeight - 2 * layout.scale } };
204
+ }
205
+ return { ...mark, geometry: null };
206
+ });
207
+ return { axes, marks, plots: { count: countPlot, rug: rugPlot } };
208
+ }
209
+
210
+ export function isResolvedRenderer(renderer) { return RESOLVED_RENDERERS.includes(renderer); }
211
+
212
+ export function resolveTerminalScene(scene, options = {}) {
213
+ const width = options.width ?? 960, height = options.height ?? 600;
214
+ const layout = deriveFigureLayout(width, height, { panels: scene.panels, layout: scene.layout, theme: scene.theme, spec: scene.spec });
215
+ const panels = scene.panels.map((panel, index) => {
216
+ const resolvedLayout = panelLayout(layout.panels[index], panel);
217
+ let axes = resolveAxes(panel, resolvedLayout), marks, plots = null;
218
+ const radius = Math.max(3.2, Math.sqrt(scene.profile.markerSize) * 0.62 * resolvedLayout.scale) * (panel.presentation?.markerScale ?? 1);
219
+ if (panel.renderer === "line") marks = lineGeometry(panel, axes, radius);
220
+ else if (panel.renderer === "scatter") marks = scatterGeometry(panel, axes, radius);
221
+ else if (["categorical_bar", "categorical_layered_bar"].includes(panel.renderer)) marks = barGeometry(panel, axes, resolvedLayout);
222
+ else if (panel.renderer === "categorical_matrix") { const matrix = matrixGeometry(panel, resolvedLayout, scene.theme); marks = matrix.marks; axes = matrix.axes; }
223
+ else if (panel.renderer === "temporal_coverage") { const coverage = coverageGeometry(panel, resolvedLayout, radius); marks = coverage.marks; axes = coverage.axes; plots = coverage.plots; }
224
+ else if (["interval_comparison", "strip_summary", "temporal_observations", "paired_points", "reference_improvement"].includes(panel.renderer)) marks = extensionGeometry(panel, axes, resolvedLayout, radius);
225
+ else marks = panel.marks.map((mark) => ({ ...mark, geometry: null }));
226
+ const evidenceFrame = cloneRect(plots ? resolvedLayout.plot : (axes.plot ?? resolvedLayout.plot));
227
+ return { ...panel, layout: resolvedLayout, axes, plots, evidenceFrame, marks, resolved: isResolvedRenderer(panel.renderer) };
228
+ });
229
+ return deepFreeze({ schemaVersion: RESOLVED_SCENE_VERSION, sourceSceneVersion: scene.schemaVersion, width, height, theme: scene.theme, spec: scene.spec, profile: scene.profile, applicationProfile: scene.applicationProfile, view: scene.view, layout, panels, motionPlan: scene.motionPlan });
230
+ }
231
+
232
+ export function resolveSceneFrame(resolvedScene, progress = 1) {
233
+ const p = clamp01(progress);
234
+ return {
235
+ ...resolvedScene,
236
+ progress: p,
237
+ panels: resolvedScene.panels.map((panel) => {
238
+ const plan = resolvedScene.motionPlan.panels.find((item) => item.panelId === panel.id);
239
+ return { ...panel, marks: panel.marks.map((mark, index) => ({
240
+ ...mark,
241
+ motion: markMotionState(mark, index, panel.marks.length, p, plan?.strategy ?? "none"),
242
+ })) };
243
+ }),
244
+ };
245
+ }
246
+
247
+ export function resolvedTerminalGeometry(resolvedScene) {
248
+ return resolvedScene.panels.map((panel) => ({ panelId: panel.id, marks: panel.marks.map((mark) => ({ id: mark.id, geometry: mark.geometry })) }));
249
+ }
package/src/scales.js ADDED
@@ -0,0 +1,68 @@
1
+ export function linearScale(domain, range) {
2
+ const [d0, d1] = domain;
3
+ const [r0, r1] = range;
4
+ const span = d1 - d0 || 1;
5
+ return (value) => r0 + ((value - d0) / span) * (r1 - r0);
6
+ }
7
+
8
+ export function parseTime(value, path = "time value") {
9
+ const milliseconds = value instanceof Date ? value.getTime() : typeof value === "number" ? value : Date.parse(value);
10
+ if (!Number.isFinite(milliseconds)) throw new TypeError(`${path} must be an ISO date, Date, or finite epoch milliseconds`);
11
+ return milliseconds;
12
+ }
13
+
14
+ export function timeScale(domain, range) {
15
+ return linearScale(domain.map((value, index) => parseTime(value, `time domain[${index}]`)), range);
16
+ }
17
+
18
+ export function bandScale(domain, range, { padding = 0.12 } = {}) {
19
+ const keys = domain.map(String), [r0, r1] = range, count = Math.max(1, keys.length);
20
+ const step = (r1 - r0) / count, bandwidth = step * (1 - padding);
21
+ const offset = (step - bandwidth) / 2, lookup = new Map(keys.map((key, index) => [key, r0 + index * step + offset]));
22
+ const scale = (value) => lookup.get(String(value));
23
+ scale.bandwidth = () => bandwidth; scale.step = () => step; scale.domain = () => [...keys];
24
+ return scale;
25
+ }
26
+
27
+ export function timeTicks(domain, count = 6) {
28
+ const numeric = domain.map((value, index) => parseTime(value, `time domain[${index}]`));
29
+ return ticks(numeric, count);
30
+ }
31
+
32
+ export function formatTimeTick(value, domain = null) {
33
+ const date = new Date(parseTime(value));
34
+ const span = domain ? Math.abs(parseTime(domain[1]) - parseTime(domain[0])) : 0;
35
+ const options = span > 1000 * 60 * 60 * 24 * 730 ? { year: "numeric" }
36
+ : span > 1000 * 60 * 60 * 24 * 60 ? { month: "short", year: "2-digit" }
37
+ : { month: "short", day: "numeric" };
38
+ return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", ...options }).format(date);
39
+ }
40
+
41
+ export function extent(values, padding = 0.08, floor = null) {
42
+ const minimum = Math.min(...values);
43
+ const maximum = Math.max(...values);
44
+ const span = maximum - minimum || Math.max(Math.abs(maximum), 1);
45
+ let low = minimum - span * padding;
46
+ const high = maximum + span * padding;
47
+ if (floor !== null) low = Math.min(floor, low);
48
+ return [low, high];
49
+ }
50
+
51
+ export function ticks(domain, count = 6) {
52
+ const [start, end] = domain;
53
+ if (count < 2) return [start];
54
+ const raw = (end - start) / (count - 1);
55
+ const power = 10 ** Math.floor(Math.log10(Math.abs(raw) || 1));
56
+ const normalized = raw / power;
57
+ const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * power;
58
+ const first = Math.ceil(start / step) * step;
59
+ const result = [];
60
+ for (let value = first; value <= end + step * 1e-9; value += step) result.push(Number(value.toPrecision(12)));
61
+ return result.length >= 2 ? result : [start, end];
62
+ }
63
+
64
+ export function formatTick(value) {
65
+ if (Math.abs(value) >= 1000 || (Math.abs(value) > 0 && Math.abs(value) < 0.01)) return value.toExponential(1);
66
+ if (Number.isInteger(value)) return String(value);
67
+ return Number(value.toFixed(2)).toString();
68
+ }