@voila.dev/chart 5.0.114 → 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 (86) 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 +4 -1
  38. package/dist/core/motion/motion-store.d.ts.map +1 -1
  39. package/dist/core/motion/motion-store.js +251 -100
  40. package/dist/core/motion/motion-store.js.map +3 -3
  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/types.d.ts +85 -0
  50. package/dist/core/types.d.ts.map +1 -1
  51. package/dist/geo/geo.d.ts.map +1 -1
  52. package/dist/geo/geo.js +2 -0
  53. package/dist/geo/geo.js.map +2 -2
  54. package/dist/react/chart.d.ts.map +1 -1
  55. package/dist/react/chart.js +6 -2
  56. package/dist/react/chart.js.map +2 -2
  57. package/dist/react/svg-scene.d.ts.map +1 -1
  58. package/dist/react/svg-scene.js +3 -1
  59. package/dist/react/svg-scene.js.map +2 -2
  60. package/dist/react/use-animated-scene.d.ts.map +1 -1
  61. package/dist/react/use-animated-scene.js +14 -2
  62. package/dist/react/use-animated-scene.js.map +2 -2
  63. package/dist/voronoi/voronoi.d.ts.map +1 -1
  64. package/dist/voronoi/voronoi.js +2 -0
  65. package/dist/voronoi/voronoi.js.map +2 -2
  66. package/package.json +2 -2
  67. package/src/canvas/paint-scene.ts +5 -1
  68. package/src/contour/density.ts +2 -0
  69. package/src/core/marks/area.ts +28 -13
  70. package/src/core/marks/bar.ts +2 -0
  71. package/src/core/marks/line.ts +15 -8
  72. package/src/core/marks/polar.ts +22 -5
  73. package/src/core/marks/shared.ts +8 -0
  74. package/src/core/marks/stats/difference.ts +28 -8
  75. package/src/core/motion/arc-motion.ts +113 -0
  76. package/src/core/motion/enter-exit.ts +44 -0
  77. package/src/core/motion/leaving.ts +55 -0
  78. package/src/core/motion/motion-store.ts +332 -109
  79. package/src/core/motion/points-motion.ts +190 -0
  80. package/src/core/motion/strings.ts +68 -0
  81. package/src/core/types.ts +98 -0
  82. package/src/geo/geo.ts +2 -0
  83. package/src/react/chart.tsx +8 -2
  84. package/src/react/svg-scene.tsx +5 -1
  85. package/src/react/use-animated-scene.ts +18 -2
  86. package/src/voronoi/voronoi.ts +2 -0
