@msb235/blits 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,61 @@ 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
+
36
+ ## 0.13.0
37
+
38
+ ### Breaking
39
+
40
+ - A mix's stock `band` is on at any weight above 0 and off at 0, where it was on above 0.6 and off
41
+ below 0.4. A channel with no rest, such as `last()`, now shows its value through a whole fade in or
42
+ out. Pass `band: { on: 0.6, off: 0.4 }` for the old behavior. `fold` follows the same default.
43
+
44
+ ### Added
45
+
46
+ - A mix's `loci`: tags that act as loci, so a voice carrying one folds with the others carrying it,
47
+ as if its `locus` named the tag.
48
+
49
+ ### Fixed
50
+
51
+ - A seek with `state: 'keep'` that sends a subject back before its start now starts it afresh there.
52
+ It kept the state it had stepped to, so what it showed, and how it played on from its start,
53
+ depended on how often the host had read it, and a mix seek could put back the state from before.
54
+ - A mix sought back and then ahead to where it stood reads the same `now` on its next sync as one
55
+ never sought. At some mix rates it read one rounding step off, and every later sync kept the error.
56
+ - Replacing voices costs less a frame: a crowd catches up only the rows marked stale, where it
57
+ scanned every row, and a few retired voices are spliced out of the list. One subject leaving for
58
+ good each frame and a new one coming (`turnover^`, 10,000 subjects) takes 0.286 ms a frame
59
+ against 0.315, and one voice replaced in place each frame (`churn^`) 0.224 against 0.252 (teitou,
60
+ medians of nine runs of 5,000 frames, each in its own process); steady voices are unchanged.
61
+
7
62
  ## 0.12.1
8
63
 
9
64
  ### Added
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
@@ -1,4 +1,9 @@
1
1
  import type { Channel, Kit } from './types.js';
2
+ /** The band a mix switches a rest-less channel's contribution across where its options name none. */
3
+ export declare const STOCK_BAND: {
4
+ readonly on: number;
5
+ readonly off: 0;
6
+ };
2
7
  /** The stock numeric channels' `lerp`, which `vec` applies per axis and lanes call directly. */
3
8
  export declare const lerpNumber: (a: number, b: number, u: number) => number;
4
9
  /**
package/dist/channels.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { shared } from './shared.js';
2
+ /** The band a mix switches a rest-less channel's contribution across where its options name none. */
3
+ export const STOCK_BAND = { on: Number.MIN_VALUE, off: 0 };
2
4
  /** The stock numeric channels' `lerp`, which `vec` applies per axis and lanes call directly. */
3
5
  export const lerpNumber = (a, b, u) => a + (b - a) * u;
