@figurestead/web 0.9.0-alpha.1 → 0.9.0-alpha.2

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.
@@ -5,34 +5,70 @@ import { prepareAtmosphere, drawAtmosphere } from "./atmosphere.js";
5
5
  import { drawBackground, drawFigureHeader } from "./marks.js";
6
6
  import { CORE_REGISTRY } from "./core-renderers.js";
7
7
  import { AnimationClock } from "./clock.js";
8
- import { createAccessibilityCompanion } from "./accessibility.js";
8
+ import { createAccessibilityCompanion, prepareAccessibilityCompanion } from "./accessibility.js";
9
9
  import { drawPanelSurface, drawPresentationAnnotations } from "./presentation.js";
10
10
  import { compileFigureModel } from "./terminal-scene.js";
11
- import { isResolvedRenderer, resolveSceneFrame, resolveTerminalScene } from "./resolved-scene.js";
11
+ import { isResolvedRenderer, resolveSceneFrame } from "./resolved-scene.js";
12
12
  import { drawResolvedPanel } from "./canvas-scene.js";
13
13
  import { composeResolvedScene } from "./composition.js";
14
+ import { resolveResponsiveCanvasScene } from "./responsive-header.js";
15
+ import { createHeightNegotiator, validateHeightNegotiation } from "./height-negotiation.js";
14
16
 
15
17
  export function createFigurestead(canvas, input, options = {}) {
16
18
  if (!(canvas instanceof HTMLCanvasElement)) throw new TypeError("createFigurestead requires an HTMLCanvasElement");
17
19
  const registry = options.registry ?? CORE_REGISTRY;
18
20
  if (registry.apiVersion !== "1") throw new TypeError("Figurestead requires renderer registry API 1");
21
+ const heightNegotiation = validateHeightNegotiation(options.heightNegotiation);
19
22
  let contract = input, scene = null, preparedPanels = [], domains = [], atmosphere, surface, resolvedScene = null, composedScene = null, clock = null, destroyed = false;
20
- let reducedOverride = options.reducedMotion ?? null, companion = null;
23
+ let reducedOverride = options.reducedMotion ?? null, companion = null, contractRevision = 0;
24
+ const heightNegotiator = createHeightNegotiator(canvas, heightNegotiation, options.onError);
21
25
  const media = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)");
22
26
  const isReduced = () => reducedOverride == null ? Boolean(media?.matches) : Boolean(reducedOverride);
23
27
 
24
- const prepare = () => {
25
- const model = compileFigureModel(contract, { registry });
26
- contract = model.contract; scene = model.scene; preparedPanels = model.preparedPanels; domains = model.domains;
27
- atmosphere = contract.view.ambient === "matrix" ? prepareAtmosphere(contract.motion) : [];
28
+ const prepareModel = (candidate) => {
29
+ const model = compileFigureModel(candidate, { registry });
30
+ return { ...model, atmosphere: model.contract.view.ambient === "matrix" ? prepareAtmosphere(model.contract.motion) : [] };
28
31
  };
29
- const layoutFactory = (width, height) => deriveFigureLayout(width, height, contract);
32
+ const applyModel = (model) => {
33
+ contract = model.contract; scene = model.scene; preparedPanels = model.preparedPanels; domains = model.domains; atmosphere = model.atmosphere;
34
+ };
35
+ const layoutFactory = (width, height, candidate = contract) => deriveFigureLayout(width, height, candidate);
36
+ const measuredText = (text, fontSize, style = "normal") => {
37
+ surface.context.save();
38
+ const prefix = style === "italic" ? "italic " : style === "500" ? "500 " : "";
39
+ surface.context.font = `${prefix}${fontSize}px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace`;
40
+ const value = surface.context.measureText(String(text));
41
+ surface.context.restore();
42
+ return { width: value.width, ascent: value.actualBoundingBoxAscent, descent: value.actualBoundingBoxDescent };
43
+ };
44
+ const observedBox = () => {
45
+ const rect = canvas.getBoundingClientRect();
46
+ return { width: rect.width, height: rect.height, visible: rect.width > 0 && rect.height > 0 && canvas.getClientRects().length > 0 };
47
+ };
48
+ const prepareResolution = (candidateScene, width, height, baselineResult) => {
49
+ const responsive = resolveResponsiveCanvasScene(candidateScene, {
50
+ width, height, baselineHeight: baselineResult.value, measureText: measuredText,
51
+ });
52
+ return { ...responsive, composed: composeResolvedScene(responsive.resolved), baselineError: baselineResult.error };
53
+ };
54
+ const commitNegotiation = (prepared, box) => heightNegotiator.commit({
55
+ contractRevision,
56
+ width: box.visible ? box.width : 0,
57
+ baselineHeight: prepared.baselineHeight,
58
+ preferredHeight: prepared.preferredHeight,
59
+ baselineError: prepared.baselineError,
60
+ });
30
61
  const resize = () => {
31
62
  if (destroyed) return;
63
+ const box = observedBox();
32
64
  surface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory });
