// The lowest mode shapes, drawn as the plate is seen from above: the normal // displacement W(r) cos(n theta), blue toward the viewer and red away. The // nodal diameters are the straight lines through the centre and the nodal // circles are the rings, so a mode is read off directly as its (n, k) pair. // Only the cos member of each degenerate pair is retained by the model (see // cymbal_basis.m), and it is the member aligned with the stroke, drawn here at // theta = 0 pointing right. import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils'; interface Props { nodeR: Float32Array; shapeW: Float32Array; // nn x n, column-major modeF: Float32Array; modeN: Float32Array; } const SHOWN = 12; const CELL = 118; const COLS = 4; export default function ModeGallery({ nodeR, shapeW, modeF, modeN }: Props) { const nShown = Math.min(SHOWN, modeF.length); const rows = Math.max(1, Math.ceil(nShown / COLS)); const ref = useCanvas( (ctx, w, h) => { drawFrame(ctx, w, h); const nn = nodeR.length; if (nn === 0 || nShown === 0) return; const cw = w / COLS; const ch = h / rows; const rMax = nodeR[nn - 1]; const rMin = nodeR[0]; const rad = Math.min(cw, ch) / 2 - 16; for (let m = 0; m < nShown; m++) { const cx = (m % COLS) * cw + cw / 2; const cy = Math.floor(m / COLS) * ch + ch / 2 - 4; const off = m * nn; let uMax = 1e-30; for (let i = 0; i < nn; i++) uMax = Math.max(uMax, Math.abs(shapeW[off + i])); const nDia = modeN[m]; const size = Math.max(8, Math.round(2 * rad)); const img = ctx.createImageData(size, size); for (let py = 0; py < size; py++) { for (let px = 0; px < size; px++) { const dx = (px - size / 2) / (size / 2); const dy = (py - size / 2) / (size / 2); const rr = Math.hypot(dx, dy); const idx = (py * size + px) * 4; if (rr > 1) { img.data[idx + 3] = 0; continue; } const rPhys = rMin + rr * (rMax - rMin); // linear interpolation of the nodal normal shape let j = Math.min(nn - 2, Math.max(0, Math.floor(((rPhys - rMin) / (rMax - rMin)) * (nn - 1)))); const t = (rPhys - nodeR[j]) / Math.max(1e-12, nodeR[j + 1] - nodeR[j]); const wr = shapeW[off + j] + t * (shapeW[off + j + 1] - shapeW[off + j]); const th = Math.atan2(dy, dx); const v = (wr / uMax) * Math.cos(nDia * th); const a = Math.min(1, Math.abs(v)); if (v >= 0) { img.data[idx] = Math.round(24 + 76 * a); img.data[idx + 1] = Math.round(30 + 151 * a); img.data[idx + 2] = Math.round(38 + 208 * a); } else { img.data[idx] = Math.round(24 + 205 * a); img.data[idx + 1] = Math.round(30 + 85 * a); img.data[idx + 2] = Math.round(38 + 77 * a); } img.data[idx + 3] = 255; } } const oc = new OffscreenCanvas(size, size); oc.getContext('2d')!.putImageData(img, 0, 0); ctx.drawImage(oc, cx - rad, cy - rad, 2 * rad, 2 * rad); ctx.strokeStyle = 'rgba(139, 147, 161, 0.5)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, rad, 0, 2 * Math.PI); ctx.stroke(); ctx.font = '10px system-ui'; ctx.textAlign = 'center'; ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(`${modeF[m].toFixed(0)} Hz ยท n = ${nDia}`, cx, cy + rad + 12); } }, [nodeR, shapeW, modeF, modeN, nShown, rows], rows * CELL, ); return ; }