d2bfcd73D model view: setup (supports, pins, excitation, pickup) and replay of saved frames on a shared timelineJeremy Magland 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 }
87d896eShow partial results while a run is in progress: plots, replay and audio update every 2 s (CPU and WebGPU); a cancelled run keeps its last partial resultJeremy Magland 30 // more frames of the same run (a partial result growing): keep the position and playback
31 extend(times: Float64Array) {
32 this.times = times;
33 if (this.frame >= times.length) this.frame = times.length - 1;
34 this.emit();
35 if (this.playing && this.frame < times.length - 1) requestAnimationFrame(this.tick);
36 }
d2bfcd73D model view: setup (supports, pins, excitation, pickup) and replay of saved frames on a shared timelineJeremy Magland 38 seek(k: number) {
39 this.show(k);
40 this.ptime = this.times[this.frame] ?? 0;
41 }
43 private show(k: number) {
44 this.frame = Math.max(0, Math.min(Math.round(k), this.times.length - 1));
45 this.emit();
46 }
48 setRate(r: number) {
49 this.rate = r;
50 this.emit();
51 }
53 toggle() {
54 if (this.playing) this.pause();
55 else this.play();
56 }
58 play() {
59 if (this.times.length < 2) return;
60 if (this.frame >= this.times.length - 1) this.seek(0);
61 this.playing = true;
62 this.last = performance.now();
63 this.emit();
64 requestAnimationFrame(this.tick);
65 }
67 pause() {
68 if (!this.playing) return;
69 this.playing = false;
70 this.emit();
71 }
73 private tick = (now: number) => {
74 if (!this.playing) return;
75 this.ptime += ((now - this.last) / 1000) * this.rate;
76 this.last = now;
77 let k = this.frame;
78 while (k < this.times.length - 1 && this.times[k + 1] <= this.ptime) k++;
79 if (k !== this.frame) this.show(k);
80 if (k >= this.times.length - 1) this.pause();
81 else requestAnimationFrame(this.tick);
82 };
83}
85const RATES: [number, string][] = [
86 [0.001, '1/1000 ×'],
87 [0.01, '1/100 ×'],
88 [0.05, '1/20 ×'],
89 [0.2, '1/5 ×'],
90 [1, '1 ×'],
91];
93// Play/pause, frame slider, step buttons, time label and speed select.
94export function timelineBar(tl: Timeline): HTMLElement {
95 const bar = document.createElement('div');
96 bar.className = 'viewer-bar';
97 bar.innerHTML = `
98 <button class="btn-small" data-r="play">Play</button>
99 <button class="btn-small icon" data-r="prev" title="Previous frame" aria-label="Previous frame">‹</button>
100 <button class="btn-small icon" data-r="next" title="Next frame" aria-label="Next frame">›</button>
101 <input type="range" min="0" max="0" value="0" data-r="slider" aria-label="frame">
102 <span class="mono" data-r="label"></span>
103 <label class="inline">speed <select data-r="rate">${RATES.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label>`;
104 const q = <T extends HTMLElement>(r: string) => bar.querySelector(`[data-r="${r}"]`) as T;
105 const play = q<HTMLButtonElement>('play');
106 const slider = q<HTMLInputElement>('slider');
107 const label = q<HTMLSpanElement>('label');
108 const rate = q<HTMLSelectElement>('rate');
109 play.addEventListener('click', () => tl.toggle());
110 q('prev').addEventListener('click', () => tl.seek(tl.frame - 1));
111 q('next').addEventListener('click', () => tl.seek(tl.frame + 1));
112 slider.addEventListener('input', () => tl.seek(Number(slider.value)));
113 rate.addEventListener('change', () => tl.setRate(Number(rate.value)));
114 const sync = () => {
115 play.textContent = tl.playing ? 'Pause' : 'Play';
116 slider.max = String(tl.times.length - 1);
117 slider.value = String(tl.frame);
118 const n = tl.times.length;
119 const dt = n > 1 ? tl.times[1] - tl.times[0] : 0;
120 label.textContent = `t = ${(tl.times[tl.frame] * 1000).toFixed(2)} ms (frame ${tl.frame + 1}/${n}, every ${(dt * 1000).toPrecision(3)} ms)`;
121 rate.value = String(tl.rate);
122 };
123 tl.onChange(sync);
124 sync();
125 return bar;
126}