// Shared playback state for saved frames. Several views subscribe; any number // of control bars can drive it. export class Timeline { times: Float64Array = new Float64Array(1); frame = 0; playing = false; rate = 1 / 100; // simulated seconds per real second private ptime = 0; private last = 0; private listeners = new Set<() => void>(); onChange(cb: () => void): () => void { this.listeners.add(cb); return () => this.listeners.delete(cb); } private emit() { for (const cb of this.listeners) cb(); } setTimes(times: Float64Array) { this.pause(); this.times = times; this.frame = 0; this.ptime = 0; this.emit(); } // more frames of the same run (a partial result growing): keep the position and playback extend(times: Float64Array) { this.times = times; if (this.frame >= times.length) this.frame = times.length - 1; this.emit(); if (this.playing && this.frame < times.length - 1) requestAnimationFrame(this.tick); } seek(k: number) { this.show(k); this.ptime = this.times[this.frame] ?? 0; } private show(k: number) { this.frame = Math.max(0, Math.min(Math.round(k), this.times.length - 1)); this.emit(); } setRate(r: number) { this.rate = r; this.emit(); } toggle() { if (this.playing) this.pause(); else this.play(); } play() { if (this.times.length < 2) return; if (this.frame >= this.times.length - 1) this.seek(0); this.playing = true; this.last = performance.now(); this.emit(); requestAnimationFrame(this.tick); } pause() { if (!this.playing) return; this.playing = false; this.emit(); } private tick = (now: number) => { if (!this.playing) return; this.ptime += ((now - this.last) / 1000) * this.rate; this.last = now; let k = this.frame; while (k < this.times.length - 1 && this.times[k + 1] <= this.ptime) k++; if (k !== this.frame) this.show(k); if (k >= this.times.length - 1) this.pause(); else requestAnimationFrame(this.tick); }; } const RATES: [number, string][] = [ [0.001, '1/1000 ×'], [0.01, '1/100 ×'], [0.05, '1/20 ×'], [0.2, '1/5 ×'], [1, '1 ×'], ]; // Play/pause, frame slider, step buttons, time label and speed select. export function timelineBar(tl: Timeline): HTMLElement { const bar = document.createElement('div'); bar.className = 'viewer-bar'; bar.innerHTML = ` `; const q = (r: string) => bar.querySelector(`[data-r="${r}"]`) as T; const play = q('play'); const slider = q('slider'); const label = q('label'); const rate = q('rate'); play.addEventListener('click', () => tl.toggle()); q('prev').addEventListener('click', () => tl.seek(tl.frame - 1)); q('next').addEventListener('click', () => tl.seek(tl.frame + 1)); slider.addEventListener('input', () => tl.seek(Number(slider.value))); rate.addEventListener('change', () => tl.setRate(Number(rate.value))); const sync = () => { play.textContent = tl.playing ? 'Pause' : 'Play'; slider.max = String(tl.times.length - 1); slider.value = String(tl.frame); const n = tl.times.length; const dt = n > 1 ? tl.times[1] - tl.times[0] : 0; label.textContent = `t = ${(tl.times[tl.frame] * 1000).toFixed(2)} ms (frame ${tl.frame + 1}/${n}, every ${(dt * 1000).toPrecision(3)} ms)`; rate.value = String(tl.rate); }; tl.onChange(sync); sync(); return bar; }