// Small multiples of the lowest mode shapes on the triangle outline. // In-plane modes are drawn as the outline displaced within the plane; // out-of-plane modes keep the outline in place and color it by the normal // displacement (blue toward the viewer, red away), since that motion is // perpendicular to the drawing. The gap at the bottom-left corner is where // the free ends whip. import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils'; interface Props { nodeX: Float32Array; nodeY: Float32Array; shapeUx: Float32Array; // nn x n, column-major shapeUy: Float32Array; shapeUz: Float32Array; modeF: Float32Array; modeFamily: Float32Array; } const SHOWN = 12; const CELL_H = 110; export default function ModeShapes({ nodeX, nodeY, shapeUx, shapeUy, shapeUz, modeF, modeFamily, }: Props) { const nShown = Math.min(SHOWN, modeF.length); const rows = Math.ceil(nShown / 4); const ref = useCanvas( (ctx, w, h) => { drawFrame(ctx, w, h); const nn = nodeX.length; if (nn === 0 || nShown === 0) return; const cols = 4; const cw = w / cols; const ch = h / rows; // model bounding box, common to all cells let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity; for (let i = 0; i < nn; i++) { xMin = Math.min(xMin, nodeX[i]); xMax = Math.max(xMax, nodeX[i]); yMin = Math.min(yMin, nodeY[i]); yMax = Math.max(yMax, nodeY[i]); } const span = Math.max(xMax - xMin, yMax - yMin); const scale = (Math.min(cw, ch) - 34) / span; const defl = span * 0.1; // displacement drawn as 10% of the frame size 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 px = (x: number) => cx + (x - (xMin + xMax) / 2) * scale; const py = (y: number) => cy - (y - (yMin + yMax) / 2) * scale; const off = m * nn; let uMax = 1e-12; for (let i = 0; i < nn; i++) { uMax = Math.max( uMax, Math.hypot(shapeUx[off + i], shapeUy[off + i], shapeUz[off + i]), ); } // undeformed outline, faint ctx.strokeStyle = 'rgba(139, 147, 161, 0.25)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i < nn; i++) { const x = px(nodeX[i]); const y = py(nodeY[i]); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); if (modeFamily[m] === 0) { // in-plane: displaced outline ctx.strokeStyle = PLOT_COLORS.trace; ctx.lineWidth = 1.6; ctx.beginPath(); for (let i = 0; i < nn; i++) { const x = px(nodeX[i] + (shapeUx[off + i] / uMax) * defl); const y = py(nodeY[i] + (shapeUy[off + i] / uMax) * defl); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } else { // out-of-plane: color the outline by normal displacement ctx.lineWidth = 2.2; for (let i = 0; i < nn - 1; i++) { const t = (shapeUz[off + i] + shapeUz[off + i + 1]) / (2 * uMax); const mag = Math.min(1, Math.abs(t)); ctx.strokeStyle = t >= 0 ? `rgba(100, 181, 246, ${0.25 + 0.75 * mag})` : `rgba(229, 115, 115, ${0.25 + 0.75 * mag})`; ctx.beginPath(); ctx.moveTo(px(nodeX[i]), py(nodeY[i])); ctx.lineTo(px(nodeX[i + 1]), py(nodeY[i + 1])); ctx.stroke(); } } ctx.font = '10px system-ui'; ctx.textAlign = 'center'; ctx.fillStyle = modeFamily[m] === 0 ? PLOT_COLORS.trace : PLOT_COLORS.accent; const fam = modeFamily[m] === 0 ? 'in' : 'out'; ctx.fillText(`${modeF[m].toFixed(0)} Hz ยท ${fam}`, cx, cy + (ch / 2) - 8); } }, [nodeX, nodeY, shapeUx, shapeUy, shapeUz, modeF, modeFamily, nShown, rows], rows * CELL_H, ); return ; }