// Animated free-free bar mode shapes. One mode is shown large and oscillating; // the strike position is marked so nodes/antinodes can be read against it. import { useEffect, useRef, useState } from 'react'; import { PLOT_COLORS } from './canvasUtils'; interface Props { modeShapes: Float32Array; // n x shapePts shapePts: number; partialsHz: number[]; strikePosition: number; } export default function ModeShapes({ modeShapes, shapePts, partialsHz, strikePosition }: Props) { const n = partialsHz.length; const [selected, setSelected] = useState(0); const canvasRef = useRef(null); const sel = Math.min(selected, Math.max(0, n - 1)); useEffect(() => { const canvas = canvasRef.current; if (!canvas || n === 0) return; let raf = 0; const t0 = performance.now(); const draw = () => { raf = requestAnimationFrame(draw); const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = 180; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); canvas.style.height = `${h}px`; const ctx = canvas.getContext('2d')!; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = PLOT_COLORS.bg; ctx.fillRect(0, 0, w, h); const padL = 14; const pw = w - 2 * padL; const midY = h / 2; const amp = h * 0.32; // oscillation rate: slow, slightly faster for higher modes const t = (performance.now() - t0) / 1000; const rate = 0.8 + sel * 0.25; const osc = Math.sin(2 * Math.PI * rate * t); // static envelope ctx.strokeStyle = PLOT_COLORS.grid; ctx.setLineDash([3, 3]); for (const sgn of [1, -1]) { ctx.beginPath(); for (let i = 0; i < shapePts; i++) { const x = padL + (i / (shapePts - 1)) * pw; const y = midY - sgn * modeShapes[sel * shapePts + i] * amp; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } ctx.setLineDash([]); // rest axis ctx.strokeStyle = PLOT_COLORS.grid; ctx.beginPath(); ctx.moveTo(padL, midY); ctx.lineTo(padL + pw, midY); ctx.stroke(); // strike position const sx = padL + strikePosition * pw; ctx.strokeStyle = PLOT_COLORS.warn; ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.moveTo(sx, 14); ctx.lineTo(sx, h - 14); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = PLOT_COLORS.warn; ctx.font = '10px system-ui'; ctx.textAlign = 'center'; ctx.fillText('strike', sx, 11); // animated shape as a thick bar ctx.strokeStyle = PLOT_COLORS.trace; ctx.lineWidth = 3; ctx.beginPath(); for (let i = 0; i < shapePts; i++) { const x = padL + (i / (shapePts - 1)) * pw; const y = midY - modeShapes[sel * shapePts + i] * amp * osc; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); ctx.lineWidth = 1; // value of the mode at the strike point (drive efficiency) const idx = Math.round(strikePosition * (shapePts - 1)); const phiS = Math.abs(modeShapes[sel * shapePts + idx]); ctx.fillStyle = PLOT_COLORS.axis; ctx.textAlign = 'left'; ctx.fillText( `mode ${sel + 1} · ${partialsHz[sel].toFixed(0)} Hz · |φ| at strike: ${(phiS * 100).toFixed(0)}%`, padL, h - 4, ); }; draw(); return () => cancelAnimationFrame(raf); }, [modeShapes, shapePts, partialsHz, strikePosition, sel, n]); if (n === 0) return null; return (
{partialsHz.map((f, i) => ( ))}
); }