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 */1011const DEFAULT_SLOWMO = 1;12const MAX_SLOWMO = 1000;1314function finitePositive(value, fallback = DEFAULT_SLOWMO) {15 const number = Number(value);16 return Number.isFinite(number) && number > 0 ? number : fallback;17}1819/** Read and clamp the review multiplier from a location search string. */20export 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}2526function browserNow() {27 return typeof performance?.now === 'function' ? performance.now() : Date.now();28}2930function browserRaf(callback) {31 if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback);32 return setTimeout(() => callback(browserNow()), 16);33}3435function browserCancelRaf(handle) {36 if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle);37 else clearTimeout(handle);38}3940/**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 */47export 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);6263 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}9495function 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}110111/**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 */115export 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);145146 return Object.freeze({147 clock,148 restore() {149 for (const undo of restore.splice(0).reverse()) undo();150 },151 });152}153154export const PRESENTATION_CLOCK = Object.freeze({ DEFAULT_SLOWMO, MAX_SLOWMO });