@voila.dev/chart 5.0.113 → 5.0.117

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 (103) hide show
  1. package/dist/canvas/paint-scene.d.ts.map +1 -1
  2. package/dist/canvas/paint-scene.js +3 -1
  3. package/dist/canvas/paint-scene.js.map +2 -2
  4. package/dist/contour/density.d.ts.map +1 -1
  5. package/dist/contour/density.js +2 -0
  6. package/dist/contour/density.js.map +2 -2
  7. package/dist/core/marks/area.d.ts.map +1 -1
  8. package/dist/core/marks/area.js +27 -11
  9. package/dist/core/marks/area.js.map +2 -2
  10. package/dist/core/marks/bar.d.ts.map +1 -1
  11. package/dist/core/marks/bar.js +2 -0
  12. package/dist/core/marks/bar.js.map +2 -2
  13. package/dist/core/marks/line.d.ts.map +1 -1
  14. package/dist/core/marks/line.js +13 -4
  15. package/dist/core/marks/line.js.map +2 -2
  16. package/dist/core/marks/polar.d.ts.map +1 -1
  17. package/dist/core/marks/polar.js +22 -5
  18. package/dist/core/marks/polar.js.map +2 -2
  19. package/dist/core/marks/shared.d.ts +8 -1
  20. package/dist/core/marks/shared.d.ts.map +1 -1
  21. package/dist/core/marks/shared.js.map +2 -2
  22. package/dist/core/marks/stats/difference.d.ts.map +1 -1
  23. package/dist/core/marks/stats/difference.js +20 -7
  24. package/dist/core/marks/stats/difference.js.map +2 -2
  25. package/dist/core/motion/arc-motion.d.ts +9 -0
  26. package/dist/core/motion/arc-motion.d.ts.map +1 -0
  27. package/dist/core/motion/arc-motion.js +67 -0
  28. package/dist/core/motion/arc-motion.js.map +7 -0
  29. package/dist/core/motion/enter-exit.d.ts +6 -0
  30. package/dist/core/motion/enter-exit.d.ts.map +1 -0
  31. package/dist/core/motion/enter-exit.js +20 -0
  32. package/dist/core/motion/enter-exit.js.map +7 -0
  33. package/dist/core/motion/leaving.d.ts +12 -0
  34. package/dist/core/motion/leaving.d.ts.map +1 -0
  35. package/dist/core/motion/leaving.js +33 -0
  36. package/dist/core/motion/leaving.js.map +7 -0
  37. package/dist/core/motion/motion-store.d.ts +22 -0
  38. package/dist/core/motion/motion-store.d.ts.map +1 -0
  39. package/dist/core/motion/motion-store.js +325 -0
  40. package/dist/core/motion/motion-store.js.map +7 -0
  41. package/dist/core/motion/points-motion.d.ts +6 -0
  42. package/dist/core/motion/points-motion.d.ts.map +1 -0
  43. package/dist/core/motion/points-motion.js +140 -0
  44. package/dist/core/motion/points-motion.js.map +7 -0
  45. package/dist/core/motion/strings.d.ts +18 -0
  46. package/dist/core/motion/strings.d.ts.map +1 -0
  47. package/dist/core/motion/strings.js +34 -0
  48. package/dist/core/motion/strings.js.map +7 -0
  49. package/dist/core/motion/timing.d.ts +30 -0
  50. package/dist/core/motion/timing.d.ts.map +1 -0
  51. package/dist/core/motion/timing.js +25 -0
  52. package/dist/core/motion/timing.js.map +7 -0
  53. package/dist/core/motion/tween.d.ts +2 -0
  54. package/dist/core/motion/tween.d.ts.map +1 -1
  55. package/dist/core/motion/tween.js +1 -0
  56. package/dist/core/motion/tween.js.map +2 -2
  57. package/dist/core/types.d.ts +85 -0
  58. package/dist/core/types.d.ts.map +1 -1
  59. package/dist/geo/geo.d.ts.map +1 -1
  60. package/dist/geo/geo.js +2 -0
  61. package/dist/geo/geo.js.map +2 -2
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +8 -0
  65. package/dist/index.js.map +2 -2
  66. package/dist/react/chart.d.ts +7 -2
  67. package/dist/react/chart.d.ts.map +1 -1
  68. package/dist/react/chart.js +12 -7
  69. package/dist/react/chart.js.map +2 -2
  70. package/dist/react/svg-scene.d.ts.map +1 -1
  71. package/dist/react/svg-scene.js +3 -1
  72. package/dist/react/svg-scene.js.map +2 -2
  73. package/dist/react/use-animated-scene.d.ts +6 -5
  74. package/dist/react/use-animated-scene.d.ts.map +1 -1
  75. package/dist/react/use-animated-scene.js +36 -24
  76. package/dist/react/use-animated-scene.js.map +2 -2
  77. package/dist/voronoi/voronoi.d.ts.map +1 -1
  78. package/dist/voronoi/voronoi.js +2 -0
  79. package/dist/voronoi/voronoi.js.map +2 -2
  80. package/package.json +2 -1
  81. package/src/canvas/paint-scene.ts +5 -1
  82. package/src/contour/density.ts +2 -0
  83. package/src/core/marks/area.ts +28 -13
  84. package/src/core/marks/bar.ts +2 -0
  85. package/src/core/marks/line.ts +15 -8
  86. package/src/core/marks/polar.ts +22 -5
  87. package/src/core/marks/shared.ts +8 -0
  88. package/src/core/marks/stats/difference.ts +28 -8
  89. package/src/core/motion/arc-motion.ts +113 -0
  90. package/src/core/motion/enter-exit.ts +44 -0
  91. package/src/core/motion/leaving.ts +55 -0
  92. package/src/core/motion/motion-store.ts +449 -0
  93. package/src/core/motion/points-motion.ts +190 -0
  94. package/src/core/motion/strings.ts +68 -0
  95. package/src/core/motion/timing.ts +63 -0
  96. package/src/core/motion/tween.ts +1 -1
  97. package/src/core/types.ts +98 -0
  98. package/src/geo/geo.ts +2 -0
  99. package/src/index.ts +9 -0
  100. package/src/react/chart.tsx +21 -9
  101. package/src/react/svg-scene.tsx +5 -1
  102. package/src/react/use-animated-scene.ts +49 -33
  103. package/src/voronoi/voronoi.ts +2 -0
