coily 0.14.0 → 0.16.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,2920 @@
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-CAXuxdKj.js";
2
+ //#region src/flush-queue.ts
3
+ /**
4
+ * Defers callbacks while inside a batch or tick. At depth 0, `request`
5
+ * runs the callback immediately. Otherwise it queues the callback, once
6
+ * per callback, until the outermost exit. Composite springs use it to
7
+ * merge 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/shape-tree.ts
37
+ function joinPath(path, key) {
38
+ return path ? `${path}.${key}` : String(key);
39
+ }
40
+ function describePath(path) {
41
+ return path ? `'${path}'` : "the root";
42
+ }
43
+ /** The sentinel a resolver returns to descend into a node instead of covering it. */
44
+ const BRANCH = Symbol("branch");
45
+ /** The standard scatter leaf guard: partial numeric shapes take only finite numbers at channels. */
46
+ function acceptNumber(input, path) {
47
+ invariant(isNumber(input) && Number.isFinite(input), () => `Expected a finite number for channel '${path}'`);
48
+ return input;
49
+ }
50
+ /**
51
+ * The canonical numeric `LeafParser`: finite-number leaves built through
52
+ * `factory`, named "channels" in structural errors.
53
+ */
54
+ function channelParser(factory) {
55
+ return {
56
+ match(value, path) {
57
+ if (!isNumber(value)) return void 0;
58
+ invariant(Number.isFinite(value), () => `Invalid value at '${path}': channel values must be finite`);
59
+ return factory(value, path);
60
+ },
61
+ mismatch: (path) => `Invalid value at '${path}': expected a number or a nested shape of numbers`,
62
+ empty: (path) => `Invalid value at ${describePath(path)}: a shape must contain at least one channel`
63
+ };
64
+ }
65
+ var LeafNode = class LeafNode {
66
+ path;
67
+ leaf;
68
+ constructor(leaf, path) {
69
+ this.path = path;
70
+ this.leaf = leaf;
71
+ }
72
+ mirror(read, slots, carrier, key) {
73
+ carrier[key] = read(this.leaf);
74
+ slots.push({
75
+ leaf: this.leaf,
76
+ key,
77
+ carrier
78
+ });
79
+ }
80
+ scatter(input, accept, apply) {
81
+ const value = accept(input, this.path);
82
+ if (apply !== null) apply(this.leaf, value);
83
+ }
84
+ zip(theirs, fn) {
85
+ invariant(theirs instanceof LeafNode, () => `Shape mismatch at ${describePath(this.path)}`);
86
+ if (fn !== null) fn(this.leaf, theirs.leaf);
87
+ }
88
+ broadcast(input, resolve, apply, _label) {
89
+ const resolved = resolve(input, this.path);
90
+ invariant(resolved !== BRANCH, `Cannot descend into channel '${this.path}'`);
91
+ if (apply !== null) apply(this.leaf, resolved);
92
+ }
93
+ cover(value, apply) {
94
+ apply(this.leaf, value);
95
+ }
96
+ };
97
+ var ListNode = class ListNode {
98
+ path;
99
+ children;
100
+ constructor(children, path) {
101
+ this.path = path;
102
+ this.children = children;
103
+ }
104
+ mirror(read, slots, carrier, key) {
105
+ const mine = [];
106
+ carrier[key] = mine;
107
+ const slotcarrier = mine;
108
+ for (let i = 0; i < this.children.length; i++) this.children[i].mirror(read, slots, slotcarrier, i);
109
+ }
110
+ scatter(input, accept, apply) {
111
+ invariant(isArray(input), () => `Expected an array at ${describePath(this.path)}`);
112
+ invariant(input.length <= this.children.length, `Unknown channel '${joinPath(this.path, this.children.length)}'`);
113
+ for (let i = 0; i < input.length; i++) {
114
+ if (input[i] === void 0) continue;
115
+ this.children[i].scatter(input[i], accept, apply);
116
+ }
117
+ }
118
+ zip(theirs, fn) {
119
+ invariant(theirs instanceof ListNode && theirs.children.length === this.children.length, () => `Shape mismatch at ${describePath(this.path)}`);
120
+ for (let i = 0; i < this.children.length; i++) this.children[i].zip(theirs.children[i], fn);
121
+ }
122
+ broadcast(input, resolve, apply, label) {
123
+ const resolved = resolve(input, this.path);
124
+ if (resolved !== BRANCH) {
125
+ if (apply !== null) this.cover(resolved, apply);
126
+ return;
127
+ }
128
+ invariant(isArray(input), () => `Expected an array at ${describePath(this.path)} in ${label}`);
129
+ invariant(input.length <= this.children.length, () => `Unknown channel '${joinPath(this.path, this.children.length)}' in ${label}`);
130
+ for (let i = 0; i < input.length; i++) {
131
+ if (input[i] === void 0) continue;
132
+ this.children[i].broadcast(input[i], resolve, apply, label);
133
+ }
134
+ }
135
+ cover(value, apply) {
136
+ for (const child of this.children) child.cover(value, apply);
137
+ }
138
+ };
139
+ var RecordNode = class RecordNode {
140
+ path;
141
+ children;
142
+ constructor(children, path) {
143
+ this.path = path;
144
+ this.children = children;
145
+ }
146
+ mirror(read, slots, carrier, key) {
147
+ const mine = {};
148
+ carrier[key] = mine;
149
+ for (const [childKey, child] of this.children) child.mirror(read, slots, mine, childKey);
150
+ }
151
+ scatter(input, accept, apply) {
152
+ invariant(isRecord(input), () => `Expected an object at ${describePath(this.path)}`);
153
+ for (const key of Object.keys(input)) {
154
+ const value = input[key];
155
+ if (value === void 0) continue;
156
+ const child = this.children.get(key);
157
+ invariant(child !== void 0, () => `Unknown channel '${joinPath(this.path, key)}'`);
158
+ child.scatter(value, accept, apply);
159
+ }
160
+ }
161
+ zip(theirs, fn) {
162
+ invariant(theirs instanceof RecordNode && theirs.children.size === this.children.size, () => `Shape mismatch at ${describePath(this.path)}`);
163
+ for (const [key, child] of this.children) {
164
+ const their = theirs.children.get(key);
165
+ invariant(their !== void 0, () => `Shape mismatch at ${describePath(this.path)}`);
166
+ child.zip(their, fn);
167
+ }
168
+ }
169
+ broadcast(input, resolve, apply, label) {
170
+ const resolved = resolve(input, this.path);
171
+ if (resolved !== BRANCH) {
172
+ if (apply !== null) this.cover(resolved, apply);
173
+ return;
174
+ }
175
+ invariant(isRecord(input), () => `Expected an object at ${describePath(this.path)} in ${label}`);
176
+ for (const key of Object.keys(input)) {
177
+ const value = input[key];
178
+ if (value === void 0) continue;
179
+ const child = this.children.get(key);
180
+ invariant(child !== void 0, () => `Unknown channel '${joinPath(this.path, key)}' in ${label}`);
181
+ child.broadcast(value, resolve, apply, label);
182
+ }
183
+ }
184
+ cover(value, apply) {
185
+ for (const child of this.children.values()) child.cover(value, apply);
186
+ }
187
+ };
188
+ /**
189
+ * A shape parsed into a node tree, paired with its leaves flattened in
190
+ * depth-first shape order. `parser.match` decides leaf or branch at every
191
+ * position below the root. The root must already be a container, which
192
+ * callers validate with their own message. Nested non-containers and
193
+ * empty nodes throw through `parser.mismatch` and `parser.empty`.
194
+ *
195
+ * Traversals run through `root`, and `new ShapeView(root, read)` makes a
196
+ * live view.
197
+ */
198
+ var ShapeTree = class {
199
+ root;
200
+ leaves;
201
+ constructor(shape, parser) {
202
+ const leaves = [];
203
+ this.root = buildContainer(shape, "", parser, leaves);
204
+ this.leaves = leaves;
205
+ }
206
+ };
207
+ function buildContainer(shape, path, parser, leaves) {
208
+ if (isArray(shape)) {
209
+ invariant(shape.length > 0, () => parser.empty(path));
210
+ return new ListNode(shape.map((child, i) => buildChild(child, joinPath(path, i), parser, leaves)), path);
211
+ }
212
+ const keys = Object.keys(shape);
213
+ invariant(keys.length > 0, () => parser.empty(path));
214
+ const children = /* @__PURE__ */ new Map();
215
+ for (const key of keys) children.set(key, buildChild(shape[key], joinPath(path, key), parser, leaves));
216
+ return new RecordNode(children, path);
217
+ }
218
+ function buildChild(value, path, parser, leaves) {
219
+ const leaf = parser.match(value, path);
220
+ if (leaf !== void 0) {
221
+ leaves.push(leaf);
222
+ return new LeafNode(leaf, path);
223
+ }
224
+ invariant(isRecordOrArray(value), () => parser.mismatch(path));
225
+ return buildContainer(value, path, parser, leaves);
226
+ }
227
+ /**
228
+ * A live view of a shape: `read(leaf)` at every leaf, mirrored into a stable
229
+ * object. `refresh` rewrites the values in place through slots recorded at
230
+ * construction, so per-frame reads do no traversal or allocation.
231
+ */
232
+ var ShapeView = class {
233
+ root;
234
+ #read;
235
+ #slots = [];
236
+ constructor(root, read) {
237
+ this.#read = read;
238
+ const scratch = {};
239
+ root.mirror(read, this.#slots, scratch, "root");
240
+ this.root = scratch["root"];
241
+ }
242
+ /** Re-reads every leaf into the view. */
243
+ refresh() {
244
+ for (const slot of this.#slots) slot.carrier[slot.key] = this.#read(slot.leaf);
245
+ }
246
+ };
247
+ //#endregion
248
+ //#region src/spring-source.ts
249
+ /**
250
+ * The property key that brands a `SpringSource` and holds the api its
251
+ * followers read. It is a registry symbol
252
+ * (`Symbol.for('coily/spring-source')`), so one copy of coily
253
+ * recognizes sources from another when a bundle duplicates the library.
254
+ */
255
+ const SpringSourceSymbol = Symbol.for("coily/spring-source");
256
+ function isSpringSource(value) {
257
+ return typeof value === "object" && value !== null && SpringSourceSymbol in value;
258
+ }
259
+ const RECIPES = /* @__PURE__ */ new WeakMap();
260
+ /** The flattened recipe behind `source`, when it is a derived source. */
261
+ function recipeOf(source) {
262
+ return RECIPES.get(source);
263
+ }
264
+ /**
265
+ * Registers a derived source's recipe. Every source built on other
266
+ * sources must register at creation. `mapSpring` composition and
267
+ * `resolveLeaderMotions` both reach a derivation's roots through the
268
+ * registry, not through the wrapper object.
269
+ */
270
+ function registerRecipe(source, recipe) {
271
+ RECIPES.set(source, recipe);
272
+ }
273
+ function applyFns(fns, value) {
274
+ let result = value;
275
+ for (const fn of fns) result = fn(result);
276
+ return result;
277
+ }
278
+ function sourceMismatch(path) {
279
+ return `Invalid value at ${describePath(path)}: expected a SpringSource or a nested shape of SpringSources`;
280
+ }
281
+ /**
282
+ * Parses a shape of sources into a live view, collecting subscription
283
+ * roots into `roots`. A mapped leaf contributes its recipe's roots and
284
+ * reads through its pipeline.
285
+ */
286
+ function compileShape(shape, roots) {
287
+ const { root } = new ShapeTree(shape, {
288
+ match(value) {
289
+ if (!isSpringSource(value)) return void 0;
290
+ const recipe = RECIPES.get(value);
291
+ if (recipe) {
292
+ roots.push(...recipe.sources);
293
+ return () => applyFns(recipe.fns, recipe.read());
294
+ }
295
+ roots.push(value);
296
+ const api = value[SpringSourceSymbol];
297
+ return () => api.value;
298
+ },
299
+ mismatch: sourceMismatch,
300
+ empty: (path) => `Invalid value at ${describePath(path)}: a shape must contain at least one source`
301
+ });
302
+ return new ShapeView(root, (leaf) => leaf());
303
+ }
304
+ function mapSpring(source, map) {
305
+ const compute = map;
306
+ let recipe;
307
+ if (isSpringSource(source)) {
308
+ const flat = RECIPES.get(source);
309
+ if (flat) recipe = {
310
+ sources: flat.sources,
311
+ read: flat.read,
312
+ fns: [...flat.fns, compute]
313
+ };
314
+ else {
315
+ const api = source[SpringSourceSymbol];
316
+ recipe = {
317
+ sources: [source],
318
+ read: () => api.value,
319
+ fns: [compute]
320
+ };
321
+ }
322
+ } else {
323
+ invariant(isRecordOrArray(source), () => sourceMismatch(""));
324
+ const roots = [];
325
+ const view = compileShape(source, roots);
326
+ recipe = {
327
+ sources: [...new Set(roots)],
328
+ read: () => {
329
+ view.refresh();
330
+ return view.root;
331
+ },
332
+ fns: [compute]
333
+ };
334
+ }
335
+ const { sources, read, fns } = recipe;
336
+ const single = sources.length === 1 ? sources[0][SpringSourceSymbol] : null;
337
+ const apis = single ? null : sources.map((leaf) => leaf[SpringSourceSymbol]);
338
+ const mapped = Object.freeze({ [SpringSourceSymbol]: Object.freeze({
339
+ get value() {
340
+ return applyFns(fns, read());
341
+ },
342
+ onUpdate: single ? (callback) => single.onUpdate(callback) : (callback) => {
343
+ const unsubscribes = apis.map((api) => api.onUpdate(callback));
344
+ return () => {
345
+ for (const unsubscribe of unsubscribes) unsubscribe();
346
+ };
347
+ },
348
+ onDispose: single ? (callback) => single.onDispose(callback) : (callback) => {
349
+ let fired = false;
350
+ const unsubscribes = apis.map((api) => api.onDispose(() => {
351
+ if (fired) return;
352
+ fired = true;
353
+ for (const unsubscribe of unsubscribes) unsubscribe();
354
+ callback();
355
+ }));
356
+ return () => {
357
+ for (const unsubscribe of unsubscribes) unsubscribe();
358
+ };
359
+ }
360
+ }) });
361
+ RECIPES.set(mapped, recipe);
362
+ return mapped;
363
+ }
364
+ //#endregion
365
+ //#region src/follow-graph.ts
366
+ const MOTION_BACKING = /* @__PURE__ */ new WeakMap();
367
+ /**
368
+ * Records what advances `source`: its own `Motion` for a spring or a
369
+ * ramp, or the aggregated sources that lead to motions (a composite's
370
+ * channel springs). The `SpringSourceSymbol` brand getters call it on
371
+ * every api read, not at construction, so springs never used as sources
372
+ * stay out of the weak registry. A repeat call overwrites the entry.
373
+ */
374
+ function registerBacking(source, backing) {
375
+ MOTION_BACKING.set(source, backing);
376
+ }
377
+ /**
378
+ * Resolves the deduplicated spring and ramp motions behind `source`,
379
+ * through any chain of `mapSpring` pipelines, `velocityOf`,
380
+ * `accelerationOf`, and `targetOf` wrappers, and composite channels. A
381
+ * foreign source resolves to nothing and couples through its emitter
382
+ * alone, with no tick-ordering constraint.
383
+ */
384
+ function resolveLeaderMotions(source) {
385
+ const leaders = /* @__PURE__ */ new Set();
386
+ collectInto(source, leaders);
387
+ return [...leaders];
388
+ }
389
+ function collectInto(source, leaders) {
390
+ const recipe = recipeOf(source);
391
+ if (recipe) {
392
+ for (const root of recipe.sources) collectInto(root, leaders);
393
+ return;
394
+ }
395
+ let backing = MOTION_BACKING.get(source);
396
+ if (backing === void 0 && isSpringSource(source)) {
397
+ const api = source[SpringSourceSymbol];
398
+ backing = MOTION_BACKING.get(source);
399
+ if (backing === void 0 && typeof api === "object" && api !== null && api !== source) {
400
+ collectInto(api, leaders);
401
+ return;
402
+ }
403
+ }
404
+ if (backing === void 0) return;
405
+ if (Array.isArray(backing)) for (const part of backing) collectInto(part, leaders);
406
+ else leaders.add(backing);
407
+ }
408
+ const NO_LEADERS = [];
409
+ /**
410
+ * The sub-step controller's history of one followed value: its last
411
+ * observed value, `delta`, and `curvature`.
412
+ */
413
+ var TargetTrail = class {
414
+ #previous;
415
+ #delta = 0;
416
+ #curvature = 0;
417
+ constructor(value) {
418
+ this.#previous = value;
419
+ }
420
+ /** The change between the last two observed values. */
421
+ get delta() {
422
+ return this.#delta;
423
+ }
424
+ /** The latest `delta` minus the one before it. 0 while the value moves in a straight line. */
425
+ get curvature() {
426
+ return this.#curvature;
427
+ }
428
+ /** Records this frame's value and returns the change since the last one. */
429
+ observe(value) {
430
+ const delta = value - this.#previous;
431
+ this.#curvature = delta - this.#delta;
432
+ this.#delta = delta;
433
+ this.#previous = value;
434
+ return delta;
435
+ }
436
+ /** Restarts the trail at `value` without recording a change. */
437
+ anchor(value) {
438
+ this.#previous = value;
439
+ }
440
+ };
441
+ /**
442
+ * The follow graph: one edge per following motion, and a cached
443
+ * dependency rank over every motion the edges touch. `MotionSet` owns
444
+ * one. This class writes `edge`, `followers`, and `rank` onto the
445
+ * motions, where the tick reads them.
446
+ */
447
+ var FollowGraph = class {
448
+ #edges = /* @__PURE__ */ new Map();
449
+ /** Motions holding a rank from the last recompute, so stale ranks reset to -1. */
450
+ #ranked = [];
451
+ /** Motions holding a follower list from the last recompute, so stale lists reset to null. */
452
+ #withFollowers = [];
453
+ /** The map's edges as an array for the per-frame plan pass, rebuilt with the ranks. */
454
+ #edgeList = [];
455
+ #dirty = false;
456
+ /** Whether no edges are registered. The tick then skips all ordering work. */
457
+ get isEmpty() {
458
+ return this.#edges.size === 0;
459
+ }
460
+ /** Every registered edge, current as of the last `ensureOrder`. */
461
+ get edges() {
462
+ return this.#edgeList;
463
+ }
464
+ /**
465
+ * Registers `edge`, replacing any edge with the same follower: a
466
+ * spring follows at most one source at a time.
467
+ */
468
+ addEdge(edge) {
469
+ this.#edges.set(edge.follower, edge);
470
+ edge.follower.edge = edge;
471
+ this.#dirty = true;
472
+ }
473
+ /** Drops the edge whose follower is `follower`, if one is registered. */
474
+ removeEdge(follower) {
475
+ if (this.#edges.delete(follower)) {
476
+ follower.edge = null;
477
+ this.#dirty = true;
478
+ }
479
+ }
480
+ /**
481
+ * Recomputes ranks if an edge was added or removed since the last call.
482
+ * Afterward, motions the graph touches rank up from 0, every leader
483
+ * before its followers, and motions outside it hold -1. Cycle members
484
+ * rank in ascending creation id. Ranks depend on graph structure and
485
+ * creation order, never on wiring order.
486
+ */
487
+ ensureOrder() {
488
+ if (!this.#dirty) return;
489
+ this.#dirty = false;
490
+ this.#recompute();
491
+ }
492
+ #recompute() {
493
+ for (const motion of this.#ranked) motion.rank = -1;
494
+ for (const motion of this.#withFollowers) motion.followers = null;
495
+ const withFollowers = [];
496
+ for (const edge of this.#edges.values()) for (const leader of edge.leaders) {
497
+ if (leader.followers === null) {
498
+ leader.followers = [];
499
+ withFollowers.push(leader);
500
+ }
501
+ leader.followers.push(edge);
502
+ }
503
+ this.#withFollowers = withFollowers;
504
+ const nodes = /* @__PURE__ */ new Set();
505
+ for (const [follower, edge] of this.#edges) {
506
+ nodes.add(follower);
507
+ for (const leader of edge.leaders) nodes.add(leader);
508
+ }
509
+ const roots = [...nodes].sort((a, b) => a.id - b.id);
510
+ const indexOf = /* @__PURE__ */ new Map();
511
+ const lowOf = /* @__PURE__ */ new Map();
512
+ const onStack = /* @__PURE__ */ new Set();
513
+ const stack = [];
514
+ const ranked = [];
515
+ const sccOf = /* @__PURE__ */ new Map();
516
+ let index = 0;
517
+ let rank = 0;
518
+ let scc = 0;
519
+ for (const root of roots) {
520
+ if (indexOf.has(root)) continue;
521
+ const frames = [{
522
+ node: root,
523
+ leaders: this.#leadersOf(root),
524
+ next: 0
525
+ }];
526
+ indexOf.set(root, index);
527
+ lowOf.set(root, index);
528
+ index++;
529
+ stack.push(root);
530
+ onStack.add(root);
531
+ while (frames.length > 0) {
532
+ const frame = frames.at(-1);
533
+ if (frame.next < frame.leaders.length) {
534
+ const leader = frame.leaders[frame.next++];
535
+ const seen = indexOf.get(leader);
536
+ if (seen === void 0) {
537
+ indexOf.set(leader, index);
538
+ lowOf.set(leader, index);
539
+ index++;
540
+ stack.push(leader);
541
+ onStack.add(leader);
542
+ frames.push({
543
+ node: leader,
544
+ leaders: this.#leadersOf(leader),
545
+ next: 0
546
+ });
547
+ } else if (onStack.has(leader)) lowOf.set(frame.node, Math.min(lowOf.get(frame.node), seen));
548
+ } else {
549
+ frames.pop();
550
+ const low = lowOf.get(frame.node);
551
+ const parent = frames.at(-1);
552
+ if (parent !== void 0) lowOf.set(parent.node, Math.min(lowOf.get(parent.node), low));
553
+ if (low === indexOf.get(frame.node)) {
554
+ const members = [];
555
+ let member;
556
+ do {
557
+ member = stack.pop();
558
+ onStack.delete(member);
559
+ members.push(member);
560
+ } while (member !== frame.node);
561
+ members.sort((a, b) => a.id - b.id);
562
+ for (const member of members) {
563
+ member.rank = rank++;
564
+ sccOf.set(member, scc);
565
+ ranked.push(member);
566
+ }
567
+ scc++;
568
+ }
569
+ }
570
+ }
571
+ }
572
+ this.#ranked = ranked;
573
+ const edgeList = [...this.#edges.values()];
574
+ for (const edge of edgeList) {
575
+ const home = sccOf.get(edge.follower);
576
+ edge.cyclic = edge.leaders.some((leader) => sccOf.get(leader) === home);
577
+ }
578
+ this.#edgeList = edgeList;
579
+ }
580
+ #leadersOf(motion) {
581
+ return this.#edges.get(motion)?.leaders ?? NO_LEADERS;
582
+ }
583
+ };
584
+ //#endregion
585
+ //#region src/motion-set.ts
586
+ function compareMotions(a, b) {
587
+ return a.rank - b.rank || a.id - b.id;
588
+ }
589
+ const MAX_SUBSTEPS = 8;
590
+ /**
591
+ * The worklist of an ordered pass: a binary min-heap over (rank,
592
+ * creation id). It accepts duplicate pushes, and the tick's pass marker
593
+ * drops the repeats at pop.
594
+ */
595
+ var MotionHeap = class {
596
+ #items = [];
597
+ clear() {
598
+ this.#items.length = 0;
599
+ }
600
+ push(motion) {
601
+ const items = this.#items;
602
+ let hole = items.length;
603
+ items.push(motion);
604
+ while (hole > 0) {
605
+ const parent = hole - 1 >> 1;
606
+ if (compareMotions(items[parent], motion) <= 0) break;
607
+ items[hole] = items[parent];
608
+ hole = parent;
609
+ }
610
+ items[hole] = motion;
611
+ }
612
+ pop() {
613
+ const items = this.#items;
614
+ if (items.length === 0) return void 0;
615
+ const top = items[0];
616
+ const last = items.pop();
617
+ if (items.length > 0) {
618
+ let hole = 0;
619
+ for (;;) {
620
+ const left = 2 * hole + 1;
621
+ if (left >= items.length) break;
622
+ const right = left + 1;
623
+ const child = right < items.length && compareMotions(items[right], items[left]) < 0 ? right : left;
624
+ if (compareMotions(last, items[child]) <= 0) break;
625
+ items[hole] = items[child];
626
+ hole = child;
627
+ }
628
+ items[hole] = last;
629
+ }
630
+ return top;
631
+ }
632
+ };
633
+ /**
634
+ * The set of motions the tick advances. A motion leaves when it rests and
635
+ * re-enters on any write that disturbs it. A ramp is in the set only
636
+ * while something watches it frame by frame. `onWake` fires when the set
637
+ * becomes non-empty, waking a sleeping ticker.
638
+ */
639
+ var MotionSet = class {
640
+ reduced = false;
641
+ onWake = null;
642
+ /**
643
+ * The coupling error budget, in value units: the local per-frame error
644
+ * the sub-step controller targets on each follow edge, floored by the
645
+ * follower's resting magnitude. `SpringSystem` validates and exposes it
646
+ * as the `couplingTolerance` option and property.
647
+ */
648
+ couplingTolerance = .1;
649
+ flushes = new FlushQueue();
650
+ /** Follow edges registered by following springs, with the dependency rank over their motions. */
651
+ graph = new FollowGraph();
652
+ /**
653
+ * Motions a reduced-motion change must reach while out of the tick: a
654
+ * `'motion'` ramp that moves on the clock unwatched, or one holding
655
+ * still under reduced motion. Each member keeps its membership current.
656
+ */
657
+ reducible = /* @__PURE__ */ new Set();
658
+ #motions = /* @__PURE__ */ new Set();
659
+ #heap = new MotionHeap();
660
+ #advanced = [];
661
+ /** The live pass worklist while an advance phase runs, so mid-phase wakes join it. */
662
+ #activeHeap = null;
663
+ /** Whether any tick is on the stack, settle sweep included. A reentrant advance must not share the pass structures. */
664
+ #inTick = false;
665
+ #debug;
666
+ #lastSize = 0;
667
+ #pass = 0;
668
+ /** Ordered-frame counter. Sub-steps share one id, so a motion joins the settle list once. */
669
+ #frame = 0;
670
+ /** Stepped time in seconds: the sum of every tick's `dt`, plus idle time the ticker credits. */
671
+ #clock = 0;
672
+ /** Counts ticks, so `advancing` can name the one in its advance phase. */
673
+ #ticks = 0;
674
+ #advancing = 0;
675
+ /**
676
+ * Returns the idle wall time, in seconds, the clock has not counted yet.
677
+ * Null when no ticker counts idle time. The ticker installs it, and
678
+ * moves the total in with `credit` on its first frame after starting or
679
+ * waking, and when it stops.
680
+ */
681
+ idleTime = null;
682
+ constructor(debug = false) {
683
+ this.#debug = debug;
684
+ }
685
+ get size() {
686
+ return this.#motions.size;
687
+ }
688
+ /**
689
+ * The system clock in seconds, which ramps read their position from. It
690
+ * moves only forward: by each tick's `dt` when the advance phase ends,
691
+ * and by idle wall time while the ticker sleeps. Wall time is read once
692
+ * per microtask checkpoint, so the clock holds still through
693
+ * synchronous code.
694
+ */
695
+ get time() {
696
+ const idle = this.idleTime;
697
+ return idle === null ? this.#clock : this.#clock + idle();
698
+ }
699
+ /**
700
+ * The id of the tick whose advance phase is running, or 0 between
701
+ * phases. A ramp steps a cached value through the sub-steps of the
702
+ * phase and reads its anchor formula everywhere else.
703
+ */
704
+ get advancing() {
705
+ return this.#advancing;
706
+ }
707
+ /** Adds idle wall time to the clock, in seconds. */
708
+ credit(seconds) {
709
+ this.#clock += seconds;
710
+ }
711
+ add(motion) {
712
+ const motions = this.#motions;
713
+ const size = motions.size;
714
+ motions.add(motion);
715
+ if (motions.size === size) return;
716
+ this.#activeHeap?.push(motion);
717
+ if (size === 0) this.onWake?.();
718
+ }
719
+ remove(motion) {
720
+ this.#motions.delete(motion);
721
+ }
722
+ /**
723
+ * Whether the motion is in the set. A motion that rests mid-pass leaves
724
+ * before its own `endFrame`, so motions ask here rather than track it.
725
+ */
726
+ has(motion) {
727
+ return this.#motions.has(motion);
728
+ }
729
+ /**
730
+ * Applies a reduced-motion change to the `reducible` registry first,
731
+ * because a ramp's jump retargets its followers, then to every moving
732
+ * motion, leaders before followers. A motion reached twice changes once.
733
+ */
734
+ setReduced(reduced) {
735
+ this.reduced = reduced;
736
+ this.flushes.batch(() => {
737
+ for (const motion of this.reducible) motion.applyReducedMotion(reduced);
738
+ this.graph.ensureOrder();
739
+ const motions = [...this.#motions];
740
+ if (!this.graph.isEmpty) motions.sort(compareMotions);
741
+ for (const motion of motions) {
742
+ motion.applyReducedMotion(reduced);
743
+ if (motion.isResting) this.#motions.delete(motion);
744
+ }
745
+ });
746
+ }
747
+ tick(dt) {
748
+ const reentrant = this.#inTick;
749
+ this.#inTick = true;
750
+ this.flushes.enter();
751
+ try {
752
+ this.graph.ensureOrder();
753
+ if (this.graph.isEmpty) this.#tickUnordered(dt, reentrant);
754
+ else this.#tickOrdered(dt, reentrant);
755
+ } finally {
756
+ this.#inTick = reentrant;
757
+ this.flushes.exit();
758
+ }
759
+ if (this.#debug && this.#motions.size !== this.#lastSize) {
760
+ this.#lastSize = this.#motions.size;
761
+ console.log(`coily: ${this.#lastSize} active motions`);
762
+ }
763
+ }
764
+ /** The fast path with no follow edges. Nothing constrains the order, so insertion order serves. */
765
+ #tickUnordered(dt, reentrant) {
766
+ const previous = this.#advancing;
767
+ this.#advancing = ++this.#ticks;
768
+ const advanced = reentrant ? [] : this.#advanced;
769
+ advanced.length = 0;
770
+ try {
771
+ for (const motion of this.#motions) {
772
+ motion.advance(dt);
773
+ advanced.push(motion);
774
+ if (motion.isResting) this.#motions.delete(motion);
775
+ }
776
+ } finally {
777
+ this.#advancing = previous;
778
+ }
779
+ this.#clock += dt;
780
+ this.#settle(advanced);
781
+ }
782
+ #tickOrdered(dt, reentrant) {
783
+ const heap = reentrant ? new MotionHeap() : this.#heap;
784
+ const advanced = reentrant ? [] : this.#advanced;
785
+ const previous = this.#activeHeap;
786
+ this.#activeHeap = heap;
787
+ const previousAdvancing = this.#advancing;
788
+ this.#advancing = ++this.#ticks;
789
+ advanced.length = 0;
790
+ const frame = ++this.#frame;
791
+ let demand = 1;
792
+ if (!this.reduced) {
793
+ for (const edge of this.graph.edges) if (this.#shouldPlan(edge)) {
794
+ const k = edge.plan(dt);
795
+ if (k > demand) demand = k;
796
+ }
797
+ }
798
+ const K = Math.min(demand, MAX_SUBSTEPS);
799
+ const h = dt / K;
800
+ try {
801
+ for (let step = 0; step < K; step++) this.#advancePass(dt, h, step === 0, heap, advanced, frame);
802
+ } finally {
803
+ this.#activeHeap = previous;
804
+ this.#advancing = previousAdvancing;
805
+ }
806
+ this.#clock += dt;
807
+ this.#settle(advanced);
808
+ }
809
+ #advancePass(dt, h, first, heap, advanced, frame) {
810
+ this.#pass++;
811
+ heap.clear();
812
+ for (const motion of this.#motions) {
813
+ if (!first && motion.rank === -1) continue;
814
+ heap.push(motion);
815
+ }
816
+ for (let motion = heap.pop(); motion !== void 0; motion = heap.pop()) {
817
+ if (motion.pass === this.#pass) continue;
818
+ const edge = motion.edge;
819
+ if (edge !== null && this.#shouldRecouple(edge)) edge.recouple(h);
820
+ if (!this.#motions.has(motion)) continue;
821
+ motion.pass = this.#pass;
822
+ motion.advance(motion.rank === -1 ? dt : h);
823
+ if (motion.frame !== frame) {
824
+ motion.frame = frame;
825
+ advanced.push(motion);
826
+ }
827
+ if (motion.isResting) this.#motions.delete(motion);
828
+ const followers = motion.followers;
829
+ if (followers !== null) for (const out of followers) heap.push(out.follower);
830
+ }
831
+ }
832
+ #settle(advanced) {
833
+ for (const motion of advanced) motion.endFrame();
834
+ }
835
+ #shouldRecouple(edge) {
836
+ for (const leader of edge.leaders) if (leader.pass === this.#pass || this.#motions.has(leader)) return true;
837
+ return false;
838
+ }
839
+ #shouldPlan(edge) {
840
+ for (const leader of edge.leaders) if (this.#motions.has(leader)) return true;
841
+ return false;
842
+ }
843
+ };
844
+ //#endregion
845
+ //#region src/config.ts
846
+ /**
847
+ * Resolved spring parameters, frozen and safe to share between springs.
848
+ * Whatever `defineSpring` input shape produced it, a config has concrete
849
+ * `mass`, `tension`, `damping`, `precision`, and `arrival`, plus the
850
+ * quantities derived from them. To change a spring's behavior, assign a
851
+ * new config to `spring.config`.
852
+ */
853
+ var SpringDefinition = class SpringDefinition {
854
+ /** The config for springs created without one: critically damped, settling in about 500ms. */
855
+ static default = new SpringDefinition({
856
+ dampingRatio: 1,
857
+ duration: 500
858
+ });
859
+ /** Mass of the moving value, as provided or derived. */
860
+ mass;
861
+ /** Spring stiffness, as provided or derived. */
862
+ tension;
863
+ /** Friction strength, as provided or derived. */
864
+ damping;
865
+ /** Decimal places of the resting threshold. See `restingMagnitude`. */
866
+ precision;
867
+ /**
868
+ * Velocity multiplier applied each time the value crosses the target.
869
+ * `'passthrough'` resolves to 1, which leaves the motion untouched.
870
+ * `'stop'` resolves to 0, which ends it at the target. Negative values
871
+ * rebound.
872
+ */
873
+ arrival;
874
+ /** Oscillation rate with no damping: `sqrt(tension / mass)`, in radians per second. */
875
+ naturalFrequency;
876
+ /** The damping at which bounce disappears: `2 * mass * naturalFrequency`. */
877
+ criticalDamping;
878
+ /**
879
+ * `damping / criticalDamping`. Below 1 the spring bounces. At 1 it
880
+ * settles fastest without overshooting. Above 1 it settles slower.
881
+ */
882
+ dampingRatio;
883
+ /** Resting threshold in value units, `0.5 * 10^-precision`. */
884
+ restingMagnitude;
885
+ constructor(input) {
886
+ const raw = input;
887
+ const mass = raw.mass;
888
+ const tension = raw.tension;
889
+ const damping = raw.damping;
890
+ const bounce = raw.bounce;
891
+ const duration = raw.duration !== void 0 ? raw.duration / 1e3 : void 0;
892
+ const displacement = raw.displacement ?? 1;
893
+ const precision = raw.precision ?? 2;
894
+ const arrival = raw.arrival;
895
+ for (const key of Object.keys(raw)) {
896
+ if (key === "arrival") continue;
897
+ const value = raw[key];
898
+ if (value !== void 0) invariant(Number.isFinite(value), () => `Invalid ${key}: expected a finite number`);
899
+ }
900
+ if (mass !== void 0) invariant(mass > 0, "Mass must be greater than 0");
901
+ if (tension !== void 0) invariant(tension > 0, "Tension must be greater than 0");
902
+ if (damping !== void 0) invariant(damping >= 0, "Damping must be greater than or equal to 0");
903
+ if (bounce !== void 0) invariant(bounce >= -1 && bounce <= 1, "Bounce must be between -1 and 1");
904
+ invariant(raw.dampingRatio === void 0 || bounce === void 0, "Provide either dampingRatio or bounce, not both");
905
+ const dampingRatio = raw.dampingRatio ?? (bounce !== void 0 ? Math.max(1e-4, 1 - bounce) : void 0);
906
+ if (dampingRatio !== void 0) invariant(dampingRatio >= 0, "Damping ratio must be greater than or equal to 0");
907
+ if (duration !== void 0) invariant(duration > 0, "Duration must be greater than 0");
908
+ invariant(precision >= 0, "Precision must be greater than or equal to 0");
909
+ 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");
910
+ this.precision = precision;
911
+ this.restingMagnitude = .5 / 10 ** precision;
912
+ if (arrival === void 0 || arrival === "passthrough") this.arrival = 1;
913
+ else if (arrival === "stop") this.arrival = 0;
914
+ else this.arrival = arrival === 0 ? 0 : arrival;
915
+ const hasM = mass !== void 0;
916
+ const hasK = tension !== void 0;
917
+ const hasC = damping !== void 0;
918
+ const hasZ = dampingRatio !== void 0;
919
+ const hasT = duration !== void 0;
920
+ if (hasK && hasC && !hasZ && !hasT) {
921
+ this.mass = mass ?? 1;
922
+ this.tension = tension;
923
+ this.damping = damping;
924
+ } else if (hasK && hasZ && !hasC && !hasT) {
925
+ this.mass = mass ?? 1;
926
+ this.tension = tension;
927
+ this.damping = 2 * dampingRatio * Math.sqrt(this.mass * tension);
928
+ } else if (hasC && hasZ && !hasK && !hasT) {
929
+ invariant(dampingRatio > 0, "dampingRatio must be > 0 to derive tension");
930
+ invariant(bounce === void 0 || bounce < 1, "bounce must be < 1 to derive tension");
931
+ this.mass = mass ?? 1;
932
+ this.tension = damping ** 2 / (4 * dampingRatio ** 2 * this.mass);
933
+ this.damping = damping;
934
+ } else if (hasK && hasC && hasZ && !hasT) {
935
+ invariant(!hasM, "mass is derived when tension, damping, and dampingRatio are all provided");
936
+ invariant(dampingRatio > 0, "dampingRatio must be > 0 to derive mass");
937
+ invariant(bounce === void 0 || bounce < 1, "bounce must be < 1 to derive mass");
938
+ this.mass = damping ** 2 / (4 * dampingRatio ** 2 * tension);
939
+ this.tension = tension;
940
+ this.damping = damping;
941
+ } else if (hasZ && hasT) {
942
+ const x0 = Math.abs(displacement);
943
+ invariant(x0 > this.restingMagnitude, () => `Displacement magnitude must be greater than the resting threshold (${this.restingMagnitude} at precision ${precision})`);
944
+ invariant(dampingRatio > 0, "dampingRatio must be > 0 for duration-based config");
945
+ const wn = restUnits(dampingRatio, x0 / this.restingMagnitude) / duration;
946
+ if (!hasK && !hasC) {
947
+ this.mass = mass ?? 1;
948
+ this.tension = wn ** 2 * this.mass;
949
+ this.damping = 2 * dampingRatio * this.mass * wn;
950
+ } else if (hasK && !hasC) {
951
+ invariant(!hasM, "mass is derived in duration-based configs with tension");
952
+ this.mass = tension / wn ** 2;
953
+ this.tension = tension;
954
+ this.damping = 2 * dampingRatio * this.mass * wn;
955
+ } else if (hasC && !hasK) {
956
+ invariant(!hasM, "mass is derived in duration-based configs with damping");
957
+ this.mass = damping / (2 * dampingRatio * wn);
958
+ this.tension = wn ** 2 * this.mass;
959
+ this.damping = damping;
960
+ } else throw new Error("Invalid spring config: provide one of the supported input shapes.");
961
+ } else throw new Error("Invalid spring config: provide one of the supported input shapes.");
962
+ this.naturalFrequency = Math.sqrt(this.tension / this.mass);
963
+ this.criticalDamping = 2 * this.mass * this.naturalFrequency;
964
+ this.dampingRatio = this.damping / this.criticalDamping;
965
+ Object.freeze(this);
966
+ }
967
+ /**
968
+ * Computes the time in ms until a spring with this config rests from
969
+ * `state`, solved exactly from the motion's decay bound. The spring
970
+ * rests at the first tick at or after it.
971
+ *
972
+ * An underdamped spring can also rest earlier, on a frame that samples
973
+ * a dip in its pulsing rest amplitude, so read the result as "no later
974
+ * than". The motion an early rest cuts off never leaves the resting
975
+ * threshold. See https://github.com/tkofh/coily/blob/main/PRECISION.md.
976
+ *
977
+ * Returns 0 when `state` is already inside the resting threshold.
978
+ * Returns Infinity when the motion never settles: a `dampingRatio` of 0
979
+ * whose `arrival` neither stops nor slows it. With `arrival` 0, the
980
+ * result is capped at the first target crossing, where the spring rests
981
+ * exactly. Other multipliers fold their per-crossing velocity loss into
982
+ * the time.
983
+ */
984
+ computeTimeRemaining(state) {
985
+ const { position, velocity } = state;
986
+ if (Math.abs(position) + Math.abs(velocity) / this.naturalFrequency <= this.restingMagnitude) return 0;
987
+ const crossing = this.arrival === 0 ? firstCrossing(this, position, velocity) * 1e3 : Infinity;
988
+ return Math.min(restTime(this, position, velocity) * 1e3, crossing);
989
+ }
990
+ };
991
+ function solveEntry(f, threshold, peak) {
992
+ let low = 0;
993
+ let high = Math.max(peak, 1e-6);
994
+ while (f(high) > threshold) {
995
+ low = high;
996
+ high *= 2;
997
+ }
998
+ for (let i = 0; i < 60; i++) {
999
+ const mid = (low + high) / 2;
1000
+ if (f(mid) > threshold) low = mid;
1001
+ else high = mid;
1002
+ }
1003
+ return high;
1004
+ }
1005
+ function restTime(config, position, velocity) {
1006
+ const { naturalFrequency, dampingRatio, restingMagnitude, arrival } = config;
1007
+ if (dampingRatio < 1) {
1008
+ const sigma = dampingRatio * naturalFrequency;
1009
+ const wd = naturalFrequency * Math.sqrt(1 - dampingRatio ** 2);
1010
+ const R = Math.hypot(position, (velocity + sigma * position) / wd);
1011
+ const drop = Math.log(R * Math.sqrt(2 + 2 * dampingRatio) / restingMagnitude);
1012
+ const plain = sigma > 0 ? drop / sigma : Infinity;
1013
+ const strength = Math.abs(arrival);
1014
+ if (strength === 0 || strength === 1) return plain;
1015
+ const first = firstCrossing(config, position, velocity);
1016
+ if (plain <= first) return plain;
1017
+ const loss = -Math.log(strength) * wd / Math.PI;
1018
+ return (drop + loss * first) / (sigma + loss);
1019
+ }
1020
+ if (dampingRatio === 1) {
1021
+ const a0 = Math.abs(position) + Math.abs(velocity) / naturalFrequency;
1022
+ const beta = 2 * Math.abs(velocity + naturalFrequency * position);
1023
+ return solveEntry((t) => Math.exp(-naturalFrequency * t) * (a0 + beta * t), restingMagnitude, beta > 0 ? Math.max(0, 1 / naturalFrequency - a0 / beta) : 0);
1024
+ }
1025
+ const wd = naturalFrequency * Math.sqrt(dampingRatio ** 2 - 1);
1026
+ const slow = dampingRatio * naturalFrequency - wd;
1027
+ const fast = dampingRatio * naturalFrequency + wd;
1028
+ const slowAmplitude = Math.abs(velocity + fast * position) / (2 * wd) * (1 + slow / naturalFrequency);
1029
+ const fastAmplitude = Math.abs(velocity + slow * position) / (2 * wd) * (1 + fast / naturalFrequency);
1030
+ return solveEntry((t) => slowAmplitude * Math.exp(-slow * t) + fastAmplitude * Math.exp(-fast * t), restingMagnitude, 0);
1031
+ }
1032
+ function restUnits(dampingRatio, ratio) {
1033
+ if (dampingRatio < 1) return Math.log(ratio * Math.sqrt(2 / (1 - dampingRatio))) / dampingRatio;
1034
+ if (dampingRatio === 1) return solveEntry((u) => Math.exp(-u) * (1 + 2 * u), 1 / ratio, .5);
1035
+ const wd = Math.sqrt(dampingRatio ** 2 - 1);
1036
+ const slow = dampingRatio - wd;
1037
+ const fast = dampingRatio + wd;
1038
+ const slowAmplitude = fast / (2 * wd) * (1 + slow);
1039
+ const fastAmplitude = slow / (2 * wd) * (1 + fast);
1040
+ return solveEntry((u) => slowAmplitude * Math.exp(-slow * u) + fastAmplitude * Math.exp(-fast * u), 1 / ratio, 0);
1041
+ }
1042
+ function firstCrossing(config, position, velocity) {
1043
+ const { naturalFrequency, dampingRatio } = config;
1044
+ if (dampingRatio < 1) {
1045
+ const wd = naturalFrequency * Math.sqrt(1 - dampingRatio ** 2);
1046
+ const c2 = (velocity + dampingRatio * naturalFrequency * position) / wd;
1047
+ let theta = Math.atan2(-position, c2) % Math.PI;
1048
+ if (theta <= 0) theta += Math.PI;
1049
+ return theta / wd;
1050
+ }
1051
+ if (dampingRatio === 1) {
1052
+ const t = -position / (velocity + naturalFrequency * position);
1053
+ return t > 0 ? t : Infinity;
1054
+ }
1055
+ const wd = naturalFrequency * Math.sqrt(dampingRatio ** 2 - 1);
1056
+ const theta = Math.atanh(-position * wd / (velocity + dampingRatio * naturalFrequency * position));
1057
+ return theta > 0 ? theta / wd : Infinity;
1058
+ }
1059
+ function defineSpring(input) {
1060
+ return new SpringDefinition(input);
1061
+ }
1062
+ //#endregion
1063
+ //#region src/emitter.ts
1064
+ /** Minimal fixed-event emitter behind motions and composite springs. */
1065
+ var Emitter = class {
1066
+ #update = [];
1067
+ #start = [];
1068
+ #stop = [];
1069
+ #dispose = [];
1070
+ on(type, handler) {
1071
+ this.#list(type).push(handler);
1072
+ return () => {
1073
+ this.off(type, handler);
1074
+ };
1075
+ }
1076
+ off(type, handler) {
1077
+ const list = this.#list(type);
1078
+ if (handler) list.splice(list.indexOf(handler) >>> 0, 1);
1079
+ else list.length = 0;
1080
+ }
1081
+ clear() {
1082
+ this.#update.length = 0;
1083
+ this.#start.length = 0;
1084
+ this.#stop.length = 0;
1085
+ this.#dispose.length = 0;
1086
+ }
1087
+ emit(type) {
1088
+ const list = this.#list(type);
1089
+ if (list.length === 1) list[0]();
1090
+ else if (list.length > 1) for (const handler of list.slice()) handler();
1091
+ }
1092
+ #list(type) {
1093
+ switch (type) {
1094
+ case "update": return this.#update;
1095
+ case "start": return this.#start;
1096
+ case "stop": return this.#stop;
1097
+ case "dispose": return this.#dispose;
1098
+ }
1099
+ }
1100
+ };
1101
+ //#endregion
1102
+ //#region src/motion.ts
1103
+ let nextId = 0;
1104
+ /**
1105
+ * Something the system advances frame by frame: a spring's `SpringMotion`
1106
+ * or a ramp's `RampMotion`. `MotionSet` ticks motions and `FollowGraph`
1107
+ * orders them through the members declared here. Owners keep their motion
1108
+ * in a private field, so none of this reaches the public api.
1109
+ *
1110
+ * A tick runs in two phases. Every moving motion first advances silently,
1111
+ * so followers recouple against frame-current values. Then each motion
1112
+ * that advanced delivers its events in `endFrame`.
1113
+ */
1114
+ var Motion = class {
1115
+ /**
1116
+ * Creation order across every kind of motion. The lower id goes first
1117
+ * wherever motions need a deterministic order and nothing else decides.
1118
+ */
1119
+ id = nextId++;
1120
+ /**
1121
+ * The last ordered pass that advanced the motion, written by
1122
+ * `MotionSet`, so a motion queued twice in one pass advances once.
1123
+ */
1124
+ pass = 0;
1125
+ /**
1126
+ * The last ordered frame that advanced the motion, written by
1127
+ * `MotionSet`. Sub-steps share one frame, and the first advance in it
1128
+ * queues the motion for `endFrame`.
1129
+ */
1130
+ frame = 0;
1131
+ /**
1132
+ * Dependency rank, written by `FollowGraph`. Motions in the follow
1133
+ * graph count up from 0, leaders before followers. -1 marks a motion
1134
+ * outside the graph.
1135
+ */
1136
+ rank = -1;
1137
+ /**
1138
+ * The edge this motion follows through, or null. `FollowGraph` writes
1139
+ * it. Only a spring follows.
1140
+ */
1141
+ edge = null;
1142
+ /**
1143
+ * The edges that read this motion, or null when none do. `FollowGraph`
1144
+ * rebuilds them with the ranks, so they can lag a mid-pass edge change
1145
+ * until the next recompute. The tick tolerates stale entries.
1146
+ */
1147
+ followers = null;
1148
+ };
1149
+ //#endregion
1150
+ //#region src/spring-motion.ts
1151
+ /**
1152
+ * One spring simulation in displacement space. Position is measured from
1153
+ * the target (0 means settled), so a retarget is a rebase that the
1154
+ * owning `Spring` performs. Picks the closed-form solver for the
1155
+ * config's damping regime and emits update, start, stop, and dispose
1156
+ * events.
1157
+ */
1158
+ var SpringMotion = class extends Motion {
1159
+ /** Whether reduced motion finishes the motion: false for a `'nonmotion'` spring. */
1160
+ #reduces;
1161
+ /**
1162
+ * First-order-hold slope in value units per second, 0 when the target
1163
+ * holds still across the step. `armSlope` sets it immediately before the
1164
+ * `advance` that reads and clears it, with no user code between.
1165
+ */
1166
+ #slope = 0;
1167
+ #config;
1168
+ #state;
1169
+ #underdampedSolver = null;
1170
+ #criticallyDampedSolver = null;
1171
+ #overdampedSolver = null;
1172
+ #currentSolver = null;
1173
+ #needsUpdate = false;
1174
+ #needsReset = false;
1175
+ #running;
1176
+ #emitter;
1177
+ constructor(config, position, velocity, purpose = "motion") {
1178
+ super();
1179
+ this.#reduces = purpose === "motion";
1180
+ this.#config = config;
1181
+ this.#state = new State(config, position, velocity);
1182
+ this.#emitter = new Emitter();
1183
+ this.#running = !this.#state.isResting;
1184
+ this.#updateSolver();
1185
+ }
1186
+ get position() {
1187
+ return this.#state.position;
1188
+ }
1189
+ set position(value) {
1190
+ this.#state.position = value;
1191
+ this.#needsReset = true;
1192
+ this.#syncStart();
1193
+ }
1194
+ get velocity() {
1195
+ return this.#state.velocity;
1196
+ }
1197
+ set velocity(value) {
1198
+ this.#state.velocity = value;
1199
+ this.#needsReset = true;
1200
+ this.#syncStart();
1201
+ }
1202
+ get timeRemaining() {
1203
+ return this.#config.computeTimeRemaining(this.#state);
1204
+ }
1205
+ get isResting() {
1206
+ return this.#state.isResting;
1207
+ }
1208
+ configure(config) {
1209
+ this.#config = config;
1210
+ this.#state.configure(config);
1211
+ this.#needsUpdate = true;
1212
+ this.#syncStart();
1213
+ }
1214
+ tick(dt, emit = true) {
1215
+ this.advance(dt);
1216
+ if (emit) this.endFrame();
1217
+ else this.#reconcileRunning();
1218
+ }
1219
+ /**
1220
+ * Makes the next `advance` integrate against a target moving at `slope`
1221
+ * value units per second instead of holding still. The owner calls it
1222
+ * with the step's new target already assigned and the position still
1223
+ * measured from the old one, so the step ends measured from the new
1224
+ * target. Only the next `advance` reads it.
1225
+ */
1226
+ armSlope(slope) {
1227
+ this.#slope = slope;
1228
+ }
1229
+ /**
1230
+ * Advances the simulation and emits nothing. The tick pass advances
1231
+ * every motion through this, then delivers events with `endFrame`. The
1232
+ * synchronous `tick` wraps the two.
1233
+ *
1234
+ * A pending slope from `armSlope` makes the step one affine transform
1235
+ * around the unchanged solver. With the target at `p(t) = p0 + g*t`, the
1236
+ * displacement u = x - p(t) obeys the homogeneous equation shifted by the
1237
+ * constant u_ss = -(2*zeta/wn)*g = -(damping/tension)*g. The solver ticks
1238
+ * w = u - u_ss exactly, and the results shift back. u is measured from
1239
+ * the moving target, which the armer already assigned, so the step ends
1240
+ * on it with no rebase arithmetic. With g = 0 the step takes the plain
1241
+ * path, so a motion never armed steps bit-exactly like an isolated
1242
+ * spring.
1243
+ */
1244
+ advance(dt) {
1245
+ invariant(this.#currentSolver, "Cannot tick a disposed motion");
1246
+ const g = this.#slope;
1247
+ if (g !== 0) {
1248
+ this.#slope = 0;
1249
+ const state = this.#state;
1250
+ const shift = this.#config.damping / this.#config.tension * g;
1251
+ state.position += shift;
1252
+ state.velocity -= g;
1253
+ if (this.#needsUpdate) {
1254
+ this.#updateSolver();
1255
+ this.#needsUpdate = false;
1256
+ } else this.#currentSolver.configure();
1257
+ this.#currentSolver.tick(dt);
1258
+ state.position -= shift;
1259
+ state.velocity += g;
1260
+ this.#needsReset = true;
1261
+ if (state.isResting) {
1262
+ state.position = 0;
1263
+ state.velocity = 0;
1264
+ }
1265
+ return;
1266
+ }
1267
+ if (this.#needsUpdate) {
1268
+ this.#updateSolver();
1269
+ this.#needsUpdate = false;
1270
+ this.#needsReset = false;
1271
+ } else if (this.#needsReset) {
1272
+ this.#currentSolver.configure();
1273
+ this.#needsReset = false;
1274
+ }
1275
+ this.#currentSolver.tick(dt);
1276
+ if (this.#state.isResting) {
1277
+ this.#state.position = 0;
1278
+ this.#state.velocity = 0;
1279
+ this.#needsReset = true;
1280
+ }
1281
+ }
1282
+ /**
1283
+ * Emits the frame's `update`, then reconciles `stop` and `start`. The
1284
+ * tick pass calls this after every motion advanced, in the order the
1285
+ * motions first advanced, so update callbacks read frame-final values
1286
+ * everywhere.
1287
+ */
1288
+ endFrame() {
1289
+ this.#emitter.emit("update");
1290
+ this.#reconcileRunning();
1291
+ }
1292
+ #reconcileRunning() {
1293
+ if (this.#state.isResting) {
1294
+ if (this.#running) {
1295
+ this.#running = false;
1296
+ this.#emitter.emit("stop");
1297
+ }
1298
+ } else if (!this.#running) {
1299
+ this.#running = true;
1300
+ this.#emitter.emit("start");
1301
+ }
1302
+ }
1303
+ /**
1304
+ * Puts the motion at rest on the target and emits nothing. The next
1305
+ * `endFrame` delivers `update`, plus `stop` if it was moving. A
1306
+ * follower jumps through this mid-tick, so its listeners wait for the
1307
+ * settle sweep.
1308
+ */
1309
+ rest() {
1310
+ this.#state.position = 0;
1311
+ this.#state.velocity = 0;
1312
+ this.#needsReset = true;
1313
+ }
1314
+ /** Ends the motion at the target at once and emits `update`, plus `stop` if it was moving. */
1315
+ finish() {
1316
+ this.rest();
1317
+ this.tick(0);
1318
+ }
1319
+ /**
1320
+ * Finishes the motion when reduced motion turns on, unless its purpose
1321
+ * is `'nonmotion'`. A motion already at rest on its target stays
1322
+ * silent. A leader's finish can jump its followers before the change
1323
+ * reaches them, and they must not emit twice.
1324
+ */
1325
+ applyReducedMotion(reduced) {
1326
+ if (!reduced || !this.#reduces) return;
1327
+ const state = this.#state;
1328
+ if (!this.#running && state.position === 0 && state.velocity === 0) return;
1329
+ this.finish();
1330
+ }
1331
+ /**
1332
+ * The shock signal for the sub-step controller, read by a follower's
1333
+ * `plan` from its leader: the deviation from the quasi-steady tracking
1334
+ * manifold x = -(damping/tension) * v, where damping/tension is
1335
+ * 2*zeta/wn. It is near zero while chasing a smoothly moving target at
1336
+ * any speed, the full displacement after a target teleport, and
1337
+ * (damping/tension) * |v| after a fling from rest.
1338
+ */
1339
+ manifoldDeviation() {
1340
+ const config = this.#config;
1341
+ return Math.abs(this.#state.position + config.damping / config.tension * this.#state.velocity);
1342
+ }
1343
+ /**
1344
+ * Newton's second law for the damped spring, `a = -(k*x + c*v) / m`,
1345
+ * with x the displacement from the target. Like position and velocity,
1346
+ * it is exact from the state and the config.
1347
+ */
1348
+ get acceleration() {
1349
+ const config = this.#config;
1350
+ return -(config.tension * this.#state.position + config.damping * this.#state.velocity) / config.mass;
1351
+ }
1352
+ /** A spring's slope never changes at a moment known in advance. */
1353
+ kinksWithin(_dt) {
1354
+ return false;
1355
+ }
1356
+ onUpdate(callback) {
1357
+ return this.#emitter.on("update", callback);
1358
+ }
1359
+ onStart(callback) {
1360
+ return this.#emitter.on("start", callback);
1361
+ }
1362
+ onStop(callback) {
1363
+ return this.#emitter.on("stop", callback);
1364
+ }
1365
+ onDispose(callback) {
1366
+ return this.#emitter.on("dispose", callback);
1367
+ }
1368
+ dispose() {
1369
+ this.#emitter.emit("dispose");
1370
+ this.#emitter.clear();
1371
+ this.#underdampedSolver = null;
1372
+ this.#criticallyDampedSolver = null;
1373
+ this.#overdampedSolver = null;
1374
+ this.#currentSolver = null;
1375
+ }
1376
+ #syncStart() {
1377
+ if (!this.#running && !this.#state.isResting) {
1378
+ this.#running = true;
1379
+ this.#emitter.emit("start");
1380
+ }
1381
+ }
1382
+ #updateSolver() {
1383
+ if (this.#config.dampingRatio < 1) {
1384
+ this.#underdampedSolver ||= new UnderdampedSolver(this.#state);
1385
+ this.#currentSolver = this.#underdampedSolver;
1386
+ } else if (this.#config.dampingRatio === 1) {
1387
+ this.#criticallyDampedSolver ||= new CriticallyDampedSolver(this.#state);
1388
+ this.#currentSolver = this.#criticallyDampedSolver;
1389
+ } else {
1390
+ this.#overdampedSolver ||= new OverdampedSolver(this.#state);
1391
+ this.#currentSolver = this.#overdampedSolver;
1392
+ }
1393
+ this.#currentSolver.configure(this.#config);
1394
+ }
1395
+ };
1396
+ //#endregion
1397
+ //#region src/spring.ts
1398
+ /**
1399
+ * One animated number, driven toward its target with damped spring
1400
+ * motion. Create springs with `SpringSystem.createSpring`. A spring moves
1401
+ * only when its system advances it.
1402
+ *
1403
+ * Reads return the exact, unrounded simulated state. Numeric writes
1404
+ * (`target`, `value`, `velocity`, `jumpTo`) throw on `NaN` or an
1405
+ * infinity. See https://github.com/tkofh/coily/blob/main/PRECISION.md
1406
+ * for the numerical contract.
1407
+ */
1408
+ var Spring = class {
1409
+ /** Brands the spring as a `TargetedSource` whose api is the spring itself. */
1410
+ get [SpringSourceSymbol]() {
1411
+ registerBacking(this, this.#motion);
1412
+ return this;
1413
+ }
1414
+ #target;
1415
+ #config;
1416
+ #purpose;
1417
+ #motion;
1418
+ #motions;
1419
+ #leader = null;
1420
+ #unsubLeader = null;
1421
+ #settled = null;
1422
+ #resolveSettled = null;
1423
+ #disposed = false;
1424
+ constructor(motions, value, config, purpose = "motion") {
1425
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
1426
+ invariant(purpose === "motion" || purpose === "nonmotion", "Spring purpose must be 'motion' or 'nonmotion'");
1427
+ this.#motions = motions;
1428
+ this.#config = config ?? SpringDefinition.default;
1429
+ this.#target = value;
1430
+ this.#purpose = purpose;
1431
+ this.#motion = new SpringMotion(this.#config, 0, 0, purpose);
1432
+ }
1433
+ /**
1434
+ * The value the spring is animating toward. While following a source,
1435
+ * reads return the source's value as of its last update.
1436
+ *
1437
+ * Assignment accepts any `SpringTarget`:
1438
+ * - A number retargets smoothly, keeping the current value and velocity.
1439
+ * It also ends any following.
1440
+ * - A `SpringSource` (a `Spring`, or a value derived with `mapSpring`)
1441
+ * makes this spring follow the source's live value.
1442
+ *
1443
+ * Under reduced motion, retargets apply instantly unless `purpose` is
1444
+ * `'nonmotion'`.
1445
+ */
1446
+ get target() {
1447
+ return this.#target;
1448
+ }
1449
+ set target(value) {
1450
+ this.#assertLive();
1451
+ if (typeof value === "number") {
1452
+ invariant(Number.isFinite(value), "Spring target must be a finite number");
1453
+ this.#unfollow();
1454
+ this.#setTarget(value);
1455
+ } else {
1456
+ invariant(isSpringSource(value), "Spring target must be a number or a SpringSource");
1457
+ const current = value[SpringSourceSymbol].value;
1458
+ invariant(typeof current === "number", "A spring can only follow a scalar SpringSource; derive one from a composite with mapSpring");
1459
+ invariant(Number.isFinite(current), "Spring target must be a finite number");
1460
+ this.#follow(value);
1461
+ }
1462
+ }
1463
+ /**
1464
+ * The current animated value: the target plus the remaining
1465
+ * displacement.
1466
+ *
1467
+ * Writing displaces the spring. It keeps its target, springs back from
1468
+ * the written value, and notifies update listeners synchronously.
1469
+ * Writing the current value is a no-op. Under reduced motion, a write
1470
+ * jumps value and target together unless `purpose` is `'nonmotion'`.
1471
+ */
1472
+ get value() {
1473
+ return this.#target + this.#motion.position;
1474
+ }
1475
+ set value(value) {
1476
+ this.#assertLive();
1477
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
1478
+ if (this.#motions.reduced && this.#purpose === "motion") {
1479
+ if (value !== this.value) this.jumpTo(value);
1480
+ return;
1481
+ }
1482
+ const position = value - this.#target;
1483
+ if (position !== this.#motion.position) {
1484
+ this.#motions.add(this.#motion);
1485
+ this.#motion.position = position;
1486
+ this.#motion.tick(0);
1487
+ }
1488
+ }
1489
+ /**
1490
+ * The current velocity, in value units per second.
1491
+ *
1492
+ * Writing flings the spring from its current value, and it settles back
1493
+ * to the target. Under reduced motion, the write is ignored unless
1494
+ * `purpose` is `'nonmotion'`.
1495
+ */
1496
+ get velocity() {
1497
+ return this.#motion.velocity;
1498
+ }
1499
+ set velocity(value) {
1500
+ this.#assertLive();
1501
+ invariant(Number.isFinite(value), "Spring velocity must be a finite number");
1502
+ if (this.#motions.reduced && this.#purpose === "motion") return;
1503
+ this.#motions.add(this.#motion);
1504
+ this.#motion.velocity = value;
1505
+ }
1506
+ /**
1507
+ * The current acceleration, in value units per second squared.
1508
+ * Read-only, because stiffness and friction derive it from the current
1509
+ * displacement and velocity. To fling the spring, write `velocity`.
1510
+ */
1511
+ get acceleration() {
1512
+ return this.#motion.acceleration;
1513
+ }
1514
+ /**
1515
+ * What the spring animates, fixed at creation. Under reduced motion, a
1516
+ * `'motion'` spring snaps to its target and a `'nonmotion'` spring keeps
1517
+ * animating. See `Purpose`.
1518
+ */
1519
+ get purpose() {
1520
+ return this.#purpose;
1521
+ }
1522
+ /**
1523
+ * The spring's `SpringDefinition`: the one it was created with or last
1524
+ * assigned, otherwise `SpringDefinition.default`. Following a source
1525
+ * never changes it.
1526
+ *
1527
+ * Assigning reconfigures the spring in place, keeping value and
1528
+ * velocity, so mid-flight changes stay smooth. `null` reverts to the
1529
+ * default.
1530
+ */
1531
+ get config() {
1532
+ return this.#config;
1533
+ }
1534
+ /** The config's mass. */
1535
+ get mass() {
1536
+ return this.#config.mass;
1537
+ }
1538
+ /** The config's tension (stiffness). */
1539
+ get tension() {
1540
+ return this.#config.tension;
1541
+ }
1542
+ /** The config's damping (friction). */
1543
+ get damping() {
1544
+ return this.#config.damping;
1545
+ }
1546
+ /** The config's damping ratio. */
1547
+ get dampingRatio() {
1548
+ return this.#config.dampingRatio;
1549
+ }
1550
+ /** The config's precision: decimal places of the resting threshold. */
1551
+ get precision() {
1552
+ return this.#config.precision;
1553
+ }
1554
+ /** The config's arrival multiplier, applied to the velocity at each target crossing. */
1555
+ get arrival() {
1556
+ return this.#config.arrival;
1557
+ }
1558
+ set config(value) {
1559
+ this.#assertLive();
1560
+ const next = value ?? SpringDefinition.default;
1561
+ if (this.#config === next) return;
1562
+ this.#config = next;
1563
+ this.#motion.configure(next);
1564
+ if (!this.#motion.isResting) this.#motions.add(this.#motion);
1565
+ }
1566
+ /**
1567
+ * The time until the spring rests, in ms, solved from the motion: 0
1568
+ * while resting, `Infinity` when it never settles. The spring rests at
1569
+ * the first tick at or after this time. A bouncy spring can rest up to
1570
+ * one oscillation earlier, when a frame samples a low point of the
1571
+ * bounce. With `arrival: 0`, the time is capped at the first target
1572
+ * crossing, exact to the ms.
1573
+ */
1574
+ get timeRemaining() {
1575
+ return this.#motion.timeRemaining;
1576
+ }
1577
+ /**
1578
+ * Whether the spring's remaining motion is inside its resting
1579
+ * threshold. Resting springs cost nothing until the next write. See
1580
+ * https://github.com/tkofh/coily/blob/main/PRECISION.md for the rest
1581
+ * test.
1582
+ */
1583
+ get isResting() {
1584
+ return this.#motion.isResting;
1585
+ }
1586
+ /**
1587
+ * A promise that resolves when the spring next rests or is disposed.
1588
+ * It is already resolved while the spring rests and after dispose.
1589
+ * Retargeting mid-flight extends the wait.
1590
+ *
1591
+ * @example
1592
+ * ```ts
1593
+ * spring.target = 300
1594
+ * await spring.settled
1595
+ * // the spring is at 300 and resting
1596
+ * ```
1597
+ */
1598
+ get settled() {
1599
+ if (this.#disposed || this.#motion.isResting) return RESOLVED;
1600
+ this.#settled ??= new Promise((resolve) => {
1601
+ this.#resolveSettled = resolve;
1602
+ const unsubscribe = this.onStop(() => {
1603
+ unsubscribe();
1604
+ this.#settled = null;
1605
+ this.#resolveSettled = null;
1606
+ resolve();
1607
+ });
1608
+ });
1609
+ return this.#settled;
1610
+ }
1611
+ /**
1612
+ * Snaps target and value to `value` and zeroes the velocity, with no
1613
+ * animation. Listeners fire synchronously: `update`, plus `stop` if the
1614
+ * spring was moving.
1615
+ */
1616
+ jumpTo(value) {
1617
+ this.#assertLive();
1618
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
1619
+ this.#target = value;
1620
+ this.#motion.finish();
1621
+ }
1622
+ /**
1623
+ * Releases the spring permanently. It stops following, detaches
1624
+ * followers (each keeps its current target), resolves `settled`, and
1625
+ * notifies dispose listeners. Calling it again is a no-op.
1626
+ *
1627
+ * A disposed spring keeps its final value readable. Writes throw.
1628
+ */
1629
+ dispose() {
1630
+ if (this.#disposed) return;
1631
+ this.#disposed = true;
1632
+ if (this.#resolveSettled) {
1633
+ this.#resolveSettled();
1634
+ this.#settled = null;
1635
+ this.#resolveSettled = null;
1636
+ }
1637
+ if (this.#unsubLeader) {
1638
+ this.#unsubLeader();
1639
+ this.#unsubLeader = null;
1640
+ this.#leader = null;
1641
+ }
1642
+ this.#motions.remove(this.#motion);
1643
+ this.#motion.dispose();
1644
+ }
1645
+ /**
1646
+ * Subscribes to value changes: every tick the spring moves, plus
1647
+ * synchronous `value` writes and `jumpTo` calls. Returns an unsubscribe
1648
+ * function.
1649
+ */
1650
+ onUpdate(callback) {
1651
+ return this.#motion.onUpdate(callback);
1652
+ }
1653
+ /**
1654
+ * Subscribes to the spring leaving rest. `start` and `stop` always
1655
+ * alternate, and retargeting mid-flight fires neither. Returns an
1656
+ * unsubscribe function.
1657
+ */
1658
+ onStart(callback) {
1659
+ return this.#motion.onStart(callback);
1660
+ }
1661
+ /**
1662
+ * Subscribes to the spring coming to rest, whether by settling or by
1663
+ * `jumpTo`. Always alternates with `start`. Returns an unsubscribe
1664
+ * function.
1665
+ */
1666
+ onStop(callback) {
1667
+ return this.#motion.onStop(callback);
1668
+ }
1669
+ /** Subscribes to `dispose`, which fires once. Returns an unsubscribe function. */
1670
+ onDispose(callback) {
1671
+ return this.#motion.onDispose(callback);
1672
+ }
1673
+ #assertLive() {
1674
+ invariant(!this.#disposed, "Cannot write to a disposed spring");
1675
+ }
1676
+ #setTarget(value) {
1677
+ invariant(Number.isFinite(value), "Spring target must be a finite number");
1678
+ if (value !== this.#target) {
1679
+ if (this.#motions.reduced && this.#purpose === "motion") {
1680
+ this.jumpTo(value);
1681
+ return;
1682
+ }
1683
+ this.#motions.add(this.#motion);
1684
+ const current = this.value;
1685
+ this.#target = value;
1686
+ this.#motion.position = current - this.#target;
1687
+ this.#motion.tick(0, false);
1688
+ }
1689
+ }
1690
+ #follow(source) {
1691
+ this.#unsubLeader?.();
1692
+ this.#leader = source;
1693
+ const api = source[SpringSourceSymbol];
1694
+ const unsubUpdate = api.onUpdate(() => {
1695
+ this.#setTarget(api.value);
1696
+ });
1697
+ const unsubDispose = api.onDispose(() => {
1698
+ this.#unfollow();
1699
+ });
1700
+ const leaders = resolveLeaderMotions(source);
1701
+ if (leaders.length > 0) {
1702
+ const edge = {
1703
+ follower: this.#motion,
1704
+ leaders,
1705
+ cyclic: false,
1706
+ trail: new TargetTrail(0),
1707
+ recouple: (h) => {
1708
+ if (this.#motions.reduced && this.#purpose === "motion") {
1709
+ const value = api.value;
1710
+ invariant(Number.isFinite(value), "Spring target must be a finite number");
1711
+ if (value === this.#target) return;
1712
+ this.#target = value;
1713
+ this.#motions.add(this.#motion);
1714
+ this.#motion.rest();
1715
+ return;
1716
+ }
1717
+ if (edge.cyclic || h === 0 || this.#config.arrival !== 1) {
1718
+ this.#setTarget(api.value);
1719
+ return;
1720
+ }
1721
+ const value = api.value;
1722
+ invariant(Number.isFinite(value), "Spring target must be a finite number");
1723
+ const previous = this.#target;
1724
+ if (value === previous) return;
1725
+ this.#motions.add(this.#motion);
1726
+ this.#target = value;
1727
+ this.#motion.armSlope((value - previous) / h);
1728
+ },
1729
+ plan: (dt) => {
1730
+ const trail = edge.trail;
1731
+ const d = trail.observe(this.#target);
1732
+ let dHat = Math.abs(d);
1733
+ let error = Math.abs(trail.curvature) / 8;
1734
+ for (const leader of leaders) {
1735
+ if (leader.kinksWithin(dt)) return Number.POSITIVE_INFINITY;
1736
+ if (leader.manifoldDeviation() > 4 * Math.abs(d)) {
1737
+ const accel = Math.abs(leader.acceleration);
1738
+ const speed = Math.abs(leader.velocity);
1739
+ dHat = Math.max(dHat, speed * dt + .5 * accel * dt * dt);
1740
+ error = Math.max(error, accel * dt * dt / 8);
1741
+ }
1742
+ }
1743
+ const tol = Math.max(this.#config.restingMagnitude, this.#motions.couplingTolerance);
1744
+ return edge.cyclic || this.#config.arrival !== 1 ? Math.ceil(dHat / (2 * tol)) : Math.ceil(Math.sqrt(error / tol));
1745
+ }
1746
+ };
1747
+ this.#motions.graph.addEdge(edge);
1748
+ this.#unsubLeader = () => {
1749
+ unsubUpdate();
1750
+ unsubDispose();
1751
+ this.#motions.graph.removeEdge(this.#motion);
1752
+ };
1753
+ this.#setTarget(api.value);
1754
+ edge.trail.anchor(this.#target);
1755
+ return;
1756
+ }
1757
+ this.#unsubLeader = () => {
1758
+ unsubUpdate();
1759
+ unsubDispose();
1760
+ this.#motions.graph.removeEdge(this.#motion);
1761
+ };
1762
+ this.#setTarget(api.value);
1763
+ }
1764
+ #unfollow() {
1765
+ if (!this.#leader) return;
1766
+ this.#unsubLeader();
1767
+ this.#unsubLeader = null;
1768
+ this.#leader = null;
1769
+ }
1770
+ };
1771
+ //#endregion
1772
+ //#region src/composite-spring.ts
1773
+ function invalidConfig(path) {
1774
+ return `Invalid config for ${describePath(path)}: expected a SpringDefinition, null, or a config shape matching the value`;
1775
+ }
1776
+ function resolveConfigNode(node, path) {
1777
+ if (node === null) return null;
1778
+ if (node instanceof SpringDefinition) return node;
1779
+ if (isRecordOrArray(node)) return BRANCH;
1780
+ throw new Error(invalidConfig(path));
1781
+ }
1782
+ function invalidPurpose(path) {
1783
+ return `Invalid purpose for ${describePath(path)}: expected 'motion', 'nonmotion', or a purpose shape matching the value`;
1784
+ }
1785
+ function resolvePurposeNode(node, path) {
1786
+ if (node === "motion" || node === "nonmotion") return node;
1787
+ if (isRecordOrArray(node)) return BRANCH;
1788
+ throw new Error(invalidPurpose(path));
1789
+ }
1790
+ const readTarget = (spring) => spring.target;
1791
+ const readValue = (spring) => spring.value;
1792
+ const readVelocity = (spring) => spring.velocity;
1793
+ const readAcceleration = (spring) => spring.acceleration;
1794
+ function acceptChannelTarget(input, path) {
1795
+ invariant(isNumber(input) && Number.isFinite(input) || isSpringSource(input) && Number.isFinite(input[SpringSourceSymbol].value), () => `Invalid value at '${path}': expected a finite number or a scalar SpringSource`);
1796
+ return input;
1797
+ }
1798
+ const assignTarget = (spring, value) => {
1799
+ spring.target = value;
1800
+ };
1801
+ const assignValue = (spring, value) => {
1802
+ spring.value = value;
1803
+ };
1804
+ const assignVelocity = (spring, value) => {
1805
+ spring.velocity = value;
1806
+ };
1807
+ const applyJump = (spring, value) => {
1808
+ spring.jumpTo(value);
1809
+ };
1810
+ const assignConfig = (spring, config) => {
1811
+ spring.config = config;
1812
+ };
1813
+ const followChannel = (mine, theirs) => {
1814
+ mine.target = theirs;
1815
+ };
1816
+ /**
1817
+ * A composite spring over a fixed numeric shape: a nested plain object
1818
+ * or array whose leaves are all numbers. Each leaf runs an independent
1819
+ * spring, called a channel. Targets, values, velocities, and configs read
1820
+ * and write as shapes, writes can name any subset of the channels, and
1821
+ * events coalesce across channels. Create one with
1822
+ * `SpringSystem.createSpring`.
1823
+ *
1824
+ * A composite spring is a `SpringSource` of its value shape. Springs
1825
+ * follow only scalar sources, so use `mapSpring` to derive one from the
1826
+ * composite, alone or at the leaves of a shape.
1827
+ *
1828
+ * A write that names an unknown channel or gives one a non-finite value
1829
+ * throws with the channel's path (`position.z`) and changes nothing.
1830
+ */
1831
+ var CompositeSpring = class CompositeSpring {
1832
+ /** Brands the composite as a `TargetedSource` whose api is the composite itself. */
1833
+ get [SpringSourceSymbol]() {
1834
+ registerBacking(this, this.#tree.leaves);
1835
+ return this;
1836
+ }
1837
+ #motions;
1838
+ #tree;
1839
+ #valueView;
1840
+ #targetView = null;
1841
+ #velocityView = null;
1842
+ #accelerationView = null;
1843
+ #emitter = new Emitter();
1844
+ #running = false;
1845
+ #dirty = false;
1846
+ #settled = null;
1847
+ #resolveSettled = null;
1848
+ #disposed = false;
1849
+ #flush = () => {
1850
+ if (this.#disposed) return;
1851
+ if (this.#dirty) {
1852
+ this.#dirty = false;
1853
+ this.#emitter.emit("update");
1854
+ }
1855
+ if (this.#running) {
1856
+ if (this.isResting) {
1857
+ this.#running = false;
1858
+ this.#emitter.emit("stop");
1859
+ }
1860
+ } else if (!this.isResting) {
1861
+ this.#running = true;
1862
+ this.#emitter.emit("start");
1863
+ }
1864
+ const resolve = this.#resolveSettled;
1865
+ if (resolve !== null && !this.#running) {
1866
+ this.#settled = null;
1867
+ this.#resolveSettled = null;
1868
+ resolve();
1869
+ }
1870
+ };
1871
+ constructor(motions, value, config, purpose) {
1872
+ this.#motions = motions;
1873
+ invariant(isRecordOrArray(value), "Composite spring value must be a plain object or an array of numeric channels");
1874
+ let purposeByPath = null;
1875
+ if (purpose !== void 0) {
1876
+ const byPath = /* @__PURE__ */ new Map();
1877
+ new ShapeTree(value, channelParser((_leafValue, path) => path)).root.broadcast(purpose, resolvePurposeNode, (path, p) => byPath.set(path, p), "purpose");
1878
+ purposeByPath = byPath;
1879
+ }
1880
+ this.#tree = new ShapeTree(value, channelParser((leafValue, path) => new Spring(motions, leafValue, void 0, purposeByPath?.get(path) ?? "motion")));
1881
+ if (config !== void 0) this.#tree.root.broadcast(config, resolveConfigNode, assignConfig, "config");
1882
+ this.#valueView = new ShapeView(this.#tree.root, readValue);
1883
+ const markDirty = () => {
1884
+ this.#dirty = true;
1885
+ this.#motions.flushes.request(this.#flush);
1886
+ };
1887
+ const schedule = () => {
1888
+ this.#motions.flushes.request(this.#flush);
1889
+ };
1890
+ for (const channel of this.#tree.leaves) {
1891
+ channel.onUpdate(markDirty);
1892
+ channel.onStart(schedule);
1893
+ channel.onStop(schedule);
1894
+ }
1895
+ }
1896
+ /**
1897
+ * The value each channel is animating toward, as a read-only mirror of
1898
+ * the shape. The mirror is reused and refreshed in place on each read,
1899
+ * so read it fresh instead of holding it across frames.
1900
+ *
1901
+ * Assignment accepts a `CompositeSpringTarget`:
1902
+ * - A partial shape retargets the channels it names, each to a number
1903
+ * or to a scalar `SpringSource` to follow, such as a `Spring` or a
1904
+ * `mapSpring` result. Naming a channel detaches it from any leader.
1905
+ * - A `CompositeSpring` of the same shape becomes the leader, and each
1906
+ * channel follows the matching leader channel.
1907
+ *
1908
+ * Unknown channels throw with their path. Under reduced motion,
1909
+ * retargets apply instantly, except on channels whose `purpose` is
1910
+ * `'nonmotion'`.
1911
+ */
1912
+ get target() {
1913
+ const view = this.#targetView ??= new ShapeView(this.#tree.root, readTarget);
1914
+ view.refresh();
1915
+ return view.root;
1916
+ }
1917
+ set target(value) {
1918
+ if (value instanceof CompositeSpring) this.#follow(value);
1919
+ else this.#scatter(value, acceptChannelTarget, assignTarget);
1920
+ }
1921
+ /**
1922
+ * The current animated value of every channel, as a read-only mirror of
1923
+ * the shape. The mirror is reused and refreshed in place on each read,
1924
+ * so read it fresh instead of holding it across frames.
1925
+ *
1926
+ * Assignment displaces the channels a partial shape names. Each keeps
1927
+ * its target and springs back from the written value. The write fires
1928
+ * one coalesced `update` synchronously. Under reduced motion, the named
1929
+ * channels jump to the written values, targets included, except on
1930
+ * channels whose `purpose` is `'nonmotion'`.
1931
+ */
1932
+ get value() {
1933
+ this.#valueView.refresh();
1934
+ return this.#valueView.root;
1935
+ }
1936
+ set value(value) {
1937
+ this.#scatter(value, acceptNumber, assignValue);
1938
+ }
1939
+ /**
1940
+ * The current velocity of every channel in value units per second, as a
1941
+ * read-only mirror of the shape. The mirror is reused and refreshed in
1942
+ * place on each read.
1943
+ *
1944
+ * Assignment flings the channels a partial shape names. Under reduced
1945
+ * motion, writes are ignored, except on channels whose `purpose` is
1946
+ * `'nonmotion'`.
1947
+ */
1948
+ get velocity() {
1949
+ const view = this.#velocityView ??= new ShapeView(this.#tree.root, readVelocity);
1950
+ view.refresh();
1951
+ return view.root;
1952
+ }
1953
+ set velocity(value) {
1954
+ this.#scatter(value, acceptNumber, assignVelocity);
1955
+ }
1956
+ /**
1957
+ * The current acceleration of every channel in value units per second
1958
+ * squared, as a read-only mirror of the shape. The mirror is reused and
1959
+ * refreshed in place on each read. To fling channels, write `velocity`.
1960
+ */
1961
+ get acceleration() {
1962
+ const view = this.#accelerationView ??= new ShapeView(this.#tree.root, readAcceleration);
1963
+ view.refresh();
1964
+ return view.root;
1965
+ }
1966
+ /**
1967
+ * The shared config when every channel resolves to the same
1968
+ * `SpringDefinition`, and `null` when they differ.
1969
+ *
1970
+ * Assignment accepts a `ConfigShape`: one config for every channel,
1971
+ * `null` to revert every channel to the default, or a shape mirroring
1972
+ * the value whose configs each cover their subtree. Non-config leaves
1973
+ * throw with their path. Assigning a `null` read back reverts every
1974
+ * channel to the default rather than restoring the mix.
1975
+ */
1976
+ get config() {
1977
+ const channels = this.#tree.leaves;
1978
+ const first = channels[0].config;
1979
+ for (let i = 1; i < channels.length; i++) if (channels[i].config !== first) return null;
1980
+ return first;
1981
+ }
1982
+ set config(value) {
1983
+ this.#assertLive();
1984
+ const root = this.#tree.root;
1985
+ const config = value ?? null;
1986
+ root.broadcast(config, resolveConfigNode, null, "config");
1987
+ this.#motions.flushes.batch(() => {
1988
+ root.broadcast(config, resolveConfigNode, assignConfig, "config");
1989
+ });
1990
+ }
1991
+ /**
1992
+ * The shared purpose when every channel resolves to the same `Purpose`,
1993
+ * and `null` when they differ. The purposes are fixed when the spring is
1994
+ * created. A `'nonmotion'` channel opts out of reduced motion. See
1995
+ * `Purpose`.
1996
+ */
1997
+ get purpose() {
1998
+ const channels = this.#tree.leaves;
1999
+ const first = channels[0].purpose;
2000
+ for (let i = 1; i < channels.length; i++) if (channels[i].purpose !== first) return null;
2001
+ return first;
2002
+ }
2003
+ /** The largest `timeRemaining` across channels, in ms. */
2004
+ get timeRemaining() {
2005
+ let max = 0;
2006
+ for (const channel of this.#tree.leaves) {
2007
+ const remaining = channel.timeRemaining;
2008
+ if (remaining > max) max = remaining;
2009
+ }
2010
+ return max;
2011
+ }
2012
+ /** Whether every channel is resting. */
2013
+ get isResting() {
2014
+ for (const channel of this.#tree.leaves) if (!channel.isResting) return false;
2015
+ return true;
2016
+ }
2017
+ /**
2018
+ * A promise that resolves when every channel next rests. It is already
2019
+ * resolved while the composite rests or after dispose. Retargeting any
2020
+ * channel mid-flight extends the wait, and disposing resolves it.
2021
+ */
2022
+ get settled() {
2023
+ if (this.#disposed || this.isResting) return RESOLVED;
2024
+ this.#settled ??= new Promise((resolve) => {
2025
+ this.#resolveSettled = resolve;
2026
+ });
2027
+ return this.#settled;
2028
+ }
2029
+ /**
2030
+ * Snaps the channels a partial shape names to the given values with no
2031
+ * animation, setting both target and value. Fires one `update`
2032
+ * synchronously, plus `stop` if the jump leaves every channel resting.
2033
+ */
2034
+ jumpTo(value) {
2035
+ this.#scatter(value, acceptNumber, applyJump);
2036
+ }
2037
+ /**
2038
+ * Releases every channel permanently. Each channel stops following, and
2039
+ * its followers detach and keep their current targets. `settled`
2040
+ * resolves and dispose listeners fire. Calling it again is a no-op.
2041
+ * Afterward, final values stay readable and writes throw.
2042
+ */
2043
+ dispose() {
2044
+ if (this.#disposed) return;
2045
+ this.#disposed = true;
2046
+ if (this.#resolveSettled) {
2047
+ this.#resolveSettled();
2048
+ this.#settled = null;
2049
+ this.#resolveSettled = null;
2050
+ }
2051
+ for (const channel of this.#tree.leaves) channel.dispose();
2052
+ this.#emitter.clear();
2053
+ }
2054
+ /**
2055
+ * Subscribes to coalesced value changes. `update` fires at most once per
2056
+ * tick, after every channel advances, and once per synchronous write that
2057
+ * moves a channel, such as a `value` write or `jumpTo`. Returns an
2058
+ * unsubscribe function.
2059
+ */
2060
+ onUpdate(callback) {
2061
+ return this.#emitter.on("update", callback);
2062
+ }
2063
+ /**
2064
+ * Subscribes to the composite leaving rest. `start` fires when a channel
2065
+ * starts to move and no other channel was moving. It always alternates
2066
+ * with `stop`. Returns an unsubscribe function.
2067
+ */
2068
+ onStart(callback) {
2069
+ return this.#emitter.on("start", callback);
2070
+ }
2071
+ /**
2072
+ * Subscribes to the composite coming to rest. `stop` fires when every
2073
+ * channel rests, after that tick's final `update`. It always alternates
2074
+ * with `start`. Returns an unsubscribe function.
2075
+ */
2076
+ onStop(callback) {
2077
+ return this.#emitter.on("stop", callback);
2078
+ }
2079
+ /** Subscribes to `dispose`, which fires once. Returns an unsubscribe function. */
2080
+ onDispose(callback) {
2081
+ return this.#tree.leaves[0].onDispose(callback);
2082
+ }
2083
+ #assertLive() {
2084
+ invariant(!this.#disposed, "Cannot write to a disposed spring");
2085
+ }
2086
+ #scatter(input, accept, apply) {
2087
+ this.#assertLive();
2088
+ const root = this.#tree.root;
2089
+ root.scatter(input, accept, null);
2090
+ this.#motions.flushes.batch(() => {
2091
+ root.scatter(input, accept, apply);
2092
+ });
2093
+ }
2094
+ #follow(leader) {
2095
+ this.#assertLive();
2096
+ const root = this.#tree.root;
2097
+ root.zip(leader.#tree.root, null);
2098
+ this.#motions.flushes.batch(() => {
2099
+ root.zip(leader.#tree.root, followChannel);
2100
+ });
2101
+ }
2102
+ };
2103
+ //#endregion
2104
+ //#region src/ramp-motion.ts
2105
+ /**
2106
+ * A ramp's motion, computed from an anchor on the system clock.
2107
+ *
2108
+ * At clock time `t` the value is `anchorValue + speed * (t - anchorTime)`
2109
+ * toward the target, clamped there. Every write re-anchors at the
2110
+ * current value first. The ramp joins the tick only while an update
2111
+ * listener, or a stop listener on a bounded ramp, needs it per frame.
2112
+ * Outside the tick it still moves, but emits no frame events, not even
2113
+ * `stop` on arrival.
2114
+ *
2115
+ * Within an advance phase, `advance` steps a cached value by `speed * h`
2116
+ * per sub-step, so a follower recoupling mid-frame reads the sub-step's
2117
+ * position. Between phases, reads use the formula.
2118
+ */
2119
+ var RampMotion = class extends Motion {
2120
+ #motions;
2121
+ #emitter = new Emitter();
2122
+ /** Whether reduced motion applies: false for a `'nonmotion'` ramp. */
2123
+ #reduces;
2124
+ #anchorValue;
2125
+ /** Clock seconds at which `#anchorValue` holds. */
2126
+ #anchorTime;
2127
+ #target;
2128
+ /** Value units per second. */
2129
+ #rate;
2130
+ /** Whether the ramp is endless and holds still under reduced motion. */
2131
+ #held = false;
2132
+ #disposed = false;
2133
+ /** Whether the ramp is in the set's `reducible` registry. */
2134
+ #reducible = false;
2135
+ /** The motion state listeners last heard: true after `start`, false after `stop`. */
2136
+ #running;
2137
+ #updateListeners = 0;
2138
+ #stopListeners = 0;
2139
+ /** The advance phase `#stepped` belongs to, or 0. */
2140
+ #phase = 0;
2141
+ #stepped = 0;
2142
+ constructor(motions, from, rate, target, purpose) {
2143
+ super();
2144
+ this.#motions = motions;
2145
+ this.#reduces = purpose === "motion";
2146
+ this.#anchorValue = from;
2147
+ this.#anchorTime = motions.time;
2148
+ this.#target = target;
2149
+ this.#rate = rate;
2150
+ if (this.#reduces && motions.reduced) if (Number.isFinite(target)) this.#anchorValue = target;
2151
+ else this.#held = true;
2152
+ this.#running = !this.isResting;
2153
+ this.#sync();
2154
+ }
2155
+ get value() {
2156
+ if (this.#phase !== 0 && this.#phase === this.#motions.advancing) return this.#stepped;
2157
+ return this.#at(this.#motions.time);
2158
+ }
2159
+ get target() {
2160
+ return this.#target;
2161
+ }
2162
+ get rate() {
2163
+ return this.#rate;
2164
+ }
2165
+ get isResting() {
2166
+ return this.#speed() === 0 || this.value === this.#target;
2167
+ }
2168
+ get velocity() {
2169
+ const value = this.value;
2170
+ const speed = this.#speed();
2171
+ if (speed === 0 || value === this.#target) return 0;
2172
+ return this.#target > value ? speed : -speed;
2173
+ }
2174
+ /** Time until arrival, in ms: 0 at rest, Infinity while moving toward an infinite target. */
2175
+ get timeRemaining() {
2176
+ const value = this.value;
2177
+ const speed = this.#speed();
2178
+ if (speed === 0 || value === this.#target) return 0;
2179
+ return Math.abs(this.#target - value) / speed * 1e3;
2180
+ }
2181
+ set value(value) {
2182
+ this.#beginWrite();
2183
+ if (value === this.value) return;
2184
+ this.#anchorValue = value;
2185
+ this.#anchorTime = this.#motions.time;
2186
+ if (this.#reduces && this.#motions.reduced && Number.isFinite(this.#target)) this.#target = value;
2187
+ this.#emitter.emit("update");
2188
+ this.#sync();
2189
+ }
2190
+ set target(target) {
2191
+ this.#beginWrite();
2192
+ this.#reanchor();
2193
+ this.#target = target;
2194
+ const endless = !Number.isFinite(target);
2195
+ if (this.#reduces && this.#motions.reduced) {
2196
+ this.#held = endless;
2197
+ if (!endless && this.#anchorValue !== target) {
2198
+ this.#anchorValue = target;
2199
+ this.#emitter.emit("update");
2200
+ }
2201
+ } else this.#held = false;
2202
+ this.#sync();
2203
+ }
2204
+ set rate(rate) {
2205
+ this.#beginWrite();
2206
+ this.#reanchor();
2207
+ this.#rate = rate;
2208
+ this.#sync();
2209
+ }
2210
+ jumpTo(value) {
2211
+ this.#beginWrite();
2212
+ this.#anchorValue = value;
2213
+ this.#anchorTime = this.#motions.time;
2214
+ this.#target = value;
2215
+ this.#held = false;
2216
+ this.#emitter.emit("update");
2217
+ this.#sync();
2218
+ }
2219
+ /**
2220
+ * Applies a change of the reduced-motion setting: a bounded ramp
2221
+ * finishes at its target, an endless one holds where it is, and a held
2222
+ * ramp resumes from where it holds. Applying the current setting again
2223
+ * changes nothing.
2224
+ */
2225
+ applyReducedMotion(reduced) {
2226
+ if (!this.#reduces || this.#disposed) return;
2227
+ if (reduced) {
2228
+ if (Number.isFinite(this.#target)) {
2229
+ if (this.value !== this.#target) {
2230
+ this.#anchorValue = this.#target;
2231
+ this.#anchorTime = this.#motions.time;
2232
+ this.#emitter.emit("update");
2233
+ }
2234
+ } else if (!this.#held) {
2235
+ this.#reanchor();
2236
+ this.#held = true;
2237
+ }
2238
+ } else if (this.#held) {
2239
+ this.#reanchor();
2240
+ this.#held = false;
2241
+ }
2242
+ this.#sync();
2243
+ }
2244
+ advance(h) {
2245
+ const phase = this.#motions.advancing;
2246
+ if (this.#phase !== phase) {
2247
+ this.#phase = phase;
2248
+ this.#stepped = this.#at(this.#motions.time);
2249
+ }
2250
+ const speed = this.#speed();
2251
+ const remaining = this.#target - this.#stepped;
2252
+ if (speed === 0 || remaining === 0) return;
2253
+ const step = speed * h;
2254
+ this.#stepped = Math.abs(remaining) <= step ? this.#target : this.#stepped + Math.sign(remaining) * step;
2255
+ }
2256
+ endFrame() {
2257
+ if (this.#stepped === this.#target && this.#anchorValue !== this.#target) {
2258
+ this.#anchorValue = this.#target;
2259
+ this.#anchorTime = this.#motions.time;
2260
+ }
2261
+ this.#phase = 0;
2262
+ this.#emitter.emit("update");
2263
+ this.#sync();
2264
+ }
2265
+ /** 0: nothing knocks a ramp off its straight line. */
2266
+ manifoldDeviation() {
2267
+ return 0;
2268
+ }
2269
+ /** 0: a ramp's speed changes only at arrival and on writes. */
2270
+ get acceleration() {
2271
+ return 0;
2272
+ }
2273
+ /** True when a bounded ramp arrives within the next `dt` seconds, ending its slope there. */
2274
+ kinksWithin(dt) {
2275
+ const speed = this.#speed();
2276
+ if (speed === 0 || !Number.isFinite(this.#target)) return false;
2277
+ const arrival = Math.abs(this.#target - this.value) / speed;
2278
+ return arrival > 0 && arrival <= dt;
2279
+ }
2280
+ onUpdate(callback) {
2281
+ return this.#listen("update", callback);
2282
+ }
2283
+ onStart(callback) {
2284
+ return this.#emitter.on("start", callback);
2285
+ }
2286
+ onStop(callback) {
2287
+ return this.#listen("stop", callback);
2288
+ }
2289
+ onDispose(callback) {
2290
+ return this.#emitter.on("dispose", callback);
2291
+ }
2292
+ dispose() {
2293
+ if (this.#disposed) return;
2294
+ this.#reanchor();
2295
+ this.#disposed = true;
2296
+ this.#emitter.emit("dispose");
2297
+ this.#emitter.clear();
2298
+ this.#sync();
2299
+ }
2300
+ get disposed() {
2301
+ return this.#disposed;
2302
+ }
2303
+ #speed() {
2304
+ return this.#held || this.#disposed ? 0 : this.#rate;
2305
+ }
2306
+ #at(time) {
2307
+ const speed = this.#speed();
2308
+ const remaining = this.#target - this.#anchorValue;
2309
+ if (speed === 0 || remaining === 0) return this.#anchorValue;
2310
+ const travel = speed * Math.max(0, time - this.#anchorTime);
2311
+ return Math.abs(remaining) <= travel ? this.#target : this.#anchorValue + Math.sign(remaining) * travel;
2312
+ }
2313
+ #reanchor() {
2314
+ const time = this.#motions.time;
2315
+ this.#anchorValue = this.#at(time);
2316
+ this.#anchorTime = time;
2317
+ }
2318
+ #beginWrite() {
2319
+ invariant(!this.#disposed, "Cannot write to a disposed ramp");
2320
+ this.#sync();
2321
+ }
2322
+ #listen(type, callback) {
2323
+ this.#sync();
2324
+ const off = this.#emitter.on(type, callback);
2325
+ if (type === "update") this.#updateListeners++;
2326
+ else this.#stopListeners++;
2327
+ this.#sync();
2328
+ let active = true;
2329
+ return () => {
2330
+ if (!active) return;
2331
+ active = false;
2332
+ off();
2333
+ if (type === "update") this.#updateListeners--;
2334
+ else this.#stopListeners--;
2335
+ this.#sync();
2336
+ };
2337
+ }
2338
+ #sync() {
2339
+ const moving = !this.#disposed && !this.isResting;
2340
+ const watched = this.#updateListeners > 0 || this.#stopListeners > 0 && Number.isFinite(this.#target);
2341
+ const ticking = moving && watched;
2342
+ if (ticking !== this.#motions.has(this)) if (ticking) this.#motions.add(this);
2343
+ else this.#motions.remove(this);
2344
+ const reducible = this.#reduces && !this.#disposed && (moving || this.#held);
2345
+ if (reducible !== this.#reducible) {
2346
+ this.#reducible = reducible;
2347
+ if (reducible) this.#motions.reducible.add(this);
2348
+ else this.#motions.reducible.delete(this);
2349
+ }
2350
+ if (moving !== this.#running) {
2351
+ this.#running = moving;
2352
+ this.#emitter.emit(moving ? "start" : "stop");
2353
+ }
2354
+ }
2355
+ };
2356
+ //#endregion
2357
+ //#region src/ramp.ts
2358
+ function assertRate(rate) {
2359
+ invariant(Number.isFinite(rate) && rate >= 0, "Ramp rate must be a finite number >= 0");
2360
+ }
2361
+ function assertTarget(target) {
2362
+ invariant(typeof target === "number" && !Number.isNaN(target), "Ramp target must be a number; use Infinity or -Infinity for an endless ramp");
2363
+ }
2364
+ function assertValue(value) {
2365
+ invariant(Number.isFinite(value), "Ramp value must be a finite number");
2366
+ }
2367
+ /**
2368
+ * A value that moves toward its target at a fixed speed, in a straight
2369
+ * line, and stops there. Create ramps with `SpringSystem.createRamp`.
2370
+ *
2371
+ * A ramp is a `SpringSource`, so a spring can follow it. With the
2372
+ * default `arrival`, the spring integrates against the ramp's steady
2373
+ * motion exactly, where a target assigned once per frame would reach it
2374
+ * as a staircase. With any other `arrival`, the spring holds the target
2375
+ * still within each of a frame's sub-steps, one stair per sub-step.
2376
+ * Typical uses are an endless spin, a heading that turns at a capped
2377
+ * speed, and a clock in ms mapped into a shape with `mapSpring`.
2378
+ *
2379
+ * ```ts
2380
+ * const system = createSpringSystem()
2381
+ * const sweep = system.createRamp(0, { rate: 270, target: Infinity })
2382
+ * const ring = system.createSpring(sweep, defineSpring({ duration: 300, dampingRatio: 1 }))
2383
+ *
2384
+ * system.advance(1000)
2385
+ * sweep.value // 270
2386
+ * ```
2387
+ *
2388
+ * A ramp costs nothing per frame unless something watches it: an
2389
+ * `onUpdate` listener, a following spring, or, on a bounded ramp, an
2390
+ * `onStop` listener or a pending `settled`. An unwatched ramp still
2391
+ * moves, and a read reports where it is.
2392
+ *
2393
+ * Ramps move on the system clock. It counts frame time, `advance` calls,
2394
+ * and the time a started system spends asleep, and stops while the
2395
+ * system is stopped. A sleeping system skips the time the page is
2396
+ * hidden. An awake system gets no frames in a hidden tab, so the hidden
2397
+ * spell counts as one frame on return: `adjustedLag` ms if it lasted
2398
+ * longer than `lagThreshold`, in full otherwise or with lag clamping off.
2399
+ *
2400
+ * The clock holds still through synchronous code, so every read in one
2401
+ * event handler or render sees the same moment. A ramp created and read
2402
+ * with no `await` in between reads its starting value.
2403
+ *
2404
+ * Under reduced motion, a `'motion'` ramp jumps to a bounded target, and
2405
+ * an endless one holds still until reduced motion turns off, then
2406
+ * resumes. A `'nonmotion'` ramp keeps moving.
2407
+ */
2408
+ var Ramp = class {
2409
+ /** Brands the ramp as a `TargetedSource` whose api is the ramp itself. */
2410
+ get [SpringSourceSymbol]() {
2411
+ registerBacking(this, this.#motion);
2412
+ return this;
2413
+ }
2414
+ #motion;
2415
+ #purpose;
2416
+ #settled = null;
2417
+ #resolveSettled = null;
2418
+ constructor(motions, from, options) {
2419
+ invariant(typeof options === "object" && options !== null, "createRamp needs options with a rate");
2420
+ const { rate, target = from, purpose = "motion" } = options;
2421
+ assertValue(from);
2422
+ assertRate(rate);
2423
+ assertTarget(target);
2424
+ invariant(purpose === "motion" || purpose === "nonmotion", "Ramp purpose must be 'motion' or 'nonmotion'");
2425
+ this.#purpose = purpose;
2426
+ this.#motion = new RampMotion(motions, from, rate, target, purpose);
2427
+ }
2428
+ /**
2429
+ * The current value.
2430
+ *
2431
+ * Assignment accepts a finite number. It moves the ramp there at once,
2432
+ * keeping its target and rate, and notifies update listeners
2433
+ * synchronously. Writing the
2434
+ * current value does nothing. Under reduced motion, a `'motion'` ramp
2435
+ * with a bounded target rests at the written value, which becomes its
2436
+ * target too.
2437
+ */
2438
+ get value() {
2439
+ return this.#motion.value;
2440
+ }
2441
+ set value(value) {
2442
+ assertValue(value);
2443
+ this.#motion.value = value;
2444
+ }
2445
+ /**
2446
+ * Where the ramp is heading, or `Infinity` or `-Infinity` for an
2447
+ * endless ramp.
2448
+ *
2449
+ * Assignment accepts any number but `NaN`, and sends the ramp toward the
2450
+ * new target at its current rate.
2451
+ * It emits `start` if the ramp was resting, and no `update`, since the
2452
+ * value has not moved. Under reduced motion, a `'motion'` ramp jumps to
2453
+ * a bounded target, notifying update listeners, and holds still toward
2454
+ * an endless one.
2455
+ */
2456
+ get target() {
2457
+ return this.#motion.target;
2458
+ }
2459
+ set target(target) {
2460
+ assertTarget(target);
2461
+ this.#motion.target = target;
2462
+ }
2463
+ /**
2464
+ * The ramp's speed, in value units per second.
2465
+ *
2466
+ * Assignment accepts any finite number >= 0 and applies from the
2467
+ * current value on. A rate of 0 rests the ramp where it is.
2468
+ */
2469
+ get rate() {
2470
+ return this.#motion.rate;
2471
+ }
2472
+ set rate(rate) {
2473
+ assertRate(rate);
2474
+ this.#motion.rate = rate;
2475
+ }
2476
+ /**
2477
+ * The current velocity, in value units per second. It is `rate`
2478
+ * signed toward the target while the ramp moves, and 0 at rest.
2479
+ */
2480
+ get velocity() {
2481
+ return this.#motion.velocity;
2482
+ }
2483
+ /** Always 0, since a ramp moves at a constant speed. */
2484
+ get acceleration() {
2485
+ return 0;
2486
+ }
2487
+ /**
2488
+ * What the ramp drives, `'motion'` or `'nonmotion'`, fixed when the
2489
+ * ramp was created. A `'nonmotion'` ramp ignores reduced motion. See
2490
+ * `Purpose`.
2491
+ */
2492
+ get purpose() {
2493
+ return this.#purpose;
2494
+ }
2495
+ /**
2496
+ * Whether the ramp is still: at its target, at a rate of 0, held under
2497
+ * reduced motion, or disposed.
2498
+ */
2499
+ get isResting() {
2500
+ return this.#motion.isResting;
2501
+ }
2502
+ /**
2503
+ * The time until the ramp reaches its target, in ms. It is 0 at rest,
2504
+ * and `Infinity` while the ramp moves toward an endless target.
2505
+ */
2506
+ get timeRemaining() {
2507
+ return this.#motion.timeRemaining;
2508
+ }
2509
+ /**
2510
+ * A promise that resolves when the ramp next comes to rest. Already
2511
+ * resolved while the ramp rests and after dispose. A pending
2512
+ * `settled` keeps a bounded ramp in the frame loop, so the promise
2513
+ * resolves on the frame the ramp arrives.
2514
+ *
2515
+ * ```ts
2516
+ * ramp.target = 100
2517
+ * await ramp.settled
2518
+ * // the ramp is at 100
2519
+ * ```
2520
+ */
2521
+ get settled() {
2522
+ if (this.#motion.disposed || this.#motion.isResting) return RESOLVED;
2523
+ this.#settled ??= new Promise((resolve) => {
2524
+ this.#resolveSettled = resolve;
2525
+ const unsubscribe = this.onStop(() => {
2526
+ unsubscribe();
2527
+ this.#settled = null;
2528
+ this.#resolveSettled = null;
2529
+ resolve();
2530
+ });
2531
+ });
2532
+ return this.#settled;
2533
+ }
2534
+ /**
2535
+ * Rests the ramp at `value`, which becomes both its value and target.
2536
+ * Notifies update listeners synchronously, and emits `stop` if the ramp
2537
+ * was moving.
2538
+ */
2539
+ jumpTo(value) {
2540
+ assertValue(value);
2541
+ this.#motion.jumpTo(value);
2542
+ }
2543
+ /**
2544
+ * Releases the ramp permanently. It stops where it is, `settled`
2545
+ * resolves, and dispose listeners run. Followers detach and keep their
2546
+ * current target. Calling it again does nothing.
2547
+ *
2548
+ * The final value stays readable, and writes throw.
2549
+ */
2550
+ dispose() {
2551
+ if (this.#motion.disposed) return;
2552
+ const resolve = this.#resolveSettled;
2553
+ this.#settled = null;
2554
+ this.#resolveSettled = null;
2555
+ this.#motion.dispose();
2556
+ resolve?.();
2557
+ }
2558
+ /**
2559
+ * Subscribes to value changes: every frame the ramp moves, plus
2560
+ * synchronous `value` writes, `jumpTo` calls, and reduced-motion jumps.
2561
+ * A subscribed listener keeps the ramp in the frame loop. Returns an
2562
+ * unsubscribe function.
2563
+ */
2564
+ onUpdate(callback) {
2565
+ return this.#motion.onUpdate(callback);
2566
+ }
2567
+ /**
2568
+ * Subscribes to the ramp leaving rest. `start` and `stop` alternate.
2569
+ * Returns an unsubscribe function.
2570
+ */
2571
+ onStart(callback) {
2572
+ return this.#motion.onStart(callback);
2573
+ }
2574
+ /**
2575
+ * Subscribes to the ramp coming to rest: arriving at its target,
2576
+ * `jumpTo`, a rate of 0, or holding under reduced motion. A subscribed
2577
+ * listener keeps a bounded ramp in the frame loop, so the event fires
2578
+ * on the frame it arrives. Returns an unsubscribe function.
2579
+ */
2580
+ onStop(callback) {
2581
+ return this.#motion.onStop(callback);
2582
+ }
2583
+ /** Subscribes to `dispose`, which fires once. Returns an unsubscribe function. */
2584
+ onDispose(callback) {
2585
+ return this.#motion.onDispose(callback);
2586
+ }
2587
+ };
2588
+ //#endregion
2589
+ //#region src/ticker.ts
2590
+ const request = (callback) => typeof window !== "undefined" ? window.requestAnimationFrame(callback) : setTimeout(() => callback(performance.now()), 16);
2591
+ const cancel = (id) => {
2592
+ if (typeof window !== "undefined") window.cancelAnimationFrame(id);
2593
+ else clearTimeout(id);
2594
+ };
2595
+ const now = () => performance.now();
2596
+ /**
2597
+ * Drives a `MotionSet` from animation frames, pacing fps caps to whole
2598
+ * display frames and clamping lag. It requests frames only while the set
2599
+ * has motions, and otherwise sleeps until the set's `onWake` rearms it.
2600
+ *
2601
+ * While started and asleep, it credits idle wall time to the set's clock,
2602
+ * less any time the document is hidden. An awake set gets no frames in a
2603
+ * hidden tab, so the gap arrives as one frame, clamped like any lag.
2604
+ *
2605
+ * Idle time reads the wall clock once per microtask checkpoint, so the
2606
+ * clock holds still through synchronous code, as it does between frames.
2607
+ */
2608
+ var Ticker = class {
2609
+ #fps;
2610
+ #gap;
2611
+ #capGap;
2612
+ #lagThreshold;
2613
+ #adjustedLag;
2614
+ /** Frame gaps longer than this count as lag: `#lagThreshold`, or Infinity when 0 disables clamping. */
2615
+ #lagLimit = 0;
2616
+ /** The ms a lagging frame steps: `#adjustedLag` clamped to `#lagThreshold`. */
2617
+ #lagStep = 0;
2618
+ #motions;
2619
+ #onVisibility = () => this.#onVisibilityChange();
2620
+ #frame = 0;
2621
+ #time = 0;
2622
+ #delta = 0;
2623
+ #acc = 0;
2624
+ #lastWallTime = 0;
2625
+ #primed = false;
2626
+ #id = 0;
2627
+ #stopped = true;
2628
+ #sleeping = false;
2629
+ /** Whether a frame callback is running. Its `finally` schedules the next frame. */
2630
+ #inFrame = false;
2631
+ /** Wall ms when the current idle spell began, or null outside one. */
2632
+ #idleSince = null;
2633
+ /** Hidden ms already closed out within the current idle spell. */
2634
+ #hiddenIdle = 0;
2635
+ /** Wall ms when the document was last hidden, or null while visible. */
2636
+ #hiddenSince = null;
2637
+ /** The wall ms synchronous code reads until the next microtask checkpoint, or null until it first reads. */
2638
+ #taskTime = null;
2639
+ /** The latest task time, so a frame never credits the clock back past a read. */
2640
+ #lastTaskTime = 0;
2641
+ constructor(motions, options) {
2642
+ this.#motions = motions;
2643
+ const fps = options?.fps ?? 0;
2644
+ const lagThreshold = options?.lagThreshold ?? 500;
2645
+ const adjustedLag = options?.adjustedLag ?? 33;
2646
+ invariant(fps >= 0, "FPS must be greater than or equal to 0");
2647
+ invariant(lagThreshold >= 0, "Lag threshold must be greater than or equal to 0");
2648
+ invariant(adjustedLag >= 0, "Adjusted lag must be greater than or equal to 0");
2649
+ this.#fps = fps;
2650
+ this.#gap = fps === 0 ? 1e3 / 60 : 1e3 / fps;
2651
+ this.#capGap = fps === 0 ? 0 : 1e3 / fps;
2652
+ this.#lagThreshold = lagThreshold;
2653
+ this.#adjustedLag = adjustedLag;
2654
+ this.#updateLag();
2655
+ motions.onWake = () => this.#wake();
2656
+ motions.idleTime = () => this.#idleSince === null ? 0 : this.#idleMs(this.#taskNow()) / 1e3;
2657
+ if (typeof document !== "undefined") {
2658
+ if (document.visibilityState === "hidden") this.#hiddenSince = this.#taskNow();
2659
+ document.addEventListener("visibilitychange", this.#onVisibility);
2660
+ }
2661
+ }
2662
+ get fps() {
2663
+ return this.#fps;
2664
+ }
2665
+ set fps(value) {
2666
+ invariant(value >= 0, "FPS must be greater than or equal to 0");
2667
+ this.#fps = value;
2668
+ this.#gap = value === 0 ? 1e3 / 60 : 1e3 / value;
2669
+ this.#capGap = value === 0 ? 0 : 1e3 / value;
2670
+ }
2671
+ get lagThreshold() {
2672
+ return this.#lagThreshold;
2673
+ }
2674
+ set lagThreshold(value) {
2675
+ invariant(value >= 0, "Lag threshold must be greater than or equal to 0");
2676
+ this.#lagThreshold = value;
2677
+ this.#updateLag();
2678
+ }
2679
+ get adjustedLag() {
2680
+ return this.#adjustedLag;
2681
+ }
2682
+ set adjustedLag(value) {
2683
+ invariant(value >= 0, "Adjusted lag must be greater than or equal to 0");
2684
+ this.#adjustedLag = value;
2685
+ this.#updateLag();
2686
+ }
2687
+ get time() {
2688
+ return this.#time;
2689
+ }
2690
+ get frame() {
2691
+ return this.#frame;
2692
+ }
2693
+ get delta() {
2694
+ return this.#delta;
2695
+ }
2696
+ get deltaRatio() {
2697
+ return this.#delta / this.#gap;
2698
+ }
2699
+ get stopped() {
2700
+ return this.#stopped;
2701
+ }
2702
+ start() {
2703
+ if (this.#stopped) {
2704
+ this.#stopped = false;
2705
+ this.#primed = false;
2706
+ this.#acc = 0;
2707
+ if (!this.#inFrame) this.#schedule(this.#taskNow());
2708
+ }
2709
+ }
2710
+ stop() {
2711
+ if (!this.#stopped) {
2712
+ if (!this.#sleeping) cancel(this.#id);
2713
+ this.#stopped = true;
2714
+ this.#sleeping = false;
2715
+ this.#creditIdle(this.#taskNow());
2716
+ }
2717
+ }
2718
+ tick() {
2719
+ this.#step(this.#gap);
2720
+ }
2721
+ /** Stops the ticker and removes its document listener. */
2722
+ dispose() {
2723
+ this.stop();
2724
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", this.#onVisibility);
2725
+ }
2726
+ #schedule(timestamp) {
2727
+ if (this.#motions.size === 0) {
2728
+ this.#sleeping = true;
2729
+ this.#acc = 0;
2730
+ if (this.#idleSince === null) {
2731
+ this.#idleSince = timestamp;
2732
+ this.#hiddenIdle = 0;
2733
+ }
2734
+ } else {
2735
+ this.#sleeping = false;
2736
+ this.#id = request((t) => this.#onFrame(t));
2737
+ }
2738
+ }
2739
+ #wake() {
2740
+ if (!this.#stopped && this.#sleeping) {
2741
+ this.#primed = false;
2742
+ this.#schedule(this.#taskNow());
2743
+ }
2744
+ }
2745
+ #onFrame(timestamp) {
2746
+ if (this.#stopped) return;
2747
+ this.#inFrame = true;
2748
+ try {
2749
+ if (this.#primed) {
2750
+ const wallElapsed = timestamp - this.#lastWallTime;
2751
+ const elapsed = wallElapsed > this.#lagLimit ? this.#lagStep : wallElapsed;
2752
+ this.#acc += elapsed;
2753
+ if (this.#acc + elapsed / 2 >= this.#capGap) {
2754
+ const delta = this.#acc;
2755
+ this.#acc = 0;
2756
+ this.#step(delta);
2757
+ }
2758
+ } else {
2759
+ this.#primed = true;
2760
+ this.#creditIdle(Math.max(timestamp, this.#lastTaskTime));
2761
+ }
2762
+ } finally {
2763
+ this.#lastWallTime = timestamp;
2764
+ this.#inFrame = false;
2765
+ if (!this.#stopped) this.#schedule(timestamp);
2766
+ }
2767
+ }
2768
+ #updateLag() {
2769
+ this.#lagLimit = this.#lagThreshold === 0 ? Number.POSITIVE_INFINITY : this.#lagThreshold;
2770
+ this.#lagStep = Math.min(this.#adjustedLag, this.#lagThreshold);
2771
+ }
2772
+ #idleMs(timestamp) {
2773
+ const since = this.#idleSince;
2774
+ if (since === null) return 0;
2775
+ let hidden = this.#hiddenIdle;
2776
+ if (this.#hiddenSince !== null) hidden += timestamp - Math.max(this.#hiddenSince, since);
2777
+ return Math.max(0, timestamp - since - hidden);
2778
+ }
2779
+ #taskNow() {
2780
+ if (this.#taskTime === null) {
2781
+ const time = now();
2782
+ this.#taskTime = time;
2783
+ this.#lastTaskTime = time;
2784
+ queueMicrotask(() => {
2785
+ this.#taskTime = null;
2786
+ });
2787
+ }
2788
+ return this.#taskTime;
2789
+ }
2790
+ #creditIdle(timestamp) {
2791
+ if (this.#idleSince === null) return;
2792
+ this.#motions.credit(this.#idleMs(timestamp) / 1e3);
2793
+ this.#idleSince = null;
2794
+ }
2795
+ #onVisibilityChange() {
2796
+ const timestamp = this.#taskNow();
2797
+ if (document.visibilityState === "hidden") this.#hiddenSince ??= timestamp;
2798
+ else if (this.#hiddenSince !== null) {
2799
+ if (this.#idleSince !== null) this.#hiddenIdle += Math.max(0, timestamp - Math.max(this.#hiddenSince, this.#idleSince));
2800
+ this.#hiddenSince = null;
2801
+ }
2802
+ }
2803
+ #step(dt) {
2804
+ this.#time += dt;
2805
+ this.#frame++;
2806
+ this.#delta = dt;
2807
+ this.#motions.tick(dt / 1e3);
2808
+ }
2809
+ };
2810
+ //#endregion
2811
+ //#region src/system.ts
2812
+ var SpringSystemImpl = class {
2813
+ #motions;
2814
+ #ticker;
2815
+ /** Removes the reduced-motion media query listener, or null without one. */
2816
+ #unwatchReducedMotion = null;
2817
+ #disposed = false;
2818
+ constructor(options) {
2819
+ this.#motions = new MotionSet(options?.debug);
2820
+ this.#ticker = new Ticker(this.#motions, options);
2821
+ if (options?.couplingTolerance !== void 0) this.couplingTolerance = options.couplingTolerance;
2822
+ const mode = options?.reducedMotion ?? "user";
2823
+ if (mode === "always") this.#motions.reduced = true;
2824
+ else if (mode === "user" && typeof window !== "undefined" && typeof window.matchMedia === "function") {
2825
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
2826
+ this.#applyReducedMotion(query.matches);
2827
+ const onChange = (event) => {
2828
+ this.#applyReducedMotion(event.matches);
2829
+ };
2830
+ query.addEventListener("change", onChange);
2831
+ this.#unwatchReducedMotion = () => query.removeEventListener("change", onChange);
2832
+ }
2833
+ }
2834
+ #applyReducedMotion(reduced) {
2835
+ this.#motions.setReduced(reduced);
2836
+ }
2837
+ get reducedMotion() {
2838
+ return this.#motions.reduced;
2839
+ }
2840
+ get couplingTolerance() {
2841
+ return this.#motions.couplingTolerance;
2842
+ }
2843
+ set couplingTolerance(value) {
2844
+ invariant(Number.isFinite(value) && value >= 0, "couplingTolerance must be a finite, non-negative number");
2845
+ this.#motions.couplingTolerance = value;
2846
+ }
2847
+ createSpring(value, config, options) {
2848
+ if (typeof value === "number") return new Spring(this.#motions, value, config, options?.purpose);
2849
+ if (isSpringSource(value)) {
2850
+ const api = value[SpringSourceSymbol];
2851
+ invariant(typeof api.value === "number", "A spring can only follow a scalar SpringSource; derive one from a composite with mapSpring");
2852
+ const spring = new Spring(this.#motions, api.value, config, options?.purpose);
2853
+ spring.target = value;
2854
+ return spring;
2855
+ }
2856
+ return new CompositeSpring(this.#motions, value, config, options?.purpose);
2857
+ }
2858
+ createRamp(from, options) {
2859
+ return new Ramp(this.#motions, from, options);
2860
+ }
2861
+ advance(dt) {
2862
+ invariant(Number.isFinite(dt) && dt >= 0, "dt must be a finite, non-negative number of milliseconds");
2863
+ this.#motions.tick(dt / 1e3);
2864
+ }
2865
+ start() {
2866
+ invariant(!this.#disposed, "Cannot start a disposed spring system");
2867
+ this.#ticker.start();
2868
+ }
2869
+ stop() {
2870
+ this.#ticker.stop();
2871
+ }
2872
+ get running() {
2873
+ return !this.#ticker.stopped;
2874
+ }
2875
+ dispose() {
2876
+ if (this.#disposed) return;
2877
+ this.#disposed = true;
2878
+ this.#ticker.dispose();
2879
+ this.#unwatchReducedMotion?.();
2880
+ this.#unwatchReducedMotion = null;
2881
+ }
2882
+ get fps() {
2883
+ return this.#ticker.fps;
2884
+ }
2885
+ set fps(value) {
2886
+ this.#ticker.fps = value;
2887
+ }
2888
+ get lagThreshold() {
2889
+ return this.#ticker.lagThreshold;
2890
+ }
2891
+ set lagThreshold(value) {
2892
+ this.#ticker.lagThreshold = value;
2893
+ }
2894
+ get adjustedLag() {
2895
+ return this.#ticker.adjustedLag;
2896
+ }
2897
+ set adjustedLag(value) {
2898
+ this.#ticker.adjustedLag = value;
2899
+ }
2900
+ };
2901
+ /**
2902
+ * Creates a spring system: the entry point of the library.
2903
+ *
2904
+ * @example
2905
+ * ```ts
2906
+ * const system = createSpringSystem()
2907
+ * system.start()
2908
+ *
2909
+ * const spring = system.createSpring(0)
2910
+ * spring.onUpdate(() => {
2911
+ * element.style.translate = `${spring.value}px 0`
2912
+ * })
2913
+ * spring.target = 300
2914
+ * ```
2915
+ */
2916
+ function createSpringSystem(options) {
2917
+ return new SpringSystemImpl(options);
2918
+ }
2919
+ //#endregion
2920
+ export { SpringSourceSymbol as a, registerRecipe as c, defineSpring as i, CompositeSpring as n, isSpringSource as o, SpringDefinition as r, mapSpring as s, createSpringSystem as t };