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