@@ -0,0 +1,63 @@
1
+ import type { EasingName } from "@voila.dev/motion";
2
+
3
+ /** How a data update moves: what `<Chart animate>` accepts beyond `true`, `false` and a duration. */
4
+ export type ChartAnimation =
5
+ | {
6
+ readonly type?: "spring";
7
+ /** Perceived duration, in milliseconds. */
8
+ readonly duration?: number;
9
+ /** 0 never overshoots (the default: a bar past its value shows a number the data doesn't have); up to 0.5. */
10
+ readonly bounce?: number;
11
+ /** Milliseconds between marks in focus order, capped at 300 ms in all. */
12
+ readonly stagger?: number;
13
+ }
14
+ | {
15
+ readonly type: "tween";
16
+ readonly duration?: number;
17
+ readonly easing?: EasingName;
18
+ readonly stagger?: number;
19
+ };
20
+
21
+ export interface ChartTiming {
22
+ readonly type: "spring" | "tween";
23
+ /** Seconds, as the motion package counts them. */
24
+ readonly duration: number;
25
+ readonly bounce: number;
26
+ readonly easing: EasingName;
27
+ /** Seconds between neighbours. */
28
+ readonly stagger: number;
29
+ }
30
+
31
+ const DEFAULT_DURATION = 300;
32
+ const MAX_STAGGER_TOTAL = 0.3;
33
+
34
+ /** The timing an `animate` prop means, or `null` to snap. */
35
+ export function chartTiming(
36
+ animate: boolean | number | ChartAnimation,
37
+ ): ChartTiming | null {
38
+ if (animate === false || animate === 0) return null;
39
+ const options: ChartAnimation =
40
+ typeof animate === "object"
41
+ ? animate
42
+ : { duration: animate === true ? DEFAULT_DURATION : animate };
43
+ return {
44
+ type: options.type ?? "spring",
45
+ duration: (options.duration ?? DEFAULT_DURATION) / 1000,
46
+ bounce: Math.min(
47
+ Math.max("bounce" in options ? (options.bounce ?? 0) : 0, 0),
48
+ 0.5,
49
+ ),
50
+ easing: ("easing" in options ? options.easing : undefined) ?? "easeOut",
51
+ stagger: (options.stagger ?? 0) / 1000,
52
+ };
53
+ }
54
+
55
+ /** The delay of the `index`-th of `count` marks: the gap shrinks so the last one never waits more than 300 ms. */
56
+ export function staggerDelay(
57
+ timing: ChartTiming,
58
+ index: number,
59
+ count: number,
60
+ ): number {
61
+ if (timing.stagger === 0 || count < 2) return 0;
62
+ return index * Math.min(timing.stagger, MAX_STAGGER_TOTAL / (count - 1));
63
+ }
@@ -19,7 +19,7 @@ function mix(from: number, to: number, t: number): number {
19
19
  }
