@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
package/src/story/index.ts
DELETED
|
@@ -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 };
|
package/src/story/tween.ts
DELETED
|
@@ -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
|
-
}
|
package/src/story/types.ts
DELETED
|
@@ -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
|
-
}
|