/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
toy-instruments / src / ui / chainview.ts
255 lines · 9.3 KBBlameHistoryRaw
1import { css, drawAxes, fmtNum, PAD, setupCanvas } from './plot';
2import { rampLUT } from './spectrogram';
4// Displacement viewer for a chain of beads along x: side (y) and top (z)
5// views at one frame, plus a space-time image of one component.
7export interface ChainGeometry {
8 n: number;
9 restX: Float64Array; // bead rest x
10 leftWall: number | null; // x of left anchor, null if free
11 rightWall: number | null;
12 slideLeft: boolean;
13 slideRight: boolean;
14 pinned: Uint8Array;
17export interface ChainFrames {
18 frames: Float32Array; // nFrames x 3n
19 times: Float64Array;
22export class ChainView {
23 private g: ChainGeometry | null = null;
24 private f: ChainFrames | null = null;
25 private frame = 0;
26 private playing = false;
27 private rate = 1 / 100;
28 private comp = 1;
29 private last = 0;
30 private ptime = 0;
31 private maxAbs = [0, 0, 0];
32 private exaggeration = 1;
34 private side: HTMLCanvasElement;
35 private top: HTMLCanvasElement;
36 private st: HTMLCanvasElement;
37 private slider: HTMLInputElement;
38 private playBtn: HTMLButtonElement;
39 private label: HTMLSpanElement;
40 private note: HTMLParagraphElement;
42 constructor(root: HTMLElement) {
43 root.innerHTML = `
44 <div class="viewer-bar">
45 <button class="btn-small" data-r="play">Play</button>
46 <input type="range" min="0" max="0" value="0" data-r="slider" aria-label="frame">
47 <span class="mono" data-r="label"></span>
48 <label class="inline">speed <select data-r="rate">
49 <option value="0.001">1/1000 ×</option><option value="0.01" selected>1/100 ×</option>
50 <option value="0.05">1/20 ×</option><option value="0.2">1/5 ×</option><option value="1">1 ×</option>
51 </select></label>
52 </div>
53 <div class="canvas-wrap short"><canvas data-r="side"></canvas></div>
54 <div class="canvas-wrap short"><canvas data-r="top"></canvas></div>
55 <p class="note" data-r="note"></p>
56 <div class="viewer-bar"><span class="sub">Space-time</span>
57 <label class="inline">component <select data-r="comp">
58 <option value="1" selected>y (transverse)</option><option value="2">z (transverse)</option><option value="0">x (longitudinal)</option>
59 </select></label></div>
60 <div class="canvas-wrap"><canvas data-r="st"></canvas></div>`;
61 const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
62 this.side = q('side');
63 this.top = q('top');
64 this.st = q('st');
65 this.slider = q('slider');
66 this.playBtn = q('play');
67 this.label = q('label');
68 this.note = q('note');
69 this.slider.addEventListener('input', () => this.seek(Number(this.slider.value)));
70 this.playBtn.addEventListener('click', () => this.toggle());
71 q<HTMLSelectElement>('rate').addEventListener('change', (e) => (this.rate = Number((e.target as HTMLSelectElement).value)));
72 q<HTMLSelectElement>('comp').addEventListener('change', (e) => {
73 this.comp = Number((e.target as HTMLSelectElement).value);
74 this.drawSpaceTime();
75 });
76 this.st.addEventListener('click', (e) => {
77 if (!this.f) return;
78 const w = this.st.clientWidth;
79 const fx = (e.offsetX - PAD.l) / (w - PAD.l - PAD.r);
80 if (fx >= 0 && fx <= 1) this.seek(Math.round(fx * (this.f.times.length - 1)));
81 });
82 const ro = new ResizeObserver(() => this.drawAll());
83 for (const c of [this.side, this.top, this.st]) ro.observe(c);
84 }
86 set(g: ChainGeometry, f: ChainFrames) {
87 this.g = g;
88 this.f = f;
89 this.maxAbs = [0, 0, 0];
90 for (let i = 0; i < f.frames.length; i++) {
91 const c = i % 3;
92 this.maxAbs[c] = Math.max(this.maxAbs[c], Math.abs(f.frames[i]));
93 }
94 this.slider.max = String(f.times.length - 1);
95 this.frame = 0;
96 this.ptime = 0;
97 this.slider.value = '0';
98 this.drawAll();
99 }
101 private seek(k: number) {
102 this.show(k);
103 this.ptime = this.f?.times[this.frame] ?? 0;
104 }
106 private show(k: number) {
107 this.frame = Math.max(0, Math.min(k, (this.f?.times.length ?? 1) - 1));
108 this.slider.value = String(this.frame);
109 this.drawFrame();
110 this.drawSpaceTime();
111 }
113 private toggle() {
114 if (!this.f) return;
115 this.playing = !this.playing;
116 this.playBtn.textContent = this.playing ? 'Pause' : 'Play';
117 if (this.playing) {
118 if (this.frame >= this.f.times.length - 1) this.seek(0);
119 this.last = performance.now();
120 requestAnimationFrame(this.tick);
121 }
122 }
124 private tick = (now: number) => {
125 if (!this.playing || !this.f) return;
126 const times = this.f.times;
127 this.ptime += ((now - this.last) / 1000) * this.rate;
128 this.last = now;
129 let k = this.frame;
130 while (k < times.length - 1 && times[k + 1] <= this.ptime) k++;
131 this.show(k);
132 if (k >= times.length - 1) this.toggle();
133 else requestAnimationFrame(this.tick);
134 };
136 private drawAll() {
137 this.drawFrame();
138 this.drawSpaceTime();
139 }
141 private drawFrame() {
142 const g = this.g;
143 const f = this.f;
144 if (!g || !f) return;
145 const xs = [g.leftWall ?? g.restX[0], g.rightWall ?? g.restX[g.n - 1]];
146 const L = xs[1] - xs[0] || 1;
147 const xr: [number, number] = [xs[0] - 0.03 * L, xs[1] + 0.03 * L];
148 const panelH = this.side.clientHeight - PAD.t - PAD.b;
149 const pxPerM = (this.side.clientWidth - PAD.l - PAD.r) / (xr[1] - xr[0]);
150 const tmax = Math.max(this.maxAbs[1], this.maxAbs[2], 1e-30);
151 this.exaggeration = Math.max(1, (0.42 * panelH) / (tmax * pxPerM));
152 // displacement range shown; at true scale a large motion may need more than the 1:1 aspect allows
153 const half = Math.max((0.5 * panelH) / pxPerM / this.exaggeration, 1.15 * tmax);
154 this.drawView(this.side, 1, xr, half);
155 this.drawView(this.top, 2, xr, half);
156 const t = f.times[this.frame];
157 this.label.textContent = `t = ${(t * 1000).toFixed(2)} ms`;
158 this.note.textContent =
159 `Displacements (all components) drawn ×${fmtNum(this.exaggeration)}. Max |y| ${fmtNum(this.maxAbs[1] * 1000)} mm, ` +
160 `max |z| ${fmtNum(this.maxAbs[2] * 1000)} mm, max |x| ${fmtNum(this.maxAbs[0] * 1000)} mm over the run.`;
161 }
163 private drawView(canvas: HTMLCanvasElement, comp: 1 | 2, xr: [number, number], half: number) {
164 const g = this.g!;
165 const f = this.f!;
166 const { ctx, w, h } = setupCanvas(canvas);
167 ctx.clearRect(0, 0, w, h);
168 const { X, Y } = drawAxes(ctx, w, h, xr, [-half * 1000, half * 1000], {
169 xLabel: 'x (m)',
170 yLabel: comp === 1 ? 'y (mm)' : 'z (mm)',
171 });
172 const E = this.exaggeration;
173 const off = this.frame * 3 * g.n;
174 const pts: [number, number][] = [];
175 const bead = (i: number): [number, number] => [
176 X(g.restX[i] + E * f.frames[off + 3 * i]),
177 Y(f.frames[off + 3 * i + comp] * 1000),
178 ];
179 if (g.leftWall !== null) pts.push([X(g.leftWall), g.slideLeft ? bead(0)[1] : Y(0)]);
180 for (let i = 0; i < g.n; i++) pts.push(bead(i));
181 if (g.rightWall !== null) pts.push([X(g.rightWall), g.slideRight ? bead(g.n - 1)[1] : Y(0)]);
182 // walls
183 ctx.strokeStyle = css('--text-muted');
184 ctx.lineWidth = 2;
185 for (const [wx, slide] of [[g.leftWall, g.slideLeft], [g.rightWall, g.slideRight]] as const) {
186 if (wx === null) continue;
187 ctx.setLineDash(slide ? [4, 3] : []);
188 ctx.beginPath();
189 ctx.moveTo(X(wx), PAD.t);
190 ctx.lineTo(X(wx), h - PAD.b);
191 ctx.stroke();
192 }
193 ctx.setLineDash([]);
194 ctx.strokeStyle = css('--series-1');
195 ctx.lineWidth = 1.5;
196 ctx.beginPath();
197 pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
198 ctx.stroke();
199 const r = g.n > 80 ? 2 : g.n > 30 ? 3 : 4.5;
200 const surface = css('--surface-1');
201 for (let i = 0; i < g.n; i++) {
202 const [x, y] = bead(i);
203 ctx.beginPath();
204 ctx.arc(x, y, g.pinned[i] ? r + 1.5 : r, 0, 2 * Math.PI);
205 ctx.fillStyle = g.pinned[i] ? css('--text-primary') : css('--series-1');
206 ctx.fill();
207 ctx.lineWidth = 1.5;
208 ctx.strokeStyle = surface;
209 ctx.stroke();
210 }
211 }
213 private drawSpaceTime() {
214 const g = this.g;
215 const f = this.f;
216 if (!g || !f) return;
217 const { ctx, w, h } = setupCanvas(this.st);
218 ctx.clearRect(0, 0, w, h);
219 const nf = f.times.length;
220 const c = this.comp;
221 const m = Math.max(this.maxAbs[c], 1e-30);
222 const lut = rampLUT('--diverging');
223 const img = new ImageData(nf, g.n);
224 for (let k = 0; k < nf; k++) {
225 for (let i = 0; i < g.n; i++) {
226 const v = f.frames[k * 3 * g.n + 3 * i + c] / m;
227 const li = Math.round((0.5 + 0.5 * Math.max(-1, Math.min(1, v))) * 255) * 3;
228 const p = 4 * ((g.n - 1 - i) * nf + k);
229 img.data[p] = lut[li];
230 img.data[p + 1] = lut[li + 1];
231 img.data[p + 2] = lut[li + 2];
232 img.data[p + 3] = 255;
233 }
234 }
235 const off = document.createElement('canvas');
236 off.width = nf;
237 off.height = g.n;
238 off.getContext('2d')!.putImageData(img, 0, 0);
239 ctx.imageSmoothingEnabled = false;
240 ctx.drawImage(off, PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b);
241 const t1 = f.times[nf - 1];
242 const { X } = drawAxes(ctx, w, h, [0, t1], [0.5, g.n + 0.5], {
243 xLabel: `time (s); color: ${['x', 'y', 'z'][c]} displacement, ±${fmtNum(m * 1000)} mm`,
244 yLabel: 'bead',
245 grid: false,
246 });
247 ctx.strokeStyle = css('--text-primary');
248 ctx.lineWidth = 1;
249 const cx = Math.round(X(f.times[this.frame])) + 0.5;
250 ctx.beginPath();
251 ctx.moveTo(cx, PAD.t);
252 ctx.lineTo(cx, h - PAD.b);
253 ctx.stroke();
254 }
↑↓ move↵openescclose