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