anim-engine 0.1.3 → 0.2.0
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/README.md +121 -100
- package/dist/animation/create-animation.d.ts +2 -1
- package/dist/animation/create-animation.js +84 -191
- package/dist/animation/runner.d.ts +37 -0
- package/dist/animation/runner.js +220 -0
- package/dist/shared/types.d.ts +0 -1
- package/dist/timeline/create-timeline.d.ts +5 -4
- package/dist/timeline/create-timeline.js +101 -76
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">anim-engine</h1>
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/anim-engine)
|
|
6
|
+
[](https://github.com/LukeCarlThompson/anim-engine/blob/main/packages/anim-engine/LICENSE)
|
|
7
|
+
[](https://github.com/LukeCarlThompson/anim-engine/actions)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
|
|
10
|
+
</div>
|
|
2
11
|
|
|
3
12
|
**Renderer-agnostic animation for JavaScript runtimes.** A fast, lightweight, pure-numeric animation engine.
|
|
4
13
|
|
|
@@ -33,7 +42,7 @@ ESM only. Tree-shakeable — import only what you use.
|
|
|
33
42
|
```ts
|
|
34
43
|
import { createAnimation, getTicker } from "anim-engine";
|
|
35
44
|
|
|
36
|
-
// The ticker drives all animations. Start it once.
|
|
45
|
+
// The ticker drives all animations. Start it once or drive it with an external ticker.
|
|
37
46
|
getTicker().start();
|
|
38
47
|
|
|
39
48
|
const anim = createAnimation({
|
|
@@ -63,28 +72,28 @@ By restricting itself to numeric values, the engine eliminates string parsing, c
|
|
|
63
72
|
|
|
64
73
|
## Key advantages
|
|
65
74
|
|
|
66
|
-
| |
|
|
67
|
-
| ---------------------------- |
|
|
68
|
-
| ⚡ **Fast** | 1.7–
|
|
69
|
-
| 🪶 **Lightweight** | Tree-shakeable ESM — import only what you use. No DOM, no canvas, no dependencies.
|
|
70
|
-
| 🎯 **Numbers only** | A single numeric type for all values. No strings, no transforms, no branches. Predictable performance.
|
|
71
|
-
| 🔌 **Renderer-agnostic** | Feed values to PixiJS, ThreeJS, DOM, canvas2d, or WebGL. Same API everywhere.
|
|
72
|
-
| 🧩 **Composable models** | Animations, keyframes, and timelines compose cleanly — put tweens inside timelines, nest keyframes anywhere.
|
|
73
|
-
| 🔄 **Continuous primitives** | Spring, smooth damp, and lerp chase live targets with zero setup — pass `() => value` for dynamic targets.
|
|
74
|
-
| 🚫 **No GC pressure** | Zero object allocations in hot update paths. State is mutated in place.
|
|
75
|
-
| 🎨 **Perceptual color** | Oklab interpolation via `lerpOklab` — no muddy browns. Compose it into any `onUpdate`.
|
|
76
|
-
| 📐 **TypeScript-first** | Full type exports, exhaustive discriminated unions, no `any`.
|
|
77
|
-
| 🔧 **Ticker control** | Bring your own game loop or use the built-in rAF ticker. Explicit — never auto-starts.
|
|
75
|
+
| | |
|
|
76
|
+
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
77
|
+
| ⚡ **Fast** | 1.7–7.5× faster than GSAP overall; 3.0–6.2× when comparing equivalent `onUpdate` callback dispatch (see [benchmarks](#benchmarks)). |
|
|
78
|
+
| 🪶 **Lightweight** | Tree-shakeable ESM — import only what you use. No DOM, no canvas, no dependencies. |
|
|
79
|
+
| 🎯 **Numbers only** | A single numeric type for all values. No strings, no transforms, no branches. Predictable performance. |
|
|
80
|
+
| 🔌 **Renderer-agnostic** | Feed values to PixiJS, ThreeJS, DOM, canvas2d, or WebGL. Same API everywhere. |
|
|
81
|
+
| 🧩 **Composable models** | Animations, keyframes, and timelines compose cleanly — put tweens inside timelines, nest keyframes anywhere. |
|
|
82
|
+
| 🔄 **Continuous primitives** | Spring, smooth damp, and lerp chase live targets with zero setup — pass `() => value` for dynamic targets. |
|
|
83
|
+
| 🚫 **No GC pressure** | Zero object allocations in hot update paths. State is mutated in place. |
|
|
84
|
+
| 🎨 **Perceptual color** | Oklab interpolation via `lerpOklab` — no muddy browns. Compose it into any `onUpdate`. |
|
|
85
|
+
| 📐 **TypeScript-first** | Full type exports, exhaustive discriminated unions, no `any`. |
|
|
86
|
+
| 🔧 **Ticker control** | Bring your own game loop or use the built-in rAF ticker. Explicit — never auto-starts. |
|
|
78
87
|
|
|
79
88
|
## Primitives
|
|
80
89
|
|
|
81
90
|
### Timed (return `Animation`)
|
|
82
91
|
|
|
83
|
-
| Primitive | Returns
|
|
84
|
-
| ------------------------------------------- |
|
|
85
|
-
| [`createAnimation`](#createanimation) | `Animation`
|
|
86
|
-
| [`createAnimation` (keyframes)](#keyframes) | `Animation`
|
|
87
|
-
| [`createTimeline`](#createtimeline) | `Timeline`
|
|
92
|
+
| Primitive | Returns | Description |
|
|
93
|
+
| ------------------------------------------- | ----------- | ---------------------------------------------------- |
|
|
94
|
+
| [`createAnimation`](#createanimation) | `Animation` | Timed tween from A to B with easing and delay |
|
|
95
|
+
| [`createAnimation` (keyframes)](#keyframes) | `Animation` | Multi-segment interpolation with per-segment easing |
|
|
96
|
+
| [`createTimeline`](#createtimeline) | `Timeline` | Orchestrate multiple animations on a shared timeline |
|
|
88
97
|
|
|
89
98
|
Animations have `play()`/`pause()`/`resume()`/`stop()`/`skipToEnd()`/`kill()` controls, return a `Promise` from `play()`, and emit `onUpdate`/`onEnded`/`onProgress` callbacks.
|
|
90
99
|
|
|
@@ -135,46 +144,48 @@ anim.skipToEnd(); // jumps to end, resolves promise
|
|
|
135
144
|
|
|
136
145
|
**Single-tween options:**
|
|
137
146
|
|
|
138
|
-
| Option | Type
|
|
139
|
-
| ------------ |
|
|
140
|
-
| `from` | `number \| () => number`
|
|
141
|
-
| `to` | `number \| () => number`
|
|
142
|
-
| `durationMs` | `number \| () => number`
|
|
143
|
-
| `ease` | `EaseName \| EaseFunction`
|
|
144
|
-
| `onStarted` | `() => void`
|
|
145
|
-
| `onUpdate` | `(value: number, velocity: number) => void`
|
|
146
|
-
| `onEnded` | `() => void`
|
|
147
|
+
| Option | Type | Default | Description |
|
|
148
|
+
| ------------ | ------------------------------------------- | ------------- | ------------------------------------------------------------ |
|
|
149
|
+
| `from` | `number \| () => number` | — | Start value |
|
|
150
|
+
| `to` | `number \| () => number` | — | End value |
|
|
151
|
+
| `durationMs` | `number \| () => number` | — | Duration in milliseconds |
|
|
152
|
+
| `ease` | `EaseName \| EaseFunction` | `"inOutSine"` | Easing function or name |
|
|
153
|
+
| `onStarted` | `() => void` | — | Called when playback begins |
|
|
154
|
+
| `onUpdate` | `(value: number, velocity: number) => void` | — | Called every frame with current value and velocity (units/s) |
|
|
155
|
+
| `onEnded` | `() => void` | — | Called when animation completes |
|
|
156
|
+
|
|
147
157
|
**Returns:** `Animation`
|
|
148
158
|
|
|
149
159
|
### Keyframes
|
|
150
160
|
|
|
151
|
-
Multi-segment animation with per-keyframe easing. The
|
|
161
|
+
Multi-segment animation with per-keyframe easing. The first keyframe is the starting point (no gap/ease). Each subsequent keyframe specifies the gap from the previous one and an optional easing. Total duration is the sum of all gaps.
|
|
152
162
|
|
|
153
163
|
```ts
|
|
154
164
|
import { createAnimation } from "anim-engine";
|
|
155
165
|
|
|
156
166
|
const anim = createAnimation({
|
|
157
167
|
keyframes: [
|
|
158
|
-
{
|
|
159
|
-
{
|
|
160
|
-
{
|
|
161
|
-
{
|
|
168
|
+
{ value: 0 },
|
|
169
|
+
{ value: 50, gap: 300, ease: "outCubic" },
|
|
170
|
+
{ value: 80, gap: 400, ease: "inOutQuad" },
|
|
171
|
+
{ value: 100, gap: 300 },
|
|
162
172
|
],
|
|
163
173
|
onUpdate: (value) => (sprite.x = value),
|
|
164
174
|
onProgress: (progress) => console.log(`${Math.round(progress * 100)}%`),
|
|
165
175
|
});
|
|
166
176
|
```
|
|
167
177
|
|
|
168
|
-
Each keyframe's `
|
|
178
|
+
Each keyframe's `gap` is in milliseconds from the previous keyframe. Total duration is the sum of all gaps (1000ms in this example). If no `ease` is specified on a keyframe, `"inOutSine"` is used as the default.
|
|
169
179
|
|
|
170
180
|
**Keyframe options:**
|
|
171
181
|
|
|
172
|
-
| Option | Type | Description
|
|
173
|
-
| ------------ | ------------------------------------------- |
|
|
174
|
-
| `keyframes` | `Keyframe[]` | Array of `{
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
182
|
+
| Option | Type | Description |
|
|
183
|
+
| ------------ | ------------------------------------------- | -------------------------------------------------- |
|
|
184
|
+
| `keyframes` | `Keyframe[]` | Array of `{ value, gap?, ease? }` keyframes |
|
|
185
|
+
| `onStarted` | `() => void` | Called when playback begins |
|
|
186
|
+
| `onUpdate` | `(value: number, velocity: number) => void` | Called every frame with current value and velocity |
|
|
187
|
+
| `onProgress` | `(progress: number) => void` | Called every frame with 0–1 global progress |
|
|
188
|
+
| `onEnded` | `() => void` | Called when the keyframe animation completes |
|
|
178
189
|
|
|
179
190
|
**Returns:** `Animation`
|
|
180
191
|
|
|
@@ -217,45 +228,38 @@ for (let i = 0; i < 6; i++) {
|
|
|
217
228
|
For more complex sequences, use `createTimeline`:
|
|
218
229
|
|
|
219
230
|
```ts
|
|
220
|
-
import {
|
|
221
|
-
|
|
222
|
-
const fadeIn = createAnimation({ from: 0, to: 1, durationMs: 300 });
|
|
223
|
-
const fadeOut = createAnimation({ from: 1, to: 0, durationMs: 300 });
|
|
231
|
+
import { createTimeline } from "anim-engine";
|
|
224
232
|
|
|
225
233
|
const flash = createTimeline([
|
|
226
|
-
{ at: 0,
|
|
227
|
-
{ gap: 0,
|
|
234
|
+
{ at: 0, keyframe: { keyframes: [{ value: 0 }, { value: 1, gap: 300 }] } },
|
|
235
|
+
{ gap: 0, keyframe: { keyframes: [{ value: 1 }, { value: 0, gap: 300 }] } },
|
|
228
236
|
]);
|
|
229
237
|
await flash.play();
|
|
230
238
|
```
|
|
231
239
|
|
|
232
|
-
|
|
233
240
|
### createTimeline
|
|
234
241
|
|
|
235
|
-
Compose multiple animations on a shared timeline with `at` or `gap` positions.
|
|
242
|
+
Compose multiple keyframe animations on a shared timeline with `at` or `gap` positions. Parallelism comes from multiple layers at the same `at`.
|
|
236
243
|
|
|
237
244
|
```ts
|
|
238
|
-
import {
|
|
239
|
-
|
|
240
|
-
const fadeIn = createAnimation({
|
|
241
|
-
from: 0,
|
|
242
|
-
to: 1,
|
|
243
|
-
durationMs: 500,
|
|
244
|
-
onUpdate: (v) => (sprite.alpha = v),
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
const slideIn = createAnimation({
|
|
248
|
-
from: -100,
|
|
249
|
-
to: 0,
|
|
250
|
-
durationMs: 800,
|
|
251
|
-
ease: "outBack",
|
|
252
|
-
onUpdate: (v) => (sprite.x = v),
|
|
253
|
-
});
|
|
245
|
+
import { createTimeline } from "anim-engine";
|
|
254
246
|
|
|
255
247
|
const timeline = createTimeline(
|
|
256
248
|
[
|
|
257
|
-
{
|
|
258
|
-
|
|
249
|
+
{
|
|
250
|
+
at: 0,
|
|
251
|
+
keyframe: {
|
|
252
|
+
keyframes: [{ value: 0 }, { value: 1, gap: 500 }],
|
|
253
|
+
onUpdate: (v) => (sprite.alpha = v),
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
at: 0,
|
|
258
|
+
keyframe: {
|
|
259
|
+
keyframes: [{ value: -100 }, { value: 0, gap: 800, ease: "outBack" }],
|
|
260
|
+
onUpdate: (v) => (sprite.x = v),
|
|
261
|
+
},
|
|
262
|
+
},
|
|
259
263
|
],
|
|
260
264
|
{
|
|
261
265
|
onProgress: (progress) => console.log(`overall: ${progress}`),
|
|
@@ -269,8 +273,8 @@ timeline.play();
|
|
|
269
273
|
|
|
270
274
|
```ts
|
|
271
275
|
type TimelineLayer =
|
|
272
|
-
| {
|
|
273
|
-
| {
|
|
276
|
+
| { keyframe: KeyframedAnimationOptions; at: DynamicValue }
|
|
277
|
+
| { keyframe: KeyframedAnimationOptions; gap: number };
|
|
274
278
|
```
|
|
275
279
|
|
|
276
280
|
| Parameter | Type | Description |
|
|
@@ -280,7 +284,7 @@ type TimelineLayer =
|
|
|
280
284
|
| `options.onProgress` | `(progress) => void` | Called every frame with overall 0–1 progress |
|
|
281
285
|
| `options.onEnded` | `() => void` | Called when timeline finishes |
|
|
282
286
|
|
|
283
|
-
`gap` is relative to the end of
|
|
287
|
+
`gap` is relative to the end of the previous layer. Use multiple layers at the same `at` for parallel animations. A simple tween is expressed as a 2-keyframe sequence: `{ value: from }, { value: to, gap: durationMs }`.
|
|
284
288
|
|
|
285
289
|
### Continuous primitives
|
|
286
290
|
|
|
@@ -379,8 +383,8 @@ Perceptually uniform Oklab interpolation. Straight RGB lerp produces muddy trans
|
|
|
379
383
|
```ts
|
|
380
384
|
import { lerpOklab, hexToRgba } from "anim-engine";
|
|
381
385
|
|
|
382
|
-
hexToRgba("#ff6b6b");
|
|
383
|
-
hexToRgba("#f80");
|
|
386
|
+
hexToRgba("#ff6b6b"); // → [1, 0.42, 0.42, 1]
|
|
387
|
+
hexToRgba("#f80"); // → [1, 0.533, 0, 1] (shorthand)
|
|
384
388
|
hexToRgba("#ff804080"); // → [1, 0.502, 0.251, 0.502] (with alpha)
|
|
385
389
|
|
|
386
390
|
const fromColor = hexToRgba("#ff6b6b");
|
|
@@ -517,7 +521,7 @@ In `createSpring`, `createSmoothDamp`, and `createLerp`, dynamic values are reso
|
|
|
517
521
|
```ts
|
|
518
522
|
const spring = createSpring({
|
|
519
523
|
to: () => getMousePosition(), // re-read every frame
|
|
520
|
-
stiffness: () => sliderValue,
|
|
524
|
+
stiffness: () => sliderValue, // dynamic stiffness
|
|
521
525
|
damping: () => dampingValue,
|
|
522
526
|
});
|
|
523
527
|
```
|
|
@@ -526,18 +530,34 @@ This distinction reflects the two use-cases: timed animations describe a fixed m
|
|
|
526
530
|
|
|
527
531
|
## Benchmarks
|
|
528
532
|
|
|
529
|
-
Performance comparison against GSAP (vitest bench, Apple Silicon M-series, Node 24).
|
|
533
|
+
Performance comparison against GSAP (vitest bench, Apple Silicon M-series, Node 24). All easing functions are matched between libraries.
|
|
534
|
+
|
|
535
|
+
### vs GSAP internal mutation
|
|
536
|
+
|
|
537
|
+
GSAP defaults to mutating target properties directly. This is faster than dispatching callbacks but couples GSAP to the mutation pattern. Anim-engine always dispatches via `onUpdate` (renderer-agnostic).
|
|
538
|
+
|
|
539
|
+
| Benchmark | anim-engine | GSAP | Ratio |
|
|
540
|
+
| --------------------------------------------- | ------------ | ------------ | ----------- |
|
|
541
|
+
| **Single tween** (cubic, 1000 frames) | 40,814 ops/s | 10,776 ops/s | 3.8× faster |
|
|
542
|
+
| **Single tween** (linear, 1000 frames) | 28,590 ops/s | 14,499 ops/s | 2.0× faster |
|
|
543
|
+
| **Single tween** (cubic bezier, 1000 frames) | 21,449 ops/s | 12,637 ops/s | 1.7× faster |
|
|
544
|
+
| **Keyframe** (3 segments, 1000 frames) | 26,741 ops/s | 3,549 ops/s | 7.5× faster |
|
|
545
|
+
| **50 concurrent tweens** (500 frames) | 892 ops/s | 438 ops/s | 2.0× faster |
|
|
546
|
+
| **200 concurrent tweens** (500 frames) | 200 ops/s | 106 ops/s | 1.9× faster |
|
|
547
|
+
| **1000 concurrent tweens** (500 frames) | 38 ops/s | 22 ops/s | 1.7× faster |
|
|
548
|
+
| **50 concurrent keyframes** (500 frames) | 934 ops/s | 174 ops/s | 5.4× faster |
|
|
549
|
+
| **50-layer timeline** (staggered, 500 frames) | 710 ops/s | 404 ops/s | 1.8× faster |
|
|
550
|
+
| **50 tweens re-play** (2 cycles, 500 frames) | 558 ops/s | 128 ops/s | 4.4× faster |
|
|
551
|
+
|
|
552
|
+
### vs GSAP onUpdate (fair comparison)
|
|
530
553
|
|
|
531
|
-
|
|
532
|
-
| -------------------------------------------- | ------------ | ------------ | ------------ |
|
|
533
|
-
| **Single tween** (cubic, 1000 frames) | 43,265 ops/s | 9,542 ops/s | 4.53× faster |
|
|
534
|
-
| **Single tween** (linear, 1000 frames) | 30,461 ops/s | 16,195 ops/s | 1.88× faster |
|
|
535
|
-
| **Single tween** (cubic bezier, 1000 frames) | 21,439 ops/s | 12,869 ops/s | 1.67× faster |
|
|
536
|
-
| **Keyframe** (3 segments, 1000 frames) | 35,307 ops/s | 3,560 ops/s | 9.92× faster |
|
|
537
|
-
| **50 concurrent tweens** (500 frames) | 942 ops/s | 473 ops/s | 1.99× faster |
|
|
538
|
-
| **50 concurrent keyframes** (500 frames) | 918 ops/s | 171 ops/s | 5.38× faster |
|
|
554
|
+
gsap also supports `onUpdate` callbacks, which is equivalent to anim-engine's renderer-agnostic model. This is the apples-to-apples comparison.
|
|
539
555
|
|
|
540
|
-
|
|
556
|
+
| Benchmark | anim-engine | GSAP (onUpdate) | Ratio |
|
|
557
|
+
| -------------------------------------- | ------------ | --------------- | ----------- |
|
|
558
|
+
| **Single tween** (cubic, 1000 frames) | 40,814 ops/s | 6,618 ops/s | 6.2× faster |
|
|
559
|
+
| **50 concurrent tweens** (500 frames) | 892 ops/s | 273 ops/s | 3.3× faster |
|
|
560
|
+
| **200 concurrent tweens** (500 frames) | 200 ops/s | 66 ops/s | 3.0× faster |
|
|
541
561
|
|
|
542
562
|
Run locally: `npm run bench`
|
|
543
563
|
|
|
@@ -643,24 +663,25 @@ requestAnimationFrame(gameLoop);
|
|
|
643
663
|
|
|
644
664
|
### Type exports
|
|
645
665
|
|
|
646
|
-
| Type
|
|
647
|
-
|
|
|
648
|
-
| `Animation`
|
|
649
|
-
| `Interpolation`
|
|
650
|
-
| `Timeline`
|
|
651
|
-
| `EaseName`
|
|
652
|
-
| `EaseFunction`
|
|
653
|
-
| `DynamicValue`
|
|
654
|
-
| `AnimationStatus`
|
|
655
|
-
| `InterpolationStatus`
|
|
656
|
-
| `AnimationOptions`
|
|
657
|
-
| `Keyframe`
|
|
658
|
-
| `
|
|
659
|
-
| `
|
|
660
|
-
| `
|
|
661
|
-
| `
|
|
662
|
-
| `
|
|
663
|
-
| `
|
|
666
|
+
| Type | Description |
|
|
667
|
+
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
668
|
+
| `Animation` | `play`, `pause`, `resume`, `stop`, `skipToEnd`, `kill`, `setProgress`, `currentValue`, `velocity`, `progress`, `status` |
|
|
669
|
+
| `Interpolation` | `start`, `stop`, `kill`, `setCurrentValue`, `currentValue`, `velocity`, `status` |
|
|
670
|
+
| `Timeline` | `play`, `pause`, `resume`, `stop`, `skipToEnd`, `kill`, `setProgress`, `progress`, `status` |
|
|
671
|
+
| `EaseName` | Union of 31 ease name strings |
|
|
672
|
+
| `EaseFunction` | `(t: number) => number` |
|
|
673
|
+
| `DynamicValue` | `number \| (() => number)` |
|
|
674
|
+
| `AnimationStatus` | `"playing" \| "paused" \| "stopped" \| "dead"` (for `Animation` / `Timeline`) |
|
|
675
|
+
| `InterpolationStatus` | `"active" \| "inactive" \| "dead"` (for `Interpolation`) |
|
|
676
|
+
| `AnimationOptions` | Single tween or keyframe animation options (discriminated union) |
|
|
677
|
+
| `Keyframe` | `{ value, gap?, ease? }` |
|
|
678
|
+
| `KeyframedAnimationOptions` | `{ keyframes: Keyframe[], onStarted?, onUpdate?, onProgress?, onEnded? }` |
|
|
679
|
+
| `TimelineLayer` | `{ keyframe: KeyframedAnimationOptions; at: DynamicValue } \| { keyframe: KeyframedAnimationOptions; gap: number }` |
|
|
680
|
+
| `SpringOptions` | `to`, `stiffness`, `damping`, `mass`, `precision?`, `onUpdate`, `onEnded` |
|
|
681
|
+
| `SmoothDampOptions` | `to`, `smoothTimeMs`, `maxSpeed?`, `precision?`, `onUpdate`, `onEnded` |
|
|
682
|
+
| `LerpOptions` | `to`, `smoothTimeMs`, `precision?`, `onUpdate`, `onEnded` |
|
|
683
|
+
| `RgbaTuple` | `readonly [number, number, number, number]` |
|
|
684
|
+
| `TickerControls` | `start`, `stop`, `update`, `add`, `remove` |
|
|
664
685
|
|
|
665
686
|
## License
|
|
666
687
|
|
|
@@ -9,12 +9,13 @@ export type SingleTweenOptions = {
|
|
|
9
9
|
onEnded?: () => void;
|
|
10
10
|
};
|
|
11
11
|
export type Keyframe = {
|
|
12
|
-
at: DynamicValue;
|
|
13
12
|
value: DynamicValue;
|
|
14
13
|
ease?: EaseName | EaseFunction;
|
|
14
|
+
gap?: DynamicValue;
|
|
15
15
|
};
|
|
16
16
|
export type KeyframedAnimationOptions = {
|
|
17
17
|
keyframes: Keyframe[];
|
|
18
|
+
onStarted?: () => void;
|
|
18
19
|
onUpdate?: (value: number, velocity: number) => void;
|
|
19
20
|
onProgress?: (progress: number) => void;
|
|
20
21
|
onEnded?: () => void;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { easingFunctions } from "../easing/easing.js";
|
|
2
2
|
import { getTicker } from "../ticker/get-ticker.js";
|
|
3
|
-
import {
|
|
3
|
+
import { createKeyframeRunner, createTweenRunner } from "./runner.js";
|
|
4
4
|
//#region src/animation/create-animation.ts
|
|
5
5
|
var resolveEasing = (ease) => typeof ease === "function" ? ease : easingFunctions[ease];
|
|
6
|
+
var resolveValue = (v) => typeof v === "function" ? v() : v;
|
|
6
7
|
var isKeyframeMode = (options) => {
|
|
7
8
|
return "keyframes" in options && Array.isArray(options.keyframes);
|
|
8
9
|
};
|
|
@@ -16,83 +17,75 @@ var createSingleTween = (options) => {
|
|
|
16
17
|
const rawFrom = options.from;
|
|
17
18
|
const rawTo = options.to;
|
|
18
19
|
const rawDurationMs = options.durationMs;
|
|
19
|
-
let
|
|
20
|
-
let cachedTo = typeof rawTo === "function" ? rawTo() : rawTo;
|
|
21
|
-
let cachedDurationMs = typeof rawDurationMs === "function" ? rawDurationMs() : rawDurationMs;
|
|
22
|
-
const state = {
|
|
23
|
-
progress: 0,
|
|
24
|
-
currentValue: 0,
|
|
25
|
-
velocity: 0
|
|
26
|
-
};
|
|
20
|
+
let cachedDurationMs = resolveValue(rawDurationMs);
|
|
27
21
|
let status = "stopped";
|
|
28
|
-
let stopped = false;
|
|
29
22
|
let resolvePromise;
|
|
30
23
|
const ticker = getTicker();
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
24
|
+
let runner;
|
|
25
|
+
const finish = () => {
|
|
26
|
+
status = "stopped";
|
|
27
|
+
ticker.remove(runner);
|
|
28
|
+
resolvePromise?.(controls);
|
|
29
|
+
resolvePromise = void 0;
|
|
30
|
+
};
|
|
31
|
+
const buildRunner = () => {
|
|
32
|
+
const from = resolveValue(rawFrom);
|
|
33
|
+
const to = resolveValue(rawTo);
|
|
34
|
+
cachedDurationMs = resolveValue(rawDurationMs);
|
|
35
|
+
return createTweenRunner({
|
|
36
|
+
from,
|
|
37
|
+
to,
|
|
38
|
+
durationMs: cachedDurationMs,
|
|
39
|
+
easeFn: resolveEasing(easeName),
|
|
40
|
+
onStarted,
|
|
41
|
+
onUpdate,
|
|
42
|
+
onEnded,
|
|
43
|
+
onComplete: finish
|
|
44
|
+
});
|
|
36
45
|
};
|
|
37
|
-
|
|
46
|
+
const hasDynamic = typeof rawFrom === "function" || typeof rawTo === "function" || typeof rawDurationMs === "function";
|
|
47
|
+
runner = buildRunner();
|
|
38
48
|
const play = () => {
|
|
39
49
|
if (status === "dead") throw new Error("Cannot play a dead animation");
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
cachedFrom = typeof rawFrom === "function" ? rawFrom() : rawFrom;
|
|
43
|
-
cachedTo = typeof rawTo === "function" ? rawTo() : rawTo;
|
|
44
|
-
cachedDurationMs = typeof rawDurationMs === "function" ? rawDurationMs() : rawDurationMs;
|
|
45
|
-
state.currentValue = cachedFrom;
|
|
46
|
-
state.velocity = 0;
|
|
50
|
+
if (hasDynamic) runner = buildRunner();
|
|
51
|
+
else runner.reset();
|
|
47
52
|
const promise = new Promise((resolve) => {
|
|
48
53
|
resolvePromise = resolve;
|
|
49
54
|
});
|
|
50
55
|
status = "playing";
|
|
51
|
-
ticker.add(
|
|
56
|
+
ticker.add(runner);
|
|
52
57
|
onStarted?.();
|
|
53
58
|
return promise;
|
|
54
59
|
};
|
|
55
60
|
const pause = () => {
|
|
56
61
|
if (status !== "playing") return;
|
|
57
62
|
status = "paused";
|
|
58
|
-
ticker.remove(
|
|
63
|
+
ticker.remove(runner);
|
|
59
64
|
};
|
|
60
65
|
const resume = () => {
|
|
61
66
|
if (status !== "paused") return;
|
|
62
67
|
status = "playing";
|
|
63
|
-
ticker.add(
|
|
68
|
+
ticker.add(runner);
|
|
64
69
|
};
|
|
65
70
|
const stop = () => {
|
|
66
|
-
stopped = true;
|
|
67
71
|
status = "stopped";
|
|
68
|
-
ticker.remove(
|
|
72
|
+
ticker.remove(runner);
|
|
69
73
|
resolvePromise?.(controls);
|
|
70
74
|
resolvePromise = void 0;
|
|
71
75
|
};
|
|
72
76
|
const skipToEnd = () => {
|
|
73
|
-
|
|
74
|
-
state.velocity = 0;
|
|
75
|
-
state.progress = 1;
|
|
76
|
-
onUpdate?.(state.currentValue, state.velocity);
|
|
77
|
+
runner.evaluate(1);
|
|
77
78
|
if (status === "playing" || status === "paused") onEnded?.();
|
|
78
79
|
status = "stopped";
|
|
79
|
-
ticker.remove(
|
|
80
|
+
ticker.remove(runner);
|
|
80
81
|
resolvePromise?.(controls);
|
|
81
82
|
resolvePromise = void 0;
|
|
82
83
|
};
|
|
83
84
|
const kill = () => {
|
|
84
85
|
status = "dead";
|
|
85
|
-
ticker.remove(
|
|
86
|
-
stopped = true;
|
|
86
|
+
ticker.remove(runner);
|
|
87
87
|
resolvePromise = void 0;
|
|
88
88
|
};
|
|
89
|
-
function handleCompletion() {
|
|
90
|
-
onEnded?.();
|
|
91
|
-
status = "stopped";
|
|
92
|
-
ticker.remove(update);
|
|
93
|
-
resolvePromise?.(controls);
|
|
94
|
-
resolvePromise = void 0;
|
|
95
|
-
}
|
|
96
89
|
const controls = {
|
|
97
90
|
play,
|
|
98
91
|
pause,
|
|
@@ -100,26 +93,18 @@ var createSingleTween = (options) => {
|
|
|
100
93
|
stop,
|
|
101
94
|
skipToEnd,
|
|
102
95
|
kill,
|
|
103
|
-
setCurrentValue: (value) => {
|
|
104
|
-
state.currentValue = value;
|
|
105
|
-
state.velocity = 0;
|
|
106
|
-
},
|
|
107
96
|
get currentValue() {
|
|
108
|
-
return
|
|
97
|
+
return runner.currentValue;
|
|
109
98
|
},
|
|
110
99
|
get velocity() {
|
|
111
|
-
return
|
|
100
|
+
return runner.velocity;
|
|
112
101
|
},
|
|
113
102
|
get progress() {
|
|
114
|
-
return
|
|
103
|
+
return runner.progress;
|
|
115
104
|
},
|
|
116
105
|
setProgress(value) {
|
|
117
106
|
if (status === "playing") pause();
|
|
118
|
-
|
|
119
|
-
state.progress = clamped;
|
|
120
|
-
state.currentValue = cachedFrom + (cachedTo - cachedFrom) * currentEase(clamped);
|
|
121
|
-
state.velocity = 0;
|
|
122
|
-
onUpdate?.(state.currentValue, state.velocity);
|
|
107
|
+
runner.evaluate(Math.max(0, Math.min(1, value)));
|
|
123
108
|
},
|
|
124
109
|
get status() {
|
|
125
110
|
return status;
|
|
@@ -131,149 +116,80 @@ var createSingleTween = (options) => {
|
|
|
131
116
|
return controls;
|
|
132
117
|
};
|
|
133
118
|
var createKeyframeAnimation = (options) => {
|
|
134
|
-
const rawKeyframes = options
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const resolveKeyframeAt = (kf) => typeof kf.at === "function" ? kf.at() : kf.at;
|
|
140
|
-
let segments = [];
|
|
141
|
-
let prefixSum = [0];
|
|
142
|
-
let totalDurationMs = 0;
|
|
143
|
-
let invTotalDuration = 0;
|
|
144
|
-
const rebuild = () => {
|
|
145
|
-
const sorted = [...rawKeyframes].map((kf) => ({
|
|
146
|
-
...kf,
|
|
147
|
-
at: resolveKeyframeAt(kf)
|
|
148
|
-
})).sort((a, b) => a.at - b.at);
|
|
149
|
-
totalDurationMs = sorted[sorted.length - 1].at;
|
|
150
|
-
invTotalDuration = totalDurationMs > 0 ? 1 / totalDurationMs : 0;
|
|
151
|
-
segments = [];
|
|
152
|
-
for (let i = 0; i < sorted.length - 1; i++) {
|
|
153
|
-
const current = sorted[i];
|
|
154
|
-
const next = sorted[i + 1];
|
|
155
|
-
const from = resolveKeyframeValue(current);
|
|
156
|
-
const to = resolveKeyframeValue(next);
|
|
157
|
-
segments.push({
|
|
158
|
-
from,
|
|
159
|
-
to,
|
|
160
|
-
range: to - from,
|
|
161
|
-
durationMs: next.at - current.at,
|
|
162
|
-
easeFn: resolveEasing(next.ease ?? "inOutSine")
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
if (segments.length === 0) {
|
|
166
|
-
const v = resolveKeyframeValue(sorted[0]);
|
|
167
|
-
segments.push({
|
|
168
|
-
from: v,
|
|
169
|
-
to: v,
|
|
170
|
-
range: 0,
|
|
171
|
-
durationMs: totalDurationMs,
|
|
172
|
-
easeFn: resolveEasing("linear")
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
prefixSum = [0];
|
|
176
|
-
for (let i = 0; i < segments.length; i++) prefixSum.push(prefixSum[i] + segments[i].durationMs);
|
|
177
|
-
};
|
|
178
|
-
rebuild();
|
|
179
|
-
const state = {
|
|
180
|
-
progress: 0,
|
|
181
|
-
currentValue: 0,
|
|
182
|
-
velocity: 0
|
|
119
|
+
const { keyframes: rawKeyframes, onStarted, onUpdate, onProgress, onEnded } = options;
|
|
120
|
+
const resolveKeyframeGaps = () => {
|
|
121
|
+
let total = 0;
|
|
122
|
+
for (let i = 1; i < rawKeyframes.length; i++) total += resolveValue(rawKeyframes[i].gap ?? 0);
|
|
123
|
+
return total;
|
|
183
124
|
};
|
|
125
|
+
let cachedDurationMs = resolveKeyframeGaps();
|
|
184
126
|
let status = "stopped";
|
|
185
|
-
let stopped = false;
|
|
186
127
|
let resolvePromise;
|
|
187
|
-
let currentSegmentIndex = 0;
|
|
188
|
-
let previousValue = segments[0].from;
|
|
189
128
|
const ticker = getTicker();
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const eased = segment.easeFn(segmentProgress);
|
|
197
|
-
previousValue = state.currentValue;
|
|
198
|
-
state.currentValue = segment.from + segment.range * eased;
|
|
199
|
-
if (segmentProgress >= 1) {
|
|
200
|
-
state.currentValue = segment.to;
|
|
201
|
-
state.velocity = 0;
|
|
202
|
-
} else state.velocity = (state.currentValue - previousValue) / (deltaMs / 1e3);
|
|
203
|
-
const elapsedTotal = prefixSum[currentSegmentIndex] + segmentElapsed;
|
|
204
|
-
state.progress = Math.min(elapsedTotal * invTotalDuration, 1);
|
|
205
|
-
onProgress?.(state.progress);
|
|
206
|
-
onUpdate?.(state.currentValue, state.velocity);
|
|
207
|
-
if (segmentProgress >= 1) if (currentSegmentIndex < segments.length - 1) {
|
|
208
|
-
currentSegmentIndex++;
|
|
209
|
-
segmentElapsed = 0;
|
|
210
|
-
segmentProgress = 0;
|
|
211
|
-
state.currentValue = segments[currentSegmentIndex].from;
|
|
212
|
-
previousValue = state.currentValue;
|
|
213
|
-
state.velocity = 0;
|
|
214
|
-
state.progress = Math.min(prefixSum[currentSegmentIndex] * invTotalDuration, 1);
|
|
215
|
-
onProgress?.(state.progress);
|
|
216
|
-
} else {
|
|
217
|
-
status = "stopped";
|
|
218
|
-
ticker.remove(update);
|
|
219
|
-
onEnded?.();
|
|
220
|
-
resolvePromise?.(controls);
|
|
221
|
-
resolvePromise = void 0;
|
|
222
|
-
}
|
|
129
|
+
let runner;
|
|
130
|
+
const finish = () => {
|
|
131
|
+
status = "stopped";
|
|
132
|
+
ticker.remove(runner);
|
|
133
|
+
resolvePromise?.(controls);
|
|
134
|
+
resolvePromise = void 0;
|
|
223
135
|
};
|
|
224
|
-
|
|
225
|
-
|
|
136
|
+
const buildRunner = () => {
|
|
137
|
+
return createKeyframeRunner({
|
|
138
|
+
keyframes: rawKeyframes.map((kf, i) => ({
|
|
139
|
+
value: resolveValue(kf.value),
|
|
140
|
+
gap: i === 0 ? 0 : resolveValue(kf.gap ?? 0),
|
|
141
|
+
easeFn: resolveEasing(kf.ease ?? "inOutSine")
|
|
142
|
+
})),
|
|
143
|
+
onStarted,
|
|
144
|
+
onUpdate,
|
|
145
|
+
onProgress,
|
|
146
|
+
onEnded,
|
|
147
|
+
onComplete: finish
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
const hasDynamic = rawKeyframes.some((kf) => typeof kf.value === "function" || typeof kf.gap === "function");
|
|
151
|
+
runner = buildRunner();
|
|
226
152
|
const play = () => {
|
|
227
153
|
if (status === "dead") throw new Error("Cannot play a dead animation");
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
segmentProgress = 0;
|
|
233
|
-
state.progress = 0;
|
|
234
|
-
state.currentValue = segments[0].from;
|
|
235
|
-
previousValue = segments[0].from;
|
|
236
|
-
state.velocity = 0;
|
|
154
|
+
if (hasDynamic) {
|
|
155
|
+
runner = buildRunner();
|
|
156
|
+
cachedDurationMs = resolveKeyframeGaps();
|
|
157
|
+
} else runner.reset();
|
|
237
158
|
const promise = new Promise((resolve) => {
|
|
238
159
|
resolvePromise = resolve;
|
|
239
160
|
});
|
|
240
161
|
status = "playing";
|
|
241
|
-
ticker.add(
|
|
162
|
+
ticker.add(runner);
|
|
163
|
+
onStarted?.();
|
|
242
164
|
return promise;
|
|
243
165
|
};
|
|
244
166
|
const pause = () => {
|
|
245
167
|
if (status !== "playing") return;
|
|
246
168
|
status = "paused";
|
|
247
|
-
ticker.remove(
|
|
169
|
+
ticker.remove(runner);
|
|
248
170
|
};
|
|
249
171
|
const resume = () => {
|
|
250
172
|
if (status !== "paused") return;
|
|
251
173
|
status = "playing";
|
|
252
|
-
ticker.add(
|
|
174
|
+
ticker.add(runner);
|
|
253
175
|
};
|
|
254
176
|
const stop = () => {
|
|
255
|
-
stopped = true;
|
|
256
177
|
status = "stopped";
|
|
257
|
-
ticker.remove(
|
|
178
|
+
ticker.remove(runner);
|
|
258
179
|
resolvePromise?.(controls);
|
|
259
180
|
resolvePromise = void 0;
|
|
260
181
|
};
|
|
261
182
|
const skipToEnd = () => {
|
|
262
|
-
|
|
263
|
-
state.currentValue = last.to;
|
|
264
|
-
state.velocity = 0;
|
|
265
|
-
state.progress = 1;
|
|
266
|
-
onUpdate?.(state.currentValue, state.velocity);
|
|
183
|
+
runner.evaluate(1);
|
|
267
184
|
if (status === "playing" || status === "paused") onEnded?.();
|
|
268
185
|
status = "stopped";
|
|
269
|
-
ticker.remove(
|
|
186
|
+
ticker.remove(runner);
|
|
270
187
|
resolvePromise?.(controls);
|
|
271
188
|
resolvePromise = void 0;
|
|
272
189
|
};
|
|
273
190
|
const kill = () => {
|
|
274
191
|
status = "dead";
|
|
275
|
-
ticker.remove(
|
|
276
|
-
stopped = true;
|
|
192
|
+
ticker.remove(runner);
|
|
277
193
|
resolvePromise = void 0;
|
|
278
194
|
};
|
|
279
195
|
const controls = {
|
|
@@ -283,47 +199,24 @@ var createKeyframeAnimation = (options) => {
|
|
|
283
199
|
stop,
|
|
284
200
|
skipToEnd,
|
|
285
201
|
kill,
|
|
286
|
-
setCurrentValue: (value) => {
|
|
287
|
-
state.currentValue = value;
|
|
288
|
-
state.velocity = 0;
|
|
289
|
-
},
|
|
290
202
|
get currentValue() {
|
|
291
|
-
return
|
|
203
|
+
return runner.currentValue;
|
|
292
204
|
},
|
|
293
205
|
get velocity() {
|
|
294
|
-
return
|
|
206
|
+
return runner.velocity;
|
|
295
207
|
},
|
|
296
208
|
get progress() {
|
|
297
|
-
return
|
|
209
|
+
return runner.progress;
|
|
298
210
|
},
|
|
299
211
|
setProgress(value) {
|
|
300
212
|
if (status === "playing") pause();
|
|
301
|
-
|
|
302
|
-
state.progress = clamped;
|
|
303
|
-
const elapsed = clamped * totalDurationMs;
|
|
304
|
-
let segIdx = 0;
|
|
305
|
-
for (let i = 0; i < segments.length; i++) {
|
|
306
|
-
if (elapsed <= prefixSum[i + 1]) {
|
|
307
|
-
segIdx = i;
|
|
308
|
-
break;
|
|
309
|
-
}
|
|
310
|
-
segIdx = i;
|
|
311
|
-
}
|
|
312
|
-
const segment = segments[segIdx];
|
|
313
|
-
const segStart = prefixSum[segIdx];
|
|
314
|
-
const segDuration = segment.durationMs;
|
|
315
|
-
const segProgress = segDuration > 0 ? (elapsed - segStart) / segDuration : 1;
|
|
316
|
-
const eased = segment.easeFn(Math.max(0, Math.min(1, segProgress)));
|
|
317
|
-
state.currentValue = segment.from + segment.range * eased;
|
|
318
|
-
state.velocity = 0;
|
|
319
|
-
onUpdate?.(state.currentValue, state.velocity);
|
|
320
|
-
onProgress?.(clamped);
|
|
213
|
+
runner.evaluate(Math.max(0, Math.min(1, value)));
|
|
321
214
|
},
|
|
322
215
|
get status() {
|
|
323
216
|
return status;
|
|
324
217
|
},
|
|
325
218
|
get durationMs() {
|
|
326
|
-
return
|
|
219
|
+
return cachedDurationMs;
|
|
327
220
|
}
|
|
328
221
|
};
|
|
329
222
|
return controls;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { EaseFunction } from '../shared/types';
|
|
2
|
+
export type Runner = {
|
|
3
|
+
(deltaMs: number): boolean;
|
|
4
|
+
evaluate: (progress: number) => number;
|
|
5
|
+
reset: () => void;
|
|
6
|
+
currentValue: number;
|
|
7
|
+
velocity: number;
|
|
8
|
+
progress: number;
|
|
9
|
+
onStarted: (() => void) | undefined;
|
|
10
|
+
onUpdate: ((value: number, velocity: number) => void) | undefined;
|
|
11
|
+
onProgress: ((progress: number) => void) | undefined;
|
|
12
|
+
onEnded: (() => void) | undefined;
|
|
13
|
+
};
|
|
14
|
+
export type TweenRunnerConfig = {
|
|
15
|
+
from: number;
|
|
16
|
+
to: number;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
easeFn: EaseFunction;
|
|
19
|
+
onStarted?: () => void;
|
|
20
|
+
onUpdate?: (value: number, velocity: number) => void;
|
|
21
|
+
onEnded?: () => void;
|
|
22
|
+
onComplete?: () => void;
|
|
23
|
+
};
|
|
24
|
+
export declare const createTweenRunner: (config: TweenRunnerConfig) => Runner;
|
|
25
|
+
export type KeyframeRunnerConfig = {
|
|
26
|
+
keyframes: {
|
|
27
|
+
value: number;
|
|
28
|
+
gap: number;
|
|
29
|
+
easeFn: EaseFunction;
|
|
30
|
+
}[];
|
|
31
|
+
onStarted?: () => void;
|
|
32
|
+
onUpdate?: (value: number, velocity: number) => void;
|
|
33
|
+
onProgress?: (progress: number) => void;
|
|
34
|
+
onEnded?: () => void;
|
|
35
|
+
onComplete?: () => void;
|
|
36
|
+
};
|
|
37
|
+
export declare const createKeyframeRunner: (config: KeyframeRunnerConfig) => Runner;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { updateTween } from "./update.js";
|
|
2
|
+
//#region src/animation/runner.ts
|
|
3
|
+
var noop = () => {};
|
|
4
|
+
var createTweenRunner = (config) => {
|
|
5
|
+
const { from, to, durationMs, easeFn } = config;
|
|
6
|
+
const onStarted = config.onStarted;
|
|
7
|
+
const onUpdate = config.onUpdate ?? noop;
|
|
8
|
+
const onEnded = config.onEnded;
|
|
9
|
+
const onComplete = config.onComplete;
|
|
10
|
+
const state = {
|
|
11
|
+
progress: 0,
|
|
12
|
+
currentValue: from,
|
|
13
|
+
velocity: 0
|
|
14
|
+
};
|
|
15
|
+
let runner;
|
|
16
|
+
const step = (deltaMs) => {
|
|
17
|
+
const completed = updateTween(state, deltaMs, durationMs, easeFn, from, to);
|
|
18
|
+
onUpdate(state.currentValue, state.velocity);
|
|
19
|
+
if (completed) {
|
|
20
|
+
onEnded?.();
|
|
21
|
+
onComplete?.();
|
|
22
|
+
}
|
|
23
|
+
return completed;
|
|
24
|
+
};
|
|
25
|
+
const evaluate = (progress) => {
|
|
26
|
+
const clamped = Math.max(0, Math.min(1, progress));
|
|
27
|
+
state.progress = clamped;
|
|
28
|
+
if (clamped >= 1) {
|
|
29
|
+
state.currentValue = to;
|
|
30
|
+
state.velocity = 0;
|
|
31
|
+
} else {
|
|
32
|
+
const range = to - from;
|
|
33
|
+
state.currentValue = from + range * easeFn(clamped);
|
|
34
|
+
state.velocity = 0;
|
|
35
|
+
}
|
|
36
|
+
onUpdate(state.currentValue, state.velocity);
|
|
37
|
+
return state.currentValue;
|
|
38
|
+
};
|
|
39
|
+
const reset = () => {
|
|
40
|
+
state.progress = 0;
|
|
41
|
+
state.currentValue = from;
|
|
42
|
+
state.velocity = 0;
|
|
43
|
+
};
|
|
44
|
+
runner = step;
|
|
45
|
+
runner.evaluate = evaluate;
|
|
46
|
+
runner.reset = reset;
|
|
47
|
+
Object.defineProperty(runner, "currentValue", {
|
|
48
|
+
get: () => state.currentValue,
|
|
49
|
+
configurable: true
|
|
50
|
+
});
|
|
51
|
+
Object.defineProperty(runner, "velocity", {
|
|
52
|
+
get: () => state.velocity,
|
|
53
|
+
configurable: true
|
|
54
|
+
});
|
|
55
|
+
Object.defineProperty(runner, "progress", {
|
|
56
|
+
get: () => state.progress,
|
|
57
|
+
configurable: true
|
|
58
|
+
});
|
|
59
|
+
runner.onStarted = onStarted;
|
|
60
|
+
runner.onUpdate = config.onUpdate;
|
|
61
|
+
runner.onProgress = void 0;
|
|
62
|
+
runner.onEnded = onEnded;
|
|
63
|
+
return runner;
|
|
64
|
+
};
|
|
65
|
+
var createKeyframeRunner = (config) => {
|
|
66
|
+
const { keyframes } = config;
|
|
67
|
+
const onStarted = config.onStarted;
|
|
68
|
+
const onUpdate = config.onUpdate ?? noop;
|
|
69
|
+
const onProgress = config.onProgress ?? noop;
|
|
70
|
+
const onEnded = config.onEnded;
|
|
71
|
+
const onComplete = config.onComplete;
|
|
72
|
+
const segments = [];
|
|
73
|
+
for (let i = 0; i < keyframes.length - 1; i++) {
|
|
74
|
+
const cur = keyframes[i];
|
|
75
|
+
const nxt = keyframes[i + 1];
|
|
76
|
+
segments.push({
|
|
77
|
+
from: cur.value,
|
|
78
|
+
to: nxt.value,
|
|
79
|
+
range: nxt.value - cur.value,
|
|
80
|
+
durationMs: nxt.gap,
|
|
81
|
+
easeFn: nxt.easeFn
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const prefixSum = [0];
|
|
85
|
+
for (let i = 0; i < segments.length; i++) prefixSum.push(prefixSum[i] + segments[i].durationMs);
|
|
86
|
+
const totalDurationMs = prefixSum[prefixSum.length - 1];
|
|
87
|
+
const invTotalDuration = totalDurationMs > 0 ? 1 / totalDurationMs : 1;
|
|
88
|
+
let currentValue = keyframes[0].value;
|
|
89
|
+
let velocity = 0;
|
|
90
|
+
let globalProgress = 0;
|
|
91
|
+
let currentSegmentIndex = 0;
|
|
92
|
+
let segmentElapsed = 0;
|
|
93
|
+
let segmentProgress = 0;
|
|
94
|
+
let runner;
|
|
95
|
+
if (segments.length === 0) {
|
|
96
|
+
const complete = () => {
|
|
97
|
+
onEnded?.();
|
|
98
|
+
onComplete?.();
|
|
99
|
+
};
|
|
100
|
+
const step = (_deltaMs) => {
|
|
101
|
+
onUpdate(currentValue, 0);
|
|
102
|
+
onProgress(1);
|
|
103
|
+
complete();
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
const evaluate = (prog) => {
|
|
107
|
+
onUpdate(currentValue, 0);
|
|
108
|
+
onProgress(Math.max(0, Math.min(1, prog)));
|
|
109
|
+
return currentValue;
|
|
110
|
+
};
|
|
111
|
+
runner = step;
|
|
112
|
+
runner.evaluate = evaluate;
|
|
113
|
+
runner.reset = () => {};
|
|
114
|
+
Object.defineProperty(runner, "currentValue", {
|
|
115
|
+
get: () => currentValue,
|
|
116
|
+
configurable: true
|
|
117
|
+
});
|
|
118
|
+
Object.defineProperty(runner, "velocity", {
|
|
119
|
+
get: () => 0,
|
|
120
|
+
configurable: true
|
|
121
|
+
});
|
|
122
|
+
Object.defineProperty(runner, "progress", {
|
|
123
|
+
get: () => 1,
|
|
124
|
+
configurable: true
|
|
125
|
+
});
|
|
126
|
+
runner.onStarted = onStarted;
|
|
127
|
+
runner.onUpdate = config.onUpdate;
|
|
128
|
+
runner.onProgress = config.onProgress;
|
|
129
|
+
runner.onEnded = onEnded;
|
|
130
|
+
return runner;
|
|
131
|
+
}
|
|
132
|
+
const step = (deltaMs) => {
|
|
133
|
+
const segment = segments[currentSegmentIndex];
|
|
134
|
+
segmentElapsed += deltaMs;
|
|
135
|
+
segmentProgress += deltaMs / segment.durationMs;
|
|
136
|
+
if (segmentProgress >= 1) segmentProgress = 1;
|
|
137
|
+
const previousValue = currentValue;
|
|
138
|
+
const eased = segment.easeFn(segmentProgress);
|
|
139
|
+
currentValue = segment.from + segment.range * eased;
|
|
140
|
+
if (segmentProgress >= 1) {
|
|
141
|
+
currentValue = segment.to;
|
|
142
|
+
velocity = 0;
|
|
143
|
+
} else velocity = (currentValue - previousValue) / (deltaMs / 1e3);
|
|
144
|
+
const elapsedTotal = prefixSum[currentSegmentIndex] + segmentElapsed;
|
|
145
|
+
globalProgress = Math.min(elapsedTotal * invTotalDuration, 1);
|
|
146
|
+
onProgress(globalProgress);
|
|
147
|
+
onUpdate(currentValue, velocity);
|
|
148
|
+
if (segmentProgress >= 1) if (currentSegmentIndex < segments.length - 1) {
|
|
149
|
+
currentSegmentIndex++;
|
|
150
|
+
segmentElapsed = 0;
|
|
151
|
+
segmentProgress = 0;
|
|
152
|
+
currentValue = segments[currentSegmentIndex].from;
|
|
153
|
+
velocity = 0;
|
|
154
|
+
globalProgress = Math.min(prefixSum[currentSegmentIndex] * invTotalDuration, 1);
|
|
155
|
+
onProgress(globalProgress);
|
|
156
|
+
} else {
|
|
157
|
+
onEnded?.();
|
|
158
|
+
onComplete?.();
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
};
|
|
163
|
+
const evaluate = (progress) => {
|
|
164
|
+
const clamped = Math.max(0, Math.min(1, progress));
|
|
165
|
+
const elapsed = clamped * totalDurationMs;
|
|
166
|
+
let segIdx = 0;
|
|
167
|
+
for (let i = 0; i < segments.length; i++) {
|
|
168
|
+
if (elapsed <= prefixSum[i + 1]) {
|
|
169
|
+
segIdx = i;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
segIdx = i;
|
|
173
|
+
}
|
|
174
|
+
const segment = segments[segIdx];
|
|
175
|
+
const segStart = prefixSum[segIdx];
|
|
176
|
+
const segDuration = segment.durationMs;
|
|
177
|
+
const segProgress = segDuration > 0 ? (elapsed - segStart) / segDuration : 1;
|
|
178
|
+
const clampedSegProgress = Math.max(0, Math.min(1, segProgress));
|
|
179
|
+
const eased = segment.easeFn(clampedSegProgress);
|
|
180
|
+
currentValue = segment.from + segment.range * eased;
|
|
181
|
+
velocity = 0;
|
|
182
|
+
globalProgress = clamped;
|
|
183
|
+
currentSegmentIndex = segIdx;
|
|
184
|
+
segmentElapsed = elapsed - segStart;
|
|
185
|
+
segmentProgress = clampedSegProgress;
|
|
186
|
+
onUpdate(currentValue, velocity);
|
|
187
|
+
onProgress(clamped);
|
|
188
|
+
return currentValue;
|
|
189
|
+
};
|
|
190
|
+
const reset = () => {
|
|
191
|
+
currentValue = keyframes[0].value;
|
|
192
|
+
velocity = 0;
|
|
193
|
+
globalProgress = 0;
|
|
194
|
+
currentSegmentIndex = 0;
|
|
195
|
+
segmentElapsed = 0;
|
|
196
|
+
segmentProgress = 0;
|
|
197
|
+
};
|
|
198
|
+
runner = step;
|
|
199
|
+
runner.evaluate = evaluate;
|
|
200
|
+
runner.reset = reset;
|
|
201
|
+
Object.defineProperty(runner, "currentValue", {
|
|
202
|
+
get: () => currentValue,
|
|
203
|
+
configurable: true
|
|
204
|
+
});
|
|
205
|
+
Object.defineProperty(runner, "velocity", {
|
|
206
|
+
get: () => velocity,
|
|
207
|
+
configurable: true
|
|
208
|
+
});
|
|
209
|
+
Object.defineProperty(runner, "progress", {
|
|
210
|
+
get: () => globalProgress,
|
|
211
|
+
configurable: true
|
|
212
|
+
});
|
|
213
|
+
runner.onStarted = onStarted;
|
|
214
|
+
runner.onUpdate = config.onUpdate;
|
|
215
|
+
runner.onProgress = config.onProgress;
|
|
216
|
+
runner.onEnded = onEnded;
|
|
217
|
+
return runner;
|
|
218
|
+
};
|
|
219
|
+
//#endregion
|
|
220
|
+
export { createKeyframeRunner, createTweenRunner };
|
package/dist/shared/types.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { KeyframedAnimationOptions } from '../animation/create-animation';
|
|
2
|
+
import { AnimationStatus, DynamicValue } from '../shared/types';
|
|
2
3
|
export type TimelineLayer = {
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
keyframe: KeyframedAnimationOptions;
|
|
5
|
+
at: DynamicValue;
|
|
5
6
|
} | {
|
|
7
|
+
keyframe: KeyframedAnimationOptions;
|
|
6
8
|
gap: number;
|
|
7
|
-
animation: Animation | Animation[];
|
|
8
9
|
};
|
|
9
10
|
export type Timeline = {
|
|
10
11
|
play: () => Promise<Timeline>;
|
|
@@ -1,119 +1,144 @@
|
|
|
1
|
+
import { easingFunctions } from "../easing/easing.js";
|
|
1
2
|
import { getTicker } from "../ticker/get-ticker.js";
|
|
3
|
+
import { createKeyframeRunner } from "../animation/runner.js";
|
|
2
4
|
//#region src/timeline/create-timeline.ts
|
|
3
|
-
var
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
var noop = () => {};
|
|
6
|
+
var resolveValue = (v) => typeof v === "function" ? v() : v;
|
|
7
|
+
var resolveEasing = (ease) => typeof ease === "function" ? ease : easingFunctions[ease];
|
|
8
|
+
var buildFromConfigs = (rawLayers) => {
|
|
9
|
+
const activeLayers = [];
|
|
10
|
+
let previousEndAt = 0;
|
|
11
|
+
for (const layer of rawLayers) {
|
|
12
|
+
const startAt = "at" in layer ? resolveValue(layer.at) : previousEndAt + layer.gap;
|
|
13
|
+
const resolvedKeyframes = layer.keyframe.keyframes.map((kf, j) => ({
|
|
14
|
+
value: resolveValue(kf.value),
|
|
15
|
+
gap: j === 0 ? 0 : resolveValue(kf.gap ?? 0),
|
|
16
|
+
easeFn: resolveEasing(kf.ease ?? "inOutSine")
|
|
17
|
+
}));
|
|
18
|
+
let layerDuration = 0;
|
|
19
|
+
for (let i = 1; i < resolvedKeyframes.length; i++) layerDuration += resolvedKeyframes[i].gap;
|
|
20
|
+
const runner = createKeyframeRunner({
|
|
21
|
+
keyframes: resolvedKeyframes,
|
|
22
|
+
onStarted: layer.keyframe.onStarted,
|
|
23
|
+
onUpdate: layer.keyframe.onUpdate,
|
|
24
|
+
onProgress: layer.keyframe.onProgress,
|
|
25
|
+
onEnded: layer.keyframe.onEnded
|
|
26
|
+
});
|
|
27
|
+
activeLayers.push({
|
|
13
28
|
startAt,
|
|
14
|
-
endAt,
|
|
15
|
-
|
|
29
|
+
endAt: startAt + layerDuration,
|
|
30
|
+
runner,
|
|
31
|
+
started: false,
|
|
32
|
+
ended: false
|
|
16
33
|
});
|
|
17
|
-
|
|
34
|
+
previousEndAt = startAt + layerDuration;
|
|
18
35
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
36
|
+
activeLayers.sort((a, b) => a.startAt - b.startAt);
|
|
37
|
+
return {
|
|
38
|
+
activeLayers,
|
|
39
|
+
totalDurationMs: Math.max(0, ...activeLayers.map((l) => l.endAt))
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
var createTimeline = (layers, options) => {
|
|
43
|
+
const { onStarted, onEnded } = options ?? {};
|
|
44
|
+
const onProgress = options?.onProgress ?? noop;
|
|
45
|
+
const rawLayers = layers;
|
|
46
|
+
let state = buildFromConfigs(rawLayers);
|
|
47
|
+
let activeLayers = state.activeLayers;
|
|
48
|
+
let totalDurationMs = state.totalDurationMs;
|
|
49
|
+
let timelineStatus = "stopped";
|
|
22
50
|
let elapsedMs = 0;
|
|
23
51
|
let resolvePromise;
|
|
24
|
-
let
|
|
52
|
+
let remainingLayers = activeLayers.length;
|
|
25
53
|
const ticker = getTicker();
|
|
54
|
+
const finish = () => {
|
|
55
|
+
timelineStatus = "stopped";
|
|
56
|
+
ticker.remove(update);
|
|
57
|
+
onEnded?.();
|
|
58
|
+
resolvePromise?.(timeline);
|
|
59
|
+
resolvePromise = void 0;
|
|
60
|
+
};
|
|
61
|
+
const update = (deltaMs) => {
|
|
62
|
+
elapsedMs += deltaMs;
|
|
63
|
+
for (const layer of activeLayers) {
|
|
64
|
+
if (!layer.started && elapsedMs >= layer.startAt) {
|
|
65
|
+
layer.runner.reset();
|
|
66
|
+
layer.runner.onStarted?.();
|
|
67
|
+
layer.started = true;
|
|
68
|
+
}
|
|
69
|
+
if (layer.started && !layer.ended) {
|
|
70
|
+
if (layer.runner(deltaMs)) {
|
|
71
|
+
layer.ended = true;
|
|
72
|
+
remainingLayers--;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
onProgress(totalDurationMs > 0 ? Math.min(elapsedMs / totalDurationMs, 1) : 1);
|
|
77
|
+
if (remainingLayers <= 0) finish();
|
|
78
|
+
};
|
|
26
79
|
const play = () => {
|
|
27
|
-
if (
|
|
80
|
+
if (timelineStatus === "dead") throw new Error("Cannot play a dead timeline");
|
|
81
|
+
state = buildFromConfigs(rawLayers);
|
|
82
|
+
activeLayers = state.activeLayers;
|
|
83
|
+
totalDurationMs = state.totalDurationMs;
|
|
84
|
+
remainingLayers = activeLayers.length;
|
|
28
85
|
elapsedMs = 0;
|
|
29
|
-
pendingAnimations = 0;
|
|
30
|
-
batches.forEach((b) => {
|
|
31
|
-
b.started = false;
|
|
32
|
-
});
|
|
33
86
|
const promise = new Promise((resolve) => {
|
|
34
87
|
resolvePromise = resolve;
|
|
35
88
|
});
|
|
36
|
-
|
|
89
|
+
timelineStatus = "playing";
|
|
37
90
|
onStarted?.();
|
|
38
91
|
ticker.add(update);
|
|
39
92
|
return promise;
|
|
40
93
|
};
|
|
41
94
|
const pause = () => {
|
|
42
|
-
if (
|
|
43
|
-
|
|
95
|
+
if (timelineStatus !== "playing") return;
|
|
96
|
+
timelineStatus = "paused";
|
|
44
97
|
ticker.remove(update);
|
|
45
|
-
for (const batch of batches) if (batch.started) {
|
|
46
|
-
for (const anim of batch.animations) if (anim.status === "playing") anim.pause();
|
|
47
|
-
}
|
|
48
98
|
};
|
|
49
99
|
const resume = () => {
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
for (const batch of batches) if (batch.started) {
|
|
53
|
-
for (const anim of batch.animations) if (anim.status === "paused") anim.resume();
|
|
54
|
-
}
|
|
100
|
+
if (timelineStatus !== "paused") return;
|
|
101
|
+
timelineStatus = "playing";
|
|
55
102
|
ticker.add(update);
|
|
56
103
|
};
|
|
57
104
|
const stop = () => {
|
|
58
|
-
if (
|
|
59
|
-
|
|
105
|
+
if (timelineStatus !== "playing" && timelineStatus !== "paused") return;
|
|
106
|
+
timelineStatus = "stopped";
|
|
60
107
|
ticker.remove(update);
|
|
61
|
-
for (const batch of batches) if (batch.started) for (const anim of batch.animations) anim.stop();
|
|
62
108
|
resolvePromise?.(timeline);
|
|
63
109
|
resolvePromise = void 0;
|
|
64
110
|
};
|
|
65
111
|
const skipToEnd = () => {
|
|
66
|
-
|
|
112
|
+
for (const layer of activeLayers) {
|
|
113
|
+
layer.runner.evaluate(1);
|
|
114
|
+
layer.runner.onEnded?.();
|
|
115
|
+
}
|
|
116
|
+
timelineStatus = "stopped";
|
|
67
117
|
ticker.remove(update);
|
|
68
|
-
for (const batch of batches) for (const anim of batch.animations) anim.skipToEnd();
|
|
69
118
|
onEnded?.();
|
|
70
119
|
resolvePromise?.(timeline);
|
|
71
120
|
resolvePromise = void 0;
|
|
72
121
|
};
|
|
73
122
|
const kill = () => {
|
|
74
|
-
|
|
75
|
-
ticker.remove(update);
|
|
76
|
-
for (const batch of batches) for (const anim of batch.animations) anim.kill();
|
|
77
|
-
resolvePromise = void 0;
|
|
78
|
-
};
|
|
79
|
-
const update = (deltaMs) => {
|
|
80
|
-
if (status !== "playing") return;
|
|
81
|
-
elapsedMs += deltaMs;
|
|
82
|
-
for (const batch of batches) if (!batch.started && elapsedMs >= batch.startAt) {
|
|
83
|
-
batch.started = true;
|
|
84
|
-
pendingAnimations += batch.animations.length;
|
|
85
|
-
for (const anim of batch.animations) anim.play().then(() => {
|
|
86
|
-
pendingAnimations--;
|
|
87
|
-
checkComplete();
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
onProgress?.(totalDurationMs > 0 ? Math.min(elapsedMs / totalDurationMs, 1) : 0);
|
|
91
|
-
if (batches.every((b) => b.started) && pendingAnimations <= 0) finish();
|
|
92
|
-
};
|
|
93
|
-
const checkComplete = () => {
|
|
94
|
-
if (pendingAnimations <= 0 && status === "playing" && batches.every((b) => b.started)) finish();
|
|
95
|
-
};
|
|
96
|
-
const finish = () => {
|
|
97
|
-
status = "stopped";
|
|
123
|
+
timelineStatus = "dead";
|
|
98
124
|
ticker.remove(update);
|
|
99
|
-
onEnded?.();
|
|
100
|
-
resolvePromise?.(timeline);
|
|
101
125
|
resolvePromise = void 0;
|
|
102
126
|
};
|
|
103
127
|
const setProgress = (value) => {
|
|
104
|
-
if (
|
|
128
|
+
if (timelineStatus === "playing") pause();
|
|
105
129
|
elapsedMs = Math.max(0, Math.min(1, value)) * totalDurationMs;
|
|
106
|
-
for (const
|
|
107
|
-
|
|
108
|
-
|
|
130
|
+
for (const layer of activeLayers) if (elapsedMs < layer.startAt) {
|
|
131
|
+
layer.runner.evaluate(0);
|
|
132
|
+
layer.started = false;
|
|
133
|
+
layer.ended = false;
|
|
109
134
|
} else {
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
135
|
+
layer.started = true;
|
|
136
|
+
const layerDuration = layer.endAt - layer.startAt;
|
|
137
|
+
const localProgress = layerDuration > 0 ? Math.min((elapsedMs - layer.startAt) / layerDuration, 1) : 1;
|
|
138
|
+
layer.runner.evaluate(localProgress);
|
|
139
|
+
layer.ended = localProgress >= 1;
|
|
116
140
|
}
|
|
141
|
+
remainingLayers = activeLayers.filter((l) => !l.ended).length;
|
|
117
142
|
};
|
|
118
143
|
const timeline = {
|
|
119
144
|
play,
|
|
@@ -124,10 +149,10 @@ var createTimeline = (layers, options) => {
|
|
|
124
149
|
kill,
|
|
125
150
|
setProgress,
|
|
126
151
|
get progress() {
|
|
127
|
-
return totalDurationMs > 0 ? Math.min(elapsedMs / totalDurationMs, 1) :
|
|
152
|
+
return totalDurationMs > 0 ? Math.min(elapsedMs / totalDurationMs, 1) : 1;
|
|
128
153
|
},
|
|
129
154
|
get status() {
|
|
130
|
-
return
|
|
155
|
+
return timelineStatus;
|
|
131
156
|
},
|
|
132
157
|
get durationMs() {
|
|
133
158
|
return totalDurationMs;
|