@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.
- package/README.md +179 -5
- package/THIRD_PARTY_NOTICES.md +12 -5
- package/package.json +42 -5
- package/src/accessibility.js +51 -11
- package/src/canvas-scene.js +12 -5
- package/src/clock.js +19 -8
- package/src/core-renderers.js +9 -3
- package/src/create-matrix-plot.js +86 -30
- 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/layout.js +4 -2
- 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 +42 -3
- package/src/responsive-header.js +159 -0
- package/src/schema.js +32 -9
- 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 +23 -8
- package/src/terminal-scene.js +2 -3
- 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 +640 -0
- package/types/theme-json.d.ts +4 -0
|
@@ -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
|
|
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
|
|
25
|
-
const model = compileFigureModel(
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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() :
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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() :
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
}
|
package/src/export-bundle.js
CHANGED
|
@@ -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.
|
|
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";
|
package/src/figure-layout.js
CHANGED
|
@@ -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 ?
|
|
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 ?
|
|
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,
|
|
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 ?
|
|
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) {
|
package/src/paper-profile.js
CHANGED
|
@@ -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")
|
|
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
|
}
|
package/src/renderers/scatter.js
CHANGED
|
@@ -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
|
|
17
|
-
const
|
|
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();
|
package/src/resolved-scene.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|