coily 0.13.1 → 0.15.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.
@@ -0,0 +1,2188 @@
1
+ import { a as RESOLVED, c as isNumber, i as State, l as isRecord, n as OverdampedSolver, o as invariant, r as UnderdampedSolver, s as isArray, t as CriticallyDampedSolver, u as isRecordOrArray } from "./solver-CVXOTDkZ.js";
2
+ //#region src/flush-queue.ts
3
+ /**
4
+ * Defers callbacks while inside a batch or tick: `request` runs the
5
+ * callback immediately at depth 0 and queues it — deduplicated — until
6
+ * the outermost exit otherwise. Composite springs use it to coalesce
7
+ * per-channel events into composite ones.
8
+ */
9
+ var FlushQueue = class {
10
+ #callbacks = /* @__PURE__ */ new Set();
11
+ #depth = 0;
12
+ request(callback) {
13
+ if (this.#depth > 0) this.#callbacks.add(callback);
14
+ else callback();
15
+ }
16
+ batch(fn) {
17
+ this.enter();
18
+ try {
19
+ fn();
20
+ } finally {
21
+ this.exit();
22
+ }
23
+ }
24
+ enter() {
25
+ this.#depth++;
26
+ }
27
+ exit() {
28
+ this.#depth--;
29
+ if (this.#depth === 0) for (const callback of this.#callbacks) {
30
+ this.#callbacks.delete(callback);
31
+ callback();
32
+ }
33
+ }
34
+ };
35
+ //#endregion
36
+ //#region src/config.ts
37
+ /**
38
+ * Immutable, resolved spring parameters. Whatever `defineSpring` input
39
+ * shape produced it, a config carries concrete `mass`, `tension`,
40
+ * `damping`, and `precision`, plus the quantities derived from them.
41
+ * Instances are frozen and safe to share between springs; to change a
42
+ * spring's behavior, assign a new config via `spring.config`.
43
+ */
44
+ var SpringDefinition = class SpringDefinition {
45
+ /** The config for springs created without one: critically damped, settling in about 500ms. */
46
+ static default = new SpringDefinition({
47
+ dampingRatio: 1,
48
+ duration: 500
49
+ });
50
+ /** Mass of the moving value, as provided or derived. */
51
+ mass;
52
+ /** Spring stiffness, as provided or derived. */
53
+ tension;
54
+ /** Friction strength, as provided or derived. */
55
+ damping;
56
+ /** Decimal places of the resting threshold — see `restingMagnitude`. */
57
+ precision;
58
+ /**
59
+ * Velocity multiplier applied when the value crosses the target, as
60
+ * provided or resolved from its named form. 1 (`'passthrough'`) leaves
61
+ * the motion untouched, 0 (`'stop'`) ends it at the target, negative
62
+ * values rebound.
63
+ */
64
+ arrival;
65
+ /** Oscillation rate with no damping: `sqrt(tension / mass)`, in radians per second. */
66
+ naturalFrequency;
67
+ /** The damping at which bounce disappears: `2 * mass * naturalFrequency`. */
68
+ criticalDamping;
69
+ /**
70
+ * `damping / criticalDamping`. Below 1 the spring overshoots and
71
+ * bounces, at 1 it settles fastest without overshooting, above 1 it
72
+ * settles slower.
73
+ */
74
+ dampingRatio;
75
+ /** Resting threshold in value units: half a unit in the last `precision` place. */
76
+ restingMagnitude;
77
+ constructor(input) {
78
+ const raw = input;
79
+ const mass = raw.mass;
80
+ const tension = raw.tension;
81
+ const damping = raw.damping;
82
+ const bounce = raw.bounce;
83
+ const duration = raw.duration !== void 0 ? raw.duration / 1e3 : void 0;
84
+ const displacement = raw.displacement ?? 1;
85
+ const precision = raw.precision ?? 2;
86
+ const arrival = raw.arrival;
87
+ for (const key of Object.keys(raw)) {
88
+ if (key === "arrival") continue;
89
+ const value = raw[key];
90
+ if (value !== void 0) invariant(Number.isFinite(value), () => `Invalid ${key}: expected a finite number`);
91
+ }
92
+ if (mass !== void 0) invariant(mass > 0, "Mass must be greater than 0");
93
+ if (tension !== void 0) invariant(tension > 0, "Tension must be greater than 0");
94
+ if (damping !== void 0) invariant(damping >= 0, "Damping must be greater than or equal to 0");
95
+ if (bounce !== void 0) invariant(bounce >= -1 && bounce <= 1, "Bounce must be between -1 and 1");
96
+ invariant(raw.dampingRatio === void 0 || bounce === void 0, "Provide either dampingRatio or bounce, not both");
97
+ const dampingRatio = raw.dampingRatio ?? (bounce !== void 0 ? Math.max(1e-4, 1 - bounce) : void 0);
98
+ if (dampingRatio !== void 0) invariant(dampingRatio >= 0, "Damping ratio must be greater than or equal to 0");
99
+ if (duration !== void 0) invariant(duration > 0, "Duration must be greater than 0");
100
+ invariant(displacement !== 0, "Displacement must not be 0");
101
+ invariant(precision >= 0, "Precision must be greater than or equal to 0");
102
+ invariant(arrival === void 0 || arrival === "passthrough" || arrival === "stop" || typeof arrival === "number" && Number.isFinite(arrival) && arrival >= -1 && arrival <= 1, "Arrival must be 'passthrough', 'stop', or a number between -1 and 1");
103
+ this.precision = precision;
104
+ this.restingMagnitude = .5 / 10 ** precision;
105
+ if (arrival === void 0 || arrival === "passthrough") this.arrival = 1;
106
+ else if (arrival === "stop") this.arrival = 0;
107
+ else this.arrival = arrival === 0 ? 0 : arrival;
108
+ const hasM = mass !== void 0;
109
+ const hasK = tension !== void 0;
110
+ const hasC = damping !== void 0;
111
+ const hasZ = dampingRatio !== void 0;
112
+ const hasT = duration !== void 0;
113
+ if (hasK && hasC && !hasZ && !hasT) {
114
+ this.mass = mass ?? 1;
115
+ this.tension = tension;
116
+ this.damping = damping;
117
+ } else if (hasK && hasZ && !hasC && !hasT) {
118
+ this.mass = mass ?? 1;
119
+ this.tension = tension;
120
+ this.damping = 2 * dampingRatio * Math.sqrt(this.mass * tension);
121
+ } else if (hasC && hasZ && !hasK && !hasT) {
122
+ invariant(dampingRatio > 0, "dampingRatio must be > 0 to derive tension");
123
+ this.mass = mass ?? 1;
124
+ this.tension = damping ** 2 / (4 * dampingRatio ** 2 * this.mass);
125
+ this.damping = damping;
126
+ } else if (hasK && hasC && hasZ && !hasT) {
127
+ invariant(!hasM, "mass is derived when tension, damping, and dampingRatio are all provided");
128
+ invariant(dampingRatio > 0, "dampingRatio must be > 0 to derive mass");
129
+ this.mass = damping ** 2 / (4 * dampingRatio ** 2 * tension);
130
+ this.tension = tension;
131
+ this.damping = damping;
132
+ } else if (hasZ && hasT) {
133
+ const threshold = .005;
134
+ const x0 = Math.abs(displacement);
135
+ invariant(x0 > threshold, "displacement must be greater than the precision threshold");
136
+ invariant(dampingRatio > 0, "dampingRatio must be > 0 for duration-based config");
137
+ const wn = restUnits(dampingRatio, x0 / threshold) / duration;
138
+ if (!hasK && !hasC) {
139
+ this.mass = mass ?? 1;
140
+ this.tension = wn ** 2 * this.mass;
141
+ this.damping = 2 * dampingRatio * this.mass * wn;
142
+ } else if (hasK && !hasC) {
143
+ invariant(!hasM, "mass is derived in duration-based configs with tension");
144
+ this.mass = tension / wn ** 2;
145
+ this.tension = tension;
146
+ this.damping = 2 * dampingRatio * this.mass * wn;
147
+ } else if (hasC && !hasK) {
148
+ invariant(!hasM, "mass is derived in duration-based configs with damping");
149
+ this.mass = damping / (2 * dampingRatio * wn);
150
+ this.tension = wn ** 2 * this.mass;
151
+ this.damping = damping;
152
+ } else throw new Error("Invalid spring config: provide one of the supported input shapes.");
153
+ } else throw new Error("Invalid spring config: provide one of the supported input shapes.");
154
+ this.naturalFrequency = Math.sqrt(this.tension / this.mass);
155
+ this.criticalDamping = 2 * this.mass * this.naturalFrequency;
156
+ this.dampingRatio = this.damping / this.criticalDamping;
157
+ Object.freeze(this);
158
+ }
159
+ /**
160
+ * Computes when a spring with this config comes to rest from `state`,
161
+ * in ms.
162
+ *
163
+ * The time is solved from the motion's decay bound rather than
164
+ * estimated: the spring is resting at the first tick at or after it.
165
+ * An underdamped spring can also rest earlier — its rest amplitude
166
+ * pulses, and a frame that samples a dip inside the resting threshold
167
+ * rests there — so read the value as "no later than", tight in
168
+ * practice. See
169
+ * https://github.com/tkofh/coily/blob/main/PRECISION.md.
170
+ *
171
+ * Returns 0 when `state` is already inside the resting threshold.
172
+ * Returns Infinity when the motion never settles: a `dampingRatio` of
173
+ * 0 whose `arrival` neither stops nor slows it. When `arrival` is 0
174
+ * the result is capped at the first target crossing, where such a
175
+ * spring rests exactly; other multipliers fold their per-crossing
176
+ * velocity loss into the time.
177
+ */
178
+ computeTimeRemaining(state) {
179
+ const { position, velocity } = state;
180
+ if (Math.abs(position) + Math.abs(velocity) / this.naturalFrequency <= this.restingMagnitude) return 0;
181
+ const crossing = this.arrival === 0 ? firstCrossing(this, position, velocity) * 1e3 : Infinity;
182
+ return Math.min(restTime(this, position, velocity) * 1e3, crossing);
183
+ }
184
+ };
185
+ function solveEntry(f, threshold, peak) {
186
+ let low = 0;
187
+ let high = Math.max(peak, 1e-6);
188
+ while (f(high) > threshold) {
189
+ low = high;
190
+ high *= 2;
191
+ }
192
+ for (let i = 0; i < 60; i++) {
193
+ const mid = (low + high) / 2;
194
+ if (f(mid) > threshold) low = mid;
195
+ else high = mid;
196
+ }
197
+ return high;
198
+ }
199
+ function restTime(config, position, velocity) {
200
+ const { naturalFrequency, dampingRatio, restingMagnitude, arrival } = config;
201
+ if (dampingRatio < 1) {
202
+ const sigma = dampingRatio * naturalFrequency;
203
+ const wd = naturalFrequency * Math.sqrt(1 - dampingRatio ** 2);
204
+ const R = Math.hypot(position, (velocity + sigma * position) / wd);
205
+ const drop = Math.log(R * Math.sqrt(2 + 2 * dampingRatio) / restingMagnitude);
206
+ const plain = sigma > 0 ? drop / sigma : Infinity;
207
+ const strength = Math.abs(arrival);
208
+ if (strength === 0 || strength === 1) return plain;
209
+ const first = firstCrossing(config, position, velocity);
210
+ if (plain <= first) return plain;
211
+ const loss = -Math.log(strength) * wd / Math.PI;
212
+ return (drop + loss * first) / (sigma + loss);
213
+ }
214
+ if (dampingRatio === 1) {
215
+ const a0 = Math.abs(position) + Math.abs(velocity) / naturalFrequency;
216
+ const beta = 2 * Math.abs(velocity + naturalFrequency * position);
217
+ return solveEntry((t) => Math.exp(-naturalFrequency * t) * (a0 + beta * t), restingMagnitude, beta > 0 ? Math.max(0, 1 / naturalFrequency - a0 / beta) : 0);
218
+ }
219
+ const wd = naturalFrequency * Math.sqrt(dampingRatio ** 2 - 1);
220
+ const slow = dampingRatio * naturalFrequency - wd;
221
+ const fast = dampingRatio * naturalFrequency + wd;
222
+ const slowAmplitude = Math.abs(velocity + fast * position) / (2 * wd) * (1 + slow / naturalFrequency);
223
+ const fastAmplitude = Math.abs(velocity + slow * position) / (2 * wd) * (1 + fast / naturalFrequency);
224
+ return solveEntry((t) => slowAmplitude * Math.exp(-slow * t) + fastAmplitude * Math.exp(-fast * t), restingMagnitude, 0);
225
+ }
226
+ function restUnits(dampingRatio, ratio) {
227
+ if (dampingRatio < 1) return Math.log(ratio * Math.sqrt(2 / (1 - dampingRatio))) / dampingRatio;
228
+ if (dampingRatio === 1) return solveEntry((u) => Math.exp(-u) * (1 + 2 * u), 1 / ratio, .5);
229
+ const wd = Math.sqrt(dampingRatio ** 2 - 1);
230
+ const slow = dampingRatio - wd;
231
+ const fast = dampingRatio + wd;
232
+ const slowAmplitude = fast / (2 * wd) * (1 + slow);
233
+ const fastAmplitude = slow / (2 * wd) * (1 + fast);
234
+ return solveEntry((u) => slowAmplitude * Math.exp(-slow * u) + fastAmplitude * Math.exp(-fast * u), 1 / ratio, 0);
235
+ }
236
+ function firstCrossing(config, position, velocity) {
237
+ const { naturalFrequency, dampingRatio } = config;
238
+ if (dampingRatio < 1) {
239
+ const wd = naturalFrequency * Math.sqrt(1 - dampingRatio ** 2);
240
+ const c2 = (velocity + dampingRatio * naturalFrequency * position) / wd;
241
+ let theta = Math.atan2(-position, c2) % Math.PI;
242
+ if (theta <= 0) theta += Math.PI;
243
+ return theta / wd;
244
+ }
245
+ if (dampingRatio === 1) {
246
+ const t = -position / (velocity + naturalFrequency * position);
247
+ return t > 0 ? t : Infinity;
248
+ }
249
+ const wd = naturalFrequency * Math.sqrt(dampingRatio ** 2 - 1);
250
+ const theta = Math.atanh(-position * wd / (velocity + dampingRatio * naturalFrequency * position));
251
+ return theta > 0 ? theta / wd : Infinity;
252
+ }
253
+ function defineSpring(input) {
254
+ return new SpringDefinition(input);
255
+ }
256
+ //#endregion
257
+ //#region src/emitter.ts
258
+ /** Minimal fixed-event emitter behind motions and composite springs. */
259
+ var Emitter = class {
260
+ #update = [];
261
+ #start = [];
262
+ #stop = [];
263
+ #dispose = [];
264
+ on(type, handler) {
265
+ this.#list(type).push(handler);
266
+ return () => {
267
+ this.off(type, handler);
268
+ };
269
+ }
270
+ off(type, handler) {
271
+ const list = this.#list(type);
272
+ if (handler) list.splice(list.indexOf(handler) >>> 0, 1);
273
+ else list.length = 0;
274
+ }
275
+ clear() {
276
+ this.#update.length = 0;
277
+ this.#start.length = 0;
278
+ this.#stop.length = 0;
279
+ this.#dispose.length = 0;
280
+ }
281
+ emit(type) {
282
+ const list = this.#list(type);
283
+ if (list.length === 1) list[0]();
284
+ else if (list.length > 1) for (const handler of list.slice()) handler();
285
+ }
286
+ #list(type) {
287
+ switch (type) {
288
+ case "update": return this.#update;
289
+ case "start": return this.#start;
290
+ case "stop": return this.#stop;
291
+ case "dispose": return this.#dispose;
292
+ }
293
+ }
294
+ };
295
+ //#endregion
296
+ //#region src/motion.ts
297
+ let nextMotionId = 0;
298
+ /**
299
+ * One spring simulation in displacement space: position is measured from
300
+ * the target (0 means settled), so retargeting is a rebase performed by
301
+ * the owning `Spring`. Picks the closed-form solver for the config's
302
+ * damping regime and emits update/start/stop/dispose events.
303
+ */
304
+ var Motion = class {
305
+ /** Monotone creation id: breaks ties wherever motions need a deterministic order. */
306
+ _id = nextMotionId++;
307
+ /** Tick-pass marker written by `MotionSet` so a motion re-added mid-pass isn't ticked twice. */
308
+ _pass = 0;
309
+ /**
310
+ * Frame marker written by `MotionSet`: sub-steps share one frame, and
311
+ * the first advance in it queues the motion for the settle sweep.
312
+ */
313
+ _frame = 0;
314
+ /**
315
+ * Dependency rank written by `FollowGraph`: motions the follow graph
316
+ * touches count up from 0, leaders before followers; -1 marks a motion
317
+ * outside the graph.
318
+ */
319
+ _rank = -1;
320
+ /**
321
+ * The edge this motion follows through, or null. Written by
322
+ * `FollowGraph` on edge add/remove so the tick's hot loop reads a
323
+ * field instead of a map.
324
+ */
325
+ _edge = null;
326
+ /**
327
+ * A first-order-hold ramp slope in value units per second, armed by a
328
+ * ramped `recouple` immediately before the `_advance` that consumes
329
+ * it (read-and-clear; no user code runs between the two). 0 means the
330
+ * target holds still across the step — every path but a ramped
331
+ * recouple.
332
+ */
333
+ _ramp = 0;
334
+ /**
335
+ * The edges reading this motion, or null when none. Written by
336
+ * `FollowGraph` with the rank cache, so it can lag a mid-pass edge
337
+ * change until the next recompute; the tick tolerates stale entries.
338
+ */
339
+ _followers = null;
340
+ respectsReducedMotion = true;
341
+ #config;
342
+ #state;
343
+ #underdampedSolver = null;
344
+ #criticallyDampedSolver = null;
345
+ #overdampedSolver = null;
346
+ #currentSolver = null;
347
+ #needsUpdate = false;
348
+ #needsReset = false;
349
+ #running;
350
+ #emitter;
351
+ constructor(config, position, velocity) {
352
+ this.#config = config;
353
+ this.#state = new State(config, position, velocity);
354
+ this.#emitter = new Emitter();
355
+ this.#running = !this.#state.isResting;
356
+ this.#updateSolver();
357
+ }
358
+ get position() {
359
+ return this.#state.position;
360
+ }
361
+ set position(value) {
362
+ this.#state.position = value;
363
+ this.#needsReset = true;
364
+ this.#syncStart();
365
+ }
366
+ get velocity() {
367
+ return this.#state.velocity;
368
+ }
369
+ set velocity(value) {
370
+ this.#state.velocity = value;
371
+ this.#needsReset = true;
372
+ this.#syncStart();
373
+ }
374
+ get timeRemaining() {
375
+ return this.#config.computeTimeRemaining(this.#state);
376
+ }
377
+ get isResting() {
378
+ return this.#state.isResting;
379
+ }
380
+ configure(config) {
381
+ this.#config = config;
382
+ this.#state.configure(config);
383
+ this.#needsUpdate = true;
384
+ this.#syncStart();
385
+ }
386
+ tick(dt, emit = true) {
387
+ this._advance(dt);
388
+ if (emit) this._settleFrame();
389
+ else this.#reconcileRunning();
390
+ }
391
+ /**
392
+ * Advances the simulation and emits nothing. The tick pass advances
393
+ * every motion through this, then delivers the frame's events with
394
+ * `_settleFrame`; the synchronous `tick` wraps the two.
395
+ *
396
+ * A pending `_ramp` integrates the step against a target moving at
397
+ * that slope instead of holding still: one affine transform around
398
+ * the unchanged solver. With the target at `p(t) = p0 + g*t`, the
399
+ * displacement u = x - p(t) obeys the homogeneous equation shifted by
400
+ * the constant u_ss = -(2*zeta/wn)*g = -(damping/tension)*g, so the
401
+ * solver ticks w = u - u_ss exactly and the results shift back.
402
+ * Measuring u from the moving target makes the ramp endpoint and the
403
+ * new target coincide — the armer assigns the target, no rebase
404
+ * arithmetic exists. The ramp is an argument of the step, never
405
+ * persistent solver state: g = 0 takes the plain step below, so a
406
+ * motion that never ramps steps bit-exactly like an isolated spring.
407
+ */
408
+ _advance(dt) {
409
+ invariant(this.#currentSolver, "Cannot tick a disposed motion");
410
+ const g = this._ramp;
411
+ if (g !== 0) {
412
+ this._ramp = 0;
413
+ const state = this.#state;
414
+ const shift = this.#config.damping / this.#config.tension * g;
415
+ state.position += shift;
416
+ state.velocity -= g;
417
+ if (this.#needsUpdate) {
418
+ this.#updateSolver();
419
+ this.#needsUpdate = false;
420
+ } else this.#currentSolver.configure();
421
+ this.#currentSolver.tick(dt);
422
+ state.position -= shift;
423
+ state.velocity += g;
424
+ this.#needsReset = true;
425
+ if (state.isResting) {
426
+ state.position = 0;
427
+ state.velocity = 0;
428
+ }
429
+ return;
430
+ }
431
+ if (this.#needsUpdate) {
432
+ this.#updateSolver();
433
+ this.#needsUpdate = false;
434
+ this.#needsReset = false;
435
+ } else if (this.#needsReset) {
436
+ this.#currentSolver.configure();
437
+ this.#needsReset = false;
438
+ }
439
+ this.#currentSolver.tick(dt);
440
+ if (this.#state.isResting) {
441
+ this.#state.position = 0;
442
+ this.#state.velocity = 0;
443
+ this.#needsReset = true;
444
+ }
445
+ }
446
+ /**
447
+ * Emits the frame's `update`, then reconciles `stop`/`start`. The tick
448
+ * pass calls this in dependency order after every motion has advanced,
449
+ * so update callbacks read frame-final values everywhere.
450
+ */
451
+ _settleFrame() {
452
+ this.#emitter.emit("update");
453
+ this.#reconcileRunning();
454
+ }
455
+ #reconcileRunning() {
456
+ if (this.#state.isResting) {
457
+ if (this.#running) {
458
+ this.#running = false;
459
+ this.#emitter.emit("stop");
460
+ }
461
+ } else if (!this.#running) {
462
+ this.#running = true;
463
+ this.#emitter.emit("start");
464
+ }
465
+ }
466
+ finish() {
467
+ this.position = 0;
468
+ this.velocity = 0;
469
+ this.tick(0);
470
+ }
471
+ /**
472
+ * The sub-step controller's shock signal, read by a follower's `plan`
473
+ * with this motion as its leader: deviation from the quasi-steady
474
+ * tracking manifold x = -(damping/tension) * v, where damping/tension
475
+ * is 2*zeta/wn. Near zero while chasing any smoothly moving target at
476
+ * any speed; the full displacement after a target teleport;
477
+ * (damping/tension) * |v| after a fling.
478
+ */
479
+ _manifoldDeviation() {
480
+ const config = this.#config;
481
+ return Math.abs(this.#state.position + config.damping / config.tension * this.#state.velocity);
482
+ }
483
+ /** Acceleration from current state, for `plan`'s kinematic bound on a shock frame. */
484
+ _acceleration() {
485
+ const config = this.#config;
486
+ return -(config.tension * this.#state.position + config.damping * this.#state.velocity) / config.mass;
487
+ }
488
+ onUpdate(callback) {
489
+ return this.#emitter.on("update", callback);
490
+ }
491
+ onStart(callback) {
492
+ return this.#emitter.on("start", callback);
493
+ }
494
+ onStop(callback) {
495
+ return this.#emitter.on("stop", callback);
496
+ }
497
+ onDispose(callback) {
498
+ return this.#emitter.on("dispose", callback);
499
+ }
500
+ dispose() {
501
+ this.#emitter.emit("dispose");
502
+ this.#emitter.clear();
503
+ this.#underdampedSolver = null;
504
+ this.#criticallyDampedSolver = null;
505
+ this.#overdampedSolver = null;
506
+ this.#currentSolver = null;
507
+ }
508
+ #syncStart() {
509
+ if (!this.#running && !this.#state.isResting) {
510
+ this.#running = true;
511
+ this.#emitter.emit("start");
512
+ }
513
+ }
514
+ #updateSolver() {
515
+ if (this.#config.dampingRatio < 1) {
516
+ this.#underdampedSolver ||= new UnderdampedSolver(this.#state);
517
+ this.#currentSolver = this.#underdampedSolver;
518
+ } else if (this.#config.dampingRatio === 1) {
519
+ this.#criticallyDampedSolver ||= new CriticallyDampedSolver(this.#state);
520
+ this.#currentSolver = this.#criticallyDampedSolver;
521
+ } else {
522
+ this.#overdampedSolver ||= new OverdampedSolver(this.#state);
523
+ this.#currentSolver = this.#overdampedSolver;
524
+ }
525
+ this.#currentSolver.configure(this.#config);
526
+ }
527
+ };
528
+ //#endregion
529
+ //#region src/shape-tree.ts
530
+ function joinPath(path, key) {
531
+ return path ? `${path}.${key}` : String(key);
532
+ }
533
+ function describePath(path) {
534
+ return path ? `'${path}'` : "the root";
535
+ }
536
+ /** The sentinel a resolver returns to descend into a node instead of covering it. */
537
+ const BRANCH = Symbol("branch");
538
+ /** The standard scatter leaf guard: partial numeric shapes take only finite numbers at channels. */
539
+ function acceptNumber(input, path) {
540
+ invariant(isNumber(input) && Number.isFinite(input), () => `Expected a finite number for channel '${path}'`);
541
+ return input;
542
+ }
543
+ /**
544
+ * The canonical numeric `LeafParser`: finite-number leaves built through
545
+ * `factory`, named "channels" in structural errors. The default for value
546
+ * shapes, whose leaves are all numbers.
547
+ */
548
+ function channelParser(factory) {
549
+ return {
550
+ match(value, path) {
551
+ if (!isNumber(value)) return void 0;
552
+ invariant(Number.isFinite(value), () => `Invalid value at '${path}': channel values must be finite`);
553
+ return factory(value, path);
554
+ },
555
+ mismatch: (path) => `Invalid value at '${path}': expected a number or a nested shape of numbers`,
556
+ empty: (path) => `Invalid value at ${describePath(path)}: a shape must contain at least one channel`
557
+ };
558
+ }
559
+ var LeafNode = class LeafNode {
560
+ path;
561
+ leaf;
562
+ constructor(leaf, path) {
563
+ this.path = path;
564
+ this.leaf = leaf;
565
+ }
566
+ mirror(read, slots, carrier, key) {
567
+ carrier[key] = read(this.leaf);
568
+ slots.push({
569
+ leaf: this.leaf,
570
+ key,
571
+ carrier
572
+ });
573
+ }
574
+ scatter(input, accept, apply) {
575
+ apply(this.leaf, accept(input, this.path));
576
+ }
577
+ zip(theirs, fn) {
578
+ invariant(theirs instanceof LeafNode, () => `Shape mismatch at ${describePath(this.path)}`);
579
+ fn(this.leaf, theirs.leaf);
580
+ }
581
+ broadcast(input, resolve, apply, _label) {
582
+ const resolved = resolve(input, this.path);
583
+ invariant(resolved !== BRANCH, `Cannot descend into channel '${this.path}'`);
584
+ apply(this.leaf, resolved);
585
+ }
586
+ cover(value, apply) {
587
+ apply(this.leaf, value);
588
+ }
589
+ };
590
+ var ListNode = class ListNode {
591
+ path;
592
+ children;
593
+ constructor(children, path) {
594
+ this.path = path;
595
+ this.children = children;
596
+ }
597
+ mirror(read, slots, carrier, key) {
598
+ const mine = [];
599
+ carrier[key] = mine;
600
+ const slotcarrier = mine;
601
+ for (let i = 0; i < this.children.length; i++) this.children[i].mirror(read, slots, slotcarrier, i);
602
+ }
603
+ scatter(input, accept, apply) {
604
+ invariant(isArray(input), () => `Expected an array at ${describePath(this.path)}`);
605
+ invariant(input.length <= this.children.length, `Unknown channel '${joinPath(this.path, this.children.length)}'`);
606
+ for (let i = 0; i < input.length; i++) {
607
+ if (input[i] === void 0) continue;
608
+ this.children[i].scatter(input[i], accept, apply);
609
+ }
610
+ }
611
+ zip(theirs, fn) {
612
+ invariant(theirs instanceof ListNode && theirs.children.length === this.children.length, () => `Shape mismatch at ${describePath(this.path)}`);
613
+ for (let i = 0; i < this.children.length; i++) this.children[i].zip(theirs.children[i], fn);
614
+ }
615
+ broadcast(input, resolve, apply, label) {
616
+ const resolved = resolve(input, this.path);
617
+ if (resolved !== BRANCH) {
618
+ this.cover(resolved, apply);
619
+ return;
620
+ }
621
+ invariant(isArray(input), () => `Expected an array at ${describePath(this.path)} in ${label}`);
622
+ invariant(input.length <= this.children.length, () => `Unknown channel '${joinPath(this.path, this.children.length)}' in ${label}`);
623
+ for (let i = 0; i < input.length; i++) {
624
+ if (input[i] === void 0) continue;
625
+ this.children[i].broadcast(input[i], resolve, apply, label);
626
+ }
627
+ }
628
+ cover(value, apply) {
629
+ for (const child of this.children) child.cover(value, apply);
630
+ }
631
+ };
632
+ var RecordNode = class RecordNode {
633
+ path;
634
+ children;
635
+ constructor(children, path) {
636
+ this.path = path;
637
+ this.children = children;
638
+ }
639
+ mirror(read, slots, carrier, key) {
640
+ const mine = {};
641
+ carrier[key] = mine;
642
+ for (const [childKey, child] of this.children) child.mirror(read, slots, mine, childKey);
643
+ }
644
+ scatter(input, accept, apply) {
645
+ invariant(isRecord(input), () => `Expected an object at ${describePath(this.path)}`);
646
+ for (const key of Object.keys(input)) {
647
+ const value = input[key];
648
+ if (value === void 0) continue;
649
+ const child = this.children.get(key);
650
+ invariant(child !== void 0, () => `Unknown channel '${joinPath(this.path, key)}'`);
651
+ child.scatter(value, accept, apply);
652
+ }
653
+ }
654
+ zip(theirs, fn) {
655
+ invariant(theirs instanceof RecordNode && theirs.children.size === this.children.size, () => `Shape mismatch at ${describePath(this.path)}`);
656
+ for (const [key, child] of this.children) {
657
+ const their = theirs.children.get(key);
658
+ invariant(their !== void 0, () => `Shape mismatch at ${describePath(this.path)}`);
659
+ child.zip(their, fn);
660
+ }
661
+ }
662
+ broadcast(input, resolve, apply, label) {
663
+ const resolved = resolve(input, this.path);
664
+ if (resolved !== BRANCH) {
665
+ this.cover(resolved, apply);
666
+ return;
667
+ }
668
+ invariant(isRecord(input), () => `Expected an object at ${describePath(this.path)} in ${label}`);
669
+ for (const key of Object.keys(input)) {
670
+ const value = input[key];
671
+ if (value === void 0) continue;
672
+ const child = this.children.get(key);
673
+ invariant(child !== void 0, () => `Unknown channel '${joinPath(this.path, key)}' in ${label}`);
674
+ child.broadcast(value, resolve, apply, label);
675
+ }
676
+ }
677
+ cover(value, apply) {
678
+ for (const child of this.children.values()) child.cover(value, apply);
679
+ }
680
+ };
681
+ /**
682
+ * A shape parsed into a node tree, paired with its leaves flattened in
683
+ * depth-first shape order. `parser.match` decides leaf vs branch at every
684
+ * position; the root must already be a container — callers validate that
685
+ * (with their own message) before building. Nested non-containers and
686
+ * empty nodes throw through `parser.mismatch`/`parser.empty`.
687
+ *
688
+ * Holds no behavior of its own: traversals run through `root`, and a live
689
+ * view comes from `new ShapeView(root, read)`.
690
+ */
691
+ var ShapeTree = class {
692
+ root;
693
+ leaves;
694
+ constructor(shape, parser) {
695
+ const leaves = [];
696
+ this.root = buildContainer(shape, "", parser, leaves);
697
+ this.leaves = leaves;
698
+ }
699
+ };
700
+ function buildContainer(shape, path, parser, leaves) {
701
+ if (isArray(shape)) {
702
+ invariant(shape.length > 0, () => parser.empty(path));
703
+ return new ListNode(shape.map((child, i) => buildChild(child, joinPath(path, i), parser, leaves)), path);
704
+ }
705
+ const keys = Object.keys(shape);
706
+ invariant(keys.length > 0, () => parser.empty(path));
707
+ const children = /* @__PURE__ */ new Map();
708
+ for (const key of keys) children.set(key, buildChild(shape[key], joinPath(path, key), parser, leaves));
709
+ return new RecordNode(children, path);
710
+ }
711
+ function buildChild(value, path, parser, leaves) {
712
+ const leaf = parser.match(value, path);
713
+ if (leaf !== void 0) {
714
+ leaves.push(leaf);
715
+ return new LeafNode(leaf, path);
716
+ }
717
+ invariant(isRecordOrArray(value), () => parser.mismatch(path));
718
+ return buildContainer(value, path, parser, leaves);
719
+ }
720
+ /**
721
+ * A live view of a shape: `read(leaf)` at every leaf, mirrored into a stable
722
+ * object — `refresh` rewrites the values in place through slots recorded at
723
+ * construction, so per-frame reads do no traversal or allocation.
724
+ */
725
+ var ShapeView = class {
726
+ root;
727
+ #read;
728
+ #slots = [];
729
+ constructor(root, read) {
730
+ this.#read = read;
731
+ const scratch = {};
732
+ root.mirror(read, this.#slots, scratch, "root");
733
+ this.root = scratch["root"];
734
+ }
735
+ /** Re-reads every leaf into the view. */
736
+ refresh() {
737
+ for (const slot of this.#slots) slot.carrier[slot.key] = this.#read(slot.leaf);
738
+ }
739
+ };
740
+ //#endregion
741
+ //#region src/spring-source.ts
742
+ /**
743
+ * The property key that brands a `SpringSource` and holds the channel its
744
+ * followers read. A registry symbol (`Symbol.for('coily/spring-source')`),
745
+ * so sources from one copy of coily are recognized by another when a
746
+ * bundle duplicates the library.
747
+ */
748
+ const SpringSourceSymbol = Symbol.for("coily/spring-source");
749
+ function isSpringSource(value) {
750
+ return typeof value === "object" && value !== null && SpringSourceSymbol in value;
751
+ }
752
+ const RECIPES = /* @__PURE__ */ new WeakMap();
753
+ /** The flattened recipe behind `source`, when it is a derived source. */
754
+ function recipeOf(source) {
755
+ return RECIPES.get(source);
756
+ }
757
+ /**
758
+ * Registers a derived source's recipe. Every source built on other
759
+ * sources must register at creation: `mapSpring` composition and
760
+ * `resolveLeaderMotions` both reach a derivation's roots through the
761
+ * registry rather than through the wrapper object.
762
+ */
763
+ function registerRecipe(source, recipe) {
764
+ RECIPES.set(source, recipe);
765
+ }
766
+ function applyFns(fns, value) {
767
+ let result = value;
768
+ for (const fn of fns) result = fn(result);
769
+ return result;
770
+ }
771
+ function sourceMismatch(path) {
772
+ return `Invalid value at ${describePath(path)}: expected a SpringSource or a nested shape of SpringSources`;
773
+ }
774
+ /**
775
+ * Parses a shape of sources into a live view, collecting subscription
776
+ * roots along the way (a mapped leaf contributes its recipe's roots and
777
+ * reads through its pipeline). Structure is validated once at creation;
778
+ * reads refresh a stable mirror through the view's slots, so per-update
779
+ * reads do no traversal or allocation.
780
+ */
781
+ function compileShape(shape, roots) {
782
+ const { root } = new ShapeTree(shape, {
783
+ match(value) {
784
+ if (!isSpringSource(value)) return void 0;
785
+ const recipe = RECIPES.get(value);
786
+ if (recipe) {
787
+ roots.push(...recipe.sources);
788
+ return () => applyFns(recipe.fns, recipe.read());
789
+ }
790
+ roots.push(value);
791
+ const api = value[SpringSourceSymbol];
792
+ return () => api.value;
793
+ },
794
+ mismatch: sourceMismatch,
795
+ empty: (path) => `Invalid value at ${describePath(path)}: a shape must contain at least one source`
796
+ });
797
+ return new ShapeView(root, (leaf) => leaf());
798
+ }
799
+ function mapSpring(source, map) {
800
+ const compute = map;
801
+ let recipe;
802
+ if (isSpringSource(source)) {
803
+ const flat = RECIPES.get(source);
804
+ if (flat) recipe = {
805
+ sources: flat.sources,
806
+ read: flat.read,
807
+ fns: [...flat.fns, compute]
808
+ };
809
+ else {
810
+ const api = source[SpringSourceSymbol];
811
+ recipe = {
812
+ sources: [source],
813
+ read: () => api.value,
814
+ fns: [compute]
815
+ };
816
+ }
817
+ } else {
818
+ invariant(isRecordOrArray(source), () => sourceMismatch(""));
819
+ const roots = [];
820
+ const view = compileShape(source, roots);
821
+ recipe = {
822
+ sources: [...new Set(roots)],
823
+ read: () => {
824
+ view.refresh();
825
+ return view.root;
826
+ },
827
+ fns: [compute]
828
+ };
829
+ }
830
+ const { sources, read, fns } = recipe;
831
+ const single = sources.length === 1 ? sources[0][SpringSourceSymbol] : null;
832
+ const apis = single ? null : sources.map((leaf) => leaf[SpringSourceSymbol]);
833
+ const mapped = Object.freeze({ [SpringSourceSymbol]: Object.freeze({
834
+ get value() {
835
+ return applyFns(fns, read());
836
+ },
837
+ onUpdate: single ? (callback) => single.onUpdate(callback) : (callback) => {
838
+ const unsubscribes = apis.map((api) => api.onUpdate(callback));
839
+ return () => {
840
+ for (const unsubscribe of unsubscribes) unsubscribe();
841
+ };
842
+ },
843
+ onDispose: single ? (callback) => single.onDispose(callback) : (callback) => {
844
+ let fired = false;
845
+ const unsubscribes = apis.map((api) => api.onDispose(() => {
846
+ if (fired) return;
847
+ fired = true;
848
+ for (const unsubscribe of unsubscribes) unsubscribe();
849
+ callback();
850
+ }));
851
+ return () => {
852
+ for (const unsubscribe of unsubscribes) unsubscribe();
853
+ };
854
+ }
855
+ }) });
856
+ RECIPES.set(mapped, recipe);
857
+ return mapped;
858
+ }
859
+ //#endregion
860
+ //#region src/follow-graph.ts
861
+ const MOTION_BACKING = /* @__PURE__ */ new WeakMap();
862
+ /**
863
+ * Records what advances `source`: its own `Motion` for a scalar spring,
864
+ * or the aggregated sources whose own registrations lead to motions (a
865
+ * composite registers its channel springs). Called from the
866
+ * `SpringSourceSymbol` brand getters on every api read — lazily, so
867
+ * springs never used as sources stay out of the weak registry, and
868
+ * idempotently, so repeated reads just overwrite the entry.
869
+ */
870
+ function registerBacking(source, backing) {
871
+ MOTION_BACKING.set(source, backing);
872
+ }
873
+ /**
874
+ * Resolves the motions behind `source`, deduplicated: every spring
875
+ * reached through any chain of derivations — `mapSpring` pipelines,
876
+ * `velocityOf`/`accelerationOf` wrappers, composite channels. A foreign
877
+ * source resolves to nothing: it imposes no tick-ordering constraint and
878
+ * couples through its emitter alone.
879
+ */
880
+ function resolveLeaderMotions(source) {
881
+ const leaders = /* @__PURE__ */ new Set();
882
+ collectInto(source, leaders);
883
+ return [...leaders];
884
+ }
885
+ function collectInto(source, leaders) {
886
+ const recipe = recipeOf(source);
887
+ if (recipe) {
888
+ for (const root of recipe.sources) collectInto(root, leaders);
889
+ return;
890
+ }
891
+ let backing = MOTION_BACKING.get(source);
892
+ if (backing === void 0 && isSpringSource(source)) {
893
+ const api = source[SpringSourceSymbol];
894
+ backing = MOTION_BACKING.get(source);
895
+ if (backing === void 0 && typeof api === "object" && api !== null && api !== source) {
896
+ collectInto(api, leaders);
897
+ return;
898
+ }
899
+ }
900
+ if (backing === void 0) return;
901
+ if (backing instanceof Motion) leaders.add(backing);
902
+ else for (const part of backing) collectInto(part, leaders);
903
+ }
904
+ const NO_LEADERS = [];
905
+ /**
906
+ * The follow graph as a walkable structure: one edge per following
907
+ * motion, and a cached dependency rank over every motion the edges
908
+ * touch. `MotionSet` owns one; springs register and unregister edges as
909
+ * follows are wired and torn down. What the tick reads per motion —
910
+ * `_edge`, `_followers`, `_rank` — lives on the motions themselves;
911
+ * this class is their writer.
912
+ */
913
+ var FollowGraph = class {
914
+ #edges = /* @__PURE__ */ new Map();
915
+ /** Motions holding a rank from the last recompute, so stale ranks reset to -1. */
916
+ #ranked = [];
917
+ /** Motions holding a follower list from the last recompute, so stale lists reset to null. */
918
+ #withFollowers = [];
919
+ /** Edge list mirroring the map, rebuilt with the ranks, so the per-frame plan pass iterates an array. */
920
+ #edgeList = [];
921
+ #dirty = false;
922
+ /** Whether no edges are registered, letting the tick skip ordering work entirely. */
923
+ get isEmpty() {
924
+ return this.#edges.size === 0;
925
+ }
926
+ /** Every registered edge, valid after `ensureOrder`; the plan pass walks this once per frame. */
927
+ get edges() {
928
+ return this.#edgeList;
929
+ }
930
+ /**
931
+ * Registers `edge`, replacing any edge with the same follower: a
932
+ * spring follows at most one source at a time.
933
+ */
934
+ addEdge(edge) {
935
+ this.#edges.set(edge.follower, edge);
936
+ edge.follower._edge = edge;
937
+ this.#dirty = true;
938
+ }
939
+ /** Drops the edge whose follower is `motion`, if one is registered. */
940
+ removeEdge(follower) {
941
+ if (this.#edges.delete(follower)) {
942
+ follower._edge = null;
943
+ this.#dirty = true;
944
+ }
945
+ }
946
+ /**
947
+ * Recomputes ranks if an edge was added or removed since the last
948
+ * call; clean calls return immediately. Afterward, motions the graph
949
+ * touches hold ranks counting up from 0 with every leader ranked
950
+ * before its followers, members of a cycle rank in ascending creation
951
+ * id, and motions outside the graph hold -1. Ranks are canonical: a
952
+ * function of graph structure and creation order, never of wiring
953
+ * order.
954
+ */
955
+ ensureOrder() {
956
+ if (!this.#dirty) return;
957
+ this.#dirty = false;
958
+ this.#recompute();
959
+ }
960
+ #recompute() {
961
+ for (const motion of this.#ranked) motion._rank = -1;
962
+ for (const motion of this.#withFollowers) motion._followers = null;
963
+ const withFollowers = [];
964
+ for (const edge of this.#edges.values()) for (const leader of edge.leaders) {
965
+ if (leader._followers === null) {
966
+ leader._followers = [];
967
+ withFollowers.push(leader);
968
+ }
969
+ leader._followers.push(edge);
970
+ }
971
+ this.#withFollowers = withFollowers;
972
+ const nodes = /* @__PURE__ */ new Set();
973
+ for (const [follower, edge] of this.#edges) {
974
+ nodes.add(follower);
975
+ for (const leader of edge.leaders) nodes.add(leader);
976
+ }
977
+ const roots = [...nodes].sort((a, b) => a._id - b._id);
978
+ const indexOf = /* @__PURE__ */ new Map();
979
+ const lowOf = /* @__PURE__ */ new Map();
980
+ const onStack = /* @__PURE__ */ new Set();
981
+ const stack = [];
982
+ const ranked = [];
983
+ const sccOf = /* @__PURE__ */ new Map();
984
+ let index = 0;
985
+ let rank = 0;
986
+ let scc = 0;
987
+ for (const root of roots) {
988
+ if (indexOf.has(root)) continue;
989
+ const frames = [{
990
+ node: root,
991
+ leaders: this.#leadersOf(root),
992
+ next: 0
993
+ }];
994
+ indexOf.set(root, index);
995
+ lowOf.set(root, index);
996
+ index++;
997
+ stack.push(root);
998
+ onStack.add(root);
999
+ while (frames.length > 0) {
1000
+ const frame = frames.at(-1);
1001
+ if (frame.next < frame.leaders.length) {
1002
+ const leader = frame.leaders[frame.next++];
1003
+ const seen = indexOf.get(leader);
1004
+ if (seen === void 0) {
1005
+ indexOf.set(leader, index);
1006
+ lowOf.set(leader, index);
1007
+ index++;
1008
+ stack.push(leader);
1009
+ onStack.add(leader);
1010
+ frames.push({
1011
+ node: leader,
1012
+ leaders: this.#leadersOf(leader),
1013
+ next: 0
1014
+ });
1015
+ } else if (onStack.has(leader)) lowOf.set(frame.node, Math.min(lowOf.get(frame.node), seen));
1016
+ } else {
1017
+ frames.pop();
1018
+ const low = lowOf.get(frame.node);
1019
+ const parent = frames.at(-1);
1020
+ if (parent !== void 0) lowOf.set(parent.node, Math.min(lowOf.get(parent.node), low));
1021
+ if (low === indexOf.get(frame.node)) {
1022
+ const members = [];
1023
+ let member;
1024
+ do {
1025
+ member = stack.pop();
1026
+ onStack.delete(member);
1027
+ members.push(member);
1028
+ } while (member !== frame.node);
1029
+ members.sort((a, b) => a._id - b._id);
1030
+ for (const motion of members) {
1031
+ motion._rank = rank++;
1032
+ sccOf.set(motion, scc);
1033
+ ranked.push(motion);
1034
+ }
1035
+ scc++;
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+ this.#ranked = ranked;
1041
+ const edgeList = [...this.#edges.values()];
1042
+ for (const edge of edgeList) {
1043
+ const home = sccOf.get(edge.follower);
1044
+ edge._cyclic = edge.leaders.some((leader) => sccOf.get(leader) === home);
1045
+ }
1046
+ this.#edgeList = edgeList;
1047
+ }
1048
+ #leadersOf(motion) {
1049
+ return this.#edges.get(motion)?.leaders ?? NO_LEADERS;
1050
+ }
1051
+ };
1052
+ //#endregion
1053
+ //#region src/motion-set.ts
1054
+ function compareMotions(a, b) {
1055
+ return a._rank - b._rank || a._id - b._id;
1056
+ }
1057
+ const MAX_SUBSTEPS = 8;
1058
+ /**
1059
+ * A binary min-heap over (rank, creation id): the ordered pass worklist.
1060
+ * Duplicate pushes are expected — the tick's pass marker deduplicates at
1061
+ * pop.
1062
+ */
1063
+ var MotionHeap = class {
1064
+ #items = [];
1065
+ clear() {
1066
+ this.#items.length = 0;
1067
+ }
1068
+ push(motion) {
1069
+ const items = this.#items;
1070
+ let hole = items.length;
1071
+ items.push(motion);
1072
+ while (hole > 0) {
1073
+ const parent = hole - 1 >> 1;
1074
+ if (compareMotions(items[parent], motion) <= 0) break;
1075
+ items[hole] = items[parent];
1076
+ hole = parent;
1077
+ }
1078
+ items[hole] = motion;
1079
+ }
1080
+ pop() {
1081
+ const items = this.#items;
1082
+ if (items.length === 0) return void 0;
1083
+ const top = items[0];
1084
+ const last = items.pop();
1085
+ if (items.length > 0) {
1086
+ let hole = 0;
1087
+ for (;;) {
1088
+ const left = 2 * hole + 1;
1089
+ if (left >= items.length) break;
1090
+ const right = left + 1;
1091
+ const child = right < items.length && compareMotions(items[right], items[left]) < 0 ? right : left;
1092
+ if (compareMotions(last, items[child]) <= 0) break;
1093
+ items[hole] = items[child];
1094
+ hole = child;
1095
+ }
1096
+ items[hole] = last;
1097
+ }
1098
+ return top;
1099
+ }
1100
+ };
1101
+ /**
1102
+ * The set of motions currently moving. Motions leave the set as they
1103
+ * rest and re-enter on any write that disturbs them; `onWake` tells the
1104
+ * ticker the set became non-empty so it can stop sleeping.
1105
+ */
1106
+ var MotionSet = class {
1107
+ reduced = false;
1108
+ onWake = null;
1109
+ /**
1110
+ * The coupling error budget, in value units: the local per-frame error
1111
+ * the sub-step controller targets on each follow edge, floored by the
1112
+ * follower's resting magnitude. `SpringSystem` exposes it as the
1113
+ * `couplingTolerance` option and property; validation lives there.
1114
+ */
1115
+ couplingTolerance = .1;
1116
+ flushes = new FlushQueue();
1117
+ /** Follow edges registered by following springs, with the dependency rank over their motions. */
1118
+ graph = new FollowGraph();
1119
+ #motions = /* @__PURE__ */ new Set();
1120
+ #heap = new MotionHeap();
1121
+ #advanced = [];
1122
+ /** The live pass worklist while an advance phase runs, so mid-phase wakes join it. */
1123
+ #activeHeap = null;
1124
+ /** Whether any tick is on the stack, settle sweep included — a reentrant advance must not share the pass structures. */
1125
+ #inTick = false;
1126
+ #debug;
1127
+ #lastSize = 0;
1128
+ #pass = 0;
1129
+ /** Ordered-frame counter: sub-steps share one id, deduplicating the settle list. */
1130
+ #frame = 0;
1131
+ constructor(debug = false) {
1132
+ this.#debug = debug;
1133
+ }
1134
+ get size() {
1135
+ return this.#motions.size;
1136
+ }
1137
+ add(motion) {
1138
+ const wasEmpty = this.#motions.size === 0;
1139
+ this.#motions.add(motion);
1140
+ this.#activeHeap?.push(motion);
1141
+ if (wasEmpty) this.onWake?.();
1142
+ }
1143
+ remove(motion) {
1144
+ this.#motions.delete(motion);
1145
+ }
1146
+ finishAll() {
1147
+ this.flushes.batch(() => {
1148
+ for (const motion of this.#motions) {
1149
+ if (!motion.respectsReducedMotion) continue;
1150
+ motion.finish();
1151
+ this.#motions.delete(motion);
1152
+ }
1153
+ });
1154
+ }
1155
+ tick(dt) {
1156
+ const reentrant = this.#inTick;
1157
+ this.#inTick = true;
1158
+ this.flushes.enter();
1159
+ try {
1160
+ this.graph.ensureOrder();
1161
+ if (this.graph.isEmpty) this.#tickUnordered(dt, reentrant);
1162
+ else this.#tickOrdered(dt, reentrant);
1163
+ } finally {
1164
+ this.#inTick = reentrant;
1165
+ this.flushes.exit();
1166
+ }
1167
+ if (this.#debug && this.#motions.size !== this.#lastSize) {
1168
+ this.#lastSize = this.#motions.size;
1169
+ console.log(`coily: ${this.#lastSize} active motions`);
1170
+ }
1171
+ }
1172
+ /** The no-edge fast path: nothing constrains order, so insertion order serves for free. */
1173
+ #tickUnordered(dt, reentrant) {
1174
+ this.#pass++;
1175
+ const advanced = reentrant ? [] : this.#advanced;
1176
+ advanced.length = 0;
1177
+ for (const motion of this.#motions) {
1178
+ if (motion._pass === this.#pass) continue;
1179
+ motion._pass = this.#pass;
1180
+ motion._advance(dt);
1181
+ advanced.push(motion);
1182
+ if (motion.isResting) this.#motions.delete(motion);
1183
+ }
1184
+ this.#settle(advanced);
1185
+ }
1186
+ #tickOrdered(dt, reentrant) {
1187
+ const heap = reentrant ? new MotionHeap() : this.#heap;
1188
+ const advanced = reentrant ? [] : this.#advanced;
1189
+ const previous = this.#activeHeap;
1190
+ this.#activeHeap = heap;
1191
+ advanced.length = 0;
1192
+ const frame = ++this.#frame;
1193
+ let demand = 1;
1194
+ if (!this.reduced) {
1195
+ for (const edge of this.graph.edges) if (this.#shouldPlan(edge)) {
1196
+ const k = edge.plan(dt);
1197
+ if (k > demand) demand = k;
1198
+ }
1199
+ }
1200
+ const K = Math.min(demand, MAX_SUBSTEPS);
1201
+ const h = dt / K;
1202
+ try {
1203
+ for (let step = 0; step < K; step++) this.#advancePass(dt, h, step === 0, heap, advanced, frame);
1204
+ } finally {
1205
+ this.#activeHeap = previous;
1206
+ }
1207
+ this.#settle(advanced);
1208
+ }
1209
+ #advancePass(dt, h, first, heap, advanced, frame) {
1210
+ this.#pass++;
1211
+ heap.clear();
1212
+ for (const motion of this.#motions) {
1213
+ if (!first && motion._rank === -1) continue;
1214
+ heap.push(motion);
1215
+ }
1216
+ for (let motion = heap.pop(); motion !== void 0; motion = heap.pop()) {
1217
+ if (motion._pass === this.#pass) continue;
1218
+ const edge = motion._edge;
1219
+ if (edge !== null && this.#shouldRecouple(edge)) edge.recouple(h);
1220
+ if (!this.#motions.has(motion)) continue;
1221
+ motion._pass = this.#pass;
1222
+ motion._advance(motion._rank === -1 ? dt : h);
1223
+ if (motion._frame !== frame) {
1224
+ motion._frame = frame;
1225
+ advanced.push(motion);
1226
+ }
1227
+ if (motion.isResting) this.#motions.delete(motion);
1228
+ const followers = motion._followers;
1229
+ if (followers !== null) for (const out of followers) heap.push(out.follower);
1230
+ }
1231
+ }
1232
+ #settle(advanced) {
1233
+ for (const motion of advanced) motion._settleFrame();
1234
+ }
1235
+ #shouldRecouple(edge) {
1236
+ for (const leader of edge.leaders) if (leader._pass === this.#pass || this.#motions.has(leader)) return true;
1237
+ return false;
1238
+ }
1239
+ #shouldPlan(edge) {
1240
+ for (const leader of edge.leaders) if (this.#motions.has(leader)) return true;
1241
+ return false;
1242
+ }
1243
+ };
1244
+ //#endregion
1245
+ //#region src/spring.ts
1246
+ /**
1247
+ * One animated number, driven toward its target with damped spring
1248
+ * motion. Create springs with `SpringSystem.createSpring`; the owning
1249
+ * system advances them, so a spring only moves while its system runs.
1250
+ *
1251
+ * Reads return the exact simulated state; nothing is rounded. Numeric
1252
+ * writes — `target`, `value`, `velocity`, `jumpTo` — require finite
1253
+ * numbers: `NaN` or an infinity throws rather than poisoning the
1254
+ * simulation. See
1255
+ * https://github.com/tkofh/coily/blob/main/PRECISION.md for the
1256
+ * numerical contract.
1257
+ */
1258
+ var Spring = class {
1259
+ /** Brands the spring as a `KinematicSource` whose api is the spring itself. */
1260
+ get [SpringSourceSymbol]() {
1261
+ registerBacking(this, this.#motion);
1262
+ return this;
1263
+ }
1264
+ #target;
1265
+ #config;
1266
+ #purpose;
1267
+ #motion;
1268
+ #motions;
1269
+ #leader = null;
1270
+ #unsubLeader = null;
1271
+ #settled = null;
1272
+ #resolveSettled = null;
1273
+ #disposed = false;
1274
+ constructor(motions, value, config, purpose = "motion") {
1275
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
1276
+ invariant(purpose === "motion" || purpose === "appearance", "Spring purpose must be 'motion' or 'appearance'");
1277
+ this.#motions = motions;
1278
+ this.#config = config ?? SpringDefinition.default;
1279
+ this.#target = value;
1280
+ this.#purpose = purpose;
1281
+ this.#motion = new Motion(this.#config, 0, 0);
1282
+ this.#motion.respectsReducedMotion = purpose === "motion";
1283
+ }
1284
+ /**
1285
+ * The value the spring is animating toward. While following a source,
1286
+ * reads return the source's value as of its last update.
1287
+ *
1288
+ * Assignment accepts any `SpringTarget`:
1289
+ * - A number retargets the spring. The current value and momentum carry
1290
+ * over, so mid-flight retargets stay smooth. Assigning a number while
1291
+ * following also stops the following.
1292
+ * - A `SpringSource` — a `Spring`, or a value derived with `mapSpring` —
1293
+ * makes this spring follow the source's live value.
1294
+ *
1295
+ * Under reduced motion, retargets apply instantly — unless `purpose` is
1296
+ * `'appearance'`, in which case they animate as normal.
1297
+ */
1298
+ get target() {
1299
+ return this.#target;
1300
+ }
1301
+ set target(value) {
1302
+ if (typeof value === "number") {
1303
+ this.#unfollow();
1304
+ this.#setTarget(value);
1305
+ } else {
1306
+ invariant(isSpringSource(value), "Spring target must be a number or a SpringSource");
1307
+ invariant(typeof value[SpringSourceSymbol].value === "number", "A spring can only follow a scalar SpringSource; derive one from a composite with mapSpring");
1308
+ this.#follow(value);
1309
+ }
1310
+ }
1311
+ /**
1312
+ * The current animated value: the target plus the remaining
1313
+ * displacement.
1314
+ *
1315
+ * Writing displaces the spring: it keeps its target and springs back
1316
+ * from the written value, notifying update listeners synchronously.
1317
+ * Writing the current value is a no-op. Under reduced motion a write
1318
+ * jumps the spring — target included — to the written value, unless
1319
+ * `purpose` is `'appearance'`, in which case it displaces as normal.
1320
+ */
1321
+ get value() {
1322
+ return this.#target + this.#motion.position;
1323
+ }
1324
+ set value(value) {
1325
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
1326
+ if (this.#motions.reduced && this.#purpose === "motion") {
1327
+ if (value !== this.value) this.jumpTo(value);
1328
+ return;
1329
+ }
1330
+ const position = value - this.#target;
1331
+ if (position !== this.#motion.position) {
1332
+ this.#motions.add(this.#motion);
1333
+ this.#motion.position = position;
1334
+ this.#motion.tick(0);
1335
+ }
1336
+ }
1337
+ /**
1338
+ * The current velocity, in value units per second.
1339
+ *
1340
+ * Writing flings the spring: motion continues from the current value
1341
+ * with the written velocity, then settles back to the target. Under
1342
+ * reduced motion writes are ignored, unless `purpose` is `'appearance'`,
1343
+ * in which case the fling applies as normal.
1344
+ */
1345
+ get velocity() {
1346
+ return this.#motion.velocity;
1347
+ }
1348
+ set velocity(value) {
1349
+ invariant(Number.isFinite(value), "Spring velocity must be a finite number");
1350
+ if (this.#motions.reduced && this.#purpose === "motion") return;
1351
+ this.#motions.add(this.#motion);
1352
+ this.#motion.velocity = value;
1353
+ }
1354
+ /**
1355
+ * The current acceleration, in value units per second squared — how
1356
+ * fast the velocity is changing. Read-only: acceleration is the
1357
+ * spring's stiffness and friction acting on its current displacement
1358
+ * and velocity, so it follows from the motion rather than being set.
1359
+ * To fling the spring, write `velocity` instead.
1360
+ */
1361
+ get acceleration() {
1362
+ const { tension, damping, mass } = this.#config;
1363
+ return -(tension * this.#motion.position + damping * this.#motion.velocity) / mass;
1364
+ }
1365
+ /**
1366
+ * What the spring animates — `'motion'` or `'appearance'` — fixed when
1367
+ * it was created. An `'appearance'` spring opts out of reduced motion:
1368
+ * it keeps animating where a `'motion'` spring snaps to its target. See
1369
+ * `Purpose`.
1370
+ */
1371
+ get purpose() {
1372
+ return this.#purpose;
1373
+ }
1374
+ /**
1375
+ * The spring's `SpringDefinition`: the one it was created with or last
1376
+ * assigned, otherwise `SpringDefinition.default`. Following a source
1377
+ * never changes it — how a spring chases its target is the spring's
1378
+ * own setting.
1379
+ *
1380
+ * Assigning a config reconfigures the spring in place — value and
1381
+ * velocity are preserved, so mid-flight reconfigures stay smooth.
1382
+ * Assigning `null` reverts to the default.
1383
+ */
1384
+ get config() {
1385
+ return this.#config;
1386
+ }
1387
+ /** The config's mass. */
1388
+ get mass() {
1389
+ return this.#config.mass;
1390
+ }
1391
+ /** The config's tension (stiffness). */
1392
+ get tension() {
1393
+ return this.#config.tension;
1394
+ }
1395
+ /** The config's damping (friction). */
1396
+ get damping() {
1397
+ return this.#config.damping;
1398
+ }
1399
+ /** The config's damping ratio. */
1400
+ get dampingRatio() {
1401
+ return this.#config.dampingRatio;
1402
+ }
1403
+ /** The config's resting precision. */
1404
+ get precision() {
1405
+ return this.#config.precision;
1406
+ }
1407
+ /** The config's arrival multiplier. */
1408
+ get arrival() {
1409
+ return this.#config.arrival;
1410
+ }
1411
+ set config(value) {
1412
+ const next = value ?? SpringDefinition.default;
1413
+ if (this.#config === next) return;
1414
+ this.#config = next;
1415
+ this.#motion.configure(next);
1416
+ if (!this.#motion.isResting) this.#motions.add(this.#motion);
1417
+ }
1418
+ /**
1419
+ * Milliseconds until the spring rests: 0 while resting, Infinity when
1420
+ * the motion never settles. The time is solved from the motion, not
1421
+ * estimated — the spring is resting at the first tick at or after it,
1422
+ * and a bouncy spring can rest up to one oscillation earlier when a
1423
+ * frame samples a low pulse. When the config's `arrival` is 0, it is
1424
+ * capped at the exact ms of the first target crossing.
1425
+ */
1426
+ get timeRemaining() {
1427
+ return this.#motion.timeRemaining;
1428
+ }
1429
+ /**
1430
+ * Whether the spring's remaining motion is inside its resting
1431
+ * threshold. Resting springs cost nothing until the next write. See
1432
+ * https://github.com/tkofh/coily/blob/main/PRECISION.md for the rest
1433
+ * test.
1434
+ */
1435
+ get isResting() {
1436
+ return this.#motion.isResting;
1437
+ }
1438
+ /**
1439
+ * A promise that resolves when the spring next comes to rest — already
1440
+ * resolved while resting or after dispose. Retargeting mid-flight
1441
+ * extends the wait; disposing resolves it.
1442
+ *
1443
+ * @example
1444
+ * ```ts
1445
+ * spring.target = 300
1446
+ * await spring.settled
1447
+ * // the spring is at 300 and resting
1448
+ * ```
1449
+ */
1450
+ get settled() {
1451
+ if (this.#disposed || this.#motion.isResting) return RESOLVED;
1452
+ this.#settled ??= new Promise((resolve) => {
1453
+ this.#resolveSettled = resolve;
1454
+ const unsubscribe = this.onStop(() => {
1455
+ unsubscribe();
1456
+ this.#settled = null;
1457
+ this.#resolveSettled = null;
1458
+ resolve();
1459
+ });
1460
+ });
1461
+ return this.#settled;
1462
+ }
1463
+ /**
1464
+ * Snaps the spring to `value` with no animation: target and value both
1465
+ * become `value` and velocity clears. Listeners are notified
1466
+ * synchronously — `update`, plus `stop` if the spring was moving.
1467
+ */
1468
+ jumpTo(value) {
1469
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
1470
+ this.#target = value;
1471
+ this.#motion.finish();
1472
+ }
1473
+ /**
1474
+ * Releases the spring permanently: stops following, detaches followers
1475
+ * (each keeps its current target), resolves `settled`, and notifies
1476
+ * dispose listeners. Calling it again is a no-op.
1477
+ *
1478
+ * A disposed spring keeps its final value readable; writes throw.
1479
+ */
1480
+ dispose() {
1481
+ if (this.#disposed) return;
1482
+ this.#disposed = true;
1483
+ if (this.#resolveSettled) {
1484
+ this.#resolveSettled();
1485
+ this.#settled = null;
1486
+ this.#resolveSettled = null;
1487
+ }
1488
+ if (this.#unsubLeader) {
1489
+ this.#unsubLeader();
1490
+ this.#unsubLeader = null;
1491
+ this.#leader = null;
1492
+ }
1493
+ this.#motions.remove(this.#motion);
1494
+ this.#motion.dispose();
1495
+ }
1496
+ /**
1497
+ * Subscribes to value changes: every tick the spring moves, plus
1498
+ * synchronous `value` writes and `jumpTo` calls. Returns an unsubscribe
1499
+ * function.
1500
+ */
1501
+ onUpdate(callback) {
1502
+ return this.#motion.onUpdate(callback);
1503
+ }
1504
+ /**
1505
+ * Subscribes to the spring leaving rest. `start` and `stop` always
1506
+ * alternate; retargeting mid-flight fires neither. Returns an
1507
+ * unsubscribe function.
1508
+ */
1509
+ onStart(callback) {
1510
+ return this.#motion.onStart(callback);
1511
+ }
1512
+ /**
1513
+ * Subscribes to the spring coming to rest, whether by settling or by
1514
+ * `jumpTo`. Always alternates with `start`. Returns an unsubscribe
1515
+ * function.
1516
+ */
1517
+ onStop(callback) {
1518
+ return this.#motion.onStop(callback);
1519
+ }
1520
+ /** Subscribes to `dispose`, which fires once. Returns an unsubscribe function. */
1521
+ onDispose(callback) {
1522
+ return this.#motion.onDispose(callback);
1523
+ }
1524
+ #setTarget(value) {
1525
+ invariant(Number.isFinite(value), "Spring target must be a finite number");
1526
+ if (value !== this.#target) {
1527
+ if (this.#motions.reduced && this.#purpose === "motion") {
1528
+ this.jumpTo(value);
1529
+ return;
1530
+ }
1531
+ this.#motions.add(this.#motion);
1532
+ const current = this.value;
1533
+ this.#target = value;
1534
+ this.#motion.position = current - this.#target;
1535
+ this.#motion.tick(0, false);
1536
+ }
1537
+ }
1538
+ #follow(source) {
1539
+ this.#unsubLeader?.();
1540
+ this.#leader = source;
1541
+ const api = source[SpringSourceSymbol];
1542
+ const unsubUpdate = api.onUpdate(() => {
1543
+ this.#setTarget(api.value);
1544
+ });
1545
+ const unsubDispose = api.onDispose(() => {
1546
+ this.#unfollow();
1547
+ });
1548
+ const leaders = resolveLeaderMotions(source);
1549
+ if (leaders.length > 0) {
1550
+ const edge = {
1551
+ follower: this.#motion,
1552
+ leaders,
1553
+ _cyclic: false,
1554
+ _prevValue: 0,
1555
+ _d1: 0,
1556
+ _d2: 0,
1557
+ recouple: (h) => {
1558
+ if (edge._cyclic || h === 0 || this.#config.arrival !== 1 || this.#motions.reduced && this.#purpose === "motion") {
1559
+ this.#setTarget(api.value);
1560
+ return;
1561
+ }
1562
+ const value = api.value;
1563
+ invariant(Number.isFinite(value), "Spring target must be a finite number");
1564
+ if (value === this.#target) return;
1565
+ this.#motions.add(this.#motion);
1566
+ this.#motion._ramp = (value - this.#target) / h;
1567
+ this.#target = value;
1568
+ },
1569
+ plan: (dt) => {
1570
+ const target = this.#target;
1571
+ const d = target - edge._prevValue;
1572
+ edge._d2 = edge._d1;
1573
+ edge._d1 = d;
1574
+ edge._prevValue = target;
1575
+ let dHat = Math.abs(d);
1576
+ let error = Math.abs(d - edge._d2) / 8;
1577
+ for (const leader of leaders) if (leader._manifoldDeviation() > 4 * Math.abs(d)) {
1578
+ const accel = Math.abs(leader._acceleration());
1579
+ const speed = Math.abs(leader.velocity);
1580
+ dHat = Math.max(dHat, speed * dt + .5 * accel * dt * dt);
1581
+ error = Math.max(error, accel * dt * dt / 8);
1582
+ }
1583
+ const tol = Math.max(this.#config.restingMagnitude, this.#motions.couplingTolerance);
1584
+ return edge._cyclic || this.#config.arrival !== 1 ? Math.ceil(dHat / (2 * tol)) : Math.ceil(Math.sqrt(error / tol));
1585
+ }
1586
+ };
1587
+ this.#motions.graph.addEdge(edge);
1588
+ this.#unsubLeader = () => {
1589
+ unsubUpdate();
1590
+ unsubDispose();
1591
+ this.#motions.graph.removeEdge(this.#motion);
1592
+ };
1593
+ this.#setTarget(api.value);
1594
+ edge._prevValue = this.#target;
1595
+ return;
1596
+ }
1597
+ this.#unsubLeader = () => {
1598
+ unsubUpdate();
1599
+ unsubDispose();
1600
+ this.#motions.graph.removeEdge(this.#motion);
1601
+ };
1602
+ this.#setTarget(api.value);
1603
+ }
1604
+ #unfollow() {
1605
+ if (!this.#leader) return;
1606
+ this.#unsubLeader();
1607
+ this.#unsubLeader = null;
1608
+ this.#leader = null;
1609
+ }
1610
+ };
1611
+ //#endregion
1612
+ //#region src/composite-spring.ts
1613
+ function invalidConfig(path) {
1614
+ return `Invalid config for ${describePath(path)}: expected a SpringDefinition, null, or a config shape matching the value`;
1615
+ }
1616
+ /**
1617
+ * Decides what a config node means: `null` and `SpringDefinition` instances are
1618
+ * configs covering the whole subtree, any other object or array is a config
1619
+ * shape to descend into (its structure is checked on the way down), and
1620
+ * anything else — a bare number, string, or options object — is invalid.
1621
+ */
1622
+ function resolveConfigNode(node, path) {
1623
+ if (node === null) return null;
1624
+ if (node instanceof SpringDefinition) return node;
1625
+ if (isRecordOrArray(node)) return BRANCH;
1626
+ throw new Error(invalidConfig(path));
1627
+ }
1628
+ function invalidPurpose(path) {
1629
+ return `Invalid purpose for ${describePath(path)}: expected 'motion', 'appearance', or a purpose shape matching the value`;
1630
+ }
1631
+ /**
1632
+ * Decides what a purpose node means: a `Purpose` string covers the whole
1633
+ * subtree, any object or array is a purpose shape to descend into, and
1634
+ * anything else is invalid.
1635
+ */
1636
+ function resolvePurposeNode(node, path) {
1637
+ if (node === "motion" || node === "appearance") return node;
1638
+ if (isRecordOrArray(node)) return BRANCH;
1639
+ throw new Error(invalidPurpose(path));
1640
+ }
1641
+ const readTarget = (spring) => spring.target;
1642
+ const readValue = (spring) => spring.value;
1643
+ const readVelocity = (spring) => spring.velocity;
1644
+ const readAcceleration = (spring) => spring.acceleration;
1645
+ /**
1646
+ * The target scatter's leaf guard: a channel animates toward a number,
1647
+ * or follows a scalar source. Checked here so the error carries the
1648
+ * channel's path.
1649
+ */
1650
+ function acceptChannelTarget(input, path) {
1651
+ invariant(isNumber(input) && Number.isFinite(input) || isSpringSource(input) && typeof input[SpringSourceSymbol].value === "number", () => `Invalid value at '${path}': expected a finite number or a scalar SpringSource`);
1652
+ return input;
1653
+ }
1654
+ const assignTarget = (spring, value) => {
1655
+ spring.target = value;
1656
+ };
1657
+ const assignValue = (spring, value) => {
1658
+ spring.value = value;
1659
+ };
1660
+ const assignVelocity = (spring, value) => {
1661
+ spring.velocity = value;
1662
+ };
1663
+ const applyJump = (spring, value) => {
1664
+ spring.jumpTo(value);
1665
+ };
1666
+ const assignConfig = (spring, config) => {
1667
+ spring.config = config;
1668
+ };
1669
+ const followChannel = (mine, theirs) => {
1670
+ mine.target = theirs;
1671
+ };
1672
+ /**
1673
+ * A composite spring over a fixed numeric shape — a nested plain object
1674
+ * or array whose leaves are all numbers. Each leaf runs an independent
1675
+ * spring, called a channel, behind one API: targets, values, velocities,
1676
+ * and configs read and write as (partial) shapes, and events are
1677
+ * coalesced across channels. Create composite springs with
1678
+ * `SpringSystem.createSpring`.
1679
+ *
1680
+ * A composite spring is a `SpringSource` of its value shape: `mapSpring`
1681
+ * derives scalar sources from it — alone or at the leaves of a shape —
1682
+ * which springs can then follow. Only scalar sources are followable
1683
+ * directly.
1684
+ *
1685
+ * The shape is fixed at construction. Writes naming unknown channels
1686
+ * throw with the channel's path (`position.z`), and so do non-finite
1687
+ * channel values.
1688
+ */
1689
+ var CompositeSpring = class CompositeSpring {
1690
+ /** Brands the composite as a `KinematicSource` whose api is the composite itself. */
1691
+ get [SpringSourceSymbol]() {
1692
+ registerBacking(this, this.#tree.leaves);
1693
+ return this;
1694
+ }
1695
+ #motions;
1696
+ #tree;
1697
+ #valueView;
1698
+ #targetView = null;
1699
+ #velocityView = null;
1700
+ #accelerationView = null;
1701
+ #emitter = new Emitter();
1702
+ #running = false;
1703
+ #dirty = false;
1704
+ #settled = null;
1705
+ #resolveSettled = null;
1706
+ #disposed = false;
1707
+ #flush = () => {
1708
+ if (this.#disposed) return;
1709
+ if (this.#dirty) {
1710
+ this.#dirty = false;
1711
+ this.#emitter.emit("update");
1712
+ }
1713
+ if (this.#running) {
1714
+ if (this.isResting) {
1715
+ this.#running = false;
1716
+ this.#emitter.emit("stop");
1717
+ }
1718
+ } else if (!this.isResting) {
1719
+ this.#running = true;
1720
+ this.#emitter.emit("start");
1721
+ }
1722
+ };
1723
+ constructor(motions, value, config, purpose) {
1724
+ this.#motions = motions;
1725
+ invariant(isRecordOrArray(value), "Composite spring value must be a plain object or an array of numeric channels");
1726
+ let purposeByPath = null;
1727
+ if (purpose !== void 0) {
1728
+ const byPath = /* @__PURE__ */ new Map();
1729
+ new ShapeTree(value, channelParser((_leafValue, path) => path)).root.broadcast(purpose, resolvePurposeNode, (path, p) => byPath.set(path, p), "purpose");
1730
+ purposeByPath = byPath;
1731
+ }
1732
+ this.#tree = new ShapeTree(value, channelParser((leafValue, path) => new Spring(motions, leafValue, void 0, purposeByPath?.get(path) ?? "motion")));
1733
+ if (config !== void 0) this.#tree.root.broadcast(config, resolveConfigNode, assignConfig, "config");
1734
+ this.#valueView = new ShapeView(this.#tree.root, readValue);
1735
+ const markDirty = () => {
1736
+ this.#dirty = true;
1737
+ this.#motions.flushes.request(this.#flush);
1738
+ };
1739
+ const schedule = () => {
1740
+ this.#motions.flushes.request(this.#flush);
1741
+ };
1742
+ for (const channel of this.#tree.leaves) {
1743
+ channel.onUpdate(markDirty);
1744
+ channel.onStart(schedule);
1745
+ channel.onStop(schedule);
1746
+ }
1747
+ }
1748
+ /**
1749
+ * The value each channel is animating toward, as a read-only mirror of
1750
+ * the shape. The mirror object is reused: every read refreshes its
1751
+ * numbers in place, so read it fresh rather than holding it across
1752
+ * frames.
1753
+ *
1754
+ * Assignment accepts a `CompositeSpringTarget`:
1755
+ * - A partial shape retargets the channels it names and leaves the
1756
+ * others alone. Each named channel takes a number, or a scalar
1757
+ * `SpringSource` to follow — a `Spring`, or a value derived with
1758
+ * `mapSpring`. While following a leader, naming a channel also
1759
+ * detaches that channel from it.
1760
+ * - A `CompositeSpring` of the same shape follows the leader channel by
1761
+ * channel.
1762
+ *
1763
+ * Unknown channels throw with their path.
1764
+ */
1765
+ get target() {
1766
+ const view = this.#targetView ??= new ShapeView(this.#tree.root, readTarget);
1767
+ view.refresh();
1768
+ return view.root;
1769
+ }
1770
+ set target(value) {
1771
+ if (value instanceof CompositeSpring) this.#follow(value);
1772
+ else this.#motions.flushes.batch(() => {
1773
+ this.#tree.root.scatter(value, acceptChannelTarget, assignTarget);
1774
+ });
1775
+ }
1776
+ /**
1777
+ * The current animated value of every channel, as a read-only mirror of
1778
+ * the shape. The mirror object is reused: every read refreshes its
1779
+ * numbers in place, so read it fresh rather than holding it across
1780
+ * frames.
1781
+ *
1782
+ * Assignment takes a partial shape and displaces the channels it names:
1783
+ * each keeps its target and springs back from the written value, with
1784
+ * one coalesced `update` fired synchronously. Under reduced motion a
1785
+ * write jumps the named channels — targets included — except channels
1786
+ * whose `purpose` is `'appearance'`, which displace as normal.
1787
+ */
1788
+ get value() {
1789
+ this.#valueView.refresh();
1790
+ return this.#valueView.root;
1791
+ }
1792
+ set value(value) {
1793
+ this.#motions.flushes.batch(() => {
1794
+ this.#tree.root.scatter(value, acceptNumber, assignValue);
1795
+ });
1796
+ }
1797
+ /**
1798
+ * The current velocity of every channel in value units per second, as a
1799
+ * read-only mirror of the shape — reused and refreshed in place on each
1800
+ * read.
1801
+ *
1802
+ * Assignment takes a partial shape and flings the channels it names;
1803
+ * the rest are untouched. Under reduced motion writes are ignored,
1804
+ * except on channels whose `purpose` is `'appearance'`.
1805
+ */
1806
+ get velocity() {
1807
+ const view = this.#velocityView ??= new ShapeView(this.#tree.root, readVelocity);
1808
+ view.refresh();
1809
+ return view.root;
1810
+ }
1811
+ set velocity(value) {
1812
+ this.#motions.flushes.batch(() => {
1813
+ this.#tree.root.scatter(value, acceptNumber, assignVelocity);
1814
+ });
1815
+ }
1816
+ /**
1817
+ * The current acceleration of every channel in value units per second
1818
+ * squared, as a read-only mirror of the shape — reused and refreshed in
1819
+ * place on each read. Read-only, like each channel's: acceleration
1820
+ * follows from the motion. To fling channels, write `velocity`.
1821
+ */
1822
+ get acceleration() {
1823
+ const view = this.#accelerationView ??= new ShapeView(this.#tree.root, readAcceleration);
1824
+ view.refresh();
1825
+ return view.root;
1826
+ }
1827
+ /**
1828
+ * The shared config when every channel resolves to the same
1829
+ * `SpringDefinition`, and `null` when they differ.
1830
+ *
1831
+ * Assignment accepts a `ConfigShape`: one config for every channel,
1832
+ * `null` to revert every channel to the default, or a shape mirroring
1833
+ * the value with configs at any level — a config at a subtree covers
1834
+ * every channel below it. Non-config leaves throw with their path.
1835
+ */
1836
+ get config() {
1837
+ const channels = this.#tree.leaves;
1838
+ const first = channels[0].config;
1839
+ for (let i = 1; i < channels.length; i++) if (channels[i].config !== first) return null;
1840
+ return first;
1841
+ }
1842
+ set config(value) {
1843
+ this.#motions.flushes.batch(() => {
1844
+ this.#tree.root.broadcast(value ?? null, resolveConfigNode, assignConfig, "config");
1845
+ });
1846
+ }
1847
+ /**
1848
+ * The shared purpose when every channel resolves to the same `Purpose`,
1849
+ * and `null` when they differ. Fixed when the spring is created — an
1850
+ * `'appearance'` channel opts out of reduced motion. See `Purpose`.
1851
+ */
1852
+ get purpose() {
1853
+ const channels = this.#tree.leaves;
1854
+ const first = channels[0].purpose;
1855
+ for (let i = 1; i < channels.length; i++) if (channels[i].purpose !== first) return null;
1856
+ return first;
1857
+ }
1858
+ /** The largest `timeRemaining` across channels, in milliseconds. */
1859
+ get timeRemaining() {
1860
+ let max = 0;
1861
+ for (const channel of this.#tree.leaves) {
1862
+ const remaining = channel.timeRemaining;
1863
+ if (remaining > max) max = remaining;
1864
+ }
1865
+ return max;
1866
+ }
1867
+ /** Whether every channel is resting. */
1868
+ get isResting() {
1869
+ for (const channel of this.#tree.leaves) if (!channel.isResting) return false;
1870
+ return true;
1871
+ }
1872
+ /**
1873
+ * A promise that resolves when every channel next rests — already
1874
+ * resolved while resting or after dispose. Retargeting any channel
1875
+ * mid-flight extends the wait; disposing resolves it.
1876
+ */
1877
+ get settled() {
1878
+ if (this.#disposed || this.isResting) return RESOLVED;
1879
+ this.#settled ??= new Promise((resolve) => {
1880
+ this.#resolveSettled = resolve;
1881
+ const unsubscribe = this.onStop(() => {
1882
+ unsubscribe();
1883
+ this.#settled = null;
1884
+ this.#resolveSettled = null;
1885
+ resolve();
1886
+ });
1887
+ });
1888
+ return this.#settled;
1889
+ }
1890
+ /**
1891
+ * Snaps the channels the partial shape names to the given values with
1892
+ * no animation, notifying listeners synchronously. Channels it leaves
1893
+ * out are untouched.
1894
+ */
1895
+ jumpTo(value) {
1896
+ this.#motions.flushes.batch(() => {
1897
+ this.#tree.root.scatter(value, acceptNumber, applyJump);
1898
+ });
1899
+ }
1900
+ /**
1901
+ * Releases every channel permanently: resolves `settled` and notifies
1902
+ * dispose listeners. Calling it again is a no-op.
1903
+ *
1904
+ * A disposed composite spring keeps its final values readable; writes
1905
+ * throw.
1906
+ */
1907
+ dispose() {
1908
+ if (this.#disposed) return;
1909
+ this.#disposed = true;
1910
+ if (this.#resolveSettled) {
1911
+ this.#resolveSettled();
1912
+ this.#settled = null;
1913
+ this.#resolveSettled = null;
1914
+ }
1915
+ for (const channel of this.#tree.leaves) channel.dispose();
1916
+ this.#emitter.clear();
1917
+ }
1918
+ /**
1919
+ * Subscribes to coalesced value changes: at most one `update` per tick,
1920
+ * fired after every channel has advanced, plus one per synchronous
1921
+ * write. Returns an unsubscribe function.
1922
+ */
1923
+ onUpdate(callback) {
1924
+ return this.#emitter.on("update", callback);
1925
+ }
1926
+ /**
1927
+ * Subscribes to the composite leaving rest: a channel starts moving
1928
+ * while all were resting. Always alternates with `stop`. Returns an
1929
+ * unsubscribe function.
1930
+ */
1931
+ onStart(callback) {
1932
+ return this.#emitter.on("start", callback);
1933
+ }
1934
+ /**
1935
+ * Subscribes to the composite coming to rest: every channel resting,
1936
+ * fired after that tick's final `update`. Always alternates with
1937
+ * `start`. Returns an unsubscribe function.
1938
+ */
1939
+ onStop(callback) {
1940
+ return this.#emitter.on("stop", callback);
1941
+ }
1942
+ /** Subscribes to `dispose`, which fires once. Returns an unsubscribe function. */
1943
+ onDispose(callback) {
1944
+ return this.#tree.leaves[0].onDispose(callback);
1945
+ }
1946
+ #follow(leader) {
1947
+ this.#motions.flushes.batch(() => {
1948
+ this.#tree.root.zip(leader.#tree.root, followChannel);
1949
+ });
1950
+ }
1951
+ };
1952
+ //#endregion
1953
+ //#region src/ticker.ts
1954
+ const request = (callback) => typeof window !== "undefined" ? window.requestAnimationFrame(callback) : setTimeout(() => callback(performance.now()), 16);
1955
+ const cancel = (id) => {
1956
+ if (typeof window !== "undefined") window.cancelAnimationFrame(id);
1957
+ else clearTimeout(id);
1958
+ };
1959
+ /**
1960
+ * Drives a `MotionSet` from animation frames. Schedules frames only
1961
+ * while motions exist — otherwise it sleeps and the set's `onWake`
1962
+ * rearms it — paces fps caps to whole display frames, and clamps lag
1963
+ * gaps.
1964
+ */
1965
+ var Ticker = class {
1966
+ #fps;
1967
+ #gap;
1968
+ #capGap;
1969
+ #lagThreshold;
1970
+ #adjustedLag;
1971
+ #motion;
1972
+ #frame = 0;
1973
+ #time = 0;
1974
+ #delta = 0;
1975
+ #acc = 0;
1976
+ #lastWallTime = 0;
1977
+ #primed = false;
1978
+ #id = 0;
1979
+ #stopped = true;
1980
+ #sleeping = false;
1981
+ constructor(motions, options) {
1982
+ this.#motion = motions;
1983
+ const fps = options?.fps ?? 0;
1984
+ const lagThreshold = options?.lagThreshold ?? 500;
1985
+ const adjustedLag = options?.adjustedLag ?? 33;
1986
+ invariant(fps >= 0, "FPS must be greater than or equal to 0");
1987
+ invariant(lagThreshold >= 0, "Lag threshold must be greater than or equal to 0");
1988
+ invariant(adjustedLag >= 0, "Adjusted lag must be greater than or equal to 0");
1989
+ this.#fps = fps;
1990
+ this.#gap = fps === 0 ? 1e3 / 60 : 1e3 / fps;
1991
+ this.#capGap = fps === 0 ? 0 : 1e3 / fps;
1992
+ this.#lagThreshold = lagThreshold === 0 ? 1e8 : lagThreshold;
1993
+ this.#adjustedLag = Math.min(adjustedLag, this.#lagThreshold);
1994
+ motions.onWake = () => this.#wake();
1995
+ }
1996
+ get fps() {
1997
+ return this.#fps;
1998
+ }
1999
+ set fps(value) {
2000
+ invariant(value >= 0, "FPS must be greater than or equal to 0");
2001
+ this.#fps = value;
2002
+ this.#gap = value === 0 ? 1e3 / 60 : 1e3 / value;
2003
+ this.#capGap = value === 0 ? 0 : 1e3 / value;
2004
+ }
2005
+ get lagThreshold() {
2006
+ return this.#lagThreshold;
2007
+ }
2008
+ set lagThreshold(value) {
2009
+ invariant(value >= 0, "Lag threshold must be greater than or equal to 0");
2010
+ this.#lagThreshold = value === 0 ? 1e8 : value;
2011
+ this.#adjustedLag = Math.min(this.#adjustedLag, this.#lagThreshold);
2012
+ }
2013
+ get adjustedLag() {
2014
+ return this.#adjustedLag;
2015
+ }
2016
+ set adjustedLag(value) {
2017
+ invariant(value >= 0, "Adjusted lag must be greater than or equal to 0");
2018
+ this.#adjustedLag = Math.min(value, this.#lagThreshold);
2019
+ }
2020
+ get time() {
2021
+ return this.#time;
2022
+ }
2023
+ get frame() {
2024
+ return this.#frame;
2025
+ }
2026
+ get delta() {
2027
+ return this.#delta;
2028
+ }
2029
+ get deltaRatio() {
2030
+ return this.#delta / this.#gap;
2031
+ }
2032
+ get stopped() {
2033
+ return this.#stopped;
2034
+ }
2035
+ start() {
2036
+ if (this.#stopped) {
2037
+ this.#stopped = false;
2038
+ this.#primed = false;
2039
+ this.#acc = 0;
2040
+ this.#schedule();
2041
+ }
2042
+ }
2043
+ stop() {
2044
+ if (!this.#stopped) {
2045
+ if (!this.#sleeping) cancel(this.#id);
2046
+ this.#stopped = true;
2047
+ this.#sleeping = false;
2048
+ }
2049
+ }
2050
+ tick() {
2051
+ this.#step(this.#gap);
2052
+ }
2053
+ #schedule() {
2054
+ if (this.#motion.size === 0) {
2055
+ this.#sleeping = true;
2056
+ this.#acc = 0;
2057
+ } else {
2058
+ this.#sleeping = false;
2059
+ this.#id = request((t) => this.#onFrame(t));
2060
+ }
2061
+ }
2062
+ #wake() {
2063
+ if (!this.#stopped && this.#sleeping) {
2064
+ this.#primed = false;
2065
+ this.#schedule();
2066
+ }
2067
+ }
2068
+ #onFrame(timestamp) {
2069
+ if (this.#stopped) return;
2070
+ try {
2071
+ if (this.#primed) {
2072
+ const wallElapsed = timestamp - this.#lastWallTime;
2073
+ const elapsed = wallElapsed > this.#lagThreshold ? this.#adjustedLag : wallElapsed;
2074
+ this.#acc += elapsed;
2075
+ if (this.#acc + elapsed / 2 >= this.#capGap) {
2076
+ const delta = this.#acc;
2077
+ this.#acc = 0;
2078
+ this.#step(delta);
2079
+ }
2080
+ } else this.#primed = true;
2081
+ } finally {
2082
+ this.#lastWallTime = timestamp;
2083
+ if (!this.#stopped) this.#schedule();
2084
+ }
2085
+ }
2086
+ #step(dt) {
2087
+ this.#time += dt;
2088
+ this.#frame++;
2089
+ this.#delta = dt;
2090
+ this.#motion.tick(dt / 1e3);
2091
+ }
2092
+ };
2093
+ //#endregion
2094
+ //#region src/system.ts
2095
+ var SpringSystemImpl = class {
2096
+ #motion;
2097
+ #ticker;
2098
+ constructor(options) {
2099
+ this.#motion = new MotionSet(options?.debug);
2100
+ this.#ticker = new Ticker(this.#motion, options);
2101
+ if (options?.couplingTolerance !== void 0) this.couplingTolerance = options.couplingTolerance;
2102
+ const mode = options?.reducedMotion ?? "user";
2103
+ if (mode === "always") this.#motion.reduced = true;
2104
+ else if (mode === "user" && typeof window !== "undefined" && typeof window.matchMedia === "function") {
2105
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
2106
+ this.#applyReducedMotion(query.matches);
2107
+ query.addEventListener("change", (event) => {
2108
+ this.#applyReducedMotion(event.matches);
2109
+ });
2110
+ }
2111
+ }
2112
+ #applyReducedMotion(reduced) {
2113
+ this.#motion.reduced = reduced;
2114
+ if (reduced) this.#motion.finishAll();
2115
+ }
2116
+ get reducedMotion() {
2117
+ return this.#motion.reduced;
2118
+ }
2119
+ get couplingTolerance() {
2120
+ return this.#motion.couplingTolerance;
2121
+ }
2122
+ set couplingTolerance(value) {
2123
+ invariant(Number.isFinite(value) && value >= 0, "couplingTolerance must be a finite, non-negative number");
2124
+ this.#motion.couplingTolerance = value;
2125
+ }
2126
+ createSpring(value, config, options) {
2127
+ if (typeof value === "number") return new Spring(this.#motion, value, config, options?.purpose);
2128
+ if (isSpringSource(value)) {
2129
+ const api = value[SpringSourceSymbol];
2130
+ invariant(typeof api.value === "number", "A spring can only follow a scalar SpringSource; derive one from a composite with mapSpring");
2131
+ const spring = new Spring(this.#motion, api.value, config, options?.purpose);
2132
+ spring.target = value;
2133
+ return spring;
2134
+ }
2135
+ return new CompositeSpring(this.#motion, value, config, options?.purpose);
2136
+ }
2137
+ advance(dt) {
2138
+ invariant(Number.isFinite(dt), "dt must be a finite number of milliseconds");
2139
+ this.#motion.tick(dt / 1e3);
2140
+ }
2141
+ start() {
2142
+ this.#ticker.start();
2143
+ }
2144
+ stop() {
2145
+ this.#ticker.stop();
2146
+ }
2147
+ get running() {
2148
+ return !this.#ticker.stopped;
2149
+ }
2150
+ get fps() {
2151
+ return this.#ticker.fps;
2152
+ }
2153
+ set fps(value) {
2154
+ this.#ticker.fps = value;
2155
+ }
2156
+ get lagThreshold() {
2157
+ return this.#ticker.lagThreshold;
2158
+ }
2159
+ set lagThreshold(value) {
2160
+ this.#ticker.lagThreshold = value;
2161
+ }
2162
+ get adjustedLag() {
2163
+ return this.#ticker.adjustedLag;
2164
+ }
2165
+ set adjustedLag(value) {
2166
+ this.#ticker.adjustedLag = value;
2167
+ }
2168
+ };
2169
+ /**
2170
+ * Creates a spring system: the entry point of the library.
2171
+ *
2172
+ * @example
2173
+ * ```ts
2174
+ * const system = createSpringSystem()
2175
+ * system.start()
2176
+ *
2177
+ * const spring = system.createSpring(0)
2178
+ * spring.onUpdate(() => {
2179
+ * element.style.translate = `${spring.value}px 0`
2180
+ * })
2181
+ * spring.target = 300
2182
+ * ```
2183
+ */
2184
+ function createSpringSystem(options) {
2185
+ return new SpringSystemImpl(options);
2186
+ }
2187
+ //#endregion
2188
+ export { registerRecipe as a, mapSpring as i, SpringSourceSymbol as n, SpringDefinition as o, isSpringSource as r, defineSpring as s, createSpringSystem as t };