// The retained shell modes, plotted as frequency against spherical-harmonic // degree l. The two membrane branches separate clearly: the lower one climbs // slowly from the l = 2 mode and is what the grains mostly excite, the upper // one starts at the breathing mode. Marker area is the radiation efficiency // sigma_l, and the bar under each marker is its damping ratio, so a mode that // is loud but short-lived is visible as a large marker with a long bar. import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils'; interface Props { modeF: Float32Array; modeL: Float32Array; modeZeta: Float32Array; modeSigma: Float32Array; breathingHz: number; } export default function ModeMap({ modeF, modeL, modeZeta, modeSigma, breathingHz }: Props) { const ref = useCanvas( (ctx, w, h) => { drawFrame(ctx, w, h); const padL = 40; const padB = 20; const pw = w - padL - 10; const ph = h - padB - 14; const top = 12; const n = modeF.length; if (n === 0) return; let lMax = 1; let fMax = 1; let zMax = 1e-9; let sMax = 1e-9; for (let i = 0; i < n; i++) { lMax = Math.max(lMax, modeL[i]); fMax = Math.max(fMax, modeF[i]); zMax = Math.max(zMax, modeZeta[i]); sMax = Math.max(sMax, modeSigma[i]); } fMax *= 1.08; const lx = (l: number) => padL + (l / lMax) * pw; const fy = (f: number) => top + ph - (f / fMax) * ph; ctx.font = '10px system-ui'; ctx.textAlign = 'center'; const lStep = lMax > 20 ? 5 : lMax > 8 ? 2 : 1; for (let l = 0; l <= lMax; l += lStep) { const x = lx(l); ctx.strokeStyle = PLOT_COLORS.grid; ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, top + ph); ctx.stroke(); ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(`${l}`, x, h - 7); } ctx.fillText('degree l', padL + pw / 2, h - 0); ctx.textAlign = 'right'; const fStep = fMax > 16000 ? 5000 : fMax > 6000 ? 2000 : 1000; for (let f = 0; f <= fMax; f += fStep) { const y = fy(f); ctx.strokeStyle = PLOT_COLORS.grid; ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + pw, y); ctx.stroke(); ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, padL - 4, y + 3); } // damping bars, then markers on top for (let i = 0; i < n; i++) { const x = lx(modeL[i]); const y = fy(modeF[i]); const barH = (modeZeta[i] / zMax) * 22; ctx.strokeStyle = 'rgba(232, 163, 61, 0.5)'; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + barH); ctx.stroke(); } for (let i = 0; i < n; i++) { const x = lx(modeL[i]); const y = fy(modeF[i]); const r = 2.5 + 5 * Math.sqrt(modeSigma[i] / sMax); const isBreathing = Math.abs(modeF[i] - breathingHz) < 1e-6; ctx.fillStyle = isBreathing ? PLOT_COLORS.good : PLOT_COLORS.trace; ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.fill(); if (isBreathing) { ctx.fillStyle = PLOT_COLORS.good; ctx.textAlign = 'left'; ctx.fillText(`breathing ${breathingHz.toFixed(0)} Hz`, x + r + 4, y + 3); } } ctx.fillStyle = PLOT_COLORS.axis; ctx.textAlign = 'left'; ctx.fillText(`${n} modes · marker = radiation efficiency · bar = damping`, padL + 6, top + 2); }, [modeF, modeL, modeZeta, modeSigma, breathingHz], 210, ); return ; }