// 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);
};
}
export 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;
}