@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.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/THIRD_PARTY_NOTICES.md +39 -0
- package/TRADEMARKS.md +13 -0
- package/package.json +31 -0
- package/src/accessibility.js +33 -0
- package/src/appearance.js +25 -0
- package/src/application-profiles.js +76 -0
- package/src/atmosphere.js +71 -0
- package/src/canvas-scene.js +323 -0
- package/src/clock.js +27 -0
- package/src/color-space.js +114 -0
- package/src/composition.js +201 -0
- package/src/core-renderers.js +33 -0
- package/src/create-matrix-plot.js +97 -0
- package/src/evidence-coverage.js +80 -0
- package/src/export-bundle.js +83 -0
- package/src/extensions/temporal/coverage.js +96 -0
- package/src/extensions/temporal/index.js +43 -0
- package/src/extensions/temporal/observations.js +87 -0
- package/src/extensions/temporal/shared.js +172 -0
- package/src/figure-layout.js +70 -0
- package/src/figure.js +42 -0
- package/src/index.js +30 -0
- package/src/layout.js +41 -0
- package/src/marks.js +218 -0
- package/src/motion-plan.js +66 -0
- package/src/motion-recipes.js +39 -0
- package/src/paper-profile.js +78 -0
- package/src/physical-export.js +34 -0
- package/src/presentation.js +127 -0
- package/src/primitives.js +24 -0
- package/src/random.js +26 -0
- package/src/registry.js +29 -0
- package/src/render-layers.js +35 -0
- package/src/renderer-test-kit.js +29 -0
- package/src/renderers/line.js +133 -0
- package/src/renderers/scatter.js +24 -0
- package/src/renderers/shared.js +21 -0
- package/src/renderers/strip-summary.js +40 -0
- package/src/resolved-scene.js +249 -0
- package/src/scales.js +68 -0
- package/src/schema.js +356 -0
- package/src/series-style.js +59 -0
- package/src/svg-export.js +232 -0
- package/src/terminal-scene.js +215 -0
- package/src/theme-catalog.js +34 -0
- package/src/theme-pack.js +268 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { clamp01, smooth } from "./marks.js";
|
|
2
|
+
|
|
3
|
+
export const MOTION_PLAN_VERSION = "figurestead.motion-plan/1";
|
|
4
|
+
export const ALLOWED_MOTION_CHANNELS = Object.freeze(["opacity", "translate", "scale", "clip", "glow"]);
|
|
5
|
+
export const TERMINAL_MOTION_STATE = Object.freeze({ opacity: 1, translateX: 0, translateY: 0, scaleX: 1, scaleY: 1, clip: 1, glow: 0 });
|
|
6
|
+
|
|
7
|
+
const STRATEGY_CHANNELS = Object.freeze({
|
|
8
|
+
none: [], reveal: ["opacity", "clip"], points_then_connect: ["opacity", "translate", "clip", "glow"],
|
|
9
|
+
bar_grow: ["opacity", "scale", "clip"], matrix_illuminate: ["opacity", "glow"],
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const AUTO = Object.freeze({
|
|
13
|
+
line: "points_then_connect", scatter: "points_then_connect",
|
|
14
|
+
categorical_bar: "bar_grow", categorical_layered_bar: "bar_grow",
|
|
15
|
+
categorical_matrix: "matrix_illuminate",
|
|
16
|
+
interval_comparison: "points_then_connect",
|
|
17
|
+
strip_summary: "points_then_connect",
|
|
18
|
+
temporal_coverage: "reveal",
|
|
19
|
+
temporal_observations: "reveal",
|
|
20
|
+
paired_points: "points_then_connect",
|
|
21
|
+
reference_improvement: "reveal",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function strategyForRenderer(renderer, requested = "auto") {
|
|
25
|
+
if (requested === "auto") return AUTO[renderer] ?? "reveal";
|
|
26
|
+
return requested ?? "none";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function compileMotionPlan(scene, view = {}) {
|
|
30
|
+
const motion = view.motion ?? "none", requested = view.strategy ?? "auto";
|
|
31
|
+
const panels = scene.panels.map((panel) => ({
|
|
32
|
+
panelId: panel.id,
|
|
33
|
+
renderer: panel.renderer,
|
|
34
|
+
strategy: motion === "none" ? "none" : strategyForRenderer(panel.renderer, requested),
|
|
35
|
+
targets: panel.marks.map((mark, index) => {
|
|
36
|
+
const strategy = motion === "none" ? "none" : strategyForRenderer(panel.renderer, requested);
|
|
37
|
+
return { id: mark.id, order: index, channels: [...(STRATEGY_CHANNELS[strategy] ?? STRATEGY_CHANNELS.reveal)] };
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
return Object.freeze({ schemaVersion: MOTION_PLAN_VERSION, motion, panels });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function markMotionState(mark, index, count, progress, strategy = "reveal") {
|
|
44
|
+
if (progress >= 1 || strategy === "none") return { ...TERMINAL_MOTION_STATE };
|
|
45
|
+
const stagger = count <= 1 ? 0 : (index / (count - 1)) * 0.28;
|
|
46
|
+
const local = smooth(clamp01((progress - stagger) / Math.max(1e-9, 1 - stagger)));
|
|
47
|
+
if (strategy === "points_then_connect") {
|
|
48
|
+
const point = mark.kind === "point", pointLocal = smooth(clamp01(local / 0.62));
|
|
49
|
+
const lineLocal = smooth(clamp01((local - 0.48) / 0.52));
|
|
50
|
+
return { opacity: point ? pointLocal : lineLocal, translateX: 0, translateY: point ? (1 - pointLocal) * -12 : 0, scaleX: 1, scaleY: 1, clip: point ? 1 : lineLocal, glow: point ? Math.sin(Math.PI * pointLocal) * 0.18 : 0 };
|
|
51
|
+
}
|
|
52
|
+
if (strategy === "bar_grow") return { opacity: local, translateX: 0, translateY: 0, scaleX: mark.orientation === "horizontal" ? local : 1, scaleY: mark.orientation === "horizontal" ? 1 : local, clip: local, glow: 0 };
|
|
53
|
+
if (strategy === "matrix_illuminate") return { opacity: local, translateX: 0, translateY: 0, scaleX: 1, scaleY: 1, clip: 1, glow: Math.sin(Math.PI * local) * 0.14 };
|
|
54
|
+
return { opacity: local, translateX: 0, translateY: 0, scaleX: 1, scaleY: 1, clip: local, glow: 0 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function assertTerminalMotionIdentity(plan, scene) {
|
|
58
|
+
plan.panels.forEach((panel) => {
|
|
59
|
+
const scenePanel = scene.panels.find((item) => item.id === panel.panelId);
|
|
60
|
+
panel.targets.forEach((target, index) => {
|
|
61
|
+
const state = markMotionState(scenePanel.marks[index], index, panel.targets.length, 1, panel.strategy);
|
|
62
|
+
if (Object.entries(TERMINAL_MOTION_STATE).some(([channel, value]) => state[channel] !== value)) throw new Error(`motion plan ${target.id} changes terminal evidence`);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cloneValue } from "./schema.js";
|
|
2
|
+
|
|
3
|
+
export const MOTION_RECIPE_VERSION = "figurestead.motion-recipe/1";
|
|
4
|
+
const MOTION_VALUES = new Set(["none", "semantic", "legacy"]);
|
|
5
|
+
const AMBIENT_VALUES = new Set(["none", "matrix"]);
|
|
6
|
+
const STRATEGY_VALUES = new Set(["auto", "none", "reveal", "points_then_connect", "bar_grow", "matrix_illuminate"]);
|
|
7
|
+
|
|
8
|
+
export const MOTION_RECIPES = Object.freeze({
|
|
9
|
+
static: Object.freeze({ key: "static", name: "Static", motion: "none", ambient: "none", strategy: "none", durationMs: 1, lightingPeak: 0 }),
|
|
10
|
+
restrained: Object.freeze({ key: "restrained", name: "Restrained", motion: "semantic", ambient: "none", strategy: "auto", durationMs: 1800, lightingPeak: 0.035 }),
|
|
11
|
+
expressive: Object.freeze({ key: "expressive", name: "Expressive", motion: "semantic", ambient: "none", strategy: "auto", durationMs: 2800, lightingPeak: 0.075 }),
|
|
12
|
+
matrix_origin: Object.freeze({ key: "matrix_origin", name: "Matrix origin", motion: "semantic", ambient: "matrix", strategy: "auto", durationMs: 3200, lightingPeak: 0.08 }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function resolveMotionRecipe(value = "restrained") {
|
|
16
|
+
const recipe = typeof value === "string" ? MOTION_RECIPES[value] : value;
|
|
17
|
+
if (!recipe || typeof recipe !== "object") throw new TypeError(`unknown motion recipe; choose ${Object.keys(MOTION_RECIPES).join(", ")}`);
|
|
18
|
+
for (const key of ["key", "motion", "ambient", "strategy"]) if (typeof recipe[key] !== "string" || !recipe[key]) throw new TypeError(`motion recipe ${key} must be a non-empty string`);
|
|
19
|
+
if (!MOTION_VALUES.has(recipe.motion)) throw new TypeError(`motion recipe motion must be one of ${[...MOTION_VALUES].join(", ")}`);
|
|
20
|
+
if (!AMBIENT_VALUES.has(recipe.ambient)) throw new TypeError(`motion recipe ambient must be one of ${[...AMBIENT_VALUES].join(", ")}`);
|
|
21
|
+
if (!STRATEGY_VALUES.has(recipe.strategy)) throw new TypeError(`motion recipe strategy is unsupported`);
|
|
22
|
+
if (!Number.isFinite(recipe.durationMs) || recipe.durationMs <= 0) throw new TypeError("motion recipe durationMs must be a positive finite number");
|
|
23
|
+
if (!Number.isFinite(recipe.lightingPeak) || recipe.lightingPeak < 0 || recipe.lightingPeak > 1) throw new TypeError("motion recipe lightingPeak must be between 0 and 1");
|
|
24
|
+
return Object.freeze({ ...recipe });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function applyMotionRecipe(input, recipe = "restrained") {
|
|
28
|
+
const result = cloneValue(input), value = resolveMotionRecipe(recipe);
|
|
29
|
+
if (!result || typeof result !== "object" || !result.motion || typeof result.motion !== "object" || Array.isArray(result.motion)) {
|
|
30
|
+
throw new TypeError("applyMotionRecipe requires a Figurestead contract with a motion object");
|
|
31
|
+
}
|
|
32
|
+
result.view = { ...(result.view ?? {}), motion: value.motion, ambient: value.ambient, strategy: value.strategy };
|
|
33
|
+
result.motion = {
|
|
34
|
+
...result.motion,
|
|
35
|
+
durationMs: value.durationMs,
|
|
36
|
+
lightingPeak: value.lightingPeak,
|
|
37
|
+
};
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { colorContrast, hexToOklab, resolveContrastColor } from "./color-space.js";
|
|
2
|
+
|
|
3
|
+
export const PAPER_PROFILE_VERSION = "figurestead.paper-profile/1";
|
|
4
|
+
export const PAPER_FLOORS = Object.freeze({ text: 4.5, evidence: 3, thinEvidenceTarget: 4, pairwiseLightness: 0.1, identityWarning: 0.12 });
|
|
5
|
+
export const PAPER_SURFACE = Object.freeze({
|
|
6
|
+
field: "#FFFFFF", panel: "#FBFBF8", grid: "#D9DEDA", spine: "#66736D",
|
|
7
|
+
label: "#17211D", secondary: "#4C5C55", faint: "#727E79",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const rounded = (value) => Number(value.toFixed(3));
|
|
11
|
+
|
|
12
|
+
function resolveRole(source, role, surface, minimum, report) {
|
|
13
|
+
const result = resolveContrastColor(source[role], surface, minimum);
|
|
14
|
+
report.resolutions.push({ token: role, ...result, contrast: rounded(result.contrast), identityDelta: rounded(result.identityDelta), lightnessDelta: rounded(result.lightnessDelta), chromaReduction: rounded(result.chromaReduction), hueDelta: rounded(result.hueDelta) });
|
|
15
|
+
if (result.identityDelta > PAPER_FLOORS.identityWarning) report.findings.push({ level: "warning", code: "palette-identity-loss", token: role, delta: rounded(result.identityDelta) });
|
|
16
|
+
return result.color;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function auditPaperTheme(theme, seriesStyles = null) {
|
|
20
|
+
const findings = [], checks = [];
|
|
21
|
+
for (const token of ["label", "secondary"]) {
|
|
22
|
+
const ratio = colorContrast(theme[token], theme.panel); checks.push({ token, surface: "panel", ratio: rounded(ratio), minimum: PAPER_FLOORS.text });
|
|
23
|
+
if (ratio < PAPER_FLOORS.text) findings.push({ level: "error", code: "contrast", token, surface: "panel", ratio: rounded(ratio), minimum: PAPER_FLOORS.text });
|
|
24
|
+
}
|
|
25
|
+
for (const token of ["primary", "summaryCore", "warm"]) {
|
|
26
|
+
const ratio = colorContrast(theme[token], theme.panel); checks.push({ token, surface: "panel", ratio: rounded(ratio), minimum: PAPER_FLOORS.evidence });
|
|
27
|
+
if (ratio < PAPER_FLOORS.evidence) findings.push({ level: "error", code: "contrast", token, surface: "panel", ratio: rounded(ratio), minimum: PAPER_FLOORS.evidence });
|
|
28
|
+
else if (ratio < PAPER_FLOORS.thinEvidenceTarget) findings.push({ level: "warning", code: "thin-evidence-contrast", token, ratio: rounded(ratio), target: PAPER_FLOORS.thinEvidenceTarget });
|
|
29
|
+
}
|
|
30
|
+
theme.series.forEach((color, index) => {
|
|
31
|
+
const ratio = colorContrast(color, theme.panel); checks.push({ token: `series[${index}]`, surface: "panel", ratio: rounded(ratio), minimum: PAPER_FLOORS.evidence });
|
|
32
|
+
if (ratio < PAPER_FLOORS.evidence) findings.push({ level: "error", code: "contrast", token: `series[${index}]`, surface: "panel", ratio: rounded(ratio), minimum: PAPER_FLOORS.evidence });
|
|
33
|
+
});
|
|
34
|
+
const activeIndexes = seriesStyles
|
|
35
|
+
? [...new Set(Object.values(seriesStyles).map((item) => item.colorIndex))].sort((a, b) => a - b)
|
|
36
|
+
: theme.series.map((_, index) => index);
|
|
37
|
+
for (let aIndex = 0; aIndex < activeIndexes.length; aIndex += 1) for (let bIndex = aIndex + 1; bIndex < activeIndexes.length; bIndex += 1) {
|
|
38
|
+
const left = activeIndexes[aIndex], right = activeIndexes[bIndex];
|
|
39
|
+
const a = hexToOklab(theme.series[left]), b = hexToOklab(theme.series[right]), deltaL = Math.abs(a.L - b.L);
|
|
40
|
+
const leftStyle = seriesStyles ? Object.values(seriesStyles).find((item) => item.colorIndex === left) : null;
|
|
41
|
+
const rightStyle = seriesStyles ? Object.values(seriesStyles).find((item) => item.colorIndex === right) : null;
|
|
42
|
+
const redundant = Boolean(leftStyle && rightStyle && (leftStyle.glyph !== rightStyle.glyph || leftStyle.lineStyle !== rightStyle.lineStyle || leftStyle.hatch !== rightStyle.hatch));
|
|
43
|
+
if (deltaL < PAPER_FLOORS.pairwiseLightness && !redundant) findings.push({ level: "warning", code: "pairwise-lightness", series: [left, right], deltaL: rounded(deltaL), redundant: false });
|
|
44
|
+
}
|
|
45
|
+
return Object.freeze({ schemaVersion: PAPER_PROFILE_VERSION, clean: findings.every((item) => item.level !== "error"), checks: Object.freeze(checks), findings: Object.freeze(findings) });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolvePaperTheme(source) {
|
|
49
|
+
const report = { schemaVersion: PAPER_PROFILE_VERSION, resolutions: [], findings: [] };
|
|
50
|
+
const theme = {
|
|
51
|
+
...source, ...PAPER_SURFACE, mode: "paper",
|
|
52
|
+
key: source.key.endsWith("-paper") ? source.key : `${source.key}-paper`,
|
|
53
|
+
name: source.name.endsWith(" · Paper") ? source.name : `${source.name} · Paper`,
|
|
54
|
+
};
|
|
55
|
+
theme.primary = resolveRole(source, "primary", theme.panel, PAPER_FLOORS.thinEvidenceTarget, report);
|
|
56
|
+
theme.summaryCore = resolveRole(source, "summaryCore", theme.panel, PAPER_FLOORS.thinEvidenceTarget, report);
|
|
57
|
+
theme.warm = resolveRole(source, "warm", theme.panel, PAPER_FLOORS.evidence, report);
|
|
58
|
+
theme.series = source.series.map((color, index) => {
|
|
59
|
+
const result = resolveContrastColor(color, theme.panel, PAPER_FLOORS.evidence);
|
|
60
|
+
report.resolutions.push({ token: `series[${index}]`, ...result, contrast: rounded(result.contrast), identityDelta: rounded(result.identityDelta), lightnessDelta: rounded(result.lightnessDelta), chromaReduction: rounded(result.chromaReduction), hueDelta: rounded(result.hueDelta) });
|
|
61
|
+
if (result.identityDelta > PAPER_FLOORS.identityWarning) report.findings.push({ level: "warning", code: "palette-identity-loss", token: `series[${index}]`, delta: rounded(result.identityDelta) });
|
|
62
|
+
return result.color;
|
|
63
|
+
});
|
|
64
|
+
if (source.primaryEdge) theme.primaryEdge = resolveContrastColor(source.primaryEdge, theme.panel, PAPER_FLOORS.evidence).color;
|
|
65
|
+
if (source.summaryEdge) theme.summaryEdge = resolveContrastColor(source.summaryEdge, theme.panel, PAPER_FLOORS.evidence).color;
|
|
66
|
+
theme.seriesEdges = (source.seriesEdges ?? theme.series.map(() => PAPER_SURFACE.label)).map((color) => resolveContrastColor(color, theme.panel, PAPER_FLOORS.evidence).color);
|
|
67
|
+
const audit = auditPaperTheme(theme);
|
|
68
|
+
report.findings.push(...audit.findings);
|
|
69
|
+
return Object.freeze({ theme: Object.freeze(theme), report: Object.freeze({ ...report, audit, clean: audit.clean }) });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function themeResolutionForProfile(source, profile = "atlas") {
|
|
73
|
+
const key = typeof profile === "string" ? profile : profile?.key;
|
|
74
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
75
|
+
if (key !== "paper") return Object.freeze({ theme: clone(source), report: null });
|
|
76
|
+
if (source.mode === "paper") return Object.freeze({ theme: clone(source), report: auditPaperTheme(source) });
|
|
77
|
+
return resolvePaperTheme(source);
|
|
78
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const PAPER_SIZE_PRESETS = Object.freeze({ "paper-single": 89, "paper-double": 183 });
|
|
2
|
+
const MM_PER_INCH = 25.4, CSS_DPI = 96, PT_PER_INCH = 72;
|
|
3
|
+
|
|
4
|
+
function positive(value, name) {
|
|
5
|
+
if (!Number.isFinite(value) || value <= 0) throw new TypeError(`${name} must be a positive finite number`);
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function resolveExportSize(options = {}) {
|
|
10
|
+
const presetWidth = options.paperSize == null ? null : PAPER_SIZE_PRESETS[options.paperSize];
|
|
11
|
+
if (options.paperSize != null && presetWidth == null) throw new TypeError(`paperSize must be ${Object.keys(PAPER_SIZE_PRESETS).join(" or ")}`);
|
|
12
|
+
const widthMm = options.physicalWidthMm == null ? presetWidth : positive(options.physicalWidthMm, "physicalWidthMm");
|
|
13
|
+
const width = positive(options.width ?? (widthMm ? Math.round(widthMm * CSS_DPI / MM_PER_INCH) : 960), "width");
|
|
14
|
+
const height = positive(options.height ?? Math.max(240, Math.round(width * 0.625)), "height");
|
|
15
|
+
if (!widthMm) return Object.freeze({ width, height, physical: null, widthAttribute: width, heightAttribute: height });
|
|
16
|
+
const heightMm = widthMm * height / width;
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
width, height, widthAttribute: `${rounded(widthMm)}mm`, heightAttribute: `${rounded(heightMm)}mm`,
|
|
19
|
+
physical: Object.freeze({ preset: options.paperSize ?? "custom", widthMm: rounded(widthMm), heightMm: rounded(heightMm), minLabelPt: positive(options.minLabelPt ?? 6, "minLabelPt") }),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rounded = (value) => Number(value.toFixed(3));
|
|
24
|
+
|
|
25
|
+
export function auditPhysicalTypography(composed, physical) {
|
|
26
|
+
if (!physical) return null;
|
|
27
|
+
const scale = physical.widthMm / composed.width * PT_PER_INCH / MM_PER_INCH;
|
|
28
|
+
const labels = composed.panels.flatMap((panel) => [
|
|
29
|
+
[panel.id, "axis", panel.layout.font.axis], [panel.id, "legend", panel.layout.font.legend],
|
|
30
|
+
[panel.id, "title", panel.layout.font.title], [panel.id, "subtitle", panel.layout.font.subtitle],
|
|
31
|
+
]).map(([panelId, role, units]) => ({ panelId, role, points: rounded(units * scale) }));
|
|
32
|
+
const minimum = Math.min(...labels.map((item) => item.points));
|
|
33
|
+
return Object.freeze({ clean: minimum >= physical.minLabelPt, minimumPt: minimum, requiredPt: physical.minLabelPt, labels: Object.freeze(labels) });
|
|
34
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { cloneValue, windowProgress } from "./schema.js";
|
|
2
|
+
import { clamp01, smooth } from "./marks.js";
|
|
3
|
+
|
|
4
|
+
export const SCIENTIFIC_POSE = Object.freeze({
|
|
5
|
+
panelSurface: true,
|
|
6
|
+
frame: true,
|
|
7
|
+
curve: "monotone",
|
|
8
|
+
legend: "auto",
|
|
9
|
+
lineWidth: 2.35,
|
|
10
|
+
markerScale: 1.22,
|
|
11
|
+
seriesMarkers: Object.freeze(["ring", "square"]),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function applyScientificPose(input, options = {}) {
|
|
15
|
+
const result = cloneValue(input);
|
|
16
|
+
if (!Array.isArray(result?.panels) || !result.panels.length) throw new TypeError("applyScientificPose requires a v0.4 contract with one or more panels");
|
|
17
|
+
const panelIds = new Set(result.panels.map((panel) => panel.id));
|
|
18
|
+
result.panels = result.panels.map((panel) => {
|
|
19
|
+
const presentation = {
|
|
20
|
+
panelSurface: true,
|
|
21
|
+
frame: true,
|
|
22
|
+
legend: options.legend ?? "auto",
|
|
23
|
+
markerScale: options.markerScale ?? SCIENTIFIC_POSE.markerScale,
|
|
24
|
+
...(panel.renderer === "line" ? {
|
|
25
|
+
curve: options.curve ?? "monotone",
|
|
26
|
+
lineWidth: options.lineWidth ?? SCIENTIFIC_POSE.lineWidth,
|
|
27
|
+
seriesMarkers: options.seriesMarkers ?? ["ring", "square"],
|
|
28
|
+
} : {}),
|
|
29
|
+
...(panel.presentation ?? {}),
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
...panel,
|
|
33
|
+
presentation,
|
|
34
|
+
...(panel.renderer === "line" ? { encoding: { ...(panel.encoding ?? {}), interpolation: options.curve ?? panel.presentation?.curve ?? "monotone" } } : {}),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
for (const focus of options.focus ?? []) {
|
|
38
|
+
if (!panelIds.has(focus.panelId)) throw new TypeError(`unknown focus panel ${focus.panelId}`);
|
|
39
|
+
const panel = result.panels.find((item) => item.id === focus.panelId);
|
|
40
|
+
panel.annotations = [...(panel.annotations ?? []), {
|
|
41
|
+
type: "focus", ...(focus.anchorId ? { anchorId: focus.anchorId } : { x: focus.x, y: focus.y, space: focus.space }), label: focus.label,
|
|
42
|
+
dx: focus.dx ?? 68, dy: focus.dy ?? 28,
|
|
43
|
+
}];
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function applyEvidencePose(input, options = {}) {
|
|
49
|
+
const focus = options.focus ?? [];
|
|
50
|
+
if (!Array.isArray(focus)) throw new TypeError("options.focus must be an array");
|
|
51
|
+
focus.forEach((item, index) => {
|
|
52
|
+
if (typeof item?.panelId !== "string" || !item.panelId.trim()) throw new TypeError(`focus[${index}].panelId must identify a panel`);
|
|
53
|
+
if (typeof item.anchorId !== "string" || !item.anchorId.trim()) throw new TypeError(`focus[${index}].anchorId must identify a compiled evidence mark`);
|
|
54
|
+
if (typeof item.label !== "string" || !item.label.trim()) throw new TypeError(`focus[${index}].label must be a non-empty string`);
|
|
55
|
+
});
|
|
56
|
+
return applyScientificPose(input, { ...options, focus });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function drawPanelSurface(context, { contract, layout }) {
|
|
60
|
+
if (!contract.presentation?.panelSurface) return;
|
|
61
|
+
const { plot } = layout;
|
|
62
|
+
context.save();
|
|
63
|
+
context.fillStyle = contract.theme.panel;
|
|
64
|
+
context.fillRect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
|
|
65
|
+
if (contract.presentation.frame) {
|
|
66
|
+
context.strokeStyle = contract.theme.spine;
|
|
67
|
+
context.globalAlpha = 0.48;
|
|
68
|
+
context.lineWidth = Math.max(0.6, 0.75 * layout.scale);
|
|
69
|
+
context.strokeRect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
|
|
70
|
+
}
|
|
71
|
+
context.restore();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function position(annotation, scales, plot) {
|
|
75
|
+
if (annotation.space === "plot") {
|
|
76
|
+
return {
|
|
77
|
+
x: plot.left + clamp01(annotation.x) * (plot.right - plot.left),
|
|
78
|
+
y: plot.bottom - clamp01(annotation.y) * (plot.bottom - plot.top),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (typeof scales?.x !== "function" || typeof scales?.y !== "function") return null;
|
|
82
|
+
const x = scales.x(annotation.x), y = scales.y(annotation.y);
|
|
83
|
+
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function drawPresentationAnnotations(context, { contract, layout, scales, progress }) {
|
|
87
|
+
const focus = contract.annotations.filter((item) => item?.type === "focus");
|
|
88
|
+
if (!focus.length) return;
|
|
89
|
+
const reveal = smooth(windowProgress(progress, contract.timeline.summaryCompiles));
|
|
90
|
+
if (reveal <= 0) return;
|
|
91
|
+
const { plot } = layout, theme = contract.theme;
|
|
92
|
+
focus.forEach((annotation) => {
|
|
93
|
+
const point = position(annotation, scales, plot);
|
|
94
|
+
if (!point || typeof annotation.label !== "string" || !annotation.label.trim()) return;
|
|
95
|
+
const dx = Number.isFinite(annotation.dx) ? annotation.dx * layout.scale : 68 * layout.scale;
|
|
96
|
+
const dy = Number.isFinite(annotation.dy) ? annotation.dy * layout.scale : 28 * layout.scale;
|
|
97
|
+
const rawX = point.x + dx, rawY = point.y + dy;
|
|
98
|
+
const labelX = Math.max(plot.left + 26 * layout.scale, Math.min(plot.right - 26 * layout.scale, rawX));
|
|
99
|
+
const labelY = Math.max(plot.top + 22 * layout.scale, Math.min(plot.bottom - 20 * layout.scale, rawY));
|
|
100
|
+
const radius = Math.max(6.5, 8.4 * layout.scale) * reveal;
|
|
101
|
+
const fill = theme.summaryCore;
|
|
102
|
+
const edge = theme.seriesEdges?.[0] ?? theme.primaryEdge ?? theme.field;
|
|
103
|
+
context.save();
|
|
104
|
+
context.globalAlpha = reveal;
|
|
105
|
+
context.strokeStyle = theme.primary;
|
|
106
|
+
context.lineWidth = Math.max(3, radius * 0.62);
|
|
107
|
+
context.globalAlpha = 0.13 * reveal;
|
|
108
|
+
context.beginPath(); context.arc(point.x, point.y, radius * 1.45, 0, Math.PI * 2); context.stroke();
|
|
109
|
+
context.globalAlpha = 0.92 * reveal;
|
|
110
|
+
context.strokeStyle = fill;
|
|
111
|
+
context.lineWidth = Math.max(1, 1.25 * layout.scale);
|
|
112
|
+
context.beginPath(); context.moveTo(point.x + radius * 0.7, point.y + radius * 0.55); context.lineTo(labelX - 8 * layout.scale, labelY - 5 * layout.scale); context.stroke();
|
|
113
|
+
context.fillStyle = fill;
|
|
114
|
+
context.strokeStyle = edge;
|
|
115
|
+
context.lineWidth = Math.max(1.4, 1.9 * layout.scale);
|
|
116
|
+
context.beginPath(); context.arc(point.x, point.y, radius, 0, Math.PI * 2); context.fill(); context.stroke();
|
|
117
|
+
context.font = `600 ${Math.max(9, layout.font.legend * 1.06)}px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace`;
|
|
118
|
+
context.textAlign = dx < 0 ? "right" : "left";
|
|
119
|
+
context.textBaseline = "middle";
|
|
120
|
+
context.strokeStyle = theme.field;
|
|
121
|
+
context.lineWidth = Math.max(1.6, 2.2 * layout.scale);
|
|
122
|
+
context.strokeText(annotation.label, labelX, labelY);
|
|
123
|
+
context.fillStyle = fill;
|
|
124
|
+
context.fillText(annotation.label, labelX, labelY);
|
|
125
|
+
context.restore();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function drawRule(context, { x1, y1, x2, y2, color, alpha = 0.7, width = 1, dash = [] }) {
|
|
2
|
+
context.save(); context.strokeStyle = color; context.globalAlpha = alpha; context.lineWidth = width; context.setLineDash?.(dash);
|
|
3
|
+
context.beginPath(); context.moveTo(x1, y1); context.lineTo(x2, y2); context.stroke(); context.restore();
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function drawBand(context, { left, top, right, bottom, color, alpha = 0.08 }) {
|
|
7
|
+
context.save(); context.fillStyle = color; context.globalAlpha = alpha; context.fillRect(left, top, right - left, bottom - top); context.restore();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function drawBar(context, { left, top, right, bottom, color, alpha = 0.55, stroke = null, lineWidth = 1 }) {
|
|
11
|
+
const width = Math.max(0, right - left), height = Math.max(0, bottom - top);
|
|
12
|
+
context.save(); context.fillStyle = color; context.globalAlpha = alpha; context.fillRect(left, top, width, height);
|
|
13
|
+
if (stroke) { context.strokeStyle = stroke; context.lineWidth = lineWidth; context.strokeRect(left, top, width, height); }
|
|
14
|
+
context.restore();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function drawInterval(context, { x1, x2, y, color, alpha = 0.7, width = 1.4, cap = 0 }) {
|
|
18
|
+
drawRule(context, { x1, y1: y, x2, y2: y, color, alpha, width });
|
|
19
|
+
if (cap > 0) { drawRule(context, { x1, y1: y - cap, x2: x1, y2: y + cap, color, alpha, width }); drawRule(context, { x1: x2, y1: y - cap, x2, y2: y + cap, color, alpha, width }); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function drawCell(context, { left, top, right, bottom, color, alpha = 1, stroke = null }) {
|
|
23
|
+
drawBar(context, { left, top, right, bottom, color, alpha, stroke, lineWidth: 0.5 });
|
|
24
|
+
}
|
package/src/random.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function mulberry32(seed) {
|
|
2
|
+
let value = seed >>> 0;
|
|
3
|
+
return () => {
|
|
4
|
+
value |= 0;
|
|
5
|
+
value = (value + 0x6D2B79F5) | 0;
|
|
6
|
+
let t = Math.imul(value ^ (value >>> 15), 1 | value);
|
|
7
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
8
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function gaussian(random) {
|
|
13
|
+
let u = 0;
|
|
14
|
+
let v = 0;
|
|
15
|
+
while (!u) u = random();
|
|
16
|
+
while (!v) v = random();
|
|
17
|
+
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function deriveSeed(seed, namespace) {
|
|
21
|
+
let hash = seed >>> 0;
|
|
22
|
+
for (let index = 0; index < namespace.length; index += 1) {
|
|
23
|
+
hash = Math.imul(hash ^ namespace.charCodeAt(index), 16777619) >>> 0;
|
|
24
|
+
}
|
|
25
|
+
return hash;
|
|
26
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const RENDERER_API_VERSION = "1";
|
|
2
|
+
|
|
3
|
+
export function defineRenderer(definition) {
|
|
4
|
+
if (!definition || typeof definition !== "object") throw new TypeError("renderer definition must be an object");
|
|
5
|
+
if (typeof definition.key !== "string" || !definition.key.trim()) throw new TypeError("renderer.key must be a non-empty string");
|
|
6
|
+
if (definition.apiVersion !== RENDERER_API_VERSION) throw new TypeError(`renderer ${definition.key} requires apiVersion ${RENDERER_API_VERSION}`);
|
|
7
|
+
["validateData", "prepare", "draw", "describe"].forEach((method) => {
|
|
8
|
+
if (typeof definition[method] !== "function") throw new TypeError(`renderer ${definition.key}.${method} must be a function`);
|
|
9
|
+
});
|
|
10
|
+
return Object.freeze({ family: "other", domains: () => ({}), ...definition });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createRendererRegistry(definitions = []) {
|
|
14
|
+
const entries = new Map();
|
|
15
|
+
definitions.forEach((candidate) => {
|
|
16
|
+
const definition = defineRenderer(candidate);
|
|
17
|
+
if (entries.has(definition.key)) throw new TypeError(`duplicate renderer key ${definition.key}`);
|
|
18
|
+
entries.set(definition.key, definition);
|
|
19
|
+
});
|
|
20
|
+
const api = {
|
|
21
|
+
apiVersion: RENDERER_API_VERSION,
|
|
22
|
+
get(key) { return entries.get(key) ?? null; },
|
|
23
|
+
has(key) { return entries.has(key); },
|
|
24
|
+
keys() { return Object.freeze([...entries.keys()]); },
|
|
25
|
+
definitions() { return Object.freeze([...entries.values()]); },
|
|
26
|
+
with(...more) { return createRendererRegistry([...entries.values(), ...more.flat()]); },
|
|
27
|
+
};
|
|
28
|
+
return Object.freeze(api);
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const RENDER_LAYER_ORDER = Object.freeze([
|
|
2
|
+
"surface", "grid", "reference", "data", "summary", "axes", "annotations", "legend",
|
|
3
|
+
]);
|
|
4
|
+
|
|
5
|
+
const REFERENCE_MARKS = new Set(["reference-band", "row-band", "baseline-rule"]);
|
|
6
|
+
const SUMMARY_MARKS = new Set(["summary-line", "median-rule"]);
|
|
7
|
+
|
|
8
|
+
export function renderLayerForMark(mark) {
|
|
9
|
+
if (REFERENCE_MARKS.has(mark?.kind)) return "reference";
|
|
10
|
+
if (SUMMARY_MARKS.has(mark?.kind) || mark?.role === "summary") return "summary";
|
|
11
|
+
return "data";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function partitionPanelMarks(marks = []) {
|
|
15
|
+
const layers = { reference: [], data: [], summary: [] };
|
|
16
|
+
marks.forEach((mark) => layers[renderLayerForMark(mark)].push(mark));
|
|
17
|
+
return layers;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function plotClipRect(panel) {
|
|
21
|
+
const plot = panel?.evidenceFrame ?? panel?.axes?.plot ?? panel?.layout?.plot;
|
|
22
|
+
if (!plot || ![plot.left, plot.top, plot.right, plot.bottom].every(Number.isFinite)) {
|
|
23
|
+
throw new TypeError("resolved panel must expose a finite evidence-frame rectangle");
|
|
24
|
+
}
|
|
25
|
+
return Object.freeze({ left: plot.left, top: plot.top, right: plot.right, bottom: plot.bottom });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function withCanvasPlotClip(context, panel, draw) {
|
|
29
|
+
const plot = plotClipRect(panel);
|
|
30
|
+
context.save();
|
|
31
|
+
context.beginPath();
|
|
32
|
+
context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
|
|
33
|
+
context.clip();
|
|
34
|
+
try { return draw(plot); } finally { context.restore(); }
|
|
35
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { RENDERER_API_VERSION } from "./registry.js";
|
|
2
|
+
|
|
3
|
+
function canonical(value) {
|
|
4
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
5
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const stable = (value) => JSON.stringify(canonical(value));
|
|
10
|
+
|
|
11
|
+
export function rendererConformance(definition, contract) {
|
|
12
|
+
const errors = [];
|
|
13
|
+
if (definition.apiVersion !== RENDERER_API_VERSION) errors.push(`apiVersion must be ${RENDERER_API_VERSION}`);
|
|
14
|
+
["key", "validateData", "prepare", "draw", "describe"].forEach((name) => { if (definition[name] == null) errors.push(`missing ${name}`); });
|
|
15
|
+
let first, second;
|
|
16
|
+
try { first = definition.prepare(contract); second = definition.prepare(contract); } catch (error) { errors.push(`prepare failed: ${error.message}`); }
|
|
17
|
+
if (first !== undefined && stable(first) !== stable(second)) errors.push("prepare is not deterministic");
|
|
18
|
+
try {
|
|
19
|
+
const description = definition.describe(contract, first);
|
|
20
|
+
if (!description || typeof description.summary !== "string" || !Array.isArray(description.headers) || !Array.isArray(description.rows)) errors.push("describe must return summary, headers, and rows");
|
|
21
|
+
} catch (error) { errors.push(`describe failed: ${error.message}`); }
|
|
22
|
+
return Object.freeze({ ok: errors.length === 0, errors: Object.freeze(errors) });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function assertRendererConformance(definition, contract) {
|
|
26
|
+
const result = rendererConformance(definition, contract);
|
|
27
|
+
if (!result.ok) throw new Error(`renderer ${definition.key ?? "unknown"} failed conformance: ${result.errors.join("; ")}`);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { arrival, numericScales } from "./shared.js";
|
|
2
|
+
import { clamp01, drawAxes, drawScopePoint, drawText, pointMotionState, smooth } from "../marks.js";
|
|
3
|
+
import { styleForSeries } from "../series-style.js";
|
|
4
|
+
|
|
5
|
+
export function prepareLine(contract) {
|
|
6
|
+
const points = contract.data.series.flatMap((series, colorIndex) => contract.data.x.map((x, index) => ({ x, y: series.y[index], colorIndex, series: series.key, index })));
|
|
7
|
+
return { points: arrival(points, contract, contract.data.revealOrder), legend: contract.data.series.map((s, colorIndex) => ({ label: s.label, colorIndex })) };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function lineSegmentState(left, right, progress, scales, compileDuration = 0.055) {
|
|
11
|
+
const availableAt = Math.max(left.delay + left.duration, right.delay + right.duration);
|
|
12
|
+
const compile = smooth(clamp01((progress - availableAt) / compileDuration));
|
|
13
|
+
const x1 = scales.x(left.x), y1 = scales.y(left.y);
|
|
14
|
+
const finalX2 = scales.x(right.x), finalY2 = scales.y(right.y);
|
|
15
|
+
return {
|
|
16
|
+
visible: compile > 0,
|
|
17
|
+
compile,
|
|
18
|
+
x1,
|
|
19
|
+
y1,
|
|
20
|
+
x2: x1 + (finalX2 - x1) * compile,
|
|
21
|
+
y2: y1 + (finalY2 - y1) * compile,
|
|
22
|
+
finalX2,
|
|
23
|
+
finalY2,
|
|
24
|
+
availableAt,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function monotoneSegmentControls(points) {
|
|
29
|
+
if (points.length < 2) return [];
|
|
30
|
+
const delta = [];
|
|
31
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
32
|
+
const width = points[index + 1].x - points[index].x;
|
|
33
|
+
if (!(width > 0)) return null;
|
|
34
|
+
delta.push((points[index + 1].y - points[index].y) / width);
|
|
35
|
+
}
|
|
36
|
+
const tangent = points.map((_, index) => {
|
|
37
|
+
if (index === 0) return delta[0];
|
|
38
|
+
if (index === points.length - 1) return delta[delta.length - 1];
|
|
39
|
+
return (delta[index - 1] + delta[index]) / 2;
|
|
40
|
+
});
|
|
41
|
+
delta.forEach((slope, index) => {
|
|
42
|
+
if (slope === 0) {
|
|
43
|
+
tangent[index] = 0;
|
|
44
|
+
tangent[index + 1] = 0;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const left = tangent[index] / slope, right = tangent[index + 1] / slope;
|
|
48
|
+
const length = left * left + right * right;
|
|
49
|
+
if (length > 9) {
|
|
50
|
+
const scale = 3 / Math.sqrt(length);
|
|
51
|
+
tangent[index] = scale * left * slope;
|
|
52
|
+
tangent[index + 1] = scale * right * slope;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return delta.map((_, index) => {
|
|
56
|
+
const left = points[index], right = points[index + 1], width = right.x - left.x;
|
|
57
|
+
return {
|
|
58
|
+
c1: { x: left.x + width / 3, y: left.y + tangent[index] * width / 3 },
|
|
59
|
+
c2: { x: right.x - width / 3, y: right.y - tangent[index + 1] * width / 3 },
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function splitCubic(p0, c1, c2, p1, progress) {
|
|
65
|
+
const mix = (left, right) => ({ x: left.x + (right.x - left.x) * progress, y: left.y + (right.y - left.y) * progress });
|
|
66
|
+
const a = mix(p0, c1), b = mix(c1, c2), c = mix(c2, p1);
|
|
67
|
+
const d = mix(a, b), e = mix(b, c);
|
|
68
|
+
return { c1: a, c2: d, end: mix(d, e) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function segmentPath(context, segment, controls, scales) {
|
|
72
|
+
context.beginPath();
|
|
73
|
+
context.moveTo(segment.x1, segment.y1);
|
|
74
|
+
if (!controls) {
|
|
75
|
+
context.lineTo(segment.x2, segment.y2);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const p0 = { x: segment.x1, y: segment.y1 };
|
|
79
|
+
const c1 = { x: scales.x(controls.c1.x), y: scales.y(controls.c1.y) };
|
|
80
|
+
const c2 = { x: scales.x(controls.c2.x), y: scales.y(controls.c2.y) };
|
|
81
|
+
const p1 = { x: segment.finalX2, y: segment.finalY2 };
|
|
82
|
+
const partial = segment.compile < 1 ? splitCubic(p0, c1, c2, p1, segment.compile) : { c1, c2, end: p1 };
|
|
83
|
+
context.bezierCurveTo(partial.c1.x, partial.c1.y, partial.c2.x, partial.c2.y, partial.end.x, partial.end.y);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function drawLine(context, env) {
|
|
87
|
+
const { contract, prepared, layout, progress, settled } = env;
|
|
88
|
+
const presentation = contract.presentation ?? {};
|
|
89
|
+
const scales = numericScales(prepared.points, contract.data, layout, { domains: env.domains });
|
|
90
|
+
drawAxes(context, { config: contract, layout, scales, xTicks: scales.xTicks, yTicks: scales.yTicks });
|
|
91
|
+
contract.data.series.forEach((series, colorIndex) => {
|
|
92
|
+
const points = prepared.points.filter((p) => p.colorIndex === colorIndex);
|
|
93
|
+
const controls = contract.encoding?.interpolation === "monotone" ? monotoneSegmentControls(points) : null;
|
|
94
|
+
const style = styleForSeries(env, series.key, colorIndex), color = style.color, edge = style.edge;
|
|
95
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
96
|
+
const segment = lineSegmentState(points[index - 1], points[index], progress, scales);
|
|
97
|
+
if (!segment.visible) continue;
|
|
98
|
+
context.save();
|
|
99
|
+
if (segment.compile < 1) {
|
|
100
|
+
context.strokeStyle = contract.theme.summaryCore;
|
|
101
|
+
context.lineWidth = Math.max(2.2, ((presentation.lineWidth ?? 1) + 3.2) * layout.scale);
|
|
102
|
+
context.globalAlpha = 0.13 * Math.sin(Math.PI * segment.compile);
|
|
103
|
+
segmentPath(context, segment, controls?.[index - 1], scales); context.stroke();
|
|
104
|
+
}
|
|
105
|
+
if (edge) {
|
|
106
|
+
context.strokeStyle = edge;
|
|
107
|
+
context.lineWidth = Math.max(2, ((presentation.lineWidth ?? 1.35) + 1.45) * layout.scale);
|
|
108
|
+
context.globalAlpha = 0.62 * Math.min(1, segment.compile * 1.6);
|
|
109
|
+
segmentPath(context, segment, controls?.[index - 1], scales); context.stroke();
|
|
110
|
+
}
|
|
111
|
+
context.strokeStyle = color;
|
|
112
|
+
context.lineWidth = Math.max(1, (style.lineWidth ?? presentation.lineWidth ?? 1.35) * layout.scale);
|
|
113
|
+
context.setLineDash?.(style.lineStyle === "dash" ? [7, 4] : style.lineStyle === "dot" ? [2, 4] : style.lineStyle === "dash-dot" ? [8, 3, 2, 3] : []);
|
|
114
|
+
context.globalAlpha = 0.72 * Math.min(1, segment.compile * 1.6);
|
|
115
|
+
segmentPath(context, segment, controls?.[index - 1], scales); context.stroke();
|
|
116
|
+
context.restore();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
prepared.points.forEach((point) => {
|
|
120
|
+
const style = styleForSeries(env, point.series, point.colorIndex), state = pointMotionState(point, progress, scales, layout.plot);
|
|
121
|
+
if (contract.view?.motion === "semantic") { state.y = state.finalY; state.x = state.finalX; }
|
|
122
|
+
drawScopePoint(context, state, {
|
|
123
|
+
color: style.color,
|
|
124
|
+
edge: style.edge,
|
|
125
|
+
radius: Math.max(3.4, Math.sqrt(contract.profile.markerSize) * 0.62 * layout.scale) * (presentation.markerScale ?? 1),
|
|
126
|
+
trailAlpha: contract.motion.trailAlpha,
|
|
127
|
+
settled: settled || contract.view?.motion === "semantic",
|
|
128
|
+
shape: style.glyph,
|
|
129
|
+
}); });
|
|
130
|
+
const legend = prepared.legend.map((item, index) => ({ ...item, style: styleForSeries(env, contract.data.series[index].key, index) }));
|
|
131
|
+
drawText(context, { config: contract, layout, legend, legendPosition: presentation.legend ?? "top-right", legendStyle: "line", seriesMarkers: presentation.seriesMarkers ?? [] });
|
|
132
|
+
return scales;
|
|
133
|
+
}
|