2 * The three planes through the source as 2D images, redrawn every frame so
3 * that the pressure can be animated over one period. Clicking a plane moves
4 * the source to that point (its third coordinate is the plane's).
5 */
6import { DIVERGING, SEQUENTIAL } from './colormaps.ts';
7import { colorParam, type Field, type Mode } from './field.ts';
9const AXIS = ['x', 'y', 'z'];
11export class SliceView {
12 private canvases: HTMLCanvasElement[] = [];
13 private titles: HTMLElement[] = [];
14 private img: ImageData[] = [];
15 private field: Field | null = null;
17 constructor(
18 container: HTMLElement,
19 private onPick: (x: [number, number, number]) => void,
20 private onHover: (text: string) => void,
21 ) {
22 for (let s = 0; s < 3; s++) {
23 const fig = document.createElement('figure');
24 fig.className = 'slice';
25 const cap = document.createElement('figcaption');
26 const cv = document.createElement('canvas');
27 cv.width = 300;
28 cv.height = 300;
29 fig.append(cv, cap);
30 container.append(fig);
31 this.canvases.push(cv);
32 this.titles.push(cap);
33 cv.addEventListener('click', (ev) => this.pick(s, ev));
34 cv.addEventListener('pointermove', (ev) => this.hover(s, ev));
35 cv.addEventListener('pointerleave', () => this.onHover(''));
36 }
37 }
39 /** Blank the planes, for while a new field is computed. */
40 clear() {
41 this.field = null;
42 this.img = [];
43 for (const [s, cv] of this.canvases.entries()) {
44 cv.getContext('2d')!.clearRect(0, 0, cv.width, cv.height);
45 this.titles[s].textContent = '';
46 }
47 this.onHover('');
48 }
50 setField(f: Field) {
51 this.field = f;
52 this.img = f.slices.map(() => new ImageData(f.ngrid, f.ngrid));
53 f.slices.forEach(({ slice }, s) => {
54 const [i, j] = slice.axes;
55 this.titles[s].textContent = `${slice.name}-plane at ${AXIS[slice.fixed]} = ${f.x0[slice.fixed].toFixed(2)} (${AXIS[i]} across, ${AXIS[j]} up)`;
56 });
57 }
59 /** Coordinates of a pointer event in the room, for slice s. */
60 private at(s: number, ev: PointerEvent | MouseEvent): { x: [number, number, number]; q: number } | null {
61 const f = this.field;
62 if (!f) return null;
63 const cv = this.canvases[s];
64 const r = cv.getBoundingClientRect();
65 const fx = (ev.clientX - r.left) / r.width;
66 const fy = 1 - (ev.clientY - r.top) / r.height;
67 const { slice } = f.slices[s];
68 const x: [number, number, number] = [...f.x0] as [number, number, number];
69 x[slice.axes[0]] = -f.a + 2 * f.a * fx;
70 x[slice.axes[1]] = -f.a + 2 * f.a * fy;
71 const col = Math.round(fx * (f.ngrid - 1));
72 const row = Math.round(fy * (f.ngrid - 1));
73 if (col < 0 || row < 0 || col >= f.ngrid || row >= f.ngrid) return null;
74 return { x, q: row * f.ngrid + col };
75 }
77 private pick(s: number, ev: MouseEvent) {
78 const p = this.at(s, ev);
79 if (p) this.onPick(p.x);
80 }
82 private hover(s: number, ev: PointerEvent) {
83 const p = this.at(s, ev);
84 const f = this.field;
85 if (!p || !f) return;
86 const { u, slice } = f.slices[s];
87 if (!slice.mask[p.q]) {
88 this.onHover(`(${p.x.map((v) => v.toFixed(2)).join(', ')}): outside the room`);
89 return;
90 }
91 const re = u[2 * p.q];
92 const im = u[2 * p.q + 1];
93 const db = 20 * Math.log10(Math.hypot(re, im) / f.ref);
94 this.onHover(`(${p.x.map((v) => v.toFixed(2)).join(', ')}): u = ${re.toExponential(3)} ${im < 0 ? '−' : '+'} ${Math.abs(im).toExponential(3)}i, level ${db.toFixed(1)} dB · click to move the source here`);
95 }
97 draw(mode: Mode, phase: number) {
98 const f = this.field;
99 if (!f) return;
100 const c = Math.cos(phase);
101 const sn = Math.sin(phase);
102 const map = mode === 'pressure' ? DIVERGING : SEQUENTIAL;
103 f.slices.forEach(({ slice, u }, s) => {
104 const img = this.img[s];
105 const n = f.ngrid;
106 for (let row = 0; row < n; row++) {
107 for (let col = 0; col < n; col++) {
108 const q = row * n + col;
109 const o = 4 * ((n - 1 - row) * n + col);
110 if (!slice.mask[q]) {
111 img.data[o + 3] = 0;
112 continue;
113 }
114 const t = colorParam(u[2 * q], u[2 * q + 1], mode, c, sn, f.umax, f.ref);
115 const i = Math.max(0, Math.min(255, Math.round(t * 255))) * 4;
116 img.data[o] = map[i];
117 img.data[o + 1] = map[i + 1];
118 img.data[o + 2] = map[i + 2];
119 img.data[o + 3] = 255;
120 }
121 }
122 const cv = this.canvases[s];
123 const ctx = cv.getContext('2d')!;
124 const tmp = new OffscreenCanvas(n, n);
125 tmp.getContext('2d')!.putImageData(img, 0, 0);
126 ctx.clearRect(0, 0, cv.width, cv.height);
127 ctx.imageSmoothingEnabled = true;
128 ctx.imageSmoothingQuality = 'high';
129 ctx.drawImage(tmp, 0, 0, cv.width, cv.height);
130 // the source
131 const [i, j] = slice.axes;
132 const px = ((f.x0[i] + f.a) / (2 * f.a)) * cv.width;
133 const py = (1 - (f.x0[j] + f.a) / (2 * f.a)) * cv.height;
134 ctx.beginPath();
135 ctx.arc(px, py, 5, 0, 2 * Math.PI);
136 ctx.lineWidth = 1.5;
137 ctx.strokeStyle = '#111';
138 ctx.stroke();
139 ctx.beginPath();
140 ctx.arc(px, py, 6.5, 0, 2 * Math.PI);
141 ctx.strokeStyle = '#fff';
142 ctx.lineWidth = 1;
143 ctx.stroke();
144 });
145 }
146}