// Where the mallet lands and where the pickup listens, set by dragging on a // scale cross-section of the shell. The model addresses both by radial // fraction (0 at the washer circle, 1 at the rim); this draws the actual // spherical cap, the washer, the mallet above the contact, and the pickup // point, and converts. The strike sits on the theta = 180 meridian and the // pickup (by default) on theta = 0, so the cross-section shows the strike on // the left half and the pickup on the right. import { useCallback, useRef } from 'react'; import type { Cymbal2Params } from '../params'; import { useCanvas, drawFrame, PLOT_COLORS } from './plots/canvasUtils'; interface Props { params: Cymbal2Params; onChange: (key: keyof Cymbal2Params, value: number) => void; } const HEIGHT = 190; export default function StrikePicker({ params, onChange }: Props) { const dragRef = useRef<'strike' | 'pickup' | null>(null); const geomRef = useRef({ cx: 0, pw: 100 }); const a = params.radius_m; const c = params.washer_radius_m; const rise = params.rise_m; // spherical cap: z(r) = rise * (1 - r^2 / a^2) (shallow approximation) const capZ = useCallback((r: number) => rise * (1 - (r * r) / (a * a)), [rise, a]); const strikeLeft = Math.cos((params.strike_theta_deg * Math.PI) / 180) < 0; const ref = useCanvas( (ctx, w, h) => { drawFrame(ctx, w, h); const padS = 16; const pw = (w - 2 * padS) / 2; const cx = w / 2; geomRef.current = { cx, pw }; const px = (r: number, left: boolean) => cx + (left ? -1 : 1) * (r / a) * pw; const baseY = h - 52; const zScale = (h - 110) / Math.max(1e-4, rise); const py = (z: number) => baseY - z * zScale; // centimetre ruler ctx.font = '10px system-ui'; ctx.textAlign = 'center'; for (let cm = -Math.floor(a * 100); cm <= a * 100 + 1e-9; cm += 5) { const x = px(Math.abs(cm) / 100, cm < 0); ctx.strokeStyle = PLOT_COLORS.grid; ctx.beginPath(); ctx.moveTo(x, baseY + 8); ctx.lineTo(x, baseY + (cm % 10 === 0 ? 14 : 11)); ctx.stroke(); if (cm % 10 === 0) { ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(`${cm}`, x, baseY + 25); } } ctx.fillStyle = PLOT_COLORS.axis; ctx.textAlign = 'left'; ctx.fillText('radius (cm)', padS, baseY + 25); // the stand and washer ctx.strokeStyle = 'rgba(139, 147, 161, 0.5)'; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(cx, py(capZ(0)) + 3); ctx.lineTo(cx, baseY + 6); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(139, 147, 161, 0.7)'; const wx0 = px(c, true); const wx1 = px(c, false); ctx.fillRect(wx0, py(capZ(c)) + 2, wx1 - wx0, 5); ctx.fillStyle = PLOT_COLORS.axis; ctx.textAlign = 'center'; ctx.fillText('washer', cx, py(capZ(c)) + 18); // the shell cap, both halves ctx.strokeStyle = '#c9a227'; ctx.lineWidth = 2.5; ctx.beginPath(); const N = 80; for (let i = 0; i <= N; i++) { const r = -a + (2 * a * i) / N; const x = px(Math.abs(r), r < 0); const y = py(capZ(Math.abs(r))); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); ctx.lineWidth = 1; // strike marker with the mallet above it const rs = c + params.strike_r * (a - c); const xs = px(rs, strikeLeft); const ys = py(capZ(rs)); ctx.strokeStyle = PLOT_COLORS.accent; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(xs + (strikeLeft ? 34 : -34), ys - 44); ctx.lineTo(xs + (strikeLeft ? 6 : -6), ys - 16); ctx.stroke(); ctx.fillStyle = PLOT_COLORS.accent; ctx.beginPath(); ctx.arc(xs, ys - 11, 6, 0, 2 * Math.PI); ctx.fill(); ctx.font = '11px system-ui'; ctx.textAlign = strikeLeft ? 'left' : 'right'; ctx.fillText( `strike r = ${(rs * 100).toFixed(1)} cm`, xs + (strikeLeft ? 12 : -12), ys - 30, ); // pickup marker on the other meridian const rp = c + params.pickup_r * (a - c); const xp = px(rp, !strikeLeft); const yp = py(capZ(rp)); ctx.strokeStyle = '#4dd0e1'; ctx.fillStyle = '#4dd0e1'; ctx.beginPath(); ctx.arc(xp, yp, 5, 0, 2 * Math.PI); ctx.fill(); ctx.beginPath(); ctx.moveTo(xp, yp + 6); ctx.lineTo(xp, yp + 16); ctx.stroke(); ctx.textAlign = !strikeLeft ? 'left' : 'right'; ctx.fillText(`pickup r = ${(rp * 100).toFixed(1)} cm`, xp + (!strikeLeft ? 10 : -10), yp + 26); ctx.fillStyle = PLOT_COLORS.axis; ctx.font = '10px system-ui'; ctx.textAlign = 'left'; ctx.fillText( `rise ${(rise * 100).toFixed(1)} cm -> curvature radius ${((a * a) / (2 * rise)).toFixed(2)} m (drawn to its own vertical scale)`, padS, 16, ); }, [params.strike_r, params.pickup_r, a, c, rise, capZ, strikeLeft], HEIGHT, ); const fracFromEvent = useCallback( (e: React.PointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const { cx, pw } = geomRef.current; const x = e.clientX - rect.left; const left = x < cx; const r = (Math.abs(x - cx) / pw) * a; const frac = Math.min(0.99, Math.max(0.01, (r - c) / (a - c))); return { frac, left }; }, [a, c], ); const onDown = useCallback( (e: React.PointerEvent) => { const { frac, left } = fracFromEvent(e); const which = left === strikeLeft ? 'strike' : 'pickup'; dragRef.current = which; e.currentTarget.setPointerCapture(e.pointerId); onChange(which === 'strike' ? 'strike_r' : 'pickup_r', frac); }, [fracFromEvent, onChange, strikeLeft], ); const onMove = useCallback( (e: React.PointerEvent) => { if (!dragRef.current) return; onChange(dragRef.current === 'strike' ? 'strike_r' : 'pickup_r', fracFromEvent(e).frac); }, [fracFromEvent, onChange], ); const onUp = useCallback((e: React.PointerEvent) => { dragRef.current = null; e.currentTarget.releasePointerCapture(e.pointerId); }, []); return (
drag the amber marker (strike, on its meridian) or the cyan one (pickup, opposite)
); }