@@ -0,0 +1,190 @@
1
+ import { areaPath, linePath, polygonPath } from "#/core/paths.ts";
2
+ import type {
3
+ GeometryMotion,
4
+ GeometryPlan,
5
+ GeometryPoint,
6
+ SceneGeometry,
7
+ ScenePath,
8
+ } from "#/core/types.ts";
9
+
10
+ type Points = Extract<SceneGeometry, { kind: "points" }>;
11
+
12
+ const POINT_FIELDS = ["x", "y", "x0", "y0"] as const;
13
+
14
+ /** A line, an area or a radar outline through its points. */
15
+ export function pointsPath(geometry: SceneGeometry): string {
16
+ if (geometry.kind !== "points") return "";
17
+ const { runs, shape, curve } = geometry;
18
+ if (shape === "polygon") return polygonPath(runs[0] ?? []);
19
+ return runs
20
+ .map((run) =>
21
+ shape === "area"
22
+ ? areaPath(
23
+ run,
24
+ run.map((point) => ({
25
+ x: point.x0 ?? point.x,
26
+ y: point.y0 ?? point.y,
27
+ })),
28
+ curve,
29
+ )
30
+ : linePath(run, curve),
31
+ )
32
+ .join("");
33
+ }
34
+
35
+ function channel(key: string, field: string): string {
36
+ return `${key}\u0001${field}`;
37
+ }
38
+
39
+ function fields(point: GeometryPoint, into: Map<string, number>) {
40
+ for (const field of POINT_FIELDS) {
41
+ const value = point[field];
42
+ if (value !== undefined) into.set(channel(point.key, field), value);
43
+ }
44
+ }
45
+
46
+ /** `point`'s numbers under `key`'s channel names: a point entering from, or folding into, a neighbour. */
47
+ function as(key: string, point: GeometryPoint): GeometryPoint {
48
+ return { ...point, key };
49
+ }
50
+
51
+ /** The nearest of `order` around `index` that `has` knows: looking back first, then ahead. */
52
+ function neighbour(
53
+ order: readonly string[],
54
+ index: number,
55
+ has: ReadonlyMap<string, GeometryPoint>,
56
+ ): GeometryPoint | undefined {
57
+ for (let at = index - 1; at >= 0; at -= 1) {
58
+ const found = has.get(order[at] as string);
59
+ if (found) return found;
60
+ }
61
+ for (let at = index + 1; at < order.length; at += 1) {
62
+ const found = has.get(order[at] as string);
63
+ if (found) return found;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /** The new keys, with the ones that left kept after the old point before them. */
69
+ function mergeOrder(
70
+ before: readonly string[],
71
+ after: readonly string[],
72
+ ): string[] {
73
+ const merged = [...after];
74
+ const known = new Set(after);
75
+ for (const [index, key] of before.entries()) {
76
+ if (known.has(key)) continue;
77
+ const previous = before[index - 1];
78
+ merged.splice(
79
+ previous === undefined ? 0 : merged.indexOf(previous) + 1,
80
+ 0,
81
+ key,
82
+ );
83
+ known.add(key);
84
+ }
85
+ return merged;
86
+ }
87
+
88
+ /**
89
+ * Points keyed by x. A point that appears enters from its nearest old
90
+ * neighbour (a month added on the right slides out of the last one); a
91
+ * point that leaves folds into its nearest new neighbour. Undefined when the
92
+ * runs don't line up: the caller falls back to tweening the path.
93
+ */
94
+ function pointsPlan(from: Points, to: Points): GeometryPlan | undefined {
95
+ if (from.runs.length !== to.runs.length || from.shape !== to.shape)
96
+ return undefined;
97
+ const starts = new Map<string, number>();
98
+ const targets = new Map<string, number>();
99
+ const orders = to.runs.map((run, index): string[] | undefined => {
100
+ const old = new Map(
101
+ (from.runs[index] ?? []).map((point) => [point.key, point]),
102
+ );
103
+ const next = new Map(run.map((point) => [point.key, point]));
104
+ // Two points under one key can't be told apart: tween the path instead.
105
+ if (
106
+ old.size !== (from.runs[index]?.length ?? 0) ||
107
+ next.size !== run.length
108
+ ) {
109
+ return undefined;
110
+ }
111
+ const order = mergeOrder([...old.keys()], [...next.keys()]);
112
+ for (const [at, key] of order.entries()) {
113
+ const target = next.get(key) ?? neighbour(order, at, next);
114
+ const start = old.get(key) ?? neighbour(order, at, old) ?? target;
115
+ if (target === undefined || start === undefined) continue;
116
+ fields(as(key, start), starts);
117
+ fields(as(key, target), targets);
118
+ }
119
+ return order;
120
+ });
121
+ if (orders.some((order) => order === undefined)) return undefined;
122
+ return {
123
+ starts,
124
+ targets,
125
+ build(value) {
126
+ const area = to.runs.some((run) => run[0]?.y0 !== undefined);
127
+ return {
128
+ ...to,
129
+ runs: (orders as string[][]).map((order) =>
130
+ order.map((key) => ({
131
+ key,
132
+ x: value(channel(key, "x")),
133
+ y: value(channel(key, "y")),
134
+ ...(area
135
+ ? { x0: value(channel(key, "x0")), y0: value(channel(key, "y0")) }
136
+ : {}),
137
+ })),
138
+ ),
139
+ };
140
+ },
141
+ };
142
+ }
143
+
144
+ /** A line's length along its points: what Canvas dashes against to trace it in. */
145
+ function pointsLength(geometry: SceneGeometry): number {
146
+ if (geometry.kind !== "points") return 0;
147
+ let length = 0;
148
+ for (const run of geometry.runs) {
149
+ for (let index = 1; index < run.length; index += 1) {
150
+ const a = run[index - 1] as GeometryPoint;
151
+ const b = run[index] as GeometryPoint;
152
+ length += Math.hypot(b.x - a.x, b.y - a.y);
153
+ }
154
+ }
155
+ // A curve runs a little longer than its chords; the dash must cover it all.
156
+ return length * 1.1;
157
+ }
158
+
159
+ /** An area, or the line over it, grows from its lower edge. */
160
+ function collapsed(node: ScenePath): ScenePath | undefined {
161
+ const geometry = node.geometry;
162
+ // A line over an area carries the area's lower edge too, so the two grow as one.
163
+ if (geometry?.kind !== "points" || geometry.runs[0]?.[0]?.y0 === undefined) {
164
+ return undefined;
165
+ }
166
+ return {
167
+ ...node,
168
+ geometry: {
169
+ ...geometry,
170
+ runs: geometry.runs.map((run) =>
171
+ run.map((point) => ({
172
+ ...point,
173
+ x: point.x0 ?? point.x,
174
+ y: point.y0 ?? point.y,
175
+ })),
176
+ ),
177
+ },
178
+ };
179
+ }
180
+
181
+ /** Lines, areas and radar outlines: points keyed by x (or by axis). */
182
+ export const POINTS_MOTION: GeometryMotion = {
183
+ plan: (from, to) =>
184
+ from.kind === "points" && to.kind === "points"
185
+ ? pointsPlan(from, to)
186
+ : undefined,
187
+ draw: pointsPath,
188
+ length: pointsLength,
189
+ enter: (node) => (node.enter === "grow" ? collapsed(node) : undefined),
190
+ };
@@ -0,0 +1,68 @@
1
+ import { tweenPath } from "#/core/motion/tween.ts";
2
+ import type { SceneNode } from "#/core/types.ts";
3
+
4
+ /** The channels that move as a string mixed by progress, not as a number. */
5
+ export const STRING_CHANNELS = ["d", "fill", "stroke"] as const;
6
+
7
+ export type StringChannel = (typeof STRING_CHANNELS)[number];
8
+
9
+ /** 2 % steps: at most 51 colours per pair, so the Canvas resolver's cache stays small. */
10
+ const COLOUR_STEPS = 50;
11
+
12
+ /**
13
+ * A colour part of the way to another, as CSS: `color-mix()` reads the
14
+ * chart's `var(--chart-1)` tokens as they are, so nothing parses colours,
15
+ * and the Canvas resolver computes the mix against the page like any
16
+ * other colour.
17
+ */
18
+ export function colourBetween(
19
+ from: string,
20
+ to: string,
21
+ progress: number,
22
+ ): string {
23
+ const step = Math.round(Math.min(1, Math.max(0, progress)) * COLOUR_STEPS);
24
+ if (step === 0) return from;
25
+ if (step === COLOUR_STEPS) return to;
26
+ return `color-mix(in oklab, ${to} ${(step * 100) / COLOUR_STEPS}%, ${from})`;
27
+ }
28
+
29
+ /** What a string channel of `node` reads now. */
30
+ export function stringOf(
31
+ node: SceneNode | undefined,
32
+ channel: StringChannel,
33
+ ): string | undefined {
34
+ if (node === undefined || node.kind === "group") return undefined;
35
+ return channel === "d"
36
+ ? node.kind === "path"
37
+ ? node.d
38
+ : undefined
39
+ : node.paint[channel];
40
+ }
41
+
42
+ /** The mix from what was painted to what `node` asks for. */
43
+ export function stringMixer(
44
+ node: SceneNode,
45
+ channel: StringChannel,
46
+ from: string,
47
+ to: string,
48
+ ): (progress: number) => string {
49
+ if (channel !== "d") {
50
+ return (progress) => colourBetween(from, to, progress);
51
+ }
52
+ const morph = node.kind === "path" ? node.morph : undefined;
53
+ return morph ? morph(from, to) : (progress) => tweenPath(from, to, progress);
54
+ }
55
+
56
+ /** `node` with each string channel set to its moving value. */
57
+ export function withStrings(
58
+ node: SceneNode,
59
+ values: Partial<Record<StringChannel, string>>,
60
+ ): SceneNode {
61
+ if (node.kind === "group") return node;
62
+ const { d, ...colours } = values;
63
+ const shaped =
64
+ d === undefined || node.kind !== "path" ? node : { ...node, d };
65
+ return Object.keys(colours).length === 0
66
+ ? shaped
67
+ : ({ ...shaped, paint: { ...shaped.paint, ...colours } } as SceneNode);
68
+ }
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,
@@ -172,9 +172,12 @@ export function Chart({
172
172
  moved: boolean;
173
173
  } | null>(null);
174
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);
175
178
  const animationTrigger = React.useMemo(
176
- () => [definition, hidden],
177
- [definition, hidden],
179
+ () => [definition, hidden, keyboardZooms],
180
+ [definition, hidden, keyboardZooms],
178
181
  );
179
182
  // An inline `animate={{ … }}` object is read by value, not identity.
180
183
  const animateKey = JSON.stringify(animate);
@@ -283,16 +286,19 @@ export function Chart({
283
286
  if (event.key === "+" || event.key === "=") {
284
287
  event.preventDefault();
285
288
  viewport.zoomAround(center, ZOOM_STEP);
289
+ setKeyboardZooms((count) => count + 1);
286
290
  return;
287
291
  }
288
292
  if (event.key === "-" || event.key === "_") {
289
293
  event.preventDefault();
290
294
  viewport.zoomAround(center, 1 / ZOOM_STEP);
295
+ setKeyboardZooms((count) => count + 1);
291
296
  return;
292
297
  }
293
298
  if (event.key === "0") {
294
299
  event.preventDefault();
295
300
  viewport.reset();
301
+ setKeyboardZooms((count) => count + 1);
296
302
  return;
297
303
  }
298
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,
@@ -21,6 +21,9 @@ export function useAnimatedScene(
21
21
  ): ChartScene {
22
22
  const [shown, setShown] = React.useState(scene);
23
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);
24
27
  const storeRef = React.useRef<{
25
28
  store: MotionStore;
26
29
  timing: ChartTiming;
@@ -33,15 +36,28 @@ export function useAnimatedScene(
33
36
  storeRef.current = { store: createMotionStore(scene, timing), timing };
34
37
  }
35
38
  const store = storeRef.current?.store;
36
- if (!changed || !timing || !store || shouldReduceMotion()) {
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;
37
51
  store?.snap(scene);
38
52
  setShown(scene);
39
53
  return;
40
54
  }
41
- store.retarget(scene, performance.now());
55
+ if (changed) store.retarget(scene, performance.now());
42
56
  function tick() {
43
57
  const now = performance.now();
44
58
  if (store?.settled(now)) {
59
+ // Only once it has played: StrictMode's rerun of the effect starts it again.
60
+ introduced.current = true;
45
61
  cancelFrame(tick);
46
62
  setShown(scene);
47
63
  return;
@@ -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),