/ proof-of-concept / cymplay
Sign in
proof-of-concept / cymplay
cymplay / src / view.ts
227 lines · 7.8 KBCodeBlameHistory
ae4a407cymplay: design and play a cymsim cymbal in the browserJeremy Magland 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
16interface Flash {
17 x: number;
18 y: number;
19 t0: number;
20 velocity: number;
21 kind: "stick" | "mallet";
24export class CymbalView {
25 private ctx: CanvasRenderingContext2D;
26 private flashes: Flash[] = [];
27 private spots: Spot[] = [];
28 private profile: Profile | null = null;
9c9fa54Guide the first visit, and separate recording from playingJeremy Magland 29 private hint: string | null = null;
ae4a407cymplay: design and play a cymsim cymbal in the browserJeremy Magland 30 private raf = 0;
31 onStrike: ((x: number, y: number) => void) | null = null;
32 /** Wheel over a position: ``steps`` is positive away from the user (harder). */
33 onWeigh: ((spot: Spot, steps: number) => void) | null = null;
35 private canvas: HTMLCanvasElement;
37 constructor(canvas: HTMLCanvasElement) {
38 this.canvas = canvas;
39 const ctx = canvas.getContext("2d");
40 if (!ctx) throw new Error("no 2d context");
41 this.ctx = ctx;
42 canvas.addEventListener("pointerdown", (ev) => {
43 if (!this.profile || !this.onStrike) return;
44 const [x, y] = this.toModel(ev);
45 this.onStrike(x, y);
46 });
47 canvas.addEventListener("wheel", (ev) => {
48 if (!this.profile || !this.onWeigh) return;
49 const [x, y] = this.toModel(ev);
50 const spot = this.spotAt(x, y);
51 if (!spot) return; // away from a position the page scrolls as usual
52 ev.preventDefault();
53 this.onWeigh(spot, ev.deltaY > 0 ? -1 : 1);
54 }, { passive: false });
55 const ro = new ResizeObserver(() => this.resize());
56 ro.observe(canvas);
57 this.resize();
58 }
60 private resize() {
61 const rect = this.canvas.getBoundingClientRect();
62 const dpr = window.devicePixelRatio || 1;
63 const size = Math.max(1, Math.floor(Math.min(rect.width, rect.height) * dpr));
64 if (this.canvas.width !== size) {
65 this.canvas.width = size;
66 this.canvas.height = size;
67 }
68 this.draw();
69 }
9c9fa54Guide the first visit, and separate recording from playingJeremy Magland 71 /** A short instruction drawn across the cymbal while it cannot be played. */
72 setHint(hint: string | null) {
73 if (hint === this.hint) return;
74 this.hint = hint;
75 this.draw();
76 }
ae4a407cymplay: design and play a cymsim cymbal in the browserJeremy Magland 78 setProfile(profile: Profile, spots: Spot[]) {
79 this.profile = profile;
80 this.spots = spots;
81 this.draw();
82 }
84 private toModel(ev: { clientX: number; clientY: number }): [number, number] {
85 const rect = this.canvas.getBoundingClientRect();
86 const { cx, cy, scale } = this.frame();
87 return [
88 ((ev.clientX - rect.left) * this.canvas.width / rect.width - cx) / scale,
89 -((ev.clientY - rect.top) * this.canvas.height / rect.height - cy) / scale,
90 ];
91 }
93 /** The position under a point of the domain, if the point is within its marker. */
94 spotAt(x: number, y: number): Spot | null {
95 const { scale } = this.frame();
96 const reach = Math.max(this.markerRadius(1.6), this.canvas.width / 28) / scale;
97 let best: Spot | null = null, bestD = reach;
98 for (const s of this.spots) {
99 const d = Math.hypot(x - s.r * Math.cos(s.phi), y - s.r * Math.sin(s.phi));
100 if (d < bestD) {
101 bestD = d;
102 best = s;
103 }
104 }
105 return best;
106 }
108 private markerRadius(weight: number): number {
109 const fs = Math.max(10, this.canvas.width / 40);
110 return fs * 0.8 * Math.min(1.6, Math.pow(Math.max(weight, 0.05), 0.3));
111 }
113 private frame() {
114 const R = this.profile?.radius ?? 0.2286;
115 const w = this.canvas.width;
116 return { cx: w / 2, cy: w / 2, scale: (0.46 * w) / R };
117 }
119 flash(x: number, y: number, velocity: number, kind: "stick" | "mallet") {
120 this.flashes.push({ x, y, t0: performance.now(), velocity, kind });
121 this.animate();
122 }
124 private animate() {
125 if (this.raf) return;
126 const tick = () => {
127 this.raf = 0;
128 this.draw();
129 const now = performance.now();
130 this.flashes = this.flashes.filter((f) => now - f.t0 < 600);
131 if (this.flashes.length) this.raf = requestAnimationFrame(tick);
132 };
133 this.raf = requestAnimationFrame(tick);
134 }
136 draw() {
137 const { ctx, canvas } = this;
138 const w = canvas.width;
139 ctx.clearRect(0, 0, w, w);
140 if (!this.profile) return;
141 const p = this.profile;
142 const { cx, cy, scale } = this.frame();
143 const toX = (x: number) => cx + x * scale, toY = (y: number) => cy - y * scale;
144 // body
145 const grad = ctx.createRadialGradient(cx - 0.2 * w, cy - 0.2 * w, 0.05 * w, cx, cy, 0.5 * w);
146 grad.addColorStop(0, "#e8c97a");
147 grad.addColorStop(0.6, "#b8892f");
148 grad.addColorStop(1, "#7a5a1c");
149 ctx.fillStyle = grad;
150 ctx.beginPath();
151 ctx.arc(cx, cy, p.radius * scale, 0, 2 * Math.PI);
152 ctx.fill();
153 ctx.strokeStyle = "rgba(0,0,0,0.35)";
154 ctx.lineWidth = Math.max(1, w / 400);
155 ctx.stroke();
156 for (const r of [p.bell_shoulder_radius, p.bell_top_radius]) {
157 ctx.beginPath();
158 ctx.arc(cx, cy, r * scale, 0, 2 * Math.PI);
159 ctx.stroke();
160 }
161 // lathe lines
162 ctx.strokeStyle = "rgba(0,0,0,0.08)";
163 for (let r = p.bell_shoulder_radius + 0.01; r < p.radius; r += 0.012) {
164 ctx.beginPath();
165 ctx.arc(cx, cy, r * scale, 0, 2 * Math.PI);
166 ctx.stroke();
167 }
168 // spots
169 const fs = Math.max(10, w / 40);
170 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)"];
171 ctx.font = `${fs}px system-ui, sans-serif`;
172 ctx.textAlign = "center";
173 ctx.textBaseline = "middle";
174 for (const s of this.spots) {
175 const x = toX(s.r * Math.cos(s.phi)), y = toY(s.r * Math.sin(s.phi));
176 const rad = this.markerRadius(s.weight);
177 ctx.beginPath();
178 ctx.arc(x, y, rad, 0, 2 * Math.PI);
179 ctx.fillStyle = rowFill[s.row % rowFill.length];
180 ctx.fill();
181 if (Math.abs(s.weight - 1) > 1e-6) {
182 ctx.strokeStyle = s.weight > 1 ? "rgba(255,255,255,0.9)" : "rgba(0,0,0,0.45)";
183 ctx.lineWidth = Math.max(1, w / 500);
184 ctx.stroke();
185 }
186 ctx.fillStyle = "#fff";
187 ctx.font = `${s.label.length > 1 ? fs * 0.62 : fs}px system-ui, sans-serif`;
188 ctx.fillText(s.label, x, y);
189 ctx.font = `${fs * 0.5}px system-ui, sans-serif`;
190 ctx.fillStyle = "rgba(0,0,0,0.75)";
191 ctx.fillText(s.velocity.toFixed(1), x, y + rad + fs * 0.42);
192 }
193 // flashes
194 const now = performance.now();
195 for (const f of this.flashes) {
196 const a = Math.min(1, (now - f.t0) / 600);
197 const rad = (0.02 + 0.06 * a) * scale * (0.5 + f.velocity / 10);
198 ctx.beginPath();
199 ctx.arc(toX(f.x), toY(f.y), rad, 0, 2 * Math.PI);
200 ctx.strokeStyle = f.kind === "stick" ? `rgba(60,120,255,${1 - a})` : `rgba(255,80,100,${1 - a})`;
201 ctx.lineWidth = Math.max(2, w / 150) * (1 - a) + 1;
202 ctx.stroke();
203 ctx.beginPath();
204 ctx.arc(toX(f.x), toY(f.y), Math.max(2, w / 120), 0, 2 * Math.PI);
205 ctx.fillStyle = `rgba(255,255,255,${1 - a})`;
206 ctx.fill();
207 }
209 ctx.fillStyle = "rgba(20,18,14,0.55)";
210 ctx.beginPath();
211 ctx.arc(cx, cy, p.radius * scale, 0, 2 * Math.PI);
212 ctx.fill();
213 ctx.fillStyle = "#fff";
214 ctx.font = `${fs * 1.05}px system-ui, sans-serif`;
215 ctx.textAlign = "center";
216 ctx.textBaseline = "middle";
217 ctx.fillText(this.hint, cx, cy);
218 }
220 ctx.fillStyle = "rgba(0,0,0,0.5)";
221 ctx.font = `${fs * 0.8}px system-ui, sans-serif`;
222 ctx.textAlign = "left";
223 ctx.fillText("x", toX(p.radius) + fs * 0.3, cy);
224 ctx.textAlign = "center";
225 ctx.fillText("y", cx, toY(p.radius) - fs * 0.6);
226 }
↑↓ move↵openescclose