@figurestead/web 0.9.0-alpha.1 → 0.9.0-alpha.3
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/README.md +214 -5
- package/THIRD_PARTY_NOTICES.md +12 -5
- package/package.json +42 -5
- package/src/accessibility.js +51 -11
- package/src/canvas-scene.js +55 -14
- package/src/clock.js +19 -8
- package/src/composition.js +18 -8
- package/src/core-renderers.js +9 -3
- package/src/create-matrix-plot.js +104 -34
- package/src/direct-labels.js +73 -0
- package/src/export-bundle.js +1 -1
- package/src/extensions/temporal/shared.js +1 -1
- package/src/figure-layout.js +3 -2
- package/src/height-negotiation.js +77 -0
- package/src/index.js +1 -1
- package/src/layout.js +4 -2
- package/src/line-identity.js +29 -0
- package/src/marks.js +2 -2
- package/src/paper-profile.js +5 -1
- package/src/renderers/scatter.js +3 -2
- package/src/resolved-scene.js +64 -7
- package/src/responsive-header.js +159 -0
- package/src/schema.js +34 -10
- package/src/scientific-layout.js +148 -0
- package/src/screen-legibility.js +30 -0
- package/src/statistics.js +21 -0
- package/src/svg-export.js +60 -16
- package/src/terminal-scene.js +11 -4
- package/src/theme-pack.js +31 -0
- package/themes/deep_observatory_sage_core.json +28 -0
- package/themes/lavender_fog_notebook.json +29 -0
- package/themes/midnight_transit_signal_slate.json +28 -0
- package/themes/registration_ink.json +28 -0
- package/themes/slipware.json +28 -0
- package/themes/ultraviolet_laboratory.json +29 -0
- package/types/extensions/temporal.d.ts +45 -0
- package/types/index.d.ts +665 -0
- package/types/theme-json.d.ts +4 -0
package/src/resolved-scene.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { planDirectLabels } from "./direct-labels.js";
|
|
2
|
+
import { lineMarkerGeometry } from "./line-identity.js";
|
|
1
3
|
import { deriveFigureLayout } from "./figure-layout.js";
|
|
4
|
+
import { refineScientificLayout } from "./scientific-layout.js";
|
|
2
5
|
import { markMotionState } from "./motion-plan.js";
|
|
3
6
|
import { monotoneSegmentControls } from "./renderers/line.js";
|
|
4
7
|
import { bandScale, formatTick, formatTimeTick, linearScale, timeScale, ticks, timeTicks } from "./scales.js";
|
|
@@ -54,6 +57,18 @@ function panelLayout(source, panel) {
|
|
|
54
57
|
return layout;
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
function preparedPanelLayout(source) {
|
|
61
|
+
return {
|
|
62
|
+
...source,
|
|
63
|
+
rect: source.rect ? cloneRect(source.rect) : { left: 0, top: 0, right: source.width, bottom: source.height },
|
|
64
|
+
plot: cloneRect(source.plot),
|
|
65
|
+
text: source.text ? { ...source.text } : null,
|
|
66
|
+
font: { ...source.font },
|
|
67
|
+
provenance: source.provenance ? { ...source.provenance } : null,
|
|
68
|
+
legend: source.legend ? { ...source.legend } : null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
function numericScale(type, domain, range) {
|
|
58
73
|
return type === "time" ? timeScale(domain, range) : linearScale(domain, range);
|
|
59
74
|
}
|
|
@@ -81,7 +96,7 @@ function pointGeometry(mark, axes, radius) {
|
|
|
81
96
|
return { cx, cy, radius };
|
|
82
97
|
}
|
|
83
98
|
|
|
84
|
-
function lineGeometry(panel, axes,
|
|
99
|
+
function lineGeometry(panel, axes, scale) {
|
|
85
100
|
const controls = new Map();
|
|
86
101
|
const series = [...new Set(panel.marks.filter((mark) => mark.kind === "point").map((mark) => mark.series))];
|
|
87
102
|
series.forEach((key) => {
|
|
@@ -91,7 +106,10 @@ function lineGeometry(panel, axes, radius) {
|
|
|
91
106
|
});
|
|
92
107
|
const segmentIndex = new Map();
|
|
93
108
|
return panel.marks.map((mark) => {
|
|
94
|
-
if (mark.kind === "point")
|
|
109
|
+
if (mark.kind === "point") {
|
|
110
|
+
const marker = lineMarkerGeometry(mark.style, scale, panel.presentation?.markerScale ?? 1);
|
|
111
|
+
return { ...mark, lineIdentity: true, geometry: { ...pointGeometry(mark, axes, marker.radius), outlineWidth: marker.outlineWidth } };
|
|
112
|
+
}
|
|
95
113
|
if (mark.kind !== "segment") return { ...mark };
|
|
96
114
|
const index = segmentIndex.get(mark.series) ?? 0; segmentIndex.set(mark.series, index + 1);
|
|
97
115
|
const control = controls.get(`${mark.series}\u0000${index}`);
|
|
@@ -113,6 +131,28 @@ function scatterGeometry(panel, axes, radius) {
|
|
|
113
131
|
});
|
|
114
132
|
}
|
|
115
133
|
|
|
134
|
+
function fallbackPointGeometry(panel, axes, radius) {
|
|
135
|
+
return panel.marks.map((mark) => {
|
|
136
|
+
if (mark.kind === "point") return { ...mark, geometry: pointGeometry(mark, axes, radius) };
|
|
137
|
+
if (mark.kind !== "renderer-mark") return { ...mark, geometry: null };
|
|
138
|
+
const evidence = mark.evidence ?? {};
|
|
139
|
+
const hasX = evidence.x != null || (evidence.group != null && axes.x.bandwidth);
|
|
140
|
+
const hasY = evidence.y != null || (evidence.yCategory != null && axes.y.bandwidth);
|
|
141
|
+
if (!hasX || !hasY) return { ...mark, geometry: null };
|
|
142
|
+
const candidate = {
|
|
143
|
+
x: evidence.x,
|
|
144
|
+
y: evidence.y,
|
|
145
|
+
group: evidence.group,
|
|
146
|
+
yCategory: evidence.yCategory,
|
|
147
|
+
xOffset: evidence.xOffset,
|
|
148
|
+
};
|
|
149
|
+
const geometry = pointGeometry(candidate, axes, radius);
|
|
150
|
+
return Number.isFinite(geometry.cx) && Number.isFinite(geometry.cy)
|
|
151
|
+
? { ...mark, geometry }
|
|
152
|
+
: { ...mark, geometry: null };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
116
156
|
function barGeometry(panel, axes, layout) {
|
|
117
157
|
const horizontal = panel.orientation === "horizontal", categories = horizontal ? panel.categories.y : panel.categories.x;
|
|
118
158
|
const category = horizontal ? axes.y : axes.x, value = horizontal ? axes.x : axes.y;
|
|
@@ -211,26 +251,43 @@ export function isResolvedRenderer(renderer) { return RESOLVED_RENDERERS.include
|
|
|
211
251
|
|
|
212
252
|
export function resolveTerminalScene(scene, options = {}) {
|
|
213
253
|
const width = options.width ?? 960, height = options.height ?? 600;
|
|
214
|
-
const layout = deriveFigureLayout(width, height, { panels: scene.panels, layout: scene.layout, theme: scene.theme, spec: scene.spec });
|
|
254
|
+
const layout = options.layout ?? deriveFigureLayout(width, height, { panels: scene.panels, layout: scene.layout, theme: scene.theme, spec: scene.spec });
|
|
215
255
|
const panels = scene.panels.map((panel, index) => {
|
|
216
|
-
|
|
256
|
+
let resolvedLayout = options.refineLayout === false ? preparedPanelLayout(layout.panels[index]) : panelLayout(layout.panels[index], panel);
|
|
217
257
|
let axes = resolveAxes(panel, resolvedLayout), marks, plots = null;
|
|
258
|
+
for (let pass = 0; options.refineLayout !== false && pass < 2; pass += 1) {
|
|
259
|
+
resolvedLayout = refineScientificLayout(resolvedLayout, panel, axes, { measureText: options.measureText, themeMode: scene.theme.mode });
|
|
260
|
+
axes = resolveAxes(panel, resolvedLayout);
|
|
261
|
+
}
|
|
218
262
|
const radius = Math.max(3.2, Math.sqrt(scene.profile.markerSize) * 0.62 * resolvedLayout.scale) * (panel.presentation?.markerScale ?? 1);
|
|
219
|
-
if (panel.renderer === "line") marks = lineGeometry(panel, axes,
|
|
263
|
+
if (panel.renderer === "line") marks = lineGeometry(panel, axes, resolvedLayout.scale);
|
|
220
264
|
else if (panel.renderer === "scatter") marks = scatterGeometry(panel, axes, radius);
|
|
221
265
|
else if (["categorical_bar", "categorical_layered_bar"].includes(panel.renderer)) marks = barGeometry(panel, axes, resolvedLayout);
|
|
222
266
|
else if (panel.renderer === "categorical_matrix") { const matrix = matrixGeometry(panel, resolvedLayout, scene.theme); marks = matrix.marks; axes = matrix.axes; }
|
|
223
267
|
else if (panel.renderer === "temporal_coverage") { const coverage = coverageGeometry(panel, resolvedLayout, radius); marks = coverage.marks; axes = coverage.axes; plots = coverage.plots; }
|
|
224
268
|
else if (["interval_comparison", "strip_summary", "temporal_observations", "paired_points", "reference_improvement"].includes(panel.renderer)) marks = extensionGeometry(panel, axes, resolvedLayout, radius);
|
|
225
|
-
else marks = panel
|
|
269
|
+
else marks = fallbackPointGeometry(panel, axes, radius);
|
|
226
270
|
const evidenceFrame = cloneRect(plots ? resolvedLayout.plot : (axes.plot ?? resolvedLayout.plot));
|
|
227
271
|
return { ...panel, layout: resolvedLayout, axes, plots, evidenceFrame, marks, resolved: isResolvedRenderer(panel.renderer) };
|
|
228
272
|
});
|
|
229
|
-
|
|
273
|
+
const result = { schemaVersion: RESOLVED_SCENE_VERSION, sourceSceneVersion: scene.schemaVersion, width, height, theme: scene.theme, spec: scene.spec, profile: scene.profile, applicationProfile: scene.applicationProfile, view: scene.view, layout, panels, motionPlan: scene.motionPlan };
|
|
274
|
+
if (scene.directLabels && !options.directPass) {
|
|
275
|
+
const plan = planDirectLabels(scene, result, options.measureText);
|
|
276
|
+
if (plan.status === "placed") {
|
|
277
|
+
const baseline = deepFreeze(result), p = panels[0];
|
|
278
|
+
const adjusted = { ...p.layout, plot: plan.plot };
|
|
279
|
+
const proposal = resolveTerminalScene(scene, { ...options, layout: { ...layout, panels: [adjusted] }, refineLayout: false, directPass: true });
|
|
280
|
+
return deepFreeze({ ...proposal, directLabelPlan: plan, fallbackScene: baseline,
|
|
281
|
+
panels: proposal.panels.map(panel => ({ ...panel, directLabelPlan: plan })) });
|
|
282
|
+
}
|
|
283
|
+
result.directLabelPlan = plan;
|
|
284
|
+
}
|
|
285
|
+
return deepFreeze(result);
|
|
230
286
|
}
|
|
231
287
|
|
|
232
288
|
export function resolveSceneFrame(resolvedScene, progress = 1) {
|
|
233
289
|
const p = clamp01(progress);
|
|
290
|
+
if (p < 1 && resolvedScene.fallbackScene) resolvedScene = { ...resolvedScene.fallbackScene, directLabelPlan: { status: "fallback", reason: "unsupported-layout" } };
|
|
234
291
|
return {
|
|
235
292
|
...resolvedScene,
|
|
236
293
|
progress: p,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { resolveTerminalScene } from "./resolved-scene.js";
|
|
2
|
+
|
|
3
|
+
export const RESPONSIVE_HEADER_MAX_WIDTH = 480;
|
|
4
|
+
|
|
5
|
+
const FONT_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
|
|
6
|
+
const finitePositive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
7
|
+
|
|
8
|
+
function metric(measureText, text, fontSize, style) {
|
|
9
|
+
return measureText?.(String(text), fontSize, style) ?? { width: String(text).length * fontSize * 0.602 };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function ellipsis(text, maximumWidth, measure) {
|
|
13
|
+
const source = String(text).trim();
|
|
14
|
+
if (measure(source).width <= maximumWidth) return { lines: [source], complete: true };
|
|
15
|
+
let candidate = source;
|
|
16
|
+
while (candidate && measure(`${candidate}…`).width > maximumWidth) candidate = candidate.slice(0, -1).trimEnd();
|
|
17
|
+
return { lines: [`${candidate}…`], complete: false };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function wrap(text, maximumWidth, maximumLines, measure) {
|
|
21
|
+
const words = String(text).trim().split(/\s+/).filter(Boolean);
|
|
22
|
+
if (!words.length) return { lines: [], complete: true };
|
|
23
|
+
const lines = [];
|
|
24
|
+
let truncated = false;
|
|
25
|
+
let cursor = 0;
|
|
26
|
+
while (cursor < words.length && lines.length < maximumLines) {
|
|
27
|
+
let line = words[cursor++];
|
|
28
|
+
if (measure(line).width > maximumWidth) {
|
|
29
|
+
lines.push(ellipsis(line, maximumWidth, measure).lines[0]);
|
|
30
|
+
truncated = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
while (cursor < words.length && measure(`${line} ${words[cursor]}`).width <= maximumWidth) line += ` ${words[cursor++]}`;
|
|
34
|
+
lines.push(line);
|
|
35
|
+
}
|
|
36
|
+
const complete = cursor === words.length && !truncated;
|
|
37
|
+
if (cursor < words.length) lines[lines.length - 1] = ellipsis(`${lines.at(-1)} ${words.slice(cursor).join(" ")}`, maximumWidth, measure).lines[0];
|
|
38
|
+
return { lines, complete };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function shiftRect(value, delta) {
|
|
42
|
+
if (!value) return value;
|
|
43
|
+
return { ...value, top: value.top + delta, bottom: value.bottom + delta };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function shiftLayout(source, height, delta) {
|
|
47
|
+
const text = { ...(source.text ?? {}) };
|
|
48
|
+
for (const key of ["xLabelY", "xLabelBaselineY", "xTickY", "xTickBaselineY"]) if (typeof text[key] === "number") text[key] += delta;
|
|
49
|
+
const annotationBounds = source.annotationBounds ? {
|
|
50
|
+
...source.annotationBounds,
|
|
51
|
+
plot: shiftRect(source.annotationBounds.plot, delta),
|
|
52
|
+
xTicks: shiftRect(source.annotationBounds.xTicks, delta),
|
|
53
|
+
xTitle: shiftRect(source.annotationBounds.xTitle, delta),
|
|
54
|
+
provenance: shiftRect(source.annotationBounds.provenance, delta),
|
|
55
|
+
yTicks: shiftRect(source.annotationBounds.yTicks, delta),
|
|
56
|
+
yTitle: shiftRect(source.annotationBounds.yTitle, delta),
|
|
57
|
+
} : null;
|
|
58
|
+
return {
|
|
59
|
+
...source,
|
|
60
|
+
height,
|
|
61
|
+
rect: { ...source.rect, bottom: source.rect.bottom + delta },
|
|
62
|
+
plot: shiftRect(source.plot, delta),
|
|
63
|
+
text,
|
|
64
|
+
provenance: source.provenance ? { ...source.provenance, y: source.provenance.y + delta } : null,
|
|
65
|
+
legend: source.legend ? { ...source.legend, top: source.legend.top + delta, bottom: source.legend.bottom + delta } : null,
|
|
66
|
+
annotationBounds,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function headerPlan(panel, measureText, availableExtra, negotiated) {
|
|
71
|
+
const { layout, spec } = panel;
|
|
72
|
+
const maximumWidth = Math.max(1, layout.plot.right - layout.plot.left);
|
|
73
|
+
const titleMeasure = (text) => metric(measureText, text, layout.font.title, "500");
|
|
74
|
+
const subtitleMeasure = (text) => metric(measureText, text, layout.font.subtitle, "italic");
|
|
75
|
+
const desiredTitle = wrap(spec.title || panel.renderer, maximumWidth, 2, titleMeasure);
|
|
76
|
+
const desiredSubtitle = spec.subtitle ? wrap(spec.subtitle, maximumWidth, 2, subtitleMeasure) : { lines: [], complete: true };
|
|
77
|
+
const titleLineHeight = layout.font.title * 1.22;
|
|
78
|
+
const subtitleLineHeight = layout.font.subtitle * 1.35;
|
|
79
|
+
const desiredExtra = (desiredTitle.lines.length - 1) * titleLineHeight + Math.max(0, desiredSubtitle.lines.length - 1) * subtitleLineHeight;
|
|
80
|
+
const titleBaseline = layout.text?.titleY ?? Math.max(layout.font.title + 8, layout.plot.top * 0.52);
|
|
81
|
+
const baseSubtitleBaseline = layout.text?.subtitleY ?? Math.max(layout.font.title + layout.font.subtitle + 14, layout.plot.top * 0.73);
|
|
82
|
+
let title = desiredTitle;
|
|
83
|
+
let subtitle = desiredSubtitle;
|
|
84
|
+
let policy = negotiated && availableExtra + 0.01 >= desiredExtra ? "B" : "C";
|
|
85
|
+
if (policy === "C") {
|
|
86
|
+
subtitle = spec.subtitle ? ellipsis(spec.subtitle, maximumWidth, subtitleMeasure) : { lines: [], complete: true };
|
|
87
|
+
if (title.lines.length > 1) {
|
|
88
|
+
const subtitleBaseline = baseSubtitleBaseline + titleLineHeight;
|
|
89
|
+
const subtitleDescent = layout.font.subtitle * 0.22;
|
|
90
|
+
if (subtitle.lines.length && subtitleBaseline + subtitleDescent > layout.plot.top + availableExtra) title = ellipsis(spec.title || panel.renderer, maximumWidth, titleMeasure);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const subtitleBaseline = baseSubtitleBaseline + Math.max(0, title.lines.length - 1) * titleLineHeight;
|
|
94
|
+
return {
|
|
95
|
+
policy,
|
|
96
|
+
desiredExtra,
|
|
97
|
+
title: { ...title, lineHeight: titleLineHeight, baselines: title.lines.map((_, index) => titleBaseline + index * titleLineHeight) },
|
|
98
|
+
subtitle: { ...subtitle, lineHeight: subtitleLineHeight, baselines: subtitle.lines.map((_, index) => subtitleBaseline + index * subtitleLineHeight) },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function fixedResponsiveHeader(panel, measureText) {
|
|
103
|
+
return headerPlan(panel, measureText, 0, false);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Internal live-Canvas layout policy. Fixed SVG serialization reuses only the C text plan. */
|
|
107
|
+
export function resolveResponsiveCanvasScene(scene, options = {}) {
|
|
108
|
+
const width = options.width;
|
|
109
|
+
const height = options.height;
|
|
110
|
+
const baselineHeight = finitePositive(options.baselineHeight) ? options.baselineHeight : null;
|
|
111
|
+
const compactSingle = width <= RESPONSIVE_HEADER_MAX_WIDTH && scene.panels.length === 1 && scene.theme.mode !== "paper";
|
|
112
|
+
if (!compactSingle) {
|
|
113
|
+
const layoutHeight = baselineHeight != null && height >= baselineHeight ? baselineHeight : height;
|
|
114
|
+
const baseline = resolveTerminalScene(scene, { width, height: layoutHeight, measureText: options.measureText });
|
|
115
|
+
const rootLayout = layoutHeight === height ? null : {
|
|
116
|
+
...baseline.layout,
|
|
117
|
+
height,
|
|
118
|
+
panels: baseline.panels.map((panel) => panel.layout),
|
|
119
|
+
};
|
|
120
|
+
const resolved = rootLayout
|
|
121
|
+
? resolveTerminalScene(scene, { width, height, measureText: options.measureText, layout: rootLayout, refineLayout: false })
|
|
122
|
+
: baseline;
|
|
123
|
+
return { resolved, preferredHeight: baselineHeight, baselineHeight, header: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const layoutHeight = baselineHeight ?? height;
|
|
127
|
+
const baseline = resolveTerminalScene(scene, { width, height: layoutHeight, measureText: options.measureText });
|
|
128
|
+
const availableExtra = baselineHeight ? Math.max(0, height - baselineHeight) : 0;
|
|
129
|
+
const desiredPlan = headerPlan(baseline.panels[0], options.measureText, availableExtra, baselineHeight != null);
|
|
130
|
+
const preferredHeight = baselineHeight == null ? null : baselineHeight + Math.ceil(desiredPlan.desiredExtra);
|
|
131
|
+
// A host may clamp below its own intrinsic baseline. In that degraded case the
|
|
132
|
+
// fixed-height C policy must resolve against the real canvas, rather than let
|
|
133
|
+
// baseline geometry extend beyond the available field.
|
|
134
|
+
const belowBaseline = baselineHeight != null && height < baselineHeight;
|
|
135
|
+
const renderedBase = belowBaseline
|
|
136
|
+
? resolveTerminalScene(scene, { width, height, measureText: options.measureText })
|
|
137
|
+
: baseline;
|
|
138
|
+
const delta = baselineHeight == null || belowBaseline ? 0 : Math.max(0, height - baselineHeight);
|
|
139
|
+
const panelLayout = shiftLayout(renderedBase.panels[0].layout, height, delta);
|
|
140
|
+
const plan = headerPlan({ ...renderedBase.panels[0], layout: panelLayout }, options.measureText, delta, baselineHeight != null && !belowBaseline);
|
|
141
|
+
panelLayout.headerText = Object.freeze({
|
|
142
|
+
policy: plan.policy,
|
|
143
|
+
baselineHeight,
|
|
144
|
+
preferredHeight,
|
|
145
|
+
appliedExtra: delta,
|
|
146
|
+
desiredExtra: desiredPlan.desiredExtra,
|
|
147
|
+
title: plan.title,
|
|
148
|
+
subtitle: plan.subtitle,
|
|
149
|
+
});
|
|
150
|
+
const rootLayout = {
|
|
151
|
+
...renderedBase.layout,
|
|
152
|
+
height,
|
|
153
|
+
plot: shiftRect(renderedBase.layout.plot, delta),
|
|
154
|
+
provenance: renderedBase.layout.provenance ? { ...renderedBase.layout.provenance, y: renderedBase.layout.provenance.y + delta } : null,
|
|
155
|
+
panels: [panelLayout],
|
|
156
|
+
};
|
|
157
|
+
const resolved = resolveTerminalScene(scene, { width, height, measureText: options.measureText, layout: rootLayout, refineLayout: false });
|
|
158
|
+
return { resolved, preferredHeight, baselineHeight, header: resolved.panels[0].layout.headerText };
|
|
159
|
+
}
|
package/src/schema.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { linearFit } from "./statistics.js";
|
|
2
|
+
|
|
1
3
|
export const SCHEMA_VERSION = "0.4";
|
|
2
4
|
export const LEGACY_SCHEMA_VERSION = "0.3";
|
|
3
5
|
export const RENDERER_API_VERSION = "1";
|
|
@@ -29,6 +31,14 @@ export function requiredString(value, path) {
|
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
const CANONICAL_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
35
|
+
|
|
36
|
+
export function requiredColor(value, path) {
|
|
37
|
+
if (typeof value !== "string" || !CANONICAL_COLOR.test(value)) {
|
|
38
|
+
throw new FiguresteadConfigError("must be a canonical #RRGGBB color", path);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
export function numberArray(value, path, { allowEmpty = false } = {}) {
|
|
33
43
|
if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) {
|
|
34
44
|
throw new FiguresteadConfigError("must be a non-empty numeric array", path);
|
|
@@ -62,21 +72,27 @@ function timelineWindow(value, path) {
|
|
|
62
72
|
}
|
|
63
73
|
}
|
|
64
74
|
|
|
65
|
-
function
|
|
66
|
-
requiredObject(theme,
|
|
67
|
-
["
|
|
68
|
-
.forEach((key) =>
|
|
75
|
+
export function validateThemeColors(theme, path = "config.theme") {
|
|
76
|
+
requiredObject(theme, path);
|
|
77
|
+
["field", "panel", "grid", "spine", "label", "secondary", "faint", "primary", "summaryCore", "warm"]
|
|
78
|
+
.forEach((key) => requiredColor(theme[key], `${path}.${key}`));
|
|
69
79
|
if (!Array.isArray(theme.series) || !theme.series.length) {
|
|
70
|
-
throw new FiguresteadConfigError("must be a non-empty color array",
|
|
80
|
+
throw new FiguresteadConfigError("must be a non-empty color array", `${path}.series`);
|
|
71
81
|
}
|
|
72
|
-
theme.series.forEach((color, index) =>
|
|
73
|
-
for (const key of ["primaryEdge", "summaryEdge"]) if (theme[key] != null)
|
|
82
|
+
theme.series.forEach((color, index) => requiredColor(color, `${path}.series[${index}]`));
|
|
83
|
+
for (const key of ["primaryEdge", "summaryEdge"]) if (theme[key] != null) requiredColor(theme[key], `${path}.${key}`);
|
|
74
84
|
if (theme.seriesEdges != null) {
|
|
75
|
-
if (!Array.isArray(theme.seriesEdges) || theme.seriesEdges.length !== theme.series.length) throw new FiguresteadConfigError(`must contain exactly ${theme.series.length} colors`,
|
|
76
|
-
theme.seriesEdges.forEach((color, index) =>
|
|
85
|
+
if (!Array.isArray(theme.seriesEdges) || theme.seriesEdges.length !== theme.series.length) throw new FiguresteadConfigError(`must contain exactly ${theme.series.length} colors`, `${path}.seriesEdges`);
|
|
86
|
+
theme.seriesEdges.forEach((color, index) => requiredColor(color, `${path}.seriesEdges[${index}]`));
|
|
77
87
|
}
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
function validateTheme(theme) {
|
|
91
|
+
requiredObject(theme, "config.theme");
|
|
92
|
+
["key", "name"].forEach((key) => requiredString(theme[key], `config.theme.${key}`));
|
|
93
|
+
validateThemeColors(theme);
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
function validateProfile(profile) {
|
|
81
97
|
requiredObject(profile, "config.profile");
|
|
82
98
|
["key", "name", "marker"].forEach((key) => requiredString(profile[key], `config.profile.${key}`));
|
|
@@ -170,6 +186,13 @@ export function normalizeScatterData(data, basePath = "config.data") {
|
|
|
170
186
|
if (summary !== null && summary !== "linear_fit") {
|
|
171
187
|
throw new FiguresteadConfigError("must be null or 'linear_fit'", `${basePath}.summary`);
|
|
172
188
|
}
|
|
189
|
+
if (summary === "linear_fit") {
|
|
190
|
+
try {
|
|
191
|
+
linearFit(x, y);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw new FiguresteadConfigError(error.message, `${basePath}.summary`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
173
196
|
return {
|
|
174
197
|
x, y, series: series.map(String), seriesLabels: normalizeSeriesLabels(keys, data.seriesLabels), summary,
|
|
175
198
|
xDomain: domain(data.xDomain, `${basePath}.xDomain`), yDomain: domain(data.yDomain, `${basePath}.yDomain`),
|
|
@@ -292,9 +315,10 @@ function normalizeStyle(value) {
|
|
|
292
315
|
glyphs.forEach((glyph, index) => { if (!MARKER_TYPES.has(glyph)) throw new FiguresteadConfigError("is not a supported glyph", `config.style.glyphs[${index}]`); });
|
|
293
316
|
const lineStyles = style.lineStyles ?? ["solid", "dash", "dot", "dash-dot"];
|
|
294
317
|
if (!Array.isArray(lineStyles) || !lineStyles.length || lineStyles.some((item) => !["solid", "dash", "dot", "dash-dot"].includes(item))) throw new FiguresteadConfigError("must contain supported line styles", "config.style.lineStyles");
|
|
318
|
+
if (style.directLabels != null && typeof style.directLabels !== "boolean") throw new FiguresteadConfigError("must be boolean", "config.style.directLabels");
|
|
295
319
|
const series = style.series ?? {};
|
|
296
320
|
requiredObject(series, "config.style.series");
|
|
297
|
-
return { glyphs: [...glyphs], lineStyles: [...lineStyles], series: cloneValue(series) };
|
|
321
|
+
return { glyphs: [...glyphs], lineStyles: [...lineStyles], series: cloneValue(series), ...(style.directLabels ? { directLabels: true } : {}) };
|
|
298
322
|
}
|
|
299
323
|
|
|
300
324
|
function normalizePanel(panel, index, figureSpec, registry) {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value));
|
|
2
|
+
|
|
3
|
+
const DEFAULT_FONT_WIDTH = 0.602;
|
|
4
|
+
|
|
5
|
+
function fallbackMetric(text, fontSize) {
|
|
6
|
+
return {
|
|
7
|
+
width: String(text).length * fontSize * DEFAULT_FONT_WIDTH,
|
|
8
|
+
ascent: fontSize * 0.78,
|
|
9
|
+
descent: fontSize * 0.22,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizedMetric(measureText, text, fontSize) {
|
|
14
|
+
const measured = measureText?.(String(text), fontSize) ?? fallbackMetric(text, fontSize);
|
|
15
|
+
const ascent = measured.ascent ?? measured.actualBoundingBoxAscent ?? fontSize * 0.78;
|
|
16
|
+
const descent = measured.descent ?? measured.actualBoundingBoxDescent ?? fontSize * 0.22;
|
|
17
|
+
return { width: measured.width, ascent, descent, height: ascent + descent };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function annotationGaps(scale) {
|
|
21
|
+
return {
|
|
22
|
+
outer: clamp(7 * scale, 4, 10),
|
|
23
|
+
plotTick: clamp(7 * scale, 4, 9),
|
|
24
|
+
tickTitle: clamp(7 * scale, 4, 9),
|
|
25
|
+
titleFooter: clamp(8 * scale, 5, 10),
|
|
26
|
+
yTitleTick: clamp(8 * scale, 5, 10),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function xTickDepth(axes, plot, metric) {
|
|
31
|
+
const slot = axes.x.step?.() ?? Math.max(40, (plot.right - plot.left) / Math.max(1, axes.xTicks.length));
|
|
32
|
+
const metrics = axes.xTicks.map((tick) => metric(tick.label));
|
|
33
|
+
const rotate = axes.xType === "band" && metrics.some((value) => value.width > slot * 0.92);
|
|
34
|
+
const depth = metrics.length
|
|
35
|
+
? Math.max(...metrics.map((value) => rotate ? (value.width + value.height) / Math.sqrt(2) : value.height))
|
|
36
|
+
: 0;
|
|
37
|
+
return { rotate, depth, metrics };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Internal annotation layout pass. It deliberately consumes resolved tick strings
|
|
42
|
+
* rather than guessing from canvas size. No contract or renderer API is exposed.
|
|
43
|
+
*/
|
|
44
|
+
export function refineScientificLayout(source, panel, axes, options = {}) {
|
|
45
|
+
const layout = {
|
|
46
|
+
...source,
|
|
47
|
+
rect: { ...source.rect }, plot: { ...source.plot }, font: { ...source.font },
|
|
48
|
+
text: { ...(source.text ?? {}) }, provenance: source.provenance ? { ...source.provenance } : null,
|
|
49
|
+
legend: source.legend ? { ...source.legend } : null,
|
|
50
|
+
};
|
|
51
|
+
const { rect, font, scale } = layout, gaps = annotationGaps(scale);
|
|
52
|
+
const axisMetric = (text) => normalizedMetric(options.measureText, text, font.axis);
|
|
53
|
+
const signatureMetric = (text) => normalizedMetric(options.measureText, text, font.signature);
|
|
54
|
+
const xTitleMetric = panel.spec.xLabel ? axisMetric(panel.spec.xLabel) : null;
|
|
55
|
+
const yTitleMetric = panel.spec.yLabel ? axisMetric(panel.spec.yLabel) : null;
|
|
56
|
+
const footerMetric = panel.spec.signature ? signatureMetric(panel.spec.signature) : null;
|
|
57
|
+
const hasLocalFooter = Boolean(
|
|
58
|
+
footerMetric && options.themeMode !== "paper" && (layout.panelIndex ?? 0) === 0
|
|
59
|
+
&& (!layout.provenance || layout.provenance.y <= rect.bottom)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const provisionalPlot = { ...layout.plot };
|
|
63
|
+
const xTick = xTickDepth(axes, provisionalPlot, axisMetric);
|
|
64
|
+
const yTickMetrics = axes.yTicks.map((tick) => axisMetric(tick.label));
|
|
65
|
+
const yTickWidth = yTickMetrics.length ? Math.max(...yTickMetrics.map((value) => value.width)) : 0;
|
|
66
|
+
|
|
67
|
+
let cursorBottom = rect.bottom - gaps.outer;
|
|
68
|
+
let provenance = null;
|
|
69
|
+
if (hasLocalFooter) {
|
|
70
|
+
const baseline = cursorBottom - footerMetric.descent;
|
|
71
|
+
provenance = {
|
|
72
|
+
left: 0, right: rect.right - gaps.outer, y: baseline,
|
|
73
|
+
bounds: { left: 0, right: 0, top: baseline - footerMetric.ascent, bottom: baseline + footerMetric.descent },
|
|
74
|
+
};
|
|
75
|
+
cursorBottom = provenance.bounds.top - gaps.titleFooter;
|
|
76
|
+
}
|
|
77
|
+
let xTitle = null;
|
|
78
|
+
if (xTitleMetric) {
|
|
79
|
+
xTitle = { left: 0, right: 0, top: cursorBottom - xTitleMetric.height, bottom: cursorBottom };
|
|
80
|
+
cursorBottom = xTitle.top - gaps.tickTitle;
|
|
81
|
+
}
|
|
82
|
+
const xTicks = {
|
|
83
|
+
left: 0, right: 0,
|
|
84
|
+
top: cursorBottom - xTick.depth,
|
|
85
|
+
bottom: cursorBottom,
|
|
86
|
+
};
|
|
87
|
+
const requestedPlotBottom = xTicks.top - gaps.plotTick;
|
|
88
|
+
const availablePlotHeight = Math.max(0, requestedPlotBottom - layout.plot.top);
|
|
89
|
+
const minimumPlotHeight = Math.min(120, availablePlotHeight);
|
|
90
|
+
layout.plot.bottom = Math.max(layout.plot.top + minimumPlotHeight, requestedPlotBottom);
|
|
91
|
+
|
|
92
|
+
const yTitleThickness = yTitleMetric?.height ?? 0;
|
|
93
|
+
const requestedPlotLeft = rect.left + gaps.outer + yTitleThickness
|
|
94
|
+
+ (yTitleMetric && yTickWidth ? gaps.yTitleTick : 0) + yTickWidth + gaps.plotTick;
|
|
95
|
+
layout.plot.left = Math.min(layout.plot.right - 160, requestedPlotLeft);
|
|
96
|
+
|
|
97
|
+
const xCenter = (layout.plot.left + layout.plot.right) / 2;
|
|
98
|
+
xTicks.left = layout.plot.left; xTicks.right = layout.plot.right;
|
|
99
|
+
if (xTitle) {
|
|
100
|
+
xTitle.left = xCenter - xTitleMetric.width / 2;
|
|
101
|
+
xTitle.right = xCenter + xTitleMetric.width / 2;
|
|
102
|
+
layout.text.xLabelY = xTitle.bottom;
|
|
103
|
+
layout.text.xLabelBaselineY = xTitle.top + xTitleMetric.ascent;
|
|
104
|
+
}
|
|
105
|
+
layout.text.xTickY = xTicks.top;
|
|
106
|
+
layout.text.xTickBaselineY = xTicks.top + (xTick.metrics[0]?.ascent ?? font.axis * 0.78);
|
|
107
|
+
layout.text.rotateX = xTick.rotate;
|
|
108
|
+
|
|
109
|
+
const yTicks = {
|
|
110
|
+
left: layout.plot.left - gaps.plotTick - yTickWidth,
|
|
111
|
+
right: layout.plot.left - gaps.plotTick,
|
|
112
|
+
top: layout.plot.top, bottom: layout.plot.bottom,
|
|
113
|
+
};
|
|
114
|
+
let yTitle = null;
|
|
115
|
+
if (yTitleMetric) {
|
|
116
|
+
const right = yTicks.left - (yTickWidth ? gaps.yTitleTick : 0);
|
|
117
|
+
yTitle = { left: right - yTitleMetric.height, right, top: layout.plot.top, bottom: layout.plot.bottom };
|
|
118
|
+
layout.text.yLabelX = yTitle.left;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (provenance) {
|
|
122
|
+
provenance.left = layout.plot.left;
|
|
123
|
+
provenance.bounds.left = provenance.left;
|
|
124
|
+
provenance.bounds.right = provenance.left + footerMetric.width;
|
|
125
|
+
layout.provenance = { left: provenance.left, right: provenance.right, y: provenance.y };
|
|
126
|
+
} else if (layout.provenance?.y <= rect.bottom) {
|
|
127
|
+
layout.provenance = null;
|
|
128
|
+
}
|
|
129
|
+
if (layout.legend) {
|
|
130
|
+
layout.legend = layout.legend.outside
|
|
131
|
+
? { ...layout.legend, top: layout.plot.top, bottom: layout.plot.bottom }
|
|
132
|
+
: { ...layout.legend, left: layout.plot.left, right: layout.plot.right, top: layout.plot.top, bottom: layout.plot.bottom };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
layout.annotationBounds = {
|
|
136
|
+
plot: { ...layout.plot }, xTicks, xTitle, provenance: provenance?.bounds ?? null, yTicks, yTitle,
|
|
137
|
+
gaps: {
|
|
138
|
+
plotToXTicks: xTicks.top - layout.plot.bottom,
|
|
139
|
+
xTicksToTitle: xTitle ? xTitle.top - xTicks.bottom : null,
|
|
140
|
+
xTitleToProvenance: xTitle && provenance ? provenance.bounds.top - xTitle.bottom : null,
|
|
141
|
+
xTicksToProvenance: !xTitle && provenance ? provenance.bounds.top - xTicks.bottom : null,
|
|
142
|
+
yTitleToTicks: yTitle ? yTicks.left - yTitle.right : null,
|
|
143
|
+
yTicksToPlot: layout.plot.left - yTicks.right,
|
|
144
|
+
},
|
|
145
|
+
rotateX: xTick.rotate,
|
|
146
|
+
};
|
|
147
|
+
return layout;
|
|
148
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { resolveContrastColor } from "./color-space.js";
|
|
2
|
+
|
|
3
|
+
export const SCREEN_LEGIBILITY_VERSION = "figurestead.screen-legibility/1";
|
|
4
|
+
|
|
5
|
+
// Figurestead project floors for browser-rendered evidence. These are not
|
|
6
|
+
// assertions of conformance with an external accessibility standard.
|
|
7
|
+
export const SCREEN_PROJECT_LEGIBILITY_FLOORS = Object.freeze({
|
|
8
|
+
provenanceContrast: 3.4,
|
|
9
|
+
compactProvenancePx: 9,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function resolveScreenTheme(source) {
|
|
13
|
+
const resolution = resolveContrastColor(
|
|
14
|
+
source.faint,
|
|
15
|
+
source.field,
|
|
16
|
+
SCREEN_PROJECT_LEGIBILITY_FLOORS.provenanceContrast,
|
|
17
|
+
);
|
|
18
|
+
const theme = { ...source, faint: resolution.color };
|
|
19
|
+
return Object.freeze({
|
|
20
|
+
theme: Object.freeze(theme),
|
|
21
|
+
report: Object.freeze({
|
|
22
|
+
schemaVersion: SCREEN_LEGIBILITY_VERSION,
|
|
23
|
+
policy: "Figurestead screen/project legibility floor",
|
|
24
|
+
token: "faint",
|
|
25
|
+
surface: "field",
|
|
26
|
+
minimum: SCREEN_PROJECT_LEGIBILITY_FLOORS.provenanceContrast,
|
|
27
|
+
resolution: Object.freeze({ ...resolution }),
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function linearFit(x, y) {
|
|
2
|
+
if (!Array.isArray(x) || !Array.isArray(y) || x.length !== y.length) {
|
|
3
|
+
throw new TypeError("linear_fit requires x and y arrays of equal length");
|
|
4
|
+
}
|
|
5
|
+
if (x.length < 2) throw new TypeError("linear_fit requires at least two finite observations");
|
|
6
|
+
if (!x.every(Number.isFinite) || !y.every(Number.isFinite)) {
|
|
7
|
+
throw new TypeError("linear_fit requires finite x and y values");
|
|
8
|
+
}
|
|
9
|
+
if (new Set(x).size < 2) throw new TypeError("linear_fit requires at least two distinct finite x values");
|
|
10
|
+
|
|
11
|
+
const meanX = x.reduce((sum, value) => sum + value, 0) / x.length;
|
|
12
|
+
const meanY = y.reduce((sum, value) => sum + value, 0) / y.length;
|
|
13
|
+
let numerator = 0;
|
|
14
|
+
let denominator = 0;
|
|
15
|
+
for (let index = 0; index < x.length; index += 1) {
|
|
16
|
+
const centeredX = x[index] - meanX;
|
|
17
|
+
numerator += centeredX * (y[index] - meanY);
|
|
18
|
+
denominator += centeredX * centeredX;
|
|
19
|
+
}
|
|
20
|
+
return { slope: numerator / denominator, intercept: meanY - (numerator / denominator) * meanX };
|
|
21
|
+
}
|