@voila.dev/chart 5.0.118 → 6.0.120

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/leaving.d.ts +2 -2
  2. package/dist/core/motion/leaving.d.ts.map +1 -1
  3. package/dist/core/motion/leaving.js +4 -11
  4. package/dist/core/motion/leaving.js.map +2 -2
  5. package/dist/core/motion/motion-store.d.ts.map +1 -1
  6. package/dist/core/motion/motion-store.js +58 -84
  7. package/dist/core/motion/motion-store.js.map +3 -3
  8. package/dist/core/motion/points-motion.d.ts.map +1 -1
  9. package/dist/core/motion/points-motion.js +9 -22
  10. package/dist/core/motion/points-motion.js.map +2 -2
  11. package/dist/core/motion/strings.d.ts +4 -2
  12. package/dist/core/motion/strings.d.ts.map +1 -1
  13. package/dist/core/motion/strings.js +5 -6
  14. package/dist/core/motion/strings.js.map +2 -2
  15. package/dist/core/motion/timing.d.ts +16 -18
  16. package/dist/core/motion/timing.d.ts.map +1 -1
  17. package/dist/core/motion/timing.js +9 -8
  18. package/dist/core/motion/timing.js.map +2 -2
  19. package/dist/core/motion/tween-timing.d.ts +17 -0
  20. package/dist/core/motion/tween-timing.d.ts.map +1 -0
  21. package/dist/core/motion/tween-timing.js +22 -0
  22. package/dist/core/motion/tween-timing.js.map +7 -0
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -0
  26. package/dist/index.js.map +2 -2
  27. package/dist/react/chart.d.ts +3 -2
  28. package/dist/react/chart.d.ts.map +1 -1
  29. package/dist/react/chart.js +3 -1
  30. package/dist/react/chart.js.map +2 -2
  31. package/dist/react/use-animated-scene.d.ts.map +1 -1
  32. package/dist/react/use-animated-scene.js +4 -4
  33. package/dist/react/use-animated-scene.js.map +2 -2
  34. package/package.json +2 -2
  35. package/src/core/motion/leaving.ts +6 -12
  36. package/src/core/motion/motion-store.ts +65 -109
  37. package/src/core/motion/points-motion.ts +12 -24
  38. package/src/core/motion/strings.ts +9 -12
  39. package/src/core/motion/timing.ts +28 -33
  40. package/src/core/motion/tween-timing.ts +35 -0
  41. package/src/index.ts +2 -0
  42. package/src/react/chart.tsx +9 -4
  43. package/src/react/use-animated-scene.ts +6 -4
@@ -1,4 +1,4 @@
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
3
  import {
4
4
  type LeavingGuide,
@@ -10,7 +10,7 @@ import {
10
10
  type StringChannel,
11
11
  stringMixer,
12
12
  stringOf,
13
- withStrings,
13
+ withString,
14
14
  } from "#/core/motion/strings.ts";
15
15
  import { type ChartTiming, staggerDelay } from "#/core/motion/timing.ts";
16
16
  import type {
@@ -43,16 +43,8 @@ interface Motion {
43
43
  readonly generator: Generator;
44
44
  /** Milliseconds, the delay included. */
45
45
  readonly start: number;
46
- }
47
-
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;
46
+ /** A path or a colour: the generator runs a progress from 0 to 1, and this reads it. */
47
+ readonly mix?: (progress: number) => string;
56
48
  }
57
49
 
58
50
  /** A data mark collapsing on its way out: drawn after its old sibling until its springs settle. */
@@ -105,6 +97,10 @@ function elapsed(motion: Motion, now: number): number {
105
97
  return Math.max(0, (now - motion.start) / 1000);
106
98
  }
107
99
 
100
+ function sample(motion: Motion, now: number) {
101
+ return motion.generator.at(elapsed(motion, now));
102
+ }
103
+
108
104
  function done(motion: Motion, now: number): boolean {
109
105
  return elapsed(motion, now) >= motion.generator.duration;
110
106
  }
@@ -118,34 +114,18 @@ export function createMotionStore(
118
114
  timing: ChartTiming,
119
115
  ): MotionStore {
120
116
  let target = initial;
121
- const numbers = new Map<string, Motion>();
122
- const strings = new Map<string, StringMotion>();
123
- const guides = new Map<string, GuideMotion>();
117
+ const channels = new Map<string, Motion>();
118
+ /** Fading out on their opacity channel. */
119
+ const guides = new Map<string, LeavingGuide>();
124
120
  const shapes = new Map<
125
121
  string,
126
122
  { readonly plan: GeometryPlan; readonly motion: GeometryMotion }
127
123
  >();
128
124
  const leaving = new Map<string, Leaving>();
129
125
 
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
126
  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;
127
+ const motion = channels.get(channelKey(node, channel));
128
+ return motion ? sample(motion, now).value : fallback;
149
129
  }
150
130
 
151
131
  /** Springs one channel from `from` to `to`, at the speed it already has. */
@@ -158,13 +138,11 @@ export function createMotionStore(
158
138
  now: number,
159
139
  ) {
160
140
  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);
141
+ const running = channels.get(key);
142
+ const velocity = running ? sample(running, now).velocity : 0;
143
+ channels.delete(key);
166
144
  if (from === to && velocity === 0) return;
167
- numbers.set(key, { generator: generator(from, to, velocity), start });
145
+ channels.set(key, { generator: timing.motion(from, to, velocity), start });
168
146
  }
