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.')}`; } }