@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,39 +0,0 @@
1
- /**
2
- * `@opendata-ai/openchart-vanilla/story`: scrollytelling story API.
3
- *
4
- * A story is a base spec plus an ordered list of steps; each step is a
5
- * deep-partial spec patch. Scrolling to step N applies patches 0..N to the
6
- * base and animates the diff -- marks morph when the data-update-transitions
7
- * gate passes, otherwise the whole chart crossfades so no step ever
8
- * visibly snaps. See plans/design-evolution/11-scrollytelling.md.
9
- */
10
-
11
- export type { Camera, CameraTarget, ViewBoxSize } from './camera-math';
12
- export {
13
- camerasClose,
14
- cameraTransform,
15
- damp,
16
- dampCamera,
17
- FULL_VIEW,
18
- fitTarget,
19
- interpolateCamera,
20
- scrubCamera,
21
- } from './camera-math';
22
- export { createChartStory } from './create-chart-story';
23
- export { crossfadeUpdate } from './crossfade';
24
- export type { ScrollyFrame, ScrollyFrameGeometry } from './progress-math';
25
- export { computeProgress, framesEqual, quantizeFrame } from './progress-math';
26
- export { isDataCameraTarget, resolveCameraTarget } from './resolve-camera-target';
27
- export type { ScrollDriver, ScrollDriverOptions, ScrollyProgressStore } from './scroll-driver';
28
- export { createScrollDriver } from './scroll-driver';
29
- export type { EasingFn, Tween, TweenConfig } from './tween';
30
- export { clamp01, createTween, easingFns, lerp, storyMotion } from './tween';
31
- export type {
32
- ChartStoryInstance,
33
- ChartStoryOptions,
34
- StoryCameraStep,
35
- StoryDataCameraTarget,
36
- StorySpec,
37
- StorySpecPatch,
38
- StoryStep,
39
- } from './types';
@@ -1,85 +0,0 @@
1
- import { clamp01 } from './tween';
2
-
3
- /**
4
- * Pure math for continuous scrollytelling progress. The `ScrollDriver`
5
- * measures DOM rects and feeds them here. Ported from
6
- * opendata/shared/lib/scrolly/progress-math.ts.
7
- */
8
-
9
- export interface ScrollyFrame {
10
- /** Geometric active step; -1 while the trigger line is above step 0 */
11
- step: number;
12
- /** 0..1 progress of the trigger line through the active step's span */
13
- stepProgress: number;
14
- /** 0..1 traversal from first step top to last step bottom */
15
- progress: number;
16
- direction: 'down' | 'up';
17
- }
18
-
19
- export type ScrollyFrameGeometry = Omit<ScrollyFrame, 'direction'>;
20
-
21
- /**
22
- * Compute the geometric frame from viewport-relative step tops.
23
- *
24
- * @param tops viewport-relative step top offsets, ordered by step index
25
- * @param lastBottom viewport-relative bottom of the final step
26
- * @param triggerY the trigger line's viewport y (innerHeight * triggerPosition)
27
- */
28
- export function computeProgress(
29
- tops: number[],
30
- lastBottom: number,
31
- triggerY: number,
32
- ): ScrollyFrameGeometry {
33
- const n = tops.length;
34
- if (n === 0 || triggerY < tops[0]!) {
35
- return { step: -1, stepProgress: 0, progress: 0 };
36
- }
37
-
38
- let step = n - 1;
39
- for (let i = 0; i < n - 1; i++) {
40
- if (triggerY < tops[i + 1]!) {
41
- step = i;
42
- break;
43
- }
44
- }
45
-
46
- const stepTop = tops[step]!;
47
- const spanEnd = step < n - 1 ? tops[step + 1]! : lastBottom;
48
- const span = Math.max(spanEnd - stepTop, 1);
49
- const firstTop = tops[0]!;
50
-
51
- return {
52
- step,
53
- stepProgress: clamp01((triggerY - stepTop) / span),
54
- progress: clamp01((triggerY - firstTop) / Math.max(lastBottom - firstTop, 1)),
55
- };
56
- }
57
-
58
- /**
59
- * Reduced-motion variant: no scrubbing. `stepProgress` pins to 0 and overall
60
- * progress quantizes to the step's start fraction, so consumers see discrete
61
- * snaps only.
62
- */
63
- export function quantizeFrame(
64
- frame: ScrollyFrameGeometry,
65
- totalSteps: number,
66
- ): ScrollyFrameGeometry {
67
- if (frame.step < 0 || totalSteps <= 0) {
68
- return { step: frame.step, stepProgress: 0, progress: 0 };
69
- }
70
- return {
71
- step: frame.step,
72
- stepProgress: 0,
73
- progress: clamp01(frame.step / totalSteps),
74
- };
75
- }
76
-
77
- export function framesEqual(a: ScrollyFrame | null, b: ScrollyFrame): boolean {
78
- return (
79
- a !== null &&
80
- a.step === b.step &&
81
- a.stepProgress === b.stepProgress &&
82
- a.progress === b.progress &&
83
- a.direction === b.direction
84
- );
85
- }
@@ -1,119 +0,0 @@
1
- /**
2
- * Resolves data-coordinate camera targets (`{ x: [a, b], y: [c, d] }`) to
3
- * viewBox-space `CameraTarget` rects using the compiled chart's axis ticks.
4
- *
5
- * There is no raw d3 scale on `ChartLayout` (by design -- the compiled
6
- * layout is the public surface, not engine internals), so resolution works
7
- * off `AxisTick[]`, which pairs each tick's raw data `value` with its pixel
8
- * `position`:
9
- * - Exact tick match (ordinal/categorical values, or a quantitative value
10
- * that happens to land on a tick): use that tick's position directly.
11
- * - Otherwise linear interpolation between the two nearest ticks by data
12
- * value. Works for quantitative and temporal axes (temporal values are
13
- * compared as timestamps). Ordinal axes with no exact match have no
14
- * well-ordered "between" and fall back to the nearest tick.
15
- */
16
-
17
- import type { AxisLayout, AxisTick, ChartLayout } from '@opendata-ai/openchart-core';
18
- import type { CameraTarget } from './camera-math';
19
- import type { StoryDataCameraTarget } from './types';
20
-
21
- const DEFAULT_PADDING = 24;
22
-
23
- /** Coerce a tick value (string, number, Date, ISO string) to a comparable number. */
24
- function toComparable(value: unknown): number | null {
25
- if (typeof value === 'number') return value;
26
- if (value instanceof Date) return value.getTime();
27
- if (typeof value === 'string') {
28
- const asDate = Date.parse(value);
29
- if (!Number.isNaN(asDate)) return asDate;
30
- }
31
- return null;
32
- }
33
-
34
- /** Resolve one data value to a pixel position along an axis, or null if unresolvable. */
35
- function resolveOnAxis(axis: AxisLayout, target: unknown): number | null {
36
- const ticks = axis.ticks;
37
- if (ticks.length === 0) return null;
38
-
39
- const exact = ticks.find((t) => t.value === target);
40
- if (exact) return exact.position;
41
-
42
- const targetNum = toComparable(target);
43
- if (targetNum === null) return null;
44
-
45
- // Ticks with comparable numeric values, sorted by data value.
46
- const numeric: Array<{ tick: AxisTick; value: number }> = [];
47
- for (const tick of ticks) {
48
- const v = toComparable(tick.value);
49
- if (v !== null) numeric.push({ tick, value: v });
50
- }
51
- if (numeric.length === 0) return null;
52
- numeric.sort((a, b) => a.value - b.value);
53
-
54
- if (targetNum <= numeric[0]!.value) return numeric[0]!.tick.position;
55
- const last = numeric.at(-1)!;
56
- if (targetNum >= last.value) return last.tick.position;
57
-
58
- for (let i = 0; i < numeric.length - 1; i++) {
59
- const lo = numeric[i]!;
60
- const hi = numeric[i + 1]!;
61
- if (targetNum >= lo.value && targetNum <= hi.value) {
62
- const span = hi.value - lo.value;
63
- const t = span === 0 ? 0 : (targetNum - lo.value) / span;
64
- return lo.tick.position + t * (hi.tick.position - lo.tick.position);
65
- }
66
- }
67
- return null;
68
- }
69
-
70
- /**
71
- * Resolve a data-coordinate camera target to viewBox space. Falls back to
72
- * the full chart area (padded) for axes that are missing or unresolvable,
73
- * so a malformed target degrades to "camera does nothing" rather than
74
- * throwing mid-scroll.
75
- */
76
- export function resolveCameraTarget(
77
- layout: ChartLayout,
78
- target: StoryDataCameraTarget,
79
- ): CameraTarget {
80
- const area = layout.area;
81
- const padding = target.padding ?? DEFAULT_PADDING;
82
-
83
- let x1 = area.x;
84
- let x2 = area.x + area.width;
85
- if (target.x && layout.axes.x) {
86
- const a = resolveOnAxis(layout.axes.x, target.x[0]);
87
- const b = resolveOnAxis(layout.axes.x, target.x[1]);
88
- if (a !== null && b !== null) {
89
- x1 = Math.min(a, b);
90
- x2 = Math.max(a, b);
91
- }
92
- }
93
-
94
- let y1 = area.y;
95
- let y2 = area.y + area.height;
96
- if (target.y && layout.axes.y) {
97
- const a = resolveOnAxis(layout.axes.y, target.y[0]);
98
- const b = resolveOnAxis(layout.axes.y, target.y[1]);
99
- if (a !== null && b !== null) {
100
- y1 = Math.min(a, b);
101
- y2 = Math.max(a, b);
102
- }
103
- }
104
-
105
- return {
106
- x: x1,
107
- y: y1,
108
- width: Math.max(x2 - x1, 1),
109
- height: Math.max(y2 - y1, 1),
110
- padding,
111
- };
112
- }
113
-
114
- /** Type guard: a camera step given in data coordinates vs. raw viewBox `CameraTarget`. */
115
- export function isDataCameraTarget(
116
- value: StoryDataCameraTarget | CameraTarget,
117
- ): value is StoryDataCameraTarget {
118
- return !('width' in value) && !('height' in value);
119
- }
@@ -1,206 +0,0 @@
1
- /**
2
- * Framework-agnostic scroll-step driver: element registry + rAF-throttled
3
- * scroll handler + subscription store. Ported from the React
4
- * `use-scroll-steps` hook in opendata/shared; the store design (60fps
5
- * frames as subscriptions, never framework state) carries over unchanged
6
- * because vanilla, React, Vue, and Svelte all want the same contract.
7
- *
8
- * Measurement is lazy: with zero subscribers the scroll handler does no
9
- * rect reads.
10
- */
11
-
12
- import { computeProgress, framesEqual, quantizeFrame, type ScrollyFrame } from './progress-math';
13
-
14
- export interface ScrollyProgressStore {
15
- /** Latest frame; `{step: -1, ...}` sentinel before any measurement */
16
- get(): ScrollyFrame;
17
- /** Emits the current frame immediately on subscribe, then on every change */
18
- subscribe(cb: (frame: ScrollyFrame) => void): () => void;
19
- }
20
-
21
- export interface ScrollDriverOptions {
22
- /** Fraction of viewport height where the trigger line sits. Default 0.4 */
23
- triggerPosition?: number;
24
- }
25
-
26
- export interface ScrollDriver {
27
- /** Register the scrolling container that wraps all steps. */
28
- setContainer(el: HTMLElement | null): void;
29
- /** Register a step element by index. Pass `null` to unregister. */
30
- registerStep(index: number, el: HTMLElement | null): void;
31
- /** Continuous progress as a subscription store. */
32
- progress: ScrollyProgressStore;
33
- /** Scroll a step into view (center). Honors reduced motion. */
34
- scrollToStep(index: number): void;
35
- /** Force a re-measurement (e.g. after layout changes). */
36
- measure(): void;
37
- /** Tear down scroll/resize listeners and the reduced-motion media query. */
38
- destroy(): void;
39
- }
40
-
41
- const SENTINEL_FRAME: ScrollyFrame = {
42
- step: -1,
43
- stepProgress: 0,
44
- progress: 0,
45
- direction: 'down',
46
- };
47
-
48
- /**
49
- * Create a scroll-step driver. Framework wrappers (React/Vue/Svelte hooks)
50
- * are thin adapters over this: they own component lifecycle, this owns the
51
- * measurement/subscription machinery.
52
- */
53
- export function createScrollDriver(options: ScrollDriverOptions = {}): ScrollDriver {
54
- const triggerPosition = options.triggerPosition ?? 0.4;
55
-
56
- let container: HTMLElement | null = null;
57
- const steps = new Map<number, HTMLElement>();
58
- const subscribers = new Set<(frame: ScrollyFrame) => void>();
59
-
60
- let frame: ScrollyFrame = SENTINEL_FRAME;
61
- let direction: 'down' | 'up' = 'down';
62
- // Direction is tracked from the container's viewport-relative position, NOT
63
- // window.scrollY: in a host that scrolls an inner container rather than the
64
- // page (Ladle, modals, dashboard panes, most app shells) window.scrollY is
65
- // pinned at 0 and would report no movement. The container's rect top moves
66
- // whenever ANY ancestor scrolls, so it works in both cases.
67
- let lastTop: number | null = null;
68
- let reducedMotion = false;
69
- let ticking = false;
70
-
71
- const measureAndEmit = () => {
72
- if (subscribers.size === 0) return;
73
- if (!container || steps.size === 0) return;
74
- if (typeof window === 'undefined') return;
75
-
76
- const viewportH = window.innerHeight;
77
- const containerRect = container.getBoundingClientRect();
78
- // Skip when the story is more than a viewport away in either direction.
79
- if (containerRect.bottom < -viewportH || containerRect.top > viewportH * 2) return;
80
-
81
- const count = steps.size;
82
- const tops: number[] = new Array(count);
83
- let lastBottom = 0;
84
- for (let i = 0; i < count; i++) {
85
- const el = steps.get(i);
86
- if (!el) return; // sparse registration mid-mount; wait for the next tick
87
- const rect = el.getBoundingClientRect();
88
- tops[i] = rect.top;
89
- if (i === count - 1) lastBottom = rect.bottom;
90
- }
91
-
92
- const triggerY = viewportH * triggerPosition;
93
- let geometry = computeProgress(tops, lastBottom, triggerY);
94
- if (reducedMotion) {
95
- geometry = quantizeFrame(geometry, count);
96
- }
97
-
98
- const next: ScrollyFrame = { ...geometry, direction };
99
- if (framesEqual(frame, next)) return;
100
- frame = next;
101
- for (const cb of subscribers) cb(frame);
102
- };
103
-
104
- const onScroll = () => {
105
- if (ticking) return;
106
- ticking = true;
107
- requestAnimationFrame(() => {
108
- // The container rising up the viewport (top decreasing) means the reader
109
- // is moving down through the story.
110
- const currentTop = container?.getBoundingClientRect().top ?? null;
111
- if (currentTop !== null && lastTop !== null) {
112
- const delta = lastTop - currentTop;
113
- if (delta > 5 || delta < -5) {
114
- direction = delta > 5 ? 'down' : 'up';
115
- }
116
- }
117
- lastTop = currentTop;
118
- ticking = false;
119
- measureAndEmit();
120
- });
121
- };
122
-
123
- let mediaQuery: MediaQueryList | null = null;
124
- const onReducedMotionChange = (e: MediaQueryListEvent) => {
125
- reducedMotion = e.matches;
126
- measureAndEmit();
127
- };
128
-
129
- if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
130
- mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
131
- reducedMotion = mediaQuery.matches;
132
- mediaQuery.addEventListener('change', onReducedMotionChange);
133
- }
134
-
135
- if (typeof window !== 'undefined') {
136
- // Capture phase on the document, not just `window` scroll. Scroll events do
137
- // not bubble, so a window listener ONLY fires when the page itself scrolls.
138
- // Any host that scrolls an inner container instead — Ladle (which sets
139
- // `body { overflow: hidden }`), modals, dashboard panes, most app shells —
140
- // would never notify the driver and the story sat frozen on step 0.
141
- // Capturing on the document sees scroll from any ancestor, and the
142
- // measurement below is already viewport-relative (getBoundingClientRect vs
143
- // innerHeight), so it needs no other change to work in both layouts.
144
- document.addEventListener('scroll', onScroll, { passive: true, capture: true });
145
- window.addEventListener('resize', onScroll, { passive: true });
146
- }
147
-
148
- const progress: ScrollyProgressStore = {
149
- get: () => frame,
150
- subscribe: (cb) => {
151
- // Refresh `frame` from current geometry BEFORE registering `cb`, so the
152
- // measure pass notifies only existing subscribers. Then hand the new
153
- // subscriber the current frame exactly once. Registering after the
154
- // measure avoids the double-invoke that happened when measureAndEmit
155
- // changed the frame (looping over cb) and the explicit cb(frame) fired again.
156
- measureAndEmit();
157
- subscribers.add(cb);
158
- cb(frame);
159
- return () => {
160
- subscribers.delete(cb);
161
- };
162
- },
163
- };
164
-
165
- return {
166
- setContainer(el) {
167
- container = el;
168
- // Seed the direction baseline so the first scroll compares against a real
169
- // position rather than reporting a spurious jump from null.
170
- lastTop = el?.getBoundingClientRect().top ?? null;
171
- measureAndEmit();
172
- },
173
- registerStep(index, el) {
174
- if (el) {
175
- steps.set(index, el);
176
- } else {
177
- steps.delete(index);
178
- }
179
- measureAndEmit();
180
- },
181
- progress,
182
- scrollToStep(index) {
183
- const el = steps.get(index);
184
- if (!el) return;
185
- el.scrollIntoView({
186
- block: 'center',
187
- behavior: reducedMotion ? 'auto' : 'smooth',
188
- });
189
- },
190
- measure() {
191
- measureAndEmit();
192
- },
193
- destroy() {
194
- if (typeof window !== 'undefined') {
195
- // `capture: true` must match the addEventListener call or the listener
196
- // is not removed.
197
- document.removeEventListener('scroll', onScroll, { capture: true });
198
- window.removeEventListener('resize', onScroll);
199
- }
200
- mediaQuery?.removeEventListener('change', onReducedMotionChange);
201
- subscribers.clear();
202
- steps.clear();
203
- container = null;
204
- },
205
- };
206
- }
@@ -1,49 +0,0 @@
1
- /**
2
- * Applies the scrollytelling camera transform to a mounted chart's marks
3
- * group (`[data-oc-marks-group]`, stamped by svg-renderer.ts). Re-finds the
4
- * group on every apply since `ChartInstance.update()` tears down and
5
- * rebuilds the SVG on every render -- the previous group reference goes
6
- * stale the instant a step patch triggers a re-render.
7
- *
8
- * DO NOT USE THIS TO "ZOOM IN" ON A CARTESIAN CHART.
9
- *
10
- * This is a geometric magnification of the marks group and nothing else. The
11
- * axes, gridlines, and annotations are siblings of that group, so they do not
12
- * move: zoom a line chart to 2010-2020 and the lines blow up while the x-axis
13
- * still reads 2000-2020 and every annotation drifts off its data point. Stroke
14
- * widths scale with the transform too, so the lines visibly fatten. The chart
15
- * ends up misrepresenting its own axis.
16
- *
17
- * To zoom a cartesian chart, narrow the scale domain instead and let the engine
18
- * recompile:
19
- *
20
- * { spec: { encoding: { x: { scale: { domain: ['2019', ...], clip: true } } } } }
21
- *
22
- * That relabels the axis, re-anchors annotations, keeps stroke widths constant,
23
- * and still morphs (the field identity is unchanged, so `canTransitionSpecShape`
24
- * passes and the marks FLIP-tween into the new scale).
25
- *
26
- * The camera remains useful where there is no axis to contradict -- graph and
27
- * other non-cartesian views, where magnifying the geometry IS the intent.
28
- */
29
-
30
- import { type Camera, cameraTransform, FULL_VIEW, type ViewBoxSize } from './camera-math';
31
-
32
- /** Read the current SVG's viewBox as a `ViewBoxSize`, or null if not yet rendered. */
33
- export function readViewBox(container: HTMLElement): ViewBoxSize | null {
34
- const svg = container.querySelector('svg');
35
- const viewBoxAttr = svg?.getAttribute('viewBox');
36
- if (!viewBoxAttr) return null;
37
- const parts = viewBoxAttr.split(/\s+/).map(Number);
38
- if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return null;
39
- return { width: parts[2]!, height: parts[3]! };
40
- }
41
-
42
- /** Apply a camera transform to the current marks group, if the chart is rendered. */
43
- export function applyStoryCamera(container: HTMLElement, camera: Camera, vb: ViewBoxSize): void {
44
- const marksGroup = container.querySelector('[data-oc-marks-group]');
45
- if (!marksGroup) return;
46
- marksGroup.setAttribute('transform', cameraTransform(camera, vb));
47
- }
48
-
49
- export { FULL_VIEW };
@@ -1,112 +0,0 @@
1
- /**
2
- * Numeric rAF tweening for scrollytelling story steps.
3
- *
4
- * Owns imperative numeric interpolation: the story driver and camera math
5
- * mutate scalar/vector values per frame using these primitives instead of
6
- * hand-rolling rAF loops. Ported from opendata/shared/lib/tween.ts.
7
- *
8
- * Everything here is SSR-safe: no `window`/`performance` access at module
9
- * scope. Callers are expected to invoke these client-side.
10
- */
11
-
12
- export const clamp01 = (t: number): number => Math.min(1, Math.max(0, t));
13
-
14
- export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
15
-
16
- export type EasingFn = (t: number) => number;
17
-
18
- export const easingFns = {
19
- linear: (t: number) => t,
20
- easeOutCubic: (t: number) => 1 - (1 - t) ** 3,
21
- easeInOutCubic: (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2),
22
- easeOutQuint: (t: number) => 1 - (1 - t) ** 5,
23
- } as const satisfies Record<string, EasingFn>;
24
-
25
- /**
26
- * Duration tokens (ms) for story state changes. Use these instead of
27
- * arbitrary numbers so all stories share one timing scale.
28
- */
29
- export const storyMotion = {
30
- /** Small opacity/emphasis shifts */
31
- fast: 300,
32
- /** Step state transitions */
33
- base: 500,
34
- /** Whole-chart crossfade fallback (steps outside the morph gate) */
35
- crossfade: 400,
36
- /** Step-mode camera transitions */
37
- camera: 600,
38
- /** Scrub-mode camera damping time constant */
39
- cameraTau: 100,
40
- } as const;
41
-
42
- export interface TweenConfig<T> {
43
- initial: T;
44
- lerp: (a: T, b: T, t: number) => T;
45
- /** Default `storyMotion.base` */
46
- duration?: number;
47
- /** Default `easingFns.easeOutCubic` */
48
- ease?: EasingFn;
49
- onFrame: (value: T) => void;
50
- }
51
-
52
- export interface Tween<T> {
53
- /**
54
- * Retarget the tween. If a tween is mid-flight, the new tween starts FROM
55
- * THE LIVE INTERPOLATED VALUE, not the previous target -- fast retargeting
56
- * never jumps. `snap: true` applies the target synchronously with no rAF.
57
- */
58
- to: (target: T, opts?: { snap?: boolean }) => void;
59
- get: () => T;
60
- cancel: () => void;
61
- }
62
-
63
- /**
64
- * Retargetable tween over an arbitrary shape T.
65
- */
66
- export function createTween<T>(config: TweenConfig<T>): Tween<T> {
67
- const {
68
- lerp: lerpT,
69
- duration = storyMotion.base,
70
- ease = easingFns.easeOutCubic,
71
- onFrame,
72
- } = config;
73
-
74
- let current = config.initial;
75
- let rafId = 0;
76
- let running = false;
77
-
78
- const stop = () => {
79
- if (running) {
80
- cancelAnimationFrame(rafId);
81
- running = false;
82
- }
83
- };
84
-
85
- const to = (target: T, opts?: { snap?: boolean }) => {
86
- stop();
87
- if (opts?.snap || duration <= 0) {
88
- current = target;
89
- onFrame(current);
90
- return;
91
- }
92
-
93
- const from = current;
94
- const start = performance.now();
95
- running = true;
96
-
97
- const tick = (now: number) => {
98
- const t = clamp01((now - start) / duration);
99
- current = lerpT(from, target, ease(t));
100
- onFrame(current);
101
- if (t < 1) {
102
- rafId = requestAnimationFrame(tick);
103
- } else {
104
- running = false;
105
- }
106
- };
107
-
108
- rafId = requestAnimationFrame(tick);
109
- };
110
-
111
- return { to, get: () => current, cancel: stop };
112
- }
@@ -1,76 +0,0 @@
1
- import type {
2
- ChartSpec,
3
- DataRow,
4
- GraphSpec,
5
- LayerSpec,
6
- MapSpec,
7
- } from '@opendata-ai/openchart-core';
8
- import type { CameraTarget } from './camera-math';
9
-
10
- /** A spec a story can drive: same union `ChartInstance.update()` accepts. */
11
- export type StorySpec<TData extends DataRow = DataRow> =
12
- | ChartSpec<TData>
13
- | LayerSpec<TData>
14
- | GraphSpec
15
- | MapSpec;
16
-
17
- /** Deep-partial patch applied onto the accumulated spec at each step. */
18
- export type StorySpecPatch = Record<string, unknown>;
19
-
20
- /**
21
- * One step in a story. Patches are cumulative: step N's effective spec is
22
- * the base spec with patches 0..N applied in order.
23
- */
24
- export interface StoryStep {
25
- /** Deep-partial patch merged onto the running spec. Omit for a no-op step. */
26
- spec?: StorySpecPatch;
27
- /**
28
- * Emphasis sugar: sets `encoding.color.highlight`. Equivalent to
29
- * `spec: { encoding: { color: { highlight: [...] } } }` but doesn't
30
- * require the caller to know the color field is nested under `encoding`.
31
- * Pass `null` to clear a highlight set by an earlier step.
32
- */
33
- highlight?: string[] | null;
34
- /**
35
- * Pan/zoom the camera to a data-coordinate region (resolved via the
36
- * chart's compiled scales) or a raw viewBox-space `CameraTarget`. Pass
37
- * `null`/omit to return to the full viewBox.
38
- */
39
- camera?: StoryCameraStep | null;
40
- }
41
-
42
- /** Data-coordinate camera target: resolved to viewBox space via the chart's scales. */
43
- export interface StoryDataCameraTarget {
44
- /** x-domain range to frame, in data units (or category names for ordinal scales). */
45
- x?: [unknown, unknown];
46
- /** y-domain range to frame, in data units (or category names for ordinal scales). */
47
- y?: [unknown, unknown];
48
- /** ViewBox units of padding added around the resolved rect. Default 24. */
49
- padding?: number;
50
- }
51
-
52
- export type StoryCameraStep = StoryDataCameraTarget | CameraTarget;
53
-
54
- export interface ChartStoryOptions<TData extends DataRow = DataRow> {
55
- spec: StorySpec<TData>;
56
- steps: StoryStep[];
57
- /** Fraction of viewport height where the scroll trigger line sits. Default 0.4. */
58
- triggerPosition?: number;
59
- /** Camera driving mode. Step mode (default) eases on step change; scrub follows scroll continuously. */
60
- cameraMode?: 'step' | 'scrub';
61
- }
62
-
63
- export interface ChartStoryInstance {
64
- /** Jump directly to a step index, applying patches 0..n and animating the diff. */
65
- goTo(index: number): void;
66
- /** Register a step element so the built-in scroll driver advances the story on scroll. */
67
- registerStep(index: number, el: HTMLElement | null): (el: HTMLElement | null) => void;
68
- /** Register the scrolling container that wraps all step elements. */
69
- setContainer(el: HTMLElement | null): void;
70
- /** Current step index. -1 before any step has activated. */
71
- readonly currentStep: number;
72
- /** Total number of steps. */
73
- readonly totalSteps: number;
74
- /** Tear down the scroll driver, camera, and underlying chart instance. */
75
- destroy(): void;
76
- }