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