coily 0.13.1 → 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 +70 -40
- package/dist/index.d.ts +2 -2
- package/dist/index.js +78 -2
- package/dist/nuxt.d.ts +38 -0
- package/dist/nuxt.js +13 -14
- package/dist/{system-Csho09XW.js → system-BXxAHM2a.js} +786 -412
- package/dist/system-Ci-R4RaI.d.ts +1032 -0
- package/dist/vue.d.ts +226 -16
- package/dist/vue.js +84 -42
- package/package.json +1 -1
- package/dist/system-86fsXJN_.d.ts +0 -386
|
@@ -1,4 +1,10 @@
|
|
|
1
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
|
+
*/
|
|
2
8
|
var FlushQueue = class {
|
|
3
9
|
#callbacks = /* @__PURE__ */ new Set();
|
|
4
10
|
#depth = 0;
|
|
@@ -27,6 +33,11 @@ var FlushQueue = class {
|
|
|
27
33
|
};
|
|
28
34
|
//#endregion
|
|
29
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
|
+
*/
|
|
30
41
|
var MotionSet = class {
|
|
31
42
|
reduced = false;
|
|
32
43
|
onWake = null;
|
|
@@ -52,6 +63,7 @@ var MotionSet = class {
|
|
|
52
63
|
finishAll() {
|
|
53
64
|
this.flushes.batch(() => {
|
|
54
65
|
for (const motion of this.#motions) {
|
|
66
|
+
if (!motion.respectsReducedMotion) continue;
|
|
55
67
|
motion.finish();
|
|
56
68
|
this.#motions.delete(motion);
|
|
57
69
|
}
|
|
@@ -83,6 +95,11 @@ function invariant(condition, message) {
|
|
|
83
95
|
const resolved = typeof message === "function" ? message() : message;
|
|
84
96
|
throw new Error(resolved ?? "Invariant Failed");
|
|
85
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
|
+
*/
|
|
86
103
|
function isRecord(value) {
|
|
87
104
|
if (typeof value !== "object" || value === null) return false;
|
|
88
105
|
const proto = Object.getPrototypeOf(value);
|
|
@@ -97,23 +114,39 @@ function isArray(value) {
|
|
|
97
114
|
function isNumber(value) {
|
|
98
115
|
return typeof value === "number";
|
|
99
116
|
}
|
|
117
|
+
const RESOLVED = Promise.resolve();
|
|
100
118
|
//#endregion
|
|
101
119
|
//#region src/config.ts
|
|
102
120
|
/**
|
|
103
|
-
* Immutable spring
|
|
104
|
-
*
|
|
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`.
|
|
105
126
|
*/
|
|
106
|
-
var
|
|
107
|
-
|
|
127
|
+
var SpringDefinition = class SpringDefinition {
|
|
128
|
+
/** The config for springs created without one: critically damped, settling in about 500ms. */
|
|
129
|
+
static default = new SpringDefinition({
|
|
108
130
|
dampingRatio: 1,
|
|
109
131
|
duration: 500
|
|
110
132
|
});
|
|
133
|
+
/** Mass of the moving value, as provided or derived. */
|
|
111
134
|
mass;
|
|
135
|
+
/** Spring stiffness, as provided or derived. */
|
|
112
136
|
tension;
|
|
137
|
+
/** Friction strength, as provided or derived. */
|
|
113
138
|
damping;
|
|
139
|
+
/** Decimal places of the resting threshold — see `restingMagnitude`. */
|
|
114
140
|
precision;
|
|
141
|
+
/** Oscillation rate with no damping: `sqrt(tension / mass)`, in radians per second. */
|
|
115
142
|
naturalFrequency;
|
|
143
|
+
/** The damping at which bounce disappears: `2 * mass * naturalFrequency`. */
|
|
116
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
|
+
*/
|
|
117
150
|
dampingRatio;
|
|
118
151
|
/** Resting threshold in value units: half a unit in the last `precision` place. */
|
|
119
152
|
restingMagnitude;
|
|
@@ -126,6 +159,10 @@ var SpringConfig = class SpringConfig {
|
|
|
126
159
|
const duration = raw.duration !== void 0 ? raw.duration / 1e3 : void 0;
|
|
127
160
|
const displacement = raw.displacement ?? 1;
|
|
128
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
|
+
}
|
|
129
166
|
if (mass !== void 0) invariant(mass > 0, "Mass must be greater than 0");
|
|
130
167
|
if (tension !== void 0) invariant(tension > 0, "Tension must be greater than 0");
|
|
131
168
|
if (damping !== void 0) invariant(damping >= 0, "Damping must be greater than or equal to 0");
|
|
@@ -194,23 +231,13 @@ var SpringConfig = class SpringConfig {
|
|
|
194
231
|
Object.freeze(this);
|
|
195
232
|
}
|
|
196
233
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* Uses the exponential decay envelope common to all three damping regimes.
|
|
201
|
-
* The decay rate depends on the regime:
|
|
202
|
-
* - Underdamped (ζ < 1): σ = ζωₙ
|
|
203
|
-
* - Critically damped (ζ = 1): σ = ωₙ
|
|
204
|
-
* - Overdamped (ζ > 1): σ = (ζ - √(ζ²-1))ωₙ (the slower eigenvalue)
|
|
234
|
+
* Estimates how long a spring with this config takes to come to rest
|
|
235
|
+
* from `state`, in milliseconds.
|
|
205
236
|
*
|
|
206
|
-
* The
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* Settling time: t = ln(A₀ / threshold) / σ, with a 2× safety factor
|
|
211
|
-
* to account for polynomial terms in the critically damped solution.
|
|
212
|
-
*
|
|
213
|
-
* Returns the estimated time in milliseconds.
|
|
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).
|
|
214
241
|
*/
|
|
215
242
|
computeTimeRemaining(state) {
|
|
216
243
|
const { position, velocity } = state;
|
|
@@ -225,10 +252,11 @@ var SpringConfig = class SpringConfig {
|
|
|
225
252
|
}
|
|
226
253
|
};
|
|
227
254
|
function defineSpring(input) {
|
|
228
|
-
return new
|
|
255
|
+
return new SpringDefinition(input);
|
|
229
256
|
}
|
|
230
257
|
//#endregion
|
|
231
258
|
//#region src/emitter.ts
|
|
259
|
+
/** Minimal fixed-event emitter behind motions and composite springs. */
|
|
232
260
|
var Emitter = class {
|
|
233
261
|
#update = [];
|
|
234
262
|
#start = [];
|
|
@@ -267,6 +295,10 @@ var Emitter = class {
|
|
|
267
295
|
};
|
|
268
296
|
//#endregion
|
|
269
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
|
+
*/
|
|
270
302
|
var State = class {
|
|
271
303
|
#config;
|
|
272
304
|
#position;
|
|
@@ -291,12 +323,18 @@ var State = class {
|
|
|
291
323
|
set velocity(value) {
|
|
292
324
|
this.#velocity = value;
|
|
293
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
|
+
*/
|
|
294
331
|
get isResting() {
|
|
295
332
|
return Math.abs(this.#position) + Math.abs(this.#velocity) / this.#config.naturalFrequency <= this.#config.restingMagnitude;
|
|
296
333
|
}
|
|
297
334
|
};
|
|
298
335
|
//#endregion
|
|
299
336
|
//#region src/solver.ts
|
|
337
|
+
/** dampingRatio < 1: decaying oscillation, x(t) = exp(-sigma*t) * (c1*cos(wd*t) + c2*sin(wd*t)). */
|
|
300
338
|
var UnderdampedSolver = class {
|
|
301
339
|
#state;
|
|
302
340
|
#dampedFrequency = 0;
|
|
@@ -328,6 +366,7 @@ var UnderdampedSolver = class {
|
|
|
328
366
|
this.#state.velocity = decay * oscillationVelocity + decayVelocity * oscillation;
|
|
329
367
|
}
|
|
330
368
|
};
|
|
369
|
+
/** dampingRatio = 1: fastest non-oscillating decay, x(t) = (c1 + c2*t) * exp(-wn*t). */
|
|
331
370
|
var CriticallyDampedSolver = class {
|
|
332
371
|
#state;
|
|
333
372
|
#naturalFrequency = 0;
|
|
@@ -353,6 +392,7 @@ var CriticallyDampedSolver = class {
|
|
|
353
392
|
this.#state.velocity = scaleVelocity * decay + decayVelocity * scale;
|
|
354
393
|
}
|
|
355
394
|
};
|
|
395
|
+
/** dampingRatio > 1: slow non-oscillating decay, x(t) = exp(-sigma*t) * (c1*sinh(wd*t) + c2*cosh(wd*t)) / wd. */
|
|
356
396
|
var OverdampedSolver = class {
|
|
357
397
|
#state;
|
|
358
398
|
#dampedFrequency = 0;
|
|
@@ -387,8 +427,16 @@ var OverdampedSolver = class {
|
|
|
387
427
|
};
|
|
388
428
|
//#endregion
|
|
389
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
|
+
*/
|
|
390
436
|
var Motion = class {
|
|
437
|
+
/** Tick-pass marker written by `MotionSet` so a motion re-added mid-pass isn't ticked twice. */
|
|
391
438
|
_pass = 0;
|
|
439
|
+
respectsReducedMotion = true;
|
|
392
440
|
#config;
|
|
393
441
|
#state;
|
|
394
442
|
#underdampedSolver = null;
|
|
@@ -513,223 +561,7 @@ var Motion = class {
|
|
|
513
561
|
}
|
|
514
562
|
};
|
|
515
563
|
//#endregion
|
|
516
|
-
//#region src/
|
|
517
|
-
function normalizeTarget(target) {
|
|
518
|
-
if (target instanceof Spring) return {
|
|
519
|
-
spring: target,
|
|
520
|
-
offset: 0
|
|
521
|
-
};
|
|
522
|
-
if (typeof target === "object" && target !== null && "spring" in target && target.spring instanceof Spring) return {
|
|
523
|
-
spring: target.spring,
|
|
524
|
-
offset: target.offset ?? 0
|
|
525
|
-
};
|
|
526
|
-
return null;
|
|
527
|
-
}
|
|
528
|
-
const RESOLVED$1 = Promise.resolve();
|
|
529
|
-
var Spring = class {
|
|
530
|
-
#target;
|
|
531
|
-
#override;
|
|
532
|
-
#resolved;
|
|
533
|
-
#motion;
|
|
534
|
-
#motions;
|
|
535
|
-
#followers = /* @__PURE__ */ new Set();
|
|
536
|
-
#leader = null;
|
|
537
|
-
#offset = 0;
|
|
538
|
-
#unsubLeader = null;
|
|
539
|
-
#settled = null;
|
|
540
|
-
#resolveSettled = null;
|
|
541
|
-
#disposed = false;
|
|
542
|
-
constructor(motions, position, config) {
|
|
543
|
-
this.#motions = motions;
|
|
544
|
-
let numericTarget;
|
|
545
|
-
let value;
|
|
546
|
-
let normalized = null;
|
|
547
|
-
if (typeof position === "number") {
|
|
548
|
-
numericTarget = position;
|
|
549
|
-
value = position;
|
|
550
|
-
} else {
|
|
551
|
-
normalized = normalizeTarget(position.target);
|
|
552
|
-
if (normalized) {
|
|
553
|
-
numericTarget = normalized.spring.value + normalized.offset;
|
|
554
|
-
value = position.value ?? numericTarget;
|
|
555
|
-
} else {
|
|
556
|
-
numericTarget = position.target ?? position.value ?? 0;
|
|
557
|
-
value = position.value ?? numericTarget;
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
if (motions.reduced) value = numericTarget;
|
|
561
|
-
this.#override = config ?? null;
|
|
562
|
-
this.#resolved = config ?? (normalized ? normalized.spring.#resolved : SpringConfig.default);
|
|
563
|
-
this.#target = numericTarget;
|
|
564
|
-
this.#motion = new Motion(this.#resolved, value - numericTarget, 0);
|
|
565
|
-
if (!this.#motion.isResting) this.#motions.add(this.#motion);
|
|
566
|
-
if (normalized) {
|
|
567
|
-
this.#leader = normalized.spring;
|
|
568
|
-
this.#offset = normalized.offset;
|
|
569
|
-
normalized.spring.#followers.add(this);
|
|
570
|
-
this.#unsubLeader = normalized.spring.onUpdate(() => {
|
|
571
|
-
this.#setTarget(this.#leader.value + this.#offset);
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
get target() {
|
|
576
|
-
return this.#target;
|
|
577
|
-
}
|
|
578
|
-
set target(value) {
|
|
579
|
-
if (typeof value === "number") {
|
|
580
|
-
this.#unfollow();
|
|
581
|
-
this.#setTarget(value);
|
|
582
|
-
} else {
|
|
583
|
-
const normalized = normalizeTarget(value);
|
|
584
|
-
this.#follow(normalized.spring, normalized.offset);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
get value() {
|
|
588
|
-
return this.#target + this.#motion.position;
|
|
589
|
-
}
|
|
590
|
-
set value(value) {
|
|
591
|
-
if (this.#motions.reduced) {
|
|
592
|
-
if (value !== this.value) this.jumpTo(value);
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
const position = value - this.#target;
|
|
596
|
-
if (position !== this.#motion.position) {
|
|
597
|
-
this.#motions.add(this.#motion);
|
|
598
|
-
this.#motion.position = position;
|
|
599
|
-
this.#motion.tick(0);
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
get velocity() {
|
|
603
|
-
return this.#motion.velocity;
|
|
604
|
-
}
|
|
605
|
-
set velocity(value) {
|
|
606
|
-
if (this.#motions.reduced) return;
|
|
607
|
-
this.#motions.add(this.#motion);
|
|
608
|
-
this.#motion.velocity = value;
|
|
609
|
-
}
|
|
610
|
-
get config() {
|
|
611
|
-
return this.#resolved;
|
|
612
|
-
}
|
|
613
|
-
get mass() {
|
|
614
|
-
return this.#resolved.mass;
|
|
615
|
-
}
|
|
616
|
-
get tension() {
|
|
617
|
-
return this.#resolved.tension;
|
|
618
|
-
}
|
|
619
|
-
get damping() {
|
|
620
|
-
return this.#resolved.damping;
|
|
621
|
-
}
|
|
622
|
-
get dampingRatio() {
|
|
623
|
-
return this.#resolved.dampingRatio;
|
|
624
|
-
}
|
|
625
|
-
get precision() {
|
|
626
|
-
return this.#resolved.precision;
|
|
627
|
-
}
|
|
628
|
-
set config(value) {
|
|
629
|
-
this.#override = value;
|
|
630
|
-
this.#applyConfig(value ?? (this.#leader ? this.#leader.#resolved : SpringConfig.default));
|
|
631
|
-
}
|
|
632
|
-
get timeRemaining() {
|
|
633
|
-
return this.#motion.timeRemaining;
|
|
634
|
-
}
|
|
635
|
-
get isResting() {
|
|
636
|
-
return this.#motion.isResting;
|
|
637
|
-
}
|
|
638
|
-
get settled() {
|
|
639
|
-
if (this.#disposed || this.#motion.isResting) return RESOLVED$1;
|
|
640
|
-
this.#settled ??= new Promise((resolve) => {
|
|
641
|
-
this.#resolveSettled = resolve;
|
|
642
|
-
const unsubscribe = this.onStop(() => {
|
|
643
|
-
unsubscribe();
|
|
644
|
-
this.#settled = null;
|
|
645
|
-
this.#resolveSettled = null;
|
|
646
|
-
resolve();
|
|
647
|
-
});
|
|
648
|
-
});
|
|
649
|
-
return this.#settled;
|
|
650
|
-
}
|
|
651
|
-
jumpTo(value) {
|
|
652
|
-
this.#target = value;
|
|
653
|
-
this.#motion.finish();
|
|
654
|
-
}
|
|
655
|
-
dispose() {
|
|
656
|
-
if (this.#disposed) return;
|
|
657
|
-
this.#disposed = true;
|
|
658
|
-
if (this.#resolveSettled) {
|
|
659
|
-
this.#resolveSettled();
|
|
660
|
-
this.#settled = null;
|
|
661
|
-
this.#resolveSettled = null;
|
|
662
|
-
}
|
|
663
|
-
if (this.#unsubLeader) {
|
|
664
|
-
this.#unsubLeader();
|
|
665
|
-
this.#unsubLeader = null;
|
|
666
|
-
}
|
|
667
|
-
if (this.#leader) {
|
|
668
|
-
this.#leader.#followers.delete(this);
|
|
669
|
-
this.#leader = null;
|
|
670
|
-
}
|
|
671
|
-
for (const follower of this.#followers) follower.#unfollow();
|
|
672
|
-
this.#motions.remove(this.#motion);
|
|
673
|
-
this.#motion.dispose();
|
|
674
|
-
}
|
|
675
|
-
onUpdate(callback) {
|
|
676
|
-
return this.#motion.onUpdate(callback);
|
|
677
|
-
}
|
|
678
|
-
onStart(callback) {
|
|
679
|
-
return this.#motion.onStart(callback);
|
|
680
|
-
}
|
|
681
|
-
onStop(callback) {
|
|
682
|
-
return this.#motion.onStop(callback);
|
|
683
|
-
}
|
|
684
|
-
onDispose(callback) {
|
|
685
|
-
return this.#motion.onDispose(callback);
|
|
686
|
-
}
|
|
687
|
-
#setTarget(value) {
|
|
688
|
-
if (value !== this.#target) {
|
|
689
|
-
if (this.#motions.reduced) {
|
|
690
|
-
this.jumpTo(value);
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
this.#motions.add(this.#motion);
|
|
694
|
-
const current = this.value;
|
|
695
|
-
this.#target = value;
|
|
696
|
-
this.#motion.position = current - this.#target;
|
|
697
|
-
this.#motion.tick(0, false);
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
#follow(leader, offset) {
|
|
701
|
-
if (this.#unsubLeader) {
|
|
702
|
-
this.#unsubLeader();
|
|
703
|
-
this.#leader.#followers.delete(this);
|
|
704
|
-
}
|
|
705
|
-
this.#leader = leader;
|
|
706
|
-
this.#offset = offset;
|
|
707
|
-
leader.#followers.add(this);
|
|
708
|
-
if (this.#override === null) this.#applyConfig(leader.#resolved);
|
|
709
|
-
this.#unsubLeader = leader.onUpdate(() => {
|
|
710
|
-
this.#setTarget(this.#leader.value + this.#offset);
|
|
711
|
-
});
|
|
712
|
-
this.#setTarget(leader.value + offset);
|
|
713
|
-
}
|
|
714
|
-
#unfollow() {
|
|
715
|
-
if (!this.#leader) return;
|
|
716
|
-
this.#unsubLeader();
|
|
717
|
-
this.#unsubLeader = null;
|
|
718
|
-
this.#leader.#followers.delete(this);
|
|
719
|
-
this.#leader = null;
|
|
720
|
-
this.#offset = 0;
|
|
721
|
-
if (this.#override === null) this.#override = this.#resolved;
|
|
722
|
-
}
|
|
723
|
-
#applyConfig(next) {
|
|
724
|
-
if (this.#resolved === next) return;
|
|
725
|
-
this.#resolved = next;
|
|
726
|
-
this.#motion.configure(next);
|
|
727
|
-
if (!this.#motion.isResting) this.#motions.add(this.#motion);
|
|
728
|
-
for (const follower of this.#followers) if (follower.#override === null) follower.#applyConfig(next);
|
|
729
|
-
}
|
|
730
|
-
};
|
|
731
|
-
//#endregion
|
|
732
|
-
//#region src/channel-tree-node.ts
|
|
564
|
+
//#region src/shape-tree.ts
|
|
733
565
|
function joinPath(path, key) {
|
|
734
566
|
return path ? `${path}.${key}` : String(key);
|
|
735
567
|
}
|
|
@@ -738,48 +570,32 @@ function describePath(path) {
|
|
|
738
570
|
}
|
|
739
571
|
/** The sentinel a resolver returns to descend into a node instead of covering it. */
|
|
740
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
|
+
}
|
|
741
578
|
/**
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
* without inspecting nodes; errors keep their dot paths because each node
|
|
746
|
-
* stores the `path` it was built at.
|
|
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.
|
|
747
582
|
*/
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
return new ListNode(shape.map((child, i) => ChannelTreeNode.#child(child, joinPath(path, i), factory, leaves)), path);
|
|
759
|
-
}
|
|
760
|
-
const keys = Object.keys(shape);
|
|
761
|
-
invariant(keys.length > 0, () => emptyShape(path));
|
|
762
|
-
const children = /* @__PURE__ */ new Map();
|
|
763
|
-
for (const key of keys) children.set(key, ChannelTreeNode.#child(shape[key], joinPath(path, key), factory, leaves));
|
|
764
|
-
return new RecordNode(children, path);
|
|
765
|
-
}
|
|
766
|
-
static #child(value, path, factory, leaves) {
|
|
767
|
-
if (isNumber(value)) {
|
|
768
|
-
const leaf = factory(value, path);
|
|
769
|
-
leaves.push(leaf);
|
|
770
|
-
return new LeafNode(leaf, path);
|
|
771
|
-
}
|
|
772
|
-
invariant(isRecordOrArray(value), `Invalid value at '${path}': expected a number or a nested shape of numbers`);
|
|
773
|
-
return ChannelTreeNode.build(value, path, factory, leaves);
|
|
774
|
-
}
|
|
775
|
-
};
|
|
776
|
-
function emptyShape(path) {
|
|
777
|
-
return `Invalid value at ${describePath(path)}: a shape must contain at least one channel`;
|
|
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
|
+
};
|
|
778
593
|
}
|
|
779
|
-
var LeafNode = class LeafNode
|
|
594
|
+
var LeafNode = class LeafNode {
|
|
595
|
+
path;
|
|
780
596
|
leaf;
|
|
781
597
|
constructor(leaf, path) {
|
|
782
|
-
|
|
598
|
+
this.path = path;
|
|
783
599
|
this.leaf = leaf;
|
|
784
600
|
}
|
|
785
601
|
mirror(read, slots, carrier, key) {
|
|
@@ -790,18 +606,12 @@ var LeafNode = class LeafNode extends ChannelTreeNode {
|
|
|
790
606
|
carrier
|
|
791
607
|
});
|
|
792
608
|
}
|
|
793
|
-
scatter(input, apply) {
|
|
794
|
-
|
|
795
|
-
apply(this.leaf, input);
|
|
609
|
+
scatter(input, accept, apply) {
|
|
610
|
+
apply(this.leaf, accept(input, this.path));
|
|
796
611
|
}
|
|
797
|
-
zip(theirs,
|
|
612
|
+
zip(theirs, fn) {
|
|
798
613
|
invariant(theirs instanceof LeafNode, () => `Shape mismatch at ${describePath(this.path)}`);
|
|
799
|
-
|
|
800
|
-
if (values !== void 0) {
|
|
801
|
-
invariant(isNumber(values), `Expected a number at '${this.path}' in ${label}`);
|
|
802
|
-
value = values;
|
|
803
|
-
}
|
|
804
|
-
fn(this.leaf, theirs.leaf, value, this.path);
|
|
614
|
+
fn(this.leaf, theirs.leaf);
|
|
805
615
|
}
|
|
806
616
|
broadcast(input, resolve, apply, _label) {
|
|
807
617
|
const resolved = resolve(input, this.path);
|
|
@@ -812,10 +622,11 @@ var LeafNode = class LeafNode extends ChannelTreeNode {
|
|
|
812
622
|
apply(this.leaf, value);
|
|
813
623
|
}
|
|
814
624
|
};
|
|
815
|
-
var ListNode = class ListNode
|
|
625
|
+
var ListNode = class ListNode {
|
|
626
|
+
path;
|
|
816
627
|
children;
|
|
817
628
|
constructor(children, path) {
|
|
818
|
-
|
|
629
|
+
this.path = path;
|
|
819
630
|
this.children = children;
|
|
820
631
|
}
|
|
821
632
|
mirror(read, slots, carrier, key) {
|
|
@@ -824,23 +635,17 @@ var ListNode = class ListNode extends ChannelTreeNode {
|
|
|
824
635
|
const slotcarrier = mine;
|
|
825
636
|
for (let i = 0; i < this.children.length; i++) this.children[i].mirror(read, slots, slotcarrier, i);
|
|
826
637
|
}
|
|
827
|
-
scatter(input, apply) {
|
|
638
|
+
scatter(input, accept, apply) {
|
|
828
639
|
invariant(isArray(input), () => `Expected an array at ${describePath(this.path)}`);
|
|
829
640
|
invariant(input.length <= this.children.length, `Unknown channel '${joinPath(this.path, this.children.length)}'`);
|
|
830
641
|
for (let i = 0; i < input.length; i++) {
|
|
831
642
|
if (input[i] === void 0) continue;
|
|
832
|
-
this.children[i].scatter(input[i], apply);
|
|
643
|
+
this.children[i].scatter(input[i], accept, apply);
|
|
833
644
|
}
|
|
834
645
|
}
|
|
835
|
-
zip(theirs,
|
|
646
|
+
zip(theirs, fn) {
|
|
836
647
|
invariant(theirs instanceof ListNode && theirs.children.length === this.children.length, () => `Shape mismatch at ${describePath(this.path)}`);
|
|
837
|
-
let
|
|
838
|
-
if (values !== void 0) {
|
|
839
|
-
invariant(isArray(values), `Expected an array at ${describePath(this.path)} in ${label}`);
|
|
840
|
-
invariant(values.length <= this.children.length, `Unknown channel '${joinPath(this.path, this.children.length)}' in ${label}`);
|
|
841
|
-
list = values;
|
|
842
|
-
}
|
|
843
|
-
for (let i = 0; i < this.children.length; i++) this.children[i].zip(theirs.children[i], list?.[i], label, fn);
|
|
648
|
+
for (let i = 0; i < this.children.length; i++) this.children[i].zip(theirs.children[i], fn);
|
|
844
649
|
}
|
|
845
650
|
broadcast(input, resolve, apply, label) {
|
|
846
651
|
const resolved = resolve(input, this.path);
|
|
@@ -859,10 +664,11 @@ var ListNode = class ListNode extends ChannelTreeNode {
|
|
|
859
664
|
for (const child of this.children) child.cover(value, apply);
|
|
860
665
|
}
|
|
861
666
|
};
|
|
862
|
-
var RecordNode = class RecordNode
|
|
667
|
+
var RecordNode = class RecordNode {
|
|
668
|
+
path;
|
|
863
669
|
children;
|
|
864
670
|
constructor(children, path) {
|
|
865
|
-
|
|
671
|
+
this.path = path;
|
|
866
672
|
this.children = children;
|
|
867
673
|
}
|
|
868
674
|
mirror(read, slots, carrier, key) {
|
|
@@ -870,28 +676,22 @@ var RecordNode = class RecordNode extends ChannelTreeNode {
|
|
|
870
676
|
carrier[key] = mine;
|
|
871
677
|
for (const [childKey, child] of this.children) child.mirror(read, slots, mine, childKey);
|
|
872
678
|
}
|
|
873
|
-
scatter(input, apply) {
|
|
679
|
+
scatter(input, accept, apply) {
|
|
874
680
|
invariant(isRecord(input), () => `Expected an object at ${describePath(this.path)}`);
|
|
875
681
|
for (const key of Object.keys(input)) {
|
|
876
682
|
const value = input[key];
|
|
877
683
|
if (value === void 0) continue;
|
|
878
684
|
const child = this.children.get(key);
|
|
879
685
|
invariant(child !== void 0, () => `Unknown channel '${joinPath(this.path, key)}'`);
|
|
880
|
-
child.scatter(value, apply);
|
|
686
|
+
child.scatter(value, accept, apply);
|
|
881
687
|
}
|
|
882
688
|
}
|
|
883
|
-
zip(theirs,
|
|
689
|
+
zip(theirs, fn) {
|
|
884
690
|
invariant(theirs instanceof RecordNode && theirs.children.size === this.children.size, () => `Shape mismatch at ${describePath(this.path)}`);
|
|
885
|
-
let record;
|
|
886
|
-
if (values !== void 0) {
|
|
887
|
-
invariant(isRecord(values), () => `Expected an object at ${describePath(this.path)} in ${label}`);
|
|
888
|
-
for (const key of Object.keys(values)) invariant(this.children.has(key), () => `Unknown channel '${joinPath(this.path, key)}' in ${label}`);
|
|
889
|
-
record = values;
|
|
890
|
-
}
|
|
891
691
|
for (const [key, child] of this.children) {
|
|
892
692
|
const their = theirs.children.get(key);
|
|
893
693
|
invariant(their !== void 0, () => `Shape mismatch at ${describePath(this.path)}`);
|
|
894
|
-
child.zip(their,
|
|
694
|
+
child.zip(their, fn);
|
|
895
695
|
}
|
|
896
696
|
}
|
|
897
697
|
broadcast(input, resolve, apply, label) {
|
|
@@ -914,11 +714,50 @@ var RecordNode = class RecordNode extends ChannelTreeNode {
|
|
|
914
714
|
}
|
|
915
715
|
};
|
|
916
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
|
+
/**
|
|
917
756
|
* A live view of a shape: `read(leaf)` at every leaf, mirrored into a stable
|
|
918
|
-
* object — `refresh` rewrites the
|
|
757
|
+
* object — `refresh` rewrites the values in place through slots recorded at
|
|
919
758
|
* construction, so per-frame reads do no traversal or allocation.
|
|
920
759
|
*/
|
|
921
|
-
var
|
|
760
|
+
var ShapeView = class {
|
|
922
761
|
root;
|
|
923
762
|
#read;
|
|
924
763
|
#slots = [];
|
|
@@ -934,83 +773,463 @@ var ChannelView = class {
|
|
|
934
773
|
}
|
|
935
774
|
};
|
|
936
775
|
//#endregion
|
|
937
|
-
//#region src/
|
|
776
|
+
//#region src/spring-source.ts
|
|
938
777
|
/**
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
* broadcast are all structural; leaf semantics
|
|
944
|
-
* live with the caller. Paths appear as dot-joined strings (`position.x`,
|
|
945
|
-
* `color.2`) in every error.
|
|
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.
|
|
946
782
|
*/
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
+
};
|
|
953
851
|
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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";
|
|
957
919
|
}
|
|
958
920
|
/**
|
|
959
|
-
*
|
|
960
|
-
*
|
|
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.
|
|
961
933
|
*/
|
|
962
|
-
|
|
963
|
-
return
|
|
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
|
+
}
|
|
964
946
|
}
|
|
965
947
|
/**
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
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.
|
|
969
956
|
*/
|
|
970
|
-
|
|
971
|
-
this.#
|
|
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;
|
|
972
989
|
}
|
|
973
990
|
/**
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
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.
|
|
978
996
|
*/
|
|
979
|
-
|
|
980
|
-
|
|
997
|
+
get acceleration() {
|
|
998
|
+
const { tension, damping, mass } = this.#config;
|
|
999
|
+
return -(tension * this.#motion.position + damping * this.#motion.velocity) / mass;
|
|
981
1000
|
}
|
|
982
1001
|
/**
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
* skipped, unknown channels (labelled `label` in errors) throw.
|
|
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`.
|
|
988
1006
|
*/
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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;
|
|
992
1188
|
}
|
|
993
1189
|
};
|
|
994
1190
|
//#endregion
|
|
995
|
-
//#region src/spring
|
|
1191
|
+
//#region src/composite-spring.ts
|
|
996
1192
|
function invalidConfig(path) {
|
|
997
|
-
return `Invalid config for ${describePath(path)}: expected a
|
|
1193
|
+
return `Invalid config for ${describePath(path)}: expected a SpringDefinition, null, or a config shape matching the value`;
|
|
998
1194
|
}
|
|
999
1195
|
/**
|
|
1000
|
-
* Decides what a config node means: `null` and `
|
|
1196
|
+
* Decides what a config node means: `null` and `SpringDefinition` instances are
|
|
1001
1197
|
* configs covering the whole subtree, any other object or array is a config
|
|
1002
1198
|
* shape to descend into (its structure is checked on the way down), and
|
|
1003
1199
|
* anything else — a bare number, string, or options object — is invalid.
|
|
1004
1200
|
*/
|
|
1005
1201
|
function resolveConfigNode(node, path) {
|
|
1006
1202
|
if (node === null) return null;
|
|
1007
|
-
if (node instanceof
|
|
1203
|
+
if (node instanceof SpringDefinition) return node;
|
|
1008
1204
|
if (isRecordOrArray(node)) return BRANCH;
|
|
1009
1205
|
throw new Error(invalidConfig(path));
|
|
1010
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
|
+
}
|
|
1011
1220
|
const readTarget = (spring) => spring.target;
|
|
1012
1221
|
const readValue = (spring) => spring.value;
|
|
1013
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
|
+
}
|
|
1014
1233
|
const assignTarget = (spring, value) => {
|
|
1015
1234
|
spring.target = value;
|
|
1016
1235
|
};
|
|
@@ -1026,19 +1245,37 @@ const applyJump = (spring, value) => {
|
|
|
1026
1245
|
const assignConfig = (spring, config) => {
|
|
1027
1246
|
spring.config = config;
|
|
1028
1247
|
};
|
|
1029
|
-
const followChannel = (mine, theirs
|
|
1030
|
-
mine.target =
|
|
1031
|
-
spring: theirs,
|
|
1032
|
-
offset
|
|
1033
|
-
} : theirs;
|
|
1248
|
+
const followChannel = (mine, theirs) => {
|
|
1249
|
+
mine.target = theirs;
|
|
1034
1250
|
};
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
+
}
|
|
1037
1273
|
#motions;
|
|
1038
|
-
#
|
|
1039
|
-
#targetView;
|
|
1274
|
+
#tree;
|
|
1040
1275
|
#valueView;
|
|
1041
|
-
#
|
|
1276
|
+
#targetView = null;
|
|
1277
|
+
#velocityView = null;
|
|
1278
|
+
#accelerationView = null;
|
|
1042
1279
|
#emitter = new Emitter();
|
|
1043
1280
|
#running = false;
|
|
1044
1281
|
#dirty = false;
|
|
@@ -1061,14 +1298,18 @@ var SpringObject = class SpringObject {
|
|
|
1061
1298
|
this.#emitter.emit("start");
|
|
1062
1299
|
}
|
|
1063
1300
|
};
|
|
1064
|
-
constructor(motions, value, config) {
|
|
1301
|
+
constructor(motions, value, config, purpose) {
|
|
1065
1302
|
this.#motions = motions;
|
|
1066
|
-
invariant(isRecordOrArray(value), "
|
|
1067
|
-
|
|
1068
|
-
if (
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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);
|
|
1072
1313
|
const markDirty = () => {
|
|
1073
1314
|
this.#dirty = true;
|
|
1074
1315
|
this.#motions.flushes.request(this.#flush);
|
|
@@ -1076,66 +1317,141 @@ var SpringObject = class SpringObject {
|
|
|
1076
1317
|
const schedule = () => {
|
|
1077
1318
|
this.#motions.flushes.request(this.#flush);
|
|
1078
1319
|
};
|
|
1079
|
-
for (const channel of this.#
|
|
1320
|
+
for (const channel of this.#tree.leaves) {
|
|
1080
1321
|
channel.onUpdate(markDirty);
|
|
1081
1322
|
channel.onStart(schedule);
|
|
1082
1323
|
channel.onStop(schedule);
|
|
1083
1324
|
}
|
|
1084
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
|
+
*/
|
|
1085
1343
|
get target() {
|
|
1086
|
-
this.#targetView.
|
|
1087
|
-
|
|
1344
|
+
const view = this.#targetView ??= new ShapeView(this.#tree.root, readTarget);
|
|
1345
|
+
view.refresh();
|
|
1346
|
+
return view.root;
|
|
1088
1347
|
}
|
|
1089
1348
|
set target(value) {
|
|
1090
|
-
if (value instanceof
|
|
1091
|
-
else
|
|
1092
|
-
|
|
1093
|
-
this.#follow(spring, offset);
|
|
1094
|
-
} else this.#motions.flushes.batch(() => {
|
|
1095
|
-
this.#map.scatter(value, assignTarget);
|
|
1349
|
+
if (value instanceof CompositeSpring) this.#follow(value);
|
|
1350
|
+
else this.#motions.flushes.batch(() => {
|
|
1351
|
+
this.#tree.root.scatter(value, acceptChannelTarget, assignTarget);
|
|
1096
1352
|
});
|
|
1097
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
|
+
*/
|
|
1098
1366
|
get value() {
|
|
1099
1367
|
this.#valueView.refresh();
|
|
1100
1368
|
return this.#valueView.root;
|
|
1101
1369
|
}
|
|
1102
1370
|
set value(value) {
|
|
1103
1371
|
this.#motions.flushes.batch(() => {
|
|
1104
|
-
this.#
|
|
1372
|
+
this.#tree.root.scatter(value, acceptNumber, assignValue);
|
|
1105
1373
|
});
|
|
1106
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
|
+
*/
|
|
1107
1384
|
get velocity() {
|
|
1108
|
-
this.#velocityView.
|
|
1109
|
-
|
|
1385
|
+
const view = this.#velocityView ??= new ShapeView(this.#tree.root, readVelocity);
|
|
1386
|
+
view.refresh();
|
|
1387
|
+
return view.root;
|
|
1110
1388
|
}
|
|
1111
1389
|
set velocity(value) {
|
|
1112
1390
|
this.#motions.flushes.batch(() => {
|
|
1113
|
-
this.#
|
|
1391
|
+
this.#tree.root.scatter(value, acceptNumber, assignVelocity);
|
|
1114
1392
|
});
|
|
1115
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
|
+
*/
|
|
1116
1414
|
get config() {
|
|
1117
|
-
const channels = this.#
|
|
1415
|
+
const channels = this.#tree.leaves;
|
|
1118
1416
|
const first = channels[0].config;
|
|
1119
1417
|
for (let i = 1; i < channels.length; i++) if (channels[i].config !== first) return null;
|
|
1120
1418
|
return first;
|
|
1121
1419
|
}
|
|
1122
1420
|
set config(value) {
|
|
1123
1421
|
this.#motions.flushes.batch(() => {
|
|
1124
|
-
this.#
|
|
1422
|
+
this.#tree.root.broadcast(value ?? null, resolveConfigNode, assignConfig, "config");
|
|
1125
1423
|
});
|
|
1126
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. */
|
|
1127
1437
|
get timeRemaining() {
|
|
1128
1438
|
let max = 0;
|
|
1129
|
-
for (const channel of this.#
|
|
1439
|
+
for (const channel of this.#tree.leaves) {
|
|
1130
1440
|
const remaining = channel.timeRemaining;
|
|
1131
1441
|
if (remaining > max) max = remaining;
|
|
1132
1442
|
}
|
|
1133
1443
|
return max;
|
|
1134
1444
|
}
|
|
1445
|
+
/** Whether every channel is resting. */
|
|
1135
1446
|
get isResting() {
|
|
1136
|
-
for (const channel of this.#
|
|
1447
|
+
for (const channel of this.#tree.leaves) if (!channel.isResting) return false;
|
|
1137
1448
|
return true;
|
|
1138
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
|
+
*/
|
|
1139
1455
|
get settled() {
|
|
1140
1456
|
if (this.#disposed || this.isResting) return RESOLVED;
|
|
1141
1457
|
this.#settled ??= new Promise((resolve) => {
|
|
@@ -1149,11 +1465,23 @@ var SpringObject = class SpringObject {
|
|
|
1149
1465
|
});
|
|
1150
1466
|
return this.#settled;
|
|
1151
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
|
+
*/
|
|
1152
1473
|
jumpTo(value) {
|
|
1153
1474
|
this.#motions.flushes.batch(() => {
|
|
1154
|
-
this.#
|
|
1475
|
+
this.#tree.root.scatter(value, acceptNumber, applyJump);
|
|
1155
1476
|
});
|
|
1156
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
|
+
*/
|
|
1157
1485
|
dispose() {
|
|
1158
1486
|
if (this.#disposed) return;
|
|
1159
1487
|
this.#disposed = true;
|
|
@@ -1162,24 +1490,40 @@ var SpringObject = class SpringObject {
|
|
|
1162
1490
|
this.#settled = null;
|
|
1163
1491
|
this.#resolveSettled = null;
|
|
1164
1492
|
}
|
|
1165
|
-
for (const channel of this.#
|
|
1493
|
+
for (const channel of this.#tree.leaves) channel.dispose();
|
|
1166
1494
|
this.#emitter.clear();
|
|
1167
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
|
+
*/
|
|
1168
1501
|
onUpdate(callback) {
|
|
1169
1502
|
return this.#emitter.on("update", callback);
|
|
1170
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
|
+
*/
|
|
1171
1509
|
onStart(callback) {
|
|
1172
1510
|
return this.#emitter.on("start", callback);
|
|
1173
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
|
+
*/
|
|
1174
1517
|
onStop(callback) {
|
|
1175
1518
|
return this.#emitter.on("stop", callback);
|
|
1176
1519
|
}
|
|
1520
|
+
/** Subscribes to `dispose`, which fires once. Returns an unsubscribe function. */
|
|
1177
1521
|
onDispose(callback) {
|
|
1178
|
-
return this.#
|
|
1522
|
+
return this.#tree.leaves[0].onDispose(callback);
|
|
1179
1523
|
}
|
|
1180
|
-
#follow(leader
|
|
1524
|
+
#follow(leader) {
|
|
1181
1525
|
this.#motions.flushes.batch(() => {
|
|
1182
|
-
this.#
|
|
1526
|
+
this.#tree.root.zip(leader.#tree.root, followChannel);
|
|
1183
1527
|
});
|
|
1184
1528
|
}
|
|
1185
1529
|
};
|
|
@@ -1190,6 +1534,12 @@ const cancel = (id) => {
|
|
|
1190
1534
|
if (typeof window !== "undefined") window.cancelAnimationFrame(id);
|
|
1191
1535
|
else clearTimeout(id);
|
|
1192
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
|
+
*/
|
|
1193
1543
|
var Ticker = class {
|
|
1194
1544
|
#fps;
|
|
1195
1545
|
#gap;
|
|
@@ -1295,18 +1645,21 @@ var Ticker = class {
|
|
|
1295
1645
|
}
|
|
1296
1646
|
#onFrame(timestamp) {
|
|
1297
1647
|
if (this.#stopped) return;
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
+
}
|
|
1310
1663
|
}
|
|
1311
1664
|
#step(dt) {
|
|
1312
1665
|
this.#time += dt;
|
|
@@ -1340,13 +1693,19 @@ var SpringSystemImpl = class {
|
|
|
1340
1693
|
get reducedMotion() {
|
|
1341
1694
|
return this.#motion.reduced;
|
|
1342
1695
|
}
|
|
1343
|
-
createSpring(
|
|
1344
|
-
return new Spring(this.#motion,
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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);
|
|
1348
1706
|
}
|
|
1349
1707
|
advance(dt) {
|
|
1708
|
+
invariant(Number.isFinite(dt), "dt must be a finite number of milliseconds");
|
|
1350
1709
|
this.#motion.tick(dt / 1e3);
|
|
1351
1710
|
}
|
|
1352
1711
|
start() {
|
|
@@ -1377,8 +1736,23 @@ var SpringSystemImpl = class {
|
|
|
1377
1736
|
this.#ticker.adjustedLag = value;
|
|
1378
1737
|
}
|
|
1379
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
|
+
*/
|
|
1380
1754
|
function createSpringSystem(options) {
|
|
1381
1755
|
return new SpringSystemImpl(options);
|
|
1382
1756
|
}
|
|
1383
1757
|
//#endregion
|
|
1384
|
-
export {
|
|
1758
|
+
export { SpringDefinition as a, mapSpring as i, SpringSourceSymbol as n, defineSpring as o, isSpringSource as r, invariant as s, createSpringSystem as t };
|