@figurestead/web 0.9.0-alpha.1 → 0.9.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +214 -5
- package/THIRD_PARTY_NOTICES.md +12 -5
- package/package.json +42 -5
- package/src/accessibility.js +51 -11
- package/src/canvas-scene.js +55 -14
- package/src/clock.js +19 -8
- package/src/composition.js +18 -8
- package/src/core-renderers.js +9 -3
- package/src/create-matrix-plot.js +104 -34
- package/src/direct-labels.js +73 -0
- package/src/export-bundle.js +1 -1
- package/src/extensions/temporal/shared.js +1 -1
- package/src/figure-layout.js +3 -2
- package/src/height-negotiation.js +77 -0
- package/src/index.js +1 -1
- package/src/layout.js +4 -2
- package/src/line-identity.js +29 -0
- package/src/marks.js +2 -2
- package/src/paper-profile.js +5 -1
- package/src/renderers/scatter.js +3 -2
- package/src/resolved-scene.js +64 -7
- package/src/responsive-header.js +159 -0
- package/src/schema.js +34 -10
- package/src/scientific-layout.js +148 -0
- package/src/screen-legibility.js +30 -0
- package/src/statistics.js +21 -0
- package/src/svg-export.js +60 -16
- package/src/terminal-scene.js +11 -4
- package/src/theme-pack.js +31 -0
- package/themes/deep_observatory_sage_core.json +28 -0
- package/themes/lavender_fog_notebook.json +29 -0
- package/themes/midnight_transit_signal_slate.json +28 -0
- package/themes/registration_ink.json +28 -0
- package/themes/slipware.json +28 -0
- package/themes/ultraviolet_laboratory.json +29 -0
- package/types/extensions/temporal.d.ts +45 -0
- package/types/index.d.ts +665 -0
- package/types/theme-json.d.ts +4 -0
package/src/core-renderers.js
CHANGED
|
@@ -5,9 +5,15 @@ import { prepareLine, drawLine } from "./renderers/line.js";
|
|
|
5
5
|
import { prepareScatter, drawScatter } from "./renderers/scatter.js";
|
|
6
6
|
import { prepareStrip, drawStrip, compileStripScene } from "./renderers/strip-summary.js";
|
|
7
7
|
|
|
8
|
+
// Core numeric axes have one precedence rule: the authored scale constraint,
|
|
9
|
+
// then the retained renderer-data override, then the finite data extent.
|
|
10
|
+
export const resolveNumericDomain = (contract, axis, automaticDomain) => (
|
|
11
|
+
contract[`${axis}Scale`]?.domain ?? contract.data[`${axis}Domain`] ?? automaticDomain
|
|
12
|
+
);
|
|
13
|
+
|
|
8
14
|
const pointDomains = (contract, prepared) => ({
|
|
9
|
-
x: contract
|
|
10
|
-
y: contract
|
|
15
|
+
x: resolveNumericDomain(contract, "x", extent(prepared.points.map((point) => point.x))),
|
|
16
|
+
y: resolveNumericDomain(contract, "y", extent(prepared.points.map((point) => point.y))),
|
|
11
17
|
});
|
|
12
18
|
|
|
13
19
|
export const LINE_RENDERER = {
|
|
@@ -25,7 +31,7 @@ export const SCATTER_RENDERER = {
|
|
|
25
31
|
export const STRIP_RENDERER = {
|
|
26
32
|
key: "strip_summary", family: "distribution", apiVersion: RENDERER_API_VERSION,
|
|
27
33
|
validateData: normalizeStripData, prepare: prepareStrip, compileScene: compileStripScene, draw: drawStrip,
|
|
28
|
-
domains(contract, prepared) { return { x: [-0.5, contract.data.groups.length - 0.5], y: contract
|
|
34
|
+
domains(contract, prepared) { return { x: [-0.5, contract.data.groups.length - 0.5], y: resolveNumericDomain(contract, "y", extent(prepared.points.map((point) => point.y))) }; },
|
|
29
35
|
describe(contract) { return { summary: `${contract.data.values.length} observations across ${contract.data.groups.length} ordered groups.`, headers: ["group", contract.spec.yLabel || "value", "series"], rows: contract.data.values.map((value, index) => [contract.data.group[index], value, contract.data.seriesLabels[contract.data.series[index]]]) }; },
|
|
30
36
|
};
|
|
31
37
|
|
|
@@ -5,34 +5,71 @@ import { prepareAtmosphere, drawAtmosphere } from "./atmosphere.js";
|
|
|
5
5
|
import { drawBackground, drawFigureHeader } from "./marks.js";
|
|
6
6
|
import { CORE_REGISTRY } from "./core-renderers.js";
|
|
7
7
|
import { AnimationClock } from "./clock.js";
|
|
8
|
-
import { createAccessibilityCompanion } from "./accessibility.js";
|
|
8
|
+
import { createAccessibilityCompanion, prepareAccessibilityCompanion } from "./accessibility.js";
|
|
9
9
|
import { drawPanelSurface, drawPresentationAnnotations } from "./presentation.js";
|
|
10
10
|
import { compileFigureModel } from "./terminal-scene.js";
|
|
11
|
-
import { isResolvedRenderer, resolveSceneFrame
|
|
11
|
+
import { isResolvedRenderer, resolveSceneFrame } from "./resolved-scene.js";
|
|
12
12
|
import { drawResolvedPanel } from "./canvas-scene.js";
|
|
13
13
|
import { composeResolvedScene } from "./composition.js";
|
|
14
|
+
import { resolveResponsiveCanvasScene } from "./responsive-header.js";
|
|
15
|
+
import { createHeightNegotiator, validateHeightNegotiation } from "./height-negotiation.js";
|
|
14
16
|
|
|
15
17
|
export function createFigurestead(canvas, input, options = {}) {
|
|
16
18
|
if (!(canvas instanceof HTMLCanvasElement)) throw new TypeError("createFigurestead requires an HTMLCanvasElement");
|
|
17
19
|
const registry = options.registry ?? CORE_REGISTRY;
|
|
18
20
|
if (registry.apiVersion !== "1") throw new TypeError("Figurestead requires renderer registry API 1");
|
|
21
|
+
const heightNegotiation = validateHeightNegotiation(options.heightNegotiation);
|
|
19
22
|
let contract = input, scene = null, preparedPanels = [], domains = [], atmosphere, surface, resolvedScene = null, composedScene = null, clock = null, destroyed = false;
|
|
20
|
-
let reducedOverride = options.reducedMotion ?? null, companion = null;
|
|
23
|
+
let reducedOverride = options.reducedMotion ?? null, companion = null, contractRevision = 0;
|
|
24
|
+
const heightNegotiator = createHeightNegotiator(canvas, heightNegotiation, options.onError);
|
|
21
25
|
const media = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)");
|
|
22
26
|
const isReduced = () => reducedOverride == null ? Boolean(media?.matches) : Boolean(reducedOverride);
|
|
23
27
|
|
|
24
|
-
const
|
|
25
|
-
const model = compileFigureModel(
|
|
26
|
-
|
|
27
|
-
atmosphere = contract.view.ambient === "matrix" ? prepareAtmosphere(contract.motion) : [];
|
|
28
|
+
const prepareModel = (candidate, retainRanks = true) => {
|
|
29
|
+
const model = compileFigureModel(candidate, { registry, directRanks: retainRanks ? Object.entries(scene?.directRanks ?? {}) : [] });
|
|
30
|
+
return { ...model, atmosphere: model.contract.view.ambient === "matrix" ? prepareAtmosphere(model.contract.motion) : [] };
|
|
28
31
|
};
|
|
29
|
-
const
|
|
32
|
+
const applyModel = (model) => {
|
|
33
|
+
contract = model.contract; scene = model.scene; preparedPanels = model.preparedPanels; domains = model.domains; atmosphere = model.atmosphere;
|
|
34
|
+
};
|
|
35
|
+
const layoutFactory = (width, height, candidate = contract) => deriveFigureLayout(width, height, candidate);
|
|
36
|
+
const measuredText = (text, fontSize, style = "normal") => {
|
|
37
|
+
surface.context.save();
|
|
38
|
+
const prefix = style === "italic" ? "italic " : style === "500" ? "500 " : "";
|
|
39
|
+
surface.context.font = `${prefix}${fontSize}px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace`;
|
|
40
|
+
surface.context.textAlign = "left"; surface.context.textBaseline = "alphabetic"; surface.context.direction = "ltr";
|
|
41
|
+
const value = surface.context.measureText(String(text));
|
|
42
|
+
surface.context.restore();
|
|
43
|
+
return { width: value.width, ascent: value.actualBoundingBoxAscent, descent: value.actualBoundingBoxDescent, left: value.actualBoundingBoxLeft, right: value.actualBoundingBoxRight };
|
|
44
|
+
};
|
|
45
|
+
const observedBox = () => {
|
|
46
|
+
const rect = canvas.getBoundingClientRect();
|
|
47
|
+
return { width: rect.width, height: rect.height, visible: rect.width > 0 && rect.height > 0 && canvas.getClientRects().length > 0 };
|
|
48
|
+
};
|
|
49
|
+
const prepareResolution = (candidateScene, width, height, baselineResult) => {
|
|
50
|
+
const responsive = resolveResponsiveCanvasScene(candidateScene, {
|
|
51
|
+
width, height, baselineHeight: baselineResult.value, measureText: measuredText,
|
|
52
|
+
});
|
|
53
|
+
return { ...responsive, composed: composeResolvedScene(responsive.resolved), baselineError: baselineResult.error };
|
|
54
|
+
};
|
|
55
|
+
const commitNegotiation = (prepared, box) => heightNegotiator.commit({
|
|
56
|
+
contractRevision,
|
|
57
|
+
width: box.visible ? box.width : 0,
|
|
58
|
+
baselineHeight: prepared.baselineHeight,
|
|
59
|
+
preferredHeight: prepared.preferredHeight,
|
|
60
|
+
baselineError: prepared.baselineError,
|
|
61
|
+
});
|
|
30
62
|
const resize = () => {
|
|
31
63
|
if (destroyed) return;
|
|
64
|
+
const box = observedBox();
|
|
32
65
|
surface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
const baseline = box.visible ? heightNegotiator.baseline(box.width, box.height) : { value: null, error: null };
|
|
67
|
+
const prepared = prepareResolution(scene, surface.layout.width, surface.layout.height, baseline);
|
|
68
|
+
resolvedScene = prepared.resolved;
|
|
69
|
+
composedScene = prepared.composed;
|
|
70
|
+
surface = { ...surface, layout: resolvedScene.layout };
|
|
71
|
+
if (clock) clock.render(clock.progress);
|
|
72
|
+
commitNegotiation(prepared, box);
|
|
36
73
|
};
|
|
37
74
|
const draw = (progress) => {
|
|
38
75
|
if (!surface || destroyed) return;
|
|
@@ -43,7 +80,7 @@ export function createFigurestead(canvas, input, options = {}) {
|
|
|
43
80
|
const frame = resolveSceneFrame(composedScene, p);
|
|
44
81
|
preparedPanels.forEach((state, index) => {
|
|
45
82
|
const resolved = isResolvedRenderer(state.panel.renderer);
|
|
46
|
-
const env = { contract: state.contract, prepared: state.prepared, layout: resolved ?
|
|
83
|
+
const env = { contract: state.contract, prepared: state.prepared, layout: resolved ? frame.panels[index].layout : surface.layout.panels[index], domains: domains[index], progress: p, settled, panel: state.panel, figure: contract, scenePanel: scene.panels[index], motionPlan: scene.motionPlan.panels[index], reducedMotion: isReduced() };
|
|
47
84
|
drawPanelSurface(surface.context, env);
|
|
48
85
|
const scales = resolved ? drawResolvedPanel(surface.context, frame, index) : state.definition.draw(surface.context, env);
|
|
49
86
|
if (!resolved) drawPresentationAnnotations(surface.context, { ...env, scales });
|
|
@@ -51,12 +88,17 @@ export function createFigurestead(canvas, input, options = {}) {
|
|
|
51
88
|
options.onProgress?.(p);
|
|
52
89
|
};
|
|
53
90
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
91
|
+
applyModel(prepareModel(input)); surface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory });
|
|
92
|
+
const initialBox = observedBox();
|
|
93
|
+
const initialBaseline = initialBox.visible ? heightNegotiator.baseline(initialBox.width, initialBox.height) : { value: null, error: null };
|
|
94
|
+
const initialResolution = prepareResolution(scene, surface.layout.width, surface.layout.height, initialBaseline);
|
|
95
|
+
resolvedScene = initialResolution.resolved;
|
|
96
|
+
composedScene = initialResolution.composed;
|
|
97
|
+
surface = { ...surface, layout: resolvedScene.layout };
|
|
98
|
+
clock = new AnimationClock({ durationMs: contract.motion.durationMs, draw, onState: options.onState, onError: options.onError });
|
|
99
|
+
companion = createAccessibilityCompanion(canvas, contract, registry, { ...options.accessibility, composedScene });
|
|
59
100
|
clock.render(isReduced() ? 1 : 0);
|
|
101
|
+
commitNegotiation(initialResolution, initialBox);
|
|
60
102
|
|
|
61
103
|
let autoplayUsed = false, wasPlayingBeforeHidden = false;
|
|
62
104
|
const resizeObserver = globalThis.ResizeObserver ? new ResizeObserver(resize) : null; resizeObserver?.observe(canvas);
|
|
@@ -65,33 +107,61 @@ export function createFigurestead(canvas, input, options = {}) {
|
|
|
65
107
|
}, { threshold: [.35] }) : null;
|
|
66
108
|
if (options.autoplay !== false) { if (intersectionObserver) intersectionObserver.observe(canvas); else { autoplayUsed = true; isReduced() ? clock.settle() : clock.play(); } }
|
|
67
109
|
const visibility = () => { if (document.hidden) { wasPlayingBeforeHidden = clock.playing; clock.pause(); } else if (wasPlayingBeforeHidden) { wasPlayingBeforeHidden = false; clock.play(); } };
|
|
68
|
-
const mediaChange = () => { if (reducedOverride == null) isReduced() ? clock.settle() :
|
|
110
|
+
const mediaChange = () => { if (reducedOverride == null) isReduced() ? clock.settle() : clock.render(clock.progress); };
|
|
69
111
|
document.addEventListener("visibilitychange", visibility); media?.addEventListener?.("change", mediaChange);
|
|
70
112
|
|
|
71
|
-
const replace = (next) => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
113
|
+
const replace = (next, retainRanks = true) => {
|
|
114
|
+
const nextModel = prepareModel(next, retainRanks);
|
|
115
|
+
const box = observedBox();
|
|
116
|
+
const baseline = box.visible ? heightNegotiator.baseline(box.width, box.height) : { value: null, error: null };
|
|
117
|
+
const nextResolution = prepareResolution(nextModel.scene, surface.layout.width, surface.layout.height, baseline);
|
|
118
|
+
const nextCompanion = prepareAccessibilityCompanion(canvas, nextModel.contract, registry, { ...options.accessibility, composedScene: nextResolution.composed });
|
|
119
|
+
const nextSurface = resizeCanvas(canvas, { dprCap: options.dprCap ?? 2, layoutFactory: (width, height) => layoutFactory(width, height, nextModel.contract) });
|
|
120
|
+
clock.pause();
|
|
121
|
+
applyModel(nextModel);
|
|
122
|
+
contractRevision += 1;
|
|
123
|
+
resolvedScene = nextResolution.resolved; composedScene = nextResolution.composed;
|
|
124
|
+
surface = { ...nextSurface, layout: resolvedScene.layout };
|
|
125
|
+
clock.durationMs = contract.motion.durationMs; clock.resetFailure();
|
|
126
|
+
nextCompanion.attach(); companion.destroy(); companion = nextCompanion; clock.settle();
|
|
127
|
+
commitNegotiation(nextResolution, box);
|
|
77
128
|
};
|
|
78
129
|
return Object.freeze({
|
|
79
130
|
play() { if (isReduced()) clock.settle(); else clock.play(); }, pause() { clock.pause(); }, replay() { if (isReduced()) clock.settle(); else clock.replay(); },
|
|
80
|
-
setData(data) {
|
|
81
|
-
|
|
82
|
-
|
|
131
|
+
setData(data) {
|
|
132
|
+
if (contract.panels.length !== 1) throw new TypeError("setData is available only for single-panel figures; use setConfig for multi-panel figures");
|
|
133
|
+
const next = cloneValue(contract);
|
|
134
|
+
// Data-only updates retain established line identities, including filtered keys.
|
|
135
|
+
// setConfig remains an explicit new style/theme contract.
|
|
136
|
+
if (contract.panels[0].renderer === "line") {
|
|
137
|
+
// Merge each override onto its complete established style, not over the key.
|
|
138
|
+
// Entries absent from this scene retain their saved style (or future override).
|
|
139
|
+
const retained = Object.fromEntries(Object.entries(scene.seriesStyles).map(([key, style]) =>
|
|
140
|
+
[key, { ...style, ...next.style.series[key] }]));
|
|
141
|
+
next.style.series = { ...next.style.series, ...retained };
|
|
142
|
+
}
|
|
143
|
+
next.panels[0].data = cloneValue(data); replace(next);
|
|
144
|
+
},
|
|
145
|
+
setConfig(next) { replace(next, false); },
|
|
146
|
+
setReducedMotion(value) { if (value !== null && typeof value !== "boolean") throw new TypeError("reduced motion must be true, false, or null"); reducedOverride = value; isReduced() ? clock.settle() : clock.render(clock.progress); },
|
|
83
147
|
resize,
|
|
84
|
-
destroy() { if (destroyed) return; destroyed = true; clock.destroy(); resizeObserver?.disconnect(); intersectionObserver?.disconnect(); document.removeEventListener("visibilitychange", visibility); media?.removeEventListener?.("change", mediaChange); companion.destroy(); },
|
|
85
|
-
getState() { return { progress: clock.progress, playing: clock.playing, reducedMotion: isReduced(), renderers: contract.panels.map((panel) => panel.renderer), sceneVersion: scene.schemaVersion, resolvedSceneVersion: resolvedScene.schemaVersion, composedSceneVersion: composedScene.schemaVersion, profile: contract.view.profile, destroyed }; },
|
|
148
|
+
destroy() { if (destroyed) return; destroyed = true; heightNegotiator.destroy(); clock.destroy(); resizeObserver?.disconnect(); intersectionObserver?.disconnect(); document.removeEventListener("visibilitychange", visibility); media?.removeEventListener?.("change", mediaChange); companion.destroy(); },
|
|
149
|
+
getState() { return { progress: clock.progress, playing: clock.playing, reducedMotion: isReduced(), runtimeFailed: clock.failed, renderers: contract.panels.map((panel) => panel.renderer), sceneVersion: scene.schemaVersion, resolvedSceneVersion: resolvedScene.schemaVersion, composedSceneVersion: composedScene.schemaVersion, profile: contract.view.profile, destroyed }; },
|
|
86
150
|
getScene() { return scene; },
|
|
87
151
|
getResolvedScene() { return resolvedScene; },
|
|
88
152
|
getComposedScene() { return composedScene; },
|
|
89
153
|
getFinalCoordinates() {
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
154
|
+
return resolvedScene.panels.map((panel) => ({
|
|
155
|
+
panelId: panel.id,
|
|
156
|
+
points: panel.marks
|
|
157
|
+
.filter((mark) => ["point", "renderer-mark"].includes(mark.kind) && Number.isFinite(mark.geometry?.cx) && Number.isFinite(mark.geometry?.cy))
|
|
158
|
+
.map((mark) => ({
|
|
159
|
+
x: mark.geometry.cx,
|
|
160
|
+
y: mark.geometry.cy,
|
|
161
|
+
dataX: mark.x ?? mark.group ?? mark.evidence?.x ?? mark.evidence?.group,
|
|
162
|
+
dataY: mark.y ?? mark.yCategory ?? mark.evidence?.y ?? mark.evidence?.yCategory,
|
|
163
|
+
})),
|
|
164
|
+
}));
|
|
95
165
|
},
|
|
96
166
|
});
|
|
97
167
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Internal screen-down PAVA planner. No renderer presets or detached glyph cycle.
|
|
2
|
+
export const DIRECT_FONT = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
|
|
3
|
+
export function solveDirectLabels(entries, lo, hi, height, gap = 4) {
|
|
4
|
+
const ordered = [...entries].sort((a,b)=>a.anchor-b.anchor || a.rank-b.rank);
|
|
5
|
+
const s=height+gap, low=lo+height/2, high=hi-height/2-(ordered.length-1)*s;
|
|
6
|
+
if (!ordered.length || high<low) return null;
|
|
7
|
+
const blocks=[];
|
|
8
|
+
ordered.forEach((e,i)=>{blocks.push({sum:e.anchor-i*s,n:1});
|
|
9
|
+
while(blocks.length>1 && blocks.at(-2).sum/blocks.at(-2).n>blocks.at(-1).sum/blocks.at(-1).n) {
|
|
10
|
+
const b=blocks.pop();blocks.at(-1).sum+=b.sum;blocks.at(-1).n+=b.n;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
const z=blocks.flatMap(b=>Array(b.n).fill(Math.min(high,Math.max(low,b.sum/b.n))));
|
|
14
|
+
return ordered.map((e,i)=>({...e,center:z[i]+i*s}));
|
|
15
|
+
}
|
|
16
|
+
export function literalLabel(t) {return typeof t==='string' && !!t.trim() && /^[\x20-\x7e]+$/.test(t);}
|
|
17
|
+
function contrast(a,b) {
|
|
18
|
+
const lum=c=>{const v=c.slice(1).match(/../g).map(x=>parseInt(x,16)/255).map(x=>x<=.04045?x/12.92:((x+.055)/1.055)**2.4);return v[0]*.2126+v[1]*.7152+v[2]*.0722;};
|
|
19
|
+
const x=lum(a),y=lum(b);return (Math.max(x,y)+.05)/(Math.min(x,y)+.05);
|
|
20
|
+
}
|
|
21
|
+
const fail=reason=>({status:'fallback',reason});
|
|
22
|
+
export function planDirectLabels(scene, resolved, measure) {
|
|
23
|
+
if(resolved.panels.length!==1) return fail('unsupported-layout');
|
|
24
|
+
const p=resolved.panels[0], input=p.directLabelsInput, plot=p.axes.plot??p.layout.plot;
|
|
25
|
+
if(!input || p.renderer!=='line') return fail('unsupported-geometry');
|
|
26
|
+
const data=input.data;
|
|
27
|
+
if(data.series.length<2 || data.series.length>3) return fail('unsupported-series-count');
|
|
28
|
+
if(p.scales.x.type!=='linear'||p.scales.y.type!=='linear'||p.encoding.interpolation!=='linear'
|
|
29
|
+
||input.pose || data.x.length<2 || data.x.some((x,i)=>!Number.isFinite(x)||(i>0&&x<=data.x[i-1]))
|
|
30
|
+
||p.domain.x[0]>=p.domain.x[1]||p.domain.y[0]>=p.domain.y[1]) return fail('unsupported-geometry');
|
|
31
|
+
if(!data.series.every(s=>literalLabel(s.label)))return fail('unsupported-text');
|
|
32
|
+
if(p.annotations.length || !measure || p.layout.headerText || resolved.width<=480) return fail('unsupported-layout');
|
|
33
|
+
const entries=[];let markerHalf=0,textWidth=0,height=0;
|
|
34
|
+
for(const s of data.series) {
|
|
35
|
+
const x=data.x.at(-1),y=s.y.at(-1);
|
|
36
|
+
// Ordinary compileFigureModel evidence coverage has already admitted all observations.
|
|
37
|
+
const points=p.marks.filter(m=>m.kind==='point'&&m.series===s.key), point=points.at(-1);
|
|
38
|
+
if(!point?.lineIdentity || point.x!==x || point.y!==y || !['ring','square','triangle','diamond'].includes(point.style.glyph))return fail('unsupported-geometry');
|
|
39
|
+
// Native Canvas/SVG polygon strokes use miter joins. Include their tips,
|
|
40
|
+
// not just radius + half-width (triangle apex extends sqrt(5) half-widths).
|
|
41
|
+
const join = point.style.glyph==='triangle' ? Math.sqrt(5) : point.style.glyph==='diamond' ? Math.SQRT2 : 1;
|
|
42
|
+
const g=point.geometry,half=g.radius+join*(g.outlineWidth+(point.style.edge?1.3:0))/2;
|
|
43
|
+
if(g.cx-half<plot.left||g.cx+half>plot.right||g.cy-half<plot.top||g.cy+half>plot.bottom)return fail('terminal-marker-clipped');
|
|
44
|
+
let t;
|
|
45
|
+
try { t=measure(s.label,p.layout.font.legend,'normal'); } catch { return fail('unsupported-layout'); }
|
|
46
|
+
if(!t || !['width','ascent','descent','left','right'].every(k=>Number.isFinite(t[k])) || t.ascent+t.descent<=0)return fail('unsupported-layout');
|
|
47
|
+
const inks=[point.style.color,point.style.edge,scene.theme.label].filter(Boolean);
|
|
48
|
+
if(inks.some(c=>!/^#[0-9a-f]{6}$/i.test(c))) return fail('unsupported-geometry');
|
|
49
|
+
if(inks.some(c=>contrast(c,scene.theme.field)<3))return fail('ink-contrast');
|
|
50
|
+
entries.push({key:s.key,label:s.label,anchor:g.cy,rank:input.ranks[s.key],point,text:t,half});
|
|
51
|
+
markerHalf=Math.max(markerHalf,half);textWidth=Math.max(textWidth,t.left+t.right,t.width);height=Math.max(height,t.ascent+t.descent,half*2);
|
|
52
|
+
}
|
|
53
|
+
const H=height+4,required=12+markerHalf*2+6+textWidth+4;
|
|
54
|
+
const right=p.layout.rect.right-2,shrink=Math.max(0,required-(right-plot.right));
|
|
55
|
+
if(shrink>.25*(plot.right-plot.left)||plot.right-shrink-plot.left<160)return fail('horizontal-capacity');
|
|
56
|
+
const planned=solveDirectLabels(entries,plot.top,plot.bottom,H);
|
|
57
|
+
if(!planned)return fail('vertical-capacity');
|
|
58
|
+
const newRight=plot.right-shrink, markerX=newRight+12+markerHalf+2,textX=markerX+markerHalf+6;
|
|
59
|
+
const substrates=[scene.theme.field,p.presentation.panelSurface?scene.theme.panel:scene.theme.field];
|
|
60
|
+
const leaderColor=[scene.theme.secondary,scene.theme.label].find(c=>substrates.every(b=>contrast(c,b)>=3));
|
|
61
|
+
const output=[];
|
|
62
|
+
for(const e of planned) {
|
|
63
|
+
const anchorX=plot.left+(e.point.geometry.cx-plot.left)*(newRight-plot.left)/(plot.right-plot.left);
|
|
64
|
+
if(anchorX+e.half>newRight || anchorX-e.half<plot.left)return fail('terminal-marker-clipped');
|
|
65
|
+
const moved=Math.abs(e.center-e.anchor)>.75;
|
|
66
|
+
if(moved&&!leaderColor)return fail('ink-contrast');
|
|
67
|
+
output.push({...e,markerX,textX:textX+e.text.left,textY:e.center+(e.text.ascent-e.text.descent)/2,
|
|
68
|
+
marker:{...e.point,id:e.point.id+'/direct-label',geometry:{...e.point.geometry,cx:markerX,cy:e.center}},
|
|
69
|
+
leader:moved?{x1:anchorX+markerHalf+2,y1:e.anchor,x2:markerX-markerHalf-3,y2:e.center,color:leaderColor}:null,
|
|
70
|
+
box:{left:markerX-markerHalf-2,top:e.center-H/2,right:textX+textWidth+2,bottom:e.center+H/2}});
|
|
71
|
+
}
|
|
72
|
+
return {status:'placed',reason:null,entries:output,shrink,plot:{...plot,right:newRight},height:H,font:p.layout.font.legend};
|
|
73
|
+
}
|
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);
|
|
@@ -151,7 +151,7 @@ export function drawTemporalAxes(context, { contract, layout, scales, plot = lay
|
|
|
151
151
|
context.fillStyle = contract.theme.secondary; context.font = `${layout.font.axis}px ${FONT_STACK}`;
|
|
152
152
|
if (showX) {
|
|
153
153
|
context.textAlign = "center"; context.textBaseline = "top";
|
|
154
|
-
scales.xTicks.forEach((value) => context.fillText(formatTimeTick(value, scales.xDomain), scales.x(value), plot.bottom + 9 * layout.scale));
|
|
154
|
+
scales.xTicks.forEach((value) => context.fillText(formatTimeTick(value, scales.xDomain), scales.x(value), layout.text?.xTickY ?? plot.bottom + 9 * layout.scale));
|
|
155
155
|
}
|
|
156
156
|
if (showY) {
|
|
157
157
|
context.textAlign = "right"; context.textBaseline = "middle";
|
package/src/figure-layout.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deriveLayout } from "./layout.js";
|
|
2
|
+
import { SCREEN_PROJECT_LEGIBILITY_FLOORS } from "./screen-legibility.js";
|
|
2
3
|
|
|
3
4
|
const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value));
|
|
4
5
|
|
|
@@ -28,7 +29,7 @@ function deriveMultiPanelLayout(width, height, contract, columns, scale, outer,
|
|
|
28
29
|
width, height, scale, rect, panelIndex: index,
|
|
29
30
|
plot: { left: rect.left + leftPad, right: rect.right - rightPad, top: rect.top + topPad, bottom: rect.bottom - bottomPad },
|
|
30
31
|
text: { titleY: rect.top + clamp(19 * scale, 14, 24), subtitleY: rect.top + clamp(37 * scale, 28, 46), xLabelY: rect.bottom - 7 * scale, yLabelX: rect.left + 12 * scale },
|
|
31
|
-
font: { title: clamp(14 * scale, 11, 17), subtitle: clamp(10 * scale, 8, 12), axis: clamp(10.5 * scale, narrow ? 10 : 8, 12), legend: clamp(9.5 * scale, narrow ? 9.5 : 8, 11), signature: clamp(8.5 * scale, narrow ?
|
|
32
|
+
font: { title: clamp(14 * scale, 11, 17), subtitle: clamp(10 * scale, 8, 12), axis: clamp(10.5 * scale, narrow ? 10 : 8, 12), legend: clamp(9.5 * scale, narrow ? 9.5 : 8, 11), signature: clamp(8.5 * scale, narrow ? SCREEN_PROJECT_LEGIBILITY_FLOORS.compactProvenancePx : 7, 10) },
|
|
32
33
|
provenance: provenanceHeight ? { left: outer, right: width - outer, y: height - clamp(8 * scale, 7, 12) } : null,
|
|
33
34
|
};
|
|
34
35
|
});
|
|
@@ -36,7 +37,7 @@ function deriveMultiPanelLayout(width, height, contract, columns, scale, outer,
|
|
|
36
37
|
width, height, scale, panels,
|
|
37
38
|
plot: { left: outer, right: width - outer, top: contentTop, bottom: contentBottom },
|
|
38
39
|
header: { left: outer, titleY: clamp(30 * scale, 22, 38), subtitleY: clamp(53 * scale, 42, 66) },
|
|
39
|
-
font: { title: clamp(19 * scale, 14, 23), subtitle: clamp(11.5 * scale, 9, 14), axis: clamp(10.5 * scale, narrow ? 10 : 8, 12), legend: clamp(10 * scale, narrow ? 9.5 : 8, 12), signature: clamp(9 * scale, narrow ?
|
|
40
|
+
font: { title: clamp(19 * scale, 14, 23), subtitle: clamp(11.5 * scale, 9, 14), axis: clamp(10.5 * scale, narrow ? 10 : 8, 12), legend: clamp(10 * scale, narrow ? 9.5 : 8, 12), signature: clamp(9 * scale, narrow ? SCREEN_PROJECT_LEGIBILITY_FLOORS.compactProvenancePx : 7, 10) },
|
|
40
41
|
provenance: provenanceHeight ? { left: outer, right: width - outer, y: height - clamp(8 * scale, 7, 12) } : null,
|
|
41
42
|
};
|
|
42
43
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const finitePositive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
2
|
+
const normalize = (value) => Math.round(value * 1000) / 1000;
|
|
3
|
+
|
|
4
|
+
export function validateHeightNegotiation(value) {
|
|
5
|
+
if (value == null) return null;
|
|
6
|
+
if (typeof value !== "object" || typeof value.getBaselineHeight !== "function" || typeof value.requestPreferredHeight !== "function") {
|
|
7
|
+
throw new TypeError("heightNegotiation requires getBaselineHeight(context) and requestPreferredHeight(request)");
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createHeightNegotiator(canvas, adapter, reportError) {
|
|
13
|
+
let destroyed = false;
|
|
14
|
+
let generation = null;
|
|
15
|
+
let controller = null;
|
|
16
|
+
let serial = 0;
|
|
17
|
+
|
|
18
|
+
const abort = () => {
|
|
19
|
+
controller?.abort();
|
|
20
|
+
controller = null;
|
|
21
|
+
generation = null;
|
|
22
|
+
};
|
|
23
|
+
const safeReport = (error, context) => {
|
|
24
|
+
try { reportError?.(error, Object.freeze({ phase: "height-negotiation", ...context })); } catch { /* Host reporting cannot destabilize the controller. */ }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const baseline = (width, currentHeight) => {
|
|
28
|
+
if (!adapter || destroyed || !finitePositive(width) || !finitePositive(currentHeight)) return { value: null, error: null };
|
|
29
|
+
try {
|
|
30
|
+
const value = adapter.getBaselineHeight(Object.freeze({ canvas, width, currentHeight }));
|
|
31
|
+
return { value: finitePositive(value) ? normalize(value) : null, error: null };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return { value: null, error };
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const commit = ({ contractRevision, width, baselineHeight, preferredHeight, baselineError = null }) => {
|
|
38
|
+
if (!adapter || destroyed) return;
|
|
39
|
+
if (baselineError) {
|
|
40
|
+
abort();
|
|
41
|
+
safeReport(baselineError, { operation: "baseline", width, baselineHeight: null, preferredHeight: null });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (![width, baselineHeight, preferredHeight].every(finitePositive)) { abort(); return; }
|
|
45
|
+
const key = `${contractRevision}:${normalize(width)}:${normalize(baselineHeight)}`;
|
|
46
|
+
if (key !== generation) {
|
|
47
|
+
abort();
|
|
48
|
+
generation = key;
|
|
49
|
+
controller = new AbortController();
|
|
50
|
+
}
|
|
51
|
+
const requestHeight = normalize(preferredHeight);
|
|
52
|
+
if (controller.requestedHeight === requestHeight) return;
|
|
53
|
+
controller.requestedHeight = requestHeight;
|
|
54
|
+
const requestController = controller;
|
|
55
|
+
const requestSerial = ++serial;
|
|
56
|
+
queueMicrotask(() => {
|
|
57
|
+
if (destroyed || requestController.signal.aborted || requestSerial !== serial || requestController !== controller) return;
|
|
58
|
+
try {
|
|
59
|
+
const returned = adapter.requestPreferredHeight(Object.freeze({
|
|
60
|
+
preferredHeight: requestHeight,
|
|
61
|
+
baselineHeight: normalize(baselineHeight),
|
|
62
|
+
width: normalize(width),
|
|
63
|
+
signal: requestController.signal,
|
|
64
|
+
}));
|
|
65
|
+
// Promise fulfillment is not an acknowledgement. Rejection is observed only
|
|
66
|
+
// to prevent an unhandled host error; it never retries or changes layout state.
|
|
67
|
+
if (returned && typeof returned.then === "function") Promise.resolve(returned).catch((error) => {
|
|
68
|
+
safeReport(error, { operation: "request", width: normalize(width), baselineHeight: normalize(baselineHeight), preferredHeight: requestHeight });
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
safeReport(error, { operation: "request", width: normalize(width), baselineHeight: normalize(baselineHeight), preferredHeight: requestHeight });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return Object.freeze({ baseline, commit, abort, destroy() { if (destroyed) return; destroyed = true; abort(); serial += 1; } });
|
|
77
|
+
}
|
package/src/index.js
CHANGED
|
@@ -22,7 +22,7 @@ export { THEME_CATALOG_VERSION, auditThemeCatalog, catalogThemePack, mergeThemeP
|
|
|
22
22
|
export { prepareScatter } from "./renderers/scatter.js";
|
|
23
23
|
export { prepareStrip } from "./renderers/strip-summary.js";
|
|
24
24
|
export { AnimationClock } from "./clock.js";
|
|
25
|
-
export { THEME_PACK_VERSION, PALETTE_PACK_VERSION, applyTheme, contrastAudit, contrastRatio, loadThemePack, normalizeThemePackLenient, resolveTheme, resolvePalette, themeForProfile, validateAuthoredThemePack, validateThemePack, validatePalettePack } from "./theme-pack.js";
|
|
25
|
+
export { THEME_PACK_VERSION, PALETTE_PACK_VERSION, applyTheme, contrastAudit, contrastRatio, renderedSeriesAudit, loadThemePack, normalizeThemePackLenient, resolveTheme, resolvePalette, themeForProfile, validateAuthoredThemePack, validateThemePack, validatePalettePack } from "./theme-pack.js";
|
|
26
26
|
export { RENDER_LAYER_ORDER, partitionPanelMarks, plotClipRect, renderLayerForMark, withCanvasPlotClip } from "./render-layers.js";
|
|
27
27
|
export { validateEvidenceCoverage } from "./evidence-coverage.js";
|
|
28
28
|
export { colorContrast, hexToOklab, hexToOklch, oklabDistance, oklchToHex, resolveContrastColor } from "./color-space.js";
|
package/src/layout.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { SCREEN_PROJECT_LEGIBILITY_FLOORS } from "./screen-legibility.js";
|
|
2
|
+
|
|
1
3
|
const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value));
|
|
2
4
|
|
|
3
5
|
export const MIN_CANVAS_WIDTH = 320;
|
|
@@ -16,11 +18,11 @@ export function deriveLayout(width, height) {
|
|
|
16
18
|
scale,
|
|
17
19
|
plot: { left, right: width - right, top, bottom: height - bottom },
|
|
18
20
|
font: {
|
|
19
|
-
title: clamp(19 * scale,
|
|
21
|
+
title: clamp(19 * scale, 14, 22),
|
|
20
22
|
subtitle: clamp(12.5 * scale, 9, 14),
|
|
21
23
|
axis: clamp(12 * scale, narrow ? 10 : 9, 13),
|
|
22
24
|
legend: clamp(11.5 * scale, narrow ? 9.5 : 8.5, 13),
|
|
23
|
-
signature: clamp(10 * scale, narrow ?
|
|
25
|
+
signature: clamp(10 * scale, narrow ? SCREEN_PROJECT_LEGIBILITY_FLOORS.compactProvenancePx : 7.5, 11),
|
|
24
26
|
},
|
|
25
27
|
provenance: { left, right: width - right, y: height - clamp(12 * scale, 10, 16) },
|
|
26
28
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Line-only prominence. Glyph identity/rhythm still come from series-style.js.
|
|
2
|
+
// Units are CSS px; the same resolved geometry feeds Canvas and SVG.
|
|
3
|
+
export function lineMarkerGeometry(style, scale = 1, markerScale = 1) {
|
|
4
|
+
const shapeScale = style.glyph === "square" ? 0.9 : style.glyph === "triangle" ? 1.1 : 1;
|
|
5
|
+
return { radius: 4.1 * Math.max(1, scale) * markerScale * shapeScale,
|
|
6
|
+
outlineWidth: 2 * Math.max(1, scale) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Append, don't begin: also used inside an own-line-only clipping path.
|
|
10
|
+
export function appendMarkerPath(context, glyph, x, y, radius) {
|
|
11
|
+
if (glyph === "square") context.rect(x - radius, y - radius, radius * 2, radius * 2);
|
|
12
|
+
else if (glyph === "triangle") { context.moveTo(x, y - radius); context.lineTo(x + radius, y + radius); context.lineTo(x - radius, y + radius); context.closePath(); }
|
|
13
|
+
else if (glyph === "diamond") { context.moveTo(x, y - radius); context.lineTo(x + radius, y); context.lineTo(x, y + radius); context.lineTo(x - radius, y); context.closePath(); }
|
|
14
|
+
else { context.moveTo(x + radius, y); context.arc(x, y, radius, 0, Math.PI * 2); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function clipOwnLine(context, markers, plot) {
|
|
18
|
+
// Intersect individual complements: overlapping markers remain a union of holes.
|
|
19
|
+
// A single even-odd path containing every marker would incorrectly XOR overlaps.
|
|
20
|
+
for (const mark of markers) {
|
|
21
|
+
const m = mark.motion, g = mark.geometry;
|
|
22
|
+
if (m.opacity <= 0) continue;
|
|
23
|
+
context.beginPath();
|
|
24
|
+
context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
|
|
25
|
+
appendMarkerPath(context, mark.style.glyph, g.cx + m.translateX, g.cy + m.translateY,
|
|
26
|
+
g.radius * Math.min(m.scaleX, m.scaleY));
|
|
27
|
+
context.clip("evenodd");
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/marks.js
CHANGED
|
@@ -116,9 +116,9 @@ export function drawAxes(context, { config, layout, scales, xTicks, yTicks, xCat
|
|
|
116
116
|
context.textAlign = "center";
|
|
117
117
|
context.textBaseline = "top";
|
|
118
118
|
if (xCategories) {
|
|
119
|
-
xCategories.forEach((label, index) => context.fillText(label, scales.x(index), plot.bottom + 10 * layout.scale));
|
|
119
|
+
xCategories.forEach((label, index) => context.fillText(label, scales.x(index), layout.text?.xTickY ?? plot.bottom + 10 * layout.scale));
|
|
120
120
|
} else {
|
|
121
|
-
xTicks.forEach((value) => context.fillText(formatTick(value), scales.x(value), plot.bottom + 10 * layout.scale));
|
|
121
|
+
xTicks.forEach((value) => context.fillText(formatTick(value), scales.x(value), layout.text?.xTickY ?? plot.bottom + 10 * layout.scale));
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
if (spec.xLabel) {
|
package/src/paper-profile.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { colorContrast, hexToOklab, resolveContrastColor } from "./color-space.js";
|
|
2
|
+
import { resolveScreenTheme } from "./screen-legibility.js";
|
|
2
3
|
|
|
3
4
|
export const PAPER_PROFILE_VERSION = "figurestead.paper-profile/1";
|
|
4
5
|
export const PAPER_FLOORS = Object.freeze({ text: 4.5, evidence: 3, thinEvidenceTarget: 4, pairwiseLightness: 0.1, identityWarning: 0.12 });
|
|
@@ -72,7 +73,10 @@ export function resolvePaperTheme(source) {
|
|
|
72
73
|
export function themeResolutionForProfile(source, profile = "atlas") {
|
|
73
74
|
const key = typeof profile === "string" ? profile : profile?.key;
|
|
74
75
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
75
|
-
if (key !== "paper")
|
|
76
|
+
if (key !== "paper") {
|
|
77
|
+
if (source.mode === "paper") return Object.freeze({ theme: clone(source), report: null });
|
|
78
|
+
return resolveScreenTheme(clone(source));
|
|
79
|
+
}
|
|
76
80
|
if (source.mode === "paper") return Object.freeze({ theme: clone(source), report: auditPaperTheme(source) });
|
|
77
81
|
return resolvePaperTheme(source);
|
|
78
82
|
}
|
package/src/renderers/scatter.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { arrival, numericScales } from "./shared.js";
|
|
2
2
|
import { compileProgress, drawAxes, drawScopePoint, drawText, pointMotionState } from "../marks.js";
|
|
3
3
|
import { styleForSeries } from "../series-style.js";
|
|
4
|
+
import { linearFit } from "../statistics.js";
|
|
4
5
|
|
|
5
6
|
export function prepareScatter(contract) {
|
|
6
7
|
const keys = [...new Set(contract.data.series)];
|
|
@@ -13,8 +14,8 @@ export function drawScatter(context, env) {
|
|
|
13
14
|
drawAxes(context, { config: contract, layout, scales, xTicks: scales.xTicks, yTicks: scales.yTicks });
|
|
14
15
|
prepared.points.forEach((point) => { const style = styleForSeries(env, point.series, point.colorIndex), state = pointMotionState(point, progress, scales, layout.plot); if (contract.view?.motion === "semantic") { state.x = state.finalX; state.y = state.finalY; } drawScopePoint(context, state, { color: style.color, edge: style.edge, radius: Math.max(3.5, Math.sqrt(contract.profile.markerSize) * 0.64 * layout.scale) * (presentation.markerScale ?? 1), trailAlpha: contract.motion.trailAlpha, settled: settled || contract.view?.motion === "semantic", shape: style.glyph }); });
|
|
15
16
|
if (contract.data.summary === "linear_fit") {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
17
|
+
const { slope, intercept } = linearFit(prepared.points.map((point) => point.x), prepared.points.map((point) => point.y));
|
|
18
|
+
const cp=compileProgress(progress, contract.timeline), x0=scales.xDomain[0], x1=x0+(scales.xDomain[1]-x0)*cp;
|
|
18
19
|
context.save();
|
|
19
20
|
if (contract.theme.summaryEdge) { context.strokeStyle=contract.theme.summaryEdge; context.globalAlpha=.62*cp; context.lineWidth=Math.max(2,2.8*layout.scale); context.beginPath(); context.moveTo(scales.x(x0),scales.y(intercept+slope*x0)); context.lineTo(scales.x(x1),scales.y(intercept+slope*x1)); context.stroke(); }
|
|
20
21
|
context.strokeStyle=contract.theme.summaryCore; context.globalAlpha=.72*cp; context.lineWidth=Math.max(1,1.5*layout.scale); context.beginPath(); context.moveTo(scales.x(x0),scales.y(intercept+slope*x0)); context.lineTo(scales.x(x1),scales.y(intercept+slope*x1)); context.stroke(); context.restore();
|