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