@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.
@@ -5,9 +5,15 @@ import { prepareLine, drawLine } from "./renderers/line.js";
5
5
  import { prepareScatter, drawScatter } from "./renderers/scatter.js";
6
6
  import { prepareStrip, drawStrip, compileStripScene } from "./renderers/strip-summary.js";
7
7
 
8
+ // Core numeric axes have one precedence rule: the authored scale constraint,
9
+ // then the retained renderer-data override, then the finite data extent.
10
+ export const resolveNumericDomain = (contract, axis, automaticDomain) => (
11
+ contract[`${axis}Scale`]?.domain ?? contract.data[`${axis}Domain`] ?? automaticDomain
12
+ );
13
+
8
14
  const pointDomains = (contract, prepared) => ({
9
- x: contract.data.xDomain || extent(prepared.points.map((point) => point.x)),
10
- y: contract.data.yDomain || extent(prepared.points.map((point) => point.y)),
15
+ x: resolveNumericDomain(contract, "x", extent(prepared.points.map((point) => point.x))),
16
+ y: resolveNumericDomain(contract, "y", extent(prepared.points.map((point) => point.y))),
11
17
  });
12
18
 
13
19
  export const LINE_RENDERER = {
@@ -25,7 +31,7 @@ export const SCATTER_RENDERER = {
25
31
  export const STRIP_RENDERER = {
26
32
  key: "strip_summary", family: "distribution", apiVersion: RENDERER_API_VERSION,
27
33
  validateData: normalizeStripData, prepare: prepareStrip, compileScene: compileStripScene, draw: drawStrip,
28
- domains(contract, prepared) { return { x: [-0.5, contract.data.groups.length - 0.5], y: contract.data.yDomain || extent(prepared.points.map((point) => point.y)) }; },
34
+ domains(contract, prepared) { return { x: [-0.5, contract.data.groups.length - 0.5], y: resolveNumericDomain(contract, "y", extent(prepared.points.map((point) => point.y))) }; },
29
35
  describe(contract) { return { summary: `${contract.data.values.length} observations across ${contract.data.groups.length} ordered groups.`, headers: ["group", contract.spec.yLabel || "value", "series"], rows: contract.data.values.map((value, index) => [contract.data.group[index], value, contract.data.seriesLabels[contract.data.series[index]]]) }; },
30
36
  };
31
37
 
@@ -5,34 +5,71 @@ 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, retainRanks = true) => {
29
+ const model = compileFigureModel(candidate, { registry, directRanks: retainRanks ? Object.entries(scene?.directRanks ?? {}) : [] });
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
+ surface.context.textAlign = "left"; surface.context.textBaseline = "alphabetic"; surface.context.direction = "ltr";
41
+ const value = surface.context.measureText(String(text));
42
+ surface.context.restore();
43
+ return { width: value.width, ascent: value.actualBoundingBoxAscent, descent: value.actualBoundingBoxDescent, left: value.actualBoundingBoxLeft, right: value.actualBoundingBoxRight };
44
+ };
45
+ const observedBox = () => {
46
+ const rect = canvas.getBoundingClientRect();
47
+ return { width: rect.width, height: rect.height, visible: rect.width > 0 && rect.height > 0 && canvas.getClientRects().length > 0 };
48
+ };
49
+ const prepareResolution = (candidateScene, width, height, baselineResult) => {
50
+ const responsive = resolveResponsiveCanvasScene(candidateScene, {
51
+ width, height, baselineHeight: baselineResult.value, measureText: measuredText,
52
+ });
53
+ return { ...responsive, composed: composeResolvedScene(responsive.resolved), baselineError: baselineResult.error };
54
+ };
55
+ const commitNegotiation = (prepared, box) => heightNegotiator.commit({
56
+ contractRevision,
57
+ width: box.visible ? box.width : 0,
58
+ baselineHeight: prepared.baselineHeight,
59
+ preferredHeight: prepared.preferredHeight,
60
+ baselineError: prepared.baselineError,
61
+ });
30
62
  const resize = () => {
31
63
  if (destroyed) return;
64
+ const box = observedBox();
32
65
  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);
66
+ const baseline = box.visible ? heightNegotiator.baseline(box.width, box.height) : { value: null, error: null };
67
+ const prepared = prepareResolution(scene, surface.layout.width, surface.layout.height, baseline);
68
+ resolvedScene = prepared.resolved;
69
+ composedScene = prepared.composed;
70
+ surface = { ...surface, layout: resolvedScene.layout };
71
+ if (clock) clock.render(clock.progress);
72
+ commitNegotiation(prepared, box);
36
73
  };
