@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.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/THIRD_PARTY_NOTICES.md +39 -0
- package/TRADEMARKS.md +13 -0
- package/package.json +31 -0
- package/src/accessibility.js +33 -0
- package/src/appearance.js +25 -0
- package/src/application-profiles.js +76 -0
- package/src/atmosphere.js +71 -0
- package/src/canvas-scene.js +323 -0
- package/src/clock.js +27 -0
- package/src/color-space.js +114 -0
- package/src/composition.js +201 -0
- package/src/core-renderers.js +33 -0
- package/src/create-matrix-plot.js +97 -0
- package/src/evidence-coverage.js +80 -0
- package/src/export-bundle.js +83 -0
- package/src/extensions/temporal/coverage.js +96 -0
- package/src/extensions/temporal/index.js +43 -0
- package/src/extensions/temporal/observations.js +87 -0
- package/src/extensions/temporal/shared.js +172 -0
- package/src/figure-layout.js +70 -0
- package/src/figure.js +42 -0
- package/src/index.js +30 -0
- package/src/layout.js +41 -0
- package/src/marks.js +218 -0
- package/src/motion-plan.js +66 -0
- package/src/motion-recipes.js +39 -0
- package/src/paper-profile.js +78 -0
- package/src/physical-export.js +34 -0
- package/src/presentation.js +127 -0
- package/src/primitives.js +24 -0
- package/src/random.js +26 -0
- package/src/registry.js +29 -0
- package/src/render-layers.js +35 -0
- package/src/renderer-test-kit.js +29 -0
- package/src/renderers/line.js +133 -0
- package/src/renderers/scatter.js +24 -0
- package/src/renderers/shared.js +21 -0
- package/src/renderers/strip-summary.js +40 -0
- package/src/resolved-scene.js +249 -0
- package/src/scales.js +68 -0
- package/src/schema.js +356 -0
- package/src/series-style.js +59 -0
- package/src/svg-export.js +232 -0
- package/src/terminal-scene.js +215 -0
- package/src/theme-catalog.js +34 -0
- 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
|
+
}
|