1// Where the mallet lands and where the pickup listens, set by dragging on a
2// scale cross-section of the shell. The model addresses both by radial
3// fraction (0 at the washer circle, 1 at the rim); this draws the actual
4// spherical cap, the washer, the mallet above the contact, and the pickup
5// point, and converts. The strike sits on the theta = 180 meridian and the
6// pickup (by default) on theta = 0, so the cross-section shows the strike on
7// the left half and the pickup on the right.
9import { useCallback, useRef } from 'react';
10import type { Cymbal2Params } from '../params';
11import { useCanvas, drawFrame, PLOT_COLORS } from './plots/canvasUtils';
13interface Props {
14 params: Cymbal2Params;
15 onChange: (key: keyof Cymbal2Params, value: number) => void;
16}
18const HEIGHT = 190;
20export default function StrikePicker({ params, onChange }: Props) {
21 const dragRef = useRef<'strike' | 'pickup' | null>(null);
22 const geomRef = useRef({ cx: 0, pw: 100 });
24 const a = params.radius_m;
25 const c = params.washer_radius_m;
26 const rise = params.rise_m;
27 // spherical cap: z(r) = rise * (1 - r^2 / a^2) (shallow approximation)
28 const capZ = useCallback((r: number) => rise * (1 - (r * r) / (a * a)), [rise, a]);
30 const strikeLeft = Math.cos((params.strike_theta_deg * Math.PI) / 180) < 0;
32 const ref = useCanvas(
33 (ctx, w, h) => {
34 drawFrame(ctx, w, h);
35 const padS = 16;
36 const pw = (w - 2 * padS) / 2;
37 const cx = w / 2;
38 geomRef.current = { cx, pw };
39 const px = (r: number, left: boolean) => cx + (left ? -1 : 1) * (r / a) * pw;
40 const baseY = h - 52;
41 const zScale = (h - 110) / Math.max(1e-4, rise);
42 const py = (z: number) => baseY - z * zScale;
44 // centimetre ruler
45 ctx.font = '10px system-ui';
46 ctx.textAlign = 'center';
47 for (let cm = -Math.floor(a * 100); cm <= a * 100 + 1e-9; cm += 5) {
48 const x = px(Math.abs(cm) / 100, cm < 0);
49 ctx.strokeStyle = PLOT_COLORS.grid;
50 ctx.beginPath();
51 ctx.moveTo(x, baseY + 8);
52 ctx.lineTo(x, baseY + (cm % 10 === 0 ? 14 : 11));
53 ctx.stroke();
54 if (cm % 10 === 0) {
55 ctx.fillStyle = PLOT_COLORS.axis;
56 ctx.fillText(`${cm}`, x, baseY + 25);
57 }
58 }
59 ctx.fillStyle = PLOT_COLORS.axis;
60 ctx.textAlign = 'left';
61 ctx.fillText('radius (cm)', padS, baseY + 25);
63 // the stand and washer
64 ctx.strokeStyle = 'rgba(139, 147, 161, 0.5)';
65 ctx.setLineDash([3, 3]);
66 ctx.beginPath();
67 ctx.moveTo(cx, py(capZ(0)) + 3);
68 ctx.lineTo(cx, baseY + 6);
69 ctx.stroke();
70 ctx.setLineDash([]);
71 ctx.fillStyle = 'rgba(139, 147, 161, 0.7)';
72 const wx0 = px(c, true);
73 const wx1 = px(c, false);
74 ctx.fillRect(wx0, py(capZ(c)) + 2, wx1 - wx0, 5);
75 ctx.fillStyle = PLOT_COLORS.axis;
76 ctx.textAlign = 'center';
77 ctx.fillText('washer', cx, py(capZ(c)) + 18);
79 // the shell cap, both halves
80 ctx.strokeStyle = '#c9a227';
81 ctx.lineWidth = 2.5;
82 ctx.beginPath();
83 const N = 80;
84 for (let i = 0; i <= N; i++) {
85 const r = -a + (2 * a * i) / N;
86 const x = px(Math.abs(r), r < 0);
87 const y = py(capZ(Math.abs(r)));
88 if (i === 0) ctx.moveTo(x, y);
89 else ctx.lineTo(x, y);
90 }
91 ctx.stroke();
92 ctx.lineWidth = 1;
94 // strike marker with the mallet above it
95 const rs = c + params.strike_r * (a - c);
96 const xs = px(rs, strikeLeft);
97 const ys = py(capZ(rs));
98 ctx.strokeStyle = PLOT_COLORS.accent;
99 ctx.lineWidth = 2.5;
100 ctx.beginPath();
101 ctx.moveTo(xs + (strikeLeft ? 34 : -34), ys - 44);
102 ctx.lineTo(xs + (strikeLeft ? 6 : -6), ys - 16);
103 ctx.stroke();
104 ctx.fillStyle = PLOT_COLORS.accent;
105 ctx.beginPath();
106 ctx.arc(xs, ys - 11, 6, 0, 2 * Math.PI);
107 ctx.fill();
108 ctx.font = '11px system-ui';
109 ctx.textAlign = strikeLeft ? 'left' : 'right';
110 ctx.fillText(
111 `strike r = ${(rs * 100).toFixed(1)} cm`,
112 xs + (strikeLeft ? 12 : -12),
113 ys - 30,
114 );
116 // pickup marker on the other meridian
117 const rp = c + params.pickup_r * (a - c);
118 const xp = px(rp, !strikeLeft);
119 const yp = py(capZ(rp));
120 ctx.strokeStyle = '#4dd0e1';
121 ctx.fillStyle = '#4dd0e1';
122 ctx.beginPath();
123 ctx.arc(xp, yp, 5, 0, 2 * Math.PI);
124 ctx.fill();
125 ctx.beginPath();
126 ctx.moveTo(xp, yp + 6);
127 ctx.lineTo(xp, yp + 16);
128 ctx.stroke();
129 ctx.textAlign = !strikeLeft ? 'left' : 'right';
130 ctx.fillText(`pickup r = ${(rp * 100).toFixed(1)} cm`, xp + (!strikeLeft ? 10 : -10), yp + 26);
132 ctx.fillStyle = PLOT_COLORS.axis;
133 ctx.font = '10px system-ui';
134 ctx.textAlign = 'left';
135 ctx.fillText(
136 `rise ${(rise * 100).toFixed(1)} cm -> curvature radius ${((a * a) / (2 * rise)).toFixed(2)} m (drawn to its own vertical scale)`,
137 padS,
138 16,
139 );
140 },
141 [params.strike_r, params.pickup_r, a, c, rise, capZ, strikeLeft],
142 HEIGHT,
143 );
145 const fracFromEvent = useCallback(
146 (e: React.PointerEvent<HTMLCanvasElement>) => {
147 const rect = e.currentTarget.getBoundingClientRect();
148 const { cx, pw } = geomRef.current;
149 const x = e.clientX - rect.left;
150 const left = x < cx;
151 const r = (Math.abs(x - cx) / pw) * a;
152 const frac = Math.min(0.99, Math.max(0.01, (r - c) / (a - c)));
153 return { frac, left };
154 },
155 [a, c],
156 );
158 const onDown = useCallback(
159 (e: React.PointerEvent<HTMLCanvasElement>) => {
160 const { frac, left } = fracFromEvent(e);
161 const which = left === strikeLeft ? 'strike' : 'pickup';
162 dragRef.current = which;
163 e.currentTarget.setPointerCapture(e.pointerId);
164 onChange(which === 'strike' ? 'strike_r' : 'pickup_r', frac);
165 },
166 [fracFromEvent, onChange, strikeLeft],
167 );
169 const onMove = useCallback(
170 (e: React.PointerEvent<HTMLCanvasElement>) => {
171 if (!dragRef.current) return;
172 onChange(dragRef.current === 'strike' ? 'strike_r' : 'pickup_r', fracFromEvent(e).frac);
173 },
174 [fracFromEvent, onChange],
175 );
177 const onUp = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
178 dragRef.current = null;
179 e.currentTarget.releasePointerCapture(e.pointerId);
180 }, []);
182 return (
183 <div className="strike-picker">
184 <canvas
185 ref={ref}
186 style={{ cursor: 'ew-resize', touchAction: 'none' }}
187 onPointerDown={onDown}
188 onPointerMove={onMove}
189 onPointerUp={onUp}
190 onPointerCancel={onUp}
191 />
192 <div className="picker-hint">
193 drag the amber marker (strike, on its meridian) or the cyan one (pickup, opposite)
194 </div>
195 </div>
196 );
197}