@msb235/blits 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ This package follows [semver](https://semver.org). Below 1.0.0, a breaking chang
4
4
  version and everything else the patch. Each release lists its changes as **Breaking**, **Added** and
5
5
  **Fixed**, and the release workflow refuses a tag with no section here.
6
6
 
7
+ ## 0.14.0
8
+
9
+ ### Added
10
+
11
+ - `set(subject, value, at?)` on `spring`, `glide`, `tween`, and a voice's handle puts a subject at a
12
+ value and keeps its velocity: a spring or tween heads for its target from there, a glide coasts
13
+ on. A seek plays it again, as it does a push.
14
+ - `@msb235/blits/fling`: `fling(element, motion, subject, opts)` drags a subject by pointer and
15
+ flings it on release, and `track` reads a pointer's velocity from its samples with no listeners
16
+ of its own. The package still builds without the DOM's types.
17
+ - `glide` takes `bounds: [min, max]`, per axis and optionally per subject: a coast that would stop
18
+ past an edge springs back to it from where it crosses, on a critically damped spring of
19
+ `stiffness` (default 170). A `set` past an edge stays there until the next push. `g.bounds(subject)`
20
+ reads them, and `Patch.motion` reports the `stiffness` of a glide that has them.
21
+ - `fling` resists a drag past a glide's bounds, carrying the subject at most `give` (default 120)
22
+ past an edge, and a press holds a subject springing back past one where it is.
23
+
24
+ ### Fixed
25
+
26
+ - A voice cued with no `start` into a mix whose frame loop sleeps on `inert`, as `ticker` does,
27
+ starts at the next sync. It started at the last one, so an effect cued after a pause had already
28
+ played that far, and a short one was over before its first frame.
29
+ - A patch that names no host type can be cued and blended in a mix that declares one, so a patch
30
+ library's patches type-check in a host-typed mix. `VoiceSpec.patch`, `BlendMember.patch`, and
31
+ `blend`'s members take a patch keeping any state.
32
+ - A seek back keeps out a subject that had already left a voice at rest by then. After a fade with
33
+ `at: 'rest'`, a seek to a moment where that subject was moving again put it back in the voice,
34
+ playing.
35
+
7
36
  ## 0.13.0
8
37
 
9
38
  ### Breaking
package/README.md CHANGED
@@ -102,6 +102,23 @@ import { kit, mix, patch, sum } from '@msb235/blits/lean';
102
102
 
103
103
  Everything else is the same and so are the poses. `lanes: true` is refused there.
104
104
 
105
+ ## Dragging and flinging
106
+
107
+ `@msb235/blits/fling` drags a motion patch's subject by pointer and flings it on release:
108
+
109
+ ```ts
110
+ import { fling } from '@msb235/blits/fling';
111
+
112
+ const scroll = glide<Page, Pose>('y', { from: 0 });
113
+ mix.cue({ patch: scroll });
114
+ const off = fling(element, scroll, page, { scale: -1 }); // content moves against the drag
115
+ ```
116
+
117
+ A press stops the glide where it is, each move puts it under the pointer, and letting go pushes it at
118
+ the pointer's velocity. `track` reads that velocity from any `(t, x)` samples, with no listeners of
119
+ its own. Anything else in the mix can read the glide's position and velocity, so parallax, a header
120
+ that shrinks or a blur that grows with speed are voices driven by a signal over `scroll.read(page)`.
121
+
105
122
  ## Writing patches for blits in another library
106
123
 
107
124
  A library that ships patches exports them with the channels they write, as a `Kit` of its own
package/dist/closed.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare const CRITICAL = 1;
5
5
  export declare const OVER = 2;
6
6
  export declare const COAST = 3;
7
7
  export declare const EASED = 4;
8
+ export declare const BOUND = 5;
8
9
  /**
9
10
  * A stretch at voice time `t`, released at `at` from `x0` moving at `v0` toward `to` (each read
10
11
  * from its offset), into `xo` and `vo`: the one copy of the closed forms, which `law` (a patch's
package/dist/closed.js CHANGED
@@ -6,6 +6,7 @@ export const CRITICAL = 1;
6
6
  export const OVER = 2;
7
7
  export const COAST = 3;
8
8
  export const EASED = 4;
9
+ export const BOUND = 5;
9
10
  const solved = { y: 0, dy: 0 };
10
11
  /**
11
12
  * The terms of a stretch that depend only on how long since its release, the same on every axis, so
@@ -114,6 +115,46 @@ function solveGlide(law, y0) {
114
115
  solved.y = y0 * e;
115
116
  solved.dy = (-y0 / tau) * e;
116
117
  }
118
+ /**
119
+ * A glide with bounds, heading for `goal`: the rest point itself where the coast stops inside them,
120
+ * else the edge it springs back to. Which one is read off the stretch, so no stretch carries more
121
+ * than an unbounded one: the coast's own rest, worked out as the glide's `aim` does, is `goal`
122
+ * exactly when it stops inside; past it on the side `x0` is on too, the subject was let go past the
123
+ * edge and springs back at once; else it coasts until it crosses the edge and springs from there,
124
+ * at the speed it crossed at. The spring is critically damped, its ω₀ the law's `k2`.
125
+ */
126
+ function solveBound(law, x0, goal, v0, t) {
127
+ const tau = law[2];
128
+ const y0 = x0 - goal;
129
+ const over = x0 + v0 * tau - goal;
130
+ if (over === 0) {
131
+ solveGlide(law, y0);
132
+ return;
133
+ }
134
+ const w0 = law[3];
135
+ if (y0 * over > 0) {
136
+ const e = Math.exp(-w0 * t);
137
+ const b = v0 + w0 * y0;
138
+ const y = e * (y0 + b * t);
139
+ solved.y = y;
140
+ solved.dy = e * b - w0 * y;
141
+ return;
142
+ }
143
+ // e^(−t/τ) where the coast meets the edge: the fraction of its speed it still has there.
144
+ const left = 1 + y0 / (v0 * tau);
145
+ const cross = left > 0 ? -tau * Math.log(left) : Number.POSITIVE_INFINITY;
146
+ if (t < cross) {
147
+ const e = timed.e;
148
+ solved.y = y0 + v0 * tau * (1 - e);
149
+ solved.dy = v0 * e;
150
+ return;
151
+ }
152
+ const s = t - cross;
153
+ const vc = v0 * left;
154
+ const e = Math.exp(-w0 * s);
155
+ solved.y = e * vc * s;
156
+ solved.dy = e * vc * (1 - w0 * s);
157
+ }
117
158
  /**
118
159
  * A stretch at voice time `t`, released at `at` from `x0` moving at `v0` toward `to` (each read
119
160
  * from its offset), into `xo` and `vo`: the one copy of the closed forms, which `law` (a patch's
@@ -130,7 +171,10 @@ export function closed(law, ease, n, at, ms, x0, x, v0, v, to, g, t, xo, vo) {
130
171
  prepare(law, dt);
131
172
  for (let i = 0; i < n; i++) {
132
173
  const goal = to[g + i];
133
- solve(law, x0[x + i] - goal, v0[v + i], dt);
174
+ if (law[0] === BOUND)
175
+ solveBound(law, x0[x + i], goal, v0[v + i], dt);
176
+ else
177
+ solve(law, x0[x + i] - goal, v0[v + i], dt);
134
178
  xo[i] = goal + solved.y;
135
179
  vo[i] = solved.dy;
136
180
  if (Math.abs(solved.y) > settle || Math.abs(solved.dy) > settle)
package/dist/cue.js CHANGED
@@ -44,7 +44,10 @@ export function cue(mix, given) {
44
44
  ? mixAt(mix, spec.start - mix.offset)
45
45
  : Number.isNaN(mix.now)
46
46
  ? 0
47
- : mix.now;
47
+ : // A loop asleep on an inert mix syncs next when it wakes, which is the cue's "now".
48
+ mix.asleep
49
+ ? Number.POSITIVE_INFINITY
50
+ : mix.now;
48
51
  const voice = new Voice(mix.nextId++, spec, patch, spec.fade ?? noFade, local, start, mix.slotOf, mix.channels, mix.opts.host, mix.send, owner);
49
52
  if (patch === ownerPatch && mix.fitting !== null)
50
53
  voice.fitting = mix.fitting;
@@ -56,6 +59,10 @@ export function cue(mix, given) {
56
59
  checkChild(spec, voice);
57
60
  if (mix.pace !== null && owner === null && spec.start !== undefined && voice.state === 'pending')
58
61
  voice.pinned = spec.start - mix.offset;
62
+ if (start === Number.POSITIVE_INFINITY && !anchored && !owner?.fitting) {
63
+ voice.waking = true;
64
+ mix.waking = true;
65
+ }
59
66
  enter(mix, voice);
60
67
  const h = handle(mix, voice);
61
68
  voice.handle = h;
@@ -0,0 +1,66 @@
1
+ import type { Moving, Value } from './motion.js';
2
+ /**
3
+ * A pointer's recent path, for the velocity it is let go at.
4
+ *
5
+ * @category fling
6
+ */
7
+ export interface Track<V extends Value = number> {
8
+ /** Where it was at `t`, ms; samples must come in time order. */
9
+ add(t: number, x: V): void;
10
+ /**
11
+ * Units per second at `at`, default the latest sample: the least-squares slope of the samples
12
+ * within `ms` of the latest, or zero once the latest is more than `stop` ms before `at`, or with
13
+ * fewer than two samples.
14
+ */
15
+ velocity(at?: number): V;
16
+ clear(): void;
17
+ }
18
+ /**
19
+ * Tracks a pointer's velocity from its samples, with no listeners of its own: anything that can give
20
+ * a time and a position can feed it.
21
+ *
22
+ * @category fling
23
+ */
24
+ export declare function track<V extends Value = number>(opts?: {
25
+ /** How far back the slope reads, ms. Default 100. */
26
+ ms?: number;
27
+ /** How long a pointer can sit still before letting go and still fling, ms. Default 40. */
28
+ stop?: number;
29
+ }): Track<V>;
30
+ /** The part of a pointer event `fling` reads. */
31
+ export interface PointerLike {
32
+ pointerId: number;
33
+ clientX: number;
34
+ clientY: number;
35
+ timeStamp: number;
36
+ }
37
+ /** Anything pointer events come from: an element, or a stand-in for one. */
38
+ export interface PointerTarget {
39
+ addEventListener(type: string, fn: (e: PointerLike) => void): void;
40
+ removeEventListener(type: string, fn: (e: PointerLike) => void): void;
41
+ setPointerCapture?(pointerId: number): void;
42
+ }
43
+ /**
44
+ * Drags `subject` of a motion patch, usually a `glide`, by pointer on `target`, and flings it on
45
+ * release at the pointer's velocity: a press stops it where it is, each move `set`s it under the
46
+ * pointer, and letting go `push`es it. Past the edge of a glide's `bounds` it follows less and less
47
+ * of the drag, never more than `give` past. One pointer at a time; a canceled one lets go at rest.
48
+ * Returns the function that removes its listeners.
49
+ *
50
+ * @category fling
51
+ */
52
+ export declare function fling<I, O, V extends Value = number>(target: PointerTarget, motion: Pick<Moving<I, O, V>, 'read' | 'set' | 'push'> & {
53
+ bounds?(subject: I): readonly [min: V, max: V] | undefined;
54
+ }, subject: I, opts?: {
55
+ /** Which pointer axes it follows: `'xy'` moves a subject on two axes. Default `'y'`. */
56
+ axis?: 'x' | 'y' | 'xy';
57
+ /** Units per pixel of pointer travel; -1 scrolls content against the drag. Default 1. */
58
+ scale?: number;
59
+ /**
60
+ * How far past an edge of the glide's bounds a drag can carry the subject, in its units; 0
61
+ * holds it at the edge. Default 120.
62
+ */
63
+ give?: number;
64
+ ms?: number;
65
+ stop?: number;
66
+ }): () => void;
package/dist/fling.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Tracks a pointer's velocity from its samples, with no listeners of its own: anything that can give
3
+ * a time and a position can feed it.
4
+ *
5
+ * @category fling
6
+ */
7
+ export function track(opts = {}) {
8
+ const span = opts.ms ?? 100;
9
+ const stop = opts.stop ?? 40;
10
+ if (!(span > 0) || !(stop >= 0))
11
+ throw new RangeError('blits: a track takes a positive ms and a stop of 0 or more');
12
+ const ts = [];
13
+ const xs = [];
14
+ let scalar = true;
15
+ return {
16
+ add(t, x) {
17
+ scalar = typeof x === 'number';
18
+ ts.push(t);
19
+ xs.push(typeof x === 'number' ? [x] : [...x]);
20
+ let drop = 0;
21
+ while (t - ts[drop] > span)
22
+ drop++;
23
+ if (drop > 0) {
24
+ ts.splice(0, drop);
25
+ xs.splice(0, drop);
26
+ }
27
+ },
28
+ velocity(at) {
29
+ const n = ts.length;
30
+ const axes = n === 0 ? 1 : xs[0].length;
31
+ const out = new Array(axes).fill(0);
32
+ const last = ts[n - 1];
33
+ if (n >= 2 && !((at ?? last) - last > stop)) {
34
+ let mt = 0;
35
+ for (const t of ts)
36
+ mt += t;
37
+ mt /= n;
38
+ let tt = 0;
39
+ for (const t of ts)
40
+ tt += (t - mt) ** 2;
41
+ if (tt > 0)
42
+ for (let k = 0; k < axes; k++) {
43
+ let mx = 0;
44
+ for (const x of xs)
45
+ mx += x[k];
46
+ mx /= n;
47
+ let tx = 0;
48
+ for (let i = 0; i < n; i++)
49
+ tx += (ts[i] - mt) * (xs[i][k] - mx);
50
+ out[k] = (tx / tt) * 1000;
51
+ }
52
+ }
53
+ return (scalar ? out[0] : out);
54
+ },
55
+ clear() {
56
+ ts.length = 0;
57
+ xs.length = 0;
58
+ },
59
+ };
60
+ }
61
+ /** How much of a drag past an edge the subject follows, right at the edge. */
62
+ const PULL = 0.55;
63
+ /** How far a drag `d` past an edge carries the subject, never as far as `give`. */
64
+ const resist = (d, give) => give > 0 ? give * (1 - 1 / ((PULL * d) / give + 1)) : 0;
65
+ /** The drag past an edge that carries the subject `r` past it, as `resist` maps it. */
66
+ const unresist = (r, give) => {
67
+ const held = Math.min(r, give * 0.99);
68
+ return give > 0 ? (give * held) / (PULL * (give - held)) : 0;
69
+ };
70
+ /**
71
+ * Drags `subject` of a motion patch, usually a `glide`, by pointer on `target`, and flings it on
72
+ * release at the pointer's velocity: a press stops it where it is, each move `set`s it under the
73
+ * pointer, and letting go `push`es it. Past the edge of a glide's `bounds` it follows less and less
74
+ * of the drag, never more than `give` past. One pointer at a time; a canceled one lets go at rest.
75
+ * Returns the function that removes its listeners.
76
+ *
77
+ * @category fling
78
+ */
79
+ export function fling(target, motion, subject, opts = {}) {
80
+ const axis = opts.axis ?? 'y';
81
+ const scale = opts.scale ?? 1;
82
+ const give = opts.give ?? 120;
83
+ if (!(give >= 0))
84
+ throw new RangeError('blits: a fling takes a give of 0 or more');
85
+ let lo;
86
+ let hi;
87
+ /** Where the drag `x` puts the subject, past an edge carried by `resist`. */
88
+ const held = (x) => lo === undefined || hi === undefined
89
+ ? x
90
+ : x.map((xi, k) => {
91
+ const a = lo[k];
92
+ const b = hi[k];
93
+ return xi < a ? a - resist(a - xi, give) : xi > b ? b + resist(xi - b, give) : xi;
94
+ });
95
+ /** The drag that puts the subject at `x`, as `held` maps it. */
96
+ const dragged = (x) => lo === undefined || hi === undefined
97
+ ? x
98
+ : x.map((xi, k) => {
99
+ const a = lo[k];
100
+ const b = hi[k];
101
+ return xi < a ? a - unresist(a - xi, give) : xi > b ? b + unresist(xi - b, give) : xi;
102
+ });
103
+ const path = track(opts);
104
+ let id;
105
+ let from = [];
106
+ let p0 = [];
107
+ const at = (e) => axis === 'x' ? [e.clientX] : axis === 'y' ? [e.clientY] : [e.clientX, e.clientY];
108
+ const value = (x) => (axis === 'xy' ? x : x[0]);
109
+ const flat = (v) => (typeof v === 'number' ? [v] : [...v]);
110
+ const zero = () => value(from.map(() => 0));
111
+ const down = (e) => {
112
+ if (id !== undefined)
113
+ return;
114
+ const now = motion.read(subject);
115
+ if (now === undefined)
116
+ return;
117
+ id = e.pointerId;
118
+ target.setPointerCapture?.(id);
119
+ const edges = motion.bounds?.(subject);
120
+ lo = edges === undefined ? undefined : flat(edges[0]);
121
+ hi = edges === undefined ? undefined : flat(edges[1]);
122
+ const here = flat(now.value);
123
+ from = dragged(here);
124
+ p0 = at(e);
125
+ motion.push(subject, zero());
126
+ // The push alone would spring a subject caught past an edge back; a set there holds it.
127
+ motion.set(subject, value(here));
128
+ path.clear();
129
+ path.add(e.timeStamp, here);
130
+ };
131
+ const move = (e) => {
132
+ if (e.pointerId !== id)
133
+ return;
134
+ const x = held(at(e).map((p, k) => from[k] + (p - p0[k]) * scale));
135
+ motion.set(subject, value(x));
136
+ path.add(e.timeStamp, x);
137
+ };
138
+ const up = (e) => {
139
+ if (e.pointerId !== id)
140
+ return;
141
+ id = undefined;
142
+ motion.push(subject, value(path.velocity(e.timeStamp)));
143
+ };
144
+ const cancel = (e) => {
145
+ if (e.pointerId !== id)
146
+ return;
147
+ id = undefined;
148
+ motion.push(subject, zero());
149
+ };
150
+ const on = [
151
+ ['pointerdown', down],
152
+ ['pointermove', move],
153
+ ['pointerup', up],
154
+ ['pointercancel', cancel],
155
+ ];
156
+ for (const [type, fn] of on)
157
+ target.addEventListener(type, fn);
158
+ return () => {
159
+ for (const [type, fn] of on)
160
+ target.removeEventListener(type, fn);
161
+ };
162
+ }
package/dist/fold.js CHANGED
@@ -190,6 +190,7 @@ export function contribution(voice, subject, now, held) {
190
190
  if (voice.out?.rest && this.isRest(delta)) {
191
191
  held.weight = 0;
192
192
  held.rested = true;
193
+ held.restedAt = now;
193
194
  voice.restedCount++;
194
195
  return null;
195
196
  }
package/dist/handle.d.ts CHANGED
@@ -68,5 +68,6 @@ export declare class VoiceHandle<I, O> implements Handle<I> {
68
68
  weightOf(subject: I): number;
69
69
  to(subject: I, target: Value, at?: number): void;
70
70
  push(subject: I, velocity: Value, at?: number): void;
71
+ set(subject: I, value: Value, at?: number): void;
71
72
  read(subject: I, at?: number): Motion<Value> | undefined;
72
73
  }
package/dist/handle.js CHANGED
@@ -164,6 +164,11 @@ export class VoiceHandle {
164
164
  if (voice !== null)
165
165
  moves(voice.patch, 'push', 'a spring or a glide').push(subject, velocity, at);
166
166
  }
167
+ set(subject, value, at) {
168
+ const voice = this.#voice;
169
+ if (voice !== null)
170
+ moves(voice.patch, 'set', 'a motion patch').set(subject, value, at);
171
+ }
167
172
  read(subject, at) {
168
173
  const voice = this.#voice;
169
174
  if (voice === null || motionOf(voice.patch) === undefined)
package/dist/history.js CHANGED
@@ -122,6 +122,7 @@ export function copyHeld(voice, h, shared = false) {
122
122
  shown: h.shown,
123
123
  weight: h.weight,
124
124
  rested: h.rested,
125
+ restedAt: h.restedAt,
125
126
  bands: h.bands === null ? null : h.bands.slice(),
126
127
  state: h.state === undefined || shared ? h.state : copyState(voice, h.state),
127
128
  stepped: h.stepped,
@@ -172,6 +173,7 @@ export function copyHeldInto(voice, h, into, shared = false) {
172
173
  into.shown = h.shown;
173
174
  into.weight = h.weight;
174
175
  into.rested = h.rested;
176
+ into.restedAt = h.restedAt;
175
177
  into.bands = h.bands === null ? null : h.bands.slice();
176
178
  into.state = h.state === undefined || shared ? h.state : copyState(voice, h.state);
177
179
  into.stepped = h.stepped;
package/dist/mixer.d.ts CHANGED
@@ -114,6 +114,13 @@ export declare class Mixer<I, O> implements Mix<I, O> {
114
114
  reducedNow: boolean;
115
115
  /** Something was changed since the last sync, so the next frame may differ from this one. */
116
116
  stirred: boolean;
117
+ /**
118
+ * The host read `inert` as true since the last sync, so its frame loop may be asleep until a
119
+ * change wakes it; a host that never asks keeps no loop asleep on it.
120
+ */
121
+ asleep: boolean;
122
+ /** Some voice cued while the mix slept waits for the next sync to set its start. */
123
+ waking: boolean;
117
124
  /** Counts the changes `stir` records, which a frame's record of a probe is current only before. */
118
125
  stirs: number;
119
126
  syncing: boolean;
@@ -259,6 +266,7 @@ export declare class Mixer<I, O> implements Mix<I, O> {
259
266
  voices(tag?: string): Handle<I>[];
260
267
  get live(): boolean;
261
268
  get inert(): boolean;
269
+ private restful;
262
270
  get rate(): number;
263
271
  set rate(r: number);
264
272
  ramp(rate: number, over: number): void;
package/dist/mixer.js CHANGED
@@ -105,6 +105,13 @@ export class Mixer {
105
105
  reducedNow = false;
106
106
  /** Something was changed since the last sync, so the next frame may differ from this one. */
107
107
  stirred = false;
108
+ /**
109
+ * The host read `inert` as true since the last sync, so its frame loop may be asleep until a
110
+ * change wakes it; a host that never asks keeps no loop asleep on it.
111
+ */
112
+ asleep = false;
113
+ /** Some voice cued while the mix slept waits for the next sync to set its start. */
114
+ waking = false;
108
115
  /** Counts the changes `stir` records, which a frame's record of a probe is current only before. */
109
116
  stirs = 0;
110
117
  syncing = false;
@@ -474,6 +481,12 @@ export class Mixer {
474
481
  get inert() {
475
482
  if (this.stirred)
476
483
  return false;
484
+ const inert = this.restful();
485
+ if (inert && !Number.isNaN(this.now))
486
+ this.asleep = true;
487
+ return inert;
488
+ }
489
+ restful() {
477
490
  // Standing still, a voice changes no pose but by its weight signal, or by an anchor or start
478
491
  // that host time may yet reach.
479
492
  if (this.pace?.stopped(this.u))
package/dist/motion.d.ts CHANGED
@@ -44,6 +44,11 @@ export type Moving<I, O, V extends Value> = Patch<I, O, void> & {
44
44
  * back at the latest frame too, however long it is before it is next read.
45
45
  */
46
46
  push(subject: I, velocity: V, at?: number): void;
47
+ /**
48
+ * Puts `subject` at `value` from voice time `at`, keeping its velocity, with `at` defaulting as
49
+ * `push`'s does: a spring or tween heads for its target from there, a glide coasts on from there.
50
+ */
51
+ set(subject: I, value: V, at?: number): void;
47
52
  };
48
53
  /**
49
54
  * The state behind a `'motion'` patch, for an engine that reads it directly; a patch another copy
@@ -90,6 +95,11 @@ export declare function spring<I, O, V extends Value = number>(writes: keyof O,
90
95
  * `ms`, and comes to rest `velocity · ms / 1000` past where it was released. `push` sets it moving
91
96
  * again from wherever it is. Closed form, so frame rate does not change where it stops.
92
97
  *
98
+ * With `bounds`, a coast that would stop past an edge springs back to it from where it crosses, at
99
+ * the speed it crosses at, on a critically damped spring of `stiffness`; one let go past an edge
100
+ * springs back from there, unless it coasts back inside. A `set` past an edge leaves the subject
101
+ * there, coasting on, until the next `push`, so a drag can hold it out.
102
+ *
93
103
  * @category patch
94
104
  */
95
105
  export declare function glide<I, O, V extends Value = number>(writes: keyof O, opts: Common<I, V> & {
@@ -97,7 +107,14 @@ export declare function glide<I, O, V extends Value = number>(writes: keyof O, o
97
107
  from: PerSubject<I, V>;
98
108
  /** The friction's time constant, ms. Default 325, a touch scroll's. */
99
109
  ms?: number;
100
- }): Moving<I, O, V>;
110
+ /** The least and greatest value a coast comes to rest at, per axis. Default none. */
111
+ bounds?: PerSubject<I, readonly [min: V, max: V]>;
112
+ /** The spring back's, per second squared, with `bounds`. Default 170, as a spring's. */
113
+ stiffness?: number;
114
+ }): Moving<I, O, V> & {
115
+ /** `subject`'s bounds, undefined for a glide without them. */
116
+ bounds(subject: I): readonly [min: V, max: V] | undefined;
117
+ };
101
118
  /**
102
119
  * From one value to another over `ms` along an easing, solved in closed form, so frame rate does not
103
120
  * change where a subject is. `to(subject, target)` retargets one subject mid-flight: the new stretch
package/dist/motion.js CHANGED
@@ -1,4 +1,4 @@
1
- import { COAST, CRITICAL, EASED, OVER, UNDER } from './closed.js';
1
+ import { BOUND, COAST, CRITICAL, EASED, OVER, UNDER } from './closed.js';
2
2
  import { curve } from './easing.js';
3
3
  import { Motions } from './motions.js';
4
4
  import { release } from './shared.js';
@@ -44,6 +44,9 @@ class MotionPatch {
44
44
  state.sample(s, setting.elapsed, state.xs, state.vs);
45
45
  return { [this.writes[0]]: state.value(s, state.xs) };
46
46
  }
47
+ set(subject, value, at) {
48
+ this.motions.change(subject, { at, x: axes(value) });
49
+ }
47
50
  read(subject, at) {
48
51
  const state = this.motions;
49
52
  const r = state.read(subject, at);
@@ -63,9 +66,18 @@ class SpringPatch extends TweenPatch {
63
66
  }
64
67
  }
65
68
  class GlidePatch extends MotionPatch {
69
+ edges;
70
+ constructor(writes, motions, edges) {
71
+ super(writes, motions);
72
+ this.edges = edges;
73
+ }
66
74
  push(subject, velocity, at) {
67
75
  this.motions.change(subject, { at, v: axes(velocity) });
68
76
  }
77
+ bounds(subject) {
78
+ const b = this.edges;
79
+ return b === undefined ? undefined : per(b, subject);
80
+ }
69
81
  }
70
82
  /**
71
83
  * The state behind a `'motion'` patch, for an engine that reads it directly; a patch another copy
@@ -124,12 +136,14 @@ class GlideShape {
124
136
  ms = undefined;
125
137
  start;
126
138
  speed;
139
+ edges;
127
140
  constructor(law, motion, opts, tau) {
128
141
  this.law = law;
129
142
  this.motion = motion;
130
143
  this.tau = tau;
131
144
  this.start = opts.from;
132
145
  this.speed = opts.velocity;
146
+ this.edges = opts.bounds;
133
147
  }
134
148
  spec() {
135
149
  return this.motion;
@@ -141,8 +155,24 @@ class GlideShape {
141
155
  const v = this.speed;
142
156
  return v === undefined ? still(x) : axes(per(v, s));
143
157
  }
144
- aim(x, v) {
145
- return x.map((xi, i) => xi + v[i] * this.tau);
158
+ aim(x, v, _was, s, put) {
159
+ const rest = x.map((xi, i) => xi + v[i] * this.tau);
160
+ if (this.edges === undefined)
161
+ return rest;
162
+ const [lo, hi] = per(this.edges, s);
163
+ const min = axes(lo);
164
+ const max = axes(hi);
165
+ if (min.length !== rest.length || max.length !== rest.length)
166
+ throw new Error('blits: bounds on a glide have as many axes as its subjects');
167
+ return rest.map((r, i) => {
168
+ const a = min[i];
169
+ const b = max[i];
170
+ const xi = x[i];
171
+ // A set past an edge stays there: a drag holds it out, and only a push lets it spring back.
172
+ if (put && (xi < a || xi > b))
173
+ return r;
174
+ return r < a ? a : r > b ? b : r;
175
+ });
146
176
  }
147
177
  scalar(s) {
148
178
  return typeof per(this.start, s) === 'number';
@@ -226,16 +256,25 @@ export function spring(writes, opts) {
226
256
  * `ms`, and comes to rest `velocity · ms / 1000` past where it was released. `push` sets it moving
227
257
  * again from wherever it is. Closed form, so frame rate does not change where it stops.
228
258
  *
259
+ * With `bounds`, a coast that would stop past an edge springs back to it from where it crosses, at
260
+ * the speed it crosses at, on a critically damped spring of `stiffness`; one let go past an edge
261
+ * springs back from there, unless it coasts back inside. A `set` past an edge leaves the subject
262
+ * there, coasting on, until the next `push`, so a drag can hold it out.
263
+ *
229
264
  * @category patch
230
265
  */
231
266
  export function glide(writes, opts) {
232
267
  const ms = opts.ms ?? 325;
233
268
  if (!(ms > 0))
234
269
  throw new RangeError('blits: a glide takes a positive ms');
270
+ const k = opts.stiffness ?? 170;
271
+ if (!(k > 0))
272
+ throw new RangeError('blits: a glide takes a positive stiffness');
235
273
  const tau = ms / 1000;
236
274
  const settle = opts.settle ?? 1e-4;
237
- const shape = new GlideShape([COAST, settle, tau, 0, 0], { kind: 'glide', ms, settle }, opts, tau);
238
- return new GlidePatch(writes, new Motions(shape));
275
+ const bounded = opts.bounds !== undefined;
276
+ const shape = new GlideShape(bounded ? [BOUND, settle, tau, Math.sqrt(k), 0] : [COAST, settle, tau, 0, 0], bounded ? { kind: 'glide', ms, settle, stiffness: k } : { kind: 'glide', ms, settle }, opts, tau);
277
+ return new GlidePatch(writes, new Motions(shape), opts.bounds);
239
278
  }
240
279
  /**
241
280
  * From one value to another over `ms` along an easing, solved in closed form, so frame rate does not
package/dist/motions.d.ts CHANGED
@@ -20,6 +20,8 @@ export interface Change {
20
20
  */
21
21
  at?: number;
22
22
  to?: number[];
23
+ /** Where it moves from, in place of where its stretch has it then. */
24
+ x?: number[];
23
25
  v?: number[];
24
26
  /** The mix clock when it was made, NaN where no mix was playing the patch. */
25
27
  made?: number;
@@ -73,8 +75,11 @@ export interface Shape<I> {
73
75
  from: (subject: I) => number[];
74
76
  /** How fast each axis starts moving, given where it starts: still unless the options say. */
75
77
  velocity: (subject: I, x: number[]) => number[];
76
- /** Where it heads when released from `x` at `v`, given where it was heading. */
77
- aim: (x: number[], v: number[], was: number[] | null, subject: I) => number[];
78
+ /**
79
+ * Where it heads when released from `x` at `v`, given where it was heading; `put` for a stretch a
80
+ * `set` released.
81
+ */
82
+ aim: (x: number[], v: number[], was: number[] | null, subject: I, put: boolean) => number[];
78
83
  /** `[form, settle, k1, k2, k3]`. */
79
84
  law: readonly number[];
80
85
  /** Whether a subject's value is a number rather than an array. */
@@ -238,7 +243,7 @@ export declare class Motions<I> {
238
243
  s: number;
239
244
  } | undefined;
240
245
  /**
241
- * Queues a retarget or push, untimed at the latest frame, for `subject`'s next read past its time.
246
+ * Queues a retarget, set or push, untimed at the latest frame, for `subject`'s next read past its time.
242
247
  * The queue keeps the changes with a time in time order, ahead of any still waiting for one.
243
248
  */
244
249
  change(subject: I, c: Change): void;
package/dist/motions.js CHANGED
@@ -126,7 +126,7 @@ export class Motions {
126
126
  return known;
127
127
  const x = this.shape.from(subject);
128
128
  const v = this.shape.velocity(subject, x);
129
- const to = this.shape.aim(x, v, null, subject);
129
+ const to = this.shape.aim(x, v, null, subject, false);
130
130
  const n = this.n < 0 ? x.length : this.n;
131
131
  this.check(x, n);
132
132
  this.check(v, n);
@@ -465,13 +465,14 @@ export class Motions {
465
465
  return { x, v, s };
466
466
  }
467
467
  /**
468
- * Queues a retarget or push, untimed at the latest frame, for `subject`'s next read past its time.
468
+ * Queues a retarget, set or push, untimed at the latest frame, for `subject`'s next read past its time.
469
469
  * The queue keeps the changes with a time in time order, ahead of any still waiting for one.
470
470
  */
471
471
  change(subject, c) {
472
472
  const back = this.owner === null ? Number.NaN : this.owner.revive(this.ownerId, subject);
473
473
  const s = this.slot(subject);
474
474
  this.check(c.to, this.n);
475
+ this.check(c.x, this.n);
475
476
  this.check(c.v, this.n);
476
477
  c.made = this.owner === null ? Number.NaN : this.owner.now();
477
478
  c.seq = this.owner === null ? Number.NaN : this.owner.seq();
@@ -494,7 +495,7 @@ export class Motions {
494
495
  else
495
496
  this.queue(list, c, c.at);
496
497
  reading.moved++;
497
- const made = { at: c.at, to: c.to, v: c.v };
498
+ const made = { at: c.at, to: c.to, x: c.x, v: c.v };
498
499
  this.owner?.changed(this.ownerId, this, subject, made);
499
500
  }
500
501
  /** Files a change with a time after every one due by then, ahead of those still waiting for one. */
@@ -671,11 +672,12 @@ export class Motions {
671
672
  landing = { x: new Float64Array(n), v: new Float64Array(n) };
672
673
  const { x, v } = landing;
673
674
  this.evaluateSegment(seg, at, x, v);
674
- const xs = numbers(x, 0, n);
675
+ const xs = change.x ?? numbers(x, 0, n);
675
676
  const vs = change.v ?? numbers(v, 0, n);
676
677
  const subject = this.numbers.subject(s);
677
678
  const known = subject === absent ? undefined : subject;
678
- const to = this.shape.aim(xs, vs, change.to ?? seg.to, known);
679
+ const put = change.x !== undefined && change.v === undefined;
680
+ const to = this.shape.aim(xs, vs, change.to ?? seg.to, known, put);
679
681
  const ms = subject === absent ? seg.ms : (this.shape.ms?.(known) ?? 0);
680
682
  return { at, ms, x0: xs, v0: vs, to, change };
681
683
  }
package/dist/move.js CHANGED
@@ -9,7 +9,7 @@ import { begin } from './held.js';
9
9
  import { noted } from './history.js';
10
10
  import { leave as leaveClock } from './origin.js';
11
11
  import { ownerReading } from './owner.js';
12
- import { mixAt, place, repin, startOf } from './place.js';
12
+ import { mixAt, place, repin, startAt, startOf } from './place.js';
13
13
  import { pageVoice } from './revive.js';
14
14
  import { delist } from './roster.js';
15
15
  import { scoredLeft, scoredRemove } from './scored.js';
@@ -47,6 +47,8 @@ function moveTo(mix, now) {
47
47
  for (const a of mix.announced)
48
48
  if (Number.isNaN(a.at))
49
49
  a.at = mix.u;
50
+ if (mix.waking)
51
+ wake(mix, now);
50
52
  if (mix.pins !== null && mix.pins.size > 0)
51
53
  repin(mix);
52
54
  if (mix.anchored > 0)
@@ -137,10 +139,31 @@ function moveTo(mix, now) {
137
139
  if (mix.sharers.length > 0 && !mix.projecting)
138
140
  outgrow(mix);
139
141
  mix.stirred = false;
142
+ mix.asleep = false;
140
143
  if (mix.retired.length > 0)
141
144
  prune(mix);
142
145
  forget(mix);
143
146
  }
147
+ /** Starts each voice cued while the mix slept at `now`, the first sync after its cue. */
148
+ function wake(mix, now) {
149
+ mix.waking = false;
150
+ for (const voice of mix.cued) {
151
+ if (!voice.waking)
152
+ continue;
153
+ voice.waking = false;
154
+ if (voice.state !== 'pending')
155
+ continue;
156
+ startAt(mix, voice, now);
157
+ // Where a seek back before the cue puts it, so the tape's cue again starts it here.
158
+ const first = voice.first;
159
+ if (first !== null) {
160
+ first.start = now;
161
+ first.anchorNow = now;
162
+ }
163
+ if (!mix.projecting)
164
+ schedule(mix, voice);
165
+ }
166
+ }
144
167
  /**
145
168
  * Which way a voice's clock is running at mix time `now`, through every owner above it: above 0
146
169
  * forward, below 0 back, 0 standing still.
package/dist/pack.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface PackedRecord {
7
7
  shown: number;
8
8
  weight: number;
9
9
  rested: boolean;
10
+ restedAt: number;
10
11
  bands: number[] | null;
11
12
  state: unknown;
12
13
  stepped: number;
package/dist/pack.js CHANGED
@@ -11,6 +11,7 @@ export function packHeld(voice, h) {
11
11
  shown: h.shown,
12
12
  weight: h.weight,
13
13
  rested: h.rested,
14
+ restedAt: h.restedAt,
14
15
  bands: h.bands === null ? null : Array.from(h.bands),
15
16
  state: h.state === undefined ? undefined : patch.pack ? patch.pack(h.state) : clone(h.state),
16
17
  stepped: h.stepped,
@@ -40,6 +41,7 @@ export function unpackHeld(voice, p) {
40
41
  h.shown = p.shown;
41
42
  h.weight = p.weight;
42
43
  h.rested = p.rested;
44
+ h.restedAt = p.restedAt;
43
45
  h.bands = bands;
44
46
  h.stepped = p.stepped;
45
47
  h.ticks = p.ticks;
package/dist/project.js CHANGED
@@ -291,6 +291,7 @@ function copyAhead(mix, c) {
291
291
  pin(c, copy, at);
292
292
  return copy;
293
293
  });
294
+ c.waking = mix.waking;
294
295
  ownBlends(c.cued);
295
296
  // A copy keeps no list of owners, so the one before says only by its voices.
296
297
  if (mix.owners !== null || (mix.projecting && c.cued.some((v) => v.holding !== null)))
package/dist/record.js CHANGED
@@ -13,6 +13,7 @@ export function record(voice, reaches, state) {
13
13
  shown: 0,
14
14
  weight: 0,
15
15
  rested: false,
16
+ restedAt: Number.NaN,
16
17
  bands: null,
17
18
  state,
18
19
  // -0, not 0: a field first given a small integer is boxed on every later write of a double.
package/dist/seek.js CHANGED
@@ -33,8 +33,7 @@ function restore(mix, voice, live, at, every, mine) {
33
33
  const h = mine && live.reaches ? unread(live) : copyHeld(voice, live);
34
34
  if (h.stepped > t)
35
35
  h.stepped = t;
36
- // Whether it had rested by `t` is not kept; the next probe finds it again.
37
- h.rested = false;
36
+ restedBy(h, live, t);
38
37
  h.weight = 0;
39
38
  h.inputs = inputs;
40
39
  if (!(h.since <= t))
@@ -63,6 +62,7 @@ function restore(mix, voice, live, at, every, mine) {
63
62
  else
64
63
  h = copyHeld(voice, snap.held, true);
65
64
  h.weight = 0;
65
+ restedBy(h, live, t);
66
66
  if (snap.at < t)
67
67
  h.copied = true;
68
68
  const kept = i + 1;
@@ -108,6 +108,14 @@ function restore(mix, voice, live, at, every, mine) {
108
108
  }
109
109
  return h;
110
110
  }
111
+ /**
112
+ * Whether a record put back for mix time `t` had rested by then, from when the live one did: a
113
+ * subject may have rested and moved on since, so a probe at `t` would not find it at rest again.
114
+ */
115
+ function restedBy(h, live, t) {
116
+ h.rested = live.restedAt <= t;
117
+ h.restedAt = h.rested ? live.restedAt : Number.NaN;
118
+ }
111
119
  /** Cuts a record's list of copies to its first `kept`, keeping those no other list holds as spares. */
112
120
  function trim(voice, snaps, kept) {
113
121
  while (snaps.length > kept) {
package/dist/shared.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * The package version, for a fact whose shape follows the code too closely to version by hand:
3
3
  * copies of different releases keep apart. `test/copies.test.ts` holds it to `package.json`.
4
4
  */
5
- export declare const release = "0.13.0";
5
+ export declare const release = "0.14.0";
6
6
  /**
7
7
  * A WeakMap every copy of blits loaded in one realm shares, so a fact one copy records about a
8
8
  * patch or channel it made is read by another copy's mix. The name carries a version, bumped when
package/dist/shared.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * The package version, for a fact whose shape follows the code too closely to version by hand:
3
3
  * copies of different releases keep apart. `test/copies.test.ts` holds it to `package.json`.
4
4
  */
5
- export const release = '0.13.0';
5
+ export const release = '0.14.0';
6
6
  /**
7
7
  * A WeakMap every copy of blits loaded in one realm shares, so a fact one copy records about a
8
8
  * patch or channel it made is read by another copy's mix. The name carries a version, bumped when
package/dist/strays.js CHANGED
@@ -117,7 +117,7 @@ function changeAhead(to, id, motion, given, change) {
117
117
  made.set(motion, bySubject);
118
118
  }
119
119
  const list = bySubject.get(subject);
120
- const c = { at, to: change.to, v: change.v };
120
+ const c = { at, to: change.to, x: change.x, v: change.v };
121
121
  if (list === undefined)
122
122
  bySubject.set(subject, [c]);
123
123
  else {
@@ -134,7 +134,7 @@ export interface Mix<I, O, H = unknown> {
134
134
  * state keeps it once, not per member. One `spec` cues them all, and a member given as a
135
135
  * `BlendMember` carries its own `name`, `tags` and `hits` in place of the spec's.
136
136
  */
137
- blend(members: readonly (Patch<I, O, unknown, H> | BlendMember<I, O, H>)[], by: Signal<I, H>, spec?: Omit<VoiceSpec<I, O, H>, 'patch' | 'weight' | 'locus'>): Handle<I>[];
137
+ blend(members: readonly (Patch<I, O, any, H> | BlendMember<I, O, H>)[], by: Signal<I, H>, spec?: Omit<VoiceSpec<I, O, H>, 'patch' | 'weight' | 'locus'>): Handle<I>[];
138
138
  /**
139
139
  * Cues an owner: a voice with no patch that holds the voices cued with `owner` set to its handle,
140
140
  * and plays them on its own clock, so they can be placed, timed and faded as one. Its handle acts
@@ -308,7 +308,9 @@ export interface Mix<I, O, H = unknown> {
308
308
  * Another frame would change no pose, so a host's frame loop may sleep: every voice is done, frozen
309
309
  * at a plain weight, or a motion whose every subject has landed on its target. At rate 0 it is
310
310
  * enough that every voice not done has started, at a plain weight, with no anchor. A change made
311
- * since the last sync, a retarget or a rate included, makes it false until the next.
311
+ * since the last sync, a retarget or a rate included, makes it false until the next. Read as true,
312
+ * it tells the mix the host may sleep: a voice cued with no `start` before the next sync starts
313
+ * at that sync.
312
314
  */
313
315
  readonly inert: boolean;
314
316
  /**
@@ -110,6 +110,8 @@ export type MotionSpec = {
110
110
  /** The friction's time constant, ms. */
111
111
  readonly ms: number;
112
112
  readonly settle: number;
113
+ /** The spring back's, per second squared; only on a glide with bounds. */
114
+ readonly stiffness?: number;
113
115
  } | {
114
116
  readonly kind: 'tween';
115
117
  /** How long a stretch takes; undefined where it varies by subject, kept on the patch then. */
@@ -47,7 +47,7 @@ export interface SpanHints {
47
47
  * @category voice
48
48
  */
49
49
  export interface VoiceSpec<I, O, H = unknown> extends SpanHints {
50
- patch: Patch<I, O, unknown, H>;
50
+ patch: Patch<I, O, any, H>;
51
51
  /**
52
52
  * What `history.revive` rebuilds this spec from, so a history `store` can page the voice out once
53
53
  * it has left. A voice cued without it stays in memory for as long as history reaches it.
@@ -72,7 +72,9 @@ export interface VoiceSpec<I, O, H = unknown> extends SpanHints {
72
72
  * When the voice starts, in ms on the host's clock, the one it passes `sync`: a rAF timestamp and
73
73
  * `performance.now()` share an origin, `Date.now()` does not. Default: the time the mix last
74
74
  * synced to, and 0 for a voice cued before the first sync, which that sync finds already as far
75
- * in as its timestamp.
75
+ * in as its timestamp. Where the host read `inert` as true since the last sync, its frame loop
76
+ * may be asleep, and the default is the next sync instead, so a voice cued after a long pause
77
+ * starts there rather than that far in.
76
78
  */
77
79
  start?: number;
78
80
  /**
@@ -175,7 +177,7 @@ export interface VoiceSpec<I, O, H = unknown> extends SpanHints {
175
177
  * @category blending
176
178
  */
177
179
  export interface BlendMember<I, O, H = unknown> {
178
- patch: Patch<I, O, unknown, H>;
180
+ patch: Patch<I, O, any, H>;
179
181
  /** What other voices call this member by. Default: the spec's `name`. */
180
182
  name?: string;
181
183
  /** Default: the spec's `tags`. */
@@ -384,6 +386,11 @@ export interface Handle<I = unknown> {
384
386
  * `glide`: the patch's own `push`. Throws for any other voice.
385
387
  */
386
388
  push(subject: I, velocity: Value, at?: number): void;
389
+ /**
390
+ * Puts `subject` at `value`, keeping its velocity, for a voice playing any motion patch: the
391
+ * patch's own `set`. Throws for any other voice.
392
+ */
393
+ set(subject: I, value: Value, at?: number): void;
387
394
  /**
388
395
  * Where `subject` is and how fast it moves, for a voice playing a motion patch: the patch's own
389
396
  * `read`. Undefined for any other voice, and wherever the patch's `read` is.
package/dist/voice.d.ts CHANGED
@@ -86,6 +86,8 @@ export interface Subject<S> {
86
86
  weight: number;
87
87
  /** Left at rest during a handover, so it stops contributing. */
88
88
  rested: boolean;
89
+ /** The mix time it rested at, which a seek reads to know whether it had by then; NaN until it does. */
90
+ restedAt: number;
89
91
  /**
90
92
  * Per written channel, whether a rest-less contribution is on: 0 unknown, 1 on, 2 off; null until
91
93
  * a rest-less channel asks.
@@ -333,6 +335,8 @@ export declare class Voice<I, O> {
333
335
  outSet: boolean;
334
336
  /** The host time a pinned start was given at, NaN for none: a seek back pins it again. */
335
337
  pinned: number;
338
+ /** Cued with no start while its mix slept, so the next sync sets its start. */
339
+ waking: boolean;
336
340
  /** Whether its start is still to be fixed by an anchor, so it waits pending. */
337
341
  placing: boolean;
338
342
  /** What each member of a join last answered, kept for once its target has left. */
package/dist/voice.js CHANGED
@@ -226,6 +226,8 @@ export class Voice {
226
226
  outSet = false;
227
227
  /** The host time a pinned start was given at, NaN for none: a seek back pins it again. */
228
228
  pinned = Number.NaN;
229
+ /** Cued with no start while its mix slept, so the next sync sets its start. */
230
+ waking = false;
229
231
  /** Whether its start is still to be fixed by an anchor, so it waits pending. */
230
232
  placing = false;
231
233
  /** What each member of a join last answered, kept for once its target has left. */
package/docs/schema.html CHANGED
@@ -512,12 +512,13 @@ interface Handle&lt;I = unknown&gt; {
512
512
  <span class="cm">/** The weight this voice gave `subject` the last frame it was probed: after fades and its signal, before a
513
513
  locus folds it. 0 where it gave none. One number on a record the mix keeps anyway, so free unasked. */</span>
514
514
  weightOf(subject: I): number;
515
- <span class="cm">/** A motion patch's own `to`, `push`, and `read`, reached without holding the patch; `at` is a voice time. `to`
516
- throws for a voice not playing a `spring` or a `tween`, and `push` for one not playing a `spring` or a `glide`;
517
- `read` gives undefined for a voice playing no motion patch. `Value` is a number or an array of them, and
515
+ <span class="cm">/** A motion patch's own `to`, `push`, `set`, and `read`, reached without holding the patch; `at` is a voice time. `to`
516
+ throws for a voice not playing a `spring` or a `tween`, `push` for one not playing a `spring` or a `glide`, and
517
+ `set` for one playing no motion patch; `read` gives undefined for a voice playing no motion patch. `Value` is a number or an array of them, and
518
518
  `Motion` is `{ value, velocity }`. See Springs, glides and tweens. */</span>
519
519
  to(subject: I, target: Value, at?: number): void;
520
520
  push(subject: I, velocity: Value, at?: number): void;
521
+ set(subject: I, value: Value, at?: number): void;
521
522
  read(subject: I, at?: number): Motion&lt;Value&gt; | undefined;
522
523
  <span class="cm">/** Resolves when the voice has been removed from the mix, however that happened. */</span>
523
524
  readonly done: Promise&lt;void&gt;;
@@ -755,9 +756,14 @@ mix.book({
755
756
  mix.cue({ patch: s });
756
757
  s.to(node, 40); <span class="cm">// heads elsewhere from wherever it is, keeping its velocity</span>
757
758
  s.push(node, 600); <span class="cm">// a kick: same target, new velocity</span>
759
+ s.set(node, 20); <span class="cm">// moved there: same target, same velocity</span>
758
760
  s.read(node); <span class="cm">// { value, velocity }, for handing the motion to another patch</span>
759
761
 
760
762
  const g = glide&lt;Node, Pose&gt;('x', { from: 0, velocity: 1200, ms: 325 }); <span class="cm">// a fling: rests 390 past the start</span>
763
+ g.set(node, 80); <span class="cm">// under a dragging pointer</span>
764
+ g.push(node, -900); <span class="cm">// let go: coasts on from there</span>
765
+
766
+ const list = glide&lt;Node, Pose&gt;('y', { from: 0, bounds: [-2400, 0] }); <span class="cm">// springs back from an edge it coasts past</span>
761
767
 
762
768
  const t = tween&lt;Node, Pose&gt;('x', { from: (n) =&gt; n.x, to: (n) =&gt; n.goal, ms: 400, ease: 'ease-out' });
763
769
  t.to(node, 40); <span class="cm">// from wherever it is, over a full 400 ms again</span></code></pre>
@@ -765,6 +771,8 @@ t.to(node, 40); <span class="cm">// from wherever it is, over a ful
765
771
  <ul>
766
772
  <li><b>Time is the voice's</b>, so <code>rate</code> and <code>seek</code> apply. A change takes effect at the voice time it names, or, named none, at the subject's own voice time at the mix's latest frame, whether or not the host probed it then; naming one is what makes a retarget land the same at any frame rate. A subject no frame of its voice has met yet, or still inside its stagger, takes an unnamed change at its first read, after any named change due by then. <code>read</code> with no time answers at that same latest frame, counting every change due by then, and answers nothing once the voice is gone.</li>
767
773
  <li><b>History is kept to the mix's horizon.</b> Each subject keeps every stretch a read may still reach, so a read earlier than a change finds the stretch that was playing then. A mix without <code>history</code> keeps only the stretch playing now.</li>
774
+ <li id="fling"><b>Dragging and flinging.</b> <code>@msb235/blits/fling</code> wires pointer events to a motion patch: <code>fling(element, glide, subject, { axis, scale })</code> stops the subject where it is on a press, <code>set</code>s it under the pointer on each move, and <code>push</code>es it on release at the pointer&rsquo;s velocity, which <code>track</code> reads as the least-squares slope of the last 100 ms of samples, or zero if the pointer sat still for 40 ms before letting go. <code>track</code> has no listeners, so anything with a time and a position can feed it. It is a subpath because it is the package&rsquo;s only code that knows a page exists, and it asks only for <code>addEventListener</code>, <code>removeEventListener</code> and <code>setPointerCapture</code>, so the package builds without the DOM&rsquo;s types. Since the coast is a voice like any other, other voices read it: a signal over <code>glide.read(subject)</code> drives parallax off its value or a blur off its velocity. Past an edge of a glide's <code>bounds</code>, a drag carries the subject less and less of the way, never more than <code>give</code> (default 120 units) past: at the edge it follows 0.55 of the drag, as iOS's scroll views do, and <code>give: 0</code> holds it at the edge. A press on a subject springing back past an edge holds it where it is. Added 2026-10-24.</li>
775
+ <li id="bounds"><b>Bounds.</b> <code>glide(…, { bounds: [min, max], stiffness })</code> gives each axis the least and greatest value a coast comes to rest at, the same for every subject or one pair per subject; <code>g.bounds(subject)</code> reads them back. A coast that would stop inside them is the glide without bounds, to the bit. One that would stop past an edge coasts until it crosses it, then springs back on a critically damped spring of <code>stiffness</code> (default 170, a spring&rsquo;s), starting at the speed it crossed at, so neither where it is nor how fast it moves jumps. One let go already past an edge springs back from there, unless its coast carries it back inside, when it coasts with no spring at all. A <code>set</code> past an edge leaves the subject there, coasting on, until the next <code>push</code>: that is how a drag holds it out, and why a <code>push(subject, 0)</code> lets it go. It needed no new state: a stretch still holds where it starts, how fast, and where it heads, which is now the edge when the coast stops past one, and the closed form tells the three cases apart from those numbers alone, so lanes, seeks and history carry it as they carry any glide. Added 2026-10-24.</li>
768
776
  <li><b>A spring or a glide settles.</b> Within <code>settle</code> (1e-4) of where it heads, and that slow, it lands there exactly, so <code>atRest</code> sees it. <code>settle: 0</code> keeps the curve exact. A tween needs none: it lands on its target exactly at <code>ms</code>.</li>
769
777
  <li><b>The state is the patch's own</b>, per subject, rather than the mix's, because the host retargets through the patch, or through <code>handle.to</code>, <code>push</code>, and <code>read</code>, which reach it. Cue each one on one voice. A patch moves every subject on the same number of axes, and throws on a subject, a <code>to</code> or a <code>push</code> with another, leaving the subject as it was. Its values are the kind its channel holds, a number for a number and an array for an array: one every subject shares is refused at <code>cue</code>, one given per subject on that subject's first sample.</li>
770
778
  <li><b>The constants are data.</b> <code>patch.motion</code>, a <code>MotionSpec</code>, is <code>{ kind: 'spring', stiffness, damping, mass, settle }</code> or <code>{ kind: 'glide', ms, settle }</code> or <code>{ kind: 'tween', ms, ease }</code>, with <code>ms</code> undefined where it varies by subject, so an engine can run one without calling <code>at</code>. Where each subject starts and heads stays on the patch, since either may be a function of the subject.</li>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Concurrent effects: each on its own clock with its own state and weight, folded into one value per subject per frame",
5
5
  "keywords": [
6
6
  "animation",
@@ -34,6 +34,10 @@
34
34
  "./lean": {
35
35
  "types": "./dist/lean.d.ts",
36
36
  "default": "./dist/lean.js"
37
+ },
38
+ "./fling": {
39
+ "types": "./dist/fling.d.ts",
40
+ "default": "./dist/fling.js"
37
41
  }
38
42
  },
39
43
  "files": [