33
- resolvedScene = resolveTerminalScene(scene, { width: surface.layout.width, height: surface.layout.height });
34
- composedScene = composeResolvedScene(resolvedScene);
35
- if (clock) draw(clock.progress);
65
+ const baseline = box.visible ? heightNegotiator.baseline(box.width, box.height) : { value: null, error: null };
66
+ const prepared = prepareResolution(scene, surface.layout.width, surface.layout.height, baseline);
67
+ resolvedScene = prepared.resolved;
68
+ composedScene = prepared.composed;
69
+ surface = { ...surface, layout: resolvedScene.layout };
70
+ if (clock) clock.render(clock.progress);
71
+ commitNegotiation(prepared, box);
36
72
  };
37
73
  const draw = (progress) => {
38
74
  if (!surface || destroyed) return;
@@ -51,12 +87,17 @@ export function createFigurestead(canvas, input, options = {}) {
51
87
  options.onProgress?.(p);
52
88
  };
53
89
 
54
- prepare(); surface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory });
55
- resolvedScene = resolveTerminalScene(scene, { width: surface.layout.width, height: surface.layout.height });
56
- composedScene = composeResolvedScene(resolvedScene);
57
- clock = new AnimationClock({ durationMs: contract.motion.durationMs, draw, onState: options.onState });
58
- companion = createAccessibilityCompanion(canvas, contract, registry, options.accessibility);
90
+ applyModel(prepareModel(input)); surface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory });
91
+ const initialBox = observedBox();
92
+ const initialBaseline = initialBox.visible ? heightNegotiator.baseline(initialBox.width, initialBox.height) : { value: null, error: null };
93
+ const initialResolution = prepareResolution(scene, surface.layout.width, surface.layout.height, initialBaseline);
94
+ resolvedScene = initialResolution.resolved;
95
+ composedScene = initialResolution.composed;
96
+ surface = { ...surface, layout: resolvedScene.layout };
97
+ clock = new AnimationClock({ durationMs: contract.motion.durationMs, draw, onState: options.onState, onError: options.onError });
98
+ companion = createAccessibilityCompanion(canvas, contract, registry, { ...options.accessibility, composedScene });
59
99
  clock.render(isReduced() ? 1 : 0);
100
+ commitNegotiation(initialResolution, initialBox);
60
101
 
61
102
  let autoplayUsed = false, wasPlayingBeforeHidden = false;
62
103
  const resizeObserver = globalThis.ResizeObserver ? new ResizeObserver(resize) : null; resizeObserver?.observe(canvas);
@@ -65,33 +106,48 @@ export function createFigurestead(canvas, input, options = {}) {
65
106
  }, { threshold: [.35] }) : null;
66
107
  if (options.autoplay !== false) { if (intersectionObserver) intersectionObserver.observe(canvas); else { autoplayUsed = true; isReduced() ? clock.settle() : clock.play(); } }
67
108
  const visibility = () => { if (document.hidden) { wasPlayingBeforeHidden = clock.playing; clock.pause(); } else if (wasPlayingBeforeHidden) { wasPlayingBeforeHidden = false; clock.play(); } };
