anim-engine 0.1.2 → 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 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 | Description |
139
- | ------------ | -------------------------------------------- | ----------- | ------------------------------------------------------------ |
140
- | `from` | `number \| () => number` | — | Start value (static or dynamic) |
141
- | `to` | `number \| () => number` | — | End value (static or dynamic) |
142
- | `durationMs` | `number` | — | Duration in milliseconds |
143
- | `ease` | `EaseName \| EaseFunction` | `"inOutSine"` | Easing function or name |
144
- | `delayMs` | `number` | `0` | Delay before starting |
145
- | `onStarted` | `() => void` | — | Called when animation begins (after delay) |
146
- | `onUpdate` | `(value: number, velocity: number) => void` | — | Called every frame with current value and velocity (units/s) |
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
- All primitives accept `number | (() => number)` for value parameters. Use a function to update the target every frame without recreating the animation:
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(), // starts at current mouse position // re-read every frame
492
- stiffness: () => sliderValue, // dynamic stiffness
519
+ to: () => getMousePosition(), // re-read every frame
520
+ stiffness: () => sliderValue, // dynamic stiffness
493
521
  damping: () => dampingValue,
494
522
  });
495
523
  ```
496
524
 
497
- The function is called every frame inside the ticker update no getter/setter objects, no mutation of the returned controls.
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: number;
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: number;
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 delayMs = options.delayMs ?? 0;
16
- const { onStarted, onUpdate, onEnded, durationMs } = options;
17
- let rawFrom = options.from;
18
- let rawTo = options.to;
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
- if (pendingStart) {
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
- const initFrom = typeof rawFrom === "function" ? rawFrom() : rawFrom;
51
- state.currentValue = initFrom;
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
- if (delayRemainingMs <= 0) onStarted?.();
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
- const skipTo = typeof rawTo === "function" ? rawTo() : rawTo;
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
- const from = typeof rawFrom === "function" ? rawFrom() : rawFrom;
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 durationMs;
128
+ return cachedDurationMs;
145
129
  }
146
130
  };
147
131
  return controls;
148
132
  };
149
133
  var createKeyframeAnimation = (options) => {
150
- const keyframes = options.keyframes;
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 sorted = [...keyframes].sort((a, b) => a.at - b.at);
155
- const totalDurationMs = sorted[sorted.length - 1].at;
156
- const invTotalDuration = 1 / totalDurationMs;
157
- const resolveKeyframeValue = (kf) => {
158
- return typeof kf.value === "function" ? kf.value() : kf.value;
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
- const segments = [];
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 = resolveKeyframeValue(sorted[0]);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "anim-engine",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "description": "JavaScript library for animating numbers",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  "prepublishOnly": "npm run build",
31
31
  "bench": "vitest bench src/benchmarks/ --run",
32
32
  "bench:baseline": "vitest bench src/benchmarks/ --run --outputJson baselines/benchmark.json",
33
+ "bench:compare": "vitest bench src/benchmarks/ --run --compare baselines/benchmark.json",
33
34
  "format": "oxfmt src/",
34
35
  "format:check": "oxfmt --check src/",
35
36
  "fmt": "oxfmt src/"