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