169
147
 
170
148
  /** Mixes one string channel from what is painted to what `to` asks for. */
@@ -175,22 +153,20 @@ export function createMotionStore(
175
153
  start: number,
176
154
  ) {
177
155
  const key = channelKey(to.key, channel);
178
- strings.delete(key);
156
+ channels.delete(key);
179
157
  const was = stringOf(from, channel);
180
158
  const goal = stringOf(to, channel);
181
159
  if (was === undefined || goal === undefined || was === goal) return;
182
- strings.set(key, {
183
- generator: generator(0, 1, 0),
160
+ channels.set(key, {
161
+ generator: timing.motion(0, 1, 0),
184
162
  start,
185
163
  mix: stringMixer(to, channel, was, goal),
186
164
  });
187
165
  }
188
166
 
189
167
  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
- }
168
+ for (const key of channels.keys()) {
169
+ if (key.startsWith(node + SEPARATOR)) channels.delete(key);
194
170
  }
195
171
  shapes.delete(node);
196
172
  leaving.delete(node);
@@ -215,7 +191,7 @@ export function createMotionStore(
215
191
  if (to.kind !== "path" || from.kind !== "path") return;
216
192
  const previous = shapes.get(key);
217
193
  shapes.delete(key);
218
- strings.delete(channelKey(key, "d"));
194
+ channels.delete(channelKey(key, "d"));
219
195
  const motion = to.motion;
220
196
  const plan =
221
197
  motion && from.geometry && to.geometry
@@ -228,7 +204,7 @@ export function createMotionStore(
228
204
  }
229
205
  for (const channel of previous?.plan.targets.keys() ?? []) {
230
206
  if (!plan.targets.has(channel)) {
231
- numbers.delete(channelKey(key, `g${channel}`));
207
+ channels.delete(channelKey(key, `g${channel}`));
232
208
  }
233
209
  }
234
210
  for (const [channel, goal] of plan.targets) {
@@ -260,35 +236,28 @@ export function createMotionStore(
260
236
  );
261
237
  next = { ...next, geometry, d: shape.motion.draw(geometry) };
262
238
  }
263
- const mixed: Partial<Record<StringChannel, string>> = {};
264
239
  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
- );
240
+ const motion = channels.get(channelKey(key, channel));
241
+ if (motion?.mix) {
242
+ next = withString(next, channel, motion.mix(sample(motion, now).value));
270
243
  }
271
244
  }
272
- next = withStrings(next, mixed) as typeof next;
273
245
  const opacity = value(key, OPACITY, 1, now);
274
246
  const drawn = value(key, DRAWN, 1, now);
275
247
  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
248
  return {
280
249
  ...next,
281
250
  paint: {
282
251
  ...next.paint,
283
252
  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
- : {}),
253
+ ...(drawn < 1 &&
254
+ next.kind === "path" &&
255
+ next.geometry && {
256
+ drawn: {
257
+ fraction: clamp(drawn),
258
+ length: next.motion?.length?.(next.geometry) ?? 0,
259
+ },
260
+ }),
292
261
  },
293
262
  } as SceneNode;
294
263
  }
@@ -316,32 +285,22 @@ export function createMotionStore(
316
285
  place(node.key);
317
286
  }
318
287
  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,
288
+ return withLeaving(out, parent, guides.values(), (node) =>
289
+ paintNode(node, now),
325
290
  );
326
- return withLeaving(out, parent, fading);
327
291
  }
328
292
 
329
293
  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
- }
294
+ for (const [key, motion] of channels) {
295
+ if (done(motion, now)) channels.delete(key);
334
296
  }
