// Where the stick lands, set by dragging on a scale cross-section of the // instrument. The model addresses the contact by arclength fraction along the // meridian (strike_r, 0 at the hole and 1 at the rim), which is the right // coordinate for the physics and a poor one to aim with, so this draws the // real profile and converts. Both contacts are draggable: the amber one is // strike_r, the dim one is roll_r2, which even-numbered strokes land on. // // Position is not a cosmetic control here. A mode with n nodal diameters has // an amplitude falling roughly as r^n toward the axis, so at the bell an // n = 40 mode is 1e-22 of its rim value: striking the bell drives only the // low-order modes and gives a sparse, clear tone, while striking the edge // drives everything and gives the wash. The strip underneath shows what the // current contact actually excites, band by band. import { useCallback, useMemo, useRef } from 'react'; import type { CymbalParams } from '../params'; import { cymbalMeridian, meridianSamples, pointAtFrac, fracAtRadius } from '../lib/profile'; import { useCanvas, drawFrame, PLOT_COLORS, colormap } from './plots/canvasUtils'; interface Props { params: CymbalParams; onChange: (key: keyof CymbalParams, value: number) => void; excDb?: Float32Array; // stroke excitation per log-frequency band, dB excBands?: number; excFLo?: number; excFHi?: number; } const HEIGHT = 210; const PROFILE_H = 150; export default function StrikePicker({ params, onChange, excDb, excBands, excFLo, excFHi }: Props) { const mer = useMemo( () => cymbalMeridian( params.radius_m, params.hole_radius_m, params.bell_radius_m, params.bell_height_m, params.bow_height_m, params.thickness_bell_m, params.thickness_rim_m, ), [ params.radius_m, params.hole_radius_m, params.bell_radius_m, params.bell_height_m, params.bow_height_m, params.thickness_bell_m, params.thickness_rim_m, ], ); const samp = useMemo(() => meridianSamples(mer), [mer]); const dragRef = useRef<'primary' | 'second' | null>(null); const geomRef = useRef({ padL: 46, pw: 100 }); const two = params.strike_count > 1; const ref = useCanvas( (ctx, w, h) => { drawFrame(ctx, w, h); const padL = 46; const padR = 14; const pw = w - padL - padR; geomRef.current = { padL, pw }; const a = mer.rRim; const px = (r: number) => padL + (r / a) * pw; // vertical scale: exaggerate the profile so the bell and bow are legible const zSpan = Math.max(1e-4, mer.apexHeight); const baseY = PROFILE_H - 30; const py = (z: number) => baseY - (z / zSpan) * (PROFILE_H - 74); // region shading: bell, bow, edge const regions: [number, number, string, string][] = [ [0, mer.rBell, 'rgba(232, 163, 61, 0.10)', 'bell'], [mer.rBell, Math.max(mer.rBell, a - 0.045), 'rgba(77, 208, 225, 0.07)', 'bow'], [Math.max(mer.rBell, a - 0.045), a, 'rgba(111, 191, 115, 0.10)', 'edge'], ]; ctx.font = '10px system-ui'; for (const [r0, r1, fill, name] of regions) { ctx.fillStyle = fill; ctx.fillRect(px(r0), 8, px(r1) - px(r0), PROFILE_H - 20); ctx.fillStyle = PLOT_COLORS.axis; ctx.textAlign = 'center'; ctx.fillText(name, (px(r0) + px(r1)) / 2, 19); } // centimetre ruler along the radius ctx.strokeStyle = PLOT_COLORS.grid; ctx.textAlign = 'center'; for (let cm = 0; cm <= a * 100 + 1e-9; cm += 2) { const x = px(cm / 100); ctx.beginPath(); ctx.moveTo(x, baseY + 6); ctx.lineTo(x, baseY + (cm % 10 === 0 ? 12 : 9)); ctx.stroke(); if (cm % 10 === 0) { ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(`${cm}`, x, baseY + 23); } } ctx.fillStyle = PLOT_COLORS.axis; ctx.textAlign = 'left'; ctx.fillText('radius (cm)', padL, baseY + 23); // the profile itself, drawn with its thickness ctx.strokeStyle = '#c9a227'; ctx.lineWidth = 2; ctx.beginPath(); for (let i = 0; i < samp.r.length; i++) { const x = px(samp.r[i]); const y = py(samp.z[i]); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); // thickness, exaggerated, as a faint band under the centreline ctx.strokeStyle = 'rgba(201, 162, 39, 0.35)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i < samp.r.length; i++) { const x = px(samp.r[i]); const y = py(samp.z[i]) + mer.thickness(samp.r[i]) * 6000; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); // the mounting rod through the hole ctx.strokeStyle = 'rgba(139, 147, 161, 0.5)'; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(px(0), 10); ctx.lineTo(px(0), baseY + 4); ctx.stroke(); ctx.setLineDash([]); // contact markers const drawContact = (frac: number, primary: boolean) => { const pt = pointAtFrac(samp, frac); const x = px(pt.r); const y = py(pt.z); const ang = (params.strike_angle_deg * Math.PI) / 180; // stroke direction: -cos(angle) * normal + sin(angle) * tangent, drawn // in the (radius, height) plane the cross-section shows const nr = -pt.tz; const nz = pt.tr; let dr = -Math.cos(ang) * nr + Math.sin(ang) * pt.tr; let dz = -Math.cos(ang) * nz + Math.sin(ang) * pt.tz; const dl = Math.hypot(dr, dz) || 1; dr /= dl; dz /= dl; const L = 34; const color = primary ? PLOT_COLORS.accent : 'rgba(154, 122, 61, 0.85)'; // the shaft is drawn along the stroke direction as the exaggerated // vertical scale of this cross-section renders it const sx = dr * (pw / a); const sy = -dz * ((PROFILE_H - 74) / zSpan); const sl = Math.hypot(sx, sy) || 1; ctx.strokeStyle = color; ctx.lineWidth = primary ? 2.5 : 1.8; ctx.beginPath(); ctx.moveTo(x - (sx / sl) * L, y - (sy / sl) * L); ctx.lineTo(x - (sx / sl) * 9, y - (sy / sl) * 9); ctx.stroke(); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x - (sx / sl) * 7, y - (sy / sl) * 7, primary ? 6 : 4.5, 0, 2 * Math.PI); ctx.fill(); ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y - 4); ctx.lineTo(x, y + 4); ctx.stroke(); if (primary) { ctx.fillStyle = PLOT_COLORS.accent; ctx.font = '11px system-ui'; ctx.textAlign = x > padL + pw * 0.6 ? 'right' : 'left'; ctx.fillText( `r = ${(pt.r * 100).toFixed(1)} cm · ${((a - pt.r) * 100).toFixed(1)} cm in from the rim`, x + (x > padL + pw * 0.6 ? -10 : 10), baseY - 6, ); } }; if (two) drawContact(params.roll_r2, false); drawContact(params.strike_r, true); // what this contact excites, band by band const stripTop = PROFILE_H + 12; const stripH = h - stripTop - 16; ctx.fillStyle = PLOT_COLORS.axis; ctx.font = '10px system-ui'; ctx.textAlign = 'left'; if (excDb && excBands && excBands > 0 && excFLo && excFHi) { let mx = -Infinity; for (let i = 0; i < excDb.length; i++) mx = Math.max(mx, excDb[i]); const bw = pw / excBands; for (let b = 0; b < excBands; b++) { const v = Math.min(1, Math.max(0, (excDb[b] - mx + 45) / 45)); const [r, g, bl] = colormap(v); ctx.fillStyle = `rgb(${r},${g},${bl})`; ctx.fillRect(padL + b * bw, stripTop, bw + 0.6, stripH); } ctx.fillStyle = PLOT_COLORS.axis; ctx.textAlign = 'center'; const lLo = Math.log10(excFLo); const lHi = Math.log10(Math.max(excFLo * 1.2, excFHi)); for (const f of [100, 300, 1000, 3000, 10000]) { if (f < excFLo || f > excFHi) continue; const x = padL + ((Math.log10(f) - lLo) / (lHi - lLo)) * pw; ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 4); } ctx.textAlign = 'right'; ctx.fillText('excited by this stroke', padL + pw, stripTop - 3); ctx.textAlign = 'left'; ctx.fillText('modal energy', padL, stripTop - 3); } else { ctx.fillText('render to see what this contact excites', padL, stripTop + 12); } }, [ params.strike_r, params.roll_r2, params.strike_angle_deg, params.strike_count, mer, samp, excDb, excBands, excFLo, excFHi, two, ], HEIGHT, ); const fracFromEvent = useCallback( (e: React.PointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const { padL, pw } = geomRef.current; const radius = ((e.clientX - rect.left - padL) / pw) * mer.rRim; return Math.min(0.999, Math.max(0.001, fracAtRadius(samp, radius))); }, [mer, samp], ); const onDown = useCallback( (e: React.PointerEvent) => { const f = fracFromEvent(e); const which = two && Math.abs(f - params.roll_r2) < Math.abs(f - params.strike_r) ? 'second' : 'primary'; dragRef.current = which; e.currentTarget.setPointerCapture(e.pointerId); onChange(which === 'primary' ? 'strike_r' : 'roll_r2', f); }, [fracFromEvent, onChange, params.strike_r, params.roll_r2, two], ); const onMove = useCallback( (e: React.PointerEvent) => { if (!dragRef.current) return; onChange(dragRef.current === 'primary' ? 'strike_r' : 'roll_r2', fracFromEvent(e)); }, [fracFromEvent, onChange], ); const onUp = useCallback((e: React.PointerEvent) => { dragRef.current = null; e.currentTarget.releasePointerCapture(e.pointerId); }, []); return (
drag to move the stick{two ? ' · the dim marker is the second contact' : ''}
); }