1// Shared playback state for saved frames. Several views subscribe; any number
2// of control bars can drive it.
4export class Timeline {
5 times: Float64Array = new Float64Array(1);
6 frame = 0;
7 playing = false;
8 rate = 1 / 100; // simulated seconds per real second
9 private ptime = 0;
10 private last = 0;
11 private listeners = new Set<() => void>();
13 onChange(cb: () => void): () => void {
14 this.listeners.add(cb);
15 return () => this.listeners.delete(cb);
16 }
18 private emit() {
19 for (const cb of this.listeners) cb();
20 }
22 setTimes(times: Float64Array) {
23 this.pause();
24 this.times = times;
25 this.frame = 0;
26 this.ptime = 0;
27 this.emit();
28 }
30 seek(k: number) {
31 this.show(k);
32 this.ptime = this.times[this.frame] ?? 0;
33 }
35 private show(k: number) {
36 this.frame = Math.max(0, Math.min(Math.round(k), this.times.length - 1));
37 this.emit();
38 }
40 setRate(r: number) {
41 this.rate = r;
42 this.emit();
43 }
45 toggle() {
46 if (this.playing) this.pause();
47 else this.play();
48 }
50 play() {
51 if (this.times.length < 2) return;
52 if (this.frame >= this.times.length - 1) this.seek(0);
53 this.playing = true;
54 this.last = performance.now();
55 this.emit();
56 requestAnimationFrame(this.tick);
57 }
59 pause() {
60 if (!this.playing) return;
61 this.playing = false;
62 this.emit();
63 }
65 private tick = (now: number) => {
66 if (!this.playing) return;
67 this.ptime += ((now - this.last) / 1000) * this.rate;
68 this.last = now;
69 let k = this.frame;
70 while (k < this.times.length - 1 && this.times[k + 1] <= this.ptime) k++;
71 if (k !== this.frame) this.show(k);
72 if (k >= this.times.length - 1) this.pause();
73 else requestAnimationFrame(this.tick);
74 };
75}
77const RATES: [number, string][] = [
78 [0.001, '1/1000 ×'],
79 [0.01, '1/100 ×'],
80 [0.05, '1/20 ×'],
81 [0.2, '1/5 ×'],
82 [1, '1 ×'],
83];
85// Play/pause, frame slider, step buttons, time label and speed select.
86export function timelineBar(tl: Timeline): HTMLElement {
87 const bar = document.createElement('div');
88 bar.className = 'viewer-bar';
89 bar.innerHTML = `
90 <button class="btn-small" data-r="play">Play</button>
91 <button class="btn-small icon" data-r="prev" title="Previous frame" aria-label="Previous frame">‹</button>
92 <button class="btn-small icon" data-r="next" title="Next frame" aria-label="Next frame">›</button>
93 <input type="range" min="0" max="0" value="0" data-r="slider" aria-label="frame">
94 <span class="mono" data-r="label"></span>
95 <label class="inline">speed <select data-r="rate">${RATES.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label>`;
96 const q = <T extends HTMLElement>(r: string) => bar.querySelector(`[data-r="${r}"]`) as T;
97 const play = q<HTMLButtonElement>('play');
98 const slider = q<HTMLInputElement>('slider');
99 const label = q<HTMLSpanElement>('label');
100 const rate = q<HTMLSelectElement>('rate');
101 play.addEventListener('click', () => tl.toggle());
102 q('prev').addEventListener('click', () => tl.seek(tl.frame - 1));
103 q('next').addEventListener('click', () => tl.seek(tl.frame + 1));
104 slider.addEventListener('input', () => tl.seek(Number(slider.value)));
105 rate.addEventListener('change', () => tl.setRate(Number(rate.value)));
106 const sync = () => {
107 play.textContent = tl.playing ? 'Pause' : 'Play';
108 slider.max = String(tl.times.length - 1);
109 slider.value = String(tl.frame);
110 const n = tl.times.length;
111 const dt = n > 1 ? tl.times[1] - tl.times[0] : 0;
112 label.textContent = `t = ${(tl.times[tl.frame] * 1000).toFixed(2)} ms (frame ${tl.frame + 1}/${n}, every ${(dt * 1000).toPrecision(3)} ms)`;
113 rate.value = String(tl.rate);
114 };
115 tl.onChange(sync);
116 sync();
117 return bar;
118}