// Top view of the cymbal on a canvas: rim, shoulder and bell circles, the preset strike spots // with their key labels, and a fading marker for every hit. Coordinates follow cymsim: x to the // right, y up, phi = atan2(y, x). import type { Profile } from "./config/schema.ts"; export interface Spot { code: string; // KeyboardEvent.code, so that the keyboard layout does not matter label: string; r: number; phi: number; row: number; // which key row the spot belongs to, used only for its colour weight: number; // velocity of this position relative to the master level velocity: number; // the resulting strike velocity [m/s], drawn under the key letter } interface Flash { x: number; y: number; t0: number; velocity: number; kind: "stick" | "mallet"; } export class CymbalView { private ctx: CanvasRenderingContext2D; private flashes: Flash[] = []; private spots: Spot[] = []; private profile: Profile | null = null; private hint: string | null = null; private raf = 0; onStrike: ((x: number, y: number) => void) | null = null; /** Wheel over a position: ``steps`` is positive away from the user (harder). */ onWeigh: ((spot: Spot, steps: number) => void) | null = null; private canvas: HTMLCanvasElement; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("no 2d context"); this.ctx = ctx; canvas.addEventListener("pointerdown", (ev) => { if (!this.profile || !this.onStrike) return; const [x, y] = this.toModel(ev); this.onStrike(x, y); }); canvas.addEventListener("wheel", (ev) => { if (!this.profile || !this.onWeigh) return; const [x, y] = this.toModel(ev); const spot = this.spotAt(x, y); if (!spot) return; // away from a position the page scrolls as usual ev.preventDefault(); this.onWeigh(spot, ev.deltaY > 0 ? -1 : 1); }, { passive: false }); const ro = new ResizeObserver(() => this.resize()); ro.observe(canvas); this.resize(); } private resize() { const rect = this.canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const size = Math.max(1, Math.floor(Math.min(rect.width, rect.height) * dpr)); if (this.canvas.width !== size) { this.canvas.width = size; this.canvas.height = size; } this.draw(); } /** A short instruction drawn across the cymbal while it cannot be played. */ setHint(hint: string | null) { if (hint === this.hint) return; this.hint = hint; this.draw(); } setProfile(profile: Profile, spots: Spot[]) { this.profile = profile; this.spots = spots; this.draw(); } private toModel(ev: { clientX: number; clientY: number }): [number, number] { const rect = this.canvas.getBoundingClientRect(); const { cx, cy, scale } = this.frame(); return [ ((ev.clientX - rect.left) * this.canvas.width / rect.width - cx) / scale, -((ev.clientY - rect.top) * this.canvas.height / rect.height - cy) / scale, ]; } /** The position under a point of the domain, if the point is within its marker. */ spotAt(x: number, y: number): Spot | null { const { scale } = this.frame(); const reach = Math.max(this.markerRadius(1.6), this.canvas.width / 28) / scale; let best: Spot | null = null, bestD = reach; for (const s of this.spots) { const d = Math.hypot(x - s.r * Math.cos(s.phi), y - s.r * Math.sin(s.phi)); if (d < bestD) { bestD = d; best = s; } } return best; } private markerRadius(weight: number): number { const fs = Math.max(10, this.canvas.width / 40); return fs * 0.8 * Math.min(1.6, Math.pow(Math.max(weight, 0.05), 0.3)); } private frame() { const R = this.profile?.radius ?? 0.2286; const w = this.canvas.width; return { cx: w / 2, cy: w / 2, scale: (0.46 * w) / R }; } flash(x: number, y: number, velocity: number, kind: "stick" | "mallet") { this.flashes.push({ x, y, t0: performance.now(), velocity, kind }); this.animate(); } private animate() { if (this.raf) return; const tick = () => { this.raf = 0; this.draw(); const now = performance.now(); this.flashes = this.flashes.filter((f) => now - f.t0 < 600); if (this.flashes.length) this.raf = requestAnimationFrame(tick); }; this.raf = requestAnimationFrame(tick); } draw() { const { ctx, canvas } = this; const w = canvas.width; ctx.clearRect(0, 0, w, w); if (!this.profile) return; const p = this.profile; const { cx, cy, scale } = this.frame(); const toX = (x: number) => cx + x * scale, toY = (y: number) => cy - y * scale; // body const grad = ctx.createRadialGradient(cx - 0.2 * w, cy - 0.2 * w, 0.05 * w, cx, cy, 0.5 * w); grad.addColorStop(0, "#e8c97a"); grad.addColorStop(0.6, "#b8892f"); grad.addColorStop(1, "#7a5a1c"); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(cx, cy, p.radius * scale, 0, 2 * Math.PI); ctx.fill(); ctx.strokeStyle = "rgba(0,0,0,0.35)"; ctx.lineWidth = Math.max(1, w / 400); ctx.stroke(); for (const r of [p.bell_shoulder_radius, p.bell_top_radius]) { ctx.beginPath(); ctx.arc(cx, cy, r * scale, 0, 2 * Math.PI); ctx.stroke(); } // lathe lines ctx.strokeStyle = "rgba(0,0,0,0.08)"; for (let r = p.bell_shoulder_radius + 0.01; r < p.radius; r += 0.012) { ctx.beginPath(); ctx.arc(cx, cy, r * scale, 0, 2 * Math.PI); ctx.stroke(); } // spots const fs = Math.max(10, w / 40); 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)"]; ctx.font = `${fs}px system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; for (const s of this.spots) { const x = toX(s.r * Math.cos(s.phi)), y = toY(s.r * Math.sin(s.phi)); const rad = this.markerRadius(s.weight); ctx.beginPath(); ctx.arc(x, y, rad, 0, 2 * Math.PI); ctx.fillStyle = rowFill[s.row % rowFill.length]; ctx.fill(); if (Math.abs(s.weight - 1) > 1e-6) { ctx.strokeStyle = s.weight > 1 ? "rgba(255,255,255,0.9)" : "rgba(0,0,0,0.45)"; ctx.lineWidth = Math.max(1, w / 500); ctx.stroke(); } ctx.fillStyle = "#fff"; ctx.font = `${s.label.length > 1 ? fs * 0.62 : fs}px system-ui, sans-serif`; ctx.fillText(s.label, x, y); ctx.font = `${fs * 0.5}px system-ui, sans-serif`; ctx.fillStyle = "rgba(0,0,0,0.75)"; ctx.fillText(s.velocity.toFixed(1), x, y + rad + fs * 0.42); } // flashes const now = performance.now(); for (const f of this.flashes) { const a = Math.min(1, (now - f.t0) / 600); const rad = (0.02 + 0.06 * a) * scale * (0.5 + f.velocity / 10); ctx.beginPath(); ctx.arc(toX(f.x), toY(f.y), rad, 0, 2 * Math.PI); ctx.strokeStyle = f.kind === "stick" ? `rgba(60,120,255,${1 - a})` : `rgba(255,80,100,${1 - a})`; ctx.lineWidth = Math.max(2, w / 150) * (1 - a) + 1; ctx.stroke(); ctx.beginPath(); ctx.arc(toX(f.x), toY(f.y), Math.max(2, w / 120), 0, 2 * Math.PI); ctx.fillStyle = `rgba(255,255,255,${1 - a})`; ctx.fill(); } if (this.hint) { ctx.fillStyle = "rgba(20,18,14,0.55)"; ctx.beginPath(); ctx.arc(cx, cy, p.radius * scale, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = "#fff"; ctx.font = `${fs * 1.05}px system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(this.hint, cx, cy); } // axes hint ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.font = `${fs * 0.8}px system-ui, sans-serif`; ctx.textAlign = "left"; ctx.fillText("x", toX(p.radius) + fs * 0.3, cy); ctx.textAlign = "center"; ctx.fillText("y", cx, toY(p.radius) - fs * 0.6); } }