animader 0.0.0-stage → 0.1.1

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/src/log.js ADDED
@@ -0,0 +1,304 @@
1
+ // animader/log - the session writer and the replay walk (plan 01,
2
+ // phase 5a).
3
+ //
4
+ // The writer's job is to produce what a DOM produces: pointer, wheel, key
5
+ // and resize events become the core records a recording contains, with
6
+ // the lanes taken from pngine's `attachPointerTracking`
7
+ // (npm/pngine/src/init-core.js:98-150):
8
+ //
9
+ // - position is the canvas-normalised coordinate, clamped to 0..1 and
10
+ // quantised to u16 by round(v * 65535); 0 is the left/top edge, 65535
11
+ // the right/bottom.
12
+ // - buttons are the DOM `MouseEvent.buttons` bitmask, modifiers the
13
+ // four pngine bits (1 shift, 2 ctrl, 4 alt, 8 meta). pngine's
14
+ // `init-core.js` also carries a Space bit at 16; it is outside the
15
+ // log's `:mods` domain (the four bits above), so the writer drops it
16
+ // and `mini.js:271` already omits it.
17
+ // - pressure is pngine's rule: `e.pressure || 0.5` on a down, and
18
+ // `e.pressure || (e.buttons ? 0.5 : 0)` on a move, quantised to u16.
19
+ // - the wheel's `:dx`/`:dy` are 24.8 device-pixel deltas:
20
+ // round(e.delta * 256 * the deltaMode factor), clamped to i32.
21
+ // `WheelEvent.deltaMode` 0 is device pixels; 1 is lines at the
22
+ // writer's `WHEEL_LINE_PX` (16) and 2 is pages at `WHEEL_PAGE_PX`
23
+ // (800), because the DOM carries counts, not pixels, in those modes
24
+ // and no browser-independent pixel value exists. The folds read only
25
+ // the sign of these lanes (src/EventLog.zig), so the conversion is
26
+ // about the recorded text's unit, not a fold input.
27
+ // - a release follows the phase 3 rule: buttons and pressure zero, the
28
+ // position and modifiers held from before it.
29
+ //
30
+ // The writer never emits a verb and no line formats a float: every line
31
+ // is a template string over the integers just stamped. A `pointermove`
32
+ // while no button is down is not recorded: the log's sequence rule has no
33
+ // move-while-up, and the player's hover deltas have no record (the one
34
+ // place the recorded stream cannot be the live struct; the folds read the
35
+ // stream).
36
+ //
37
+ // `:at` is `Math.round(clock())`, required to be a safe integer in
38
+ // 0..2^53-1 (the schema's `at-usec` domain); a clock outside it throws
39
+ // rather than writing a line the parser must refuse.
40
+
41
+ const SJON_ESCAPES = {
42
+ "\0": "\\0",
43
+ '"': '\\"',
44
+ "\\": "\\\\",
45
+ "\n": "\\n",
46
+ "\r": "\\r",
47
+ "\t": "\\t",
48
+ };
49
+
50
+ /** The SJON string escape set (SJON/src/StringEscape.zig) for one value. */
51
+ const quote = (value) =>
52
+ '"' + String(value).replace(/[\0"\\\n\r\t]/g, (c) => SJON_ESCAPES[c]) + '"';
53
+
54
+ const clamp01 = (v) => {
55
+ if (!Number.isFinite(v)) return 0;
56
+ if (v < 0) return 0;
57
+ if (v > 1) return 1;
58
+ return v;
59
+ };
60
+
61
+ const q16 = (v) => Math.round(clamp01(v) * 65535);
62
+
63
+ const clampI32 = (v) => {
64
+ if (!Number.isFinite(v)) return 0;
65
+ if (v < -2147483648) return -2147483648;
66
+ if (v > 2147483647) return 2147483647;
67
+ return Math.round(v);
68
+ };
69
+
70
+ /**
71
+ * The writer's line and page wheel factors, in device pixels. The DOM's
72
+ * `WheelEvent.deltaMode` 1 and 2 carry counts, not pixels, and no
73
+ * browser-independent pixel value exists, so the writer pins these: 16
74
+ * is a default CSS line box, 800 a nominal page. The values are pinned
75
+ * by the three-mode red in test/log.test.js.
76
+ */
77
+ const WHEEL_LINE_PX = 16;
78
+ const WHEEL_PAGE_PX = 800;
79
+
80
+ /** `WheelEvent.deltaMode` -> the device-pixel factor of its counts. */
81
+ const wheelFactor = (mode) => (mode === 1 ? WHEEL_LINE_PX : mode === 2 ? WHEEL_PAGE_PX : 1);
82
+
83
+ const modsOf = (e) =>
84
+ (e.shiftKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.altKey ? 4 : 0) | (e.metaKey ? 8 : 0);
85
+
86
+ const ptrOf = (e) => (Number.isInteger(e.pointerId) ? e.pointerId : 0) & 0xff;
87
+
88
+ const requireDim = (value, what) => {
89
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
90
+ throw new RangeError(`attachLog: ${what} must be an integer 1..65535, got ${value}`);
91
+ }
92
+ return value;
93
+ };
94
+
95
+ const EMPTY_V = () => [0, 0, 0];
96
+
97
+ /**
98
+ * Record pointer, wheel, key and resize events on a canvas-like
99
+ * EventTarget.
100
+ *
101
+ * @param {object} canvas an EventTarget with `width`/`height` and
102
+ * `getBoundingClientRect()` (the canvas element, or a test double).
103
+ * @param {() => number} clock session microseconds; `:at` is
104
+ * `Math.round(clock())`, required inside 0..2^53-1.
105
+ * @returns {{ drain: () => object[], text: () => string, detach: () => void }}
106
+ * `drain()` hands the records appended since the last call, `text()`
107
+ * the canonical session text so far (deterministic: the same handle
108
+ * answers the same string), `detach()` removes every listener through
109
+ * one AbortController.
110
+ */
111
+ export function attachLog(canvas, clock) {
112
+ const sessionW = requireDim(canvas.width, "canvas.width");
113
+ const sessionH = requireDim(canvas.height, "canvas.height");
114
+ const sessionLine = `(session :version 1 :canvas [${sessionW} ${sessionH}])`;
115
+
116
+ const ac = new AbortController();
117
+ const signal = ac.signal;
118
+ const records = [];
119
+ const lines = [];
120
+ let drained = 0;
121
+ /** Pointer ids currently down: the log's `down (move)* (up|cancel)`. */
122
+ const down = new Set();
123
+ /** The held u16 position and modifier bits a release carries. */
124
+ let lastX = 0;
125
+ let lastY = 0;
126
+ let lastMods = 0;
127
+
128
+ const stamp = () => {
129
+ const raw = clock();
130
+ const t = Math.round(raw);
131
+ if (!Number.isSafeInteger(t) || t < 0) {
132
+ throw new RangeError(`attachLog: clock() must answer microseconds in 0..2^53-1, got ${raw}`);
133
+ }
134
+ return t;
135
+ };
136
+
137
+ const emit = (record, line) => {
138
+ records.push(record);
139
+ lines.push(line);
140
+ };
141
+
142
+ const unitOf = (e) => {
143
+ const rect = canvas.getBoundingClientRect();
144
+ const nx = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0;
145
+ const ny = rect.height > 0 ? (e.clientY - rect.top) / rect.height : 0;
146
+ return { x: q16(nx), y: q16(ny), mods: modsOf(e) };
147
+ };
148
+
149
+ canvas.addEventListener(
150
+ "pointerdown",
151
+ (e) => {
152
+ const at = stamp();
153
+ const id = ptrOf(e);
154
+ const { x, y, mods } = unitOf(e);
155
+ const buttons = (e.buttons ?? 0) & 0xff;
156
+ const p = q16(e.pressure || 0.5);
157
+ lastX = x;
158
+ lastY = y;
159
+ lastMods = mods;
160
+ down.add(id);
161
+ emit(
162
+ { at, kind: "down", ptr: id, buttons, mods, x, y, p, str: null, v: EMPTY_V() },
163
+ `(down :at ${at} :ptr ${id} :x ${x} :y ${y} :b ${buttons} :p ${p} :mods ${mods})`,
164
+ );
165
+ },
166
+ { signal },
167
+ );
168
+
169
+ canvas.addEventListener(
170
+ "pointermove",
171
+ (e) => {
172
+ const id = ptrOf(e);
173
+ if (!down.has(id)) return;
174
+ const at = stamp();
175
+ const { x, y, mods } = unitOf(e);
176
+ const buttons = (e.buttons ?? 0) & 0xff;
177
+ const p = q16(e.pressure || (e.buttons ? 0.5 : 0));
178
+ lastX = x;
179
+ lastY = y;
180
+ lastMods = mods;
181
+ emit(
182
+ { at, kind: "move", ptr: id, buttons, mods, x, y, p, str: null, v: EMPTY_V() },
183
+ `(move :at ${at} :ptr ${id} :x ${x} :y ${y} :b ${buttons} :p ${p} :mods ${mods})`,
184
+ );
185
+ },
186
+ { passive: true, signal },
187
+ );
188
+
189
+ canvas.addEventListener(
190
+ "pointerup",
191
+ (e) => {
192
+ const id = ptrOf(e);
193
+ if (!down.has(id)) return;
194
+ down.delete(id);
195
+ const at = stamp();
196
+ emit(
197
+ { at, kind: "up", ptr: id, buttons: 0, mods: lastMods, x: lastX, y: lastY, p: 0, str: null, v: EMPTY_V() },
198
+ `(up :at ${at} :ptr ${id} :x ${lastX} :y ${lastY} :b 0 :p 0 :mods ${lastMods})`,
199
+ );
200
+ },
201
+ { signal },
202
+ );
203
+
204
+ canvas.addEventListener(
205
+ "pointercancel",
206
+ (e) => {
207
+ const id = ptrOf(e);
208
+ if (!down.has(id)) return;
209
+ down.delete(id);
210
+ const at = stamp();
211
+ emit(
212
+ { at, kind: "cancel", ptr: id, buttons: 0, mods: 0, x: 0, y: 0, p: 0, str: null, v: EMPTY_V() },
213
+ `(cancel :at ${at} :ptr ${id})`,
214
+ );
215
+ },
216
+ { signal },
217
+ );
218
+
219
+ canvas.addEventListener(
220
+ "wheel",
221
+ (e) => {
222
+ const at = stamp();
223
+ const id = ptrOf(e);
224
+ const factor = wheelFactor(e.deltaMode);
225
+ const dx = clampI32(e.deltaX * factor * 256);
226
+ const dy = clampI32(e.deltaY * factor * 256);
227
+ const mods = modsOf(e);
228
+ lastMods = mods;
229
+ emit(
230
+ { at, kind: "wheel", ptr: id, buttons: 0, mods, x: lastX, y: lastY, p: 0, str: null, v: [dx, dy, 0] },
231
+ `(wheel :at ${at} :ptr ${id} :x ${lastX} :y ${lastY} :dx ${dx} :dy ${dy} :mods ${mods})`,
232
+ );
233
+ },
234
+ { passive: false, signal },
235
+ );
236
+
237
+ const key = (isDown) => (e) => {
238
+ const at = stamp();
239
+ const code = e.code ?? "";
240
+ const mods = modsOf(e);
241
+ const buttons = isDown ? 1 : 0;
242
+ emit(
243
+ { at, kind: "key", ptr: 0, buttons, mods, x: 0, y: 0, p: 0, str: code, v: EMPTY_V() },
244
+ `(key :at ${at} :code ${quote(code)} :down ${buttons} :mods ${mods})`,
245
+ );
246
+ };
247
+ document.addEventListener("keydown", key(true), { signal });
248
+ document.addEventListener("keyup", key(false), { signal });
249
+
250
+ canvas.addEventListener(
251
+ "resize",
252
+ () => {
253
+ const at = stamp();
254
+ const w = requireDim(canvas.width, "canvas.width");
255
+ const h = requireDim(canvas.height, "canvas.height");
256
+ emit(
257
+ { at, kind: "resize", ptr: 0, buttons: 0, mods: 0, x: 0, y: 0, p: 0, str: null, v: [w, h, 0] },
258
+ `(resize :at ${at} :v [${w} ${h}])`,
259
+ );
260
+ },
261
+ { signal },
262
+ );
263
+
264
+ return {
265
+ drain() {
266
+ const out = records.slice(drained);
267
+ drained = records.length;
268
+ return out;
269
+ },
270
+ text() {
271
+ return [sessionLine, ...lines].join("\n") + "\n";
272
+ },
273
+ detach() {
274
+ ac.abort();
275
+ },
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Parse a session text through the artifact and deliver records to `sink`
281
+ * as `clock()` passes their `:at`. The parse is the wasm one, so verbs
282
+ * expand to the records a recording would have contained.
283
+ *
284
+ * @param {import("./index.js").Log} log the loaded boundary artifact.
285
+ * @param {string} text the session document.
286
+ * @param {(record: object) => void} sink one call per record, in `:at`
287
+ * order; a sink may block (the clock is what paces delivery).
288
+ * @param {() => number} clock session microseconds.
289
+ * @returns {Promise<void>}
290
+ */
291
+ export async function replay(log, text, sink, clock) {
292
+ const { records } = log.decode(log.parse(text));
293
+ for (const record of records) {
294
+ for (;;) {
295
+ const now = Math.round(clock());
296
+ if (!Number.isSafeInteger(now)) {
297
+ throw new RangeError(`replay: clock() must answer a safe integer, got ${now}`);
298
+ }
299
+ if (now >= record.at) break;
300
+ await new Promise((resolve) => setTimeout(resolve, 0));
301
+ }
302
+ sink(record);
303
+ }
304
+ }