coily 0.12.2 → 0.14.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,1758 @@
1
+ //#region src/flush-queue.ts
2
+ /**
3
+ * Defers callbacks while inside a batch or tick: `request` runs the
4
+ * callback immediately at depth 0 and queues it — deduplicated — until
5
+ * the outermost exit otherwise. Composite springs use it to coalesce
6
+ * per-channel events into composite ones.
7
+ */
8
+ var FlushQueue = class {
9
+ #callbacks = /* @__PURE__ */ new Set();
10
+ #depth = 0;
11
+ request(callback) {
12
+ if (this.#depth > 0) this.#callbacks.add(callback);
13
+ else callback();
14
+ }
15
+ batch(fn) {
16
+ this.enter();
17
+ try {
18
+ fn();
19
+ } finally {
20
+ this.exit();
21
+ }
22
+ }
23
+ enter() {
24
+ this.#depth++;
25
+ }
26
+ exit() {
27
+ this.#depth--;
28
+ if (this.#depth === 0) for (const callback of this.#callbacks) {
29
+ this.#callbacks.delete(callback);
30
+ callback();
31
+ }
32
+ }
33
+ };
34
+ //#endregion
35
+ //#region src/motion-set.ts
36
+ /**
37
+ * The set of motions currently moving. Motions leave the set as they
38
+ * rest and re-enter on any write that disturbs them; `onWake` tells the
39
+ * ticker the set became non-empty so it can stop sleeping.
40
+ */
41
+ var MotionSet = class {
42
+ reduced = false;
43
+ onWake = null;
44
+ flushes = new FlushQueue();
45
+ #motions = /* @__PURE__ */ new Set();
46
+ #debug;
47
+ #lastSize = 0;
48
+ #pass = 0;
49
+ constructor(debug = false) {
50
+ this.#debug = debug;
51
+ }
52
+ get size() {
53
+ return this.#motions.size;
54
+ }
55
+ add(motion) {
56
+ const wasEmpty = this.#motions.size === 0;
57
+ this.#motions.add(motion);
58
+ if (wasEmpty) this.onWake?.();
59
+ }
60
+ remove(motion) {
61
+ this.#motions.delete(motion);
62
+ }
63
+ finishAll() {
64
+ this.flushes.batch(() => {
65
+ for (const motion of this.#motions) {
66
+ if (!motion.respectsReducedMotion) continue;
67
+ motion.finish();
68
+ this.#motions.delete(motion);
69
+ }
70
+ });
71
+ }
72
+ tick(dt) {
73
+ this.#pass++;
74
+ this.flushes.enter();
75
+ try {
76
+ for (const motion of this.#motions) {
77
+ if (motion._pass === this.#pass) continue;
78
+ motion._pass = this.#pass;
79
+ motion.tick(dt);
80
+ if (motion.isResting) this.#motions.delete(motion);
81
+ }
82
+ } finally {
83
+ this.flushes.exit();
84
+ }
85
+ if (this.#debug && this.#motions.size !== this.#lastSize) {
86
+ this.#lastSize = this.#motions.size;
87
+ console.log(`coily: ${this.#lastSize} active motions`);
88
+ }
89
+ }
90
+ };
91
+ //#endregion
92
+ //#region src/util.ts
93
+ function invariant(condition, message) {
94
+ if (condition) return;
95
+ const resolved = typeof message === "function" ? message() : message;
96
+ throw new Error(resolved ?? "Invariant Failed");
97
+ }
98
+ /**
99
+ * True for plain objects only. Class instances are excluded so shape
100
+ * walks reject values like a `Spring` instead of descending into their
101
+ * fields as if they were nested shapes.
102
+ */
103
+ function isRecord(value) {
104
+ if (typeof value !== "object" || value === null) return false;
105
+ const proto = Object.getPrototypeOf(value);
106
+ return proto === Object.prototype || proto === null;
107
+ }
108
+ function isRecordOrArray(value) {
109
+ return isRecord(value) || Array.isArray(value);
110
+ }
111
+ function isArray(value) {
112
+ return Array.isArray(value);
113
+ }
114
+ function isNumber(value) {
115
+ return typeof value === "number";
116
+ }
117
+ const RESOLVED = Promise.resolve();
118
+ //#endregion
119
+ //#region src/config.ts
120
+ /**
121
+ * Immutable, resolved spring parameters. Whatever `defineSpring` input
122
+ * shape produced it, a config carries concrete `mass`, `tension`,
123
+ * `damping`, and `precision`, plus the quantities derived from them.
124
+ * Instances are frozen and safe to share between springs; to change a
125
+ * spring's behavior, assign a new config via `spring.config`.
126
+ */
127
+ var SpringDefinition = class SpringDefinition {
128
+ /** The config for springs created without one: critically damped, settling in about 500ms. */
129
+ static default = new SpringDefinition({
130
+ dampingRatio: 1,
131
+ duration: 500
132
+ });
133
+ /** Mass of the moving value, as provided or derived. */
134
+ mass;
135
+ /** Spring stiffness, as provided or derived. */
136
+ tension;
137
+ /** Friction strength, as provided or derived. */
138
+ damping;
139
+ /** Decimal places of the resting threshold — see `restingMagnitude`. */
140
+ precision;
141
+ /** Oscillation rate with no damping: `sqrt(tension / mass)`, in radians per second. */
142
+ naturalFrequency;
143
+ /** The damping at which bounce disappears: `2 * mass * naturalFrequency`. */
144
+ criticalDamping;
145
+ /**
146
+ * `damping / criticalDamping`. Below 1 the spring overshoots and
147
+ * bounces, at 1 it settles fastest without overshooting, above 1 it
148
+ * settles slower.
149
+ */
150
+ dampingRatio;
151
+ /** Resting threshold in value units: half a unit in the last `precision` place. */
152
+ restingMagnitude;
153
+ constructor(input) {
154
+ const raw = input;
155
+ const mass = raw.mass;
156
+ const tension = raw.tension;
157
+ const damping = raw.damping;
158
+ const bounce = raw.bounce;
159
+ const duration = raw.duration !== void 0 ? raw.duration / 1e3 : void 0;
160
+ const displacement = raw.displacement ?? 1;
161
+ const precision = raw.precision ?? 2;
162
+ for (const key of Object.keys(raw)) {
163
+ const value = raw[key];
164
+ if (value !== void 0) invariant(Number.isFinite(value), () => `Invalid ${key}: expected a finite number`);
165
+ }
166
+ if (mass !== void 0) invariant(mass > 0, "Mass must be greater than 0");
167
+ if (tension !== void 0) invariant(tension > 0, "Tension must be greater than 0");
168
+ if (damping !== void 0) invariant(damping >= 0, "Damping must be greater than or equal to 0");
169
+ if (bounce !== void 0) invariant(bounce >= -1 && bounce <= 1, "Bounce must be between -1 and 1");
170
+ invariant(raw.dampingRatio === void 0 || bounce === void 0, "Provide either dampingRatio or bounce, not both");
171
+ const dampingRatio = raw.dampingRatio ?? (bounce !== void 0 ? Math.max(1e-4, 1 - bounce) : void 0);
172
+ if (dampingRatio !== void 0) invariant(dampingRatio >= 0, "Damping ratio must be greater than or equal to 0");
173
+ if (duration !== void 0) invariant(duration > 0, "Duration must be greater than 0");
174
+ invariant(displacement !== 0, "Displacement must not be 0");
175
+ invariant(precision >= 0, "Precision must be greater than or equal to 0");
176
+ this.precision = precision;
177
+ this.restingMagnitude = .5 / 10 ** precision;
178
+ const hasM = mass !== void 0;
179
+ const hasK = tension !== void 0;
180
+ const hasC = damping !== void 0;
181
+ const hasZ = dampingRatio !== void 0;
182
+ const hasT = duration !== void 0;
183
+ if (hasK && hasC && !hasZ && !hasT) {
184
+ this.mass = mass ?? 1;
185
+ this.tension = tension;
186
+ this.damping = damping;
187
+ } else if (hasK && hasZ && !hasC && !hasT) {
188
+ this.mass = mass ?? 1;
189
+ this.tension = tension;
190
+ this.damping = 2 * dampingRatio * Math.sqrt(this.mass * tension);
191
+ } else if (hasC && hasZ && !hasK && !hasT) {
192
+ invariant(dampingRatio > 0, "dampingRatio must be > 0 to derive tension");
193
+ this.mass = mass ?? 1;
194
+ this.tension = damping ** 2 / (4 * dampingRatio ** 2 * this.mass);
195
+ this.damping = damping;
196
+ } else if (hasK && hasC && hasZ && !hasT) {
197
+ invariant(!hasM, "mass is derived when tension, damping, and dampingRatio are all provided");
198
+ invariant(dampingRatio > 0, "dampingRatio must be > 0 to derive mass");
199
+ this.mass = damping ** 2 / (4 * dampingRatio ** 2 * tension);
200
+ this.tension = tension;
201
+ this.damping = damping;
202
+ } else if (hasZ && hasT) {
203
+ const threshold = .005;
204
+ const x0 = Math.abs(displacement);
205
+ invariant(x0 > threshold, "displacement must be greater than the precision threshold");
206
+ let sigmaCoeff;
207
+ if (dampingRatio < 1) sigmaCoeff = dampingRatio;
208
+ else if (dampingRatio === 1) sigmaCoeff = 1;
209
+ else sigmaCoeff = dampingRatio - Math.sqrt(dampingRatio ** 2 - 1);
210
+ invariant(sigmaCoeff > 0, "dampingRatio must be > 0 for duration-based config");
211
+ const wn = 2 * Math.log(x0 / threshold) / (sigmaCoeff * duration);
212
+ if (!hasK && !hasC) {
213
+ this.mass = mass ?? 1;
214
+ this.tension = wn ** 2 * this.mass;
215
+ this.damping = 2 * dampingRatio * this.mass * wn;
216
+ } else if (hasK && !hasC) {
217
+ invariant(!hasM, "mass is derived in duration-based configs with tension");
218
+ this.mass = tension / wn ** 2;
219
+ this.tension = tension;
220
+ this.damping = 2 * dampingRatio * this.mass * wn;
221
+ } else if (hasC && !hasK) {
222
+ invariant(!hasM, "mass is derived in duration-based configs with damping");
223
+ this.mass = damping / (2 * dampingRatio * wn);
224
+ this.tension = wn ** 2 * this.mass;
225
+ this.damping = damping;
226
+ } else throw new Error("Invalid spring config: provide one of the supported input shapes.");
227
+ } else throw new Error("Invalid spring config: provide one of the supported input shapes.");
228
+ this.naturalFrequency = Math.sqrt(this.tension / this.mass);
229
+ this.criticalDamping = 2 * this.mass * this.naturalFrequency;
230
+ this.dampingRatio = this.damping / this.criticalDamping;
231
+ Object.freeze(this);
232
+ }
233
+ /**
234
+ * Estimates how long a spring with this config takes to come to rest
235
+ * from `state`, in milliseconds.
236
+ *
237
+ * The estimate follows the motion's decay envelope with a 2x safety
238
+ * margin, so actual rest usually lands earlier. Returns 0 when `state`
239
+ * is already inside the resting threshold, and Infinity when the config
240
+ * never settles (a `dampingRatio` of 0).
241
+ */
242
+ computeTimeRemaining(state) {
243
+ const { position, velocity } = state;
244
+ const a0 = Math.abs(position) + Math.abs(velocity) / this.naturalFrequency;
245
+ if (a0 <= this.restingMagnitude) return 0;
246
+ let sigma;
247
+ if (this.dampingRatio < 1) sigma = this.dampingRatio * this.naturalFrequency;
248
+ else if (this.dampingRatio === 1) sigma = this.naturalFrequency;
249
+ else sigma = (this.dampingRatio - Math.sqrt(this.dampingRatio * this.dampingRatio - 1)) * this.naturalFrequency;
250
+ if (sigma <= 0) return Infinity;
251
+ return 2 * Math.log(a0 / this.restingMagnitude) / sigma * 1e3;
252
+ }
253
+ };
254
+ function defineSpring(input) {
255
+ return new SpringDefinition(input);
256
+ }
257
+ //#endregion
258
+ //#region src/emitter.ts
259
+ /** Minimal fixed-event emitter behind motions and composite springs. */
260
+ var Emitter = class {
261
+ #update = [];
262
+ #start = [];
263
+ #stop = [];
264
+ #dispose = [];
265
+ on(type, handler) {
266
+ this.#list(type).push(handler);
267
+ return () => {
268
+ this.off(type, handler);
269
+ };
270
+ }
271
+ off(type, handler) {
272
+ const list = this.#list(type);
273
+ if (handler) list.splice(list.indexOf(handler) >>> 0, 1);
274
+ else list.length = 0;
275
+ }
276
+ clear() {
277
+ this.#update.length = 0;
278
+ this.#start.length = 0;
279
+ this.#stop.length = 0;
280
+ this.#dispose.length = 0;
281
+ }
282
+ emit(type) {
283
+ const list = this.#list(type);
284
+ if (list.length === 1) list[0]();
285
+ else if (list.length > 1) for (const handler of list.slice()) handler();
286
+ }
287
+ #list(type) {
288
+ switch (type) {
289
+ case "update": return this.#update;
290
+ case "start": return this.#start;
291
+ case "stop": return this.#stop;
292
+ case "dispose": return this.#dispose;
293
+ }
294
+ }
295
+ };
296
+ //#endregion
297
+ //#region src/state.ts
298
+ /**
299
+ * The mutable position/velocity pair a motion and its solvers share, in
300
+ * displacement space: position 0 means at the target.
301
+ */
302
+ var State = class {
303
+ #config;
304
+ #position;
305
+ #velocity;
306
+ constructor(config, position, velocity) {
307
+ this.#config = config;
308
+ this.#position = position;
309
+ this.#velocity = velocity;
310
+ }
311
+ configure(config) {
312
+ this.#config = config;
313
+ }
314
+ get position() {
315
+ return this.#position;
316
+ }
317
+ set position(value) {
318
+ this.#position = value;
319
+ }
320
+ get velocity() {
321
+ return this.#velocity;
322
+ }
323
+ set velocity(value) {
324
+ this.#velocity = value;
325
+ }
326
+ /**
327
+ * The rest test: displacement, plus the displacement the current
328
+ * velocity could still convert into (|x| + |v| / wn), is inside the
329
+ * config's resting threshold.
330
+ */
331
+ get isResting() {
332
+ return Math.abs(this.#position) + Math.abs(this.#velocity) / this.#config.naturalFrequency <= this.#config.restingMagnitude;
333
+ }
334
+ };
335
+ //#endregion
336
+ //#region src/solver.ts
337
+ /** dampingRatio < 1: decaying oscillation, x(t) = exp(-sigma*t) * (c1*cos(wd*t) + c2*sin(wd*t)). */
338
+ var UnderdampedSolver = class {
339
+ #state;
340
+ #dampedFrequency = 0;
341
+ #decayRate = 0;
342
+ #t = 0;
343
+ #c1 = 0;
344
+ #c2 = 0;
345
+ constructor(state) {
346
+ this.#state = state;
347
+ }
348
+ configure(config) {
349
+ if (config) {
350
+ this.#decayRate = config.dampingRatio * config.naturalFrequency;
351
+ this.#dampedFrequency = config.naturalFrequency * Math.sqrt(1 - config.dampingRatio ** 2);
352
+ }
353
+ this.#t = 0;
354
+ this.#c1 = this.#state.position;
355
+ this.#c2 = (this.#state.velocity + this.#decayRate * this.#state.position) / this.#dampedFrequency;
356
+ }
357
+ tick(dt) {
358
+ this.#t += dt;
359
+ const sin = Math.sin(this.#dampedFrequency * this.#t);
360
+ const cos = Math.cos(this.#dampedFrequency * this.#t);
361
+ const decay = Math.exp(-this.#decayRate * this.#t);
362
+ const decayVelocity = -this.#decayRate * decay;
363
+ const oscillation = this.#c1 * cos + this.#c2 * sin;
364
+ const oscillationVelocity = -this.#c1 * this.#dampedFrequency * sin + this.#c2 * this.#dampedFrequency * cos;
365
+ this.#state.position = decay * oscillation;
366
+ this.#state.velocity = decay * oscillationVelocity + decayVelocity * oscillation;
367
+ }
368
+ };
369
+ /** dampingRatio = 1: fastest non-oscillating decay, x(t) = (c1 + c2*t) * exp(-wn*t). */
370
+ var CriticallyDampedSolver = class {
371
+ #state;
372
+ #naturalFrequency = 0;
373
+ #t = 0;
374
+ #c1 = 0;
375
+ #c2 = 0;
376
+ constructor(state) {
377
+ this.#state = state;
378
+ }
379
+ configure(config) {
380
+ if (config) this.#naturalFrequency = config.naturalFrequency;
381
+ this.#t = 0;
382
+ this.#c1 = this.#state.position;
383
+ this.#c2 = this.#state.velocity + this.#naturalFrequency * this.#state.position;
384
+ }
385
+ tick(dt) {
386
+ this.#t += dt;
387
+ const decay = Math.exp(-this.#naturalFrequency * this.#t);
388
+ const decayVelocity = -this.#naturalFrequency * decay;
389
+ const scale = this.#c1 + this.#c2 * this.#t;
390
+ const scaleVelocity = this.#c2;
391
+ this.#state.position = scale * decay;
392
+ this.#state.velocity = scaleVelocity * decay + decayVelocity * scale;
393
+ }
394
+ };
395
+ /** dampingRatio > 1: slow non-oscillating decay, x(t) = exp(-sigma*t) * (c1*sinh(wd*t) + c2*cosh(wd*t)) / wd. */
396
+ var OverdampedSolver = class {
397
+ #state;
398
+ #dampedFrequency = 0;
399
+ #decayRate = 0;
400
+ #t = 0;
401
+ #c1 = 0;
402
+ #c2 = 0;
403
+ constructor(state) {
404
+ this.#state = state;
405
+ }
406
+ configure(config) {
407
+ if (config) {
408
+ this.#decayRate = config.dampingRatio * config.naturalFrequency;
409
+ this.#dampedFrequency = config.naturalFrequency * Math.sqrt(config.dampingRatio ** 2 - 1);
410
+ }
411
+ this.#t = 0;
412
+ this.#c1 = this.#state.velocity + this.#decayRate * this.#state.position;
413
+ this.#c2 = this.#state.position * this.#dampedFrequency;
414
+ }
415
+ tick(dt) {
416
+ this.#t += dt;
417
+ const decay = Math.exp(-this.#decayRate * this.#t);
418
+ const decayVelocity = -this.#decayRate * decay;
419
+ const clamped = Math.min(this.#dampedFrequency * this.#t, 300);
420
+ const sinh = Math.sinh(clamped);
421
+ const cosh = Math.cosh(clamped);
422
+ const scale = this.#c1 * sinh + this.#c2 * cosh;
423
+ const scaleVelocity = this.#c1 * this.#dampedFrequency * cosh + this.#c2 * this.#dampedFrequency * sinh;
424
+ this.#state.position = scale * decay / this.#dampedFrequency;
425
+ this.#state.velocity = (scale * decayVelocity + scaleVelocity * decay) / this.#dampedFrequency;
426
+ }
427
+ };
428
+ //#endregion
429
+ //#region src/motion.ts
430
+ /**
431
+ * One spring simulation in displacement space: position is measured from
432
+ * the target (0 means settled), so retargeting is a rebase performed by
433
+ * the owning `Spring`. Picks the closed-form solver for the config's
434
+ * damping regime and emits update/start/stop/dispose events.
435
+ */
436
+ var Motion = class {
437
+ /** Tick-pass marker written by `MotionSet` so a motion re-added mid-pass isn't ticked twice. */
438
+ _pass = 0;
439
+ respectsReducedMotion = true;
440
+ #config;
441
+ #state;
442
+ #underdampedSolver = null;
443
+ #criticallyDampedSolver = null;
444
+ #overdampedSolver = null;
445
+ #currentSolver = null;
446
+ #needsUpdate = false;
447
+ #needsReset = false;
448
+ #timeRemaining = 0;
449
+ #running;
450
+ #emitter;
451
+ constructor(config, position, velocity) {
452
+ this.#config = config;
453
+ this.#state = new State(config, position, velocity);
454
+ this.#emitter = new Emitter();
455
+ this.#running = !this.#state.isResting;
456
+ this.#updateSolver();
457
+ this.#timeRemaining = this.#config.computeTimeRemaining(this.#state);
458
+ }
459
+ get position() {
460
+ return this.#state.position;
461
+ }
462
+ set position(value) {
463
+ this.#state.position = value;
464
+ this.#needsReset = true;
465
+ this.#syncStart();
466
+ }
467
+ get velocity() {
468
+ return this.#state.velocity;
469
+ }
470
+ set velocity(value) {
471
+ this.#state.velocity = value;
472
+ this.#needsReset = true;
473
+ this.#syncStart();
474
+ }
475
+ get timeRemaining() {
476
+ return this.#timeRemaining;
477
+ }
478
+ get isResting() {
479
+ return this.#state.isResting;
480
+ }
481
+ configure(config) {
482
+ this.#config = config;
483
+ this.#state.configure(config);
484
+ this.#needsUpdate = true;
485
+ this.#syncStart();
486
+ }
487
+ tick(dt, emit = true) {
488
+ invariant(this.#currentSolver, "Cannot tick a disposed motion");
489
+ const needsTimeRemaining = this.#needsUpdate || this.#needsReset;
490
+ if (this.#needsUpdate) {
491
+ this.#updateSolver();
492
+ this.#needsUpdate = false;
493
+ this.#needsReset = false;
494
+ } else if (this.#needsReset) {
495
+ this.#currentSolver.configure();
496
+ this.#needsReset = false;
497
+ }
498
+ if (needsTimeRemaining) this.#timeRemaining = this.#config.computeTimeRemaining(this.#state);
499
+ this.#currentSolver.tick(dt);
500
+ this.#timeRemaining = Math.max(0, this.#timeRemaining - dt * 1e3);
501
+ if (this.#state.isResting) {
502
+ this.#state.position = 0;
503
+ this.#state.velocity = 0;
504
+ this.#needsReset = true;
505
+ }
506
+ if (emit) this.#emitter.emit("update");
507
+ if (this.#state.isResting) {
508
+ if (this.#running) {
509
+ this.#running = false;
510
+ this.#timeRemaining = 0;
511
+ this.#emitter.emit("stop");
512
+ }
513
+ } else if (!this.#running) {
514
+ this.#running = true;
515
+ this.#emitter.emit("start");
516
+ }
517
+ }
518
+ finish() {
519
+ this.position = 0;
520
+ this.velocity = 0;
521
+ this.tick(0);
522
+ }
523
+ onUpdate(callback) {
524
+ return this.#emitter.on("update", callback);
525
+ }
526
+ onStart(callback) {
527
+ return this.#emitter.on("start", callback);
528
+ }
529
+ onStop(callback) {
530
+ return this.#emitter.on("stop", callback);
531
+ }
532
+ onDispose(callback) {
533
+ return this.#emitter.on("dispose", callback);
534
+ }
535
+ dispose() {
536
+ this.#emitter.emit("dispose");
537
+ this.#emitter.clear();
538
+ this.#underdampedSolver = null;
539
+ this.#criticallyDampedSolver = null;
540
+ this.#overdampedSolver = null;
541
+ this.#currentSolver = null;
542
+ }
543
+ #syncStart() {
544
+ if (!this.#running && !this.#state.isResting) {
545
+ this.#running = true;
546
+ this.#emitter.emit("start");
547
+ }
548
+ }
549
+ #updateSolver() {
550
+ if (this.#config.dampingRatio < 1) {
551
+ this.#underdampedSolver ||= new UnderdampedSolver(this.#state);
552
+ this.#currentSolver = this.#underdampedSolver;
553
+ } else if (this.#config.dampingRatio === 1) {
554
+ this.#criticallyDampedSolver ||= new CriticallyDampedSolver(this.#state);
555
+ this.#currentSolver = this.#criticallyDampedSolver;
556
+ } else {
557
+ this.#overdampedSolver ||= new OverdampedSolver(this.#state);
558
+ this.#currentSolver = this.#overdampedSolver;
559
+ }
560
+ this.#currentSolver.configure(this.#config);
561
+ }
562
+ };
563
+ //#endregion
564
+ //#region src/shape-tree.ts
565
+ function joinPath(path, key) {
566
+ return path ? `${path}.${key}` : String(key);
567
+ }
568
+ function describePath(path) {
569
+ return path ? `'${path}'` : "the root";
570
+ }
571
+ /** The sentinel a resolver returns to descend into a node instead of covering it. */
572
+ const BRANCH = Symbol("branch");
573
+ /** The standard scatter leaf guard: partial numeric shapes take only finite numbers at channels. */
574
+ function acceptNumber(input, path) {
575
+ invariant(isNumber(input) && Number.isFinite(input), () => `Expected a finite number for channel '${path}'`);
576
+ return input;
577
+ }
578
+ /**
579
+ * The canonical numeric `LeafParser`: finite-number leaves built through
580
+ * `factory`, named "channels" in structural errors. The default for value
581
+ * shapes, whose leaves are all numbers.
582
+ */
583
+ function channelParser(factory) {
584
+ return {
585
+ match(value, path) {
586
+ if (!isNumber(value)) return void 0;
587
+ invariant(Number.isFinite(value), () => `Invalid value at '${path}': channel values must be finite`);
588
+ return factory(value, path);
589
+ },
590
+ mismatch: (path) => `Invalid value at '${path}': expected a number or a nested shape of numbers`,
591
+ empty: (path) => `Invalid value at ${describePath(path)}: a shape must contain at least one channel`
592
+ };
593
+ }
594
+ var LeafNode = class LeafNode {
595
+ path;
596
+ leaf;
597
+ constructor(leaf, path) {
598
+ this.path = path;
599
+ this.leaf = leaf;
600
+ }
601
+ mirror(read, slots, carrier, key) {
602
+ carrier[key] = read(this.leaf);
603
+ slots.push({
604
+ leaf: this.leaf,
605
+ key,
606
+ carrier
607
+ });
608
+ }
609
+ scatter(input, accept, apply) {
610
+ apply(this.leaf, accept(input, this.path));
611
+ }
612
+ zip(theirs, fn) {
613
+ invariant(theirs instanceof LeafNode, () => `Shape mismatch at ${describePath(this.path)}`);
614
+ fn(this.leaf, theirs.leaf);
615
+ }
616
+ broadcast(input, resolve, apply, _label) {
617
+ const resolved = resolve(input, this.path);
618
+ invariant(resolved !== BRANCH, `Cannot descend into channel '${this.path}'`);
619
+ apply(this.leaf, resolved);
620
+ }
621
+ cover(value, apply) {
622
+ apply(this.leaf, value);
623
+ }
624
+ };
625
+ var ListNode = class ListNode {
626
+ path;
627
+ children;
628
+ constructor(children, path) {
629
+ this.path = path;
630
+ this.children = children;
631
+ }
632
+ mirror(read, slots, carrier, key) {
633
+ const mine = [];
634
+ carrier[key] = mine;
635
+ const slotcarrier = mine;
636
+ for (let i = 0; i < this.children.length; i++) this.children[i].mirror(read, slots, slotcarrier, i);
637
+ }
638
+ scatter(input, accept, apply) {
639
+ invariant(isArray(input), () => `Expected an array at ${describePath(this.path)}`);
640
+ invariant(input.length <= this.children.length, `Unknown channel '${joinPath(this.path, this.children.length)}'`);
641
+ for (let i = 0; i < input.length; i++) {
642
+ if (input[i] === void 0) continue;
643
+ this.children[i].scatter(input[i], accept, apply);
644
+ }
645
+ }
646
+ zip(theirs, fn) {
647
+ invariant(theirs instanceof ListNode && theirs.children.length === this.children.length, () => `Shape mismatch at ${describePath(this.path)}`);
648
+ for (let i = 0; i < this.children.length; i++) this.children[i].zip(theirs.children[i], fn);
649
+ }
650
+ broadcast(input, resolve, apply, label) {
651
+ const resolved = resolve(input, this.path);
652
+ if (resolved !== BRANCH) {
653
+ this.cover(resolved, apply);
654
+ return;
655
+ }
656
+ invariant(isArray(input), () => `Expected an array at ${describePath(this.path)} in ${label}`);
657
+ invariant(input.length <= this.children.length, () => `Unknown channel '${joinPath(this.path, this.children.length)}' in ${label}`);
658
+ for (let i = 0; i < input.length; i++) {
659
+ if (input[i] === void 0) continue;
660
+ this.children[i].broadcast(input[i], resolve, apply, label);
661
+ }
662
+ }
663
+ cover(value, apply) {
664
+ for (const child of this.children) child.cover(value, apply);
665
+ }
666
+ };
667
+ var RecordNode = class RecordNode {
668
+ path;
669
+ children;
670
+ constructor(children, path) {
671
+ this.path = path;
672
+ this.children = children;
673
+ }
674
+ mirror(read, slots, carrier, key) {
675
+ const mine = {};
676
+ carrier[key] = mine;
677
+ for (const [childKey, child] of this.children) child.mirror(read, slots, mine, childKey);
678
+ }
679
+ scatter(input, accept, apply) {
680
+ invariant(isRecord(input), () => `Expected an object at ${describePath(this.path)}`);
681
+ for (const key of Object.keys(input)) {
682
+ const value = input[key];
683
+ if (value === void 0) continue;
684
+ const child = this.children.get(key);
685
+ invariant(child !== void 0, () => `Unknown channel '${joinPath(this.path, key)}'`);
686
+ child.scatter(value, accept, apply);
687
+ }
688
+ }
689
+ zip(theirs, fn) {
690
+ invariant(theirs instanceof RecordNode && theirs.children.size === this.children.size, () => `Shape mismatch at ${describePath(this.path)}`);
691
+ for (const [key, child] of this.children) {
692
+ const their = theirs.children.get(key);
693
+ invariant(their !== void 0, () => `Shape mismatch at ${describePath(this.path)}`);
694
+ child.zip(their, fn);
695
+ }
696
+ }
697
+ broadcast(input, resolve, apply, label) {
698
+ const resolved = resolve(input, this.path);
699
+ if (resolved !== BRANCH) {
700
+ this.cover(resolved, apply);
701
+ return;
702
+ }
703
+ invariant(isRecord(input), () => `Expected an object at ${describePath(this.path)} in ${label}`);
704
+ for (const key of Object.keys(input)) {
705
+ const value = input[key];
706
+ if (value === void 0) continue;
707
+ const child = this.children.get(key);
708
+ invariant(child !== void 0, () => `Unknown channel '${joinPath(this.path, key)}' in ${label}`);
709
+ child.broadcast(value, resolve, apply, label);
710
+ }
711
+ }
712
+ cover(value, apply) {
713
+ for (const child of this.children.values()) child.cover(value, apply);
714
+ }
715
+ };
716
+ /**
717
+ * A shape parsed into a node tree, paired with its leaves flattened in
718
+ * depth-first shape order. `parser.match` decides leaf vs branch at every
719
+ * position; the root must already be a container — callers validate that
720
+ * (with their own message) before building. Nested non-containers and
721
+ * empty nodes throw through `parser.mismatch`/`parser.empty`.
722
+ *
723
+ * Holds no behavior of its own: traversals run through `root`, and a live
724
+ * view comes from `new ShapeView(root, read)`.
725
+ */
726
+ var ShapeTree = class {
727
+ root;
728
+ leaves;
729
+ constructor(shape, parser) {
730
+ const leaves = [];
731
+ this.root = buildContainer(shape, "", parser, leaves);
732
+ this.leaves = leaves;
733
+ }
734
+ };
735
+ function buildContainer(shape, path, parser, leaves) {
736
+ if (isArray(shape)) {
737
+ invariant(shape.length > 0, () => parser.empty(path));
738
+ return new ListNode(shape.map((child, i) => buildChild(child, joinPath(path, i), parser, leaves)), path);
739
+ }
740
+ const keys = Object.keys(shape);
741
+ invariant(keys.length > 0, () => parser.empty(path));
742
+ const children = /* @__PURE__ */ new Map();
743
+ for (const key of keys) children.set(key, buildChild(shape[key], joinPath(path, key), parser, leaves));
744
+ return new RecordNode(children, path);
745
+ }
746
+ function buildChild(value, path, parser, leaves) {
747
+ const leaf = parser.match(value, path);
748
+ if (leaf !== void 0) {
749
+ leaves.push(leaf);
750
+ return new LeafNode(leaf, path);
751
+ }
752
+ invariant(isRecordOrArray(value), () => parser.mismatch(path));
753
+ return buildContainer(value, path, parser, leaves);
754
+ }
755
+ /**
756
+ * A live view of a shape: `read(leaf)` at every leaf, mirrored into a stable
757
+ * object — `refresh` rewrites the values in place through slots recorded at
758
+ * construction, so per-frame reads do no traversal or allocation.
759
+ */
760
+ var ShapeView = class {
761
+ root;
762
+ #read;
763
+ #slots = [];
764
+ constructor(root, read) {
765
+ this.#read = read;
766
+ const scratch = {};
767
+ root.mirror(read, this.#slots, scratch, "root");
768
+ this.root = scratch["root"];
769
+ }
770
+ /** Re-reads every leaf into the view. */
771
+ refresh() {
772
+ for (const slot of this.#slots) slot.carrier[slot.key] = this.#read(slot.leaf);
773
+ }
774
+ };
775
+ //#endregion
776
+ //#region src/spring-source.ts
777
+ /**
778
+ * The property key that brands a `SpringSource` and holds the channel its
779
+ * followers read. A registry symbol (`Symbol.for('coily/spring-source')`),
780
+ * so sources from one copy of coily are recognized by another when a
781
+ * bundle duplicates the library.
782
+ */
783
+ const SpringSourceSymbol = Symbol.for("coily/spring-source");
784
+ function isSpringSource(value) {
785
+ return typeof value === "object" && value !== null && SpringSourceSymbol in value;
786
+ }
787
+ const RECIPES = /* @__PURE__ */ new WeakMap();
788
+ function applyFns(fns, value) {
789
+ let result = value;
790
+ for (const fn of fns) result = fn(result);
791
+ return result;
792
+ }
793
+ function sourceMismatch(path) {
794
+ return `Invalid value at ${describePath(path)}: expected a SpringSource or a nested shape of SpringSources`;
795
+ }
796
+ /**
797
+ * Parses a shape of sources into a live view, collecting subscription
798
+ * roots along the way (a mapped leaf contributes its recipe's roots and
799
+ * reads through its pipeline). Structure is validated once at creation;
800
+ * reads refresh a stable mirror through the view's slots, so per-update
801
+ * reads do no traversal or allocation.
802
+ */
803
+ function compileShape(shape, roots) {
804
+ const { root } = new ShapeTree(shape, {
805
+ match(value) {
806
+ if (!isSpringSource(value)) return void 0;
807
+ const recipe = RECIPES.get(value);
808
+ if (recipe) {
809
+ roots.push(...recipe.sources);
810
+ return () => applyFns(recipe.fns, recipe.read());
811
+ }
812
+ roots.push(value);
813
+ const api = value[SpringSourceSymbol];
814
+ return () => api.value;
815
+ },
816
+ mismatch: sourceMismatch,
817
+ empty: (path) => `Invalid value at ${describePath(path)}: a shape must contain at least one source`
818
+ });
819
+ return new ShapeView(root, (leaf) => leaf());
820
+ }
821
+ function mapSpring(source, map) {
822
+ const compute = map;
823
+ let recipe;
824
+ if (isSpringSource(source)) {
825
+ const flat = RECIPES.get(source);
826
+ if (flat) recipe = {
827
+ sources: flat.sources,
828
+ read: flat.read,
829
+ fns: [...flat.fns, compute]
830
+ };
831
+ else {
832
+ const api = source[SpringSourceSymbol];
833
+ recipe = {
834
+ sources: [source],
835
+ read: () => api.value,
836
+ fns: [compute]
837
+ };
838
+ }
839
+ } else {
840
+ invariant(isRecordOrArray(source), () => sourceMismatch(""));
841
+ const roots = [];
842
+ const view = compileShape(source, roots);
843
+ recipe = {
844
+ sources: [...new Set(roots)],
845
+ read: () => {
846
+ view.refresh();
847
+ return view.root;
848
+ },
849
+ fns: [compute]
850
+ };
851
+ }
852
+ const { sources, read, fns } = recipe;
853
+ const single = sources.length === 1 ? sources[0][SpringSourceSymbol] : null;
854
+ const apis = single ? null : sources.map((leaf) => leaf[SpringSourceSymbol]);
855
+ const mapped = Object.freeze({ [SpringSourceSymbol]: Object.freeze({
856
+ get value() {
857
+ return applyFns(fns, read());
858
+ },
859
+ onUpdate: single ? (callback) => single.onUpdate(callback) : (callback) => {
860
+ const unsubscribes = apis.map((api) => api.onUpdate(callback));
861
+ return () => {
862
+ for (const unsubscribe of unsubscribes) unsubscribe();
863
+ };
864
+ },
865
+ onDispose: single ? (callback) => single.onDispose(callback) : (callback) => {
866
+ let fired = false;
867
+ const unsubscribes = apis.map((api) => api.onDispose(() => {
868
+ if (fired) return;
869
+ fired = true;
870
+ for (const unsubscribe of unsubscribes) unsubscribe();
871
+ callback();
872
+ }));
873
+ return () => {
874
+ for (const unsubscribe of unsubscribes) unsubscribe();
875
+ };
876
+ }
877
+ }) });
878
+ RECIPES.set(mapped, recipe);
879
+ return mapped;
880
+ }
881
+ //#endregion
882
+ //#region src/spring.ts
883
+ /**
884
+ * One animated number, driven toward its target with damped spring
885
+ * motion. Create springs with `SpringSystem.createSpring`; the owning
886
+ * system advances them, so a spring only moves while its system runs.
887
+ *
888
+ * Reads return the exact simulated state; nothing is rounded. Numeric
889
+ * writes — `target`, `value`, `velocity`, `jumpTo` — require finite
890
+ * numbers: `NaN` or an infinity throws rather than poisoning the
891
+ * simulation. See
892
+ * https://github.com/tkofh/coily/blob/main/PRECISION.md for the
893
+ * numerical contract.
894
+ */
895
+ var Spring = class {
896
+ /** Brands the spring as a `KinematicSource` whose api is the spring itself. */
897
+ get [SpringSourceSymbol]() {
898
+ return this;
899
+ }
900
+ #target;
901
+ #config;
902
+ #purpose;
903
+ #motion;
904
+ #motions;
905
+ #leader = null;
906
+ #unsubLeader = null;
907
+ #settled = null;
908
+ #resolveSettled = null;
909
+ #disposed = false;
910
+ constructor(motions, value, config, purpose = "motion") {
911
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
912
+ invariant(purpose === "motion" || purpose === "appearance", "Spring purpose must be 'motion' or 'appearance'");
913
+ this.#motions = motions;
914
+ this.#config = config ?? SpringDefinition.default;
915
+ this.#target = value;
916
+ this.#purpose = purpose;
917
+ this.#motion = new Motion(this.#config, 0, 0);
918
+ this.#motion.respectsReducedMotion = purpose === "motion";
919
+ }
920
+ /**
921
+ * The value the spring is animating toward. While following a source,
922
+ * reads return the source's value as of its last update.
923
+ *
924
+ * Assignment accepts any `SpringTarget`:
925
+ * - A number retargets the spring. The current value and momentum carry
926
+ * over, so mid-flight retargets stay smooth. Assigning a number while
927
+ * following also stops the following.
928
+ * - A `SpringSource` — a `Spring`, or a value derived with `mapSpring` —
929
+ * makes this spring follow the source's live value.
930
+ *
931
+ * Under reduced motion, retargets apply instantly — unless `purpose` is
932
+ * `'appearance'`, in which case they animate as normal.
933
+ */
934
+ get target() {
935
+ return this.#target;
936
+ }
937
+ set target(value) {
938
+ if (typeof value === "number") {
939
+ this.#unfollow();
940
+ this.#setTarget(value);
941
+ } else {
942
+ invariant(isSpringSource(value), "Spring target must be a number or a SpringSource");
943
+ invariant(typeof value[SpringSourceSymbol].value === "number", "A spring can only follow a scalar SpringSource; derive one from a composite with mapSpring");
944
+ this.#follow(value);
945
+ }
946
+ }
947
+ /**
948
+ * The current animated value: the target plus the remaining
949
+ * displacement.
950
+ *
951
+ * Writing displaces the spring: it keeps its target and springs back
952
+ * from the written value, notifying update listeners synchronously.
953
+ * Writing the current value is a no-op. Under reduced motion a write
954
+ * jumps the spring — target included — to the written value, unless
955
+ * `purpose` is `'appearance'`, in which case it displaces as normal.
956
+ */
957
+ get value() {
958
+ return this.#target + this.#motion.position;
959
+ }
960
+ set value(value) {
961
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
962
+ if (this.#motions.reduced && this.#purpose === "motion") {
963
+ if (value !== this.value) this.jumpTo(value);
964
+ return;
965
+ }
966
+ const position = value - this.#target;
967
+ if (position !== this.#motion.position) {
968
+ this.#motions.add(this.#motion);
969
+ this.#motion.position = position;
970
+ this.#motion.tick(0);
971
+ }
972
+ }
973
+ /**
974
+ * The current velocity, in value units per second.
975
+ *
976
+ * Writing flings the spring: motion continues from the current value
977
+ * with the written velocity, then settles back to the target. Under
978
+ * reduced motion writes are ignored, unless `purpose` is `'appearance'`,
979
+ * in which case the fling applies as normal.
980
+ */
981
+ get velocity() {
982
+ return this.#motion.velocity;
983
+ }
984
+ set velocity(value) {
985
+ invariant(Number.isFinite(value), "Spring velocity must be a finite number");
986
+ if (this.#motions.reduced && this.#purpose === "motion") return;
987
+ this.#motions.add(this.#motion);
988
+ this.#motion.velocity = value;
989
+ }
990
+ /**
991
+ * The current acceleration, in value units per second squared — how
992
+ * fast the velocity is changing. Read-only: acceleration is the
993
+ * spring's stiffness and friction acting on its current displacement
994
+ * and velocity, so it follows from the motion rather than being set.
995
+ * To fling the spring, write `velocity` instead.
996
+ */
997
+ get acceleration() {
998
+ const { tension, damping, mass } = this.#config;
999
+ return -(tension * this.#motion.position + damping * this.#motion.velocity) / mass;
1000
+ }
1001
+ /**
1002
+ * What the spring animates — `'motion'` or `'appearance'` — fixed when
1003
+ * it was created. An `'appearance'` spring opts out of reduced motion:
1004
+ * it keeps animating where a `'motion'` spring snaps to its target. See
1005
+ * `Purpose`.
1006
+ */
1007
+ get purpose() {
1008
+ return this.#purpose;
1009
+ }
1010
+ /**
1011
+ * The spring's `SpringDefinition`: the one it was created with or last
1012
+ * assigned, otherwise `SpringDefinition.default`. Following a source
1013
+ * never changes it — how a spring chases its target is the spring's
1014
+ * own setting.
1015
+ *
1016
+ * Assigning a config reconfigures the spring in place — value and
1017
+ * velocity are preserved, so mid-flight reconfigures stay smooth.
1018
+ * Assigning `null` reverts to the default.
1019
+ */
1020
+ get config() {
1021
+ return this.#config;
1022
+ }
1023
+ /** The config's mass. */
1024
+ get mass() {
1025
+ return this.#config.mass;
1026
+ }
1027
+ /** The config's tension (stiffness). */
1028
+ get tension() {
1029
+ return this.#config.tension;
1030
+ }
1031
+ /** The config's damping (friction). */
1032
+ get damping() {
1033
+ return this.#config.damping;
1034
+ }
1035
+ /** The config's damping ratio. */
1036
+ get dampingRatio() {
1037
+ return this.#config.dampingRatio;
1038
+ }
1039
+ /** The config's resting precision. */
1040
+ get precision() {
1041
+ return this.#config.precision;
1042
+ }
1043
+ set config(value) {
1044
+ const next = value ?? SpringDefinition.default;
1045
+ if (this.#config === next) return;
1046
+ this.#config = next;
1047
+ this.#motion.configure(next);
1048
+ if (!this.#motion.isResting) this.#motions.add(this.#motion);
1049
+ }
1050
+ /**
1051
+ * Estimated milliseconds until the spring rests: 0 while resting,
1052
+ * Infinity when undamped. The estimate carries a safety margin, so
1053
+ * actual rest usually lands earlier.
1054
+ */
1055
+ get timeRemaining() {
1056
+ return this.#motion.timeRemaining;
1057
+ }
1058
+ /**
1059
+ * Whether the spring's remaining motion is inside its resting
1060
+ * threshold. Resting springs cost nothing until the next write. See
1061
+ * https://github.com/tkofh/coily/blob/main/PRECISION.md for the rest
1062
+ * test.
1063
+ */
1064
+ get isResting() {
1065
+ return this.#motion.isResting;
1066
+ }
1067
+ /**
1068
+ * A promise that resolves when the spring next comes to rest — already
1069
+ * resolved while resting or after dispose. Retargeting mid-flight
1070
+ * extends the wait; disposing resolves it.
1071
+ *
1072
+ * @example
1073
+ * ```ts
1074
+ * spring.target = 300
1075
+ * await spring.settled
1076
+ * // the spring is at 300 and resting
1077
+ * ```
1078
+ */
1079
+ get settled() {
1080
+ if (this.#disposed || this.#motion.isResting) return RESOLVED;
1081
+ this.#settled ??= new Promise((resolve) => {
1082
+ this.#resolveSettled = resolve;
1083
+ const unsubscribe = this.onStop(() => {
1084
+ unsubscribe();
1085
+ this.#settled = null;
1086
+ this.#resolveSettled = null;
1087
+ resolve();
1088
+ });
1089
+ });
1090
+ return this.#settled;
1091
+ }
1092
+ /**
1093
+ * Snaps the spring to `value` with no animation: target and value both
1094
+ * become `value` and velocity clears. Listeners are notified
1095
+ * synchronously — `update`, plus `stop` if the spring was moving.
1096
+ */
1097
+ jumpTo(value) {
1098
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
1099
+ this.#target = value;
1100
+ this.#motion.finish();
1101
+ }
1102
+ /**
1103
+ * Releases the spring permanently: stops following, detaches followers
1104
+ * (each keeps its current target), resolves `settled`, and notifies
1105
+ * dispose listeners. Calling it again is a no-op.
1106
+ *
1107
+ * A disposed spring keeps its final value readable; writes throw.
1108
+ */
1109
+ dispose() {
1110
+ if (this.#disposed) return;
1111
+ this.#disposed = true;
1112
+ if (this.#resolveSettled) {
1113
+ this.#resolveSettled();
1114
+ this.#settled = null;
1115
+ this.#resolveSettled = null;
1116
+ }
1117
+ if (this.#unsubLeader) {
1118
+ this.#unsubLeader();
1119
+ this.#unsubLeader = null;
1120
+ this.#leader = null;
1121
+ }
1122
+ this.#motions.remove(this.#motion);
1123
+ this.#motion.dispose();
1124
+ }
1125
+ /**
1126
+ * Subscribes to value changes: every tick the spring moves, plus
1127
+ * synchronous `value` writes and `jumpTo` calls. Returns an unsubscribe
1128
+ * function.
1129
+ */
1130
+ onUpdate(callback) {
1131
+ return this.#motion.onUpdate(callback);
1132
+ }
1133
+ /**
1134
+ * Subscribes to the spring leaving rest. `start` and `stop` always
1135
+ * alternate; retargeting mid-flight fires neither. Returns an
1136
+ * unsubscribe function.
1137
+ */
1138
+ onStart(callback) {
1139
+ return this.#motion.onStart(callback);
1140
+ }
1141
+ /**
1142
+ * Subscribes to the spring coming to rest, whether by settling or by
1143
+ * `jumpTo`. Always alternates with `start`. Returns an unsubscribe
1144
+ * function.
1145
+ */
1146
+ onStop(callback) {
1147
+ return this.#motion.onStop(callback);
1148
+ }
1149
+ /** Subscribes to `dispose`, which fires once. Returns an unsubscribe function. */
1150
+ onDispose(callback) {
1151
+ return this.#motion.onDispose(callback);
1152
+ }
1153
+ #setTarget(value) {
1154
+ invariant(Number.isFinite(value), "Spring target must be a finite number");
1155
+ if (value !== this.#target) {
1156
+ if (this.#motions.reduced && this.#purpose === "motion") {
1157
+ this.jumpTo(value);
1158
+ return;
1159
+ }
1160
+ this.#motions.add(this.#motion);
1161
+ const current = this.value;
1162
+ this.#target = value;
1163
+ this.#motion.position = current - this.#target;
1164
+ this.#motion.tick(0, false);
1165
+ }
1166
+ }
1167
+ #follow(source) {
1168
+ this.#unsubLeader?.();
1169
+ this.#leader = source;
1170
+ const api = source[SpringSourceSymbol];
1171
+ const unsubUpdate = api.onUpdate(() => {
1172
+ this.#setTarget(api.value);
1173
+ });
1174
+ const unsubDispose = api.onDispose(() => {
1175
+ this.#unfollow();
1176
+ });
1177
+ this.#unsubLeader = () => {
1178
+ unsubUpdate();
1179
+ unsubDispose();
1180
+ };
1181
+ this.#setTarget(api.value);
1182
+ }
1183
+ #unfollow() {
1184
+ if (!this.#leader) return;
1185
+ this.#unsubLeader();
1186
+ this.#unsubLeader = null;
1187
+ this.#leader = null;
1188
+ }
1189
+ };
1190
+ //#endregion
1191
+ //#region src/composite-spring.ts
1192
+ function invalidConfig(path) {
1193
+ return `Invalid config for ${describePath(path)}: expected a SpringDefinition, null, or a config shape matching the value`;
1194
+ }
1195
+ /**
1196
+ * Decides what a config node means: `null` and `SpringDefinition` instances are
1197
+ * configs covering the whole subtree, any other object or array is a config
1198
+ * shape to descend into (its structure is checked on the way down), and
1199
+ * anything else — a bare number, string, or options object — is invalid.
1200
+ */
1201
+ function resolveConfigNode(node, path) {
1202
+ if (node === null) return null;
1203
+ if (node instanceof SpringDefinition) return node;
1204
+ if (isRecordOrArray(node)) return BRANCH;
1205
+ throw new Error(invalidConfig(path));
1206
+ }
1207
+ function invalidPurpose(path) {
1208
+ return `Invalid purpose for ${describePath(path)}: expected 'motion', 'appearance', or a purpose shape matching the value`;
1209
+ }
1210
+ /**
1211
+ * Decides what a purpose node means: a `Purpose` string covers the whole
1212
+ * subtree, any object or array is a purpose shape to descend into, and
1213
+ * anything else is invalid.
1214
+ */
1215
+ function resolvePurposeNode(node, path) {
1216
+ if (node === "motion" || node === "appearance") return node;
1217
+ if (isRecordOrArray(node)) return BRANCH;
1218
+ throw new Error(invalidPurpose(path));
1219
+ }
1220
+ const readTarget = (spring) => spring.target;
1221
+ const readValue = (spring) => spring.value;
1222
+ const readVelocity = (spring) => spring.velocity;
1223
+ const readAcceleration = (spring) => spring.acceleration;
1224
+ /**
1225
+ * The target scatter's leaf guard: a channel animates toward a number,
1226
+ * or follows a scalar source. Checked here so the error carries the
1227
+ * channel's path.
1228
+ */
1229
+ function acceptChannelTarget(input, path) {
1230
+ 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`);
1231
+ return input;
1232
+ }
1233
+ const assignTarget = (spring, value) => {
1234
+ spring.target = value;
1235
+ };
1236
+ const assignValue = (spring, value) => {
1237
+ spring.value = value;
1238
+ };
1239
+ const assignVelocity = (spring, value) => {
1240
+ spring.velocity = value;
1241
+ };
1242
+ const applyJump = (spring, value) => {
1243
+ spring.jumpTo(value);
1244
+ };
1245
+ const assignConfig = (spring, config) => {
1246
+ spring.config = config;
1247
+ };
1248
+ const followChannel = (mine, theirs) => {
1249
+ mine.target = theirs;
1250
+ };
1251
+ /**
1252
+ * A composite spring over a fixed numeric shape — a nested plain object
1253
+ * or array whose leaves are all numbers. Each leaf runs an independent
1254
+ * spring, called a channel, behind one API: targets, values, velocities,
1255
+ * and configs read and write as (partial) shapes, and events are
1256
+ * coalesced across channels. Create composite springs with
1257
+ * `SpringSystem.createSpring`.
1258
+ *
1259
+ * A composite spring is a `SpringSource` of its value shape: `mapSpring`
1260
+ * derives scalar sources from it — alone or at the leaves of a shape —
1261
+ * which springs can then follow. Only scalar sources are followable
1262
+ * directly.
1263
+ *
1264
+ * The shape is fixed at construction. Writes naming unknown channels
1265
+ * throw with the channel's path (`position.z`), and so do non-finite
1266
+ * channel values.
1267
+ */
1268
+ var CompositeSpring = class CompositeSpring {
1269
+ /** Brands the composite as a `KinematicSource` whose api is the composite itself. */
1270
+ get [SpringSourceSymbol]() {
1271
+ return this;
1272
+ }
1273
+ #motions;
1274
+ #tree;
1275
+ #valueView;
1276
+ #targetView = null;
1277
+ #velocityView = null;
1278
+ #accelerationView = null;
1279
+ #emitter = new Emitter();
1280
+ #running = false;
1281
+ #dirty = false;
1282
+ #settled = null;
1283
+ #resolveSettled = null;
1284
+ #disposed = false;
1285
+ #flush = () => {
1286
+ if (this.#disposed) return;
1287
+ if (this.#dirty) {
1288
+ this.#dirty = false;
1289
+ this.#emitter.emit("update");
1290
+ }
1291
+ if (this.#running) {
1292
+ if (this.isResting) {
1293
+ this.#running = false;
1294
+ this.#emitter.emit("stop");
1295
+ }
1296
+ } else if (!this.isResting) {
1297
+ this.#running = true;
1298
+ this.#emitter.emit("start");
1299
+ }
1300
+ };
1301
+ constructor(motions, value, config, purpose) {
1302
+ this.#motions = motions;
1303
+ invariant(isRecordOrArray(value), "Composite spring value must be a plain object or an array of numeric channels");
1304
+ let purposeByPath = null;
1305
+ if (purpose !== void 0) {
1306
+ const byPath = /* @__PURE__ */ new Map();
1307
+ new ShapeTree(value, channelParser((_leafValue, path) => path)).root.broadcast(purpose, resolvePurposeNode, (path, p) => byPath.set(path, p), "purpose");
1308
+ purposeByPath = byPath;
1309
+ }
1310
+ this.#tree = new ShapeTree(value, channelParser((leafValue, path) => new Spring(motions, leafValue, void 0, purposeByPath?.get(path) ?? "motion")));
1311
+ if (config !== void 0) this.#tree.root.broadcast(config, resolveConfigNode, assignConfig, "config");
1312
+ this.#valueView = new ShapeView(this.#tree.root, readValue);
1313
+ const markDirty = () => {
1314
+ this.#dirty = true;
1315
+ this.#motions.flushes.request(this.#flush);
1316
+ };
1317
+ const schedule = () => {
1318
+ this.#motions.flushes.request(this.#flush);
1319
+ };
1320
+ for (const channel of this.#tree.leaves) {
1321
+ channel.onUpdate(markDirty);
1322
+ channel.onStart(schedule);
1323
+ channel.onStop(schedule);
1324
+ }
1325
+ }
1326
+ /**
1327
+ * The value each channel is animating toward, as a read-only mirror of
1328
+ * the shape. The mirror object is reused: every read refreshes its
1329
+ * numbers in place, so read it fresh rather than holding it across
1330
+ * frames.
1331
+ *
1332
+ * Assignment accepts a `CompositeSpringTarget`:
1333
+ * - A partial shape retargets the channels it names and leaves the
1334
+ * others alone. Each named channel takes a number, or a scalar
1335
+ * `SpringSource` to follow — a `Spring`, or a value derived with
1336
+ * `mapSpring`. While following a leader, naming a channel also
1337
+ * detaches that channel from it.
1338
+ * - A `CompositeSpring` of the same shape follows the leader channel by
1339
+ * channel.
1340
+ *
1341
+ * Unknown channels throw with their path.
1342
+ */
1343
+ get target() {
1344
+ const view = this.#targetView ??= new ShapeView(this.#tree.root, readTarget);
1345
+ view.refresh();
1346
+ return view.root;
1347
+ }
1348
+ set target(value) {
1349
+ if (value instanceof CompositeSpring) this.#follow(value);
1350
+ else this.#motions.flushes.batch(() => {
1351
+ this.#tree.root.scatter(value, acceptChannelTarget, assignTarget);
1352
+ });
1353
+ }
1354
+ /**
1355
+ * The current animated value of every channel, as a read-only mirror of
1356
+ * the shape. The mirror object is reused: every read refreshes its
1357
+ * numbers in place, so read it fresh rather than holding it across
1358
+ * frames.
1359
+ *
1360
+ * Assignment takes a partial shape and displaces the channels it names:
1361
+ * each keeps its target and springs back from the written value, with
1362
+ * one coalesced `update` fired synchronously. Under reduced motion a
1363
+ * write jumps the named channels — targets included — except channels
1364
+ * whose `purpose` is `'appearance'`, which displace as normal.
1365
+ */
1366
+ get value() {
1367
+ this.#valueView.refresh();
1368
+ return this.#valueView.root;
1369
+ }
1370
+ set value(value) {
1371
+ this.#motions.flushes.batch(() => {
1372
+ this.#tree.root.scatter(value, acceptNumber, assignValue);
1373
+ });
1374
+ }
1375
+ /**
1376
+ * The current velocity of every channel in value units per second, as a
1377
+ * read-only mirror of the shape — reused and refreshed in place on each
1378
+ * read.
1379
+ *
1380
+ * Assignment takes a partial shape and flings the channels it names;
1381
+ * the rest are untouched. Under reduced motion writes are ignored,
1382
+ * except on channels whose `purpose` is `'appearance'`.
1383
+ */
1384
+ get velocity() {
1385
+ const view = this.#velocityView ??= new ShapeView(this.#tree.root, readVelocity);
1386
+ view.refresh();
1387
+ return view.root;
1388
+ }
1389
+ set velocity(value) {
1390
+ this.#motions.flushes.batch(() => {
1391
+ this.#tree.root.scatter(value, acceptNumber, assignVelocity);
1392
+ });
1393
+ }
1394
+ /**
1395
+ * The current acceleration of every channel in value units per second
1396
+ * squared, as a read-only mirror of the shape — reused and refreshed in
1397
+ * place on each read. Read-only, like each channel's: acceleration
1398
+ * follows from the motion. To fling channels, write `velocity`.
1399
+ */
1400
+ get acceleration() {
1401
+ const view = this.#accelerationView ??= new ShapeView(this.#tree.root, readAcceleration);
1402
+ view.refresh();
1403
+ return view.root;
1404
+ }
1405
+ /**
1406
+ * The shared config when every channel resolves to the same
1407
+ * `SpringDefinition`, and `null` when they differ.
1408
+ *
1409
+ * Assignment accepts a `ConfigShape`: one config for every channel,
1410
+ * `null` to revert every channel to the default, or a shape mirroring
1411
+ * the value with configs at any level — a config at a subtree covers
1412
+ * every channel below it. Non-config leaves throw with their path.
1413
+ */
1414
+ get config() {
1415
+ const channels = this.#tree.leaves;
1416
+ const first = channels[0].config;
1417
+ for (let i = 1; i < channels.length; i++) if (channels[i].config !== first) return null;
1418
+ return first;
1419
+ }
1420
+ set config(value) {
1421
+ this.#motions.flushes.batch(() => {
1422
+ this.#tree.root.broadcast(value ?? null, resolveConfigNode, assignConfig, "config");
1423
+ });
1424
+ }
1425
+ /**
1426
+ * The shared purpose when every channel resolves to the same `Purpose`,
1427
+ * and `null` when they differ. Fixed when the spring is created — an
1428
+ * `'appearance'` channel opts out of reduced motion. See `Purpose`.
1429
+ */
1430
+ get purpose() {
1431
+ const channels = this.#tree.leaves;
1432
+ const first = channels[0].purpose;
1433
+ for (let i = 1; i < channels.length; i++) if (channels[i].purpose !== first) return null;
1434
+ return first;
1435
+ }
1436
+ /** The largest `timeRemaining` across channels, in milliseconds. */
1437
+ get timeRemaining() {
1438
+ let max = 0;
1439
+ for (const channel of this.#tree.leaves) {
1440
+ const remaining = channel.timeRemaining;
1441
+ if (remaining > max) max = remaining;
1442
+ }
1443
+ return max;
1444
+ }
1445
+ /** Whether every channel is resting. */
1446
+ get isResting() {
1447
+ for (const channel of this.#tree.leaves) if (!channel.isResting) return false;
1448
+ return true;
1449
+ }
1450
+ /**
1451
+ * A promise that resolves when every channel next rests — already
1452
+ * resolved while resting or after dispose. Retargeting any channel
1453
+ * mid-flight extends the wait; disposing resolves it.
1454
+ */
1455
+ get settled() {
1456
+ if (this.#disposed || this.isResting) return RESOLVED;
1457
+ this.#settled ??= new Promise((resolve) => {
1458
+ this.#resolveSettled = resolve;
1459
+ const unsubscribe = this.onStop(() => {
1460
+ unsubscribe();
1461
+ this.#settled = null;
1462
+ this.#resolveSettled = null;
1463
+ resolve();
1464
+ });
1465
+ });
1466
+ return this.#settled;
1467
+ }
1468
+ /**
1469
+ * Snaps the channels the partial shape names to the given values with
1470
+ * no animation, notifying listeners synchronously. Channels it leaves
1471
+ * out are untouched.
1472
+ */
1473
+ jumpTo(value) {
1474
+ this.#motions.flushes.batch(() => {
1475
+ this.#tree.root.scatter(value, acceptNumber, applyJump);
1476
+ });
1477
+ }
1478
+ /**
1479
+ * Releases every channel permanently: resolves `settled` and notifies
1480
+ * dispose listeners. Calling it again is a no-op.
1481
+ *
1482
+ * A disposed composite spring keeps its final values readable; writes
1483
+ * throw.
1484
+ */
1485
+ dispose() {
1486
+ if (this.#disposed) return;
1487
+ this.#disposed = true;
1488
+ if (this.#resolveSettled) {
1489
+ this.#resolveSettled();
1490
+ this.#settled = null;
1491
+ this.#resolveSettled = null;
1492
+ }
1493
+ for (const channel of this.#tree.leaves) channel.dispose();
1494
+ this.#emitter.clear();
1495
+ }
1496
+ /**
1497
+ * Subscribes to coalesced value changes: at most one `update` per tick,
1498
+ * fired after every channel has advanced, plus one per synchronous
1499
+ * write. Returns an unsubscribe function.
1500
+ */
1501
+ onUpdate(callback) {
1502
+ return this.#emitter.on("update", callback);
1503
+ }
1504
+ /**
1505
+ * Subscribes to the composite leaving rest: a channel starts moving
1506
+ * while all were resting. Always alternates with `stop`. Returns an
1507
+ * unsubscribe function.
1508
+ */
1509
+ onStart(callback) {
1510
+ return this.#emitter.on("start", callback);
1511
+ }
1512
+ /**
1513
+ * Subscribes to the composite coming to rest: every channel resting,
1514
+ * fired after that tick's final `update`. Always alternates with
1515
+ * `start`. Returns an unsubscribe function.
1516
+ */
1517
+ onStop(callback) {
1518
+ return this.#emitter.on("stop", callback);
1519
+ }
1520
+ /** Subscribes to `dispose`, which fires once. Returns an unsubscribe function. */
1521
+ onDispose(callback) {
1522
+ return this.#tree.leaves[0].onDispose(callback);
1523
+ }
1524
+ #follow(leader) {
1525
+ this.#motions.flushes.batch(() => {
1526
+ this.#tree.root.zip(leader.#tree.root, followChannel);
1527
+ });
1528
+ }
1529
+ };
1530
+ //#endregion
1531
+ //#region src/ticker.ts
1532
+ const request = (callback) => typeof window !== "undefined" ? window.requestAnimationFrame(callback) : setTimeout(() => callback(performance.now()), 16);
1533
+ const cancel = (id) => {
1534
+ if (typeof window !== "undefined") window.cancelAnimationFrame(id);
1535
+ else clearTimeout(id);
1536
+ };
1537
+ /**
1538
+ * Drives a `MotionSet` from animation frames. Schedules frames only
1539
+ * while motions exist — otherwise it sleeps and the set's `onWake`
1540
+ * rearms it — paces fps caps to whole display frames, and clamps lag
1541
+ * gaps.
1542
+ */
1543
+ var Ticker = class {
1544
+ #fps;
1545
+ #gap;
1546
+ #capGap;
1547
+ #lagThreshold;
1548
+ #adjustedLag;
1549
+ #motion;
1550
+ #frame = 0;
1551
+ #time = 0;
1552
+ #delta = 0;
1553
+ #acc = 0;
1554
+ #lastWallTime = 0;
1555
+ #primed = false;
1556
+ #id = 0;
1557
+ #stopped = true;
1558
+ #sleeping = false;
1559
+ constructor(motions, options) {
1560
+ this.#motion = motions;
1561
+ const fps = options?.fps ?? 0;
1562
+ const lagThreshold = options?.lagThreshold ?? 500;
1563
+ const adjustedLag = options?.adjustedLag ?? 33;
1564
+ invariant(fps >= 0, "FPS must be greater than or equal to 0");
1565
+ invariant(lagThreshold >= 0, "Lag threshold must be greater than or equal to 0");
1566
+ invariant(adjustedLag >= 0, "Adjusted lag must be greater than or equal to 0");
1567
+ this.#fps = fps;
1568
+ this.#gap = fps === 0 ? 1e3 / 60 : 1e3 / fps;
1569
+ this.#capGap = fps === 0 ? 0 : 1e3 / fps;
1570
+ this.#lagThreshold = lagThreshold === 0 ? 1e8 : lagThreshold;
1571
+ this.#adjustedLag = Math.min(adjustedLag, this.#lagThreshold);
1572
+ motions.onWake = () => this.#wake();
1573
+ }
1574
+ get fps() {
1575
+ return this.#fps;
1576
+ }
1577
+ set fps(value) {
1578
+ invariant(value >= 0, "FPS must be greater than or equal to 0");
1579
+ this.#fps = value;
1580
+ this.#gap = value === 0 ? 1e3 / 60 : 1e3 / value;
1581
+ this.#capGap = value === 0 ? 0 : 1e3 / value;
1582
+ }
1583
+ get lagThreshold() {
1584
+ return this.#lagThreshold;
1585
+ }
1586
+ set lagThreshold(value) {
1587
+ invariant(value >= 0, "Lag threshold must be greater than or equal to 0");
1588
+ this.#lagThreshold = value === 0 ? 1e8 : value;
1589
+ this.#adjustedLag = Math.min(this.#adjustedLag, this.#lagThreshold);
1590
+ }
1591
+ get adjustedLag() {
1592
+ return this.#adjustedLag;
1593
+ }
1594
+ set adjustedLag(value) {
1595
+ invariant(value >= 0, "Adjusted lag must be greater than or equal to 0");
1596
+ this.#adjustedLag = Math.min(value, this.#lagThreshold);
1597
+ }
1598
+ get time() {
1599
+ return this.#time;
1600
+ }
1601
+ get frame() {
1602
+ return this.#frame;
1603
+ }
1604
+ get delta() {
1605
+ return this.#delta;
1606
+ }
1607
+ get deltaRatio() {
1608
+ return this.#delta / this.#gap;
1609
+ }
1610
+ get stopped() {
1611
+ return this.#stopped;
1612
+ }
1613
+ start() {
1614
+ if (this.#stopped) {
1615
+ this.#stopped = false;
1616
+ this.#primed = false;
1617
+ this.#acc = 0;
1618
+ this.#schedule();
1619
+ }
1620
+ }
1621
+ stop() {
1622
+ if (!this.#stopped) {
1623
+ if (!this.#sleeping) cancel(this.#id);
1624
+ this.#stopped = true;
1625
+ this.#sleeping = false;
1626
+ }
1627
+ }
1628
+ tick() {
1629
+ this.#step(this.#gap);
1630
+ }
1631
+ #schedule() {
1632
+ if (this.#motion.size === 0) {
1633
+ this.#sleeping = true;
1634
+ this.#acc = 0;
1635
+ } else {
1636
+ this.#sleeping = false;
1637
+ this.#id = request((t) => this.#onFrame(t));
1638
+ }
1639
+ }
1640
+ #wake() {
1641
+ if (!this.#stopped && this.#sleeping) {
1642
+ this.#primed = false;
1643
+ this.#schedule();
1644
+ }
1645
+ }
1646
+ #onFrame(timestamp) {
1647
+ if (this.#stopped) return;
1648
+ try {
1649
+ if (this.#primed) {
1650
+ const wallElapsed = timestamp - this.#lastWallTime;
1651
+ const elapsed = wallElapsed > this.#lagThreshold ? this.#adjustedLag : wallElapsed;
1652
+ this.#acc += elapsed;
1653
+ if (this.#acc + elapsed / 2 >= this.#capGap) {
1654
+ const delta = this.#acc;
1655
+ this.#acc = 0;
1656
+ this.#step(delta);
1657
+ }
1658
+ } else this.#primed = true;
1659
+ } finally {
1660
+ this.#lastWallTime = timestamp;
1661
+ if (!this.#stopped) this.#schedule();
1662
+ }
1663
+ }
1664
+ #step(dt) {
1665
+ this.#time += dt;
1666
+ this.#frame++;
1667
+ this.#delta = dt;
1668
+ this.#motion.tick(dt / 1e3);
1669
+ }
1670
+ };
1671
+ //#endregion
1672
+ //#region src/system.ts
1673
+ var SpringSystemImpl = class {
1674
+ #motion;
1675
+ #ticker;
1676
+ constructor(options) {
1677
+ this.#motion = new MotionSet(options?.debug);
1678
+ this.#ticker = new Ticker(this.#motion, options);
1679
+ const mode = options?.reducedMotion ?? "user";
1680
+ if (mode === "always") this.#motion.reduced = true;
1681
+ else if (mode === "user" && typeof window !== "undefined" && typeof window.matchMedia === "function") {
1682
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
1683
+ this.#applyReducedMotion(query.matches);
1684
+ query.addEventListener("change", (event) => {
1685
+ this.#applyReducedMotion(event.matches);
1686
+ });
1687
+ }
1688
+ }
1689
+ #applyReducedMotion(reduced) {
1690
+ this.#motion.reduced = reduced;
1691
+ if (reduced) this.#motion.finishAll();
1692
+ }
1693
+ get reducedMotion() {
1694
+ return this.#motion.reduced;
1695
+ }
1696
+ createSpring(value, config, options) {
1697
+ if (typeof value === "number") return new Spring(this.#motion, value, config, options?.purpose);
1698
+ if (isSpringSource(value)) {
1699
+ const api = value[SpringSourceSymbol];
1700
+ invariant(typeof api.value === "number", "A spring can only follow a scalar SpringSource; derive one from a composite with mapSpring");
1701
+ const spring = new Spring(this.#motion, api.value, config, options?.purpose);
1702
+ spring.target = value;
1703
+ return spring;
1704
+ }
1705
+ return new CompositeSpring(this.#motion, value, config, options?.purpose);
1706
+ }
1707
+ advance(dt) {
1708
+ invariant(Number.isFinite(dt), "dt must be a finite number of milliseconds");
1709
+ this.#motion.tick(dt / 1e3);
1710
+ }
1711
+ start() {
1712
+ this.#ticker.start();
1713
+ }
1714
+ stop() {
1715
+ this.#ticker.stop();
1716
+ }
1717
+ get running() {
1718
+ return !this.#ticker.stopped;
1719
+ }
1720
+ get fps() {
1721
+ return this.#ticker.fps;
1722
+ }
1723
+ set fps(value) {
1724
+ this.#ticker.fps = value;
1725
+ }
1726
+ get lagThreshold() {
1727
+ return this.#ticker.lagThreshold;
1728
+ }
1729
+ set lagThreshold(value) {
1730
+ this.#ticker.lagThreshold = value;
1731
+ }
1732
+ get adjustedLag() {
1733
+ return this.#ticker.adjustedLag;
1734
+ }
1735
+ set adjustedLag(value) {
1736
+ this.#ticker.adjustedLag = value;
1737
+ }
1738
+ };
1739
+ /**
1740
+ * Creates a spring system: the entry point of the library.
1741
+ *
1742
+ * @example
1743
+ * ```ts
1744
+ * const system = createSpringSystem()
1745
+ * system.start()
1746
+ *
1747
+ * const spring = system.createSpring(0)
1748
+ * spring.onUpdate(() => {
1749
+ * element.style.translate = `${spring.value}px 0`
1750
+ * })
1751
+ * spring.target = 300
1752
+ * ```
1753
+ */
1754
+ function createSpringSystem(options) {
1755
+ return new SpringSystemImpl(options);
1756
+ }
1757
+ //#endregion
1758
+ export { SpringDefinition as a, mapSpring as i, SpringSourceSymbol as n, defineSpring as o, isSpringSource as r, invariant as s, createSpringSystem as t };