@voila.dev/chart 5.0.117 → 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.
- package/dist/core/motion/leaving.d.ts +2 -2
- package/dist/core/motion/leaving.d.ts.map +1 -1
- package/dist/core/motion/leaving.js +4 -11
- package/dist/core/motion/leaving.js.map +2 -2
- package/dist/core/motion/motion-store.d.ts.map +1 -1
- package/dist/core/motion/motion-store.js +58 -84
- package/dist/core/motion/motion-store.js.map +3 -3
- package/dist/core/motion/points-motion.d.ts.map +1 -1
- package/dist/core/motion/points-motion.js +9 -22
- package/dist/core/motion/points-motion.js.map +2 -2
- package/dist/core/motion/strings.d.ts +4 -2
- package/dist/core/motion/strings.d.ts.map +1 -1
- package/dist/core/motion/strings.js +5 -6
- package/dist/core/motion/strings.js.map +2 -2
- package/dist/core/motion/timing.d.ts +16 -18
- package/dist/core/motion/timing.d.ts.map +1 -1
- package/dist/core/motion/timing.js +9 -8
- package/dist/core/motion/timing.js.map +2 -2
- package/dist/core/motion/tween-timing.d.ts +17 -0
- package/dist/core/motion/tween-timing.d.ts.map +1 -0
- package/dist/core/motion/tween-timing.js +22 -0
- package/dist/core/motion/tween-timing.js.map +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +2 -2
- package/dist/react/chart.d.ts +3 -2
- package/dist/react/chart.d.ts.map +1 -1
- package/dist/react/chart.js +3 -1
- package/dist/react/chart.js.map +2 -2
- package/dist/react/use-animated-scene.d.ts.map +1 -1
- package/dist/react/use-animated-scene.js +4 -4
- package/dist/react/use-animated-scene.js.map +2 -2
- package/package.json +2 -2
- package/src/core/motion/leaving.ts +6 -12
- package/src/core/motion/motion-store.ts +65 -109
- package/src/core/motion/points-motion.ts +12 -24
- package/src/core/motion/strings.ts +9 -12
- package/src/core/motion/timing.ts +28 -33
- package/src/core/motion/tween-timing.ts +35 -0
- package/src/index.ts +2 -0
- package/src/react/chart.tsx +9 -4
- package/src/react/use-animated-scene.ts +6 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
const guides = new Map<string,
|
|
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 =
|
|
148
|
-
return motion ?
|
|
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 =
|
|
162
|
-
const velocity = running
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
generator:
|
|
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
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
266
|
-
if (motion) {
|
|
267
|
-
|
|
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 &&
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
320
|
-
(
|
|
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
|
|
331
|
-
|
|
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
|
-
[...
|
|
337
|
-
(key) => key.split(SEPARATOR)[0],
|
|
338
|
-
),
|
|
298
|
+
[...channels.keys()].map((key) => key.split(SEPARATOR)[0]),
|
|
339
299
|
);
|
|
340
|
-
for (const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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)?.
|
|
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
|
-
|
|
319
|
+
const fadingGuides = leavingGuides(target.nodes, (key) =>
|
|
361
320
|
after.byKey.has(key),
|
|
362
|
-
)
|
|
363
|
-
|
|
364
|
-
|
|
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 =
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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 [
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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(
|
|
117
|
-
fields(
|
|
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
|
|
57
|
-
export function
|
|
58
|
-
node:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/react/chart.tsx
CHANGED
|
@@ -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,
|
|
75
|
-
* `{
|
|
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={{ … }}`
|
|
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(
|
|
190
|
+
() => chartTiming(latestAnimate.current),
|
|
186
191
|
[animateKey],
|
|
187
192
|
);
|
|
188
193
|
const shown = useAnimatedScene(scene, animationTrigger, timing);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
69
|
-
return () => cancelFrame(tick);
|
|
71
|
+
return () => cancelAnimationFrame(request);
|
|
70
72
|
}, [scene, trigger, timing]);
|
|
71
73
|
|
|
72
74
|
return shown;
|