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