37
74
  const draw = (progress) => {
38
75
  if (!surface || destroyed) return;
@@ -43,7 +80,7 @@ export function createFigurestead(canvas, input, options = {}) {
43
80
  const frame = resolveSceneFrame(composedScene, p);
44
81
  preparedPanels.forEach((state, index) => {
45
82
  const resolved = isResolvedRenderer(state.panel.renderer);
46
- const env = { contract: state.contract, prepared: state.prepared, layout: resolved ? composedScene.panels[index].layout : surface.layout.panels[index], domains: domains[index], progress: p, settled, panel: state.panel, figure: contract, scenePanel: scene.panels[index], motionPlan: scene.motionPlan.panels[index], reducedMotion: isReduced() };
83
+ const env = { contract: state.contract, prepared: state.prepared, layout: resolved ? frame.panels[index].layout : surface.layout.panels[index], domains: domains[index], progress: p, settled, panel: state.panel, figure: contract, scenePanel: scene.panels[index], motionPlan: scene.motionPlan.panels[index], reducedMotion: isReduced() };
47
84
  drawPanelSurface(surface.context, env);
48
85
  const scales = resolved ? drawResolvedPanel(surface.context, frame, index) : state.definition.draw(surface.context, env);
49
86
  if (!resolved) drawPresentationAnnotations(surface.context, { ...env, scales });
@@ -51,12 +88,17 @@ export function createFigurestead(canvas, input, options = {}) {
51
88
  options.onProgress?.(p);
52
89
  };
53
90
 
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);
91
+ applyModel(prepareModel(input)); surface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory });
92
+ const initialBox = observedBox();
93
+ const initialBaseline = initialBox.visible ? heightNegotiator.baseline(initialBox.width, initialBox.height) : { value: null, error: null };
94
+ const initialResolution = prepareResolution(scene, surface.layout.width, surface.layout.height, initialBaseline);
95
+ resolvedScene = initialResolution.resolved;
96
+ composedScene = initialResolution.composed;
97
+ surface = { ...surface, layout: resolvedScene.layout };
98
+ clock = new AnimationClock({ durationMs: contract.motion.durationMs, draw, onState: options.onState, onError: options.onError });
99
+ companion = createAccessibilityCompanion(canvas, contract, registry, { ...options.accessibility, composedScene });
59
100
  clock.render(isReduced() ? 1 : 0);
101
+ commitNegotiation(initialResolution, initialBox);
60
102
 
61
103
  let autoplayUsed = false, wasPlayingBeforeHidden = false;
62
104
  const resizeObserver = globalThis.ResizeObserver ? new ResizeObserver(resize) : null; resizeObserver?.observe(canvas);
@@ -65,33 +107,61 @@ export function createFigurestead(canvas, input, options = {}) {
65
107
  }, { threshold: [.35] }) : null;
66
108
  if (options.autoplay !== false) { if (intersectionObserver) intersectionObserver.observe(canvas); else { autoplayUsed = true; isReduced() ? clock.settle() : clock.play(); } }
67
109
  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); };
110
+ const mediaChange = () => { if (reducedOverride == null) isReduced() ? clock.settle() : clock.render(clock.progress); };
69
111
  document.addEventListener("visibilitychange", visibility); media?.addEventListener?.("change", mediaChange);
70
112
 
71
- 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 replace = (next, retainRanks = true) => {
114
+ const nextModel = prepareModel(next, retainRanks);
115
+ const box = observedBox();
116
+ const baseline = box.visible ? heightNegotiator.baseline(box.width, box.height) : { value: null, error: null };
117
+ const nextResolution = prepareResolution(nextModel.scene, surface.layout.width, surface.layout.height, baseline);
118
+ const nextCompanion = prepareAccessibilityCompanion(canvas, nextModel.contract, registry, { ...options.accessibility, composedScene: nextResolution.composed });
119
+ const nextSurface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory: (width, height) => layoutFactory(width, height, nextModel.contract) });
120
+ clock.pause();
121
+ applyModel(nextModel);
122
+ contractRevision += 1;
123
+ resolvedScene = nextResolution.resolved; composedScene = nextResolution.composed;
124
+ surface = { ...nextSurface, layout: resolvedScene.layout };
125
+ clock.durationMs = contract.motion.durationMs; clock.resetFailure();
126
+ nextCompanion.attach(); companion.destroy(); companion = nextCompanion; clock.settle();
127
+ commitNegotiation(nextResolution, box);
77
128
  };
