1// The lowest mode shapes, drawn as the plate is seen from above: the normal
2// displacement W(r) cos(n theta), blue toward the viewer and red away. The
3// nodal diameters are the straight lines through the centre and the nodal
4// circles are the rings, so a mode is read off directly as its (n, k) pair.
5// Only the cos member of each degenerate pair is retained by the model (see
6// cymbal_basis.m), and it is the member aligned with the stroke, drawn here at
7// theta = 0 pointing right.
9import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils';
11interface Props {
12 nodeR: Float32Array;
13 shapeW: Float32Array; // nn x n, column-major
14 modeF: Float32Array;
15 modeN: Float32Array;
16}
18const SHOWN = 12;
19const CELL = 118;
20const COLS = 4;
22export default function ModeGallery({ nodeR, shapeW, modeF, modeN }: Props) {
23 const nShown = Math.min(SHOWN, modeF.length);
24 const rows = Math.max(1, Math.ceil(nShown / COLS));
25 const ref = useCanvas(
26 (ctx, w, h) => {
27 drawFrame(ctx, w, h);
28 const nn = nodeR.length;
29 if (nn === 0 || nShown === 0) return;
30 const cw = w / COLS;
31 const ch = h / rows;
32 const rMax = nodeR[nn - 1];
33 const rMin = nodeR[0];
34 const rad = Math.min(cw, ch) / 2 - 16;
36 for (let m = 0; m < nShown; m++) {
37 const cx = (m % COLS) * cw + cw / 2;
38 const cy = Math.floor(m / COLS) * ch + ch / 2 - 4;
39 const off = m * nn;
40 let uMax = 1e-30;
41 for (let i = 0; i < nn; i++) uMax = Math.max(uMax, Math.abs(shapeW[off + i]));
42 const nDia = modeN[m];
44 const size = Math.max(8, Math.round(2 * rad));
45 const img = ctx.createImageData(size, size);
46 for (let py = 0; py < size; py++) {
47 for (let px = 0; px < size; px++) {
48 const dx = (px - size / 2) / (size / 2);
49 const dy = (py - size / 2) / (size / 2);
50 const rr = Math.hypot(dx, dy);
51 const idx = (py * size + px) * 4;
52 if (rr > 1) {
53 img.data[idx + 3] = 0;
54 continue;
55 }
56 const rPhys = rMin + rr * (rMax - rMin);
57 // linear interpolation of the nodal normal shape
58 let j = Math.min(nn - 2, Math.max(0, Math.floor(((rPhys - rMin) / (rMax - rMin)) * (nn - 1))));
59 const t = (rPhys - nodeR[j]) / Math.max(1e-12, nodeR[j + 1] - nodeR[j]);
60 const wr = shapeW[off + j] + t * (shapeW[off + j + 1] - shapeW[off + j]);
61 const th = Math.atan2(dy, dx);
62 const v = (wr / uMax) * Math.cos(nDia * th);
63 const a = Math.min(1, Math.abs(v));
64 if (v >= 0) {
65 img.data[idx] = Math.round(24 + 76 * a);
66 img.data[idx + 1] = Math.round(30 + 151 * a);
67 img.data[idx + 2] = Math.round(38 + 208 * a);
68 } else {
69 img.data[idx] = Math.round(24 + 205 * a);
70 img.data[idx + 1] = Math.round(30 + 85 * a);
71 img.data[idx + 2] = Math.round(38 + 77 * a);
72 }
73 img.data[idx + 3] = 255;
74 }
75 }
76 const oc = new OffscreenCanvas(size, size);
77 oc.getContext('2d')!.putImageData(img, 0, 0);
78 ctx.drawImage(oc, cx - rad, cy - rad, 2 * rad, 2 * rad);
80 ctx.strokeStyle = 'rgba(139, 147, 161, 0.5)';
81 ctx.lineWidth = 1;
82 ctx.beginPath();
83 ctx.arc(cx, cy, rad, 0, 2 * Math.PI);
84 ctx.stroke();
86 ctx.font = '10px system-ui';
87 ctx.textAlign = 'center';
88 ctx.fillStyle = PLOT_COLORS.axis;
89 ctx.fillText(`${modeF[m].toFixed(0)} Hz · n = ${nDia}`, cx, cy + rad + 12);
90 }
91 },
92 [nodeR, shapeW, modeF, modeN, nShown, rows],
93 rows * CELL,
94 );
95 return <canvas ref={ref} />;
96}