@figurestead/web 0.9.0-alpha.1

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/THIRD_PARTY_NOTICES.md +39 -0
  4. package/TRADEMARKS.md +13 -0
  5. package/package.json +31 -0
  6. package/src/accessibility.js +33 -0
  7. package/src/appearance.js +25 -0
  8. package/src/application-profiles.js +76 -0
  9. package/src/atmosphere.js +71 -0
  10. package/src/canvas-scene.js +323 -0
  11. package/src/clock.js +27 -0
  12. package/src/color-space.js +114 -0
  13. package/src/composition.js +201 -0
  14. package/src/core-renderers.js +33 -0
  15. package/src/create-matrix-plot.js +97 -0
  16. package/src/evidence-coverage.js +80 -0
  17. package/src/export-bundle.js +83 -0
  18. package/src/extensions/temporal/coverage.js +96 -0
  19. package/src/extensions/temporal/index.js +43 -0
  20. package/src/extensions/temporal/observations.js +87 -0
  21. package/src/extensions/temporal/shared.js +172 -0
  22. package/src/figure-layout.js +70 -0
  23. package/src/figure.js +42 -0
  24. package/src/index.js +30 -0
  25. package/src/layout.js +41 -0
  26. package/src/marks.js +218 -0
  27. package/src/motion-plan.js +66 -0
  28. package/src/motion-recipes.js +39 -0
  29. package/src/paper-profile.js +78 -0
  30. package/src/physical-export.js +34 -0
  31. package/src/presentation.js +127 -0
  32. package/src/primitives.js +24 -0
  33. package/src/random.js +26 -0
  34. package/src/registry.js +29 -0
  35. package/src/render-layers.js +35 -0
  36. package/src/renderer-test-kit.js +29 -0
  37. package/src/renderers/line.js +133 -0
  38. package/src/renderers/scatter.js +24 -0
  39. package/src/renderers/shared.js +21 -0
  40. package/src/renderers/strip-summary.js +40 -0
  41. package/src/resolved-scene.js +249 -0
  42. package/src/scales.js +68 -0
  43. package/src/schema.js +356 -0
  44. package/src/series-style.js +59 -0
  45. package/src/svg-export.js +232 -0
  46. package/src/terminal-scene.js +215 -0
  47. package/src/theme-catalog.js +34 -0
  48. package/src/theme-pack.js +268 -0