78
129
  return Object.freeze({
79
130
  play() { if (isReduced()) clock.settle(); else clock.play(); }, pause() { clock.pause(); }, replay() { if (isReduced()) clock.settle(); else clock.replay(); },
80
- 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
- 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); },
131
+ setData(data) {
132
+ if (contract.panels.length !== 1) throw new TypeError("setData is available only for single-panel figures; use setConfig for multi-panel figures");
133
+ const next = cloneValue(contract);
134
+ // Data-only updates retain established line identities, including filtered keys.
135
+ // setConfig remains an explicit new style/theme contract.
136
+ if (contract.panels[0].renderer === "line") {
137
+ // Merge each override onto its complete established style, not over the key.
138
+ // Entries absent from this scene retain their saved style (or future override).
139
+ const retained = Object.fromEntries(Object.entries(scene.seriesStyles).map(([key, style]) =>
140
+ [key, { ...style, ...next.style.series[key] }]));
141
+ next.style.series = { ...next.style.series, ...retained };
142
+ }
143
+ next.panels[0].data = cloneValue(data); replace(next);
144
+ },
145
+ setConfig(next) { replace(next, false); },
146
+ 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
147
  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 }; },
148
+ destroy() { if (destroyed) return; destroyed = true; heightNegotiator.destroy(); clock.destroy(); resizeObserver?.disconnect(); intersectionObserver?.disconnect(); document.removeEventListener("visibilitychange", visibility); media?.removeEventListener?.("change", mediaChange); companion.destroy(); },
149
+ 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
150
  getScene() { return scene; },
87
151
  getResolvedScene() { return resolvedScene; },
88
152
  getComposedScene() { return composedScene; },
89
153
  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
- });
154
+ return resolvedScene.panels.map((panel) => ({
155
+ panelId: panel.id,
156
+ points: panel.marks
157
+ .filter((mark) => ["point", "renderer-mark"].includes(mark.kind) && Number.isFinite(mark.geometry?.cx) && Number.isFinite(mark.geometry?.cy))
158
+ .map((mark) => ({
159
+ x: mark.geometry.cx,
160
+ y: mark.geometry.cy,
161
+ dataX: mark.x ?? mark.group ?? mark.evidence?.x ?? mark.evidence?.group,
162
+ dataY: mark.y ?? mark.yCategory ?? mark.evidence?.y ?? mark.evidence?.yCategory,
163
+ })),
164
+ }));
95
165
  },
96
166
  });
97
167
  }