68
- const mediaChange = () => { if (reducedOverride == null) isReduced() ? clock.settle() : draw(clock.progress); };
109
+ const mediaChange = () => { if (reducedOverride == null) isReduced() ? clock.settle() : clock.render(clock.progress); };
69
110
  document.addEventListener("visibilitychange", visibility); media?.addEventListener?.("change", mediaChange);
70
111
 
71
112
  const replace = (next) => {
72
- clock.pause(); contract = next; prepare(); clock.durationMs = contract.motion.durationMs;
73
- surface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory });
74
- resolvedScene = resolveTerminalScene(scene, { width: surface.layout.width, height: surface.layout.height });
75
- composedScene = composeResolvedScene(resolvedScene);
76
- companion.destroy(); companion = createAccessibilityCompanion(canvas, contract, registry, options.accessibility); clock.settle();
113
+ const nextModel = prepareModel(next);
114
+ const box = observedBox();
115
+ const baseline = box.visible ? heightNegotiator.baseline(box.width, box.height) : { value: null, error: null };
116
+ const nextResolution = prepareResolution(nextModel.scene, surface.layout.width, surface.layout.height, baseline);
117
+ const nextCompanion = prepareAccessibilityCompanion(canvas, nextModel.contract, registry, { ...options.accessibility, composedScene: nextResolution.composed });
118
+ const nextSurface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory: (width, height) => layoutFactory(width, height, nextModel.contract) });
119
+ clock.pause();
120
+ applyModel(nextModel);
121
+ contractRevision += 1;
122
+ resolvedScene = nextResolution.resolved; composedScene = nextResolution.composed;
123
+ surface = { ...nextSurface, layout: resolvedScene.layout };
124
+ clock.durationMs = contract.motion.durationMs; clock.resetFailure();
125
+ nextCompanion.attach(); companion.destroy(); companion = nextCompanion; clock.settle();
126
+ commitNegotiation(nextResolution, box);
77
127
  };
78
128
  return Object.freeze({
79
129
  play() { if (isReduced()) clock.settle(); else clock.play(); }, pause() { clock.pause(); }, replay() { if (isReduced()) clock.settle(); else clock.replay(); },
80
130
  setData(data) { if (contract.panels.length !== 1) throw new TypeError("setData is available only for single-panel figures; use setConfig for multi-panel figures"); const next = cloneValue(contract); next.panels[0].data = cloneValue(data); replace(next); },
81
131
  setConfig(next) { replace(next); },
82
- setReducedMotion(value) { if (value !== null && typeof value !== "boolean") throw new TypeError("reduced motion must be true, false, or null"); reducedOverride = value; isReduced() ? clock.settle() : draw(clock.progress); },
132
+ setReducedMotion(value) { if (value !== null && typeof value !== "boolean") throw new TypeError("reduced motion must be true, false, or null"); reducedOverride = value; isReduced() ? clock.settle() : clock.render(clock.progress); },
83
133
  resize,
84
- destroy() { if (destroyed) return; destroyed = true; clock.destroy(); resizeObserver?.disconnect(); intersectionObserver?.disconnect(); document.removeEventListener("visibilitychange", visibility); media?.removeEventListener?.("change", mediaChange); companion.destroy(); },
85
- getState() { return { progress: clock.progress, playing: clock.playing, reducedMotion: isReduced(), renderers: contract.panels.map((panel) => panel.renderer), sceneVersion: scene.schemaVersion, resolvedSceneVersion: resolvedScene.schemaVersion, composedSceneVersion: composedScene.schemaVersion, profile: contract.view.profile, destroyed }; },
134
+ destroy() { if (destroyed) return; destroyed = true; heightNegotiator.destroy(); clock.destroy(); resizeObserver?.disconnect(); intersectionObserver?.disconnect(); document.removeEventListener("visibilitychange", visibility); media?.removeEventListener?.("change", mediaChange); companion.destroy(); },
135
+ getState() { return { progress: clock.progress, playing: clock.playing, reducedMotion: isReduced(), runtimeFailed: clock.failed, renderers: contract.panels.map((panel) => panel.renderer), sceneVersion: scene.schemaVersion, resolvedSceneVersion: resolvedScene.schemaVersion, composedSceneVersion: composedScene.schemaVersion, profile: contract.view.profile, destroyed }; },
86
136
  getScene() { return scene; },
87
137
  getResolvedScene() { return resolvedScene; },
88
138
  getComposedScene() { return composedScene; },
89
139
  getFinalCoordinates() {
90
- return preparedPanels.map((state, index) => {
91
- if (isResolvedRenderer(state.panel.renderer)) return { panelId: state.panel.id, points: resolvedScene.panels[index].marks.filter((mark) => mark.kind === "point").map((mark) => ({ x: mark.geometry.cx, y: mark.geometry.cy, dataX: mark.x ?? mark.group, dataY: mark.y ?? mark.yCategory })) };
92
- const scales = state.definition.draw(surface.context, { contract: state.contract, prepared: state.prepared, layout: surface.layout.panels[index], domains: domains[index], progress: 1, settled: true, panel: state.panel, figure: contract });
93
- return { panelId: state.panel.id, points: (state.prepared.points ?? []).map((point) => ({ x: scales?.x?.(point.x), y: scales?.y?.(point.y), dataX: point.x, dataY: point.y })) };
94
- });
140
+ return resolvedScene.panels.map((panel) => ({
141
+ panelId: panel.id,
142
+ points: panel.marks
143
+ .filter((mark) => ["point", "renderer-mark"].includes(mark.kind) && Number.isFinite(mark.geometry?.cx) && Number.isFinite(mark.geometry?.cy))
144
+ .map((mark) => ({
145
+ x: mark.geometry.cx,
146
+ y: mark.geometry.cy,
147
+ dataX: mark.x ?? mark.group ?? mark.evidence?.x ?? mark.evidence?.group,
148
+ dataY: mark.y ?? mark.yCategory ?? mark.evidence?.y ?? mark.evidence?.yCategory,
149
+ })),
150
+ }));
95
151
  },
96
152
  });
