/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
toy-instruments / src / ui / membraneview.ts
150 lines · 5.6 KBCodeBlameHistory
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 1import { LinePlot, css, fmtNum, setupCanvas } from './plot';
2import { rampLUT } from './spectrogram';
3import { maxAbsPerComponent } from './chainview';
4import { timelineBar, type Timeline } from './timeline';
6// 2D views of a flat membrane in the x-z plane vibrating along y: a top-down
7// map of y displacement, and a cross-section along a chosen diameter.
9export interface MembraneGeometry {
10 pos: [number, number][]; // bead (x, z)
11 radius: number; // rim radius
12 spacing: number; // lattice spacing
13 strike: [number, number] | null; // excitation point
15 pickupNote?: string; // shown when pickup is null (default: sum over all beads)
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 16 sectionAngle: number; // degrees, direction of the cross-section diameter
19export class MembraneView {
20 private g: MembraneGeometry | null = null;
21 private frames: Float32Array | null = null;
22 private maxY = 1;
23 private section: number[] = []; // bead indices along the diameter, sorted
24 private sectionS: Float64Array = new Float64Array(0);
25 private map: HTMLCanvasElement;
26 private profile: LinePlot;
27 private note: HTMLParagraphElement;
29 constructor(root: HTMLElement, private tl: Timeline) {
30 root.innerHTML = `
31 <div data-r="bar"></div>
32 <div class="membrane-row">
33 <div class="map-wrap"><canvas data-r="map"></canvas></div>
34 <div class="canvas-wrap profile-wrap"><canvas data-r="profile"></canvas></div>
35 </div>
36 <p class="note" data-r="note"></p>`;
37 const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
38 q('bar').replaceWith(timelineBar(tl));
39 this.map = q('map');
40 this.profile = new LinePlot(q('profile'));
41 this.note = q('note');
42 tl.onChange(() => this.draw());
43 new ResizeObserver(() => this.draw()).observe(this.map);
44 }
46 set(g: MembraneGeometry, frames: Float32Array) {
47 this.g = g;
48 this.frames = frames;
49 this.maxY = Math.max(maxAbsPerComponent(frames)[1], 1e-30);
50 const th = (g.sectionAngle * Math.PI) / 180;
51 const ux = Math.cos(th);
52 const uz = Math.sin(th);
53 const near = g.pos
54 .map(([x, z], i) => ({ i, s: x * ux + z * uz, d: Math.abs(-x * uz + z * ux) }))
55 .filter((b) => b.d < 0.51 * g.spacing)
56 .sort((a, b) => a.s - b.s);
57 this.section = near.map((b) => b.i);
58 this.sectionS = Float64Array.from(near.map((b) => b.s));
59 this.draw();
60 }
62 private draw() {
63 const g = this.g;
64 const f = this.frames;
65 if (!g || !f) return;
66 const n = g.pos.length;
67 const off = this.tl.frame * 3 * n;
69 // top-down map
70 const { ctx, w, h } = setupCanvas(this.map);
71 ctx.clearRect(0, 0, w, h);
72 const size = Math.min(w, h);
73 const s = (0.46 * size) / g.radius;
74 const cx = w / 2;
75 const cy = h / 2;
76 const lut = rampLUT('--diverging');
77 const cell = 0.58 * g.spacing * s; // hexagon circumradius that tiles the lattice
78 for (let i = 0; i < n; i++) {
79 const v = f[off + 3 * i + 1] / this.maxY;
80 const li = Math.round((0.5 + 0.5 * Math.max(-1, Math.min(1, v))) * 255) * 3;
81 ctx.fillStyle = `rgb(${lut[li]},${lut[li + 1]},${lut[li + 2]})`;
82 const [x, z] = g.pos[i];
83 const px = cx + x * s;
84 const pz = cy + z * s;
85 ctx.beginPath();
86 for (let k = 0; k < 6; k++) {
87 const a = (Math.PI / 3) * k + Math.PI / 6;
88 const X = px + cell * Math.cos(a);
89 const Z = pz + cell * Math.sin(a);
90 if (k) ctx.lineTo(X, Z);
91 else ctx.moveTo(X, Z);
92 }
93 ctx.closePath();
94 ctx.fill();
95 }
96 ctx.strokeStyle = css('--text-muted');
97 ctx.lineWidth = 2;
98 ctx.beginPath();
99 ctx.arc(cx, cy, g.radius * s, 0, 2 * Math.PI);
100 ctx.stroke();
101 // section line
102 const th = (g.sectionAngle * Math.PI) / 180;
103 ctx.strokeStyle = css('--text-secondary');
104 ctx.setLineDash([4, 4]);
105 ctx.lineWidth = 1;
106 ctx.beginPath();
107 ctx.moveTo(cx - g.radius * s * Math.cos(th), cy - g.radius * s * Math.sin(th));
108 ctx.lineTo(cx + g.radius * s * Math.cos(th), cy + g.radius * s * Math.sin(th));
109 ctx.stroke();
110 ctx.setLineDash([]);
111 if (g.strike) {
112 const [x, z] = g.strike;
113 ctx.strokeStyle = css('--series-2');
114 ctx.lineWidth = 2.5;
115 const r = 6;
116 ctx.beginPath();
117 ctx.moveTo(cx + x * s - r, cy + z * s - r);
118 ctx.lineTo(cx + x * s + r, cy + z * s + r);
119 ctx.moveTo(cx + x * s + r, cy + z * s - r);
120 ctx.lineTo(cx + x * s - r, cy + z * s + r);
121 ctx.stroke();
122 }
123 if (g.pickup !== null) {
124 const [x, z] = g.pos[g.pickup];
125 ctx.strokeStyle = css('--series-3');
126 ctx.lineWidth = 2.5;
127 ctx.beginPath();
128 ctx.arc(cx + x * s, cy + z * s, 7, 0, 2 * Math.PI);
129 ctx.stroke();
130 }
131 ctx.fillStyle = css('--text-muted');
132 ctx.font = '11px system-ui, sans-serif';
133 ctx.fillText('x →', w - 34, h - 8);
134 ctx.fillText('z ↓', 6, 14);
136 // cross-section
137 const y = new Float64Array(this.section.length);
138 this.section.forEach((i, k) => (y[k] = f[off + 3 * i + 1] * 1000));
139 const m = this.maxY * 1000 * 1.1;
140 this.profile.set([{ x: this.sectionS, y, color: css('--series-1'), label: 'y (mm)' }], {
141 xLabel: `position along the dashed diameter (m)`,
142 yLabel: 'y displacement (mm)',
143 xRange: [-g.radius, g.radius],
144 yRange: [-m, m],
145 });
146 this.note.textContent =
147 `Colour: vertical (y) displacement, blue down and red up, scaled to ±${fmtNum(this.maxY * 1000)} mm (the largest over the run). ` +
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 148 `${g.strike ? 'Orange ×: excitation point. ' : ''}${g.pickup !== null ? 'Green ring: pickup bead.' : (g.pickupNote ?? 'Pickup: sum over all beads.')}`;
↑↓ move↵openescclose