@@ -0,0 +1,73 @@
1
+ // Internal screen-down PAVA planner. No renderer presets or detached glyph cycle.
2
+ export const DIRECT_FONT = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
3
+ export function solveDirectLabels(entries, lo, hi, height, gap = 4) {
4
+ const ordered = [...entries].sort((a,b)=>a.anchor-b.anchor || a.rank-b.rank);
5
+ const s=height+gap, low=lo+height/2, high=hi-height/2-(ordered.length-1)*s;
6
+ if (!ordered.length || high<low) return null;
7
+ const blocks=[];
8
+ ordered.forEach((e,i)=>{blocks.push({sum:e.anchor-i*s,n:1});
9
+ while(blocks.length>1 && blocks.at(-2).sum/blocks.at(-2).n>blocks.at(-1).sum/blocks.at(-1).n) {
10
+ const b=blocks.pop();blocks.at(-1).sum+=b.sum;blocks.at(-1).n+=b.n;
11
+ }
12
+ });
13
+ const z=blocks.flatMap(b=>Array(b.n).fill(Math.min(high,Math.max(low,b.sum/b.n))));
14
+ return ordered.map((e,i)=>({...e,center:z[i]+i*s}));
15
+ }
16
+ export function literalLabel(t) {return typeof t==='string' && !!t.trim() && /^[\x20-\x7e]+$/.test(t);}
17
+ function contrast(a,b) {
18
+ const lum=c=>{const v=c.slice(1).match(/../g).map(x=>parseInt(x,16)/255).map(x=>x<=.04045?x/12.92:((x+.055)/1.055)**2.4);return v[0]*.2126+v[1]*.7152+v[2]*.0722;};
19
+ const x=lum(a),y=lum(b);return (Math.max(x,y)+.05)/(Math.min(x,y)+.05);
20
+ }
21
+ const fail=reason=>({status:'fallback',reason});
22
+ export function planDirectLabels(scene, resolved, measure) {
23
+ if(resolved.panels.length!==1) return fail('unsupported-layout');
24
+ const p=resolved.panels[0], input=p.directLabelsInput, plot=p.axes.plot??p.layout.plot;
25
+ if(!input || p.renderer!=='line') return fail('unsupported-geometry');
26
+ const data=input.data;
27
+ if(data.series.length<2 || data.series.length>3) return fail('unsupported-series-count');
28
+ if(p.scales.x.type!=='linear'||p.scales.y.type!=='linear'||p.encoding.interpolation!=='linear'
29
+ ||input.pose || data.x.length<2 || data.x.some((x,i)=>!Number.isFinite(x)||(i>0&&x<=data.x[i-1]))
30
+ ||p.domain.x[0]>=p.domain.x[1]||p.domain.y[0]>=p.domain.y[1]) return fail('unsupported-geometry');
31
+ if(!data.series.every(s=>literalLabel(s.label)))return fail('unsupported-text');
32
+ if(p.annotations.length || !measure || p.layout.headerText || resolved.width<=480) return fail('unsupported-layout');
33
+ const entries=[];let markerHalf=0,textWidth=0,height=0;
34
+ for(const s of data.series) {
35
+ const x=data.x.at(-1),y=s.y.at(-1);
36
+ // Ordinary compileFigureModel evidence coverage has already admitted all observations.
37
+ const points=p.marks.filter(m=>m.kind==='point'&&m.series===s.key), point=points.at(-1);
38
+ if(!point?.lineIdentity || point.x!==x || point.y!==y || !['ring','square','triangle','diamond'].includes(point.style.glyph))return fail('unsupported-geometry');
39
+ // Native Canvas/SVG polygon strokes use miter joins. Include their tips,
40
+ // not just radius + half-width (triangle apex extends sqrt(5) half-widths).
41
+ const join = point.style.glyph==='triangle' ? Math.sqrt(5) : point.style.glyph==='diamond' ? Math.SQRT2 : 1;
42
+ const g=point.geometry,half=g.radius+join*(g.outlineWidth+(point.style.edge?1.3:0))/2;
43
+ if(g.cx-half<plot.left||g.cx+half>plot.right||g.cy-half<plot.top||g.cy+half>plot.bottom)return fail('terminal-marker-clipped');
44
+ let t;
45
+ try { t=measure(s.label,p.layout.font.legend,'normal'); } catch { return fail('unsupported-layout'); }
46
+ if(!t || !['width','ascent','descent','left','right'].every(k=>Number.isFinite(t[k])) || t.ascent+t.descent<=0)return fail('unsupported-layout');
47
+ const inks=[point.style.color,point.style.edge,scene.theme.label].filter(Boolean);
48
+ if(inks.some(c=>!/^#[0-9a-f]{6}$/i.test(c))) return fail('unsupported-geometry');
49
+ if(inks.some(c=>contrast(c,scene.theme.field)<3))return fail('ink-contrast');
50
+ entries.push({key:s.key,label:s.label,anchor:g.cy,rank:input.ranks[s.key],point,text:t,half});
51
+ markerHalf=Math.max(markerHalf,half);textWidth=Math.max(textWidth,t.left+t.right,t.width);height=Math.max(height,t.ascent+t.descent,half*2);
52
+ }
53
+ const H=height+4,required=12+markerHalf*2+6+textWidth+4;
54
+ const right=p.layout.rect.right-2,shrink=Math.max(0,required-(right-plot.right));
55
+ if(shrink>.25*(plot.right-plot.left)||plot.right-shrink-plot.left<160)return fail('horizontal-capacity');
56
+ const planned=solveDirectLabels(entries,plot.top,plot.bottom,H);
57
+ if(!planned)return fail('vertical-capacity');
58
+ const newRight=plot.right-shrink, markerX=newRight+12+markerHalf+2,textX=markerX+markerHalf+6;
59
+ const substrates=[scene.theme.field,p.presentation.panelSurface?scene.theme.panel:scene.theme.field];
60
+ const leaderColor=[scene.theme.secondary,scene.theme.label].find(c=>substrates.every(b=>contrast(c,b)>=3));
61
+ const output=[];
62
+ for(const e of planned) {
63
+ const anchorX=plot.left+(e.point.geometry.cx-plot.left)*(newRight-plot.left)/(plot.right-plot.left);
64
+ if(anchorX+e.half>newRight || anchorX-e.half<plot.left)return fail('terminal-marker-clipped');
65
+ const moved=Math.abs(e.center-e.anchor)>.75;
66
+ if(moved&&!leaderColor)return fail('ink-contrast');
67
+ output.push({...e,markerX,textX:textX+e.text.left,textY:e.center+(e.text.ascent-e.text.descent)/2,
68
+ marker:{...e.point,id:e.point.id+'/direct-label',geometry:{...e.point.geometry,cx:markerX,cy:e.center}},
69
+ leader:moved?{x1:anchorX+markerHalf+2,y1:e.anchor,x2:markerX-markerHalf-3,y2:e.center,color:leaderColor}:null,
70
+ box:{left:markerX-markerHalf-2,top:e.center-H/2,right:textX+textWidth+2,bottom:e.center+H/2}});
71
+ }
72
+ return {status:'placed',reason:null,entries:output,shrink,plot:{...plot,right:newRight},height:H,font:p.layout.font.legend};
73
+ }
@@ -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.3";
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/index.js CHANGED
@@ -22,7 +22,7 @@ export { THEME_CATALOG_VERSION, auditThemeCatalog, catalogThemePack, mergeThemeP
22
22
  export { prepareScatter } from "./renderers/scatter.js";
23
23
  export { prepareStrip } from "./renderers/strip-summary.js";
24
24
  export { AnimationClock } from "./clock.js";
25
- export { THEME_PACK_VERSION, PALETTE_PACK_VERSION, applyTheme, contrastAudit, contrastRatio, loadThemePack, normalizeThemePackLenient, resolveTheme, resolvePalette, themeForProfile, validateAuthoredThemePack, validateThemePack, validatePalettePack } from "./theme-pack.js";
25
+ export { THEME_PACK_VERSION, PALETTE_PACK_VERSION, applyTheme, contrastAudit, contrastRatio, renderedSeriesAudit, loadThemePack, normalizeThemePackLenient, resolveTheme, resolvePalette, themeForProfile, validateAuthoredThemePack, validateThemePack, validatePalettePack } from "./theme-pack.js";
26
26
  export { RENDER_LAYER_ORDER, partitionPanelMarks, plotClipRect, renderLayerForMark, withCanvasPlotClip } from "./render-layers.js";
27
27
  export { validateEvidenceCoverage } from "./evidence-coverage.js";
28
28
  export { colorContrast, hexToOklab, hexToOklch, oklabDistance, oklchToHex, resolveContrastColor } from "./color-space.js";
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
  };
@@ -0,0 +1,29 @@
1
+ // Line-only prominence. Glyph identity/rhythm still come from series-style.js.
2
+ // Units are CSS px; the same resolved geometry feeds Canvas and SVG.
3
+ export function lineMarkerGeometry(style, scale = 1, markerScale = 1) {
4
+ const shapeScale = style.glyph === "square" ? 0.9 : style.glyph === "triangle" ? 1.1 : 1;
5
+ return { radius: 4.1 * Math.max(1, scale) * markerScale * shapeScale,
6
+ outlineWidth: 2 * Math.max(1, scale) };
7
+ }
8
+
9
+ // Append, don't begin: also used inside an own-line-only clipping path.
10
+ export function appendMarkerPath(context, glyph, x, y, radius) {
11
+ if (glyph === "square") context.rect(x - radius, y - radius, radius * 2, radius * 2);
12
+ else if (glyph === "triangle") { context.moveTo(x, y - radius); context.lineTo(x + radius, y + radius); context.lineTo(x - radius, y + radius); context.closePath(); }
13
+ else if (glyph === "diamond") { context.moveTo(x, y - radius); context.lineTo(x + radius, y); context.lineTo(x, y + radius); context.lineTo(x - radius, y); context.closePath(); }
14
+ else { context.moveTo(x + radius, y); context.arc(x, y, radius, 0, Math.PI * 2); }
15
+ }
16
+
17
+ export function clipOwnLine(context, markers, plot) {
18
+ // Intersect individual complements: overlapping markers remain a union of holes.
19
+ // A single even-odd path containing every marker would incorrectly XOR overlaps.
20
+ for (const mark of markers) {
21
+ const m = mark.motion, g = mark.geometry;
22
+ if (m.opacity <= 0) continue;
23
+ context.beginPath();
24
+ context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
25
+ appendMarkerPath(context, mark.style.glyph, g.cx + m.translateX, g.cy + m.translateY,
26
+ g.radius * Math.min(m.scaleX, m.scaleY));
27
+ context.clip("evenodd");
28
+ }
29
+ }
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();