@sigx/lynx-motion 0.1.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/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@sigx/lynx-motion",
3
+ "version": "0.1.0",
4
+ "description": "Animation primitives for sigx-lynx — spring/tween drivers built on AnimatedValue",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "dist"
14
+ ],
15
+ "keywords": [
16
+ "sigx",
17
+ "lynx",
18
+ "animation",
19
+ "spring",
20
+ "tween",
21
+ "motion"
22
+ ],
23
+ "author": "Andreas Ekdahl",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@sigx/lynx": "^0.1.4"
27
+ },
28
+ "devDependencies": {
29
+ "@lynx-js/react": "^0.119.0",
30
+ "typescript": "^6.0.3",
31
+ "vite": "^8.0.12",
32
+ "@sigx/lynx-plugin": "^0.2.7",
33
+ "@sigx/lynx-runtime-main": "^0.2.7"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/signalxjs/lynx.git",
38
+ "directory": "packages/lynx-motion"
39
+ },
40
+ "homepage": "https://github.com/signalxjs/lynx/tree/main/packages/lynx-motion",
41
+ "bugs": {
42
+ "url": "https://github.com/signalxjs/lynx/issues"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "build": "vite build && tsgo --emitDeclarationOnly",
49
+ "dev": "vite build --watch"
50
+ }
51
+ }
package/src/animate.ts ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Animation orchestration ported from `@lynx-js/motion/dist/mini/core/animate.js`
3
+ * v0.0.3 (Apache-2.0). Spring solver + easeOut math from
4
+ * `motion-dom` v12.23.12 + `motion-utils` v12.23.6 (also Apache-2.0),
5
+ * inlined directly inside the worklet body.
6
+ *
7
+ * Why everything is inlined inside the function: SWC's worklet transform
8
+ * captures any free identifier referenced by the worklet body into the
9
+ * `_c` map. Plain JS function references at module scope (or sibling
10
+ * imports) don't survive JSON serialization across the MT/BG bridge — they
11
+ * arrive on MT as undefined or stringified placeholders. Upstream
12
+ * motion-mini sidesteps this by making every helper its own `'main thread'`
13
+ * worklet (so the placeholder objects survive). For Phase 2.7 we keep the
14
+ * port simpler by inlining the math straight into the `animate` worklet
15
+ * body, with `inflight` cancellation state tucked on `globalThis` so it's
16
+ * not a free-identifier capture.
17
+ *
18
+ * Sigx-specific adaptations:
19
+ * - Operates on sigx's `SharedValue<number>` (writes `sv.current.value`)
20
+ * instead of motion's `MotionValue`.
21
+ * - Per-SharedValue cancellation tracking via `globalThis.__sigxMotionInflight`.
22
+ * - Uses `globalThis.requestAnimationFrame` (installed by upstream's
23
+ * worklet-runtime IIFE on MT, Lynx SDK ≥ 2.16); falls back to
24
+ * `setTimeout(cb, 16)` otherwise.
25
+ *
26
+ * The standalone modules `easings.ts` and `spring.ts` still ship as a
27
+ * pure-math API for non-worklet callers (tests, BG-side debugging). They
28
+ * are NOT imported by this worklet.
29
+ */
30
+
31
+ import type { SharedValue } from '@sigx/lynx';
32
+
33
+ // ============================================================================
34
+ // Public types
35
+ // ============================================================================
36
+
37
+ export interface SpringOptions {
38
+ stiffness?: number;
39
+ damping?: number;
40
+ mass?: number;
41
+ velocity?: number;
42
+ restSpeed?: number;
43
+ restDelta?: number;
44
+ }
45
+
46
+ export interface TimingOptions {
47
+ /** Tween duration in seconds. Default 0.3. */
48
+ duration?: number;
49
+ }
50
+
51
+ export interface AnimateOptions extends SpringOptions, TimingOptions {
52
+ type?: 'spring' | 'tween';
53
+ }
54
+
55
+ // Note: motion-style `onUpdate` / `onComplete` / custom `ease` callbacks are
56
+ // intentionally NOT in this surface. Function references don't survive
57
+ // worklet `_c` capture across the MT/BG bridge — they'd be silent footguns.
58
+ // Sigx-native equivalents (zero extra wiring, integrate with reactivity):
59
+ // onUpdate(v) → BG: `effect(() => doX(sv.value))`
60
+ // onComplete() → `await withSpring(sv, target); doNext()`
61
+ // ease: customFn → use a built-in (`linear`, `easeIn`, `easeOut`, etc.);
62
+ // custom curves are rare and would need a `registerEasing(name, fn)`
63
+ // pattern (deferred follow-up).
64
+
65
+ export interface AnimateControls {
66
+ stop(): void;
67
+ finished: Promise<void>;
68
+ }
69
+
70
+ // ============================================================================
71
+ // Public API — all math inlined inside the worklet body.
72
+ // ============================================================================
73
+
74
+ /**
75
+ * Animate a `SharedValue<number>` toward `target`. Returns
76
+ * `{ stop, finished }`. Default is spring; pass `type: 'tween'` or
77
+ * `{ duration }` to use a tween with `easeOut`.
78
+ */
79
+ export function animate(
80
+ sv: SharedValue<number>,
81
+ target: number,
82
+ options: AnimateOptions = {},
83
+ ): AnimateControls {
84
+ 'main thread';
85
+
86
+ // ─── Inlined: in-flight cancellation map (per-SharedValue) ──────────
87
+ // Stash on globalThis so SWC doesn't capture a module-scope reference
88
+ // into _c. The map persists across animate() calls on MT.
89
+ const g = globalThis as { __sigxMotionInflight?: Map<number, () => void> };
90
+ if (!g.__sigxMotionInflight) g.__sigxMotionInflight = new Map();
91
+ const inflight = g.__sigxMotionInflight;
92
+
93
+ const prev = inflight.get(sv._wvid);
94
+ if (prev) prev();
95
+
96
+ const startValue = sv.current.value;
97
+
98
+ const isSpring =
99
+ options.type === 'spring' ||
100
+ (options.type !== 'tween' && options.duration == null);
101
+
102
+ // ─── Inlined: spring solver (motion-dom port, Apache-2.0) ────────────
103
+ // Returns { next(elapsedMs): { done, value } }. Only built when isSpring.
104
+ const buildSolver = (): ((t: number) => { done: boolean; value: number }) | null => {
105
+ if (!isSpring) return null;
106
+
107
+ const stiffness = options.stiffness ?? 100;
108
+ const damping = options.damping ?? 10;
109
+ const mass = options.mass ?? 1;
110
+ const initialVelocity = -((options.velocity ?? 0) / 1000); // ms→s, sign convention
111
+
112
+ const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass));
113
+ const initialDelta = target - startValue;
114
+ const undampedAngularFreq = Math.sqrt(stiffness / mass) / 1000;
115
+
116
+ const isGranularScale = Math.abs(initialDelta) < 5;
117
+ const restSpeed = options.restSpeed ?? (isGranularScale ? 0.01 : 2);
118
+ const restDelta = options.restDelta ?? (isGranularScale ? 0.005 : 0.5);
119
+
120
+ let resolveSpring: (t: number) => number;
121
+
122
+ if (dampingRatio < 1) {
123
+ const angularFreq = undampedAngularFreq * Math.sqrt(1 - dampingRatio * dampingRatio);
124
+ resolveSpring = (t) => {
125
+ const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t);
126
+ return target - envelope *
127
+ (((initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) / angularFreq) *
128
+ Math.sin(angularFreq * t) +
129
+ initialDelta * Math.cos(angularFreq * t));
130
+ };
131
+ } else if (dampingRatio === 1) {
132
+ resolveSpring = (t) =>
133
+ target - Math.exp(-undampedAngularFreq * t) *
134
+ (initialDelta + (initialVelocity + undampedAngularFreq * initialDelta) * t);
135
+ } else {
136
+ const dampedAngularFreq = undampedAngularFreq * Math.sqrt(dampingRatio * dampingRatio - 1);
137
+ resolveSpring = (t) => {
138
+ const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t);
139
+ const freqForT = Math.min(dampedAngularFreq * t, 300);
140
+ return target - (envelope *
141
+ ((initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) * Math.sinh(freqForT) +
142
+ dampedAngularFreq * initialDelta * Math.cosh(freqForT))) / dampedAngularFreq;
143
+ };
144
+ }
145
+
146
+ // Velocity sample: 5ms-window finite-difference of resolveSpring.
147
+ const calcVelocity = (t: number, current: number): number => {
148
+ const prevT = Math.max(t - 5, 0);
149
+ const prevVal = resolveSpring(prevT);
150
+ const dt = t - prevT;
151
+ return dt ? (current - prevVal) * (1000 / dt) : 0;
152
+ };
153
+
154
+ return (t: number) => {
155
+ const current = resolveSpring(t);
156
+ let currentVelocity = t === 0 ? initialVelocity : 0;
157
+ if (dampingRatio < 1) {
158
+ currentVelocity = t === 0 ? initialVelocity * 1000 : calcVelocity(t, current);
159
+ }
160
+ const isBelowVelocityThreshold = Math.abs(currentVelocity) <= restSpeed;
161
+ const isBelowDisplacementThreshold = Math.abs(target - current) <= restDelta;
162
+ const done = isBelowVelocityThreshold && isBelowDisplacementThreshold;
163
+ return { done, value: done ? target : current };
164
+ };
165
+ };
166
+
167
+ const solverNext = buildSolver();
168
+
169
+ // ─── Inlined: easeOut (cubicBezier(0, 0, 0.58, 1)) ────────────────────
170
+ const calcBezier = (t: number, a1: number, a2: number): number =>
171
+ (((1.0 - 3.0 * a2 + 3.0 * a1) * t + (3.0 * a2 - 6.0 * a1)) * t + 3.0 * a1) * t;
172
+
173
+ const binarySubdivide = (x: number, mX1: number, mX2: number): number => {
174
+ let lo = 0, hi = 1, cT = 0, cX: number, i = 0;
175
+ do {
176
+ cT = lo + (hi - lo) / 2;
177
+ cX = calcBezier(cT, mX1, mX2) - x;
178
+ if (cX > 0) hi = cT; else lo = cT;
179
+ } while (Math.abs(cX) > 1e-7 && ++i < 12);
180
+ return cT;
181
+ };
182
+
183
+ const easeOutDefault = (t: number): number => {
184
+ if (t === 0 || t === 1) return t;
185
+ return calcBezier(binarySubdivide(t, 0, 0.58), 0, 1);
186
+ };
187
+
188
+ const duration = options.duration ?? 0.3;
189
+ const startTime = Date.now();
190
+
191
+ let canceled = false;
192
+ let resolvePromise: (() => void) | undefined;
193
+ let settled = false;
194
+ const finished = new Promise<void>((resolve) => { resolvePromise = resolve; });
195
+
196
+ const settle = (): void => {
197
+ if (settled) return;
198
+ settled = true;
199
+ if (inflight.get(sv._wvid) === stop) inflight.delete(sv._wvid);
200
+ resolvePromise?.();
201
+ };
202
+
203
+ const stop = (): void => {
204
+ if (canceled) return;
205
+ canceled = true;
206
+ settle();
207
+ };
208
+
209
+ inflight.set(sv._wvid, stop);
210
+
211
+ // ─── Inlined: tick scheduler ──────────────────────────────────────────
212
+ // `globalThis.requestAnimationFrame` is installed by upstream's
213
+ // worklet-runtime IIFE on MT; falls back to `setTimeout(cb, 16)` when
214
+ // unavailable (older SDKs, test environments).
215
+ const rafGlobal = globalThis as { requestAnimationFrame?: (cb: () => void) => void };
216
+ const scheduleNext = (cb: () => void): void => {
217
+ if (typeof rafGlobal.requestAnimationFrame === 'function') {
218
+ rafGlobal.requestAnimationFrame(cb);
219
+ } else {
220
+ setTimeout(cb, 16);
221
+ }
222
+ };
223
+
224
+ // ─── Flush trigger (microtask-debounced) ─────────────────────────────
225
+ // Writing `sv.current.value` is a plain ref mutation — it doesn't
226
+ // schedule the native flush by itself. Without a flush, the SharedValue
227
+ // bridge never publishes the new value to BG and `useAnimatedStyle`
228
+ // bindings never apply.
229
+ //
230
+ // We coalesce calls via a `globalThis.__sigxMotionFlushScheduled` flag:
231
+ // multiple ticks across multiple concurrent animations within the same
232
+ // microtask boundary all set the flag, the first one schedules the
233
+ // microtask, the rest see the flag and bail. End result: ONE flush per
234
+ // microtask regardless of how many animations are live. Same pattern
235
+ // upstream's `MTElementWrapper.flushElementTree` uses.
236
+ const flushTree = (): void => {
237
+ const g = globalThis as Record<string, unknown>;
238
+ if (g['__sigxMotionFlushScheduled']) return;
239
+ g['__sigxMotionFlushScheduled'] = true;
240
+ Promise.resolve().then(() => {
241
+ g['__sigxMotionFlushScheduled'] = false;
242
+ const fn = g['__FlushElementTree'] as (() => void) | undefined;
243
+ if (fn) fn();
244
+ });
245
+ };
246
+
247
+ const tick = (): void => {
248
+ if (canceled) return;
249
+
250
+ const now = Date.now();
251
+ const elapsedMs = now - startTime;
252
+ const elapsedSec = elapsedMs / 1000;
253
+
254
+ let current: number;
255
+ let done: boolean;
256
+
257
+ if (solverNext) {
258
+ const state = solverNext(elapsedMs);
259
+ current = state.value;
260
+ done = state.done;
261
+ } else if (elapsedSec >= duration) {
262
+ current = target;
263
+ done = true;
264
+ } else {
265
+ const p = elapsedSec / duration;
266
+ current = startValue + (target - startValue) * easeOutDefault(p);
267
+ done = false;
268
+ }
269
+
270
+ sv.current.value = current;
271
+ flushTree();
272
+
273
+ if (done) {
274
+ if (!solverNext && sv.current.value !== target) {
275
+ sv.current.value = target;
276
+ flushTree();
277
+ }
278
+ settle();
279
+ } else {
280
+ scheduleNext(tick);
281
+ }
282
+ };
283
+
284
+ scheduleNext(tick);
285
+
286
+ return { stop, finished };
287
+ }
288
+
289
+ /** Test hook — clear the in-flight map. Not part of the public API. */
290
+ export function _resetInflight(): void {
291
+ const g = globalThis as { __sigxMotionInflight?: Map<number, () => void> };
292
+ if (g.__sigxMotionInflight) g.__sigxMotionInflight.clear();
293
+ }
package/src/easings.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Easing functions ported from `motion-utils` v12.23.6 (Apache-2.0).
3
+ * Upstream: https://github.com/motiondivision/motion/tree/main/packages/motion-utils/src/easing
4
+ *
5
+ * Sigx adaptation:
6
+ * - Inlined into a single file (cubic-bezier + modifiers + named easings).
7
+ * - No `'main thread'` directive; these are pure math functions safe to
8
+ * call from BG or MT context. Sigx's tween path on MT calls them
9
+ * directly inside the worklet body.
10
+ *
11
+ * Reference for individual implementations:
12
+ * - cubicBezier: motion-utils/src/easing/cubic-bezier.ts (modified from
13
+ * Gaëtan Renaudeau's BezierEasing — https://github.com/gre/bezier-easing,
14
+ * MIT-licensed)
15
+ * - ease.ts, back.ts, circ.ts, anticipate.ts, modifiers/{mirror,reverse}.ts
16
+ *
17
+ * Verbatim translation; any divergence from upstream is a bug.
18
+ */
19
+
20
+ export type Easing = (t: number) => number;
21
+
22
+ // noop — identity; serves as `linear` and as the cubic-bezier shortcut when
23
+ // (mX1, mY1) === (mX2, mY2).
24
+ const noop: Easing = (t) => t;
25
+
26
+ // ---- cubic bezier ---------------------------------------------------------
27
+
28
+ const calcBezier = (t: number, a1: number, a2: number): number =>
29
+ (((1.0 - 3.0 * a2 + 3.0 * a1) * t + (3.0 * a2 - 6.0 * a1)) * t + 3.0 * a1) *
30
+ t;
31
+
32
+ const subdivisionPrecision = 0.0000001;
33
+ const subdivisionMaxIterations = 12;
34
+
35
+ function binarySubdivide(
36
+ x: number,
37
+ lowerBound: number,
38
+ upperBound: number,
39
+ mX1: number,
40
+ mX2: number,
41
+ ): number {
42
+ let currentX: number;
43
+ let currentT: number = 0;
44
+ let i = 0;
45
+ do {
46
+ currentT = lowerBound + (upperBound - lowerBound) / 2.0;
47
+ currentX = calcBezier(currentT, mX1, mX2) - x;
48
+ if (currentX > 0.0) {
49
+ upperBound = currentT;
50
+ } else {
51
+ lowerBound = currentT;
52
+ }
53
+ } while (
54
+ Math.abs(currentX) > subdivisionPrecision &&
55
+ ++i < subdivisionMaxIterations
56
+ );
57
+ return currentT;
58
+ }
59
+
60
+ export function cubicBezier(
61
+ mX1: number,
62
+ mY1: number,
63
+ mX2: number,
64
+ mY2: number,
65
+ ): Easing {
66
+ if (mX1 === mY1 && mX2 === mY2) return noop;
67
+ const getTForX = (aX: number) => binarySubdivide(aX, 0, 1, mX1, mX2);
68
+ return (t) => (t === 0 || t === 1 ? t : calcBezier(getTForX(t), mY1, mY2));
69
+ }
70
+
71
+ // ---- modifiers ------------------------------------------------------------
72
+
73
+ /** Reverses an easing — turns easeIn into easeOut. */
74
+ export const reverseEasing = (easing: Easing): Easing => (p) =>
75
+ 1 - easing(1 - p);
76
+
77
+ /** Mirrors an easing across the midpoint — turns easeIn into easeInOut. */
78
+ export const mirrorEasing = (easing: Easing): Easing => (p) =>
79
+ p <= 0.5 ? easing(2 * p) / 2 : (2 - easing(2 * (1 - p))) / 2;
80
+
81
+ // ---- named easings --------------------------------------------------------
82
+
83
+ export const linear: Easing = noop;
84
+
85
+ export const easeIn: Easing = cubicBezier(0.42, 0, 1, 1);
86
+ export const easeOut: Easing = cubicBezier(0, 0, 0.58, 1);
87
+ export const easeInOut: Easing = cubicBezier(0.42, 0, 0.58, 1);
88
+
89
+ export const circIn: Easing = (p) => 1 - Math.sin(Math.acos(p));
90
+ export const circOut: Easing = reverseEasing(circIn);
91
+ export const circInOut: Easing = mirrorEasing(circIn);
92
+
93
+ export const backOut: Easing = cubicBezier(0.33, 1.53, 0.69, 0.99);
94
+ export const backIn: Easing = reverseEasing(backOut);
95
+ export const backInOut: Easing = mirrorEasing(backIn);
96
+
97
+ export const anticipate: Easing = (p) =>
98
+ (p *= 2) < 1 ? 0.5 * backIn(p) : 0.5 * (2 - Math.pow(2, -10 * (p - 1)));
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ // Animation orchestration ('main thread' worklets that operate on SharedValue)
2
+ export { animate } from './animate.js';
3
+ export type {
4
+ AnimateOptions,
5
+ AnimateControls,
6
+ SpringOptions,
7
+ TimingOptions,
8
+ } from './animate.js';
9
+
10
+ // Convenience wrappers
11
+ export { withSpring } from './with-spring.js';
12
+ export { withTiming } from './with-timing.js';
13
+
14
+ // Spring solver — pure-math API for non-worklet callers (tests, BG-side
15
+ // debugging, future scroll-driven derived values). NOT used by the worklet
16
+ // path: `animate()` has its own inlined copy of the solver to avoid
17
+ // cross-file `_c` capture of plain function references that don't survive
18
+ // JSON serialization across the MT/BG bridge.
19
+ export { spring, clamp } from './spring.js';
20
+ export type {
21
+ SpringSolver,
22
+ SpringStep,
23
+ SpringSolverOptions,
24
+ } from './spring.js';
25
+
26
+ // Easings — same story as spring: pure-math API for non-worklet uses; the
27
+ // `animate()` worklet has its own inlined `easeOut`. Custom easings passed
28
+ // via `animate(sv, target, { ease: customFn })` will not survive worklet
29
+ // capture and is documented as out-of-scope for v0.1.
30
+ export {
31
+ cubicBezier,
32
+ reverseEasing,
33
+ mirrorEasing,
34
+ linear,
35
+ easeIn,
36
+ easeOut,
37
+ easeInOut,
38
+ circIn,
39
+ circOut,
40
+ circInOut,
41
+ backIn,
42
+ backOut,
43
+ backInOut,
44
+ anticipate,
45
+ } from './easings.js';
46
+ export type { Easing } from './easings.js';
package/src/spring.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Spring solver ported from `motion-dom` v12.23.12 + `motion-utils` v12.23.6
3
+ * (both Apache-2.0).
4
+ * Upstream:
5
+ * - https://github.com/motiondivision/motion/tree/main/packages/motion-dom/src/animation/generators/spring
6
+ * - https://github.com/motiondivision/motion/tree/main/packages/motion-utils/src
7
+ *
8
+ * Sigx adaptation:
9
+ * - Inlined the motion-utils helpers we depend on (`clamp`,
10
+ * `millisecondsToSeconds`, `secondsToMilliseconds`, `velocityPerSecond`)
11
+ * and the velocity calculator from `motion-dom/animation/generators/utils`.
12
+ * These are tiny pure functions; inlining keeps `@sigx/lynx-motion` zero-deps
13
+ * beyond the sigx workspace.
14
+ * - Skipped the duration→physics resolution path (motion's `findSpring`).
15
+ * Phase 2.7 ships only physics-based options (stiffness/damping/mass).
16
+ * If a future user wants `withSpring(av, target, { duration, bounce })`,
17
+ * add `findSpring` then.
18
+ * - `calculatedDuration` and `toString()` / `toTransition()` from upstream
19
+ * are dropped; they're for WAAPI integration which Lynx doesn't use.
20
+ *
21
+ * Verbatim translation of the integrator math; any divergence is a bug.
22
+ */
23
+
24
+ // ---- inlined helpers ------------------------------------------------------
25
+
26
+ const clamp = (min: number, max: number, v: number): number =>
27
+ v > max ? max : v < min ? min : v;
28
+
29
+ const millisecondsToSeconds = (ms: number): number => ms / 1000;
30
+ const secondsToMilliseconds = (s: number): number => s * 1000;
31
+
32
+ const velocityPerSecond = (velocity: number, frameDuration: number): number =>
33
+ frameDuration ? velocity * (1000 / frameDuration) : 0;
34
+
35
+ const velocitySampleDuration = 5; // ms
36
+ const calcGeneratorVelocity = (
37
+ resolveValue: (t: number) => number,
38
+ t: number,
39
+ current: number,
40
+ ): number => {
41
+ const prevT = Math.max(t - velocitySampleDuration, 0);
42
+ return velocityPerSecond(current - resolveValue(prevT), t - prevT);
43
+ };
44
+
45
+ // ---- defaults -------------------------------------------------------------
46
+
47
+ const springDefaults = {
48
+ stiffness: 100,
49
+ damping: 10,
50
+ mass: 1.0,
51
+ velocity: 0.0,
52
+ restSpeed: { granular: 0.01, default: 2 },
53
+ restDelta: { granular: 0.005, default: 0.5 },
54
+ };
55
+
56
+ // ---- public API -----------------------------------------------------------
57
+
58
+ export interface SpringOptions {
59
+ /** Spring physics. Default 100. */
60
+ stiffness?: number;
61
+ /** Spring damping. Default 10. */
62
+ damping?: number;
63
+ /** Mass of the spring object. Default 1. */
64
+ mass?: number;
65
+ /** Initial velocity in units/sec. Default 0. */
66
+ velocity?: number;
67
+ /** Threshold below which the spring is considered at rest. */
68
+ restSpeed?: number;
69
+ restDelta?: number;
70
+ }
71
+
72
+ export interface SpringStep {
73
+ done: boolean;
74
+ value: number;
75
+ }
76
+
77
+ export interface SpringSolver {
78
+ /**
79
+ * Advance the spring to `t` ms after `t=0`. Returns the integrated value
80
+ * and a `done` flag set when the spring is below the rest thresholds.
81
+ */
82
+ next(t: number): SpringStep;
83
+ }
84
+
85
+ export interface SpringSolverOptions extends SpringOptions {
86
+ /** Required: `[origin, target]`. Pinned to a 2-element keyframes shape. */
87
+ keyframes: [number, number];
88
+ }
89
+
90
+ /**
91
+ * Build a spring solver. Call `.next(elapsedMs)` repeatedly to step the
92
+ * animation. The solver owns no time state — call `.next` with the current
93
+ * elapsed time each tick (motion's pattern).
94
+ */
95
+ export function spring(options: SpringSolverOptions): SpringSolver {
96
+ const origin = options.keyframes[0];
97
+ const target = options.keyframes[1];
98
+
99
+ const state: SpringStep = { done: false, value: origin };
100
+
101
+ const stiffness = options.stiffness ?? springDefaults.stiffness;
102
+ const damping = options.damping ?? springDefaults.damping;
103
+ const mass = options.mass ?? springDefaults.mass;
104
+ // Negate to match motion's convention: positive velocity moves toward
105
+ // target, but the integrator expects velocity in source-units convention.
106
+ const initialVelocity = -millisecondsToSeconds(options.velocity ?? 0);
107
+
108
+ const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass));
109
+ const initialDelta = target - origin;
110
+ const undampedAngularFreq = millisecondsToSeconds(
111
+ Math.sqrt(stiffness / mass),
112
+ );
113
+
114
+ const isGranularScale = Math.abs(initialDelta) < 5;
115
+ const restSpeed =
116
+ options.restSpeed ??
117
+ (isGranularScale
118
+ ? springDefaults.restSpeed.granular
119
+ : springDefaults.restSpeed.default);
120
+ const restDelta =
121
+ options.restDelta ??
122
+ (isGranularScale
123
+ ? springDefaults.restDelta.granular
124
+ : springDefaults.restDelta.default);
125
+
126
+ let resolveSpring: (t: number) => number;
127
+
128
+ if (dampingRatio < 1) {
129
+ // Underdamped — oscillates while decaying.
130
+ const angularFreq =
131
+ undampedAngularFreq * Math.sqrt(1 - dampingRatio * dampingRatio);
132
+ resolveSpring = (t) => {
133
+ const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t);
134
+ return (
135
+ target -
136
+ envelope *
137
+ (((initialVelocity +
138
+ dampingRatio * undampedAngularFreq * initialDelta) /
139
+ angularFreq) *
140
+ Math.sin(angularFreq * t) +
141
+ initialDelta * Math.cos(angularFreq * t))
142
+ );
143
+ };
144
+ } else if (dampingRatio === 1) {
145
+ // Critically damped — fastest non-oscillating return.
146
+ resolveSpring = (t) =>
147
+ target -
148
+ Math.exp(-undampedAngularFreq * t) *
149
+ (initialDelta +
150
+ (initialVelocity + undampedAngularFreq * initialDelta) * t);
151
+ } else {
152
+ // Overdamped — slow non-oscillating return.
153
+ const dampedAngularFreq =
154
+ undampedAngularFreq * Math.sqrt(dampingRatio * dampingRatio - 1);
155
+ resolveSpring = (t) => {
156
+ const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t);
157
+ // Cap freq*t to keep sinh/cosh from hitting Infinity.
158
+ const freqForT = Math.min(dampedAngularFreq * t, 300);
159
+ return (
160
+ target -
161
+ (envelope *
162
+ ((initialVelocity +
163
+ dampingRatio * undampedAngularFreq * initialDelta) *
164
+ Math.sinh(freqForT) +
165
+ dampedAngularFreq * initialDelta * Math.cosh(freqForT))) /
166
+ dampedAngularFreq
167
+ );
168
+ };
169
+ }
170
+
171
+ return {
172
+ next(t: number): SpringStep {
173
+ const current = resolveSpring(t);
174
+
175
+ let currentVelocity = t === 0 ? initialVelocity : 0.0;
176
+ // Velocity calc only needed for underdamped — over/critically-damped
177
+ // can't overshoot, so position alone tells us we're done.
178
+ if (dampingRatio < 1) {
179
+ currentVelocity =
180
+ t === 0
181
+ ? secondsToMilliseconds(initialVelocity)
182
+ : calcGeneratorVelocity(resolveSpring, t, current);
183
+ }
184
+
185
+ const isBelowVelocityThreshold = Math.abs(currentVelocity) <= restSpeed;
186
+ const isBelowDisplacementThreshold =
187
+ Math.abs(target - current) <= restDelta;
188
+
189
+ state.done = isBelowVelocityThreshold && isBelowDisplacementThreshold;
190
+ state.value = state.done ? target : current;
191
+ return state;
192
+ },
193
+ };
194
+ }
195
+
196
+ // Re-export `clamp` because it's small and useful for animation math, and
197
+ // motion-mini exports it from its mini surface.
198
+ export { clamp };