@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.
Files changed (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /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
- });
@@ -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
- }
@@ -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
- }