97
153
  }
@@ -6,7 +6,7 @@ import { compileTerminalScene, evidenceFingerprint } from "./terminal-scene.js";
6
6
  import { auditPhysicalTypography, resolveExportSize } from "./physical-export.js";
7
7
 
8
8
  export const EXPORT_MANIFEST_VERSION = "figurestead.export-manifest/1";
9
- export const FIGURESTEAD_PACKAGE_VERSION = "0.9.0-alpha.1";
9
+ export const FIGURESTEAD_PACKAGE_VERSION = "0.9.0-alpha.2";
10
10
 
11
11
  function sorted(value) {
12
12
  if (Array.isArray(value)) return value.map(sorted);
@@ -151,7 +151,7 @@ export function drawTemporalAxes(context, { contract, layout, scales, plot = lay
151
151
  context.fillStyle = contract.theme.secondary; context.font = `${layout.font.axis}px ${FONT_STACK}`;
152
152
  if (showX) {
153
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));
154
+ scales.xTicks.forEach((value) => context.fillText(formatTimeTick(value, scales.xDomain), scales.x(value), layout.text?.xTickY ?? plot.bottom + 9 * layout.scale));
155
155
  }
156
156
  if (showY) {
157
157
  context.textAlign = "right"; context.textBaseline = "middle";
@@ -1,4 +1,5 @@
1
1
  import { deriveLayout } from "./layout.js";
2
+ import { SCREEN_PROJECT_LEGIBILITY_FLOORS } from "./screen-legibility.js";
2
3
 
3
4
  const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value));
4
5
 
@@ -28,7 +29,7 @@ function deriveMultiPanelLayout(width, height, contract, columns, scale, outer,
28
29
  width, height, scale, rect, panelIndex: index,
29
30
  plot: { left: rect.left + leftPad, right: rect.right - rightPad, top: rect.top + topPad, bottom: rect.bottom - bottomPad },
30
31
  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
+ 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 ? SCREEN_PROJECT_LEGIBILITY_FLOORS.compactProvenancePx : 7, 10) },
32
33
  provenance: provenanceHeight ? { left: outer, right: width - outer, y: height - clamp(8 * scale, 7, 12) } : null,
