/ simulated_instruments / cymbal
Sign in
simulated_instruments / cymbal
cymbal / webapp / src / components / StrikePicker.tsx
277 lines · 10.2 KBBlameHistoryRaw
1// Where the stick lands, set by dragging on a scale cross-section of the
2// instrument. The model addresses the contact by arclength fraction along the
3// meridian (strike_r, 0 at the hole and 1 at the rim), which is the right
4// coordinate for the physics and a poor one to aim with, so this draws the
5// real profile and converts. Both contacts are draggable: the amber one is
6// strike_r, the dim one is roll_r2, which even-numbered strokes land on.
7//
8// Position is not a cosmetic control here. A mode with n nodal diameters has
9// an amplitude falling roughly as r^n toward the axis, so at the bell an
10// n = 40 mode is 1e-22 of its rim value: striking the bell drives only the
11// low-order modes and gives a sparse, clear tone, while striking the edge
12// drives everything and gives the wash. The strip underneath shows what the
13// current contact actually excites, band by band.
15import { useCallback, useMemo, useRef } from 'react';
16import type { CymbalParams } from '../params';
17import { cymbalMeridian, meridianSamples, pointAtFrac, fracAtRadius } from '../lib/profile';
18import { useCanvas, drawFrame, PLOT_COLORS, colormap } from './plots/canvasUtils';
20interface Props {
21 params: CymbalParams;
22 onChange: (key: keyof CymbalParams, value: number) => void;
23 excDb?: Float32Array; // stroke excitation per log-frequency band, dB
24 excBands?: number;
25 excFLo?: number;
26 excFHi?: number;
29const HEIGHT = 210;
30const PROFILE_H = 150;
32export default function StrikePicker({ params, onChange, excDb, excBands, excFLo, excFHi }: Props) {
33 const mer = useMemo(
34 () =>
35 cymbalMeridian(
36 params.radius_m,
37 params.hole_radius_m,
38 params.bell_radius_m,
39 params.bell_height_m,
40 params.bow_height_m,
41 params.thickness_bell_m,
42 params.thickness_rim_m,
43 ),
44 [
45 params.radius_m, params.hole_radius_m, params.bell_radius_m,
46 params.bell_height_m, params.bow_height_m,
47 params.thickness_bell_m, params.thickness_rim_m,
48 ],
49 );
50 const samp = useMemo(() => meridianSamples(mer), [mer]);
51 const dragRef = useRef<'primary' | 'second' | null>(null);
52 const geomRef = useRef({ padL: 46, pw: 100 });
54 const two = params.strike_count > 1;
56 const ref = useCanvas(
57 (ctx, w, h) => {
58 drawFrame(ctx, w, h);
59 const padL = 46;
60 const padR = 14;
61 const pw = w - padL - padR;
62 geomRef.current = { padL, pw };
63 const a = mer.rRim;
64 const px = (r: number) => padL + (r / a) * pw;
65 // vertical scale: exaggerate the profile so the bell and bow are legible
66 const zSpan = Math.max(1e-4, mer.apexHeight);
67 const baseY = PROFILE_H - 30;
68 const py = (z: number) => baseY - (z / zSpan) * (PROFILE_H - 74);
70 // region shading: bell, bow, edge
71 const regions: [number, number, string, string][] = [
72 [0, mer.rBell, 'rgba(232, 163, 61, 0.10)', 'bell'],
73 [mer.rBell, Math.max(mer.rBell, a - 0.045), 'rgba(77, 208, 225, 0.07)', 'bow'],
74 [Math.max(mer.rBell, a - 0.045), a, 'rgba(111, 191, 115, 0.10)', 'edge'],
75 ];
76 ctx.font = '10px system-ui';
77 for (const [r0, r1, fill, name] of regions) {
78 ctx.fillStyle = fill;
79 ctx.fillRect(px(r0), 8, px(r1) - px(r0), PROFILE_H - 20);
80 ctx.fillStyle = PLOT_COLORS.axis;
81 ctx.textAlign = 'center';
82 ctx.fillText(name, (px(r0) + px(r1)) / 2, 19);
83 }
85 // centimetre ruler along the radius
86 ctx.strokeStyle = PLOT_COLORS.grid;
87 ctx.textAlign = 'center';
88 for (let cm = 0; cm <= a * 100 + 1e-9; cm += 2) {
89 const x = px(cm / 100);
90 ctx.beginPath();
91 ctx.moveTo(x, baseY + 6);
92 ctx.lineTo(x, baseY + (cm % 10 === 0 ? 12 : 9));
93 ctx.stroke();
94 if (cm % 10 === 0) {
95 ctx.fillStyle = PLOT_COLORS.axis;
96 ctx.fillText(`${cm}`, x, baseY + 23);
97 }
98 }
99 ctx.fillStyle = PLOT_COLORS.axis;
100 ctx.textAlign = 'left';
101 ctx.fillText('radius (cm)', padL, baseY + 23);
103 // the profile itself, drawn with its thickness
104 ctx.strokeStyle = '#c9a227';
105 ctx.lineWidth = 2;
106 ctx.beginPath();
107 for (let i = 0; i < samp.r.length; i++) {
108 const x = px(samp.r[i]);
109 const y = py(samp.z[i]);
110 if (i === 0) ctx.moveTo(x, y);
111 else ctx.lineTo(x, y);
112 }
113 ctx.stroke();
114 // thickness, exaggerated, as a faint band under the centreline
115 ctx.strokeStyle = 'rgba(201, 162, 39, 0.35)';
116 ctx.lineWidth = 1;
117 ctx.beginPath();
118 for (let i = 0; i < samp.r.length; i++) {
119 const x = px(samp.r[i]);
120 const y = py(samp.z[i]) + mer.thickness(samp.r[i]) * 6000;
121 if (i === 0) ctx.moveTo(x, y);
122 else ctx.lineTo(x, y);
123 }
124 ctx.stroke();
126 // the mounting rod through the hole
127 ctx.strokeStyle = 'rgba(139, 147, 161, 0.5)';
128 ctx.setLineDash([3, 3]);
129 ctx.beginPath();
130 ctx.moveTo(px(0), 10);
131 ctx.lineTo(px(0), baseY + 4);
132 ctx.stroke();
133 ctx.setLineDash([]);
135 // contact markers
136 const drawContact = (frac: number, primary: boolean) => {
137 const pt = pointAtFrac(samp, frac);
138 const x = px(pt.r);
139 const y = py(pt.z);
140 const ang = (params.strike_angle_deg * Math.PI) / 180;
141 // stroke direction: -cos(angle) * normal + sin(angle) * tangent, drawn
142 // in the (radius, height) plane the cross-section shows
143 const nr = -pt.tz;
144 const nz = pt.tr;
145 let dr = -Math.cos(ang) * nr + Math.sin(ang) * pt.tr;
146 let dz = -Math.cos(ang) * nz + Math.sin(ang) * pt.tz;
147 const dl = Math.hypot(dr, dz) || 1;
148 dr /= dl;
149 dz /= dl;
150 const L = 34;
151 const color = primary ? PLOT_COLORS.accent : 'rgba(154, 122, 61, 0.85)';
152 // the shaft is drawn along the stroke direction as the exaggerated
153 // vertical scale of this cross-section renders it
154 const sx = dr * (pw / a);
155 const sy = -dz * ((PROFILE_H - 74) / zSpan);
156 const sl = Math.hypot(sx, sy) || 1;
157 ctx.strokeStyle = color;
158 ctx.lineWidth = primary ? 2.5 : 1.8;
159 ctx.beginPath();
160 ctx.moveTo(x - (sx / sl) * L, y - (sy / sl) * L);
161 ctx.lineTo(x - (sx / sl) * 9, y - (sy / sl) * 9);
162 ctx.stroke();
163 ctx.fillStyle = color;
164 ctx.beginPath();
165 ctx.arc(x - (sx / sl) * 7, y - (sy / sl) * 7, primary ? 6 : 4.5, 0, 2 * Math.PI);
166 ctx.fill();
167 ctx.strokeStyle = color;
168 ctx.lineWidth = 1;
169 ctx.beginPath();
170 ctx.moveTo(x, y - 4);
171 ctx.lineTo(x, y + 4);
172 ctx.stroke();
173 if (primary) {
174 ctx.fillStyle = PLOT_COLORS.accent;
175 ctx.font = '11px system-ui';
176 ctx.textAlign = x > padL + pw * 0.6 ? 'right' : 'left';
177 ctx.fillText(
178 `r = ${(pt.r * 100).toFixed(1)} cm · ${((a - pt.r) * 100).toFixed(1)} cm in from the rim`,
179 x + (x > padL + pw * 0.6 ? -10 : 10),
180 baseY - 6,
181 );
182 }
183 };
184 if (two) drawContact(params.roll_r2, false);
185 drawContact(params.strike_r, true);
187 // what this contact excites, band by band
188 const stripTop = PROFILE_H + 12;
189 const stripH = h - stripTop - 16;
190 ctx.fillStyle = PLOT_COLORS.axis;
191 ctx.font = '10px system-ui';
192 ctx.textAlign = 'left';
193 if (excDb && excBands && excBands > 0 && excFLo && excFHi) {
194 let mx = -Infinity;
195 for (let i = 0; i < excDb.length; i++) mx = Math.max(mx, excDb[i]);
196 const bw = pw / excBands;
197 for (let b = 0; b < excBands; b++) {
198 const v = Math.min(1, Math.max(0, (excDb[b] - mx + 45) / 45));
199 const [r, g, bl] = colormap(v);
200 ctx.fillStyle = `rgb(${r},${g},${bl})`;
201 ctx.fillRect(padL + b * bw, stripTop, bw + 0.6, stripH);
202 }
203 ctx.fillStyle = PLOT_COLORS.axis;
204 ctx.textAlign = 'center';
205 const lLo = Math.log10(excFLo);
206 const lHi = Math.log10(Math.max(excFLo * 1.2, excFHi));
207 for (const f of [100, 300, 1000, 3000, 10000]) {
208 if (f < excFLo || f > excFHi) continue;
209 const x = padL + ((Math.log10(f) - lLo) / (lHi - lLo)) * pw;
210 ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 4);
211 }
212 ctx.textAlign = 'right';
213 ctx.fillText('excited by this stroke', padL + pw, stripTop - 3);
214 ctx.textAlign = 'left';
215 ctx.fillText('modal energy', padL, stripTop - 3);
216 } else {
217 ctx.fillText('render to see what this contact excites', padL, stripTop + 12);
218 }
219 },
220 [
221 params.strike_r, params.roll_r2, params.strike_angle_deg, params.strike_count,
222 mer, samp, excDb, excBands, excFLo, excFHi, two,
223 ],
224 HEIGHT,
225 );
227 const fracFromEvent = useCallback(
228 (e: React.PointerEvent<HTMLCanvasElement>) => {
229 const rect = e.currentTarget.getBoundingClientRect();
230 const { padL, pw } = geomRef.current;
231 const radius = ((e.clientX - rect.left - padL) / pw) * mer.rRim;
232 return Math.min(0.999, Math.max(0.001, fracAtRadius(samp, radius)));
233 },
234 [mer, samp],
235 );
237 const onDown = useCallback(
238 (e: React.PointerEvent<HTMLCanvasElement>) => {
239 const f = fracFromEvent(e);
240 const which =
241 two && Math.abs(f - params.roll_r2) < Math.abs(f - params.strike_r) ? 'second' : 'primary';
242 dragRef.current = which;
243 e.currentTarget.setPointerCapture(e.pointerId);
244 onChange(which === 'primary' ? 'strike_r' : 'roll_r2', f);
245 },
246 [fracFromEvent, onChange, params.strike_r, params.roll_r2, two],
247 );
249 const onMove = useCallback(
250 (e: React.PointerEvent<HTMLCanvasElement>) => {
251 if (!dragRef.current) return;
252 onChange(dragRef.current === 'primary' ? 'strike_r' : 'roll_r2', fracFromEvent(e));
253 },
254 [fracFromEvent, onChange],
255 );
257 const onUp = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
258 dragRef.current = null;
259 e.currentTarget.releasePointerCapture(e.pointerId);
260 }, []);
262 return (
263 <div className="strike-picker">
264 <canvas
265 ref={ref}
266 style={{ cursor: 'ew-resize', touchAction: 'none' }}
267 onPointerDown={onDown}
268 onPointerMove={onMove}
269 onPointerUp={onUp}
270 onPointerCancel={onUp}
271 />
272 <div className="picker-hint">
273 drag to move the stick{two ? ' · the dim marker is the second contact' : ''}
274 </div>
275 </div>
276 );
↑↓ move↵openescclose