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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # @figurestead/web experimental public alpha
2
2
 
3
+ This README targets `0.9.0-alpha.3`. Registry commands require that exact version to be available; review candidates are installed from their retained tarball. To follow the prerelease channel intentionally, use `npm install @figurestead/web@alpha`; an unqualified install follows `latest`, not necessarily this alpha.
4
+
3
5
  ```sh
4
- npm install @figurestead/web@0.9.0-alpha.2
6
+ npm install @figurestead/web@0.9.0-alpha.3
5
7
  ```
6
8
 
7
9
  The package root provides Figurestead's accepted core rendering and custom
@@ -24,7 +26,7 @@ files after installing `@figurestead/web` and run them through a normal ESM
24
26
  browser tool such as Vite.
25
27
 
26
28
  ```sh
27
- npm install @figurestead/web@0.9.0-alpha.2
29
+ npm install @figurestead/web@0.9.0-alpha.3
28
30
  ```
29
31
 
30
32
  <!-- figurestead-npm-first-success:index.html -->
@@ -100,7 +102,7 @@ curated subpaths are `registration-ink`, `ultraviolet-laboratory`,
100
102
  `deep-observatory-sage-core` under `@figurestead/web/themes/`.
101
103
 
102
104
  The declarations and all six curated-theme subpaths above are included in
103
- `@figurestead/web@0.9.0-alpha.2`.
105
+ `@figurestead/web@0.9.0-alpha.3`.
104
106
 
105
107
  Python and browser surfaces share normalized contract vocabulary and selected
106
108
  theme definitions. Shared semantics do not imply pixel-identical output or
@@ -108,6 +110,16 @@ identical renderer coverage. In particular, the populated categorical matrix
108
110
  is currently Python-rendered; this package does not claim a browser categorical
109
111
  matrix renderer.
110
112
 