33
34
  };
34
35
  });
@@ -36,7 +37,7 @@ function deriveMultiPanelLayout(width, height, contract, columns, scale, outer,
36
37
  width, height, scale, panels,
37
38
  plot: { left: outer, right: width - outer, top: contentTop, bottom: contentBottom },
38
39
  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
+ 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 ? SCREEN_PROJECT_LEGIBILITY_FLOORS.compactProvenancePx : 7, 10) },
40
41
  provenance: provenanceHeight ? { left: outer, right: width - outer, y: height - clamp(8 * scale, 7, 12) } : null,
41
42
  };
42
43
  }
@@ -0,0 +1,77 @@
1
+ const finitePositive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
2
+ const normalize = (value) => Math.round(value * 1000) / 1000;
3
+
4
+ export function validateHeightNegotiation(value) {
5
+ if (value == null) return null;
6
+ if (typeof value !== "object" || typeof value.getBaselineHeight !== "function" || typeof value.requestPreferredHeight !== "function") {
7
+ throw new TypeError("heightNegotiation requires getBaselineHeight(context) and requestPreferredHeight(request)");
8
+ }
9
+ return value;
10
+ }
11
+
12
+ export function createHeightNegotiator(canvas, adapter, reportError) {
13
+ let destroyed = false;
14
+ let generation = null;
15
+ let controller = null;
16
+ let serial = 0;
17
+
18
+ const abort = () => {
19
+ controller?.abort();
20
+ controller = null;
21
+ generation = null;
22
+ };
23
+ const safeReport = (error, context) => {
24
+ try { reportError?.(error, Object.freeze({ phase: "height-negotiation", ...context })); } catch { /* Host reporting cannot destabilize the controller. */ }
25
+ };
26
+
27
+ const baseline = (width, currentHeight) => {
28
+ if (!adapter || destroyed || !finitePositive(width) || !finitePositive(currentHeight)) return { value: null, error: null };
29
+ try {
30
+ const value = adapter.getBaselineHeight(Object.freeze({ canvas, width, currentHeight }));
31
+ return { value: finitePositive(value) ? normalize(value) : null, error: null };
32
+ } catch (error) {
33
+ return { value: null, error };
34
+ }
35
+ };
36
+
37
+ const commit = ({ contractRevision, width, baselineHeight, preferredHeight, baselineError = null }) => {
38
+ if (!adapter || destroyed) return;
39
+ if (baselineError) {
40
+ abort();
41
+ safeReport(baselineError, { operation: "baseline", width, baselineHeight: null, preferredHeight: null });
42
+ return;
43
+ }
44
+ if (![width, baselineHeight, preferredHeight].every(finitePositive)) { abort(); return; }
45
+ const key = `${contractRevision}:${normalize(width)}:${normalize(baselineHeight)}`;
46
+ if (key !== generation) {
47
+ abort();
48
+ generation = key;
49
+ controller = new AbortController();
50
+ }
51
+ const requestHeight = normalize(preferredHeight);
52
+ if (controller.requestedHeight === requestHeight) return;
53
+ controller.requestedHeight = requestHeight;
54
+ const requestController = controller;
55
+ const requestSerial = ++serial;
56
+ queueMicrotask(() => {
57
+ if (destroyed || requestController.signal.aborted || requestSerial !== serial || requestController !== controller) return;
58
+ try {
59
+ const returned = adapter.requestPreferredHeight(Object.freeze({
60
+ preferredHeight: requestHeight,
61
+ baselineHeight: normalize(baselineHeight),
62
+ width: normalize(width),
63
+ signal: requestController.signal,
64
+ }));
65
+ // Promise fulfillment is not an acknowledgement. Rejection is observed only
66
+ // to prevent an unhandled host error; it never retries or changes layout state.
67
+ if (returned && typeof returned.then === "function") Promise.resolve(returned).catch((error) => {
68
+ safeReport(error, { operation: "request", width: normalize(width), baselineHeight: normalize(baselineHeight), preferredHeight: requestHeight });
69
+ });
70
+ } catch (error) {
71
+ safeReport(error, { operation: "request", width: normalize(width), baselineHeight: normalize(baselineHeight), preferredHeight: requestHeight });
72
+ }
73
+ });
74
+ };
75
+
76
+ return Object.freeze({ baseline, commit, abort, destroy() { if (destroyed) return; destroyed = true; abort(); serial += 1; } });
77
+ }
package/src/layout.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { SCREEN_PROJECT_LEGIBILITY_FLOORS } from "./screen-legibility.js";
2
+
1
3
  const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value));
