import { css, drawAxes, fmtNum, PAD, setupCanvas } from './plot'; import { rampLUT } from './spectrogram'; import { timelineBar, type Timeline } from './timeline'; // Displacement viewer for a chain of beads along x: side (y) and top (z) // views at one frame, plus a space-time image of one component. export interface ChainGeometry { n: number; restX: Float64Array; // bead rest x leftWall: number | null; // x of left anchor, null if free rightWall: number | null; slideLeft: boolean; slideRight: boolean; pinned: Uint8Array; } export interface ChainFrames { frames: Float32Array; // nFrames x 3n times: Float64Array; } export class ChainView { private g: ChainGeometry | null = null; private f: ChainFrames | null = null; private comp = 1; private maxAbs = [0, 0, 0]; private exaggeration = 1; private stImage: HTMLCanvasElement | null = null; private stKey = ''; private stFrames: Float32Array | null = null; private side: HTMLCanvasElement; private top: HTMLCanvasElement; private st: HTMLCanvasElement; private note: HTMLParagraphElement; private get frame() { return this.tl.frame; } constructor(root: HTMLElement, private tl: Timeline) { root.innerHTML = `

Space-time
`; const q = (r: string) => root.querySelector(`[data-r="${r}"]`) as T; q('bar').replaceWith(timelineBar(tl)); this.side = q('side'); this.top = q('top'); this.st = q('st'); this.note = q('note'); q('comp').addEventListener('change', (e) => { this.comp = Number((e.target as HTMLSelectElement).value); this.drawSpaceTime(); }); this.st.addEventListener('click', (e) => { if (!this.f) return; const w = this.st.clientWidth; const fx = (e.offsetX - PAD.l) / (w - PAD.l - PAD.r); if (fx >= 0 && fx <= 1) tl.seek(Math.round(fx * (this.f.times.length - 1))); }); tl.onChange(() => this.drawAll()); const ro = new ResizeObserver(() => this.drawAll()); for (const c of [this.side, this.top, this.st]) ro.observe(c); } // frames must match the timeline's times set(g: ChainGeometry, f: ChainFrames) { this.g = g; this.f = f; this.maxAbs = maxAbsPerComponent(f.frames); this.drawAll(); } private drawAll() { this.drawFrame(); this.drawSpaceTime(); } private drawFrame() { const g = this.g; const f = this.f; if (!g || !f) return; const xs = [g.leftWall ?? g.restX[0], g.rightWall ?? g.restX[g.n - 1]]; const L = xs[1] - xs[0] || 1; const xr: [number, number] = [xs[0] - 0.03 * L, xs[1] + 0.03 * L]; const panelH = this.side.clientHeight - PAD.t - PAD.b; const pxPerM = (this.side.clientWidth - PAD.l - PAD.r) / (xr[1] - xr[0]); const tmax = Math.max(this.maxAbs[1], this.maxAbs[2], 1e-30); this.exaggeration = Math.max(1, (0.42 * panelH) / (tmax * pxPerM)); // displacement range shown; at true scale a large motion may need more than the 1:1 aspect allows const half = Math.max((0.5 * panelH) / pxPerM / this.exaggeration, 1.15 * tmax); this.drawView(this.side, 1, xr, half); this.drawView(this.top, 2, xr, half); this.note.textContent = `Displacements (all components) drawn ×${fmtNum(this.exaggeration)}. Max |y| ${fmtNum(this.maxAbs[1] * 1000)} mm, ` + `max |z| ${fmtNum(this.maxAbs[2] * 1000)} mm, max |x| ${fmtNum(this.maxAbs[0] * 1000)} mm over the run.`; } private drawView(canvas: HTMLCanvasElement, comp: 1 | 2, xr: [number, number], half: number) { const g = this.g!; const f = this.f!; const { ctx, w, h } = setupCanvas(canvas); ctx.clearRect(0, 0, w, h); const { X, Y } = drawAxes(ctx, w, h, xr, [-half * 1000, half * 1000], { xLabel: 'x (m)', yLabel: comp === 1 ? 'y (mm)' : 'z (mm)', }); const E = this.exaggeration; const off = this.frame * 3 * g.n; const pts: [number, number][] = []; const bead = (i: number): [number, number] => [ X(g.restX[i] + E * f.frames[off + 3 * i]), Y(f.frames[off + 3 * i + comp] * 1000), ]; if (g.leftWall !== null) pts.push([X(g.leftWall), g.slideLeft ? bead(0)[1] : Y(0)]); for (let i = 0; i < g.n; i++) pts.push(bead(i)); if (g.rightWall !== null) pts.push([X(g.rightWall), g.slideRight ? bead(g.n - 1)[1] : Y(0)]); // walls ctx.strokeStyle = css('--text-muted'); ctx.lineWidth = 2; for (const [wx, slide] of [[g.leftWall, g.slideLeft], [g.rightWall, g.slideRight]] as const) { if (wx === null) continue; ctx.setLineDash(slide ? [4, 3] : []); ctx.beginPath(); ctx.moveTo(X(wx), PAD.t); ctx.lineTo(X(wx), h - PAD.b); ctx.stroke(); } ctx.setLineDash([]); ctx.strokeStyle = css('--series-1'); ctx.lineWidth = 1.5; ctx.beginPath(); pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.stroke(); const r = g.n > 80 ? 2 : g.n > 30 ? 3 : 4.5; const surface = css('--surface-1'); for (let i = 0; i < g.n; i++) { const [x, y] = bead(i); ctx.beginPath(); ctx.arc(x, y, g.pinned[i] ? r + 1.5 : r, 0, 2 * Math.PI); ctx.fillStyle = g.pinned[i] ? css('--text-primary') : css('--series-1'); ctx.fill(); ctx.lineWidth = 1.5; ctx.strokeStyle = surface; ctx.stroke(); } } private drawSpaceTime() { const g = this.g; const f = this.f; if (!g || !f) return; const { ctx, w, h } = setupCanvas(this.st); ctx.clearRect(0, 0, w, h); const nf = f.times.length; const c = this.comp; const m = Math.max(this.maxAbs[c], 1e-30); const key = `${c}|${f.frames.length}|${css('--diverging')}`; if (!this.stImage || this.stKey !== key || this.stFrames !== f.frames) { const lut = rampLUT('--diverging'); const img = new ImageData(nf, g.n); for (let k = 0; k < nf; k++) { for (let i = 0; i < g.n; i++) { const v = f.frames[k * 3 * g.n + 3 * i + c] / m; const li = Math.round((0.5 + 0.5 * Math.max(-1, Math.min(1, v))) * 255) * 3; const p = 4 * ((g.n - 1 - i) * nf + k); img.data[p] = lut[li]; img.data[p + 1] = lut[li + 1]; img.data[p + 2] = lut[li + 2]; img.data[p + 3] = 255; } } const off = document.createElement('canvas'); off.width = nf; off.height = g.n; off.getContext('2d')!.putImageData(img, 0, 0); this.stImage = off; this.stKey = key; this.stFrames = f.frames; } const off = this.stImage; ctx.imageSmoothingEnabled = false; ctx.drawImage(off, PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b); const t1 = f.times[nf - 1]; const { X } = drawAxes(ctx, w, h, [0, t1], [0.5, g.n + 0.5], { xLabel: `time (s); color: ${['x', 'y', 'z'][c]} displacement, ±${fmtNum(m * 1000)} mm`, yLabel: 'bead', grid: false, }); ctx.strokeStyle = css('--text-primary'); ctx.lineWidth = 1; const cx = Math.round(X(f.times[this.frame])) + 0.5; ctx.beginPath(); ctx.moveTo(cx, PAD.t); ctx.lineTo(cx, h - PAD.b); ctx.stroke(); } } export function maxAbsPerComponent(frames: Float32Array): number[] { const m = [0, 0, 0]; for (let i = 0; i < frames.length; i++) { const c = i % 3; m[c] = Math.max(m[c], Math.abs(frames[i])); } return m; }