@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.
@@ -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, radius) {
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") return { ...mark, geometry: pointGeometry(mark, axes, radius) };
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
- const resolvedLayout = panelLayout(layout.panels[index], panel);
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, radius);
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.marks.map((mark) => ({ ...mark, geometry: null }));
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
- return deepFreeze({ schemaVersion: RESOLVED_SCENE_VERSION, sourceSceneVersion: scene.schemaVersion, width, height, theme: scene.theme, spec: scene.spec, profile: scene.profile, applicationProfile: scene.applicationProfile, view: scene.view, layout, panels, motionPlan: scene.motionPlan });
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 validateTheme(theme) {
66
- requiredObject(theme, "config.theme");
67
- ["key", "name", "field", "panel", "grid", "spine", "label", "secondary", "faint", "primary", "summaryCore", "warm"]
68
- .forEach((key) => requiredString(theme[key], `config.theme.${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", "config.theme.series");
80
+ throw new FiguresteadConfigError("must be a non-empty color array", `${path}.series`);
71
81
  }
72
- theme.series.forEach((color, index) => requiredString(color, `config.theme.series[${index}]`));
73
- for (const key of ["primaryEdge", "summaryEdge"]) if (theme[key] != null) requiredString(theme[key], `config.theme.${key}`);
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`, "config.theme.seriesEdges");
76
- theme.seriesEdges.forEach((color, index) => requiredString(color, `config.theme.seriesEdges[${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
+ }