113
+ ## Line identity and direct labels
114
+
115
+ Default line series combine color with persistent open **circle (S1), square (S2), and upright triangle (S3)** markers and matching line-and-marker legends. Line rhythm remains an independent semantic channel. `setData` retains established keyed identity through supported filtering/reordering and partial style overrides. `setConfig` replaces the contract.
116
+
117
+ Lavender Fog Notebook is the reference light theme; Ultraviolet Laboratory is the reference dark theme for the [bounded three-series line profile](https://github.com/CharlesMish/figurestead/blob/main/docs/reference-themes.md), not universal accessibility, CVD or print qualification. Product defaults are unchanged.
118
+
119
+ Opt in with `style: { ...contract.style, directLabels: true }` (`style.directLabels: true`). Direct labels reuse the actual body marker identity beside the traces. V1 covers ordinary one-panel, 2–3-series line figures with single-line printable ASCII labels. Unsupported or insufficient layouts fall back atomically to the ordinary legend. This treatment does not support Unicode, multiline or math interpretation; printable ASCII math punctuation is literal. Active browser transitions use ordinary treatment until settled, and exports without trustworthy measurement retain the ordinary legend.
120
+
121
+ See the [ordinary/direct-label example](https://github.com/CharlesMish/figurestead/tree/main/examples/direct-series-labels) and [detailed direct-label contract](https://github.com/CharlesMish/figurestead/blob/main/docs/direct-series-labels.md). The [explicit rendered-series contrast audit](https://github.com/CharlesMish/figurestead/blob/main/docs/rendered-series-contrast.md) measures caller-specified rendering facts separately from the static palette audit.
122
+
111
123
  ## Controller failures
112
124
 
113
125
  `setConfig(next)` is transactional: a validation, compilation, renderer
@@ -180,4 +192,27 @@ preferred height is never inferred as the next baseline by Figurestead.
180
192
  Height negotiation applies only to live Canvas rendering. SVG, paper, and
181
193
  explicitly dimensioned exports retain their requested dimensions.
182
194
 
183
- Version 0.9.0-alpha.2. [Source and full project documentation](https://github.com/CharlesMish/figurestead).
195
+ Version 0.9.0-alpha.3. [Source and full project documentation](https://github.com/CharlesMish/figurestead).
196
+
197
+ ## Rendered series contrast
198
+
199
+ `contrastAudit(theme)` remains a static authored-palette inspection.
200
+ `renderedSeriesAudit(theme, { substrate, opacity, compositing: "srgb-source-over" })`
201
+ measures the supplied theme's series colors in the caller-supplied single-layer
202
+ context. The opaque `#RRGGBB` substrate and opacity in [0,1] are required; no
203
+ renderer/surface defaults are inferred. Results retain unrounded effective sRGB
204
+ channels and ratio. The exact policy is `passes = ratio >= 3`, without an epsilon
205
+ or display-rounding rule. Values effectively on the floating-point threshold may
206
+ classify differently across Python/JavaScript final ULPs; this is not physical
207
+ certification. Figures and colors are never changed.
208
+
209
+ Nonempty **theme-level** `seriesEdges` are conservatively rejected. Per-series
210
+ contract/style overrides can independently change the rendered color or add an
211
+ edge; they are not inspected by that theme-level check. The caller must account
212
+ for those overrides before interpreting the result as a rendered-mark measurement.
213
+
214
+ Verified Canvas line **segments** use panel or field with effective .78 (Firefox
215
+ exposes its float32 value); SVG line **segments** use panel or field at 1. Python's
216
+ verified default line **polyline** uses field/.88. Companion markers/points may
217
+ have different opacity and are outside those contexts. This is not an assessment
218
+ of glow, antialias boundaries, every layered mark or whole-figure accessibility.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@figurestead/web",
3
- "version": "0.9.0-alpha.2",
3
+ "version": "0.9.0-alpha.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,13 +1,12 @@
1
+ import { DIRECT_FONT } from "./direct-labels.js";
2
+ import { appendMarkerPath, clipOwnLine, lineMarkerGeometry } from "./line-identity.js";
1
3
  import { partitionPanelMarks, withCanvasPlotClip } from "./render-layers.js";
2
4
 
3
5
  const FONT_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
4
6
 
5
7
  function markerPath(context, glyph, x, y, radius) {
6
8
  context.beginPath();
7
- if (glyph === "square") context.rect(x - radius, y - radius, radius * 2, radius * 2);
8
- else if (glyph === "triangle") { context.moveTo(x, y - radius); context.lineTo(x + radius, y + radius); context.lineTo(x - radius, y + radius); context.closePath(); }
9
- 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(); }
10
- else context.arc(x, y, radius, 0, Math.PI * 2);
9
+ appendMarkerPath(context, glyph, x, y, radius);
11
10
  }
12
11
 
13
12
  function mixPoint(a, b, t) { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; }
@@ -72,7 +71,18 @@ function drawAxes(context, panel, theme) {
72
71
  context.restore();
73
72
  }
74
73
 
74
+ function drawDirectLabels(context, plan, theme) {
75
+ context.save(); context.font = `${plan.font}px ${DIRECT_FONT}`; context.textAlign = "left"; context.textBaseline = "alphabetic"; context.direction = "ltr";
76
+ for (const e of plan.entries) {
77
+ if (e.leader) { const l=e.leader;context.globalAlpha=1;context.strokeStyle=l.color;context.lineWidth=.7;context.setLineDash([]);context.beginPath();context.moveTo(l.x1,l.y1);context.lineTo(l.x2,l.y2);context.stroke(); }
78
+ drawPoint(context, {...e.marker, motion:{opacity:1,scaleX:1,scaleY:1,translateX:0,translateY:0}});
79
+ context.globalAlpha=1;context.fillStyle=theme.label;context.fillText(e.label,e.textX,e.textY);
80
+ }
81
+ context.restore();
82
+ }
83
+
75
84
  function drawLegend(context, panel, theme) {
85
+ if (panel.directLabelPlan?.status === "placed") return drawDirectLabels(context, panel.directLabelPlan, theme);
76
86
  if (panel.presentation?.legend === "none" || !panel.legend.length) return;
77
87
  const { layout } = panel, outside = layout.legend.outside;
78
88
  context.save(); context.font = `${layout.font.legend}px ${FONT_STACK}`; context.textBaseline = "middle";
@@ -82,8 +92,17 @@ function drawLegend(context, panel, theme) {
82
92
  const x = entry?.markerX ?? (outside ? layout.legend.left : layout.plot.right - 24 * layout.scale);
83
93
  const textX = entry?.textX ?? (outside ? x + 12 * layout.scale : x - 10 * layout.scale);
84
94
  const y = entry?.y ?? (outside ? layout.legend.top + (14 + index * 20) * layout.scale : insideTop + index * 20 * layout.scale);
85
- context.strokeStyle = style.edge ?? style.color ?? theme.series[item.colorIndex % theme.series.length]; context.lineWidth = Math.max(1, 1.2 * layout.scale);
86
- markerPath(context, style.glyph ?? "ring", x, y, 4 * layout.scale); context.stroke();
95
+ if (panel.renderer === "line") {
96
+ const geometry = { cx: x, cy: y, ...lineMarkerGeometry(style, layout.scale, panel.presentation?.markerScale ?? 1) };
97
+ const point = { lineIdentity: true, style, geometry, motion: { opacity: 1, scaleX: 1, scaleY: 1, translateX: 0, translateY: 0 } };
98
+ const half = 12 * Math.max(1, layout.scale);
99
+ drawLine(context, { style, geometry: { x1: x - half, y1: y, x2: x + half, y2: y }, motion: { opacity: 1, clip: 1 } }, theme, [point],
100
+ { left: x - half - 3, right: x + half + 3, top: y - 10, bottom: y + 10 });
101
+ drawPoint(context, point);
102
+ } else {
103
+ context.strokeStyle = style.edge ?? style.color ?? theme.series[item.colorIndex % theme.series.length]; context.lineWidth = Math.max(1, 1.2 * layout.scale);
104
+ markerPath(context, style.glyph ?? "ring", x, y, 4 * layout.scale); context.stroke();
105
+ }
87
106
  context.fillStyle = theme.label;
88
107
  context.textAlign = entry?.textAnchor ?? (outside ? "left" : "right"); context.fillText(entry?.displayLabel ?? item.label, textX, y);
89
108
  });
@@ -166,16 +185,29 @@ function drawBandKey(context, panel, theme) {
166
185
 
167
186
  function drawPoint(context, mark) {
168
187
  const motion = mark.motion, g = mark.geometry, x = g.cx + motion.translateX, y = g.cy + motion.translateY, radius = g.radius * Math.min(motion.scaleX, motion.scaleY);
169
- context.save(); context.globalAlpha = motion.opacity; context.strokeStyle = mark.style.edge ?? mark.style.color; context.lineWidth = Math.max(1.6, radius * 0.58);
188
+ context.save(); context.globalAlpha = motion.opacity;
189
+ if (mark.lineIdentity) {
190
+ context.setLineDash([]);
191
+ context.strokeStyle = mark.style.edge ?? mark.style.color;
192
+ context.lineWidth = g.outlineWidth + (mark.style.edge ? 1.3 : 0);
193
+ markerPath(context, mark.style.glyph, x, y, radius); context.stroke();
194
+ if (mark.style.edge) {
195
+ context.strokeStyle = mark.style.color; context.lineWidth = g.outlineWidth;
196
+ markerPath(context, mark.style.glyph, x, y, radius); context.stroke();
197
+ }
198
+ context.restore(); return;
199
+ }
200
+ context.strokeStyle = mark.style.edge ?? mark.style.color; context.lineWidth = Math.max(1.6, radius * 0.58);
170
201
  markerPath(context, mark.style.glyph, x, y, radius); context.stroke();
171
202
  context.strokeStyle = mark.style.color; context.lineWidth = Math.max(0.9, radius * 0.24); markerPath(context, mark.style.glyph, x, y, radius); context.stroke();
172
203
  if (motion.glow > 0) { context.globalAlpha = motion.glow; context.lineWidth = radius; markerPath(context, mark.style.glyph, x, y, radius * 1.45); context.stroke(); }
173
204
  context.restore();
174
205
  }
175
206
 
176
- function drawLine(context, mark, theme) {
207
+ function drawLine(context, mark, theme, markers = [], plot = null) {
177
208
  const motion = mark.motion;
178
209
  context.save(); context.globalAlpha = motion.opacity * (theme.mode === "paper" ? 1 : 0.78); context.strokeStyle = mark.style.edge ?? mark.style.color;
210
+ if (markers.length) clipOwnLine(context, markers, plot);
179
211
  context.lineWidth = Math.max(1, (mark.style.lineWidth ?? 1.6) + (mark.style.edge ? 1.3 : 0));
180
212
  context.setLineDash?.(mark.style.lineStyle === "dash" ? [7, 4] : mark.style.lineStyle === "dot" ? [2, 4] : mark.style.lineStyle === "dash-dot" ? [8, 3, 2, 3] : []);
181
213
  strokeSegment(context, mark.geometry, motion.clip);
@@ -298,7 +330,9 @@ export function drawResolvedPanel(context, frame, panelIndex) {
298
330
  const drawMark = (mark) => {
299
331
  if (!mark.geometry || mark.motion.opacity <= 0) return;
300
332
  if (mark.kind === "point") drawPoint(context, mark);
301
- else if (["segment", "summary-line"].includes(mark.kind)) drawLine(context, mark, theme);
333
+ else if (["segment", "summary-line"].includes(mark.kind)) drawLine(context, mark, theme,
334
+ panel.renderer === "line" ? panel.marks.filter(p => p.lineIdentity && p.series === mark.series) : [],
335
+ panel.axes.plot ?? panel.layout.plot);
302
336
  else if (mark.kind === "median-rule") drawLine(context, mark, theme);
303
337
  else if (mark.kind === "bar") drawBar(context, mark, theme);
304
338
  else if (mark.kind === "cell") drawCell(context, mark, theme, panel.layout.font.axis);
@@ -54,14 +54,22 @@ function fitText(value, maxWidth, fontSize) {
54
54
  return `${text.slice(0, Math.max(1, maxChars - 1))}…`;
55
55
  }
56
56
 
57
+ function lineLegendStep(panel) {
58
+ // Body-sized markers need unscaled clearance even in compact layouts.
59
+ const halfHeight = panel.marks.filter(mark => mark.lineIdentity)
60
+ .reduce((size, mark) => Math.max(size, mark.geometry.radius + mark.geometry.outlineWidth / 2), 0);
61
+ return 2 * halfHeight + 4;
62
+ }
63
+
57
64
  function legendDimensions(panel) {
58
65
  const scale = panel.layout.scale;
59
66
  const font = panel.layout.font.legend;
60
67
  const plot = panel.axes.plot ?? panel.layout.plot;
61
68
  const labelWidth = panel.legend.reduce((width, item) => Math.max(width, String(item.label).length * font * 0.62), 0);
62
69
  return {
63
- width: Math.min(Math.max(72 * scale, labelWidth + 34 * scale), Math.max(24, plot.right - plot.left - 24 * scale)),
64
- height: Math.min(Math.max(18 * scale, (12 + Math.max(0, panel.legend.length - 1) * 20) * scale), Math.max(18, plot.bottom - plot.top - 24 * scale)),
70
+ width: Math.min(Math.max(72 * scale, labelWidth + (panel.renderer === "line" ? 56 * Math.max(1, scale) : 34 * scale)), Math.max(24, plot.right - plot.left - 24 * scale)),
71
+ height: Math.min(panel.renderer === "line" ? lineLegendStep(panel) * panel.legend.length
72
+ : Math.max(18 * scale, (12 + Math.max(0, panel.legend.length - 1) * 20) * scale), Math.max(18, plot.bottom - plot.top - 24 * scale)),
65
73
  };
66
74
  }
67
75
 
@@ -83,14 +91,15 @@ function legendScore(panel, box) {
83
91
  function legendEntries(panel, box, position, outside) {
84
92
  const scale = panel.layout.scale, right = position.endsWith("right"), markerInset = 8 * scale;
85
93
  const count = panel.legend.length;
86
- const topInset = Math.min(12 * scale, box.height / 2);
87
- const bottomInset = Math.min(8 * scale, box.height / 2);
88
- const step = count > 1 ? Math.min(20 * scale, Math.max(1, (box.height - topInset - bottomInset) / (count - 1))) : 0;
94
+ const lineStep = panel.renderer === "line" ? lineLegendStep(panel) : null;
95
+ const topInset = Math.min(lineStep == null ? 12 * scale : lineStep / 2, box.height / 2);
96
+ const bottomInset = Math.min(lineStep == null ? 8 * scale : lineStep / 2, box.height / 2);
97
+ const step = count > 1 ? Math.min(lineStep ?? 20 * scale, Math.max(1, (box.height - topInset - bottomInset) / (count - 1))) : 0;
89
98
  return panel.legend.map((item, index) => {
90
99
  const y = clamp(box.top + topInset + index * step, box.top, box.bottom);
91
- const textAnchor = outside || !right ? "start" : "end";
92
- const markerX = textAnchor === "start" ? box.left + markerInset : box.right - markerInset;
93
- const textX = textAnchor === "start" ? box.left + 20 * scale : box.right - 20 * scale;
100
+ const textAnchor = panel.renderer === "line" || outside || !right ? "start" : "end";
101
+ const markerX = panel.renderer === "line" ? box.left + 18 * Math.max(1, scale) : textAnchor === "start" ? box.left + markerInset : box.right - markerInset;
102
+ const textX = panel.renderer === "line" ? box.left + 36 * Math.max(1, scale) : textAnchor === "start" ? box.left + 20 * scale : box.right - 20 * scale;
94
103
  const maxTextWidth = Math.max(8, textAnchor === "start" ? box.right - textX : textX - box.left);
95
104
  return { markerX, textX, y, textAnchor, displayLabel: fitText(item.label, maxTextWidth, panel.layout.font.legend) };
96
105
  });
@@ -194,6 +203,7 @@ export function composeResolvedScene(resolvedScene) {
194
203
  ...resolvedScene,
195
204
  schemaVersion: COMPOSED_SCENE_VERSION,
196
205
  resolvedSceneVersion: resolvedScene.schemaVersion,
206
+ ...(resolvedScene.fallbackScene ? { fallbackScene: composeResolvedScene(resolvedScene.fallbackScene) } : {}),
197
207
  panels,
198
208
  };
199
209
  result.compositionAudit = auditComposition(result);
@@ -25,8 +25,8 @@ export function createFigurestead(canvas, input, options = {}) {
25
25
  const media = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)");
26
26
  const isReduced = () => reducedOverride == null ? Boolean(media?.matches) : Boolean(reducedOverride);
27
27
 
28
- const prepareModel = (candidate) => {
29
- const model = compileFigureModel(candidate, { registry });
28
+ const prepareModel = (candidate, retainRanks = true) => {
29
+ const model = compileFigureModel(candidate, { registry, directRanks: retainRanks ? Object.entries(scene?.directRanks ?? {}) : [] });
30
30
  return { ...model, atmosphere: model.contract.view.ambient === "matrix" ? prepareAtmosphere(model.contract.motion) : [] };
31
31
  };
32
32
  const applyModel = (model) => {
@@ -37,9 +37,10 @@ export function createFigurestead(canvas, input, options = {}) {
37
37
  surface.context.save();
38
38
  const prefix = style === "italic" ? "italic " : style === "500" ? "500 " : "";
39
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";
40
41
  const value = surface.context.measureText(String(text));
41
42
  surface.context.restore();
42
- return { width: value.width, ascent: value.actualBoundingBoxAscent, descent: value.actualBoundingBoxDescent };
43
+ return { width: value.width, ascent: value.actualBoundingBoxAscent, descent: value.actualBoundingBoxDescent, left: value.actualBoundingBoxLeft, right: value.actualBoundingBoxRight };
43
44
  };
44
45
  const observedBox = () => {
45
46
  const rect = canvas.getBoundingClientRect();
@@ -79,7 +80,7 @@ export function createFigurestead(canvas, input, options = {}) {
79
80
  const frame = resolveSceneFrame(composedScene, p);
80
81
  preparedPanels.forEach((state, index) => {
81
82
  const resolved = isResolvedRenderer(state.panel.renderer);
82
- 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() };
83
84
  drawPanelSurface(surface.context, env);
84
85
  const scales = resolved ? drawResolvedPanel(surface.context, frame, index) : state.definition.draw(surface.context, env);
85
86
  if (!resolved) drawPresentationAnnotations(surface.context, { ...env, scales });
@@ -109,8 +110,8 @@ export function createFigurestead(canvas, input, options = {}) {
109
110
  const mediaChange = () => { if (reducedOverride == null) isReduced() ? clock.settle() : clock.render(clock.progress); };
110
111
  document.addEventListener("visibilitychange", visibility); media?.addEventListener?.("change", mediaChange);
111
112
 
112
- const replace = (next) => {
113
- const nextModel = prepareModel(next);
113
+ const replace = (next, retainRanks = true) => {
114
+ const nextModel = prepareModel(next, retainRanks);
114
115
  const box = observedBox();
115
116
  const baseline = box.visible ? heightNegotiator.baseline(box.width, box.height) : { value: null, error: null };
116
117
  const nextResolution = prepareResolution(nextModel.scene, surface.layout.width, surface.layout.height, baseline);
@@ -127,8 +128,21 @@ export function createFigurestead(canvas, input, options = {}) {
127
128
  };
128
129
  return Object.freeze({
129
130
  play() { if (isReduced()) clock.settle(); else clock.play(); }, pause() { clock.pause(); }, replay() { if (isReduced()) clock.settle(); else clock.replay(); },
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); },
131
- setConfig(next) { replace(next); },
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); },
132
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); },
133
147
  resize,
134
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(); },
@@ -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.2";
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);
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";
@@ -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
+ }
@@ -1,3 +1,5 @@
1
+ import { planDirectLabels } from "./direct-labels.js";
2
+ import { lineMarkerGeometry } from "./line-identity.js";
1
3
  import { deriveFigureLayout } from "./figure-layout.js";
2
4
  import { refineScientificLayout } from "./scientific-layout.js";
3
5
  import { markMotionState } from "./motion-plan.js";
@@ -94,7 +96,7 @@ function pointGeometry(mark, axes, radius) {
94
96
  return { cx, cy, radius };
95
97
  }
96
98
 
97
- function lineGeometry(panel, axes, radius) {
99
+ function lineGeometry(panel, axes, scale) {
98
100
  const controls = new Map();
99
101
  const series = [...new Set(panel.marks.filter((mark) => mark.kind === "point").map((mark) => mark.series))];
100
102
  series.forEach((key) => {
@@ -104,7 +106,10 @@ function lineGeometry(panel, axes, radius) {
104
106
  });
105
107
  const segmentIndex = new Map();
106
108
  return panel.marks.map((mark) => {
107
- if (mark.kind === "point") return { ...mark, geometry: pointGeometry(mark, axes, radius) };
109
+ if (mark.kind === "point") {
110
+ const marker = lineMarkerGeometry(mark.style, scale, panel.presentation?.markerScale ?? 1);
111
+ return { ...mark, lineIdentity: true, geometry: { ...pointGeometry(mark, axes, marker.radius), outlineWidth: marker.outlineWidth } };
112
+ }
108
113
  if (mark.kind !== "segment") return { ...mark };
109
114
  const index = segmentIndex.get(mark.series) ?? 0; segmentIndex.set(mark.series, index + 1);
110
115
  const control = controls.get(`${mark.series}\u0000${index}`);
@@ -255,7 +260,7 @@ export function resolveTerminalScene(scene, options = {}) {
255
260
  axes = resolveAxes(panel, resolvedLayout);
256
261
  }
257
262
  const radius = Math.max(3.2, Math.sqrt(scene.profile.markerSize) * 0.62 * resolvedLayout.scale) * (panel.presentation?.markerScale ?? 1);
258
- if (panel.renderer === "line") marks = lineGeometry(panel, axes, radius);
263
+ if (panel.renderer === "line") marks = lineGeometry(panel, axes, resolvedLayout.scale);
259
264
  else if (panel.renderer === "scatter") marks = scatterGeometry(panel, axes, radius);
260
265
  else if (["categorical_bar", "categorical_layered_bar"].includes(panel.renderer)) marks = barGeometry(panel, axes, resolvedLayout);
261
266
  else if (panel.renderer === "categorical_matrix") { const matrix = matrixGeometry(panel, resolvedLayout, scene.theme); marks = matrix.marks; axes = matrix.axes; }
@@ -265,11 +270,24 @@ export function resolveTerminalScene(scene, options = {}) {
265
270
  const evidenceFrame = cloneRect(plots ? resolvedLayout.plot : (axes.plot ?? resolvedLayout.plot));
266
271
  return { ...panel, layout: resolvedLayout, axes, plots, evidenceFrame, marks, resolved: isResolvedRenderer(panel.renderer) };
267
272
  });
268
- return deepFreeze({ schemaVersion: RESOLVED_SCENE_VERSION, sourceSceneVersion: scene.schemaVersion, width, height, theme: scene.theme, spec: scene.spec, profile: scene.profile, applicationProfile: scene.applicationProfile, view: scene.view, layout, panels, motionPlan: scene.motionPlan });
273
+ const result = { schemaVersion: RESOLVED_SCENE_VERSION, sourceSceneVersion: scene.schemaVersion, width, height, theme: scene.theme, spec: scene.spec, profile: scene.profile, applicationProfile: scene.applicationProfile, view: scene.view, layout, panels, motionPlan: scene.motionPlan };
274
+ if (scene.directLabels && !options.directPass) {
275
+ const plan = planDirectLabels(scene, result, options.measureText);
276
+ if (plan.status === "placed") {
277
+ const baseline = deepFreeze(result), p = panels[0];
278
+ const adjusted = { ...p.layout, plot: plan.plot };
279
+ const proposal = resolveTerminalScene(scene, { ...options, layout: { ...layout, panels: [adjusted] }, refineLayout: false, directPass: true });
280
+ return deepFreeze({ ...proposal, directLabelPlan: plan, fallbackScene: baseline,
281
+ panels: proposal.panels.map(panel => ({ ...panel, directLabelPlan: plan })) });
282
+ }
283
+ result.directLabelPlan = plan;
284
+ }
285
+ return deepFreeze(result);
269
286
  }
270
287
 
271
288
  export function resolveSceneFrame(resolvedScene, progress = 1) {
272
289
  const p = clamp01(progress);
290
+ if (p < 1 && resolvedScene.fallbackScene) resolvedScene = { ...resolvedScene.fallbackScene, directLabelPlan: { status: "fallback", reason: "unsupported-layout" } };
273
291
  return {
274
292
  ...resolvedScene,
275
293
  progress: p,
package/src/schema.js CHANGED
@@ -315,9 +315,10 @@ function normalizeStyle(value) {
315
315
  glyphs.forEach((glyph, index) => { if (!MARKER_TYPES.has(glyph)) throw new FiguresteadConfigError("is not a supported glyph", `config.style.glyphs[${index}]`); });
316
316
  const lineStyles = style.lineStyles ?? ["solid", "dash", "dot", "dash-dot"];
317
317
  if (!Array.isArray(lineStyles) || !lineStyles.length || lineStyles.some((item) => !["solid", "dash", "dot", "dash-dot"].includes(item))) throw new FiguresteadConfigError("must contain supported line styles", "config.style.lineStyles");
318
+ if (style.directLabels != null && typeof style.directLabels !== "boolean") throw new FiguresteadConfigError("must be boolean", "config.style.directLabels");
318
319
  const series = style.series ?? {};
319
320
  requiredObject(series, "config.style.series");
320
- return { glyphs: [...glyphs], lineStyles: [...lineStyles], series: cloneValue(series) };
321
+ return { glyphs: [...glyphs], lineStyles: [...lineStyles], series: cloneValue(series), ...(style.directLabels ? { directLabels: true } : {}) };
321
322
  }
322
323
 
323
324
  function normalizePanel(panel, index, figureSpec, registry) {
package/src/svg-export.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { DIRECT_FONT } from "./direct-labels.js";
2
+ import { lineMarkerGeometry } from "./line-identity.js";
1
3
  import { CORE_REGISTRY } from "./core-renderers.js";
2
4
  import { compileTerminalScene, evidenceFingerprint } from "./terminal-scene.js";
3
5
  import { resolveTerminalScene } from "./resolved-scene.js";
@@ -28,17 +30,29 @@ function svgNamespace(composed, scene, options) {
28
30
  }
29
31
 
30
32
  function dash(style) { return style === "dash" ? "7 4" : style === "dot" ? "2 4" : style === "dash-dot" ? "8 3 2 3" : null; }
31
- function marker(mark) {
32
- const { cx, cy, radius } = mark.geometry, common = { "data-mark-id": mark.id, fill: "none", stroke: mark.style.color, "stroke-width": 1.5 };
33
+ function marker(mark, appearance = null) {
34
+ const { cx, cy, radius } = mark.geometry, common = { "data-mark-id": mark.id, fill: "none", stroke: mark.style.color, "stroke-width": mark.lineIdentity ? mark.geometry.outlineWidth : 1.5, ...appearance };
33
35
  if (mark.style.glyph === "square") return `<rect ${attrs({ ...common, x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 })}/>`;
34
36
  if (mark.style.glyph === "triangle") return `<path ${attrs({ ...common, d: `M ${cx} ${cy - radius} L ${cx + radius} ${cy + radius} L ${cx - radius} ${cy + radius} Z` })}/>`;
35
37
  if (mark.style.glyph === "diamond") return `<path ${attrs({ ...common, d: `M ${cx} ${cy - radius} L ${cx + radius} ${cy} L ${cx} ${cy + radius} L ${cx - radius} ${cy} Z` })}/>`;
36
38
  return `<circle ${attrs({ ...common, cx, cy, r: radius })}/>`;
37
39
  }
38
40
 
39
- function segment(mark) {
41
+ function segment(mark, extra = {}) {
40
42
  const g = mark.geometry, d = g.c1x == null ? `M ${g.x1} ${g.y1} L ${g.x2} ${g.y2}` : `M ${g.x1} ${g.y1} C ${g.c1x} ${g.c1y} ${g.c2x} ${g.c2y} ${g.x2} ${g.y2}`;
41
- return `<path ${attrs({ "data-mark-id": mark.id, d, fill: "none", stroke: mark.style.color, "stroke-width": mark.style.lineWidth ?? 1.6, "stroke-dasharray": dash(mark.style.lineStyle), "stroke-linecap": "round" })}/>`;
43
+ return `<path ${attrs({ "data-mark-id": mark.id, d, fill: "none", stroke: mark.style.color, "stroke-width": mark.style.lineWidth ?? 1.6, "stroke-dasharray": dash(mark.style.lineStyle), "stroke-linecap": "round", ...extra })}/>`;
44
+ }
45
+
46
+ function linePoint(mark) {
47
+ const edge = mark.style.edge ? marker(mark, { stroke: mark.style.edge, "stroke-width": mark.geometry.outlineWidth + 1.3 }) : "";
48
+ return edge + marker(mark);
49
+ }
50
+
51
+ function maskedLine(mark, points, bounds, id) {
52
+ // Luminance mask is applied only to this series' line (never the panel/grid).
53
+ const mask = `<mask ${attrs({ id, maskUnits: "userSpaceOnUse", x: bounds.left, y: bounds.top, width: bounds.right - bounds.left, height: bounds.bottom - bounds.top, "mask-type": "luminance" })}><rect ${attrs({ x: bounds.left, y: bounds.top, width: bounds.right - bounds.left, height: bounds.bottom - bounds.top, fill: "white" })}/>${points.map(p => marker(p, { fill: "black", stroke: "none", "data-mark-id": null })).join("")}</mask>`;
54
+ const edge = mark.style.edge ? segment(mark, { stroke: mark.style.edge, "stroke-width": mark.style.lineWidth + 1.3 }) : "";
55
+ return `<defs>${mask}</defs><g mask="url(#${esc(id)})">${edge}${segment(mark)}</g>`;
42
56
  }
43
57
 
44
58
  function bar(mark, theme) {
@@ -156,7 +170,12 @@ function axes(panel, theme) {
156
170
  return pieces.join("");
157
171
  }
158
172
 
159
- function legend(panel, theme) {
173
+ function legend(panel, theme, namespace) {
174
+ if (panel.directLabelPlan?.status === "placed") return panel.directLabelPlan.entries.map(e => {
175
+ const l=e.leader;
176
+ return (l ? `<path ${attrs({d:`M ${l.x1} ${l.y1} L ${l.x2} ${l.y2}`,fill:"none",stroke:l.color,"stroke-width":.7})}/>` : "")
177
+ + linePoint(e.marker) + `<text ${attrs({x:e.textX,y:e.textY,fill:theme.label,"font-family":DIRECT_FONT,"font-size":panel.directLabelPlan.font,"xml:space":"preserve"})}>${esc(e.label)}</text>`;
178
+ }).join("");
160
179
  if (panel.presentation?.legend === "none") return "";
161
180
  const insideTop = panel.layout.plot.bottom - Math.max(14, 14 + (panel.legend.length - 1) * 20) * panel.layout.scale;
162
181
  return panel.legend.map((item, index) => {
@@ -164,7 +183,14 @@ function legend(panel, theme) {
164
183
  const x = entry?.markerX ?? (panel.layout.legend.outside ? panel.layout.legend.left : panel.layout.plot.right - 24 * panel.layout.scale);
165
184
  const textX = entry?.textX ?? (panel.layout.legend.outside ? x + 12 * panel.layout.scale : x - 10 * panel.layout.scale);
166
185
  const y = entry?.y ?? (panel.layout.legend.outside ? panel.layout.legend.top + (14 + index * 20) * panel.layout.scale : insideTop + index * 20 * panel.layout.scale), style = item.style ?? {};
167
- const point = `<circle ${attrs({ cx: x, cy: y, r: 4 * panel.layout.scale, fill: "none", stroke: style.color ?? theme.series[item.colorIndex % theme.series.length] })}/>`;
186
+ let point = `<circle ${attrs({ cx: x, cy: y, r: 4 * panel.layout.scale, fill: "none", stroke: style.color ?? theme.series[item.colorIndex % theme.series.length] })}/>`;
187
+ if (panel.renderer === "line") {
188
+ const mark = { id: `${panel.id}-legend-${index}`, lineIdentity: true, style,
189
+ geometry: { cx: x, cy: y, ...lineMarkerGeometry(style, panel.layout.scale, panel.presentation?.markerScale ?? 1) } };
190
+ const half = 12 * Math.max(1, panel.layout.scale);
191
+ point = maskedLine({ ...mark, geometry: { x1: x - half, y1: y, x2: x + half, y2: y } }, [mark],
192
+ { left: x - half - 3, right: x + half + 3, top: y - 10, bottom: y + 10 }, `${namespace}-${safeId(panel.id)}-legend-${index}`) + linePoint(mark);
193
+ }
168
194
  const label = entry?.displayLabel ?? item.label;
169
195
  return `${point}<text ${attrs({ x: textX, y, fill: theme.label, "font-size": panel.layout.font.legend, "text-anchor": entry?.textAnchor ?? (panel.layout.legend.outside ? "start" : "end"), "dominant-baseline": "middle", "data-full-label": item.label })}><title>${esc(item.label)}</title>${esc(label)}</text>`;
170
196
  }).join("");
@@ -198,7 +224,10 @@ function panelHeaderSvg(panel, theme, responsive) {
198
224
 
199
225
  function panelSvg(panel, theme, profile, namespace, responsive = null) {
200
226
  if (!panel.resolved) throw new TypeError(`SVG export requires a scene-aware renderer; ${panel.renderer} remains on the compatibility path`);
201
- const render = (mark) => mark.kind === "point" ? marker(mark)
227
+ const render = (mark) => panel.renderer === "line" && mark.kind === "segment"
228
+ ? maskedLine(mark, panel.marks.filter(p => p.lineIdentity && p.series === mark.series), plotClipRect(panel), `${namespace}-${safeId(panel.id)}-${hashText(mark.id)}-identity`)
229
+ : mark.lineIdentity ? linePoint(mark)
230
+ : mark.kind === "point" ? marker(mark)
202
231
  : ["segment", "summary-line"].includes(mark.kind) ? segment(mark)
203
232
  : mark.kind === "median-rule" ? segment(mark)
204
233
  : mark.kind === "bar" ? bar(mark, theme)
@@ -220,7 +249,7 @@ function panelSvg(panel, theme, profile, namespace, responsive = null) {
220
249
  const title = panelHeaderSvg(panel, theme, responsive);
221
250
  const provenance = theme.mode !== "paper" && panel.spec.signature && (panel.layout.panelIndex ?? 0) === 0
222
251
  ? `<text ${attrs({ x: panel.layout.provenance?.left ?? panel.layout.plot.left, y: panel.layout.provenance?.y ?? panel.layout.rect.bottom - 8 * panel.layout.scale, fill: theme.faint, "font-size": panel.layout.font.signature, "text-anchor": "start", "data-layer": "provenance" })}>${esc(panel.spec.signature)}</text>` : "";
223
- return `<g ${attrs({ "data-panel-id": panel.id, "data-renderer": panel.renderer, "data-denominator": panel.denominator == null ? null : JSON.stringify(panel.denominator), "data-x-category-order": panel.categories.x?.join("|"), "data-y-category-order": panel.categories.y?.join("|") })}><defs><clipPath id="${esc(clipId)}" clipPathUnits="userSpaceOnUse"><rect ${attrs({ x: plot.left, y: plot.top, width: plot.right - plot.left, height: plot.bottom - plot.top })}/></clipPath></defs><g data-layer="surface">${panelSurface(panel, theme)}</g><g data-layer="grid">${grid(panel, theme, profile)}</g>${renderLayer("reference", layers.reference)}${renderLayer("data", dataMarks)}${renderLayer("summary", layers.summary)}<g data-layer="axes">${title}${axes(panel, theme)}${provenance}</g><g data-layer="annotations">${dataLabels}${annotations(panel, theme)}</g><g data-layer="legend">${legend(panel, theme)}${matrixLegend(panel, theme, namespace)}${denominator}</g></g>`;
252
+ return `<g ${attrs({ "data-panel-id": panel.id, "data-renderer": panel.renderer, "data-denominator": panel.denominator == null ? null : JSON.stringify(panel.denominator), "data-x-category-order": panel.categories.x?.join("|"), "data-y-category-order": panel.categories.y?.join("|") })}><defs><clipPath id="${esc(clipId)}" clipPathUnits="userSpaceOnUse"><rect ${attrs({ x: plot.left, y: plot.top, width: plot.right - plot.left, height: plot.bottom - plot.top })}/></clipPath></defs><g data-layer="surface">${panelSurface(panel, theme)}</g><g data-layer="grid">${grid(panel, theme, profile)}</g>${renderLayer("reference", layers.reference)}${renderLayer("data", dataMarks)}${renderLayer("summary", layers.summary)}<g data-layer="axes">${title}${axes(panel, theme)}${provenance}</g><g data-layer="annotations">${dataLabels}${annotations(panel, theme)}</g><g data-layer="legend">${legend(panel, theme, namespace)}${matrixLegend(panel, theme, namespace)}${denominator}</g></g>`;
224
253
  }
225
254
 
226
255
  export function resolvedSceneToSvg(resolved, options = {}) {
@@ -3,7 +3,7 @@ import { CORE_REGISTRY } from "./core-renderers.js";
3
3
  import { panelContract } from "./figure.js";
4
4
  import { resolvePanelDomains } from "./figure.js";
5
5
  import { resolveApplicationProfile } from "./application-profiles.js";
6
- import { legendWithStyles, resolveSeriesStyles } from "./series-style.js";
6
+ import { collectSeriesKeys, legendWithStyles, resolveSeriesStyles } from "./series-style.js";
7
7
  import { compileMotionPlan, assertTerminalMotionIdentity } from "./motion-plan.js";
8
8
  import { auditPaperTheme, themeResolutionForProfile } from "./paper-profile.js";
9
9
  import { validateEvidenceCoverage } from "./evidence-coverage.js";
@@ -117,6 +117,9 @@ export function compileFigureModel(input, options = {}) {
117
117
  ...panel,
118
118
  presentation: { ...profilePresentation(applicationProfile), ...(panel.presentation ?? {}) },
119
119
  }));
120
+ if (contract.style.directLabels && contract.panels.some(p => p.presentation.legend === "none")) throw new TypeError("directLabels requires the ordinary legend for fallback");
121
+ const directRanks = new Map(options.directRanks ?? []);
122
+ if (contract.style.directLabels) for (const key of collectSeriesKeys(contract)) if (!directRanks.has(key)) directRanks.set(key, directRanks.size);
120
123
  const styles = resolveSeriesStyles(contract);
121
124
  contract.seriesStyles = styles;
122
125
  contract.appearanceReport = applicationProfile.key === "paper" ? {
@@ -149,10 +152,15 @@ export function compileFigureModel(input, options = {}) {
149
152
  notes: [child.spec.note, ...(child.annotations ?? []).filter((item) => item?.type === "scientific_note").map((item) => item.text)].filter(Boolean),
150
153
  legend: compiled.legend ?? legendWithStyles(legend, keys, styles),
151
154
  meta: compiled.meta ?? null,
155
+ ...(contract.style.directLabels ? { directLabelsInput: { data: panel.renderer === "line" ? { ...child.data, series: child.data.series.map((s,i) => {
156
+ const authored = (input.panels?.[panelIndex]?.data ?? input.data)?.series?.[i];
157
+ return { ...s, label: authored && Object.hasOwn(authored,"label") ? authored.label : s.label };
158
+ }) } : child.data, ranks: Object.fromEntries(directRanks), pose: !!panel.presentation.curve && panel.presentation.curve !== "linear" || !!panel.presentation.seriesMarkers } } : {}),
152
159
  marks: defaultMarks,
153
160
  };
154
161
  });
155
162
  const scene = {
163
+ ...(contract.style.directLabels ? { directLabels: true, directRanks: Object.fromEntries(directRanks) } : {}),
156
164
  schemaVersion: TERMINAL_SCENE_VERSION,
157
165
  contractSchemaVersion: contract.schemaVersion,
158
166
  rendererApiVersion: contract.rendererApiVersion,
package/src/theme-pack.js CHANGED
@@ -251,6 +251,7 @@ export function contrastRatio(left, right) {
251
251
  return colorContrast(left, right);
252
252
  }
253
253
 
254
+ /** Authored palette inspection; does not model renderer compositing. */
254
255
  export function contrastAudit(theme) {
255
256
  if (theme.mode === "paper") return auditPaperTheme(theme).findings;
256
257
  const findings = [];
@@ -266,3 +267,33 @@ export function contrastAudit(theme) {
266
267
  });
267
268
  return findings;
268
269
  }
270
+
271
+ /** Supplied theme series colors in a caller-supplied single-layer context.
272
+ * Theme-level seriesEdges reject; contract/style overrides are not inspected.
273
+ * Callers must account for effective mark colors/layers before claiming rendered
274
+ * contrast. Verified line segments do not include companion markers/points.
275
+ * No defaults: encoded-sRGB blend, then luminance; passes = ratio >= 3 exactly.
276
+ * Final ULPs may differ across runtimes; this is not physical certification.
277
+ */
278
+ export function renderedSeriesAudit(theme, context) {
279
+ object(context, "renderContext");
280
+ const fields = ["substrate", "opacity", "compositing"];
281
+ if (Object.keys(context).length !== fields.length || fields.some(key => !hasOwn(context, key))) {
282
+ throw new FiguresteadConfigError("requires exactly substrate, opacity, compositing", "renderContext");
283
+ }
284
+ const { substrate, opacity, compositing } = context;
285
+ if (typeof substrate !== "string" || substrate.length !== 7 || !COLOR.test(substrate)) throw new FiguresteadConfigError("must be an opaque #RRGGBB color", "renderContext.substrate");
286
+ if (typeof opacity !== "number" || !Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new FiguresteadConfigError("must be a finite number in [0, 1]", "renderContext.opacity");
287
+ if (compositing !== "srgb-source-over") throw new FiguresteadConfigError("must be srgb-source-over", "renderContext.compositing");
288
+ if (theme.seriesEdges?.length) throw new FiguresteadConfigError("layered series edges are not supported by this single-layer audit", "theme.seriesEdges");
289
+ if (!Array.isArray(theme.series) || !theme.series.length || theme.series.some(c => typeof c !== "string" || c.length !== 7 || !COLOR.test(c))) throw new FiguresteadConfigError("must contain opaque #RRGGBB colors", "theme.series");
290
+ const background = hexChannels(substrate).map(c => c / 255);
291
+ const luminance = channels => channels.map(c => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
292
+ .reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
293
+ const back = luminance(background);
294
+ return theme.series.map((color, index) => {
295
+ const effectiveColor = hexChannels(color).map((c, i) => opacity * c / 255 + (1 - opacity) * background[i]);
296
+ const front = luminance(effectiveColor), ratio = (Math.max(front, back) + 0.05) / (Math.min(front, back) + 0.05);
297
+ return { token: `series[${index}]`, color, substrate, opacity, compositing, effectiveColor, ratio, minimum: 3, passes: ratio >= 3 };
298
+ });
299
+ }
package/types/index.d.ts CHANGED
@@ -162,6 +162,8 @@ export interface SeriesStyleOverride {
162
162
  }
163
163
 
164
164
  export interface FiguresteadStyle {
165
+ /** Opt-in single-panel terminal line labels; ordinary legend on unsupported/failed fit. */
166
+ directLabels?: boolean;
165
167
  glyphs: MarkerGlyph[];
166
168
  lineStyles: LineStyle[];
167
169
  series: Record<string, SeriesStyleOverride>;
@@ -529,7 +531,7 @@ export function evidenceFingerprint(scene: TerminalScene): string;
529
531
  export const RESOLVED_SCENE_VERSION: "figurestead.resolved-scene/1";
530
532
  export const RESOLVED_RENDERERS: readonly string[];
531
533
  export function isResolvedRenderer(renderer: string): boolean;
532
- export function resolveTerminalScene(scene: TerminalScene, options?: { width?: number; height?: number; measureText?: (text: string, fontSize: number) => { width: number; ascent?: number; descent?: number } }): ResolvedScene;
534
+ export function resolveTerminalScene(scene: TerminalScene, options?: { width?: number; height?: number; measureText?: (text: string, fontSize: number) => { width: number; ascent?: number; descent?: number; left?: number; right?: number } }): ResolvedScene;
533
535
  export function resolveSceneFrame(scene: ResolvedScene, progress?: number): ResolvedScene;
534
536
  export function resolveSceneFrame(scene: ComposedScene, progress?: number): ComposedScene;
535
537
  export function resolvedTerminalGeometry(scene: ResolvedScene | ComposedScene): UnknownRecord[];
@@ -609,6 +611,29 @@ export function validatePalettePack(input: PalettePack): UnknownRecord;
609
611
  export function resolvePalette(pack: PalettePack, key: string, options?: { mode?: ApplicationProfileKey }): UnknownRecord & { theme: FiguresteadTheme };
610
612
  export function contrastRatio(left: CanonicalColor, right: CanonicalColor): number;
611
613
  export function contrastAudit(theme: FiguresteadTheme): UnknownRecord[];
614
+ /** Caller-supplied single-layer facts; no renderer, substrate or opacity defaults. */
615
+ export interface SeriesRenderContext {
616
+ substrate: CanonicalColor;
617
+ opacity: number;
618
+ compositing: "srgb-source-over";
619
+ }
620
+ export interface RenderedSeriesContrast {
621
+ token: string;
622
+ color: CanonicalColor;
623
+ substrate: CanonicalColor;
624
+ opacity: number;
625
+ compositing: "srgb-source-over";
626
+ /** Unrounded encoded sRGB channels, each in [0, 1]. */
627
+ effectiveColor: number[];
628
+ ratio: number;
629
+ minimum: 3;
630
+ passes: boolean;
631
+ }
632
+ /** Rejects nonempty theme-level seriesEdges, not contract/style overrides.
633
+ * Caller must account for effective colors/layers. Line segment contexts exclude
634
+ * companion markers. Unrounded ratio >= 3; final ULPs may differ across runtimes.
635
+ */
636
+ export function renderedSeriesAudit(theme: FiguresteadTheme, context: SeriesRenderContext): RenderedSeriesContrast[];
612
637
 
613
638
  export const RENDER_LAYER_ORDER: readonly string[];
614
639
  export function renderLayerForMark(mark: UnknownRecord): string;