20
20
 
21
21
  /** Two paths can tween when only their numbers differ. */
22
- function tweenPath(from: string, to: string, t: number): string {
22
+ export function tweenPath(from: string, to: string, t: number): string {
23
23
  const fromNumbers = from.match(NUMBER);
24
24
  const toNumbers = to.match(NUMBER);
25
25
  if (
package/src/core/types.ts CHANGED
@@ -92,6 +92,12 @@ export interface ChartPaint {
92
92
  readonly strokeOpacity?: number;
93
93
  /** Fill with diagonal hatching in the fill colour: a value pencilled in, not yet true. */
94
94
  readonly hatch?: boolean;
95
+ /**
96
+ * Only the first `fraction` of the stroke is drawn: a line tracing itself
97
+ * in. `length` is the path's length in pixels, which Canvas needs for its
98
+ * dash and SVG doesn't (it measures with `pathLength`).
99
+ */
100
+ readonly drawn?: { readonly fraction: number; readonly length: number };
95
101
  }
96
102
 
97
103
  export interface ChartTextPaint extends ChartPaint {
@@ -108,8 +114,17 @@ interface SceneNodeBase {
108
114
  readonly series?: string;
109
115
  /** A role for styling and tests: "mark", "axis", "grid", "label"… */
110
116
  readonly role?: string;
117
+ /** How the node appears when an update adds it: see `ChartEnter`. Fades by default. */
118
+ readonly enter?: ChartEnter;
111
119
  }
112
120
 
121
+ /**
122
+ * How a data mark appears. "grow" rises from its baseline (bars, areas) or
123
+ * opens from its neighbour (slices); "draw" traces a line in on the first
124
+ * render; "fade" fades in; "none" is there at once.
125
+ */
126
+ export type ChartEnter = "grow" | "draw" | "fade" | "none";
127
+
113
128
  export interface SceneGroup extends SceneNodeBase {
114
129
  readonly kind: "group";
115
130
  /** Shifts the children: a facet's cell drawn at its place in the grid. Clips are in the shifted space. */
@@ -125,6 +140,8 @@ export interface SceneRect extends SceneNodeBase {
125
140
  readonly width: number;
126
141
  readonly height: number;
127
142
  readonly corners?: ChartCorners;
143
+ /** The edge a bar grows from and shrinks back to: `y` for a column, `x` for a bar, in pixels. */
144
+ readonly baseline?: { readonly axis: "x" | "y"; readonly at: number };
128
145
  readonly paint: ChartPaint;
129
146
  }
130
147
 
@@ -132,9 +149,90 @@ export interface SceneRect extends SceneNodeBase {
132
149
  export interface ScenePath extends SceneNodeBase {
133
150
  readonly kind: "path";
134
151
  readonly d: string;
152
+ /** What the shape means, when it has a simple meaning: motion moves that, then redraws `d`. */
153
+ readonly geometry?: SceneGeometry;
154
+ /**
155
+ * How that geometry moves, brought by the mark that drew it (like
156
+ * `morph`), so a chart only loads the motion of the shapes it has.
157
+ */
158
+ readonly motion?: GeometryMotion;
135
159
  readonly paint: ChartPaint;
160
+ /**
161
+ * How an update moves this outline into a new one whose commands differ:
162
+ * a country, a contour, a cell. Set by the marks whose shapes have no
163
+ * simpler meaning, so the morph only loads with them; without it, paths
164
+ * whose commands differ swap at the end.
165
+ */
166
+ readonly morph?: PathMorpher;
167
+ }
168
+
169
+ /** A point of a line, an area or a radar, keyed by its position so an update can slide it. */
170
+ export interface GeometryPoint {
171
+ readonly key: string;
172
+ readonly x: number;
173
+ readonly y: number;
174
+ /** The area's lower edge under this point. */
175
+ readonly x0?: number;
176
+ readonly y0?: number;
177
+ }
178
+
179
+ export type SceneGeometry =
180
+ | {
181
+ readonly kind: "points";
182
+ /** One run per unbroken stretch: a gap in the data splits the line. */
183
+ readonly runs: ReadonlyArray<ReadonlyArray<GeometryPoint>>;
184
+ readonly shape: "line" | "area" | "polygon";
185
+ readonly curve?: ChartCurve;
186
+ }
187
+ | {
188
+ readonly kind: "arc";
189
+ readonly cx: number;
190
+ readonly cy: number;
191
+ readonly innerRadius: number;
192
+ readonly outerRadius: number;
193
+ readonly startAngle: number;
194
+ readonly endAngle: number;
195
+ };
196
+
197
+ /**
198
+ * A geometry in motion: every number it has, by channel name, where each
199
+ * starts and where it goes, and how to rebuild the shape from the numbers of
200
+ * one frame. The store springs the numbers; the plan only knows shapes.
201
+ */
202
+ export interface GeometryPlan {
203
+ readonly starts: ReadonlyMap<string, number>;
204
+ readonly targets: ReadonlyMap<string, number>;
205
+ build(value: (channel: string) => number): SceneGeometry;
136
206
  }
137
207
 
208
+ /** What the motion store asks of a kind of geometry. */
209
+ export interface GeometryMotion {
210
+ /** How `from` (what is painted) becomes `to`; undefined to tween the path instead. */
211
+ plan(from: SceneGeometry, to: SceneGeometry): GeometryPlan | undefined;
212
+ /** The path data of one frame, through the mark's own builder, so every frame is a valid shape. */
213
+ draw(geometry: SceneGeometry): string;
214
+ /** Its length in pixels: what Canvas dashes against to trace a line in. */
215
+ length?(geometry: SceneGeometry): number;
216
+ /** The shape a node an update adds grows from; `siblings` is its new list, `known` the old scene. */
217
+ enter?(
218
+ node: ScenePath,
219
+ siblings: readonly SceneNode[],
220
+ known: ReadonlyMap<string, SceneNode>,
221
+ ): ScenePath | undefined;
222
+ /** The shape a node an update removes collapses to; `siblings` is its old list, `known` the new scene. */
223
+ exit?(
224
+ node: ScenePath,
225
+ siblings: readonly SceneNode[],
226
+ known: ReadonlyMap<string, SceneNode>,
227
+ ): ScenePath | undefined;
228
+ }
229
+
230
+ /** The outlines between two paths, 0 at `from` and 1 at `to`. */
231
+ export type PathMorpher = (
232
+ from: string,
233
+ to: string,
234
+ ) => (progress: number) => string;
235
+
138
236
  export interface SceneCircle extends SceneNodeBase {
139
237
  readonly kind: "circle";
140
238
  readonly cx: number;
package/src/geo/geo.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { mixPath } from "@voila.dev/motion";
1
2
  import {
2
3
  type GeoPermissibleObjects,
3
4
  type GeoProjection,
@@ -202,6 +203,7 @@ export function geoShape<TFeature extends Feature>(
202
203
  key: `${id}:${name}`,
203
204
  role: "mark",
204
205
  d,
206
+ morph: mixPath,
205
207
  paint: paint({
206
208
  fill,
207
209
  stroke: options.stroke ?? context.theme.background,
package/src/index.ts CHANGED
@@ -60,6 +60,15 @@ export {
60
60
  export { type WaffleOptions, waffleY } from "#/core/marks/stats/waffle.ts";
61
61
  export { type TextOptions, text } from "#/core/marks/text.ts";
62
62
  export { type TickOptions, tickX, tickY } from "#/core/marks/tick.ts";
63
+ export {
64
+ createMotionStore,
65
+ type MotionStore,
66
+ } from "#/core/motion/motion-store.ts";
67
+ export {
68
+ type ChartAnimation,
69
+ type ChartTiming,
70
+ chartTiming,
71
+ } from "#/core/motion/timing.ts";
63
72
  export { easeOutCubic, tweenScene } from "#/core/motion/tween.ts";
64
73
  export {
65
74
  type ChartFocusStop,
@@ -1,6 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { compileChart } from "#/core/compile.ts";
3
3
  import type { ChartDefinition } from "#/core/define-chart.ts";
4
+ import { type ChartAnimation, chartTiming } from "#/core/motion/timing.ts";
4
5
  import { findNearest, focusStops } from "#/core/nearest.ts";
5
6
  import { toNumber } from "#/core/scales/continuous.ts";
6
7
  import type { ChartPoint } from "#/core/types.ts";
@@ -29,7 +30,6 @@ import { useTextMeasurer } from "#/react/use-text-measurer.ts";
29
30
 
30
31
  const DEFAULT_HEIGHT = 300;
31
32
  const DEFAULT_INITIAL_WIDTH = 640;
32
- const DEFAULT_DURATION = 300;
33
33
 
34
34
  /** Focus ring, forced colours and reduced motion: what inline styles cannot say. */
35
35
  const CHART_CSS = `
@@ -69,8 +69,12 @@ interface Props
69
69
  readonly legend?: boolean;
70
70
  /** The visually hidden table of the values. On by default; turn it off only when the page shows the same table. */
71
71
  readonly dataTable?: boolean;
72
- /** Tween a data update, in milliseconds; `false` snaps. Reduced motion always snaps. */
73
- readonly animate?: boolean | number;
72
+ /**
73
+ * How a data update moves: `true` (a spring with no bounce), a perceived
74
+ * duration in milliseconds, or `{ duration, bounce, stagger }` and
75
+ * `{ type: "tween", easing }`. `false` snaps; reduced motion always does.
76
+ */
77
+ readonly animate?: boolean | number | ChartAnimation;
74
78
  /**
75
79
  * Zoom a continuous x: the wheel while the chart has focus, + and −,
76
80
  * Shift and the arrows to pan, a drag to pan when zoomed, 0 to reset.
@@ -168,15 +172,20 @@ export function Chart({
168
172
  moved: boolean;
169
173
  } | null>(null);
170
174
  const stops = React.useMemo(() => focusStops(scene), [scene]);
175
+ // A step zoom from the keyboard moves like a data update; the wheel and a
176
+ // drag are direct manipulation, so they snap.
177
+ const [keyboardZooms, setKeyboardZooms] = React.useState(0);
171
178
  const animationTrigger = React.useMemo(
172
- () => [definition, hidden],
173
- [definition, hidden],
179
+ () => [definition, hidden, keyboardZooms],
180
+ [definition, hidden, keyboardZooms],
174
181
  );
175
- const shown = useAnimatedScene(
176
- scene,
177
- animationTrigger,
178
- animate === false ? 0 : animate === true ? DEFAULT_DURATION : animate,
182
+ // An inline `animate={{ … }}` object is read by value, not identity.
183
+ const animateKey = JSON.stringify(animate);
184
+ const timing = React.useMemo(
185
+ () => chartTiming(JSON.parse(animateKey)),
186
+ [animateKey],
179
187
  );
188
+ const shown = useAnimatedScene(scene, animationTrigger, timing);
180
189
  const messages = messagesFor(scene.locale);
181
190
  const { active, handlers } = useChartFocus({
182
191
  stops,
@@ -277,16 +286,19 @@ export function Chart({
277
286
  if (event.key === "+" || event.key === "=") {
278
287
  event.preventDefault();
279
288
  viewport.zoomAround(center, ZOOM_STEP);
289
+ setKeyboardZooms((count) => count + 1);
280
290
  return;
281
291
  }
282
292
  if (event.key === "-" || event.key === "_") {
283
293
  event.preventDefault();
284
294
  viewport.zoomAround(center, 1 / ZOOM_STEP);
295
+ setKeyboardZooms((count) => count + 1);
285
296
  return;
286
297
  }
287
298
  if (event.key === "0") {
288
299
  event.preventDefault();
289
300
  viewport.reset();
301
+ setKeyboardZooms((count) => count + 1);
290
302
  return;
291
303
  }
292
304
  if (
@@ -53,7 +53,11 @@ function paintProps(
53
53
  : (paint.fill ?? "none"),
54
54
  stroke: paint.stroke,
55
55
  strokeWidth: paint.strokeWidth,
56
- strokeDasharray: paint.strokeDasharray,
56
+ strokeDasharray: paint.drawn ? "1 1" : paint.strokeDasharray,
57
+ // Measured in path lengths: the dash shows the first `fraction` of the line.
58
+ ...(paint.drawn
59
+ ? { pathLength: 1, strokeDashoffset: 1 - paint.drawn.fraction }
60
+ : {}),
57
61
  strokeLinecap: paint.strokeLinecap,
58
62
  strokeLinejoin: paint.strokeLinejoin,
59
63
  opacity: paint.opacity,
@@ -1,57 +1,73 @@
1
+ import { cancelFrame, frame, shouldReduceMotion } from "@voila.dev/motion";
1
2
  import * as React from "react";
2
- import { easeOutCubic, tweenScene } from "#/core/motion/tween.ts";
3
+ import {
4
+ createMotionStore,
5
+ type MotionStore,
6
+ } from "#/core/motion/motion-store.ts";
7
+ import type { ChartTiming } from "#/core/motion/timing.ts";
3
8
  import type { ChartScene } from "#/core/types.ts";
4
9
 
5
- function prefersReducedMotion(): boolean {
6
- return (
7
- typeof window !== "undefined" &&
8
- window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
9
- );
10
- }
11
-
12
10
  /**
13
11
  * The scene to draw this frame. A data change (`trigger` changed: a new
14
- * definition, a series toggled) tweens from what is on screen to the new
15
- * scene; a resize snaps, since watching bars slide while a window is dragged
16
- * helps no one. Reduced motion always snaps. An update that lands mid-tween
17
- * starts from the frame already painted, so nothing jumps.
12
+ * definition, a series toggled) moves from what is on screen to the new
13
+ * scene on springs; one landing mid-flight keeps every node's speed. A
14
+ * resize snaps, since watching bars slide while a window is dragged helps no
15
+ * one. Reduced motion always snaps.
18
16
  */
19
17
  export function useAnimatedScene(
20
18
  scene: ChartScene,
21
19
  trigger: unknown,
22
- duration: number,
20
+ timing: ChartTiming | null,
23
21
  ): ChartScene {
24
22
  const [shown, setShown] = React.useState(scene);
25
- const shownRef = React.useRef(scene);
26
23
  const triggerRef = React.useRef(trigger);
24
+ // The first render may play once: lines that `enter: "draw"` trace themselves in.
25
+ const introduced = React.useRef(false);
26
+ const introStart = React.useRef<number | null>(null);
27
+ const storeRef = React.useRef<{
28
+ store: MotionStore;
29
+ timing: ChartTiming;
30
+ } | null>(null);
27
31
 
28
32
  React.useLayoutEffect(() => {
29
33
  const changed = triggerRef.current !== trigger;
30
34
  triggerRef.current = trigger;
31
- if (!changed || duration <= 0 || prefersReducedMotion()) {
32
- shownRef.current = scene;
35
+ if (timing && storeRef.current?.timing !== timing) {
36
+ storeRef.current = { store: createMotionStore(scene, timing), timing };
37
+ }
38
+ const store = storeRef.current?.store;
39
+ const still = !timing || !store || shouldReduceMotion();
40
+ let intro = false;
41
+ if (!still && !changed && !introduced.current) {
42
+ const start = introStart.current ?? performance.now();
43
+ // A re-render mid-intro (the width measured after mount, fonts loaded): the
44
+ // new geometry, drawn from where the intro already is, never from scratch.
45
+ if (introStart.current !== null) store.snap(scene);
46
+ intro = store.introduce(start);
47
+ if (intro) introStart.current = start;
48
+ }
49
+ if (still || (!changed && !intro)) {
50
+ introduced.current = true;
51
+ store?.snap(scene);
33
52
  setShown(scene);
34
53
  return;
35
54
  }
36
- const from = shownRef.current;
37
- // Frame zero now, before paint: nodes that left are gone at once.
38
- const first = tweenScene(from, scene, 0);
39
- shownRef.current = first;
40
- setShown(first);
41
- const start = performance.now();
42
- let frame = 0;
43
- function step(now: number) {
44
- const t = Math.min(1, (now - start) / duration);
45
- const next = tweenScene(from, scene, easeOutCubic(t));
46
- shownRef.current = next;
47
- setShown(next);
48
- if (t < 1) {
49
- frame = requestAnimationFrame(step);
55
+ if (changed) store.retarget(scene, performance.now());
56
+ function tick() {
57
+ const now = performance.now();
58
+ if (store?.settled(now)) {
59
+ // Only once it has played: StrictMode's rerun of the effect starts it again.
60
+ introduced.current = true;
61
+ cancelFrame(tick);
62
+ setShown(scene);
63
+ return;
50
64
  }
65
+ setShown(store?.frame(now) ?? scene);
51
66
  }
52
- frame = requestAnimationFrame(step);
53
- return () => cancelAnimationFrame(frame);
54
- }, [scene, trigger, duration]);
67
+ tick();
68
+ frame.update(tick, true);
69
+ return () => cancelFrame(tick);
70
+ }, [scene, trigger, timing]);
55
71
 
56
72
  return shown;
57
73
  }
@@ -1,3 +1,4 @@
1
+ import { mixPath } from "@voila.dev/motion";
1
2
  import { Delaunay } from "d3-delaunay";
2
3
  import { isPlaceable, readChannel } from "#/core/channel.ts";
3
4
  import { markId, valueOn } from "#/core/marks/shared.ts";
@@ -66,6 +67,7 @@ export function voronoi<TDatum>(
66
67
  key: `${id}:${dataIndex}`,
67
68
  role: "mark",
68
69
  d: diagram.renderCell(cell) ?? "",
70
+ morph: mixPath,
69
71
  paint: options.fill
70
72
  ? {
71
73
  fill: options.fill(data[dataIndex], dataIndex),