@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,323 @@
1
+ import { partitionPanelMarks, withCanvasPlotClip } from "./render-layers.js";
2
+
3
+ const FONT_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
4
+
5
+ function markerPath(context, glyph, x, y, radius) {
6
+ context.beginPath();
7
+ if (glyph === "square") context.rect(x - radius, y - radius, radius * 2, radius * 2);
8
+ else if (glyph === "triangle") { context.moveTo(x, y - radius); context.lineTo(x + radius, y + radius); context.lineTo(x - radius, y + radius); context.closePath(); }
9
+ else if (glyph === "diamond") { context.moveTo(x, y - radius); context.lineTo(x + radius, y); context.lineTo(x, y + radius); context.lineTo(x - radius, y); context.closePath(); }
10
+ else context.arc(x, y, radius, 0, Math.PI * 2);
11
+ }
12
+
13
+ function mixPoint(a, b, t) { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; }
14
+ function partialCubic(g, progress) {
15
+ const p0 = { x: g.x1, y: g.y1 }, c1 = { x: g.c1x, y: g.c1y }, c2 = { x: g.c2x, y: g.c2y }, p1 = { x: g.x2, y: g.y2 };
16
+ const a = mixPoint(p0, c1, progress), b = mixPoint(c1, c2, progress), c = mixPoint(c2, p1, progress);
17
+ const d = mixPoint(a, b, progress), e = mixPoint(b, c, progress);
18
+ return { c1: a, c2: d, end: mixPoint(d, e, progress) };
19
+ }
20
+
21
+ function strokeSegment(context, geometry, progress = 1) {
22
+ context.beginPath(); context.moveTo(geometry.x1, geometry.y1);
23
+ if (geometry.c1x != null) {
24
+ const value = progress < 1 ? partialCubic(geometry, progress) : { c1: { x: geometry.c1x, y: geometry.c1y }, c2: { x: geometry.c2x, y: geometry.c2y }, end: { x: geometry.x2, y: geometry.y2 } };
25
+ context.bezierCurveTo(value.c1.x, value.c1.y, value.c2.x, value.c2.y, value.end.x, value.end.y);
26
+ } else context.lineTo(geometry.x1 + (geometry.x2 - geometry.x1) * progress, geometry.y1 + (geometry.y2 - geometry.y1) * progress);
27
+ context.stroke();
28
+ }
29
+
30
+ function tickPosition(axis, tick) {
31
+ const value = axis(tick.value);
32
+ return axis.bandwidth ? value + axis.bandwidth() / 2 : value;
33
+ }
34
+
35
+ function fitLabel(context, value, width) {
36
+ if (context.measureText(value).width <= width) return value;
37
+ let text = String(value);
38
+ while (text.length > 2 && context.measureText(`${text}…`).width > width) text = text.slice(0, -1);
39
+ return `${text}…`;
40
+ }
41
+
42
+ function drawGrid(context, panel, theme, profile) {
43
+ const { layout, axes } = panel, plot = axes.plot ?? layout.plot;
44
+ context.save(); context.strokeStyle = theme.grid; context.lineWidth = Math.max(0.6, 0.85 * layout.scale); context.globalAlpha = profile.gridAlpha * 0.5;
45
+ if (axes.xType !== "band" && profile.gridX) axes.xTicks.forEach((tick) => {
46
+ const x = tickPosition(axes.x, tick); context.beginPath(); context.moveTo(x, plot.top); context.lineTo(x, plot.bottom); context.stroke();
47
+ });
48
+ if (axes.yType !== "band" && profile.gridY) axes.yTicks.forEach((tick) => {
49
+ const y = tickPosition(axes.y, tick); context.beginPath(); context.moveTo(plot.left, y); context.lineTo(plot.right, y); context.stroke();
50
+ });
51
+ context.restore();
52
+ }
53
+
54
+ function drawAxes(context, panel, theme) {
55
+ const { layout, axes, spec } = panel, plot = axes.plot ?? layout.plot, { font } = layout;
56
+ context.save(); context.font = `${font.axis}px ${FONT_STACK}`; context.fillStyle = theme.secondary; context.strokeStyle = theme.spine;
57
+ context.lineWidth = Math.max(0.6, 0.85 * layout.scale);
58
+ context.strokeStyle = theme.spine; context.beginPath(); context.moveTo(plot.left, plot.top); context.lineTo(plot.left, plot.bottom); context.lineTo(plot.right, plot.bottom); context.stroke();
59
+ const xSlot = axes.x.step?.() ?? Math.max(40, (plot.right - plot.left) / Math.max(1, axes.xTicks.length));
60
+ const rotateX = axes.xType === "band" && axes.xTicks.some((tick) => context.measureText(tick.label).width > xSlot * 0.92);
61
+ context.textAlign = rotateX ? "right" : "center"; context.textBaseline = "top";
62
+ axes.xTicks.forEach((tick) => {
63
+ const x = tickPosition(axes.x, tick), label = fitLabel(context, tick.label, rotateX ? xSlot * 1.75 : xSlot * 0.92);
64
+ if (rotateX) { context.save(); context.translate(x, plot.bottom + 7 * layout.scale); context.rotate(-Math.PI / 4); context.fillText(label, 0, 0); context.restore(); }
65
+ else context.fillText(label, x, plot.bottom + 7 * layout.scale);
66
+ });
67
+ context.textAlign = "right"; context.textBaseline = "middle";
68
+ axes.yTicks.forEach((tick) => context.fillText(fitLabel(context, tick.label, Math.max(26, plot.left - layout.rect.left - 12 * layout.scale)), plot.left - 7 * layout.scale, tickPosition(axes.y, tick)));
69
+ if (spec.xLabel) { context.fillStyle = theme.label; context.textAlign = "center"; context.textBaseline = "bottom"; context.fillText(spec.xLabel, (plot.left + plot.right) / 2, layout.text?.xLabelY ?? layout.rect.bottom - 6 * layout.scale); }
70
+ if (spec.yLabel) { context.save(); context.translate(layout.text?.yLabelX ?? layout.rect.left + 12 * layout.scale, (plot.top + plot.bottom) / 2); context.rotate(-Math.PI / 2); context.fillStyle = theme.label; context.textAlign = "center"; context.textBaseline = "top"; context.fillText(spec.yLabel, 0, 0); context.restore(); }
71
+ context.restore();
72
+ }
73
+
74
+ function drawLegend(context, panel, theme) {
75
+ if (panel.presentation?.legend === "none" || !panel.legend.length) return;
76
+ const { layout } = panel, outside = layout.legend.outside;
77
+ context.save(); context.font = `${layout.font.legend}px ${FONT_STACK}`; context.textBaseline = "middle";
78
+ const insideTop = layout.plot.bottom - Math.max(14, 14 + (panel.legend.length - 1) * 20) * layout.scale;
79
+ panel.legend.forEach((item, index) => {
80
+ const style = item.style ?? {}, entry = layout.legend.entries?.[index];
81
+ const x = entry?.markerX ?? (outside ? layout.legend.left : layout.plot.right - 24 * layout.scale);
82
+ const textX = entry?.textX ?? (outside ? x + 12 * layout.scale : x - 10 * layout.scale);
83
+ const y = entry?.y ?? (outside ? layout.legend.top + (14 + index * 20) * layout.scale : insideTop + index * 20 * layout.scale);
84
+ context.strokeStyle = style.edge ?? style.color ?? theme.series[item.colorIndex % theme.series.length]; context.lineWidth = Math.max(1, 1.2 * layout.scale);
85
+ markerPath(context, style.glyph ?? "ring", x, y, 4 * layout.scale); context.stroke();
86
+ context.fillStyle = theme.label;
87
+ context.textAlign = entry?.textAnchor ?? (outside ? "left" : "right"); context.fillText(entry?.displayLabel ?? item.label, textX, y);
88
+ });
89
+ context.restore();
90
+ }
91
+
92
+ function drawComposedAnnotations(context, panel, theme, progress) {
93
+ if (!panel.composedAnnotations?.length || progress <= 0) return;
94
+ panel.composedAnnotations.forEach((annotation) => {
95
+ const g = annotation.geometry;
96
+ if (!g) return;
97
+ const reveal = Math.max(0, Math.min(1, progress));
98
+ context.save();
99
+ context.globalAlpha = reveal;
100
+ context.strokeStyle = theme.primary;
101
+ context.lineWidth = Math.max(3, g.radius * 0.62);
102
+ context.globalAlpha = 0.13 * reveal;
103
+ context.beginPath(); context.arc(g.anchorX, g.anchorY, g.radius * 1.45, 0, Math.PI * 2); context.stroke();
104
+ context.globalAlpha = 0.92 * reveal;
105
+ context.strokeStyle = theme.summaryCore;
106
+ context.lineWidth = Math.max(1, 1.25 * panel.layout.scale);
107
+ context.beginPath(); context.moveTo(g.anchorX + (g.labelX < g.anchorX ? -1 : 1) * g.radius * 0.7, g.anchorY + (g.labelY < g.anchorY ? -1 : 1) * g.radius * 0.55); context.lineTo(g.labelX + (g.textAnchor === "end" ? 8 : -8) * panel.layout.scale, g.labelY - 5 * panel.layout.scale); context.stroke();
108
+ context.fillStyle = theme.summaryCore;
109
+ context.strokeStyle = theme.seriesEdges?.[0] ?? theme.primaryEdge ?? theme.field;
110
+ context.lineWidth = Math.max(1.4, 1.9 * panel.layout.scale);
111
+ context.beginPath(); context.arc(g.anchorX, g.anchorY, g.radius, 0, Math.PI * 2); context.fill(); context.stroke();
112
+ context.font = `600 ${Math.max(9, panel.layout.font.legend * 1.06)}px ${FONT_STACK}`;
113
+ context.textAlign = g.textAnchor; context.textBaseline = "middle";
114
+ const label = annotation.displayLabel ?? annotation.label;
115
+ context.strokeStyle = theme.field; context.lineWidth = Math.max(1.6, 2.2 * panel.layout.scale); context.strokeText(label, g.labelX, g.labelY);
116
+ context.fillStyle = theme.summaryCore; context.fillText(label, g.labelX, g.labelY);
117
+ context.restore();
118
+ });
119
+ }
120
+
121
+ function drawMatrixLegend(context, panel, theme) {
122
+ if (panel.renderer !== "categorical_matrix" || !panel.valueScale || !panel.marks.length) return;
123
+ const { layout, valueScale } = panel, width = Math.min(190 * layout.scale, (layout.plot.right - layout.plot.left) * 0.42);
124
+ const left = layout.plot.right - width, top = layout.plot.top - 28 * layout.scale, height = Math.max(5, 7 * layout.scale), style = panel.marks[0].style;
125
+ const gradient = context.createLinearGradient(left, 0, left + width, 0); gradient.addColorStop(0, style.low); gradient.addColorStop(0.68, style.color); gradient.addColorStop(1, style.high);
126
+ context.save(); context.fillStyle = gradient; context.fillRect(left, top, width, height); context.strokeStyle = theme.spine; context.strokeRect(left, top, width, height);
127
+ context.font = `${layout.font.legend}px ${FONT_STACK}`; context.fillStyle = theme.label; context.textAlign = "left"; context.textBaseline = "bottom"; context.fillText(valueScale.label, left, top - 3 * layout.scale);
128
+ context.fillStyle = theme.secondary; context.textBaseline = "top"; context.fillText(String(valueScale.domain[0]), left, top + height + 2 * layout.scale); context.textAlign = "right"; context.fillText(String(valueScale.domain[1]), left + width, top + height + 2 * layout.scale); context.restore();
129
+ }
130
+
131
+ function drawPanelText(context, panel, theme) {
132
+ const { layout, spec } = panel;
133
+ context.save(); context.textAlign = "left"; context.textBaseline = "alphabetic";
134
+ context.fillStyle = theme.mode === "paper" ? theme.label : theme.primary; context.font = `500 ${layout.font.title}px ${FONT_STACK}`;
135
+ context.fillText(spec.title || panel.renderer, layout.plot.left, layout.text?.titleY ?? layout.rect.top + 20 * layout.scale);
136
+ if (spec.subtitle) { context.fillStyle = theme.secondary; context.font = `italic ${layout.font.subtitle}px ${FONT_STACK}`; context.fillText(spec.subtitle, layout.plot.left, layout.text?.subtitleY ?? layout.rect.top + 39 * layout.scale); }
137
+ if (theme.mode !== "paper" && spec.signature && (layout.panelIndex ?? 0) === 0) {
138
+ const provenance = layout.provenance ?? { left: layout.plot.left, y: layout.rect.bottom - 8 * layout.scale };
139
+ context.fillStyle = theme.faint; context.font = `${layout.font.signature}px ${FONT_STACK}`; context.textAlign = "left";
140
+ context.fillText(spec.signature, provenance.left, provenance.y);
141
+ }
142
+ context.restore();
143
+ }
144
+
145
+ function drawBandKey(context, panel, theme) {
146
+ const bands = panel.marks.filter((mark) => mark.kind === "reference-band");
147
+ if (!bands.length) return;
148
+ const plot = panel.axes.plot ?? panel.layout.plot;
149
+ context.save(); context.font = `${Math.max(7, panel.layout.font.legend * 0.86)}px ${FONT_STACK}`; context.textAlign = "left"; context.textBaseline = "middle";
150
+ let x = plot.left;
151
+ bands.forEach((band) => {
152
+ const label = `${band.from}–${band.to}`, swatch = Math.max(10, 13 * panel.layout.scale);
153
+ context.globalAlpha = 0.48; context.fillStyle = band.style.color; context.fillRect(x, plot.top - 14 * panel.layout.scale, swatch, 5 * panel.layout.scale);
154
+ context.globalAlpha = 0.88; context.fillStyle = theme.secondary; context.fillText(label, x + swatch + 4 * panel.layout.scale, plot.top - 11 * panel.layout.scale);
155
+ x += swatch + context.measureText(label).width + 17 * panel.layout.scale;
156
+ });
157
+ context.fillStyle = theme.faint; context.fillText("Reference bands · provisional", x, plot.top - 11 * panel.layout.scale); context.restore();
158
+ }
159
+
160
+ function drawPoint(context, mark) {
161
+ const motion = mark.motion, g = mark.geometry, x = g.cx + motion.translateX, y = g.cy + motion.translateY, radius = g.radius * Math.min(motion.scaleX, motion.scaleY);
162
+ context.save(); context.globalAlpha = motion.opacity; context.strokeStyle = mark.style.edge ?? mark.style.color; context.lineWidth = Math.max(1.6, radius * 0.58);
163
+ markerPath(context, mark.style.glyph, x, y, radius); context.stroke();
164
+ context.strokeStyle = mark.style.color; context.lineWidth = Math.max(0.9, radius * 0.24); markerPath(context, mark.style.glyph, x, y, radius); context.stroke();
165
+ if (motion.glow > 0) { context.globalAlpha = motion.glow; context.lineWidth = radius; markerPath(context, mark.style.glyph, x, y, radius * 1.45); context.stroke(); }
166
+ context.restore();
167
+ }
168
+
169
+ function drawLine(context, mark, theme) {
170
+ const motion = mark.motion;
171
+ context.save(); context.globalAlpha = motion.opacity * (theme.mode === "paper" ? 1 : 0.78); context.strokeStyle = mark.style.edge ?? mark.style.color;
172
+ context.lineWidth = Math.max(1, (mark.style.lineWidth ?? 1.6) + (mark.style.edge ? 1.3 : 0));
173
+ context.setLineDash?.(mark.style.lineStyle === "dash" ? [7, 4] : mark.style.lineStyle === "dot" ? [2, 4] : mark.style.lineStyle === "dash-dot" ? [8, 3, 2, 3] : []);
174
+ strokeSegment(context, mark.geometry, motion.clip);
175
+ if (mark.style.edge) { context.strokeStyle = mark.style.color; context.lineWidth = Math.max(1, mark.style.lineWidth ?? 1.6); strokeSegment(context, mark.geometry, motion.clip); }
176
+ context.restore();
177
+ }
178
+
179
+ function drawBar(context, mark, theme) {
180
+ const m = mark.motion, g = mark.geometry;
181
+ let { left, right, top, bottom } = g;
182
+ if (mark.orientation === "horizontal") right = left + (right - left) * m.scaleX;
183
+ else top = bottom - (bottom - top) * m.scaleY;
184
+ context.save(); context.globalAlpha = m.opacity * (theme.mode === "paper" ? 1 : g.alpha);
185
+ if (mark.missing) { context.fillStyle = theme.warm; context.textAlign = "center"; context.textBaseline = "middle"; context.fillText("×", (left + right) / 2, (top + bottom) / 2); }
186
+ else {
187
+ context.fillStyle = mark.style.color; context.strokeStyle = mark.style.edge ?? mark.style.color; context.fillRect(left, top, Math.max(0, right - left), Math.max(0, bottom - top)); context.strokeRect(left, top, Math.max(0, right - left), Math.max(0, bottom - top));
188
+ if (theme.mode === "paper" && mark.style.hatch && mark.style.hatch !== "none") drawBarHatch(context, { left, right, top, bottom }, mark.style.hatch, mark.style.edge ?? theme.label);
189
+ }
190
+ context.restore();
191
+ }
192
+
193
+ function drawBarHatch(context, rect, hatch, color) {
194
+ const spacing = 6, height = rect.bottom - rect.top;
195
+ context.save(); context.beginPath(); context.rect(rect.left, rect.top, rect.right - rect.left, height); context.clip();
196
+ context.globalAlpha = 0.34; context.strokeStyle = color; context.lineWidth = 0.75;
197
+ if (["diag", "cross"].includes(hatch)) for (let x = rect.left - height; x <= rect.right; x += spacing) {
198
+ context.beginPath(); context.moveTo(x, rect.bottom); context.lineTo(x + height, rect.top); context.stroke();
199
+ }
200
+ if (hatch === "cross") for (let x = rect.left; x <= rect.right + height; x += spacing) {
201
+ context.beginPath(); context.moveTo(x, rect.bottom); context.lineTo(x - height, rect.top); context.stroke();
202
+ }
203
+ if (hatch === "vertical") for (let x = rect.left + spacing / 2; x < rect.right; x += spacing) {
204
+ context.beginPath(); context.moveTo(x, rect.top); context.lineTo(x, rect.bottom); context.stroke();
205
+ }
206
+ context.restore();
207
+ }
208
+
209
+ function drawCell(context, mark, theme, font) {
210
+ const m = mark.motion, g = mark.geometry;
211
+ context.save(); context.globalAlpha = m.opacity; context.fillStyle = g.fill; context.strokeStyle = mark.style.edge; context.fillRect(g.left, g.top, g.right - g.left, g.bottom - g.top); context.strokeRect(g.left, g.top, g.right - g.left, g.bottom - g.top);
212
+ if (mark.status === "insufficient") { context.strokeStyle = theme.warm; context.beginPath(); context.moveTo(g.left, g.bottom); context.lineTo(g.right, g.top); context.stroke(); }
213
+ if (mark.label && g.right - g.left > 24 && g.bottom - g.top > 14) { context.fillStyle = g.labelColor ?? theme.label; context.font = `${font}px ${FONT_STACK}`; context.textAlign = "center"; context.textBaseline = "middle"; context.fillText(mark.label, (g.left + g.right) / 2, (g.top + g.bottom) / 2); }
214
+ context.restore();
215
+ }
216
+
217
+ function lineDash(style, scale = 1) { return style === "dash" ? [7 * scale, 4 * scale] : style === "dot" ? [2 * scale, 4 * scale] : style === "dash-dot" ? [8 * scale, 3 * scale, 2 * scale, 3 * scale] : []; }
218
+
219
+ function drawIntervalMark(context, mark, scale, theme) {
220
+ const m = mark.motion, g = mark.geometry, center = (g.x1 + g.x2) / 2, half = (g.x2 - g.x1) * m.clip / 2;
221
+ const x1 = center - half, x2 = center + half, cap = g.cap * Math.max(0.2, m.clip);
222
+ context.save(); context.globalAlpha = m.opacity * (theme.mode === "paper" ? 1 : (mark.role === "context" ? 0.62 : 0.9)); context.strokeStyle = mark.style.color;
223
+ context.lineWidth = Math.max(0.8, (mark.style.lineWidth ?? 1.6) * scale); context.setLineDash?.(lineDash(mark.style.lineStyle, scale));
224
+ context.beginPath(); context.moveTo(x1, g.y); context.lineTo(x2, g.y); context.moveTo(x1, g.y - cap); context.lineTo(x1, g.y + cap); context.moveTo(x2, g.y - cap); context.lineTo(x2, g.y + cap); context.stroke(); context.restore();
225
+ }
226
+
227
+ function drawConnector(context, mark, panel, theme) {
228
+ const m = mark.motion, g = mark.geometry, end = g.x1 + (g.x2 - g.x1) * m.clip;
229
+ context.save(); context.globalAlpha = m.opacity * (theme.mode === "paper" ? 1 : 0.72); context.strokeStyle = mark.style.color; context.lineWidth = Math.max(1, (mark.style.lineWidth ?? 1.35) * panel.layout.scale);
230
+ context.beginPath(); context.moveTo(g.x1, g.y); context.lineTo(end, g.y); context.stroke();
231
+ context.restore();
232
+ }
233
+
234
+ function drawConnectorLabel(context, mark, theme, panel) {
235
+ if (mark.motion.clip < 0.999) return;
236
+ const g = mark.geometry, leftFirst = g.x1 <= g.x2, pad = 6 * panel.layout.scale;
237
+ context.save(); context.globalAlpha = mark.motion.opacity; context.font = `${panel.layout.font.legend}px ${FONT_STACK}`; context.textBaseline = "middle";
238
+ context.fillStyle = theme.series[0]; context.textAlign = leftFirst ? "right" : "left"; context.fillText(fitLabel(context, mark.endpointALabel, 110 * panel.layout.scale), g.x1 + (leftFirst ? -pad : pad), g.y - 7 * panel.layout.scale);
239
+ context.fillStyle = theme.series[1 % theme.series.length]; context.textAlign = leftFirst ? "left" : "right"; context.fillText(fitLabel(context, mark.endpointBLabel, 110 * panel.layout.scale), g.x2 + (leftFirst ? pad : -pad), g.y + 7 * panel.layout.scale);
240
+ const delta = `${mark.delta >= 0 ? "+" : ""}${Number(mark.delta.toPrecision(4))}`; context.fillStyle = theme.label; context.textAlign = "center"; context.fillText(delta, (g.x1 + g.x2) / 2, g.y - 18 * panel.layout.scale); context.restore();
241
+ }
242
+
243
+ function drawReferenceBand(context, mark) {
244
+ const m = mark.motion, g = mark.geometry;
245
+ context.save(); context.globalAlpha = m.opacity * 0.1; context.fillStyle = mark.style.color; context.fillRect(g.left, g.top, g.right - g.left, g.bottom - g.top);
246
+ context.globalAlpha = m.opacity * 0.45; context.strokeStyle = mark.style.color; context.beginPath(); context.moveTo(g.left, g.bottom); context.lineTo(g.right, g.bottom); context.stroke(); context.restore();
247
+ }
248
+
249
+ function drawBaseline(context, mark, panel, theme) {
250
+ const m = mark.motion, g = mark.geometry;
251
+ context.save(); context.globalAlpha = m.opacity * (theme.mode === "paper" ? 1 : 0.92); context.strokeStyle = mark.style.color; context.lineWidth = Math.max(1, (mark.style.lineWidth ?? 1.2) * panel.layout.scale); context.setLineDash?.(lineDash(mark.style.lineStyle, panel.layout.scale));
252
+ context.beginPath(); context.moveTo(g.x, g.top); context.lineTo(g.x, g.bottom); context.stroke(); context.setLineDash?.([]); context.restore();
253
+ }
254
+
255
+ function drawBaselineLabel(context, mark, panel) {
256
+ if (mark.motion.clip < 0.999) return;
257
+ const g = mark.geometry; context.save(); context.globalAlpha = mark.motion.opacity; context.fillStyle = mark.style.color; context.font = `${Math.max(7, panel.layout.font.legend * 0.9)}px ${FONT_STACK}`; context.textAlign = "center"; context.textBaseline = "bottom"; context.fillText(fitLabel(context, mark.label, Math.max(50, panel.layout.plot.right - panel.layout.plot.left)), g.x, g.top - 5 * panel.layout.scale); context.restore();
258
+ }
259
+
260
+ function drawRowBand(context, mark) {
261
+ const g = mark.geometry; context.save(); context.globalAlpha = mark.motion.opacity * 0.28; context.fillStyle = mark.style.color; context.fillRect(g.left, g.top, g.right - g.left, g.bottom - g.top); context.restore();
262
+ }
263
+
264
+ function drawRug(context, mark, scale, theme) {
265
+ const m = mark.motion, g = mark.geometry;
266
+ context.save(); context.globalAlpha = m.opacity * (theme.mode === "paper" ? 1 : 0.84); context.strokeStyle = mark.style.color; context.lineWidth = Math.max(1, 1.35 * scale); context.beginPath(); context.moveTo(g.x, g.y - g.halfHeight); context.lineTo(g.x, g.y + g.halfHeight); context.stroke(); context.restore();
267
+ }
268
+
269
+ function drawTemporalBar(context, mark, theme, panel) {
270
+ const m = mark.motion, g = mark.geometry, top = g.bottom - (g.bottom - g.top) * m.clip;
271
+ context.save(); context.globalAlpha = m.opacity * (theme.mode === "paper" ? 1 : 0.24); context.fillStyle = mark.style.color; context.strokeStyle = mark.style.edge ?? mark.style.color; context.fillRect(g.left, top, Math.max(0, g.right - g.left), g.bottom - top); context.strokeRect(g.left, top, Math.max(0, g.right - g.left), g.bottom - top); context.restore();
272
+ }
273
+
274
+ function drawTemporalBarLabel(context, mark, theme, panel) {
275
+ if (mark.motion.clip < 0.999) return;
276
+ const g = mark.geometry; context.save(); context.globalAlpha = mark.motion.opacity; context.fillStyle = theme.secondary; context.font = `${Math.max(7, panel.layout.font.axis * 0.75)}px ${FONT_STACK}`; context.textAlign = "center"; context.textBaseline = "bottom"; context.fillText(String(mark.value), g.labelX, g.labelY); context.restore();
277
+ }
278
+
279
+ function drawDenominatorLabels(context, panel, theme) {
280
+ const denominator = panel.meta?.denominator;
281
+ if (!denominator) return;
282
+ context.save(); context.fillStyle = theme.warm; context.font = `${panel.layout.font.signature}px ${FONT_STACK}`; context.textAlign = "right"; context.textBaseline = "bottom";
283
+ context.fillText(`${denominator.label}: ${denominator.value}`, panel.layout.plot.right, panel.layout.plot.top - 5 * panel.layout.scale);
284
+ context.restore();
285
+ }
286
+
287
+ export function drawResolvedPanel(context, frame, panelIndex) {
288
+ const panel = frame.panels[panelIndex], theme = frame.theme;
289
+ const layers = partitionPanelMarks(panel.marks);
290
+ drawGrid(context, panel, theme, frame.profile);
291
+ const drawMark = (mark) => {
292
+ if (!mark.geometry || mark.motion.opacity <= 0) return;
293
+ if (mark.kind === "point") drawPoint(context, mark);
294
+ else if (["segment", "summary-line"].includes(mark.kind)) drawLine(context, mark, theme);
295
+ else if (mark.kind === "median-rule") drawLine(context, mark, theme);
296
+ else if (mark.kind === "bar") drawBar(context, mark, theme);
297
+ else if (mark.kind === "cell") drawCell(context, mark, theme, panel.layout.font.axis);
298
+ else if (mark.kind === "interval") drawIntervalMark(context, mark, panel.layout.scale, theme);
299
+ else if (mark.kind === "connector") drawConnector(context, mark, panel, theme);
300
+ else if (mark.kind === "baseline-rule") drawBaseline(context, mark, panel, theme);
301
+ else if (mark.kind === "rug") drawRug(context, mark, panel.layout.scale, theme);
302
+ else if (mark.kind === "temporal-bar") drawTemporalBar(context, mark, theme, panel);
303
+ };
304
+ for (const key of ["reference", "data", "summary"]) withCanvasPlotClip(context, panel, () => {
305
+ const marks = key === "data"
306
+ ? [...layers.data.filter((mark) => mark.kind !== "point"), ...layers.data.filter((mark) => mark.kind === "point")]
307
+ : layers[key];
308
+ marks.forEach(drawMark);
309
+ });
310
+ drawPanelText(context, panel, theme);
311
+ drawAxes(context, panel, theme);
312
+ panel.marks.forEach((mark) => {
313
+ if (!mark.geometry || mark.motion.opacity <= 0) return;
314
+ if (mark.kind === "connector") drawConnectorLabel(context, mark, theme, panel);
315
+ else if (mark.kind === "baseline-rule") drawBaselineLabel(context, mark, panel);
316
+ else if (mark.kind === "temporal-bar") drawTemporalBarLabel(context, mark, theme, panel);
317
+ });
318
+ drawComposedAnnotations(context, panel, theme, frame.progress ?? 1);
319
+ drawLegend(context, panel, theme); drawMatrixLegend(context, panel, theme);
320
+ if (panel.meta?.showBandKey) drawBandKey(context, panel, theme);
321
+ drawDenominatorLabels(context, panel, theme);
322
+ return { x: panel.axes.x, y: panel.axes.y, xDomain: panel.domain.x, yDomain: panel.domain.y };
323
+ }
package/src/clock.js ADDED
@@ -0,0 +1,27 @@
1
+ const clamp = (value) => Math.max(0, Math.min(1, value));
2
+
3
+ export class AnimationClock {
4
+ constructor({ durationMs, draw, onState = () => {}, raf = globalThis.requestAnimationFrame?.bind(globalThis), cancel = globalThis.cancelAnimationFrame?.bind(globalThis) }) {
5
+ this.durationMs = durationMs; this.draw = draw; this.onState = onState;
6
+ this.raf = raf || ((fn) => setTimeout(() => fn(performance.now()), 16));
7
+ this.cancel = cancel || clearTimeout; this.progress = 0; this.playing = false;
8
+ this.frame = null; this.startedAt = null; this.destroyed = false;
9
+ }
10
+ render(progress = this.progress) { this.progress = clamp(progress); this.draw(this.progress); }
11
+ play() {
12
+ if (this.destroyed || this.playing || this.progress >= 1) return;
13
+ this.playing = true; this.startedAt = null; this.onState("playing");
14
+ const tick = (now) => {
15
+ if (!this.playing || this.destroyed) return;
16
+ if (this.startedAt == null) this.startedAt = now - this.progress * this.durationMs;
17
+ this.render((now - this.startedAt) / this.durationMs);
18
+ if (this.progress >= 1) { this.playing = false; this.frame = null; this.onState("complete"); return; }
19
+ this.frame = this.raf(tick);
20
+ };
21
+ this.frame = this.raf(tick);
22
+ }
23
+ pause() { if (!this.playing) return; this.playing = false; if (this.frame != null) this.cancel(this.frame); this.frame = null; this.onState("paused"); }
24
+ replay() { this.pause(); this.render(0); this.play(); }
25
+ settle() { this.pause(); this.render(1); this.onState("complete"); }
26
+ destroy() { this.pause(); this.destroyed = true; this.draw = () => {}; }
27
+ }
@@ -0,0 +1,114 @@
1
+ const HEX = /^#([0-9a-f]{6})$/i;
2
+ const clamp = (value, minimum = 0, maximum = 1) => Math.max(minimum, Math.min(maximum, value));
3
+
4
+ export function hexToSrgb(value) {
5
+ const match = HEX.exec(value);
6
+ if (!match) throw new TypeError(`expected #RRGGBB, received ${value}`);
7
+ return [0, 2, 4].map((offset) => Number.parseInt(match[1].slice(offset, offset + 2), 16) / 255);
8
+ }
9
+
10
+ export function srgbToHex(rgb) {
11
+ return `#${rgb.map((value) => Math.round(clamp(value) * 255).toString(16).padStart(2, "0")).join("")}`.toUpperCase();
12
+ }
13
+
14
+ const toLinear = (value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
15
+ const toSrgb = (value) => value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055;
16
+
17
+ export function srgbToOklab(rgb) {
18
+ const [r, g, b] = rgb.map(toLinear);
19
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
20
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
21
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
22
+ return {
23
+ L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
24
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
25
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
26
+ };
27
+ }
28
+
29
+ export function oklabToSrgb({ L, a, b }) {
30
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
31
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
32
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
33
+ return [
34
+ toSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
35
+ toSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
36
+ toSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
37
+ ];
38
+ }
39
+
40
+ export function hexToOklab(value) { return srgbToOklab(hexToSrgb(value)); }
41
+
42
+ export function oklabToOklch({ L, a, b }) {
43
+ const C = Math.hypot(a, b);
44
+ return { L, C, h: C < 1e-9 ? 0 : (Math.atan2(b, a) * 180 / Math.PI + 360) % 360 };
45
+ }
46
+
47
+ export function oklchToOklab({ L, C, h }) {
48
+ const angle = h * Math.PI / 180;
49
+ return { L, a: C * Math.cos(angle), b: C * Math.sin(angle) };
50
+ }
51
+
52
+ export function hexToOklch(value) { return oklabToOklch(hexToOklab(value)); }
53
+
54
+ function inGamut(rgb) { return rgb.every((value) => Number.isFinite(value) && value >= -1e-7 && value <= 1 + 1e-7); }
55
+
56
+ export function oklchToHex(value) {
57
+ let candidate = { L: clamp(value.L), C: Math.max(0, value.C), h: value.h };
58
+ let rgb = oklabToSrgb(oklchToOklab(candidate));
59
+ if (!inGamut(rgb)) {
60
+ let low = 0, high = candidate.C;
61
+ for (let index = 0; index < 24; index += 1) {
62
+ const mid = (low + high) / 2;
63
+ const attempt = oklabToSrgb(oklchToOklab({ ...candidate, C: mid }));
64
+ if (inGamut(attempt)) low = mid; else high = mid;
65
+ }
66
+ candidate = { ...candidate, C: low };
67
+ rgb = oklabToSrgb(oklchToOklab(candidate));
68
+ }
69
+ return { hex: srgbToHex(rgb), oklch: candidate };
70
+ }
71
+
72
+ export function relativeLuminance(value) {
73
+ const [r, g, b] = hexToSrgb(value).map(toLinear);
74
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
75
+ }
76
+
77
+ export function colorContrast(left, right) {
78
+ const values = [relativeLuminance(left), relativeLuminance(right)].sort((a, b) => b - a);
79
+ return (values[0] + 0.05) / (values[1] + 0.05);
80
+ }
81
+
82
+ export function oklabDistance(left, right) {
83
+ return Math.hypot(left.L - right.L, left.a - right.a, left.b - right.b);
84
+ }
85
+
86
+ export function resolveContrastColor(source, surface, minimum, options = {}) {
87
+ const authoredLab = hexToOklab(source), authored = oklabToOklch(authoredLab);
88
+ const originalContrast = colorContrast(source, surface);
89
+ if (originalContrast >= minimum) return {
90
+ source: source.toUpperCase(), color: source.toUpperCase(), contrast: originalContrast,
91
+ identityDelta: 0, lightnessDelta: 0, chromaReduction: 0, hueDelta: 0, changed: false,
92
+ };
93
+ const chromaCap = options.chromaCap ?? 0.22;
94
+ const C = Math.min(authored.C, chromaCap);
95
+ let winner = null;
96
+ for (let step = 0; step <= 1000; step += 1) {
97
+ const L = step / 1000, converted = oklchToHex({ L, C, h: authored.h });
98
+ const contrast = colorContrast(converted.hex, surface);
99
+ if (contrast + 1e-9 < minimum) continue;
100
+ const lab = hexToOklab(converted.hex), distance = oklabDistance(authoredLab, lab);
101
+ if (!winner || distance < winner.identityDelta - 1e-9 || (Math.abs(distance - winner.identityDelta) < 1e-9 && converted.hex < winner.color)) {
102
+ const resolvedLch = hexToOklch(converted.hex);
103
+ const hueDistance = Math.abs(authored.h - resolvedLch.h);
104
+ winner = {
105
+ source: source.toUpperCase(), color: converted.hex, contrast, identityDelta: distance,
106
+ lightnessDelta: resolvedLch.L - authored.L,
107
+ chromaReduction: Math.max(0, authored.C - resolvedLch.C),
108
+ hueDelta: Math.min(hueDistance, 360 - hueDistance), changed: converted.hex !== source.toUpperCase(),
109
+ };
110
+ }
111
+ }
112
+ if (!winner) throw new TypeError(`no in-gamut paper color can meet contrast ${minimum} against ${surface}`);
113
+ return winner;
114
+ }