import { LinePlot, css, fmtNum, setupCanvas } from './plot';
import { rampLUT } from './spectrogram';
import { maxAbsPerComponent } from './chainview';
import { timelineBar, type Timeline } from './timeline';
// 2D views of a flat membrane in the x-z plane vibrating along y: a top-down
// map of y displacement, and a cross-section along a chosen diameter.
export interface MembraneGeometry {
pos: [number, number][]; // bead (x, z)
radius: number; // rim radius
spacing: number; // lattice spacing
strike: [number, number] | null; // excitation point
pickup: number | null; // pickup bead, null = not a single bead
pickupNote?: string; // shown when pickup is null (default: sum over all beads)
sectionAngle: number; // degrees, direction of the cross-section diameter
}
export class MembraneView {
private g: MembraneGeometry | null = null;
private frames: Float32Array | null = null;
private maxY = 1;
private section: number[] = []; // bead indices along the diameter, sorted
private sectionS: Float64Array = new Float64Array(0);
private map: HTMLCanvasElement;
private profile: LinePlot;
private note: HTMLParagraphElement;
constructor(root: HTMLElement, private tl: Timeline) {
root.innerHTML = `
`;
const q = (r: string) => root.querySelector(`[data-r="${r}"]`) as T;
q('bar').replaceWith(timelineBar(tl));
this.map = q('map');
this.profile = new LinePlot(q('profile'));
this.note = q('note');
tl.onChange(() => this.draw());
new ResizeObserver(() => this.draw()).observe(this.map);
}
set(g: MembraneGeometry, frames: Float32Array) {
this.g = g;
this.frames = frames;
this.maxY = Math.max(maxAbsPerComponent(frames)[1], 1e-30);
const th = (g.sectionAngle * Math.PI) / 180;
const ux = Math.cos(th);
const uz = Math.sin(th);
const near = g.pos
.map(([x, z], i) => ({ i, s: x * ux + z * uz, d: Math.abs(-x * uz + z * ux) }))
.filter((b) => b.d < 0.51 * g.spacing)
.sort((a, b) => a.s - b.s);
this.section = near.map((b) => b.i);
this.sectionS = Float64Array.from(near.map((b) => b.s));
this.draw();
}
private draw() {
const g = this.g;
const f = this.frames;
if (!g || !f) return;
const n = g.pos.length;
const off = this.tl.frame * 3 * n;
// top-down map
const { ctx, w, h } = setupCanvas(this.map);
ctx.clearRect(0, 0, w, h);
const size = Math.min(w, h);
const s = (0.46 * size) / g.radius;
const cx = w / 2;
const cy = h / 2;
const lut = rampLUT('--diverging');
const cell = 0.58 * g.spacing * s; // hexagon circumradius that tiles the lattice
for (let i = 0; i < n; i++) {
const v = f[off + 3 * i + 1] / this.maxY;
const li = Math.round((0.5 + 0.5 * Math.max(-1, Math.min(1, v))) * 255) * 3;
ctx.fillStyle = `rgb(${lut[li]},${lut[li + 1]},${lut[li + 2]})`;
const [x, z] = g.pos[i];
const px = cx + x * s;
const pz = cy + z * s;
ctx.beginPath();
for (let k = 0; k < 6; k++) {
const a = (Math.PI / 3) * k + Math.PI / 6;
const X = px + cell * Math.cos(a);
const Z = pz + cell * Math.sin(a);
if (k) ctx.lineTo(X, Z);
else ctx.moveTo(X, Z);
}
ctx.closePath();
ctx.fill();
}
ctx.strokeStyle = css('--text-muted');
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, g.radius * s, 0, 2 * Math.PI);
ctx.stroke();
// section line
const th = (g.sectionAngle * Math.PI) / 180;
ctx.strokeStyle = css('--text-secondary');
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - g.radius * s * Math.cos(th), cy - g.radius * s * Math.sin(th));
ctx.lineTo(cx + g.radius * s * Math.cos(th), cy + g.radius * s * Math.sin(th));
ctx.stroke();
ctx.setLineDash([]);
if (g.strike) {
const [x, z] = g.strike;
ctx.strokeStyle = css('--series-2');
ctx.lineWidth = 2.5;
const r = 6;
ctx.beginPath();
ctx.moveTo(cx + x * s - r, cy + z * s - r);
ctx.lineTo(cx + x * s + r, cy + z * s + r);
ctx.moveTo(cx + x * s + r, cy + z * s - r);
ctx.lineTo(cx + x * s - r, cy + z * s + r);
ctx.stroke();
}
if (g.pickup !== null) {
const [x, z] = g.pos[g.pickup];
ctx.strokeStyle = css('--series-3');
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(cx + x * s, cy + z * s, 7, 0, 2 * Math.PI);
ctx.stroke();
}
ctx.fillStyle = css('--text-muted');
ctx.font = '11px system-ui, sans-serif';
ctx.fillText('x →', w - 34, h - 8);
ctx.fillText('z ↓', 6, 14);
// cross-section
const y = new Float64Array(this.section.length);
this.section.forEach((i, k) => (y[k] = f[off + 3 * i + 1] * 1000));
const m = this.maxY * 1000 * 1.1;
this.profile.set([{ x: this.sectionS, y, color: css('--series-1'), label: 'y (mm)' }], {
xLabel: `position along the dashed diameter (m)`,
yLabel: 'y displacement (mm)',
xRange: [-g.radius, g.radius],
yRange: [-m, m],
});
this.note.textContent =
`Colour: vertical (y) displacement, blue down and red up, scaled to ±${fmtNum(this.maxY * 1000)} mm (the largest over the run). ` +
`${g.strike ? 'Orange ×: excitation point. ' : ''}${g.pickup !== null ? 'Green ring: pickup bead.' : (g.pickupNote ?? 'Pickup: sum over all beads.')}`;
}
}