import { css, drawAxes, fmtNum, PAD, setupCanvas } from './plot';
import { rampLUT } from './spectrogram';
// 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 frame = 0;
private playing = false;
private rate = 1 / 100;
private comp = 1;
private last = 0;
private ptime = 0;
private maxAbs = [0, 0, 0];
private exaggeration = 1;
private side: HTMLCanvasElement;
private top: HTMLCanvasElement;
private st: HTMLCanvasElement;
private slider: HTMLInputElement;
private playBtn: HTMLButtonElement;
private label: HTMLSpanElement;
private note: HTMLParagraphElement;
constructor(root: HTMLElement) {
root.innerHTML = `
Space-time
`;
const q = (r: string) => root.querySelector(`[data-r="${r}"]`) as T;
this.side = q('side');
this.top = q('top');
this.st = q('st');
this.slider = q('slider');
this.playBtn = q('play');
this.label = q('label');
this.note = q('note');
this.slider.addEventListener('input', () => this.seek(Number(this.slider.value)));
this.playBtn.addEventListener('click', () => this.toggle());
q('rate').addEventListener('change', (e) => (this.rate = Number((e.target as HTMLSelectElement).value)));
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) this.seek(Math.round(fx * (this.f.times.length - 1)));
});
const ro = new ResizeObserver(() => this.drawAll());
for (const c of [this.side, this.top, this.st]) ro.observe(c);
}
set(g: ChainGeometry, f: ChainFrames) {
this.g = g;
this.f = f;
this.maxAbs = [0, 0, 0];
for (let i = 0; i < f.frames.length; i++) {
const c = i % 3;
this.maxAbs[c] = Math.max(this.maxAbs[c], Math.abs(f.frames[i]));
}
this.slider.max = String(f.times.length - 1);
this.frame = 0;
this.ptime = 0;
this.slider.value = '0';
this.drawAll();
}
private seek(k: number) {
this.show(k);
this.ptime = this.f?.times[this.frame] ?? 0;
}
private show(k: number) {
this.frame = Math.max(0, Math.min(k, (this.f?.times.length ?? 1) - 1));
this.slider.value = String(this.frame);
this.drawFrame();
this.drawSpaceTime();
}
private toggle() {
if (!this.f) return;
this.playing = !this.playing;
this.playBtn.textContent = this.playing ? 'Pause' : 'Play';
if (this.playing) {
if (this.frame >= this.f.times.length - 1) this.seek(0);
this.last = performance.now();
requestAnimationFrame(this.tick);
}
}
private tick = (now: number) => {
if (!this.playing || !this.f) return;
const times = this.f.times;
this.ptime += ((now - this.last) / 1000) * this.rate;
this.last = now;
let k = this.frame;
while (k < times.length - 1 && times[k + 1] <= this.ptime) k++;
this.show(k);
if (k >= times.length - 1) this.toggle();
else requestAnimationFrame(this.tick);
};
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);
const t = f.times[this.frame];
this.label.textContent = `t = ${(t * 1000).toFixed(2)} ms`;
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 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);
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();
}
}