@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 +39 -4
- package/package.json +1 -1
- package/src/canvas-scene.js +43 -9
- package/src/composition.js +18 -8
- package/src/create-matrix-plot.js +22 -8
- package/src/direct-labels.js +73 -0
- package/src/export-bundle.js +1 -1
- package/src/index.js +1 -1
- package/src/line-identity.js +29 -0
- package/src/resolved-scene.js +22 -4
- package/src/schema.js +2 -1
- package/src/svg-export.js +37 -8
- package/src/terminal-scene.js +9 -1
- package/src/theme-pack.js +31 -0
- package/types/index.d.ts +26 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
package/src/canvas-scene.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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;
|
|
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);
|
package/src/composition.js
CHANGED
|
@@ -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(
|
|
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
|
|
87
|
-
const
|
|
88
|
-
const
|
|
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 ?
|
|
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) {
|
|
131
|
-
|
|
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
|
+
}
|
package/src/export-bundle.js
CHANGED
|
@@ -6,7 +6,7 @@ import { compileTerminalScene, evidenceFingerprint } from "./terminal-scene.js";
|
|
|
6
6
|
import { auditPhysicalTypography, resolveExportSize } from "./physical-export.js";
|
|
7
7
|
|
|
8
8
|
export const EXPORT_MANIFEST_VERSION = "figurestead.export-manifest/1";
|
|
9
|
-
export const FIGURESTEAD_PACKAGE_VERSION = "0.9.0-alpha.
|
|
9
|
+
export const FIGURESTEAD_PACKAGE_VERSION = "0.9.0-alpha.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
|
+
}
|
package/src/resolved-scene.js
CHANGED
|
@@ -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,
|
|
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")
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 = {}) {
|
package/src/terminal-scene.js
CHANGED
|
@@ -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;
|