2
4
 
3
5
  export const MIN_CANVAS_WIDTH = 320;
@@ -16,11 +18,11 @@ export function deriveLayout(width, height) {
16
18
  scale,
17
19
  plot: { left, right: width - right, top, bottom: height - bottom },
18
20
  font: {
19
- title: clamp(19 * scale, 13, 22),
21
+ title: clamp(19 * scale, 14, 22),
20
22
  subtitle: clamp(12.5 * scale, 9, 14),
21
23
  axis: clamp(12 * scale, narrow ? 10 : 9, 13),
22
24
  legend: clamp(11.5 * scale, narrow ? 9.5 : 8.5, 13),
23
- signature: clamp(10 * scale, narrow ? 8 : 7.5, 11),
25
+ signature: clamp(10 * scale, narrow ? SCREEN_PROJECT_LEGIBILITY_FLOORS.compactProvenancePx : 7.5, 11),
24
26
  },
25
27
  provenance: { left, right: width - right, y: height - clamp(12 * scale, 10, 16) },
26
28
  };
package/src/marks.js CHANGED
@@ -116,9 +116,9 @@ export function drawAxes(context, { config, layout, scales, xTicks, yTicks, xCat
116
116
  context.textAlign = "center";
117
117
  context.textBaseline = "top";
118
118
  if (xCategories) {
119
- xCategories.forEach((label, index) => context.fillText(label, scales.x(index), plot.bottom + 10 * layout.scale));
119
+ xCategories.forEach((label, index) => context.fillText(label, scales.x(index), layout.text?.xTickY ?? plot.bottom + 10 * layout.scale));
120
120
  } else {
121
- xTicks.forEach((value) => context.fillText(formatTick(value), scales.x(value), plot.bottom + 10 * layout.scale));
121
+ xTicks.forEach((value) => context.fillText(formatTick(value), scales.x(value), layout.text?.xTickY ?? plot.bottom + 10 * layout.scale));
122
122
  }
123
123
 
124
124
  if (spec.xLabel) {
@@ -1,4 +1,5 @@
1
1
  import { colorContrast, hexToOklab, resolveContrastColor } from "./color-space.js";
2
+ import { resolveScreenTheme } from "./screen-legibility.js";
2
3
 
3
4
  export const PAPER_PROFILE_VERSION = "figurestead.paper-profile/1";
4
5
  export const PAPER_FLOORS = Object.freeze({ text: 4.5, evidence: 3, thinEvidenceTarget: 4, pairwiseLightness: 0.1, identityWarning: 0.12 });
@@ -72,7 +73,10 @@ export function resolvePaperTheme(source) {
72
73
  export function themeResolutionForProfile(source, profile = "atlas") {
73
74
  const key = typeof profile === "string" ? profile : profile?.key;
74
75
  const clone = (value) => JSON.parse(JSON.stringify(value));
75
- if (key !== "paper") return Object.freeze({ theme: clone(source), report: null });
76
+ if (key !== "paper") {
77
+ if (source.mode === "paper") return Object.freeze({ theme: clone(source), report: null });
78
+ return resolveScreenTheme(clone(source));
79
+ }
76
80
  if (source.mode === "paper") return Object.freeze({ theme: clone(source), report: auditPaperTheme(source) });
77
81
  return resolvePaperTheme(source);
78
82
  }
@@ -1,6 +1,7 @@
1
1
  import { arrival, numericScales } from "./shared.js";
2
2
  import { compileProgress, drawAxes, drawScopePoint, drawText, pointMotionState } from "../marks.js";
3
3
  import { styleForSeries } from "../series-style.js";
4
+ import { linearFit } from "../statistics.js";
4
5
 
5
6
  export function prepareScatter(contract) {
6
7
  const keys = [...new Set(contract.data.series)];
@@ -13,8 +14,8 @@ export function drawScatter(context, env) {
13
14
  drawAxes(context, { config: contract, layout, scales, xTicks: scales.xTicks, yTicks: scales.yTicks });
14
15
  prepared.points.forEach((point) => { const style = styleForSeries(env, point.series, point.colorIndex), state = pointMotionState(point, progress, scales, layout.plot); if (contract.view?.motion === "semantic") { state.x = state.finalX; state.y = state.finalY; } drawScopePoint(context, state, { color: style.color, edge: style.edge, radius: Math.max(3.5, Math.sqrt(contract.profile.markerSize) * 0.64 * layout.scale) * (presentation.markerScale ?? 1), trailAlpha: contract.motion.trailAlpha, settled: settled || contract.view?.motion === "semantic", shape: style.glyph }); });
15
16
  if (contract.data.summary === "linear_fit") {
16
- const n = prepared.points.length, sx = prepared.points.reduce((a,p)=>a+p.x,0), sy = prepared.points.reduce((a,p)=>a+p.y,0), sxx = prepared.points.reduce((a,p)=>a+p.x*p.x,0), sxy = prepared.points.reduce((a,p)=>a+p.x*p.y,0);
17
- const slope = (n*sxy-sx*sy)/(n*sxx-sx*sx || 1), intercept=(sy-slope*sx)/n, cp=compileProgress(progress, contract.timeline), x0=scales.xDomain[0], x1=x0+(scales.xDomain[1]-x0)*cp;
17
+ const { slope, intercept } = linearFit(prepared.points.map((point) => point.x), prepared.points.map((point) => point.y));
18
+ const cp=compileProgress(progress, contract.timeline), x0=scales.xDomain[0], x1=x0+(scales.xDomain[1]-x0)*cp;
18
19
  context.save();
19
20
  if (contract.theme.summaryEdge) { context.strokeStyle=contract.theme.summaryEdge; context.globalAlpha=.62*cp; context.lineWidth=Math.max(2,2.8*layout.scale); context.beginPath(); context.moveTo(scales.x(x0),scales.y(intercept+slope*x0)); context.lineTo(scales.x(x1),scales.y(intercept+slope*x1)); context.stroke(); }
20
21
  context.strokeStyle=contract.theme.summaryCore; context.globalAlpha=.72*cp; context.lineWidth=Math.max(1,1.5*layout.scale); context.beginPath(); context.moveTo(scales.x(x0),scales.y(intercept+slope*x0)); context.lineTo(scales.x(x1),scales.y(intercept+slope*x1)); context.stroke(); context.restore();
@@ -1,4 +1,5 @@
1
1
  import { deriveFigureLayout } from "./figure-layout.js";
2
+ import { refineScientificLayout } from "./scientific-layout.js";
2
3
  import { markMotionState } from "./motion-plan.js";
3
4
  import { monotoneSegmentControls } from "./renderers/line.js";
4
5
  import { bandScale, formatTick, formatTimeTick, linearScale, timeScale, ticks, timeTicks } from "./scales.js";
@@ -54,6 +55,18 @@ function panelLayout(source, panel) {
54
55
  return layout;
55
56
  }
56
57
 
58
+ function preparedPanelLayout(source) {
59
+ return {
60
+ ...source,
61
+ rect: source.rect ? cloneRect(source.rect) : { left: 0, top: 0, right: source.width, bottom: source.height },
62
+ plot: cloneRect(source.plot),
63
+ text: source.text ? { ...source.text } : null,
64
+ font: { ...source.font },
65
+ provenance: source.provenance ? { ...source.provenance } : null,
66
+ legend: source.legend ? { ...source.legend } : null,
67
+ };
68
+ }
69
+
57
70
  function numericScale(type, domain, range) {
58
71
  return type === "time" ? timeScale(domain, range) : linearScale(domain, range);
59
72
  }
@@ -113,6 +126,28 @@ function scatterGeometry(panel, axes, radius) {
113
126
  });
114
127
  }
115
128
 
129
+ function fallbackPointGeometry(panel, axes, radius) {
130
+ return panel.marks.map((mark) => {
131
+ if (mark.kind === "point") return { ...mark, geometry: pointGeometry(mark, axes, radius) };
132
+ if (mark.kind !== "renderer-mark") return { ...mark, geometry: null };
133
+ const evidence = mark.evidence ?? {};
134
+ const hasX = evidence.x != null || (evidence.group != null && axes.x.bandwidth);
135
+ const hasY = evidence.y != null || (evidence.yCategory != null && axes.y.bandwidth);
136
+ if (!hasX || !hasY) return { ...mark, geometry: null };
137
+ const candidate = {
138
+ x: evidence.x,
139
+ y: evidence.y,
140
+ group: evidence.group,
141
+ yCategory: evidence.yCategory,
142
+ xOffset: evidence.xOffset,
143
+ };
144
+ const geometry = pointGeometry(candidate, axes, radius);
145
+ return Number.isFinite(geometry.cx) && Number.isFinite(geometry.cy)
146
+ ? { ...mark, geometry }
147
+ : { ...mark, geometry: null };
148
+ });
149
+ }
150
+
116
151
  function barGeometry(panel, axes, layout) {
117
152
  const horizontal = panel.orientation === "horizontal", categories = horizontal ? panel.categories.y : panel.categories.x;
118
153
  const category = horizontal ? axes.y : axes.x, value = horizontal ? axes.x : axes.y;
@@ -211,10 +246,14 @@ export function isResolvedRenderer(renderer) { return RESOLVED_RENDERERS.include
211
246
 
212
247
  export function resolveTerminalScene(scene, options = {}) {
213
248
  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 });
249
+ const layout = options.layout ?? deriveFigureLayout(width, height, { panels: scene.panels, layout: scene.layout, theme: scene.theme, spec: scene.spec });
215
250
  const panels = scene.panels.map((panel, index) => {
216
- const resolvedLayout = panelLayout(layout.panels[index], panel);
251
+ let resolvedLayout = options.refineLayout === false ? preparedPanelLayout(layout.panels[index]) : panelLayout(layout.panels[index], panel);
217
252
  let axes = resolveAxes(panel, resolvedLayout), marks, plots = null;
253
+ for (let pass = 0; options.refineLayout !== false && pass < 2; pass += 1) {
254
+ resolvedLayout = refineScientificLayout(resolvedLayout, panel, axes, { measureText: options.measureText, themeMode: scene.theme.mode });
255
+ axes = resolveAxes(panel, resolvedLayout);
256
+ }
218
257
  const radius = Math.max(3.2, Math.sqrt(scene.profile.markerSize) * 0.62 * resolvedLayout.scale) * (panel.presentation?.markerScale ?? 1);
219
258
  if (panel.renderer === "line") marks = lineGeometry(panel, axes, radius);
220
259
  else if (panel.renderer === "scatter") marks = scatterGeometry(panel, axes, radius);
@@ -222,7 +261,7 @@ export function resolveTerminalScene(scene, options = {}) {
222
261
  else if (panel.renderer === "categorical_matrix") { const matrix = matrixGeometry(panel, resolvedLayout, scene.theme); marks = matrix.marks; axes = matrix.axes; }
223
262
  else if (panel.renderer === "temporal_coverage") { const coverage = coverageGeometry(panel, resolvedLayout, radius); marks = coverage.marks; axes = coverage.axes; plots = coverage.plots; }
224
263
  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 }));
264
+ else marks = fallbackPointGeometry(panel, axes, radius);
226
265
  const evidenceFrame = cloneRect(plots ? resolvedLayout.plot : (axes.plot ?? resolvedLayout.plot));
227
266
  return { ...panel, layout: resolvedLayout, axes, plots, evidenceFrame, marks, resolved: isResolvedRenderer(panel.renderer) };
228
267
  });
@@ -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
+ }