package/src/schema.js ADDED
@@ -0,0 +1,356 @@
1
+ export const SCHEMA_VERSION = "0.4";
2
+ export const LEGACY_SCHEMA_VERSION = "0.3";
3
+ export const RENDERER_API_VERSION = "1";
4
+ export const RENDERERS = Object.freeze(["line", "scatter", "strip_summary"]);
5
+
6
+ export class FiguresteadConfigError extends Error {
7
+ constructor(message, path = "config") {
8
+ super(`${path}: ${message}`);
9
+ this.name = "FiguresteadConfigError";
10
+ this.path = path;
11
+ }
12
+ }
13
+
14
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
15
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
16
+
17
+ export function cloneValue(value) {
18
+ if (globalThis.structuredClone) return globalThis.structuredClone(value);
19
+ return JSON.parse(JSON.stringify(value));
20
+ }
21
+
22
+ export function requiredObject(value, path) {
23
+ if (!isObject(value)) throw new FiguresteadConfigError("must be an object", path);
24
+ }
25
+
26
+ export function requiredString(value, path) {
27
+ if (typeof value !== "string" || !value.trim()) {
28
+ throw new FiguresteadConfigError("must be a non-empty string", path);
29
+ }
30
+ }
31
+
32
+ export function numberArray(value, path, { allowEmpty = false } = {}) {
33
+ if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) {
34
+ throw new FiguresteadConfigError("must be a non-empty numeric array", path);
35
+ }
36
+ value.forEach((item, index) => {
37
+ if (!isFiniteNumber(item)) throw new FiguresteadConfigError("must be a finite number", `${path}[${index}]`);
38
+ });
39
+ return value;
40
+ }
41
+
42
+ export function sameLength(value, length, path) {
43
+ if (!Array.isArray(value) || value.length !== length) {
44
+ throw new FiguresteadConfigError(`must contain exactly ${length} items`, path);
45
+ }
46
+ }
47
+
48
+ export function domain(value, path) {
49
+ if (value == null) return null;
50
+ if (!Array.isArray(value) || value.length !== 2 || !value.every(isFiniteNumber) || value[0] >= value[1]) {
51
+ throw new FiguresteadConfigError("must be two strictly increasing finite numbers", path);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ function timelineWindow(value, path) {
57
+ if (!Array.isArray(value) || value.length !== 2 || !value.every(isFiniteNumber)) {
58
+ throw new FiguresteadConfigError("must contain two numbers", path);
59
+ }
60
+ if (value[0] < 0 || value[1] > 1 || value[0] > value[1]) {
61
+ throw new FiguresteadConfigError("must satisfy 0 <= start <= end <= 1", path);
62
+ }
63
+ }
64
+
65
+ function validateTheme(theme) {
66
+ requiredObject(theme, "config.theme");
67
+ ["key", "name", "field", "panel", "grid", "spine", "label", "secondary", "faint", "primary", "summaryCore", "warm"]
68
+ .forEach((key) => requiredString(theme[key], `config.theme.${key}`));
69
+ if (!Array.isArray(theme.series) || !theme.series.length) {
70
+ throw new FiguresteadConfigError("must be a non-empty color array", "config.theme.series");
71
+ }
72
+ theme.series.forEach((color, index) => requiredString(color, `config.theme.series[${index}]`));
73
+ for (const key of ["primaryEdge", "summaryEdge"]) if (theme[key] != null) requiredString(theme[key], `config.theme.${key}`);
74
+ if (theme.seriesEdges != null) {
75
+ if (!Array.isArray(theme.seriesEdges) || theme.seriesEdges.length !== theme.series.length) throw new FiguresteadConfigError(`must contain exactly ${theme.series.length} colors`, "config.theme.seriesEdges");
76
+ theme.seriesEdges.forEach((color, index) => requiredString(color, `config.theme.seriesEdges[${index}]`));
77
+ }
78
+ }
79
+
80
+ function validateProfile(profile) {
81
+ requiredObject(profile, "config.profile");
82
+ ["key", "name", "marker"].forEach((key) => requiredString(profile[key], `config.profile.${key}`));
83
+ ["markerSize", "markerAlpha", "edgeWidth", "coreFraction", "gridAlpha"].forEach((key) => {
84
+ if (!isFiniteNumber(profile[key])) throw new FiguresteadConfigError("must be a finite number", `config.profile.${key}`);
85
+ });
86
+ ["pointGlow", "gridX", "gridY", "summaryGlow"].forEach((key) => {
87
+ if (typeof profile[key] !== "boolean") throw new FiguresteadConfigError("must be boolean", `config.profile.${key}`);
88
+ });
89
+ }
90
+
91
+ function validateTimeline(timeline) {
92
+ requiredObject(timeline, "config.timeline");
93
+ ["rainIn", "marksEnter", "summaryCompiles", "rainOut", "settle"]
94
+ .forEach((key) => timelineWindow(timeline[key], `config.timeline.${key}`));
95
+ }
96
+
97
+ function validateMotion(motion) {
98
+ requiredObject(motion, "config.motion");
99
+ ["durationMs", "lightingPeak", "trailAlpha"].forEach((key) => {
100
+ if (!isFiniteNumber(motion[key]) || motion[key] < 0) {
101
+ throw new FiguresteadConfigError("must be a non-negative finite number", `config.motion.${key}`);
102
+ }
103
+ });
104
+ ["frames", "fps", "rainStreams", "rainGlyphs", "seed"].forEach((key) => {
105
+ if (!Number.isInteger(motion[key]) || (key !== "seed" && motion[key] < 0)) {
106
+ throw new FiguresteadConfigError("must be an integer", `config.motion.${key}`);
107
+ }
108
+ });
109
+ if (motion.durationMs <= 0) throw new FiguresteadConfigError("must be greater than zero", "config.motion.durationMs");
110
+ }
111
+
112
+ function normalizeSpec(spec) {
113
+ requiredObject(spec, "config.spec");
114
+ requiredString(spec.title, "config.spec.title");
115
+ return {
116
+ title: spec.title,
117
+ subtitle: spec.subtitle ?? "",
118
+ xLabel: spec.xLabel ?? "",
119
+ yLabel: spec.yLabel ?? "",
120
+ note: spec.note ?? "",
121
+ signature: spec.signature ?? "figurestead",
122
+ description: spec.description ?? "",
123
+ };
124
+ }
125
+
126
+ function normalizeSeriesLabels(keys, supplied) {
127
+ if (supplied != null && !isObject(supplied)) {
128
+ throw new FiguresteadConfigError("must be an object", "config.data.seriesLabels");
129
+ }
130
+ const labels = {};
131
+ keys.forEach((key) => { labels[String(key)] = supplied?.[String(key)] ?? String(key); });
132
+ return labels;
133
+ }
134
+
135
+ export function normalizeLineData(data, basePath = "config.data") {
136
+ requiredObject(data, basePath);
137
+ const x = numberArray(data.x, `${basePath}.x`);
138
+ if (!Array.isArray(data.series) || !data.series.length) {
139
+ throw new FiguresteadConfigError("must be a non-empty series array", `${basePath}.series`);
140
+ }
141
+ const seen = new Set();
142
+ const series = data.series.map((item, index) => {
143
+ requiredObject(item, `${basePath}.series[${index}]`);
144
+ requiredString(item.key, `${basePath}.series[${index}].key`);
145
+ if (seen.has(item.key)) throw new FiguresteadConfigError("must be unique", `${basePath}.series[${index}].key`);
146
+ seen.add(item.key);
147
+ const y = numberArray(item.y, `${basePath}.series[${index}].y`);
148
+ sameLength(y, x.length, `${basePath}.series[${index}].y`);
149
+ return { key: item.key, label: item.label || item.key, y };
150
+ });
151
+ const revealOrder = data.revealOrder ?? "random";
152
+ if (!new Set(["random", "x"]).has(revealOrder)) {
153
+ throw new FiguresteadConfigError("must be 'random' or 'x'", `${basePath}.revealOrder`);
154
+ }
155
+ if (revealOrder === "x" && x.some((value, index) => index && value < x[index - 1])) {
156
+ throw new FiguresteadConfigError("requires nondecreasing x values", `${basePath}.revealOrder`);
157
+ }
158
+ return { x, series, revealOrder, xDomain: domain(data.xDomain, `${basePath}.xDomain`), yDomain: domain(data.yDomain, `${basePath}.yDomain`) };
159
+ }
160
+
161
+ export function normalizeScatterData(data, basePath = "config.data") {
162
+ requiredObject(data, basePath);
163
+ const x = numberArray(data.x, `${basePath}.x`);
164
+ const y = numberArray(data.y, `${basePath}.y`);
165
+ sameLength(y, x.length, `${basePath}.y`);
166
+ const series = data.series == null ? Array(x.length).fill("series") : data.series;
167
+ sameLength(series, x.length, `${basePath}.series`);
168
+ const keys = [...new Set(series.map(String))];
169
+ const summary = data.summary ?? null;
170
+ if (summary !== null && summary !== "linear_fit") {
171
+ throw new FiguresteadConfigError("must be null or 'linear_fit'", `${basePath}.summary`);
172
+ }
173
+ return {
174
+ x, y, series: series.map(String), seriesLabels: normalizeSeriesLabels(keys, data.seriesLabels), summary,
175
+ xDomain: domain(data.xDomain, `${basePath}.xDomain`), yDomain: domain(data.yDomain, `${basePath}.yDomain`),
176
+ };
177
+ }
178
+
179
+ export function normalizeStripData(data, basePath = "config.data") {
180
+ requiredObject(data, basePath);
181
+ if (!Array.isArray(data.groups) || !data.groups.length) {
182
+ throw new FiguresteadConfigError("must be a non-empty category-order array", `${basePath}.groups`);
183
+ }
184
+ const groups = data.groups.map(String);
185
+ if (new Set(groups).size !== groups.length) throw new FiguresteadConfigError("categories must be unique", `${basePath}.groups`);
186
+ const values = numberArray(data.values, `${basePath}.values`);
187
+ sameLength(data.group, values.length, `${basePath}.group`);
188
+ const assignment = data.group.map(String);
189
+ assignment.forEach((group, index) => {
190
+ if (!groups.includes(group)) throw new FiguresteadConfigError(`unknown category ${group}`, `${basePath}.group[${index}]`);
191
+ });
192
+ const series = data.series == null ? Array(values.length).fill("series") : data.series;
193
+ sameLength(series, values.length, `${basePath}.series`);
194
+ const keys = [...new Set(series.map(String))];
195
+ const summary = data.summary ?? null;
196
+ if (summary !== null && summary !== "median") {
197
+ throw new FiguresteadConfigError("must be null or 'median'", `${basePath}.summary`);
198
+ }
199
+ return {
200
+ groups, values, group: assignment, series: series.map(String),
201
+ seriesLabels: normalizeSeriesLabels(keys, data.seriesLabels), summary,
202
+ yDomain: domain(data.yDomain, `${basePath}.yDomain`),
203
+ };
204
+ }
205
+
206
+ const FALLBACK_VALIDATORS = {
207
+ line: normalizeLineData,
208
+ scatter: normalizeScatterData,
209
+ strip_summary: normalizeStripData,
210
+ };
211
+
212
+ function normalizeScale(value, path, fallbackType) {
213
+ if (value == null) return { type: fallbackType, domain: null, label: "" };
214
+ requiredObject(value, path);
215
+ const type = value.type ?? fallbackType;
216
+ if (!["linear", "time", "band"].includes(type)) throw new FiguresteadConfigError("type must be linear, time, or band", `${path}.type`);
217
+ let normalizedDomain = value.domain ?? null;
218
+ if (normalizedDomain != null) {
219
+ if (!Array.isArray(normalizedDomain) || (type === "band" ? !normalizedDomain.length : normalizedDomain.length !== 2)) {
220
+ throw new FiguresteadConfigError(type === "band" ? "domain must be a non-empty category array" : "domain must contain two values", `${path}.domain`);
221
+ }
222
+ if (type === "linear") normalizedDomain = domain(normalizedDomain, `${path}.domain`);
223
+ if (type === "time" && normalizedDomain.some((item) => !Number.isFinite(typeof item === "number" ? item : Date.parse(item)))) {
224
+ throw new FiguresteadConfigError("time domain values must be ISO dates or epoch milliseconds", `${path}.domain`);
225
+ }
226
+ }
227
+ return { type, domain: normalizedDomain, label: value.label ?? "", nice: value.nice !== false, padding: value.padding ?? 0.12 };
228
+ }
229
+
230
+ function normalizeLayout(value, panelCount) {
231
+ const layout = value ?? {};
232
+ requiredObject(layout, "config.layout");
233
+ const columns = layout.columns ?? 1, gap = layout.gap ?? 22;
234
+ if (!Number.isInteger(columns) || columns < 1) throw new FiguresteadConfigError("must be a positive integer", "config.layout.columns");
235
+ if (!isFiniteNumber(gap) || gap < 0) throw new FiguresteadConfigError("must be a non-negative number", "config.layout.gap");
236
+ ["sharedX", "sharedY"].forEach((key) => { if (layout[key] != null && typeof layout[key] !== "boolean") throw new FiguresteadConfigError("must be boolean", `config.layout.${key}`); });
237
+ return { type: "grid", columns: Math.min(columns, panelCount), gap, sharedX: Boolean(layout.sharedX), sharedY: Boolean(layout.sharedY) };
238
+ }
239
+
240
+ const LEGEND_POSITIONS = new Set(["auto", "top-right", "top-left", "bottom-right", "bottom-left", "outside-right", "none"]);
241
+ const CURVE_TYPES = new Set(["linear", "monotone"]);
242
+ const MARKER_TYPES = new Set(["ring", "square", "triangle", "diamond"]);
243
+
244
+ function normalizePresentation(value, path) {
245
+ if (value == null) return null;
246
+ requiredObject(value, path);
247
+ const result = {};
248
+ for (const key of ["panelSurface", "frame"]) {
249
+ if (value[key] != null && typeof value[key] !== "boolean") throw new FiguresteadConfigError("must be boolean", `${path}.${key}`);
250
+ if (value[key] != null) result[key] = value[key];
251
+ }
252
+ if (value.curve != null && !CURVE_TYPES.has(value.curve)) throw new FiguresteadConfigError("must be linear or monotone", `${path}.curve`);
253
+ if (value.curve != null) result.curve = value.curve;
254
+ if (value.legend != null && !LEGEND_POSITIONS.has(value.legend)) throw new FiguresteadConfigError("must be auto, top-right, top-left, bottom-right, bottom-left, outside-right, or none", `${path}.legend`);
255
+ if (value.legend != null) result.legend = value.legend;
256
+ for (const [key, minimum, maximum] of [["lineWidth", 0.5, 6], ["markerScale", 0.5, 2.5]]) {
257
+ if (value[key] != null && (!isFiniteNumber(value[key]) || value[key] < minimum || value[key] > maximum)) throw new FiguresteadConfigError(`must be between ${minimum} and ${maximum}`, `${path}.${key}`);
258
+ if (value[key] != null) result[key] = value[key];
259
+ }
260
+ if (value.seriesMarkers != null) {
261
+ if (!Array.isArray(value.seriesMarkers) || !value.seriesMarkers.length) throw new FiguresteadConfigError("must be a non-empty marker array", `${path}.seriesMarkers`);
262
+ value.seriesMarkers.forEach((marker, index) => { if (!MARKER_TYPES.has(marker)) throw new FiguresteadConfigError("must be ring, square, triangle, or diamond", `${path}.seriesMarkers[${index}]`); });
263
+ result.seriesMarkers = [...value.seriesMarkers];
264
+ }
265
+ return result;
266
+ }
267
+
268
+ function normalizeEncoding(value, path, presentation) {
269
+ const encoding = value ?? {};
270
+ requiredObject(encoding, path);
271
+ const interpolation = encoding.interpolation ?? presentation?.curve ?? "linear";
272
+ if (!CURVE_TYPES.has(interpolation)) throw new FiguresteadConfigError("must be linear or monotone", `${path}.interpolation`);
273
+ return { interpolation };
274
+ }
275
+
276
+ function normalizeView(value) {
277
+ const view = value ?? {};
278
+ requiredObject(view, "config.view");
279
+ const profile = view.profile ?? "atlas", motion = view.motion ?? "legacy", ambient = view.ambient ?? "none", strategy = view.strategy ?? "auto";
280
+ if (!["paper", "atlas", "talk"].includes(profile)) throw new FiguresteadConfigError("must be paper, atlas, or talk", "config.view.profile");
281
+ if (!["none", "semantic", "legacy"].includes(motion)) throw new FiguresteadConfigError("must be none, semantic, or legacy", "config.view.motion");
282
+ if (!["none", "matrix"].includes(ambient)) throw new FiguresteadConfigError("must be none or matrix", "config.view.ambient");
283
+ if (!["auto", "none", "reveal", "points_then_connect", "bar_grow", "matrix_illuminate"].includes(strategy)) throw new FiguresteadConfigError("is not a supported motion strategy", "config.view.strategy");
284
+ return { profile, motion, ambient, strategy };
285
+ }
286
+
287
+ function normalizeStyle(value) {
288
+ const style = value ?? {};
289
+ requiredObject(style, "config.style");
290
+ const glyphs = style.glyphs ?? ["ring", "square", "triangle", "diamond"];
291
+ if (!Array.isArray(glyphs) || !glyphs.length) throw new FiguresteadConfigError("must be a non-empty array", "config.style.glyphs");
292
+ glyphs.forEach((glyph, index) => { if (!MARKER_TYPES.has(glyph)) throw new FiguresteadConfigError("is not a supported glyph", `config.style.glyphs[${index}]`); });
293
+ const lineStyles = style.lineStyles ?? ["solid", "dash", "dot", "dash-dot"];
294
+ 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");
295
+ const series = style.series ?? {};
296
+ requiredObject(series, "config.style.series");
297
+ return { glyphs: [...glyphs], lineStyles: [...lineStyles], series: cloneValue(series) };
298
+ }
299
+
300
+ function normalizePanel(panel, index, figureSpec, registry) {
301
+ const path = `config.panels[${index}]`; requiredObject(panel, path); requiredString(panel.renderer, `${path}.renderer`);
302
+ const definition = registry?.get(panel.renderer);
303
+ const validator = definition?.validateData ?? FALLBACK_VALIDATORS[panel.renderer];
304
+ if (!validator) throw new FiguresteadConfigError(`unknown renderer ${JSON.stringify(panel.renderer)}${registry ? `; expected ${registry.keys().join(", ")}` : ""}`, `${path}.renderer`);
305
+ const panelSpec = panel.spec == null ? {} : panel.spec; requiredObject(panelSpec, `${path}.spec`);
306
+ const spec = { title: panelSpec.title ?? "", subtitle: panelSpec.subtitle ?? "", xLabel: panelSpec.xLabel ?? "", yLabel: panelSpec.yLabel ?? "", description: panelSpec.description ?? "", note: panelSpec.note ?? "", signature: panelSpec.signature ?? figureSpec.signature };
307
+ const presentation = panel.presentation == null ? null : normalizePresentation(panel.presentation, `${path}.presentation`);
308
+ return {
309
+ id: panel.id ?? `panel-${index + 1}`, renderer: panel.renderer, spec,
310
+ xScale: normalizeScale(panel.xScale, `${path}.xScale`, panel.renderer === "strip_summary" ? "band" : "linear"),
311
+ yScale: normalizeScale(panel.yScale, `${path}.yScale`, "linear"),
312
+ annotations: Array.isArray(panel.annotations) ? cloneValue(panel.annotations) : [],
313
+ ...(presentation == null ? {} : { presentation }),
314
+ encoding: normalizeEncoding(panel.encoding, `${path}.encoding`, presentation),
315
+ data: validator(panel.data, `${path}.data`),
316
+ };
317
+ }
318
+
319
+ function validateSharedScaleCompatibility(contract) {
320
+ for (const axis of ["X", "Y"]) {
321
+ if (!contract.layout[`shared${axis}`]) continue;
322
+ const key = `${axis.toLowerCase()}Scale`;
323
+ const types = [...new Set(contract.panels.map((panel) => panel[key].type))];
324
+ if (types.length !== 1) throw new FiguresteadConfigError(`shared${axis} requires one scale type; found ${types.join(", ")}`, `config.layout.shared${axis}`);
325
+ if (types[0] === "band") throw new FiguresteadConfigError(`shared${axis} categorical domains are not supported in renderer API 1`, `config.layout.shared${axis}`);
326
+ }
327
+ }
328
+
329
+ export function validateContract(input, registry = null) {
330
+ requiredObject(input, "config");
331
+ if (![SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(input.schemaVersion)) throw new FiguresteadConfigError(`unsupported schema version ${JSON.stringify(input.schemaVersion)}; expected ${SCHEMA_VERSION} or legacy ${LEGACY_SCHEMA_VERSION}`, "config.schemaVersion");
332
+ validateTheme(input.theme);
333
+ validateProfile(input.profile);
334
+ validateTimeline(input.timeline);
335
+ validateMotion(input.motion);
336
+ const spec = normalizeSpec(input.spec), legacy = input.schemaVersion === LEGACY_SCHEMA_VERSION;
337
+ const sourcePanels = legacy ? [{ id: "panel-1", renderer: input.renderer, spec: {}, data: input.data }] : input.panels;
338
+ if (!Array.isArray(sourcePanels) || !sourcePanels.length) throw new FiguresteadConfigError("must be a non-empty array", "config.panels");
339
+ if (!legacy && input.rendererApiVersion !== RENDERER_API_VERSION) throw new FiguresteadConfigError(`expected renderer API ${RENDERER_API_VERSION}`, "config.rendererApiVersion");
340
+ const contract = cloneValue(input);
341
+ delete contract.renderer; delete contract.data;
342
+ contract.schemaVersion = SCHEMA_VERSION; contract.rendererApiVersion = RENDERER_API_VERSION; contract.spec = spec;
343
+ contract.panels = sourcePanels.map((panel, index) => normalizePanel(panel, index, spec, registry));
344
+ contract.layout = normalizeLayout(legacy ? { columns: 1 } : input.layout, contract.panels.length);
345
+ contract.view = normalizeView(input.view);
346
+ contract.style = normalizeStyle(input.style);
347
+ validateSharedScaleCompatibility(contract);
348
+ contract.sourceSchemaVersion = input.schemaVersion;
349
+ return contract;
350
+ }
351
+
352
+ export function windowProgress(progress, window) {
353
+ const [start, end] = window;
354
+ if (end === start) return progress >= end ? 1 : 0;
355
+ return Math.max(0, Math.min(1, (progress - start) / (end - start)));
356
+ }
@@ -0,0 +1,59 @@
1
+ import { cloneValue } from "./schema.js";
2
+
3
+ export const SERIES_STYLE_VERSION = "figurestead.series-style/1";
4
+ export const GLYPH_CYCLE = Object.freeze(["ring", "square", "triangle", "diamond"]);
5
+ export const LINE_STYLE_CYCLE = Object.freeze(["solid", "dash", "dot", "dash-dot"]);
6
+ export const HATCH_CYCLE = Object.freeze(["none", "diag", "cross", "vertical"]);
7
+
8
+ function seriesKeys(panel) {
9
+ const data = panel.data ?? {};
10
+ if (Array.isArray(data.series)) {
11
+ if (data.series.length && typeof data.series[0] === "object") return data.series.map((item) => String(item.key));
12
+ return [...new Set(data.series.map(String))];
13
+ }
14
+ if (Array.isArray(data.entries)) return [...new Set(data.entries.map((item) => String(item.series ?? "series")))];
15
+ if (panel.renderer === "categorical_matrix") return ["value"];
16
+ return ["series"];
17
+ }
18
+
19
+ export function collectSeriesKeys(contract) {
20
+ const keys = [];
21
+ contract.panels.forEach((panel) => seriesKeys(panel).forEach((key) => { if (!keys.includes(key)) keys.push(key); }));
22
+ return keys;
23
+ }
24
+
25
+ export function resolveSeriesStyles(contract) {
26
+ const markers = contract.style?.glyphs ?? GLYPH_CYCLE;
27
+ const lineStyles = contract.style?.lineStyles ?? LINE_STYLE_CYCLE;
28
+ const overrides = contract.style?.series ?? {};
29
+ return Object.freeze(Object.fromEntries(collectSeriesKeys(contract).map((key, index) => {
30
+ const colorIndex = index % contract.theme.series.length;
31
+ const base = {
32
+ key,
33
+ colorIndex,
34
+ color: contract.theme.series[colorIndex],
35
+ edge: contract.theme.seriesEdges?.[colorIndex] ?? null,
36
+ glyph: markers[index % markers.length],
37
+ lineStyle: lineStyles[Math.floor(index / Math.max(1, markers.length)) % lineStyles.length],
38
+ hatch: HATCH_CYCLE[index % HATCH_CYCLE.length],
39
+ lineWidth: contract.applicationProfile?.lineWidth ?? contract.panels[0]?.presentation?.lineWidth ?? 1.35,
40
+ };
41
+ return [key, Object.freeze({ ...base, ...(overrides[key] ?? {}) })];
42
+ })));
43
+ }
44
+
45
+ export function styleForSeries(env, key, fallbackIndex = 0) {
46
+ const resolved = env.figure?.seriesStyles?.[String(key)] ?? env.seriesStyles?.[String(key)];
47
+ if (resolved) return resolved;
48
+ const theme = env.contract.theme, colorIndex = fallbackIndex % theme.series.length;
49
+ return {
50
+ key: String(key), colorIndex, color: theme.series[colorIndex],
51
+ edge: theme.seriesEdges?.[colorIndex] ?? null,
52
+ glyph: env.contract.presentation?.seriesMarkers?.[fallbackIndex % Math.max(1, env.contract.presentation.seriesMarkers.length)] ?? GLYPH_CYCLE[fallbackIndex % GLYPH_CYCLE.length],
53
+ lineStyle: "solid", hatch: HATCH_CYCLE[fallbackIndex % HATCH_CYCLE.length], lineWidth: env.contract.presentation?.lineWidth ?? 1.35,
54
+ };
55
+ }
56
+
57
+ export function legendWithStyles(legend, keys, styles) {
58
+ return legend.map((item, index) => ({ ...cloneValue(item), key: keys[index] ?? item.key ?? String(index), style: styles[keys[index] ?? item.key ?? String(index)] ?? null }));
59
+ }