@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21
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/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { clamp01, createTween, easingFns, lerp } from '../tween';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Deterministic rAF: we control frame timestamps manually.
|
|
6
|
-
*/
|
|
7
|
-
let rafCallbacks: Map<number, FrameRequestCallback>;
|
|
8
|
-
let rafId: number;
|
|
9
|
-
let now: number;
|
|
10
|
-
|
|
11
|
-
function advanceFrame(dtMs: number) {
|
|
12
|
-
now += dtMs;
|
|
13
|
-
const pending = [...rafCallbacks.entries()];
|
|
14
|
-
rafCallbacks.clear();
|
|
15
|
-
for (const [, cb] of pending) cb(now);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
beforeEach(() => {
|
|
19
|
-
rafCallbacks = new Map();
|
|
20
|
-
rafId = 0;
|
|
21
|
-
now = 1000;
|
|
22
|
-
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
23
|
-
rafCallbacks.set(++rafId, cb);
|
|
24
|
-
return rafId;
|
|
25
|
-
});
|
|
26
|
-
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
|
27
|
-
rafCallbacks.delete(id);
|
|
28
|
-
});
|
|
29
|
-
vi.stubGlobal('performance', { now: () => now });
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
vi.unstubAllGlobals();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('easingFns', () => {
|
|
37
|
-
it.each(Object.entries(easingFns))('%s hits 0 at 0 and 1 at 1', (_, fn) => {
|
|
38
|
-
expect(fn(0)).toBeCloseTo(0);
|
|
39
|
-
expect(fn(1)).toBeCloseTo(1);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it.each(Object.entries(easingFns))('%s is monotonic on [0,1]', (_, fn) => {
|
|
43
|
-
let prev = fn(0);
|
|
44
|
-
for (let t = 0.05; t <= 1.0001; t += 0.05) {
|
|
45
|
-
const v = fn(t);
|
|
46
|
-
expect(v).toBeGreaterThanOrEqual(prev - 1e-9);
|
|
47
|
-
prev = v;
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe('lerp / clamp01', () => {
|
|
53
|
-
it('lerp endpoints and midpoint', () => {
|
|
54
|
-
expect(lerp(0, 10, 0)).toBe(0);
|
|
55
|
-
expect(lerp(0, 10, 1)).toBe(10);
|
|
56
|
-
expect(lerp(0, 10, 0.5)).toBe(5);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('clamp01 clamps', () => {
|
|
60
|
-
expect(clamp01(-1)).toBe(0);
|
|
61
|
-
expect(clamp01(2)).toBe(1);
|
|
62
|
-
expect(clamp01(0.5)).toBe(0.5);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe('createTween', () => {
|
|
67
|
-
it('retargets FROM THE LIVE interpolated value, not the old endpoint', () => {
|
|
68
|
-
const frames: number[] = [];
|
|
69
|
-
const tween = createTween<number>({
|
|
70
|
-
initial: 0,
|
|
71
|
-
lerp,
|
|
72
|
-
duration: 100,
|
|
73
|
-
ease: easingFns.linear,
|
|
74
|
-
onFrame: (v) => frames.push(v),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
tween.to(100);
|
|
78
|
-
advanceFrame(50); // live value ~50
|
|
79
|
-
const live = frames.at(-1)!;
|
|
80
|
-
expect(live).toBeCloseTo(50);
|
|
81
|
-
|
|
82
|
-
// Fast-scroll interruption: retargeting must restart from the live
|
|
83
|
-
// mid-flight value, not snap back to the previous target first.
|
|
84
|
-
tween.to(0);
|
|
85
|
-
advanceFrame(0); // first frame of the retargeted tween
|
|
86
|
-
advanceFrame(1);
|
|
87
|
-
const firstAfterRetarget = frames.at(-1)!;
|
|
88
|
-
expect(firstAfterRetarget).toBeLessThanOrEqual(live);
|
|
89
|
-
expect(firstAfterRetarget).toBeGreaterThan(40); // started near 50, not 100
|
|
90
|
-
|
|
91
|
-
advanceFrame(200);
|
|
92
|
-
expect(tween.get()).toBe(0);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('snap applies synchronously with no rAF', () => {
|
|
96
|
-
const frames: number[] = [];
|
|
97
|
-
const tween = createTween<number>({
|
|
98
|
-
initial: 0,
|
|
99
|
-
lerp,
|
|
100
|
-
onFrame: (v) => frames.push(v),
|
|
101
|
-
});
|
|
102
|
-
tween.to(7, { snap: true });
|
|
103
|
-
expect(frames).toEqual([7]);
|
|
104
|
-
expect(tween.get()).toBe(7);
|
|
105
|
-
expect(rafCallbacks.size).toBe(0);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('works over arbitrary shapes', () => {
|
|
109
|
-
type State = { a: number; b: number };
|
|
110
|
-
const lerpState = (x: State, y: State, t: number): State => ({
|
|
111
|
-
a: lerp(x.a, y.a, t),
|
|
112
|
-
b: lerp(x.b, y.b, t),
|
|
113
|
-
});
|
|
114
|
-
let latest: State = { a: 0, b: 10 };
|
|
115
|
-
const tween = createTween<State>({
|
|
116
|
-
initial: latest,
|
|
117
|
-
lerp: lerpState,
|
|
118
|
-
duration: 100,
|
|
119
|
-
ease: easingFns.linear,
|
|
120
|
-
onFrame: (v) => {
|
|
121
|
-
latest = v;
|
|
122
|
-
},
|
|
123
|
-
});
|
|
124
|
-
tween.to({ a: 10, b: 0 });
|
|
125
|
-
advanceFrame(50);
|
|
126
|
-
expect(latest.a).toBeCloseTo(5);
|
|
127
|
-
expect(latest.b).toBeCloseTo(5);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('cancel stops emission mid-flight', () => {
|
|
131
|
-
const frames: number[] = [];
|
|
132
|
-
const tween = createTween<number>({
|
|
133
|
-
initial: 0,
|
|
134
|
-
lerp,
|
|
135
|
-
duration: 100,
|
|
136
|
-
ease: easingFns.linear,
|
|
137
|
-
onFrame: (v) => frames.push(v),
|
|
138
|
-
});
|
|
139
|
-
tween.to(100);
|
|
140
|
-
advanceFrame(30);
|
|
141
|
-
const count = frames.length;
|
|
142
|
-
tween.cancel();
|
|
143
|
-
advanceFrame(30);
|
|
144
|
-
expect(frames.length).toBe(count);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('zero duration snaps immediately', () => {
|
|
148
|
-
const frames: number[] = [];
|
|
149
|
-
const tween = createTween<number>({
|
|
150
|
-
initial: 0,
|
|
151
|
-
lerp,
|
|
152
|
-
duration: 0,
|
|
153
|
-
onFrame: (v) => frames.push(v),
|
|
154
|
-
});
|
|
155
|
-
tween.to(50);
|
|
156
|
-
expect(frames).toEqual([50]);
|
|
157
|
-
expect(rafCallbacks.size).toBe(0);
|
|
158
|
-
});
|
|
159
|
-
});
|
package/src/story/camera-math.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { lerp } from './tween';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Pure math for the scrollytelling camera. `createChartStory` owns DOM
|
|
5
|
-
* writes; everything here is testable without a browser. Ported from
|
|
6
|
-
* opendata/shared/lib/scrolly/camera-math.ts.
|
|
7
|
-
*
|
|
8
|
-
* The camera is a center + zoom over a fixed viewBox, applied as a
|
|
9
|
-
* `transform` on a dedicated wrapper `<g>` around the marks group (NOT
|
|
10
|
-
* viewBox interpolation -- that scales text and gives labels no way to
|
|
11
|
-
* counter-scale). See .claude/rules/svg-animation.md: CSS transform
|
|
12
|
-
* replaces SVG transform attributes, so the camera transform must compose
|
|
13
|
-
* with, not replace, existing SVG transforms on marks.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
export interface Camera {
|
|
17
|
-
cx: number;
|
|
18
|
-
cy: number;
|
|
19
|
-
/** Zoom factor. 1 = full viewBox visible. */
|
|
20
|
-
k: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface CameraTarget {
|
|
24
|
-
x: number;
|
|
25
|
-
y: number;
|
|
26
|
-
width: number;
|
|
27
|
-
height: number;
|
|
28
|
-
/** ViewBox units added on all sides before fitting */
|
|
29
|
-
padding?: number;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface ViewBoxSize {
|
|
33
|
-
width: number;
|
|
34
|
-
height: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export const FULL_VIEW = (vb: ViewBoxSize): CameraTarget => ({
|
|
38
|
-
x: 0,
|
|
39
|
-
y: 0,
|
|
40
|
-
width: vb.width,
|
|
41
|
-
height: vb.height,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
/** Fit a target rect inside the viewBox, preserving aspect ratio. */
|
|
45
|
-
export function fitTarget(target: CameraTarget, vb: ViewBoxSize): Camera {
|
|
46
|
-
const pad = target.padding ?? 0;
|
|
47
|
-
const w = target.width + 2 * pad;
|
|
48
|
-
const h = target.height + 2 * pad;
|
|
49
|
-
return {
|
|
50
|
-
cx: target.x + target.width / 2,
|
|
51
|
-
cy: target.y + target.height / 2,
|
|
52
|
-
k: Math.min(vb.width / w, vb.height / h),
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Pan lerps linearly; zoom lerps in LOG space so the zoom rate reads
|
|
58
|
-
* constant (a 1->4 zoom passes through 2 at the midpoint, not 2.5).
|
|
59
|
-
*/
|
|
60
|
-
export function interpolateCamera(a: Camera, b: Camera, t: number): Camera {
|
|
61
|
-
return {
|
|
62
|
-
cx: lerp(a.cx, b.cx, t),
|
|
63
|
-
cy: lerp(a.cy, b.cy, t),
|
|
64
|
-
k: Math.exp(lerp(Math.log(a.k), Math.log(b.k), t)),
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** SVG transform string that frames the camera inside the viewBox. */
|
|
69
|
-
export function cameraTransform(c: Camera, vb: ViewBoxSize): string {
|
|
70
|
-
return `translate(${vb.width / 2} ${vb.height / 2}) scale(${c.k}) translate(${-c.cx} ${-c.cy})`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Frame-rate-independent exponential damping: two 8ms steps land where one
|
|
75
|
-
* 16ms step does. `tauMs` is the time constant (~63% of the gap per tau).
|
|
76
|
-
*/
|
|
77
|
-
export function damp(current: number, target: number, tauMs: number, dtMs: number): number {
|
|
78
|
-
if (tauMs <= 0) return target;
|
|
79
|
-
return current + (target - current) * (1 - Math.exp(-dtMs / tauMs));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function dampCamera(current: Camera, target: Camera, tauMs: number, dtMs: number): Camera {
|
|
83
|
-
return {
|
|
84
|
-
cx: damp(current.cx, target.cx, tauMs, dtMs),
|
|
85
|
-
cy: damp(current.cy, target.cy, tauMs, dtMs),
|
|
86
|
-
// k damped in log space, same rationale as interpolateCamera
|
|
87
|
-
k: Math.exp(damp(Math.log(current.k), Math.log(target.k), tauMs, dtMs)),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function camerasClose(a: Camera, b: Camera, epsilon = 1e-3): boolean {
|
|
92
|
-
return (
|
|
93
|
-
Math.abs(a.cx - b.cx) < epsilon &&
|
|
94
|
-
Math.abs(a.cy - b.cy) < epsilon &&
|
|
95
|
-
Math.abs(Math.log(a.k) - Math.log(b.k)) < epsilon
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Where the scrub camera should point for a given frame.
|
|
101
|
-
*
|
|
102
|
-
* Hold-zone semantics: the camera DWELLS on the active step's target while
|
|
103
|
-
* the reader reads, and only transitions toward the next step's target in
|
|
104
|
-
* the tail of the span (`stepProgress > holdUntil`). Interpolating across
|
|
105
|
-
* the whole span would mean the camera departs the moment a step activates
|
|
106
|
-
* and never rests on what the text is describing.
|
|
107
|
-
*/
|
|
108
|
-
export function scrubCamera(
|
|
109
|
-
step: number,
|
|
110
|
-
stepProgress: number,
|
|
111
|
-
fitted: Camera[],
|
|
112
|
-
holdUntil: number,
|
|
113
|
-
ease: (t: number) => number,
|
|
114
|
-
): Camera {
|
|
115
|
-
const n = fitted.length;
|
|
116
|
-
if (n === 0) return { cx: 0, cy: 0, k: 1 };
|
|
117
|
-
const clamped = Math.min(Math.max(step, 0), n - 1);
|
|
118
|
-
const current = fitted[clamped]!;
|
|
119
|
-
const next = fitted[Math.min(clamped + 1, n - 1)]!;
|
|
120
|
-
if (step < 0 || clamped >= n - 1 || stepProgress <= holdUntil) {
|
|
121
|
-
return current;
|
|
122
|
-
}
|
|
123
|
-
const t = (stepProgress - holdUntil) / (1 - holdUntil);
|
|
124
|
-
return interpolateCamera(current, next, ease(t));
|
|
125
|
-
}
|
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `createChartStory`: a base spec plus an ordered list of deep-partial
|
|
3
|
-
* patch steps, driven by scroll (built-in `ScrollDriver`) or directly via
|
|
4
|
-
* `goTo(n)`.
|
|
5
|
-
*
|
|
6
|
-
* Animation-clock ownership (decision baked 2026-07-11, see
|
|
7
|
-
* plans/design-evolution/11-scrollytelling.md): the data-update-transitions
|
|
8
|
-
* driver in `../transition.ts` owns mark morphing; the ported tween in
|
|
9
|
-
* `./tween.ts` owns camera and any scalar story-level animation. They never
|
|
10
|
-
* animate the same property. `goTo` calls `ChartInstance.update()`, which
|
|
11
|
-
* internally decides morph-vs-instant-swap via `canTransition`; this module
|
|
12
|
-
* predicts that decision ahead of time (`canTransitionSpecShape`) only to
|
|
13
|
-
* decide whether to arm the crossfade fallback, and separately drives the
|
|
14
|
-
* camera tween. It does not bridge the two clocks.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { DataRow, MapSpec } from '@opendata-ai/openchart-core';
|
|
18
|
-
import { deepMergeSpec, isMapSpec } from '@opendata-ai/openchart-core';
|
|
19
|
-
import { createMap, type MapInstance, type MapMountOptions } from '../map-mount';
|
|
20
|
-
import { type ChartInstance, createChart, type MountOptions } from '../mount';
|
|
21
|
-
import { canTransitionSpecShape } from '../transition';
|
|
22
|
-
import {
|
|
23
|
-
type Camera,
|
|
24
|
-
camerasClose,
|
|
25
|
-
dampCamera,
|
|
26
|
-
FULL_VIEW,
|
|
27
|
-
fitTarget,
|
|
28
|
-
interpolateCamera,
|
|
29
|
-
scrubCamera,
|
|
30
|
-
type ViewBoxSize,
|
|
31
|
-
} from './camera-math';
|
|
32
|
-
import { crossfadeUpdate } from './crossfade';
|
|
33
|
-
import { isDataCameraTarget, resolveCameraTarget } from './resolve-camera-target';
|
|
34
|
-
import { createScrollDriver } from './scroll-driver';
|
|
35
|
-
import { applyStoryCamera, readViewBox } from './story-camera';
|
|
36
|
-
import { createTween, easingFns, storyMotion } from './tween';
|
|
37
|
-
import type {
|
|
38
|
-
ChartStoryInstance,
|
|
39
|
-
ChartStoryOptions,
|
|
40
|
-
StorySpec,
|
|
41
|
-
StorySpecPatch,
|
|
42
|
-
StoryStep,
|
|
43
|
-
} from './types';
|
|
44
|
-
|
|
45
|
-
function prefersReducedMotion(): boolean {
|
|
46
|
-
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
|
47
|
-
try {
|
|
48
|
-
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
49
|
-
} catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Merge a step's `highlight` sugar into its `spec` patch as `encoding.color.highlight`. */
|
|
55
|
-
function stepToPatch(step: StoryStep): StorySpecPatch {
|
|
56
|
-
let patch = step.spec ?? {};
|
|
57
|
-
if ('highlight' in step) {
|
|
58
|
-
// `highlight: null` clears a highlight set by an earlier step. It must map
|
|
59
|
-
// to `[]` (not `undefined`): deep-merge skips `undefined`, so `?? undefined`
|
|
60
|
-
// would leave the prior step's highlight in place. An empty array is a real
|
|
61
|
-
// value that replaces it, and the engine reads `highlight.length > 0` as
|
|
62
|
-
// active, so `[]` reads as "no highlight."
|
|
63
|
-
const highlight = step.highlight === null ? [] : (step.highlight ?? undefined);
|
|
64
|
-
patch = deepMergeSpec(patch, {
|
|
65
|
-
encoding: { color: { highlight } },
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
return patch;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Apply patches 0..index (inclusive) onto the base spec, cumulatively. */
|
|
72
|
-
function resolveSpecAtStep(base: StorySpec, steps: StoryStep[], index: number): StorySpec {
|
|
73
|
-
let spec: StorySpec = base;
|
|
74
|
-
for (let i = 0; i <= index && i < steps.length; i++) {
|
|
75
|
-
spec = deepMergeSpec(spec, stepToPatch(steps[i]!));
|
|
76
|
-
}
|
|
77
|
-
return spec;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Create a scrollytelling story bound to a container. Mounts a chart with
|
|
82
|
-
* the base spec, then drives it through cumulative patch steps as the
|
|
83
|
-
* reader scrolls (or via `goTo`).
|
|
84
|
-
*
|
|
85
|
-
* `editMode` (any of `onEdit`/`onSelect`/`onDeselect`/`onTextEdit` on
|
|
86
|
-
* `mountOptions`) and an active story are mutually exclusive in v1: passing
|
|
87
|
-
* both warns once and disables the story's own drive loop, matching the
|
|
88
|
-
* seriesSearch/edit-mode precedent in `mount.ts`.
|
|
89
|
-
*/
|
|
90
|
-
export function createChartStory<TData extends DataRow = DataRow>(
|
|
91
|
-
container: HTMLElement,
|
|
92
|
-
options: ChartStoryOptions<TData>,
|
|
93
|
-
mountOptions?: MountOptions,
|
|
94
|
-
): ChartStoryInstance {
|
|
95
|
-
const { spec, steps, triggerPosition = 0.4, cameraMode = 'step' } = options;
|
|
96
|
-
// The story machinery is spec-shape agnostic (it only deep-merges patches
|
|
97
|
-
// and hands specs to `update()`, which accepts the non-generic union).
|
|
98
|
-
// Widen once here so the internal helpers don't have to thread `TData`
|
|
99
|
-
// through a discriminated union that TS can't narrow across variants.
|
|
100
|
-
const baseSpec = spec as StorySpec;
|
|
101
|
-
const isMap = isMapSpec(baseSpec as unknown as Record<string, unknown>);
|
|
102
|
-
|
|
103
|
-
if (isMap && cameraMode === 'scrub') {
|
|
104
|
-
console.warn(
|
|
105
|
-
'[openchart] cameraMode: "scrub" is not supported for map stories; falling back to "step" mode. Map camera is driven via geo.focus patches in the spec.',
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (isMap && steps.some((s) => s.camera)) {
|
|
110
|
-
console.warn(
|
|
111
|
-
'[openchart] step.camera is ignored for map stories. Drive the map camera via geo.focus patches in the spec instead.',
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const editModeRequested = !!(
|
|
116
|
-
mountOptions?.onEdit ||
|
|
117
|
-
mountOptions?.onSelect ||
|
|
118
|
-
mountOptions?.onDeselect ||
|
|
119
|
-
mountOptions?.onTextEdit
|
|
120
|
-
);
|
|
121
|
-
if (editModeRequested) {
|
|
122
|
-
console.warn(
|
|
123
|
-
'[openchart] a chart story and edit mode are mutually exclusive; the story will not drive updates while editing callbacks are active.',
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
let currentStep = -1;
|
|
128
|
-
let destroyed = false;
|
|
129
|
-
|
|
130
|
-
const initialSpec = resolveSpecAtStep(baseSpec, steps, 0);
|
|
131
|
-
|
|
132
|
-
// Branch: map vs chart mount
|
|
133
|
-
let instance: ChartInstance | MapInstance;
|
|
134
|
-
if (isMap) {
|
|
135
|
-
// Not a spread: MapMountOptions callback signatures differ from MountOptions.
|
|
136
|
-
// When adding fields to MapMountOptions, check if they should transfer here.
|
|
137
|
-
const mapOpts: MapMountOptions = {
|
|
138
|
-
theme: mountOptions?.theme,
|
|
139
|
-
darkMode: mountOptions?.darkMode,
|
|
140
|
-
watermark: mountOptions?.watermark,
|
|
141
|
-
responsive: mountOptions?.responsive,
|
|
142
|
-
};
|
|
143
|
-
instance = createMap(container, initialSpec as MapSpec, mapOpts);
|
|
144
|
-
} else {
|
|
145
|
-
instance = createChart(container, initialSpec as Exclude<StorySpec, MapSpec>, mountOptions);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Chart camera infra (not used for maps)
|
|
149
|
-
const cameraTween = isMap
|
|
150
|
-
? null
|
|
151
|
-
: createTween<Camera>({
|
|
152
|
-
initial: { cx: 0, cy: 0, k: 1 },
|
|
153
|
-
lerp: interpolateCamera,
|
|
154
|
-
duration: storyMotion.camera,
|
|
155
|
-
ease: easingFns.easeInOutCubic,
|
|
156
|
-
onFrame: (camera) => {
|
|
157
|
-
const vb = readViewBox(container);
|
|
158
|
-
if (vb) applyStoryCamera(container, camera, vb);
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
/** Fit a step's camera (data-coordinate or raw target, or full view) into the viewBox. */
|
|
163
|
-
function fittedCameraForStep(step: StoryStep | undefined, vb: ViewBoxSize): Camera {
|
|
164
|
-
if (!step?.camera) return fitTarget(FULL_VIEW(vb), vb);
|
|
165
|
-
const target = isDataCameraTarget(step.camera)
|
|
166
|
-
? resolveCameraTarget((instance as ChartInstance).layout, step.camera)
|
|
167
|
-
: step.camera;
|
|
168
|
-
return fitTarget(target, vb);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ---- Scrub-mode camera: self-stopping damping loop toward a live target ---
|
|
172
|
-
// Step mode eases discretely on step change (the tween above). Scrub mode
|
|
173
|
-
// reads continuous `stepProgress` and dwells on the active target through a
|
|
174
|
-
// hold zone, transitioning in the tail of the span. The two modes never run
|
|
175
|
-
// at once; the settle loop and the step tween are mutually exclusive drivers.
|
|
176
|
-
let scrubCurrent: Camera | null = null;
|
|
177
|
-
let scrubTarget: Camera | null = null;
|
|
178
|
-
let settleRaf = 0;
|
|
179
|
-
let settling = false;
|
|
180
|
-
|
|
181
|
-
// Memoized per-step fitted cameras for scrub mode. The fit depends only on
|
|
182
|
-
// the current layout (rebuilt on instance.update) and the viewBox size, so
|
|
183
|
-
// recomputing it on every scroll frame is wasted work. Cache keyed on the
|
|
184
|
-
// layout reference plus the viewBox dimensions; invalidate when either moves.
|
|
185
|
-
let fittedCache: Camera[] | null = null;
|
|
186
|
-
let fittedCacheLayout: unknown = null;
|
|
187
|
-
let fittedCacheVb: ViewBoxSize | null = null;
|
|
188
|
-
|
|
189
|
-
function fittedCamerasForScrub(vb: ViewBoxSize): Camera[] {
|
|
190
|
-
if (
|
|
191
|
-
fittedCache &&
|
|
192
|
-
fittedCacheLayout === (instance as ChartInstance).layout &&
|
|
193
|
-
fittedCacheVb &&
|
|
194
|
-
fittedCacheVb.width === vb.width &&
|
|
195
|
-
fittedCacheVb.height === vb.height
|
|
196
|
-
) {
|
|
197
|
-
return fittedCache;
|
|
198
|
-
}
|
|
199
|
-
fittedCache = steps.map((step) => fittedCameraForStep(step, vb));
|
|
200
|
-
fittedCacheLayout = (instance as ChartInstance).layout;
|
|
201
|
-
fittedCacheVb = vb;
|
|
202
|
-
return fittedCache;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function stopSettle(): void {
|
|
206
|
-
if (settling) {
|
|
207
|
-
cancelAnimationFrame(settleRaf);
|
|
208
|
-
settling = false;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function startSettle(): void {
|
|
213
|
-
if (settling) return;
|
|
214
|
-
settling = true;
|
|
215
|
-
let last = performance.now();
|
|
216
|
-
const tick = (now: number): void => {
|
|
217
|
-
const target = scrubTarget;
|
|
218
|
-
const current = scrubCurrent;
|
|
219
|
-
const vb = readViewBox(container);
|
|
220
|
-
if (!target || !current || !vb) {
|
|
221
|
-
settling = false;
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
const dt = Math.min(now - last, 64);
|
|
225
|
-
last = now;
|
|
226
|
-
const next = dampCamera(current, target, storyMotion.cameraTau, dt);
|
|
227
|
-
scrubCurrent = next;
|
|
228
|
-
applyStoryCamera(container, next, vb);
|
|
229
|
-
if (camerasClose(next, target)) {
|
|
230
|
-
scrubCurrent = target;
|
|
231
|
-
applyStoryCamera(container, target, vb);
|
|
232
|
-
settling = false;
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
settleRaf = requestAnimationFrame(tick);
|
|
236
|
-
};
|
|
237
|
-
settleRaf = requestAnimationFrame(tick);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Step-mode camera: retargetable eased tween on step change. */
|
|
241
|
-
function applyCameraForStep(step: StoryStep | undefined, snap: boolean): void {
|
|
242
|
-
if (!cameraTween) return;
|
|
243
|
-
const vb = readViewBox(container);
|
|
244
|
-
if (!vb) return;
|
|
245
|
-
cameraTween.to(fittedCameraForStep(step, vb), { snap: snap || prefersReducedMotion() });
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** Scrub-mode camera: continuous, driven by the scroll frame's stepProgress. */
|
|
249
|
-
function applyScrubCamera(frame: { step: number; stepProgress: number }): void {
|
|
250
|
-
const vb = readViewBox(container);
|
|
251
|
-
if (!vb) return;
|
|
252
|
-
const fitted = fittedCamerasForScrub(vb);
|
|
253
|
-
if (fitted.length === 0) return;
|
|
254
|
-
|
|
255
|
-
if (prefersReducedMotion()) {
|
|
256
|
-
const clamped = Math.min(Math.max(frame.step, 0), fitted.length - 1);
|
|
257
|
-
stopSettle();
|
|
258
|
-
scrubCurrent = fitted[clamped]!;
|
|
259
|
-
applyStoryCamera(container, scrubCurrent, vb);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
scrubTarget = scrubCamera(
|
|
264
|
-
frame.step,
|
|
265
|
-
frame.stepProgress,
|
|
266
|
-
fitted,
|
|
267
|
-
0.65,
|
|
268
|
-
easingFns.easeInOutCubic,
|
|
269
|
-
);
|
|
270
|
-
if (scrubCurrent === null) scrubCurrent = scrubTarget;
|
|
271
|
-
startSettle();
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function goTo(index: number): void {
|
|
275
|
-
if (destroyed) return;
|
|
276
|
-
const clamped = Math.max(0, Math.min(index, steps.length - 1));
|
|
277
|
-
if (clamped === currentStep) return;
|
|
278
|
-
|
|
279
|
-
const isFirst = currentStep === -1;
|
|
280
|
-
currentStep = clamped;
|
|
281
|
-
|
|
282
|
-
const nextSpec = resolveSpecAtStep(baseSpec, steps, clamped);
|
|
283
|
-
|
|
284
|
-
if (isMap) {
|
|
285
|
-
// Maps always apply directly: update handles fill + camera via geo.focus
|
|
286
|
-
(instance as MapInstance).update(nextSpec as MapSpec);
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const prevSpec = isFirst ? null : resolveSpecAtStep(baseSpec, steps, clamped - 1);
|
|
291
|
-
|
|
292
|
-
const willLikelyMorph =
|
|
293
|
-
!isFirst &&
|
|
294
|
-
!editModeRequested &&
|
|
295
|
-
prevSpec !== null &&
|
|
296
|
-
canTransitionSpecShape(prevSpec, nextSpec);
|
|
297
|
-
|
|
298
|
-
const applyUpdate = () =>
|
|
299
|
-
(instance as ChartInstance).update(nextSpec as Exclude<StorySpec, MapSpec>);
|
|
300
|
-
|
|
301
|
-
if (isFirst || willLikelyMorph || editModeRequested) {
|
|
302
|
-
applyUpdate();
|
|
303
|
-
} else {
|
|
304
|
-
// Outside the morph gate (re-encode, type change, etc.): no step may
|
|
305
|
-
// visibly snap, so crossfade the whole chart instead.
|
|
306
|
-
crossfadeUpdate(container, applyUpdate, { reducedMotion: prefersReducedMotion() });
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Step mode drives the camera on each discrete step change. Scrub mode
|
|
310
|
-
// drives it continuously from the scroll frame instead (see the scroll
|
|
311
|
-
// subscription below), so it skips the discrete step tween here.
|
|
312
|
-
if (cameraMode === 'step') applyCameraForStep(steps[clamped], isFirst);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const scrollDriver = editModeRequested ? null : createScrollDriver({ triggerPosition });
|
|
316
|
-
let unsubscribeScroll: (() => void) | null = null;
|
|
317
|
-
if (scrollDriver) {
|
|
318
|
-
unsubscribeScroll = scrollDriver.progress.subscribe((frame) => {
|
|
319
|
-
if (frame.step < 0) return;
|
|
320
|
-
// Discrete spec steps always advance at step boundaries; the v1 non-goal
|
|
321
|
-
// is continuous DATA morph scrubbing, not camera scrub. In scrub mode the
|
|
322
|
-
// camera additionally follows the continuous frame.
|
|
323
|
-
goTo(frame.step);
|
|
324
|
-
if (!isMap && cameraMode === 'scrub') applyScrubCamera(frame);
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
return {
|
|
329
|
-
goTo,
|
|
330
|
-
registerStep(index, el) {
|
|
331
|
-
scrollDriver?.registerStep(index, el);
|
|
332
|
-
return (nextEl: HTMLElement | null) => scrollDriver?.registerStep(index, nextEl);
|
|
333
|
-
},
|
|
334
|
-
setContainer(el) {
|
|
335
|
-
scrollDriver?.setContainer(el);
|
|
336
|
-
},
|
|
337
|
-
get currentStep() {
|
|
338
|
-
return currentStep;
|
|
339
|
-
},
|
|
340
|
-
get totalSteps() {
|
|
341
|
-
return steps.length;
|
|
342
|
-
},
|
|
343
|
-
destroy() {
|
|
344
|
-
if (destroyed) return;
|
|
345
|
-
destroyed = true;
|
|
346
|
-
unsubscribeScroll?.();
|
|
347
|
-
scrollDriver?.destroy();
|
|
348
|
-
cameraTween?.cancel();
|
|
349
|
-
stopSettle();
|
|
350
|
-
instance.destroy();
|
|
351
|
-
},
|
|
352
|
-
};
|
|
353
|
-
}
|
package/src/story/crossfade.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Whole-chart crossfade fallback for story steps whose spec diff falls
|
|
3
|
-
* outside the data-update-transitions morph gate (`canTransition` in
|
|
4
|
-
* `../transition.ts`): re-encodes, type changes, and anything else that
|
|
5
|
-
* would otherwise instant-snap. No step in a story may visibly snap, so
|
|
6
|
-
* this ghosts the current rendered SVG over the container, lets
|
|
7
|
-
* `ChartInstance.update()` perform its normal instant swap underneath,
|
|
8
|
-
* then fades the ghost out to reveal the new state.
|
|
9
|
-
*
|
|
10
|
-
* This is deliberately NOT the mark-morphing transition driver: it never
|
|
11
|
-
* interpolates geometry, only opacity. See the animation-clock ownership
|
|
12
|
-
* note in the scrollytelling plan -- the transitions driver owns mark
|
|
13
|
-
* morphing, this owns the pixel-level fallback when that driver declines.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { storyMotion } from './tween';
|
|
17
|
-
|
|
18
|
-
export interface CrossfadeOptions {
|
|
19
|
-
/** Fade duration in ms. Default `storyMotion.crossfade`. */
|
|
20
|
-
duration?: number;
|
|
21
|
-
/** Skip the fade and swap instantly (reduced motion). */
|
|
22
|
-
reducedMotion?: boolean;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Ghost the container's current SVG, run `applyUpdate` (which mutates the
|
|
27
|
-
* container to its next state), then fade the ghost out.
|
|
28
|
-
*/
|
|
29
|
-
export function crossfadeUpdate(
|
|
30
|
-
container: HTMLElement,
|
|
31
|
-
applyUpdate: () => void,
|
|
32
|
-
options: CrossfadeOptions = {},
|
|
33
|
-
): void {
|
|
34
|
-
const duration = options.duration ?? storyMotion.crossfade;
|
|
35
|
-
const svg = container.querySelector('svg');
|
|
36
|
-
|
|
37
|
-
if (options.reducedMotion || !svg || duration <= 0) {
|
|
38
|
-
applyUpdate();
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const ghost = svg.cloneNode(true) as SVGElement;
|
|
43
|
-
ghost.setAttribute('aria-hidden', 'true');
|
|
44
|
-
ghost.style.position = 'absolute';
|
|
45
|
-
ghost.style.inset = '0';
|
|
46
|
-
ghost.style.width = '100%';
|
|
47
|
-
ghost.style.height = '100%';
|
|
48
|
-
ghost.style.pointerEvents = 'none';
|
|
49
|
-
ghost.style.transition = `opacity ${duration}ms ease-out`;
|
|
50
|
-
ghost.style.opacity = '1';
|
|
51
|
-
|
|
52
|
-
const priorPosition = container.style.position;
|
|
53
|
-
container.style.position = priorPosition || 'relative';
|
|
54
|
-
container.appendChild(ghost);
|
|
55
|
-
|
|
56
|
-
applyUpdate();
|
|
57
|
-
|
|
58
|
-
// Force layout so the transition starts from opacity: 1 before dropping.
|
|
59
|
-
void ghost.getBoundingClientRect();
|
|
60
|
-
ghost.style.opacity = '0';
|
|
61
|
-
|
|
62
|
-
const cleanup = () => {
|
|
63
|
-
ghost.remove();
|
|
64
|
-
if (!priorPosition) container.style.position = '';
|
|
65
|
-
};
|
|
66
|
-
ghost.addEventListener('transitionend', cleanup, { once: true });
|
|
67
|
-
// Safety net in case transitionend never fires (e.g. element removed by
|
|
68
|
-
// an intervening render before the transition completes).
|
|
69
|
-
setTimeout(cleanup, duration + 100);
|
|
70
|
-
}
|