335
297
  const moving = new Set(
336
- [...numbers.keys(), ...strings.keys()].map(
337
- (key) => key.split(SEPARATOR)[0],
338
- ),
298
+ [...channels.keys()].map((key) => key.split(SEPARATOR)[0]),
339
299
  );
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);
300
+ for (const byNode of [shapes, leaving, guides]) {
301
+ for (const key of byNode.keys()) {
302
+ if (!moving.has(key)) byNode.delete(key);
303
+ }
345
304
  }
346
305
  }
347
306
 
@@ -353,19 +312,15 @@ export function createMotionStore(
353
312
  const node =
354
313
  before.byKey.get(key) ??
355
314
  leaving.get(key)?.node ??
356
- guides.get(key)?.guide.node;
315
+ guides.get(key)?.node;
357
316
  return node && paintNode(node, now);
358
317
  }
359
318
  // Read before the exits below drop what the guides were showing.
360
- for (const guide of leavingGuides(target.nodes, (key) =>
319
+ const fadingGuides = leavingGuides(target.nodes, (key) =>
361
320
  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
- }
321
+ ).map(
322
+ (guide) => [guide, value(guide.node.key, OPACITY, 1, now)] as const,
323
+ );
369
324
  for (const key of new Set([...before.byKey.keys(), ...leaving.keys()])) {
370
325
  if (after.byKey.has(key)) continue;
371
326
  const base = (before.byKey.get(key) ??
@@ -386,6 +341,13 @@ export function createMotionStore(
386
341
  siblings,
387
342
  });
388
343
  }
344
+ for (const [guide, shown] of fadingGuides) {
345
+ guides.set(guide.node.key, guide);
346
+ channels.set(channelKey(guide.node.key, OPACITY), {
347
+ generator: timing.motion(shown, 0, 0),
348
+ start: now,
349
+ });
350
+ }
389
351
  const marks = [...after.byKey.values()].filter(
390
352
  (node) => node.role === "mark",
391
353
  );
@@ -401,14 +363,12 @@ export function createMotionStore(
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 (guides.has(node.key))
371
+ channels.delete(channelKey(node.key, OPACITY));
412
372
  spring1(node.key, OPACITY, shown, 1, start, now);
413
373
  }
414
374
  }
@@ -428,22 +388,18 @@ export function createMotionStore(
428
388
  return any;
429
389
  },
430
390
  snap(scene) {
431
- for (const motions of [numbers, strings, guides, shapes, leaving]) {
391
+ for (const motions of [channels, guides, shapes, leaving]) {
432
392
  motions.clear();
433
393
  }
434
394
  target = scene;
435
395
  },
436
396
  frame(now) {
437
397
  prune(now);
438
- if (numbers.size + strings.size + guides.size === 0) return target;
398
+ if (channels.size === 0) return target;
439
399
  return { ...target, nodes: paintList(target.nodes, null, now) };
440
400
  },
441
401
  settled(now) {
442
- return [
443
- ...numbers.values(),
444
- ...strings.values(),
445
- ...guides.values(),
446
- ].every((motion) => done(motion, now));
402
+ return [...channels.values()].every((motion) => done(motion, now));
447
403
  },
448
404
  };
449
405
  }
@@ -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);
@@ -1,4 +1,4 @@
1
- import { cancelFrame, frame, shouldReduceMotion } from "@voila.dev/motion";
1
+ import { shouldReduceMotion } from "@voila.dev/motion";
2
2
  import * as React from "react";
3
3
  import {
4
4
  createMotionStore,
@@ -53,20 +53,22 @@ export function useAnimatedScene(
53
53
  return;
54
54
  }
55
55
  if (changed) store.retarget(scene, performance.now());
56
+ // One chart needs one callback a frame: a plain requestAnimationFrame
57
+ // keeps motion's shared frame loop out of every chart bundle.
58
+ let request = 0;
56
59
  function tick() {
57
60
  const now = performance.now();
58
61
  if (store?.settled(now)) {
59
62
  // Only once it has played: StrictMode's rerun of the effect starts it again.
60
63
  introduced.current = true;
61
- cancelFrame(tick);
62
64
  setShown(scene);
63
65
  return;
64
66
  }
65
67
  setShown(store?.frame(now) ?? scene);
68
+ request = requestAnimationFrame(tick);
66
69
  }
67
70
  tick();
68
- frame.update(tick, true);
69
- return () => cancelFrame(tick);
71
+ return () => cancelAnimationFrame(request);
70
72
  }, [scene, trigger, timing]);
71
73
 
72
74
  return shown;