@voila.dev/chart 5.0.118 → 6.0.121

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 (43) hide show
  1. package/dist/core/motion/motion-store.d.ts.map +1 -1
  2. package/dist/core/motion/motion-store.js +65 -105
  3. package/dist/core/motion/motion-store.js.map +3 -3
  4. package/dist/core/motion/points-motion.d.ts.map +1 -1
  5. package/dist/core/motion/points-motion.js +9 -22
  6. package/dist/core/motion/points-motion.js.map +2 -2
  7. package/dist/core/motion/strings.d.ts +4 -2
  8. package/dist/core/motion/strings.d.ts.map +1 -1
  9. package/dist/core/motion/strings.js +5 -6
  10. package/dist/core/motion/strings.js.map +2 -2
  11. package/dist/core/motion/timing.d.ts +16 -18
  12. package/dist/core/motion/timing.d.ts.map +1 -1
  13. package/dist/core/motion/timing.js +9 -8
  14. package/dist/core/motion/timing.js.map +2 -2
  15. package/dist/core/motion/tween-timing.d.ts +17 -0
  16. package/dist/core/motion/tween-timing.d.ts.map +1 -0
  17. package/dist/core/motion/tween-timing.js +22 -0
  18. package/dist/core/motion/tween-timing.js.map +7 -0
  19. package/dist/index.d.ts +2 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -0
  22. package/dist/index.js.map +2 -2
  23. package/dist/react/chart.d.ts +3 -2
  24. package/dist/react/chart.d.ts.map +1 -1
  25. package/dist/react/chart.js +3 -1
  26. package/dist/react/chart.js.map +2 -2
  27. package/dist/react/use-animated-scene.d.ts.map +1 -1
  28. package/dist/react/use-animated-scene.js +4 -4
  29. package/dist/react/use-animated-scene.js.map +2 -2
  30. package/package.json +2 -2
  31. package/src/core/motion/motion-store.ts +93 -139
  32. package/src/core/motion/points-motion.ts +12 -24
  33. package/src/core/motion/strings.ts +9 -12
  34. package/src/core/motion/timing.ts +28 -33
  35. package/src/core/motion/tween-timing.ts +35 -0
  36. package/src/index.ts +2 -0
  37. package/src/react/chart.tsx +9 -4
  38. package/src/react/use-animated-scene.ts +6 -4
  39. package/dist/core/motion/leaving.d.ts +0 -12
  40. package/dist/core/motion/leaving.d.ts.map +0 -1
  41. package/dist/core/motion/leaving.js +0 -33
  42. package/dist/core/motion/leaving.js.map +0 -7
  43. package/src/core/motion/leaving.ts +0 -55
@@ -1,16 +1,11 @@
1
- import { type Generator, spring, tween } from "@voila.dev/motion";
1
+ import type { Generator } from "@voila.dev/motion";
2
2
  import { enterFrom, exitTo } from "#/core/motion/enter-exit.ts";
3
- import {
4
- type LeavingGuide,
5
- leavingGuides,
6
- withLeaving,
7
- } from "#/core/motion/leaving.ts";
8
3
  import {
9
4
  STRING_CHANNELS,
10
5
  type StringChannel,
11
6
  stringMixer,
12
7
  stringOf,
13
- withStrings,
8
+ withString,
14
9
  } from "#/core/motion/strings.ts";
15
10
  import { type ChartTiming, staggerDelay } from "#/core/motion/timing.ts";
