1// Top view of the cymbal on a canvas: rim, shoulder and bell circles, the preset strike spots
2// with their key labels, and a fading marker for every hit. Coordinates follow cymsim: x to the
3// right, y up, phi = atan2(y, x).
4import type { Profile } from "./config/schema.ts";
6export interface Spot {
7 code: string; // KeyboardEvent.code, so that the keyboard layout does not matter
8 label: string;
9 r: number;
10 phi: number;
11 row: number; // which key row the spot belongs to, used only for its colour
12 weight: number; // velocity of this position relative to the master level
13 velocity: number; // the resulting strike velocity [m/s], drawn under the key letter
14}
16interface Flash {
17 x: number;
18 y: number;
19 t0: number;
20 velocity: number;
21 kind: "stick" | "mallet";
22}
24export class CymbalView {
25 private ctx: CanvasRenderingContext2D;
26 private flashes: Flash[] = [];
27 private spots: Spot[] = [];
28 private profile: Profile | null = null;
29 private raf = 0;
30 onStrike: ((x: number, y: number) => void) | null = null;
31 /** Wheel over a position: ``steps`` is positive away from the user (harder). */
32 onWeigh: ((spot: Spot, steps: number) => void) | null = null;
34 private canvas: HTMLCanvasElement;
36 constructor(canvas: HTMLCanvasElement) {
37 this.canvas = canvas;
38 const ctx = canvas.getContext("2d");
39 if (!ctx) throw new Error("no 2d context");
40 this.ctx = ctx;
41 canvas.addEventListener("pointerdown", (ev) => {
42 if (!this.profile || !this.onStrike) return;
43 const [x, y] = this.toModel(ev);
44 this.onStrike(x, y);
45 });
46 canvas.addEventListener("wheel", (ev) => {
47 if (!this.profile || !this.onWeigh) return;
48 const [x, y] = this.toModel(ev);
49 const spot = this.spotAt(x, y);
50 if (!spot) return; // away from a position the page scrolls as usual
51 ev.preventDefault();
52 this.onWeigh(spot, ev.deltaY > 0 ? -1 : 1);
53 }, { passive: false });
54 const ro = new ResizeObserver(() => this.resize());
55 ro.observe(canvas);
56 this.resize();
57 }
59 private resize() {
60 const rect = this.canvas.getBoundingClientRect();
61 const dpr = window.devicePixelRatio || 1;
62 const size = Math.max(1, Math.floor(Math.min(rect.width, rect.height) * dpr));
63 if (this.canvas.width !== size) {
64 this.canvas.width = size;
65 this.canvas.height = size;
66 }
67 this.draw();
68 }
70 setProfile(profile: Profile, spots: Spot[]) {
71 this.profile = profile;
72 this.spots = spots;
73 this.draw();
74 }
76 private toModel(ev: { clientX: number; clientY: number }): [number, number] {
77 const rect = this.canvas.getBoundingClientRect();
78 const { cx, cy, scale } = this.frame();
79 return [
80 ((ev.clientX - rect.left) * this.canvas.width / rect.width - cx) / scale,
81 -((ev.clientY - rect.top) * this.canvas.height / rect.height - cy) / scale,
82 ];
83 }
85 /** The position under a point of the domain, if the point is within its marker. */
86 spotAt(x: number, y: number): Spot | null {
87 const { scale } = this.frame();
88 const reach = Math.max(this.markerRadius(1.6), this.canvas.width / 28) / scale;
89 let best: Spot | null = null, bestD = reach;
90 for (const s of this.spots) {
91 const d = Math.hypot(x - s.r * Math.cos(s.phi), y - s.r * Math.sin(s.phi));
92 if (d < bestD) {
93 bestD = d;
94 best = s;
95 }
96 }
97 return best;
98 }
100 private markerRadius(weight: number): number {
101 const fs = Math.max(10, this.canvas.width / 40);
102 return fs * 0.8 * Math.min(1.6, Math.pow(Math.max(weight, 0.05), 0.3));
103 }
105 private frame() {
106 const R = this.profile?.radius ?? 0.2286;
107 const w = this.canvas.width;
108 return { cx: w / 2, cy: w / 2, scale: (0.46 * w) / R };
109 }
111 flash(x: number, y: number, velocity: number, kind: "stick" | "mallet") {
112 this.flashes.push({ x, y, t0: performance.now(), velocity, kind });
113 this.animate();
114 }
116 private animate() {
117 if (this.raf) return;
118 const tick = () => {
119 this.raf = 0;
120 this.draw();
121 const now = performance.now();
122 this.flashes = this.flashes.filter((f) => now - f.t0 < 600);
123 if (this.flashes.length) this.raf = requestAnimationFrame(tick);
124 };
125 this.raf = requestAnimationFrame(tick);
126 }
128 draw() {
129 const { ctx, canvas } = this;
130 const w = canvas.width;
131 ctx.clearRect(0, 0, w, w);
132 if (!this.profile) return;
133 const p = this.profile;
134 const { cx, cy, scale } = this.frame();
135 const toX = (x: number) => cx + x * scale, toY = (y: number) => cy - y * scale;
136 // body
137 const grad = ctx.createRadialGradient(cx - 0.2 * w, cy - 0.2 * w, 0.05 * w, cx, cy, 0.5 * w);
138 grad.addColorStop(0, "#e8c97a");
139 grad.addColorStop(0.6, "#b8892f");
140 grad.addColorStop(1, "#7a5a1c");
141 ctx.fillStyle = grad;
142 ctx.beginPath();
143 ctx.arc(cx, cy, p.radius * scale, 0, 2 * Math.PI);
144 ctx.fill();
145 ctx.strokeStyle = "rgba(0,0,0,0.35)";
146 ctx.lineWidth = Math.max(1, w / 400);
147 ctx.stroke();
148 for (const r of [p.bell_shoulder_radius, p.bell_top_radius]) {
149 ctx.beginPath();
150 ctx.arc(cx, cy, r * scale, 0, 2 * Math.PI);
151 ctx.stroke();
152 }
153 // lathe lines
154 ctx.strokeStyle = "rgba(0,0,0,0.08)";
155 for (let r = p.bell_shoulder_radius + 0.01; r < p.radius; r += 0.012) {
156 ctx.beginPath();
157 ctx.arc(cx, cy, r * scale, 0, 2 * Math.PI);
158 ctx.stroke();
159 }
160 // spots
161 const fs = Math.max(10, w / 40);
162 const rowFill = ["rgba(30,70,150,0.62)", "rgba(20,110,110,0.62)", "rgba(130,60,140,0.62)", "rgba(170,110,20,0.72)"];
163 ctx.font = `${fs}px system-ui, sans-serif`;
164 ctx.textAlign = "center";
165 ctx.textBaseline = "middle";
166 for (const s of this.spots) {
167 const x = toX(s.r * Math.cos(s.phi)), y = toY(s.r * Math.sin(s.phi));
168 const rad = this.markerRadius(s.weight);
169 ctx.beginPath();
170 ctx.arc(x, y, rad, 0, 2 * Math.PI);
171 ctx.fillStyle = rowFill[s.row % rowFill.length];
172 ctx.fill();
173 if (Math.abs(s.weight - 1) > 1e-6) {
174 ctx.strokeStyle = s.weight > 1 ? "rgba(255,255,255,0.9)" : "rgba(0,0,0,0.45)";
175 ctx.lineWidth = Math.max(1, w / 500);
176 ctx.stroke();
177 }
178 ctx.fillStyle = "#fff";
179 ctx.font = `${s.label.length > 1 ? fs * 0.62 : fs}px system-ui, sans-serif`;
180 ctx.fillText(s.label, x, y);
181 ctx.font = `${fs * 0.5}px system-ui, sans-serif`;
182 ctx.fillStyle = "rgba(0,0,0,0.75)";
183 ctx.fillText(s.velocity.toFixed(1), x, y + rad + fs * 0.42);
184 }
185 // flashes
186 const now = performance.now();
187 for (const f of this.flashes) {
188 const a = Math.min(1, (now - f.t0) / 600);
189 const rad = (0.02 + 0.06 * a) * scale * (0.5 + f.velocity / 10);
190 ctx.beginPath();
191 ctx.arc(toX(f.x), toY(f.y), rad, 0, 2 * Math.PI);
192 ctx.strokeStyle = f.kind === "stick" ? `rgba(60,120,255,${1 - a})` : `rgba(255,80,100,${1 - a})`;
193 ctx.lineWidth = Math.max(2, w / 150) * (1 - a) + 1;
194 ctx.stroke();
195 ctx.beginPath();
196 ctx.arc(toX(f.x), toY(f.y), Math.max(2, w / 120), 0, 2 * Math.PI);
197 ctx.fillStyle = `rgba(255,255,255,${1 - a})`;
198 ctx.fill();
199 }
200 // axes hint
201 ctx.fillStyle = "rgba(0,0,0,0.5)";
202 ctx.font = `${fs * 0.8}px system-ui, sans-serif`;
203 ctx.textAlign = "left";
204 ctx.fillText("x", toX(p.radius) + fs * 0.3, cy);
205 ctx.textAlign = "center";
206 ctx.fillText("y", cx, toY(p.radius) - fs * 0.6);
207 }
208}