4
6
  const mix = lerpNumber;
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/crowd.d.ts CHANGED
@@ -92,6 +92,8 @@ export declare class Crowd<I, O> implements Positions<I, O> {
92
92
  dead: number;
93
93
  /** Whether some row is `Flag.STALE`, for `freshen` to copy before the fill's loop. */
94
94
  stale: boolean;
95
+ /** The rows marked stale since the last `freshen`, which checks each is still stale. */
96
+ staleRows: number[];
95
97
  readonly xs: Float64Array;
96
98
  readonly vs: Float64Array;
97
99
  /** What a keys row reads into, folded before the next row reads. */
package/dist/crowd.js CHANGED
@@ -54,6 +54,8 @@ export class Crowd {
54
54
  dead = 0;
55
55
  /** Whether some row is `Flag.STALE`, for `freshen` to copy before the fill's loop. */
56
56
  stale = false;
57
+ /** The rows marked stale since the last `freshen`, which checks each is still stale. */
58
+ staleRows = [];
57
59
  xs;
58
60
  vs;
59
61
  /** What a keys row reads into, folded before the next row reads. */
@@ -82,6 +84,7 @@ export class Crowd {
82
84
  restale(p, flags) {
83
85
  this.hot[p * this.stride + 0 /* Hot.FLAGS */] = flags | 4 /* Flag.STALE */;
84
86
  this.stale = true;
87
+ this.staleRows.push(p);
85
88
  }
86
89
  /** Samples of another width than the channel's, which `samples` cannot hold, by row. */
87
90
  odd = null;
@@ -347,7 +350,12 @@ export function freshen(c) {
347
350
  c.stale = false;
348
351
  const hot = c.hot;
349
352
  const H = c.stride;
350
- for (let p = 0; p < c.list.length; p++) {
353
+ const rows = c.staleRows;
354
+ c.staleRows = [];
355
+ for (let i = 0; i < rows.length; i++) {
356
+ const p = rows[i];
357
+ if (p >= c.list.length)
358
+ continue;
351
359
  const f = hot[p * H + 0 /* Hot.FLAGS */];
352
360
  if ((f & 4 /* Flag.STALE */) === 0 || (f & 128 /* Flag.MOTION */) === 0)
353
361
  continue;
package/dist/cue.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Mixer } from './mixer.js';
2
2
  import type { Handle, Kit, Patch, VoiceSpec } from './types.js';
3
- export declare function cue<I, O>(mix: Mixer<I, O>, spec: VoiceSpec<I, O>): Handle<I>;
3
+ export declare function cue<I, O>(mix: Mixer<I, O>, given: VoiceSpec<I, O>): Handle<I>;
4
4
  /** Refuses a patch writing a channel `kit` lacks, or holds as another kind than it was written for. */
5
5
  export declare function fitsKit<I, O, S, H>(patch: Patch<I, O, S, H>, kit: Kit<O>): void;
6
6
  /** Refuses a spec whose patch the mix cannot play, or whose state its history cannot keep. */
package/dist/cue.js CHANGED
@@ -17,7 +17,8 @@ import { checkChild, refit } from './spans.js';
17
17
  import { cuedById, motionOwner } from './strays.js';
18
18
  import { record, unplayable } from './tape.js';
19
19
  import { Voice } from './voice.js';
20
- export function cue(mix, spec) {
20
+ export function cue(mix, given) {
21
+ const spec = locusOf(mix, given);
21
22
  const patch = spec.patch;
22
23
  playable(mix, spec);
23
24
  if (spec.from === 'current' && patch.form !== 'keys')
@@ -43,7 +44,10 @@ export function cue(mix, spec) {
43
44
  ? mixAt(mix, spec.start - mix.offset)
44
45
  : Number.isNaN(mix.now)
45
46
  ? 0
46
- : 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;
47
51
  const voice = new Voice(mix.nextId++, spec, patch, spec.fade ?? noFade, local, start, mix.slotOf, mix.channels, mix.opts.host, mix.send, owner);
48
52
  if (patch === ownerPatch && mix.fitting !== null)
49
53
  voice.fitting = mix.fitting;
@@ -55,6 +59,10 @@ export function cue(mix, spec) {
55
59
  checkChild(spec, voice);
56
60
  if (mix.pace !== null && owner === null && spec.start !== undefined && voice.state === 'pending')
57
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
+ }
58
66
  enter(mix, voice);
59
67
  const h = handle(mix, voice);
60
68
  voice.handle = h;
@@ -88,6 +96,22 @@ export function fitsKit(patch, kit) {
88
96
  throw new Error(`blits: channel ${String(channel)} is ${here.kind ?? 'a custom channel'} in this kit, but the patch was written for ${wanted.kind ?? 'a custom channel'}`);
89
97
  }
90
98
  }
99
+ /** The spec with its `locus` set from a tag the mix declares one, where it carries one. */
100
+ function locusOf(mix, spec) {
101
+ const loci = mix.opts.loci;
102
+ const tags = spec.tags;
103
+ if (loci === undefined || tags === undefined)
104
+ return spec;
105
+ let locus = spec.locus;
106
+ for (const tag of tags) {
107
+ if (!loci.includes(tag) || tag === locus)
108
+ continue;
109
+ if (locus !== undefined)
110
+ throw new Error(`blits: a voice folds in one locus, not both ${locus} and ${tag}`);
111
+ locus = tag;
112
+ }
113
+ return locus === spec.locus ? spec : { ...spec, locus };
114
+ }
91
115
  /** Refuses a spec whose patch the mix cannot play, or whose state its history cannot keep. */
92
116
  export function playable(mix, spec) {
93
117
  const patch = spec.patch;
@@ -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
@@ -2,6 +2,7 @@ import { foldNumber } from './channels.js';
2
2
  import { passAt, phaseAt, phaseBefore, silent, stepOf } from './clock.js';
3
3
  import { copy as copyValue, first } from './clone.js';
4
4
  import { open as openRecord, shut as shutRecord, weighAt } from './everyone.js';
5
+ import { waits } from './held.js';
5
6
  import { apart, copyState, recordHost, remember } from './history.js';
6
7
  import { stateful } from './hosts.js';
7
8
  import { locusScratch } from './locus.js';
@@ -59,6 +60,13 @@ function rebuild(mix, voice, subject, now, held) {
59
60
  held.stepped = held.since < now ? held.since : now;
60
61
  held.probed = Number.NaN;
61
62
  }
63
+ /** Whether a seek since its last read moved a stateful record's origin, as one back before it does. */
64
+ function sentBack(mix, voice, held) {
65
+ const p = voice.patch;
66
+ if (p.state === undefined && p.step === undefined && !voice.keeping)
67
+ return false;
68
+ return mix.sinceOf(voice, held.delay) !== held.since;
69
+ }
62
70
  /**
63
71
  * One live voice's delta for one subject this frame, through its record for the subject, or null
64
72
  * when it does not reach. Its weight is left in `w`.
@@ -71,6 +79,11 @@ export function contribution(voice, subject, now, held) {
71
79
  return null;
72
80
  if (held.rebuilt !== voice.rebuilds)
73
81
  rebuild(this, voice, subject, now, held);
82
+ else if (held.seeks !== voice.seeks && sentBack(this, voice, held)) {
83
+ // A seek sent it back before its start, so it starts afresh there, whatever state it kept.
84
+ rebuild(this, voice, subject, now, held);
85
+ waits(voice, held, now);
86
+ }
74
87
  const raw = voice.rawAt(now, held.delay);
75
88
  const early = voice.before(raw, now);
76
89
  if (early && !voice.holdsFirst)
@@ -177,6 +190,7 @@ export function contribution(voice, subject, now, held) {
177
190
  if (voice.out?.rest && this.isRest(delta)) {
178
191
  held.weight = 0;
179
192
  held.rested = true;
193
+ held.restedAt = now;
180
194
  voice.restedCount++;
181
195
  return null;
182
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
@@ -115,7 +115,7 @@ export class VoiceHandle {
115
115
  // A pending voice's clock is anchored at its start, so it starts there on time.
116
116
  if (voice.state !== 'pending') {
117
117
  const u = this.#host.nowFor(voice);
118
- leave(voice, u);
118
+ leave(voice, u, true);
119
119
  voice.rebase(u);
120
120
  }
121
121
  voice.anchorElapsed = elapsed;
@@ -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/held.d.ts CHANGED
@@ -24,6 +24,8 @@ export declare function sinceOf<I, O>(this: Mixer<I, O>, voice: Voice<I, O>, del
24
24
  * the voice first showed, for one freezing before.
25
25
  */
26
26
  export declare function shownOf<I, O>(this: Mixer<I, O>, voice: Voice<I, O>, since: number): number;
27
+ /** Lists a record whose origin is still ahead of `now`, for a later change to the clock to move. */
28
+ export declare function waits<I, O>(voice: Voice<I, O>, held: Subject<unknown>, now: number): void;
27
29
  /**
28
30
  * A pending voice starts at `now`. Starting anywhere but 0 it was sought there, so a record made
29
31
  * while it waited is made again for where its clock is.
package/dist/held.js CHANGED
@@ -64,19 +64,7 @@ export function held(voice, subject, slot, guess = null) {
64
64
  held.unknown = (this.backward && voice.spec.from === 'current') || fresh;
65
65
  }
66
66
  voice.subjects.set(subject, held);
67
- if (!(since <= now)) {
68
- voice.early ??= [];
69
- const early = voice.early;
70
- // Prunes at each doubling, so records whose origin has passed are not kept to the voice's end.
71
- if (early.length >= 64 && (early.length & (early.length - 1)) === 0) {
72
- let kept = 0;
73
- for (const h of early)
74
- if (!(h.since <= now))
75
- early[kept++] = h;
76
- early.length = kept;
77
- }
78
- early.push(held);
79
- }
67
+ waits(voice, held, now);
80
68
  // A subject a voice saw while it shared one record was counted then.
81
69
  if (sighted(voice, slot))
82
70
  inherit(voice, held, slot);
@@ -137,6 +125,22 @@ export function shownOf(voice, since) {
137
125
  const from = since < start ? start : since;
138
126
  return voice.freezesBefore && voice.opened < from ? voice.opened : from;
139
127
  }
128
+ /** Lists a record whose origin is still ahead of `now`, for a later change to the clock to move. */
129
+ export function waits(voice, held, now) {
130
+ if (held.since <= now)
131
+ return;
132
+ voice.early ??= [];
133
+ const early = voice.early;
134
+ // Prunes at each doubling, so records whose origin has passed are not kept to the voice's end.
135
+ if (early.length >= 64 && (early.length & (early.length - 1)) === 0) {
136
+ let kept = 0;
137
+ for (const h of early)
138
+ if (!(h.since <= now))
139
+ early[kept++] = h;
140
+ early.length = kept;
141
+ }
142
+ early.push(held);
143
+ }
140
144
  /**
141
145
  * A pending voice starts at `now`. Starting anywhere but 0 it was sought there, so a record made
142
146
  * while it waited is made again for where its clock is.
@@ -154,7 +158,11 @@ export function begin(mix, voice, now) {
154
158
  }
155
159
  /** Counts a record whose `since` was still ahead from where its voice's clock now puts it. */
156
160
  export function origin(mix, voice, held) {
161
+ const was = held.since;
157
162
  held.since = mix.sinceOf(voice, held.delay);
163
+ // Sent back before a start it had passed: its next read starts it afresh.
164
+ if (held.since !== was && (held.ticks > 0 || held.stepped > was))
165
+ held.rebuilt = -1;
158
166
  held.shown = mix.shownOf(voice, held.since);
159
167
  if (held.ticks === 0 && held.stepped > held.since)
160
168
  held.stepped = held.since;
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;