anim-engine 0.1.1 → 0.1.3
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 +44 -16
- package/dist/animation/create-animation.d.ts +2 -3
- package/dist/animation/create-animation.js +59 -63
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -135,16 +135,15 @@ anim.skipToEnd(); // jumps to end, resolves promise
|
|
|
135
135
|
|
|
136
136
|
**Single-tween options:**
|
|
137
137
|
|
|
138
|
-
| Option | Type | Default
|
|
139
|
-
| ------------ | -------------------------------------------- |
|
|
140
|
-
| `from` | `number \| () => number` | —
|
|
141
|
-
| `to` | `number \| () => number` | —
|
|
142
|
-
| `durationMs` | `number`
|
|
143
|
-
| `ease` | `EaseName \| EaseFunction` | `"inOutSine"`
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `onEnded` | `() => void` | — | Called when animation completes |
|
|
138
|
+
| Option | Type | Default | Description |
|
|
139
|
+
| ------------ | -------------------------------------------- | ------------- | ------------------------------------------------------------ |
|
|
140
|
+
| `from` | `number \| () => number` | — | Start value (static or dynamic) |
|
|
141
|
+
| `to` | `number \| () => number` | — | End value (static or dynamic) |
|
|
142
|
+
| `durationMs` | `number \| () => number` | — | Duration in milliseconds (static or dynamic) |
|
|
143
|
+
| `ease` | `EaseName \| EaseFunction` | `"inOutSine"` | Easing function or name |
|
|
144
|
+
| `onStarted` | `() => void` | — | Called when animation begins |
|
|
145
|
+
| `onUpdate` | `(value: number, velocity: number) => void` | — | Called every frame with current value and velocity (units/s) |
|
|
146
|
+
| `onEnded` | `() => void` | — | Called when animation completes |
|
|
148
147
|
**Returns:** `Animation`
|
|
149
148
|
|
|
150
149
|
### Keyframes
|
|
@@ -166,7 +165,7 @@ const anim = createAnimation({
|
|
|
166
165
|
});
|
|
167
166
|
```
|
|
168
167
|
|
|
169
|
-
Each keyframe's `at` is in milliseconds — the last keyframe's `at` sets the total duration (1000ms in this example). If no `ease` is specified, the previous segment's ease carries forward.
|
|
168
|
+
Each keyframe's `at` is in milliseconds — the last keyframe's `at` sets the total duration (1000ms in this example). Both `at` and `value` accept `DynamicValue` for per-play resolution. If no `ease` is specified, the previous segment's ease carries forward.
|
|
170
169
|
|
|
171
170
|
**Keyframe options:**
|
|
172
171
|
|
|
@@ -484,17 +483,46 @@ You can pass the result directly to any `ease` option — it's an `EaseFunction`
|
|
|
484
483
|
|
|
485
484
|
## Dynamic values
|
|
486
485
|
|
|
487
|
-
|
|
486
|
+
The `DynamicValue` type (`number | (() => number)`) lets you provide values that are resolved at runtime. How often they're resolved depends on the primitive:
|
|
487
|
+
|
|
488
|
+
### Timed animations (per-play)
|
|
489
|
+
|
|
490
|
+
In `createAnimation` — tweens and keyframes — all dynamic values are resolved **once when `play()` is called** and cached for the duration of that run. The frame-hot update path reads from the cache with zero overhead:
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
const anim = createAnimation({
|
|
494
|
+
from: () => getLayoutStart(),
|
|
495
|
+
to: () => getLayoutEnd(),
|
|
496
|
+
durationMs: () => 500 / speedMultiplier,
|
|
497
|
+
keyframes: [
|
|
498
|
+
{ at: 0, value: 0 },
|
|
499
|
+
{ at: () => scrollHeight * 0.3, value: 50 },
|
|
500
|
+
{ at: () => scrollHeight, value: 100 },
|
|
501
|
+
],
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Values resolved here, cached for duration
|
|
505
|
+
await anim.play();
|
|
506
|
+
|
|
507
|
+
// On re-play, values are re-resolved
|
|
508
|
+
await anim.play();
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
This means `from`, `to`, `durationMs`, `keyframe.at`, and `keyframe.value` are all evaluated at play time and remain stable throughout the animation. If you need truly per-frame dynamic values, that's the domain of continuous primitives.
|
|
512
|
+
|
|
513
|
+
### Continuous primitives (per-frame)
|
|
514
|
+
|
|
515
|
+
In `createSpring`, `createSmoothDamp`, and `createLerp`, dynamic values are resolved **every frame** — these primitives are designed to chase live targets:
|
|
488
516
|
|
|
489
517
|
```ts
|
|
490
518
|
const spring = createSpring({
|
|
491
|
-
to: () => getMousePosition(), //
|
|
492
|
-
stiffness: () => sliderValue,
|
|
519
|
+
to: () => getMousePosition(), // re-read every frame
|
|
520
|
+
stiffness: () => sliderValue, // dynamic stiffness
|
|
493
521
|
damping: () => dampingValue,
|
|
494
522
|
});
|
|
495
523
|
```
|
|
496
524
|
|
|
497
|
-
|
|
525
|
+
This distinction reflects the two use-cases: timed animations describe a fixed motion path evaluated at start, while continuous primitives describe ongoing behaviour that adapts to changing input.
|
|
498
526
|
|
|
499
527
|
## Benchmarks
|
|
500
528
|
|
|
@@ -626,7 +654,7 @@ requestAnimationFrame(gameLoop);
|
|
|
626
654
|
| `AnimationStatus` | `"playing" \| "paused" \| "stopped" \| "dead"` (for `Animation` / `Timeline`) |
|
|
627
655
|
| `InterpolationStatus` | `"active" \| "inactive" \| "dead"` (for `Interpolation`) |
|
|
628
656
|
| `AnimationOptions` | Single tween or keyframe animation options (discriminated union) |
|
|
629
|
-
| `Keyframe` | `{ at, value, ease
|
|
657
|
+
| `Keyframe` | `{ at: DynamicValue, value: DynamicValue, ease?: EaseName \| EaseFunction }` |
|
|
630
658
|
| `TimelineLayer` | `{ at: number; animation: Animation \| Animation[] } \| { gap: number; animation: Animation \| Animation[] }` |
|
|
631
659
|
| `SpringOptions` | `to`, `stiffness`, `damping`, `mass`, `precision?`, `onUpdate`, `onEnded` |
|
|
632
660
|
| `SmoothDampOptions` | `to`, `smoothTimeMs`, `maxSpeed?`, `precision?`, `onUpdate`, `onEnded` |
|
|
@@ -2,15 +2,14 @@ import { Animation, DynamicValue, EaseFunction, EaseName } from '../shared/types
|
|
|
2
2
|
export type SingleTweenOptions = {
|
|
3
3
|
from: DynamicValue;
|
|
4
4
|
to: DynamicValue;
|
|
5
|
-
durationMs:
|
|
5
|
+
durationMs: DynamicValue;
|
|
6
6
|
ease?: EaseName | EaseFunction;
|
|
7
|
-
delayMs?: number;
|
|
8
7
|
onStarted?: () => void;
|
|
9
8
|
onUpdate?: (value: number, velocity: number) => void;
|
|
10
9
|
onEnded?: () => void;
|
|
11
10
|
};
|
|
12
11
|
export type Keyframe = {
|
|
13
|
-
at:
|
|
12
|
+
at: DynamicValue;
|
|
14
13
|
value: DynamicValue;
|
|
15
14
|
ease?: EaseName | EaseFunction;
|
|
16
15
|
};
|
|
@@ -12,10 +12,13 @@ var createAnimation = (options) => {
|
|
|
12
12
|
};
|
|
13
13
|
var createSingleTween = (options) => {
|
|
14
14
|
const easeName = options.ease ?? "inOutSine";
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const { onStarted, onUpdate, onEnded } = options;
|
|
16
|
+
const rawFrom = options.from;
|
|
17
|
+
const rawTo = options.to;
|
|
18
|
+
const rawDurationMs = options.durationMs;
|
|
19
|
+
let cachedFrom = typeof rawFrom === "function" ? rawFrom() : rawFrom;
|
|
20
|
+
let cachedTo = typeof rawTo === "function" ? rawTo() : rawTo;
|
|
21
|
+
let cachedDurationMs = typeof rawDurationMs === "function" ? rawDurationMs() : rawDurationMs;
|
|
19
22
|
const state = {
|
|
20
23
|
progress: 0,
|
|
21
24
|
currentValue: 0,
|
|
@@ -24,21 +27,10 @@ var createSingleTween = (options) => {
|
|
|
24
27
|
let status = "stopped";
|
|
25
28
|
let stopped = false;
|
|
26
29
|
let resolvePromise;
|
|
27
|
-
let delayRemainingMs = 0;
|
|
28
|
-
let pendingStart = false;
|
|
29
30
|
const ticker = getTicker();
|
|
30
31
|
const update = (deltaMs) => {
|
|
31
32
|
if (stopped) return;
|
|
32
|
-
|
|
33
|
-
delayRemainingMs -= deltaMs;
|
|
34
|
-
if (delayRemainingMs > 0) return;
|
|
35
|
-
pendingStart = false;
|
|
36
|
-
onStarted?.();
|
|
37
|
-
deltaMs = -delayRemainingMs;
|
|
38
|
-
}
|
|
39
|
-
const from = typeof rawFrom === "function" ? rawFrom() : rawFrom;
|
|
40
|
-
const to = typeof rawTo === "function" ? rawTo() : rawTo;
|
|
41
|
-
const completed = updateTween(state, deltaMs, durationMs, currentEase, from, to);
|
|
33
|
+
const completed = updateTween(state, deltaMs, cachedDurationMs, currentEase, cachedFrom, cachedTo);
|
|
42
34
|
onUpdate?.(state.currentValue, state.velocity);
|
|
43
35
|
if (completed) handleCompletion();
|
|
44
36
|
};
|
|
@@ -47,22 +39,17 @@ var createSingleTween = (options) => {
|
|
|
47
39
|
if (status === "dead") throw new Error("Cannot play a dead animation");
|
|
48
40
|
stopped = false;
|
|
49
41
|
state.progress = 0;
|
|
50
|
-
|
|
51
|
-
|
|
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;
|
|
52
46
|
state.velocity = 0;
|
|
53
|
-
if (delayMs > 0) {
|
|
54
|
-
delayRemainingMs = delayMs;
|
|
55
|
-
pendingStart = true;
|
|
56
|
-
} else {
|
|
57
|
-
pendingStart = false;
|
|
58
|
-
delayRemainingMs = 0;
|
|
59
|
-
}
|
|
60
47
|
const promise = new Promise((resolve) => {
|
|
61
48
|
resolvePromise = resolve;
|
|
62
49
|
});
|
|
63
50
|
status = "playing";
|
|
64
51
|
ticker.add(update);
|
|
65
|
-
|
|
52
|
+
onStarted?.();
|
|
66
53
|
return promise;
|
|
67
54
|
};
|
|
68
55
|
const pause = () => {
|
|
@@ -83,8 +70,7 @@ var createSingleTween = (options) => {
|
|
|
83
70
|
resolvePromise = void 0;
|
|
84
71
|
};
|
|
85
72
|
const skipToEnd = () => {
|
|
86
|
-
|
|
87
|
-
state.currentValue = skipTo;
|
|
73
|
+
state.currentValue = cachedTo;
|
|
88
74
|
state.velocity = 0;
|
|
89
75
|
state.progress = 1;
|
|
90
76
|
onUpdate?.(state.currentValue, state.velocity);
|
|
@@ -131,9 +117,7 @@ var createSingleTween = (options) => {
|
|
|
131
117
|
if (status === "playing") pause();
|
|
132
118
|
const clamped = Math.max(0, Math.min(1, value));
|
|
133
119
|
state.progress = clamped;
|
|
134
|
-
|
|
135
|
-
const to = typeof rawTo === "function" ? rawTo() : rawTo;
|
|
136
|
-
state.currentValue = from + (to - from) * currentEase(clamped);
|
|
120
|
+
state.currentValue = cachedFrom + (cachedTo - cachedFrom) * currentEase(clamped);
|
|
137
121
|
state.velocity = 0;
|
|
138
122
|
onUpdate?.(state.currentValue, state.velocity);
|
|
139
123
|
},
|
|
@@ -141,46 +125,57 @@ var createSingleTween = (options) => {
|
|
|
141
125
|
return status;
|
|
142
126
|
},
|
|
143
127
|
get durationMs() {
|
|
144
|
-
return
|
|
128
|
+
return cachedDurationMs;
|
|
145
129
|
}
|
|
146
130
|
};
|
|
147
131
|
return controls;
|
|
148
132
|
};
|
|
149
133
|
var createKeyframeAnimation = (options) => {
|
|
150
|
-
const
|
|
134
|
+
const rawKeyframes = options.keyframes;
|
|
151
135
|
const onUpdate = options.onUpdate;
|
|
152
136
|
const onProgress = options.onProgress;
|
|
153
137
|
const onEnded = options.onEnded;
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
138
|
+
const resolveKeyframeValue = (kf) => typeof kf.value === "function" ? kf.value() : kf.value;
|
|
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);
|
|
159
177
|
};
|
|
160
|
-
|
|
161
|
-
for (let i = 0; i < sorted.length - 1; i++) {
|
|
162
|
-
const current = sorted[i];
|
|
163
|
-
const next = sorted[i + 1];
|
|
164
|
-
const from = resolveKeyframeValue(current);
|
|
165
|
-
const to = resolveKeyframeValue(next);
|
|
166
|
-
segments.push({
|
|
167
|
-
from,
|
|
168
|
-
to,
|
|
169
|
-
range: to - from,
|
|
170
|
-
durationMs: next.at - current.at,
|
|
171
|
-
easeFn: resolveEasing(next.ease ?? "inOutSine")
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
const prefixSum = [0];
|
|
175
|
-
for (let i = 0; i < segments.length; i++) prefixSum.push(prefixSum[i] + segments[i].durationMs);
|
|
176
|
-
if (segments.length === 0) return createSingleTween({
|
|
177
|
-
from: resolveKeyframeValue(sorted[0]),
|
|
178
|
-
to: resolveKeyframeValue(sorted[0]),
|
|
179
|
-
durationMs: totalDurationMs,
|
|
180
|
-
ease: "linear",
|
|
181
|
-
onUpdate,
|
|
182
|
-
onEnded
|
|
183
|
-
});
|
|
178
|
+
rebuild();
|
|
184
179
|
const state = {
|
|
185
180
|
progress: 0,
|
|
186
181
|
currentValue: 0,
|
|
@@ -190,7 +185,7 @@ var createKeyframeAnimation = (options) => {
|
|
|
190
185
|
let stopped = false;
|
|
191
186
|
let resolvePromise;
|
|
192
187
|
let currentSegmentIndex = 0;
|
|
193
|
-
let previousValue =
|
|
188
|
+
let previousValue = segments[0].from;
|
|
194
189
|
const ticker = getTicker();
|
|
195
190
|
const update = (deltaMs) => {
|
|
196
191
|
if (stopped) return;
|
|
@@ -230,6 +225,7 @@ var createKeyframeAnimation = (options) => {
|
|
|
230
225
|
let segmentElapsed = 0;
|
|
231
226
|
const play = () => {
|
|
232
227
|
if (status === "dead") throw new Error("Cannot play a dead animation");
|
|
228
|
+
rebuild();
|
|
233
229
|
stopped = false;
|
|
234
230
|
currentSegmentIndex = 0;
|
|
235
231
|
segmentElapsed = 0;
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anim-engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "JavaScript library for animating numbers",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"repository":
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/LukeCarlThompson/anim-engine.git"
|
|
10
|
+
},
|
|
8
11
|
"files": [
|
|
9
12
|
"dist",
|
|
10
13
|
"README.md",
|
|
@@ -27,6 +30,7 @@
|
|
|
27
30
|
"prepublishOnly": "npm run build",
|
|
28
31
|
"bench": "vitest bench src/benchmarks/ --run",
|
|
29
32
|
"bench:baseline": "vitest bench src/benchmarks/ --run --outputJson baselines/benchmark.json",
|
|
33
|
+
"bench:compare": "vitest bench src/benchmarks/ --run --compare baselines/benchmark.json",
|
|
30
34
|
"format": "oxfmt src/",
|
|
31
35
|
"format:check": "oxfmt --check src/",
|
|
32
36
|
"fmt": "oxfmt src/"
|