1import { css, drawAxes, fmtNum, PAD, setupCanvas } from './plot';
2import { rampLUT } from './spectrogram';
3import { timelineBar, type Timeline } from './timeline';
5// Displacement viewer for a chain of beads along x: side (y) and top (z)
6// views at one frame, plus a space-time image of one component.
8export interface ChainGeometry {
9 n: number;
10 restX: Float64Array; // bead rest x
11 leftWall: number | null; // x of left anchor, null if free
12 rightWall: number | null;
13 slideLeft: boolean;
14 slideRight: boolean;
15 pinned: Uint8Array;
16}
18export interface ChainFrames {
19 frames: Float32Array; // nFrames x 3n
20 times: Float64Array;
21}
23export class ChainView {
24 private g: ChainGeometry | null = null;
25 private f: ChainFrames | null = null;
26 private comp = 1;
27 private maxAbs = [0, 0, 0];
28 private exaggeration = 1;
29 private stImage: HTMLCanvasElement | null = null;
30 private stKey = '';
31 private stFrames: Float32Array | null = null;
33 private side: HTMLCanvasElement;
34 private top: HTMLCanvasElement;
35 private st: HTMLCanvasElement;
36 private note: HTMLParagraphElement;
38 private get frame() {
39 return this.tl.frame;
40 }
42 constructor(root: HTMLElement, private tl: Timeline) {
43 root.innerHTML = `
44 <div data-r="bar"></div>
45 <div class="canvas-wrap short"><canvas data-r="side"></canvas></div>
46 <div class="canvas-wrap short"><canvas data-r="top"></canvas></div>
47 <p class="note" data-r="note"></p>
48 <div class="viewer-bar"><span class="sub">Space-time</span>
49 <label class="inline">component <select data-r="comp">
50 <option value="1" selected>y (transverse)</option><option value="2">z (transverse)</option><option value="0">x (longitudinal)</option>
51 </select></label></div>
52 <div class="canvas-wrap"><canvas data-r="st"></canvas></div>`;
53 const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
54 q('bar').replaceWith(timelineBar(tl));
55 this.side = q('side');
56 this.top = q('top');
57 this.st = q('st');
58 this.note = q('note');
59 q<HTMLSelectElement>('comp').addEventListener('change', (e) => {
60 this.comp = Number((e.target as HTMLSelectElement).value);
61 this.drawSpaceTime();
62 });
63 this.st.addEventListener('click', (e) => {
64 if (!this.f) return;
65 const w = this.st.clientWidth;
66 const fx = (e.offsetX - PAD.l) / (w - PAD.l - PAD.r);
67 if (fx >= 0 && fx <= 1) tl.seek(Math.round(fx * (this.f.times.length - 1)));
68 });
69 tl.onChange(() => this.drawAll());
70 const ro = new ResizeObserver(() => this.drawAll());
71 for (const c of [this.side, this.top, this.st]) ro.observe(c);
72 }
74 // frames must match the timeline's times
75 set(g: ChainGeometry, f: ChainFrames) {
76 this.g = g;
77 this.f = f;
78 this.maxAbs = maxAbsPerComponent(f.frames);
79 this.drawAll();
80 }
82 private drawAll() {
83 this.drawFrame();
84 this.drawSpaceTime();
85 }
87 private drawFrame() {
88 const g = this.g;
89 const f = this.f;
90 if (!g || !f) return;
91 const xs = [g.leftWall ?? g.restX[0], g.rightWall ?? g.restX[g.n - 1]];
92 const L = xs[1] - xs[0] || 1;
93 const xr: [number, number] = [xs[0] - 0.03 * L, xs[1] + 0.03 * L];
94 const panelH = this.side.clientHeight - PAD.t - PAD.b;
95 const pxPerM = (this.side.clientWidth - PAD.l - PAD.r) / (xr[1] - xr[0]);
96 const tmax = Math.max(this.maxAbs[1], this.maxAbs[2], 1e-30);
97 this.exaggeration = Math.max(1, (0.42 * panelH) / (tmax * pxPerM));
98 // displacement range shown; at true scale a large motion may need more than the 1:1 aspect allows
99 const half = Math.max((0.5 * panelH) / pxPerM / this.exaggeration, 1.15 * tmax);
100 this.drawView(this.side, 1, xr, half);
101 this.drawView(this.top, 2, xr, half);
102 this.note.textContent =
103 `Displacements (all components) drawn ×${fmtNum(this.exaggeration)}. Max |y| ${fmtNum(this.maxAbs[1] * 1000)} mm, ` +
104 `max |z| ${fmtNum(this.maxAbs[2] * 1000)} mm, max |x| ${fmtNum(this.maxAbs[0] * 1000)} mm over the run.`;
105 }
107 private drawView(canvas: HTMLCanvasElement, comp: 1 | 2, xr: [number, number], half: number) {
108 const g = this.g!;
109 const f = this.f!;
110 const { ctx, w, h } = setupCanvas(canvas);
111 ctx.clearRect(0, 0, w, h);
112 const { X, Y } = drawAxes(ctx, w, h, xr, [-half * 1000, half * 1000], {
113 xLabel: 'x (m)',
114 yLabel: comp === 1 ? 'y (mm)' : 'z (mm)',
115 });
116 const E = this.exaggeration;
117 const off = this.frame * 3 * g.n;
118 const pts: [number, number][] = [];
119 const bead = (i: number): [number, number] => [
120 X(g.restX[i] + E * f.frames[off + 3 * i]),
121 Y(f.frames[off + 3 * i + comp] * 1000),
122 ];
123 if (g.leftWall !== null) pts.push([X(g.leftWall), g.slideLeft ? bead(0)[1] : Y(0)]);
124 for (let i = 0; i < g.n; i++) pts.push(bead(i));
125 if (g.rightWall !== null) pts.push([X(g.rightWall), g.slideRight ? bead(g.n - 1)[1] : Y(0)]);
126 // walls
127 ctx.strokeStyle = css('--text-muted');
128 ctx.lineWidth = 2;
129 for (const [wx, slide] of [[g.leftWall, g.slideLeft], [g.rightWall, g.slideRight]] as const) {
130 if (wx === null) continue;
131 ctx.setLineDash(slide ? [4, 3] : []);
132 ctx.beginPath();
133 ctx.moveTo(X(wx), PAD.t);
134 ctx.lineTo(X(wx), h - PAD.b);
135 ctx.stroke();
136 }
137 ctx.setLineDash([]);
138 ctx.strokeStyle = css('--series-1');
139 ctx.lineWidth = 1.5;
140 ctx.beginPath();
141 pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
142 ctx.stroke();
143 const r = g.n > 80 ? 2 : g.n > 30 ? 3 : 4.5;
144 const surface = css('--surface-1');
145 for (let i = 0; i < g.n; i++) {
146 const [x, y] = bead(i);
147 ctx.beginPath();
148 ctx.arc(x, y, g.pinned[i] ? r + 1.5 : r, 0, 2 * Math.PI);
149 ctx.fillStyle = g.pinned[i] ? css('--text-primary') : css('--series-1');
150 ctx.fill();
151 ctx.lineWidth = 1.5;
152 ctx.strokeStyle = surface;
153 ctx.stroke();
154 }
155 }
157 private drawSpaceTime() {
158 const g = this.g;
159 const f = this.f;
160 if (!g || !f) return;
161 const { ctx, w, h } = setupCanvas(this.st);
162 ctx.clearRect(0, 0, w, h);
163 const nf = f.times.length;
164 const c = this.comp;
165 const m = Math.max(this.maxAbs[c], 1e-30);
166 const key = `${c}|${f.frames.length}|${css('--diverging')}`;
167 if (!this.stImage || this.stKey !== key || this.stFrames !== f.frames) {
168 const lut = rampLUT('--diverging');
169 const img = new ImageData(nf, g.n);
170 for (let k = 0; k < nf; k++) {
171 for (let i = 0; i < g.n; i++) {
172 const v = f.frames[k * 3 * g.n + 3 * i + c] / m;
173 const li = Math.round((0.5 + 0.5 * Math.max(-1, Math.min(1, v))) * 255) * 3;
174 const p = 4 * ((g.n - 1 - i) * nf + k);
175 img.data[p] = lut[li];
176 img.data[p + 1] = lut[li + 1];
177 img.data[p + 2] = lut[li + 2];
178 img.data[p + 3] = 255;
179 }
180 }
181 const off = document.createElement('canvas');
182 off.width = nf;
183 off.height = g.n;
184 off.getContext('2d')!.putImageData(img, 0, 0);
185 this.stImage = off;
186 this.stKey = key;
187 this.stFrames = f.frames;
188 }
189 const off = this.stImage;
190 ctx.imageSmoothingEnabled = false;
191 ctx.drawImage(off, PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b);
192 const t1 = f.times[nf - 1];
193 const { X } = drawAxes(ctx, w, h, [0, t1], [0.5, g.n + 0.5], {
194 xLabel: `time (s); color: ${['x', 'y', 'z'][c]} displacement, ±${fmtNum(m * 1000)} mm`,
195 yLabel: 'bead',
196 grid: false,
197 });
198 ctx.strokeStyle = css('--text-primary');
199 ctx.lineWidth = 1;
200 const cx = Math.round(X(f.times[this.frame])) + 0.5;
201 ctx.beginPath();
202 ctx.moveTo(cx, PAD.t);
203 ctx.lineTo(cx, h - PAD.b);
204 ctx.stroke();
205 }
206}
208export function maxAbsPerComponent(frames: Float32Array): number[] {
209 const m = [0, 0, 0];
210 for (let i = 0; i < frames.length; i++) {
211 const c = i % 3;
212 m[c] = Math.max(m[c], Math.abs(frames[i]));
213 }
214 return m;
215}