16
11
  import type {
@@ -43,21 +38,17 @@ interface Motion {
43
38
  readonly generator: Generator;
44
39
  /** Milliseconds, the delay included. */
45
40
  readonly start: number;
41
+ /** A path or a colour: the generator runs a progress from 0 to 1, and this reads it. */
42
+ readonly mix?: (progress: number) => string;
46
43
  }
47
44
 
48
- /** A path or a colour: a progress from 0 to 1, and the mix it reads. */
49
- interface StringMotion extends Motion {
50
- readonly mix: (progress: number) => string;
51
- }
52
-
53
- /** A guide fading out where it stood. */
54
- interface GuideMotion extends Motion {
55
- readonly guide: LeavingGuide;
56
- }
57
-
58
- /** A data mark collapsing on its way out: drawn after its old sibling until its springs settle. */
45
+ /**
46
+ * A node on its way out, drawn after its old sibling until its springs
47
+ * settle: a data mark collapsing, or a guide fading where it stood.
48
+ */
59
49
  interface Leaving {
60
50
  readonly node: SceneNode;
51
+ /** Its old previous sibling's key, else its group's `first` marker. */
61
52
  readonly after: string | undefined;
62
53
  readonly siblings: readonly SceneNode[];
63
54
  }
@@ -76,25 +67,46 @@ const FIELDS: Record<SceneNode["kind"], readonly string[]> = {
76
67
  const OPACITY = "opacity";
77
68
  const DRAWN = "drawn";
78
69
  const SEPARATOR = "\u0000";
70
+ /**
71
+ * A tick label or a grid line vanishing at once reads as a glitch, so they
72
+ * fade out. Data marks never linger: a bar the data no longer has would show
73
+ * a value that isn't true.
74
+ */
75
+ const GUIDE_ROLES = new Set(["grid", "axis"]);
79
76
 
77
+ /** Where the leaving first children of the group `parent` are drawn. */
78
+ function first(parent: string | null): string | undefined {
79
+ return parent === null ? undefined : parent + SEPARATOR;
80
+ }
81
+
82
+ /** Visits every node with its list and the key it is drawn after when it leaves (see `Leaving`). */
80
83
  function walk(
81
84
  nodes: readonly SceneNode[],
82
- visit: (node: SceneNode, siblings: readonly SceneNode[]) => void,
85
+ visit: (
86
+ node: SceneNode,
87
+ siblings: readonly SceneNode[],
88
+ after: string | undefined,
89
+ ) => void,
90
+ parent: string | null = null,
83
91
  ) {
92
+ let after = first(parent);
84
93
  for (const node of nodes) {
85
- visit(node, nodes);
86
- if (node.kind === "group") walk(node.children, visit);
94
+ visit(node, nodes, after);
95
+ if (node.kind === "group") walk(node.children, visit, node.key);
96
+ after = node.key;
87
97
  }
88
98
  }
89
99
 
90
100
  function index(nodes: readonly SceneNode[]) {
91
101
  const byKey = new Map<string, SceneNode>();
92
102
  const siblings = new Map<string, readonly SceneNode[]>();
93
- walk(nodes, (node, list) => {
103
+ const afters = new Map<string, string | undefined>();
104
+ walk(nodes, (node, list, after) => {
94
105
  byKey.set(node.key, node);
95
106
  siblings.set(node.key, list);
107
+ afters.set(node.key, after);
96
108
  });
97
- return { byKey, siblings };
109
+ return { byKey, siblings, afters };
98
110
  }
99
111
 
100
112
  function channelKey(node: string, channel: string): string {
@@ -105,6 +117,10 @@ function elapsed(motion: Motion, now: number): number {
105
117
  return Math.max(0, (now - motion.start) / 1000);
106
118
  }
107
119
 
120
+ function sample(motion: Motion, now: number) {
121
+ return motion.generator.at(elapsed(motion, now));
122
+ }
123
+
108
124
  function done(motion: Motion, now: number): boolean {
109
125
  return elapsed(motion, now) >= motion.generator.duration;
110
126
  }
@@ -118,34 +134,16 @@ export function createMotionStore(
118
134
  timing: ChartTiming,
119
135
  ): MotionStore {
120
136
  let target = initial;
121
- const numbers = new Map<string, Motion>();
122
- const strings = new Map<string, StringMotion>();
123
- const guides = new Map<string, GuideMotion>();
137
+ const channels = new Map<string, Motion>();
124
138
  const shapes = new Map<
125
139
  string,
126
140
  { readonly plan: GeometryPlan; readonly motion: GeometryMotion }
127
141
  >();
128
142
  const leaving = new Map<string, Leaving>();
129
143
 
130
- function generator(from: number, to: number, velocity: number): Generator {
131
- return timing.type === "spring"
132
- ? spring({
133
- from,
134
- to,
135
- velocity,
136
- duration: timing.duration,
137
- bounce: timing.bounce,
138
- })
139
- : tween({
140
- keyframes: [from, to],
141
- duration: timing.duration,
142
- ease: timing.easing,
143
- });
144
- }
145
-
146
144
  function value(node: string, channel: string, fallback: number, now: number) {
147
- const motion = numbers.get(channelKey(node, channel));
148
- return motion ? motion.generator.at(elapsed(motion, now)).value : fallback;
145
+ const motion = channels.get(channelKey(node, channel));
146
+ return motion ? sample(motion, now).value : fallback;
149
147
  }
150
148
 
151
149
  /** Springs one channel from `from` to `to`, at the speed it already has. */
@@ -158,13 +156,11 @@ export function createMotionStore(
158
156
  now: number,
159
157
  ) {
160
158
  const key = channelKey(node, channel);
161
- const running = numbers.get(key);
162
- const velocity = running
163
- ? running.generator.at(elapsed(running, now)).velocity
164
- : 0;
165
- numbers.delete(key);
159
+ const running = channels.get(key);
160
+ const velocity = running ? sample(running, now).velocity : 0;
161
+ channels.delete(key);
166
162
  if (from === to && velocity === 0) return;
167
- numbers.set(key, { generator: generator(from, to, velocity), start });
163
+ channels.set(key, { generator: timing.motion(from, to, velocity), start });
168
164
  }
169
165
 
170
166
  /** Mixes one string channel from what is painted to what `to` asks for. */
@@ -175,22 +171,20 @@ export function createMotionStore(
175
171
  start: number,
176
172
  ) {
177
173
  const key = channelKey(to.key, channel);
178
- strings.delete(key);
174
+ channels.delete(key);
179
175
  const was = stringOf(from, channel);
180
176
  const goal = stringOf(to, channel);
181
177
  if (was === undefined || goal === undefined || was === goal) return;
182
- strings.set(key, {
183
- generator: generator(0, 1, 0),
178
+ channels.set(key, {
179
+ generator: timing.motion(0, 1, 0),
184
180
  start,
185
181
  mix: stringMixer(to, channel, was, goal),
186
182
  });
187
183
  }
188
184
 
189
185
  function drop(node: string) {
190
- for (const motions of [numbers, strings]) {
191
- for (const key of motions.keys()) {
192
- if (key.startsWith(node + SEPARATOR)) motions.delete(key);
193
- }
186
+ for (const key of channels.keys()) {
187
+ if (key.startsWith(node + SEPARATOR)) channels.delete(key);
194
188
  }
195
189
  shapes.delete(node);
196
190
  leaving.delete(node);
@@ -215,7 +209,7 @@ export function createMotionStore(
215
209
  if (to.kind !== "path" || from.kind !== "path") return;
216
210
  const previous = shapes.get(key);
217
211
  shapes.delete(key);
218
- strings.delete(channelKey(key, "d"));
212
+ channels.delete(channelKey(key, "d"));
219
213
  const motion = to.motion;
220
214
  const plan =
221
215
  motion && from.geometry && to.geometry
@@ -228,7 +222,7 @@ export function createMotionStore(
228
222
  }
229
223
  for (const channel of previous?.plan.targets.keys() ?? []) {
230
224
  if (!plan.targets.has(channel)) {
231
- numbers.delete(channelKey(key, `g${channel}`));
225
+ channels.delete(channelKey(key, `g${channel}`));
232
226
  }
233
227
  }
234
228
  for (const [channel, goal] of plan.targets) {
@@ -260,43 +254,33 @@ export function createMotionStore(
260
254
  );
261
255
  next = { ...next, geometry, d: shape.motion.draw(geometry) };
262
256
  }
263
- const mixed: Partial<Record<StringChannel, string>> = {};
264
257
  for (const channel of STRING_CHANNELS) {
265
- const motion = strings.get(channelKey(key, channel));
266
- if (motion) {
267
- mixed[channel] = motion.mix(
268
- motion.generator.at(elapsed(motion, now)).value,
269
- );
258
+ const motion = channels.get(channelKey(key, channel));
259
+ if (motion?.mix) {
260
+ next = withString(next, channel, motion.mix(sample(motion, now).value));
270
261
  }
271
262
  }
272
- next = withStrings(next, mixed) as typeof next;
273
263
  const opacity = value(key, OPACITY, 1, now);
274
264
  const drawn = value(key, DRAWN, 1, now);
275
265
  if (opacity === 1 && drawn === 1) return next;
276
- const geometry = next.kind === "path" ? next.geometry : undefined;
277
- const length =
278
- geometry && next.kind === "path" ? next.motion?.length?.(geometry) : 0;
279
266
  return {
280
267
  ...next,
281
268
  paint: {
282
269
  ...next.paint,
283
270
  opacity: (next.paint.opacity ?? 1) * clamp(opacity),
284
- ...(drawn < 1 && geometry
285
- ? {
286
- drawn: {
287
- fraction: clamp(drawn),
288
- length: length ?? 0,
289
- },
290
- }
291
- : {}),
271
+ ...(drawn < 1 &&
272
+ next.kind === "path" &&
273
+ next.geometry && {
274
+ drawn: {
275
+ fraction: clamp(drawn),
276
+ length: next.motion?.length?.(next.geometry) ?? 0,
277
+ },
278
+ }),
292
279
  },
293
280
  } as SceneNode;
294
281
  }
295
282
 
296
- /**
297
- * The painted list, with the marks still collapsing after the sibling they
298
- * followed, and the guides still fading back at their places.
299
- */
283
+ /** The painted list, with the nodes still leaving after the sibling they followed. */
300
284
  function paintList(
301
285
  list: readonly SceneNode[],
302
286
  parent: string | null,
@@ -310,38 +294,25 @@ export function createMotionStore(
310
294
  place(key);
311
295
  }
312
296
  }
313
- if (parent === null) place(undefined);
297
+ place(first(parent));
314
298
  for (const node of list) {
315
299
  out.push(paintNode(node, now));
316
300
  place(node.key);
317
301
  }
318
- if (guides.size === 0) return out;
319
- const fading = [...guides.values()].map(
320
- (motion) =>
321
- [
322
- motion.guide,
323
- Math.max(0, motion.generator.at(elapsed(motion, now)).value),
324
- ] as const,
325
- );
326
- return withLeaving(out, parent, fading);
302
+ return out;
327
303
  }
328
304
 
329
305
  function prune(now: number) {
330
- for (const motions of [numbers, strings, guides]) {
331
- for (const [key, motion] of motions) {
332
- if (done(motion, now)) motions.delete(key);
333
- }
306
+ for (const [key, motion] of channels) {
307
+ if (done(motion, now)) channels.delete(key);
334
308
  }
335
309
  const moving = new Set(
336
- [...numbers.keys(), ...strings.keys()].map(
337
- (key) => key.split(SEPARATOR)[0],
338
- ),
310
+ [...channels.keys()].map((key) => key.split(SEPARATOR)[0]),
339
311
  );
340
- for (const key of shapes.keys()) {
341
- if (!moving.has(key)) shapes.delete(key);
342
- }
343
- for (const key of leaving.keys()) {
344
- if (!moving.has(key)) leaving.delete(key);
312
+ for (const byNode of [shapes, leaving]) {
313
+ for (const key of byNode.keys()) {
314
+ if (!moving.has(key)) byNode.delete(key);
315
+ }
345
316
  }
346
317
  }
347
318
 
@@ -350,39 +321,30 @@ export function createMotionStore(
350
321
  const before = index(target.nodes);
351
322
  const after = index(scene.nodes);
352
323
  function painted(key: string): SceneNode | undefined {
353
- const node =
354
- before.byKey.get(key) ??
355
- leaving.get(key)?.node ??
356
- guides.get(key)?.guide.node;
324
+ const node = before.byKey.get(key) ?? leaving.get(key)?.node;
357
325
  return node && paintNode(node, now);
358
326
  }
359
- // Read before the exits below drop what the guides were showing.
360
- for (const guide of leavingGuides(target.nodes, (key) =>
361
- after.byKey.has(key),
362
- )) {
363
- guides.set(guide.node.key, {
364
- generator: generator(value(guide.node.key, OPACITY, 1, now), 0, 0),
365
- start: now,
366
- guide,
367
- });
368
- }
369
327
  for (const key of new Set([...before.byKey.keys(), ...leaving.keys()])) {
370
328
  if (after.byKey.has(key)) continue;
371
329
  const base = (before.byKey.get(key) ??
372
330
  leaving.get(key)?.node) as SceneNode;
373
- const siblings =
374
- before.siblings.get(key) ?? leaving.get(key)?.siblings ?? [];
375
- const goal = exitTo(base, siblings, after.byKey);
331
+ const exit = leaving.get(key);
332
+ const siblings = exit?.siblings ?? before.siblings.get(key) ?? [];
333
+ // A whole guide group leaving goes at once, its lines with it.
334
+ const guide = base.kind !== "group" && GUIDE_ROLES.has(base.role ?? "");
335
+ const goal = exitTo(base, siblings, after.byKey) ?? (guide && base);
376
336
  const from = painted(key);
377
337
  if (!goal || !from) {
378
338
  drop(key);
379
339
  continue;
380
340
  }
381
341
  move(from, goal, now, now);
382
- const at = siblings.findIndex((node) => node.key === key);
342
+ if (guide) {
343
+ spring1(key, OPACITY, value(key, OPACITY, 1, now), 0, now, now);
344
+ }
383
345
  leaving.set(key, {
384
346
  node: goal,
385
- after: siblings[at - 1]?.key,
347
+ after: exit ? exit.after : before.afters.get(key),
386
348
  siblings,
387
349
  });
388
350
  }
@@ -396,23 +358,19 @@ export function createMotionStore(
396
358
  now +
397
359
  staggerDelay(timing, order.get(node.key) ?? 0, marks.length) * 1000;
398
360
  const old = painted(node.key);
399
- leaving.delete(node.key);
361
+ const returning = leaving.delete(node.key);
400
362
  const from =
401
363
  old ??
402
364
  enterFrom(node, after.siblings.get(node.key) ?? [], before.byKey);
403
365
  if (from) move(from, node, start, now);
404
- const fading = numbers.get(channelKey(node.key, OPACITY));
405
- const returning = guides.get(node.key);
406
- if ((!from && node.enter !== "none") || fading || returning) {
407
- const shown = returning
408
- ? Math.max(0, returning.generator.at(elapsed(returning, now)).value)
409
- : fading
410
- ? value(node.key, OPACITY, 1, now)
411
- : 0;
366
+ const fading = channels.get(channelKey(node.key, OPACITY));
367
+ if ((!from && node.enter !== "none") || fading) {
368
+ const shown = fading ? Math.max(0, sample(fading, now).value) : 0;
369
+ // A guide coming back starts from rest: its fade-out speed points the wrong way.
370
+ if (returning) channels.delete(channelKey(node.key, OPACITY));
412
371
  spring1(node.key, OPACITY, shown, 1, start, now);
413
372
  }
414
373
  }
415
- for (const key of after.byKey.keys()) guides.delete(key);
416
374
  target = scene;
417
375
  },
418
376
  introduce(now) {
@@ -428,22 +386,18 @@ export function createMotionStore(
428
386
  return any;
429
387
  },
430
388
  snap(scene) {
431
- for (const motions of [numbers, strings, guides, shapes, leaving]) {
389
+ for (const motions of [channels, shapes, leaving]) {
432
390
  motions.clear();
433
391
  }
434
392
  target = scene;
435
393
  },
436
394
  frame(now) {
437
395
  prune(now);
438
- if (numbers.size + strings.size + guides.size === 0) return target;
396
+ if (channels.size === 0) return target;
439
397
  return { ...target, nodes: paintList(target.nodes, null, now) };
440
398
  },
441
399
  settled(now) {
442
- return [
443
- ...numbers.values(),
444
- ...strings.values(),
445
- ...guides.values(),
446
- ].every((motion) => done(motion, now));
400
+ return [...channels.values()].every((motion) => done(motion, now));
447
401
  },
448
402
  };
449
403
  }
@@ -11,6 +11,11 @@ type Points = Extract<SceneGeometry, { kind: "points" }>;
11
11
 
12
12
  const POINT_FIELDS = ["x", "y", "x0", "y0"] as const;
13
13
 
14
+ /** Where `point` sits on its area's lower edge. */
15
+ function lowerEdge(point: GeometryPoint): GeometryPoint {
16
+ return { ...point, x: point.x0 ?? point.x, y: point.y0 ?? point.y };
17
+ }
18
+
14
19
  /** A line, an area or a radar outline through its points. */
15
20
  export function pointsPath(geometry: SceneGeometry): string {
16
21
  if (geometry.kind !== "points") return "";
@@ -19,14 +24,7 @@ export function pointsPath(geometry: SceneGeometry): string {
19
24
  return runs
20
25
  .map((run) =>
21
26
  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
- )
27
+ ? areaPath(run, run.map(lowerEdge), curve)
30
28
  : linePath(run, curve),
31
29
  )
32
30
  .join("");
@@ -36,18 +34,14 @@ function channel(key: string, field: string): string {
36
34
  return `${key}\u0001${field}`;
37
35
  }
38
36
 
39
- function fields(point: GeometryPoint, into: Map<string, number>) {
37
+ /** `point`'s numbers under `key`'s channel names: `point` may be a neighbour that `key` enters from or folds into. */
38
+ function fields(key: string, point: GeometryPoint, into: Map<string, number>) {
40
39
  for (const field of POINT_FIELDS) {
41
40
  const value = point[field];
42
- if (value !== undefined) into.set(channel(point.key, field), value);
41
+ if (value !== undefined) into.set(channel(key, field), value);
43
42
  }
44
43
  }
45
44
 
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
45
  /** The nearest of `order` around `index` that `has` knows: looking back first, then ahead. */
52
46
  function neighbour(
53
47
  order: readonly string[],
@@ -113,8 +107,8 @@ function pointsPlan(from: Points, to: Points): GeometryPlan | undefined {
113
107
  const target = next.get(key) ?? neighbour(order, at, next);
114
108
  const start = old.get(key) ?? neighbour(order, at, old) ?? target;
115
109
  if (target === undefined || start === undefined) continue;
116
- fields(as(key, start), starts);
117
- fields(as(key, target), targets);
110
+ fields(key, start, starts);
111
+ fields(key, target, targets);
118
112
  }
119
113
  return order;
120
114
  });
@@ -167,13 +161,7 @@ function collapsed(node: ScenePath): ScenePath | undefined {
167
161
  ...node,
168
162
  geometry: {
169
163
  ...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
- ),
164
+ runs: geometry.runs.map((run) => run.map(lowerEdge)),
177
165
  },
178
166
  };
179
167
  }
@@ -53,16 +53,13 @@ export function stringMixer(
53
53
  return morph ? morph(from, to) : (progress) => tweenPath(from, to, progress);
54
54
  }
55
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);
56
+ /** `node` with one string channel set to its moving value. */
57
+ export function withString<Node extends Exclude<SceneNode, { kind: "group" }>>(
58
+ node: Node,
59
+ channel: StringChannel,
60
+ value: string,
61
+ ): Node {
62
+ if (channel !== "d")
63
+ return { ...node, paint: { ...node.paint, [channel]: value } };
64
+ return node.kind === "path" ? { ...node, d: value } : node;
68
65
  }
@@ -1,34 +1,30 @@
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
- };
1
+ import { type Generator, spring } from "@voila.dev/motion";
20
2
 
3
+ /**
4
+ * How every channel of a chart moves on an update. The store only asks it
5
+ * for one channel's motion, so a chart that never tweens never loads the
6
+ * tween: `tween()` builds one, the default is a spring.
7
+ */
21
8
  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
9
  /** Seconds between neighbours. */
28
10
  readonly stagger: number;
11
+ /** One channel from `from` to `to`, leaving at `velocity` (units per second). */
12
+ motion(from: number, to: number, velocity: number): Generator;
13
+ }
14
+
15
+ /** The spring `<Chart animate>` takes beyond `true`, `false` and a duration. */
16
+ export interface ChartSpring {
17
+ /** Perceived duration, in milliseconds. */
18
+ readonly duration?: number;
19
+ /** 0 never overshoots (the default: a bar past its value shows a number the data doesn't have); up to 0.5. */
20
+ readonly bounce?: number;
21
+ /** Milliseconds between marks in focus order, capped at 300 ms in all. */
22
+ readonly stagger?: number;
29
23
  }
30
24
 
31
- const DEFAULT_DURATION = 300;
25
+ export type ChartAnimation = ChartSpring | ChartTiming;
26
+
27
+ export const DEFAULT_DURATION = 300;
32
28
  const MAX_STAGGER_TOTAL = 0.3;
33
29
 
34
30
  /** The timing an `animate` prop means, or `null` to snap. */
@@ -36,19 +32,18 @@ export function chartTiming(
36
32
  animate: boolean | number | ChartAnimation,
37
33
  ): ChartTiming | null {
38
34
  if (animate === false || animate === 0) return null;
39
- const options: ChartAnimation =
35
+ if (typeof animate === "object" && "motion" in animate) return animate;
36
+ const options: ChartSpring =
40
37
  typeof animate === "object"
41
38
  ? animate
42
39
  : { duration: animate === true ? DEFAULT_DURATION : animate };
40
+ const duration = (options.duration ?? DEFAULT_DURATION) / 1000;
41
+ const bounce = Math.min(Math.max(options.bounce ?? 0, 0), 0.5);
43
42
  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
43
  stagger: (options.stagger ?? 0) / 1000,
44
+ motion(from, to, velocity) {
45
+ return spring({ from, to, velocity, duration, bounce });
46
+ },
52
47
  };
53
48
  }
54
49
 
@@ -0,0 +1,35 @@
1
+ import { type EasingName, tween as tweenGenerator } from "@voila.dev/motion";
2
+ import { type ChartTiming, DEFAULT_DURATION } from "#/core/motion/timing.ts";
3
+
4
+ export interface ChartTweenOptions {
5
+ /** Milliseconds. */
6
+ readonly duration?: number;
7
+ readonly easing?: EasingName;
8
+ /** Milliseconds between marks in focus order, capped at 300 ms in all. */
9
+ readonly stagger?: number;
10
+ }
11
+
12
+ /**
13
+ * A fixed curve instead of the spring, for `<Chart animate={tween()} />`.
14
+ * Its own import, so the easings only load in a chart that asks for them.
15
+ * The options stay on the timing as plain fields: `<Chart>` reads an inline
16
+ * `animate` by value, and a function alone would not tell two tweens apart.
17
+ */
18
+ export function tween(
19
+ options: ChartTweenOptions = {},
20
+ ): ChartTiming & ChartTweenOptions {
21
+ const duration = options.duration ?? DEFAULT_DURATION;
22
+ const easing = options.easing ?? "easeOut";
23
+ return {
24
+ duration,
25
+ easing,
26
+ stagger: (options.stagger ?? 0) / 1000,
27
+ motion(from, to) {
28
+ return tweenGenerator({
29
+ keyframes: [from, to],
30
+ duration: duration / 1000,
31
+ ease: easing,
32
+ });
33
+ },
34
+ };
35
+ }
package/src/index.ts CHANGED
@@ -66,10 +66,12 @@ export {
66
66
  } from "#/core/motion/motion-store.ts";
67
67
  export {
68
68
  type ChartAnimation,
69
+ type ChartSpring,
69
70
  type ChartTiming,
70
71
  chartTiming,
71
72
  } from "#/core/motion/timing.ts";
72
73
  export { easeOutCubic, tweenScene } from "#/core/motion/tween.ts";
74
+ export { type ChartTweenOptions, tween } from "#/core/motion/tween-timing.ts";
73
75
  export {
74
76
  type ChartFocusStop,
75
77
  findNearest,
@@ -71,8 +71,9 @@ interface Props
71
71
  readonly dataTable?: boolean;
72
72
  /**
73
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.
74
+ * duration in milliseconds, `{ duration, bounce, stagger }`, or
75
+ * `tween({ duration, easing, stagger })`. `false` snaps; reduced motion
76
+ * always does.
76
77
  */
77
78
  readonly animate?: boolean | number | ChartAnimation;
78
79
  /**
@@ -179,10 +180,14 @@ export function Chart({
179
180
  () => [definition, hidden, keyboardZooms],
180
181
  [definition, hidden, keyboardZooms],
181
182
  );
182
- // An inline `animate={{ … }}` object is read by value, not identity.
183
+ // An inline `animate={{ … }}` or `tween({ … })` is read by value, not
184
+ // identity: a new object each render must not restart the motion.
185
+ const latestAnimate = React.useRef(animate);
186
+ latestAnimate.current = animate;
183
187
  const animateKey = JSON.stringify(animate);
188
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the value of `animate`.
184
189
  const timing = React.useMemo(
185
- () => chartTiming(JSON.parse(animateKey)),
190
+ () => chartTiming(latestAnimate.current),
186
191
  [animateKey],
187
192
  );
188
193
  const shown = useAnimatedScene(scene, animationTrigger, timing);