- commit
- b7cc9e511f7e8b9d56e40b7848b545d78e593ddf
- parent
- 1981ad96212daa3008d63fed63b10dd7a4b77eeb
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 154 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 91 | ++++++++++++++++++ |
@@ -0,0 +1,154 @@
1+/**2+ * A review clock for presentation work.3+ *4+ * `?slowmo=N` stretches the visual timeline without changing the engine's5+ * turn cadence. Consumers that own simulation or input timing should use6+ * `realNow()` and the native scheduling functions exposed by the clock.7+ * Presentation code can use the virtual functions, or install the clock to8+ * adapt the browser globals before the stage starts.9+ */10+11+const DEFAULT_SLOWMO = 1;12+const MAX_SLOWMO = 1000;13+14+function finitePositive(value, fallback = DEFAULT_SLOWMO) {15+ const number = Number(value);16+ return Number.isFinite(number) && number > 0 ? number : fallback;17+}18+19+/** Read and clamp the review multiplier from a location search string. */20+export function parseSlowmo(search = globalThis.location?.search || '') {21+ const raw = new URLSearchParams(search).get('slowmo');22+ if (raw === null || raw.trim() === '') return DEFAULT_SLOWMO;23+ return Math.min(MAX_SLOWMO, Math.max(DEFAULT_SLOWMO, finitePositive(raw)));24+}25+26+function browserNow() {27+ return typeof performance?.now === 'function' ? performance.now() : Date.now();28+}29+30+function browserRaf(callback) {31+ if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback);32+ return setTimeout(() => callback(browserNow()), 16);33+}34+35+function browserCancelRaf(handle) {36+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle);37+ else clearTimeout(handle);38+}39+40+/**41+ * Create an isolated presentation clock.42+ *43+ * The virtual timeline has the same origin as the raw clock, then advances at44+ * `1 / slowmo` speed. This keeps existing animation code's timestamps valid45+ * while ensuring a frame callback and a visual timeout observe one timeline.46+ */47+export function createPresentationClock({48+ slowmo = DEFAULT_SLOWMO,49+ now = browserNow,50+ raf = browserRaf,51+ cancelRaf = browserCancelRaf,52+ setTimeoutFn = globalThis.setTimeout?.bind(globalThis),53+ clearTimeoutFn = globalThis.clearTimeout?.bind(globalThis),54+ setIntervalFn = globalThis.setInterval?.bind(globalThis),55+ clearIntervalFn = globalThis.clearInterval?.bind(globalThis),56+} = {}) {57+ const factor = Math.min(MAX_SLOWMO, Math.max(DEFAULT_SLOWMO, finitePositive(slowmo)));58+ const realStart = now();59+ const virtualStart = realStart;60+ const virtualNow = () => virtualStart + (now() - realStart) / factor;61+ const delay = value => Math.max(0, finitePositive(value, 0) * factor);62+63+ const clock = {64+ slowmo: factor,65+ now: virtualNow,66+ realNow: now,67+ requestAnimationFrame(callback) {68+ if (typeof callback !== 'function') throw new TypeError('requestAnimationFrame callback must be a function');69+ return raf(() => callback(virtualNow()));70+ },71+ cancelAnimationFrame: cancelRaf,72+ setTimeout(callback, milliseconds = 0, ...args) {73+ if (typeof setTimeoutFn !== 'function') throw new Error('setTimeout is unavailable');74+ return setTimeoutFn(callback, delay(milliseconds), ...args);75+ },76+ clearTimeout: clearTimeoutFn,77+ setInterval(callback, milliseconds = 0, ...args) {78+ if (typeof setIntervalFn !== 'function') throw new Error('setInterval is unavailable');79+ return setIntervalFn(callback, delay(milliseconds), ...args);80+ },81+ clearInterval: clearIntervalFn,82+ native: Object.freeze({83+ now,84+ requestAnimationFrame: raf,85+ cancelAnimationFrame: cancelRaf,86+ setTimeout: setTimeoutFn,87+ clearTimeout: clearTimeoutFn,88+ setInterval: setIntervalFn,89+ clearInterval: clearIntervalFn,90+ }),91+ };92+ return Object.freeze(clock);93+}94+95+function replace(target, name, value, restore) {96+ const descriptor = Object.getOwnPropertyDescriptor(target, name);97+ const previous = target[name];98+ try {99+ target[name] = value;100+ if (target[name] !== value) throw new TypeError(`Cannot install ${name}`);101+ restore.push(() => {102+ if (descriptor) Object.defineProperty(target, name, descriptor);103+ else target[name] = previous;104+ });105+ } catch {106+ // A host may expose a read-only browser primitive. The isolated clock is107+ // still useful to callers, so leave that primitive unchanged.108+ }109+}110+111+/**112+ * Install the review clock on a browser-like global and return a restore hook.113+ * The returned clock always exposes raw timing for input/simulation callers.114+ */115+export function installPresentationClock({ target = globalThis, search, slowmo } = {}) {116+ const performanceTarget = target.performance;117+ const rawNow = typeof performanceTarget?.now === 'function'118+ ? performanceTarget.now.bind(performanceTarget)119+ : () => Date.now();120+ const rawRaf = typeof target.requestAnimationFrame === 'function'121+ ? target.requestAnimationFrame.bind(target)122+ : callback => target.setTimeout(() => callback(rawNow()), 16);123+ const rawCancelRaf = typeof target.cancelAnimationFrame === 'function'124+ ? target.cancelAnimationFrame.bind(target)125+ : handle => target.clearTimeout(handle);126+ const clock = createPresentationClock({127+ slowmo: slowmo ?? parseSlowmo(search),128+ now: rawNow,129+ raf: rawRaf,130+ cancelRaf: rawCancelRaf,131+ setTimeoutFn: typeof target.setTimeout === 'function' ? target.setTimeout.bind(target) : undefined,132+ clearTimeoutFn: typeof target.clearTimeout === 'function' ? target.clearTimeout.bind(target) : undefined,133+ setIntervalFn: typeof target.setInterval === 'function' ? target.setInterval.bind(target) : undefined,134+ clearIntervalFn: typeof target.clearInterval === 'function' ? target.clearInterval.bind(target) : undefined,135+ });136+ const restore = [];137+ if (performanceTarget && typeof performanceTarget.now === 'function')138+ replace(performanceTarget, 'now', clock.now, restore);139+ replace(target, 'requestAnimationFrame', clock.requestAnimationFrame, restore);140+ if (typeof target.cancelAnimationFrame === 'function') replace(target, 'cancelAnimationFrame', clock.cancelAnimationFrame, restore);141+ replace(target, 'setTimeout', clock.setTimeout, restore);142+ if (typeof target.clearTimeout === 'function') replace(target, 'clearTimeout', clock.clearTimeout, restore);143+ if (typeof target.setInterval === 'function') replace(target, 'setInterval', clock.setInterval, restore);144+ if (typeof target.clearInterval === 'function') replace(target, 'clearInterval', clock.clearInterval, restore);145+146+ return Object.freeze({147+ clock,148+ restore() {149+ for (const undo of restore.splice(0).reverse()) undo();150+ },151+ });152+}153+154+export const PRESENTATION_CLOCK = Object.freeze({ DEFAULT_SLOWMO, MAX_SLOWMO });@@ -0,0 +1,91 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import {4+ createPresentationClock,5+ installPresentationClock,6+ parseSlowmo,7+} from '../src/presentation-clock.js';8+9+test('parseSlowmo accepts positive review multipliers and clamps unsafe values', () => {10+ assert.equal(parseSlowmo(''), 1);11+ assert.equal(parseSlowmo('?slowmo=8'), 8);12+ assert.equal(parseSlowmo('?slowmo=0'), 1);13+ assert.equal(parseSlowmo('?slowmo=-2'), 1);14+ assert.equal(parseSlowmo('?slowmo=999999'), 1000);15+ assert.equal(parseSlowmo('?slowmo=nope'), 1);16+});17+18+test('presentation clock slows virtual now and RAF timestamps together', () => {19+ let raw = 100;20+ let rafCallback;21+ const clock = createPresentationClock({22+ slowmo: 4,23+ now: () => raw,24+ raf: callback => { rafCallback = callback; return 17; },25+ cancelRaf: () => {},26+ setTimeoutFn: () => 1,27+ clearTimeoutFn: () => {},28+ setIntervalFn: () => 2,29+ clearIntervalFn: () => {},30+ });31+32+ raw = 500;33+ assert.equal(clock.realNow(), 500);34+ assert.equal(clock.now(), 200);35+ let frame;36+ assert.equal(clock.requestAnimationFrame(value => { frame = value; }), 17);37+ rafCallback();38+ assert.equal(frame, 200);39+});40+41+test('presentation timers stretch delays while native timer APIs stay available', () => {42+ const timeouts = [];43+ const intervals = [];44+ const clock = createPresentationClock({45+ slowmo: 8,46+ now: () => 0,47+ raf: callback => { callback(); return 1; },48+ cancelRaf: () => {},49+ setTimeoutFn: (callback, delay, ...args) => { timeouts.push({ callback, delay, args }); return 3; },50+ clearTimeoutFn: handle => { assert.equal(handle, 3); },51+ setIntervalFn: (callback, delay, ...args) => { intervals.push({ callback, delay, args }); return 4; },52+ clearIntervalFn: handle => { assert.equal(handle, 4); },53+ });54+ const callback = () => {};55+ assert.equal(clock.setTimeout(callback, 90, 'x'), 3);56+ assert.equal(timeouts[0].delay, 720);57+ assert.deepEqual(timeouts[0].args, ['x']);58+ assert.equal(clock.setInterval(callback, 110), 4);59+ assert.equal(intervals[0].delay, 880);60+ clock.clearTimeout(3);61+ clock.clearInterval(4);62+});63+64+test('installPresentationClock patches presentation globals and restores them', () => {65+ let raw = 10;66+ const scheduled = [];67+ const target = {68+ performance: { now: () => raw },69+ requestAnimationFrame(callback) { scheduled.push(callback); return 1; },70+ cancelAnimationFrame() {},71+ setTimeout(callback, delay) { scheduled.push({ callback, delay }); return 2; },72+ clearTimeout() {},73+ setInterval() { return 3; },74+ clearInterval() {},75+ };76+ const nativeNow = target.performance.now;77+ const nativeRaf = target.requestAnimationFrame;78+ const installed = installPresentationClock({ target, slowmo: 5 });79+ raw = 510;80+ assert.equal(target.performance.now(), 110);81+ let timestamp;82+ target.requestAnimationFrame(value => { timestamp = value; });83+ scheduled[0]();84+ assert.equal(timestamp, 110);85+ target.setTimeout(() => {}, 20);86+ assert.equal(scheduled[1].delay, 100);87+ assert.equal(installed.clock.realNow(), 510);88+ installed.restore();89+ assert.equal(target.performance.now, nativeNow);90+ assert.equal(target.requestAnimationFrame, nativeRaf);91+});