/** * The three planes through the source as 2D images, redrawn every frame so * that the pressure can be animated over one period. Clicking a plane moves * the source to that point (its third coordinate is the plane's). */ import { DIVERGING, SEQUENTIAL } from './colormaps.ts'; import { colorParam, type Field, type Mode } from './field.ts'; const AXIS = ['x', 'y', 'z']; export class SliceView { private canvases: HTMLCanvasElement[] = []; private titles: HTMLElement[] = []; private img: ImageData[] = []; private field: Field | null = null; constructor( container: HTMLElement, private onPick: (x: [number, number, number]) => void, private onHover: (text: string) => void, ) { for (let s = 0; s < 3; s++) { const fig = document.createElement('figure'); fig.className = 'slice'; const cap = document.createElement('figcaption'); const cv = document.createElement('canvas'); cv.width = 300; cv.height = 300; fig.append(cv, cap); container.append(fig); this.canvases.push(cv); this.titles.push(cap); cv.addEventListener('click', (ev) => this.pick(s, ev)); cv.addEventListener('pointermove', (ev) => this.hover(s, ev)); cv.addEventListener('pointerleave', () => this.onHover('')); } } /** Blank the planes, for while a new field is computed. */ clear() { this.field = null; this.img = []; for (const [s, cv] of this.canvases.entries()) { cv.getContext('2d')!.clearRect(0, 0, cv.width, cv.height); this.titles[s].textContent = ''; } this.onHover(''); } setField(f: Field) { this.field = f; this.img = f.slices.map(() => new ImageData(f.ngrid, f.ngrid)); f.slices.forEach(({ slice }, s) => { const [i, j] = slice.axes; this.titles[s].textContent = `${slice.name}-plane at ${AXIS[slice.fixed]} = ${f.x0[slice.fixed].toFixed(2)} (${AXIS[i]} across, ${AXIS[j]} up)`; }); } /** Coordinates of a pointer event in the room, for slice s. */ private at(s: number, ev: PointerEvent | MouseEvent): { x: [number, number, number]; q: number } | null { const f = this.field; if (!f) return null; const cv = this.canvases[s]; const r = cv.getBoundingClientRect(); const fx = (ev.clientX - r.left) / r.width; const fy = 1 - (ev.clientY - r.top) / r.height; const { slice } = f.slices[s]; const x: [number, number, number] = [...f.x0] as [number, number, number]; x[slice.axes[0]] = -f.a + 2 * f.a * fx; x[slice.axes[1]] = -f.a + 2 * f.a * fy; const col = Math.round(fx * (f.ngrid - 1)); const row = Math.round(fy * (f.ngrid - 1)); if (col < 0 || row < 0 || col >= f.ngrid || row >= f.ngrid) return null; return { x, q: row * f.ngrid + col }; } private pick(s: number, ev: MouseEvent) { const p = this.at(s, ev); if (p) this.onPick(p.x); } private hover(s: number, ev: PointerEvent) { const p = this.at(s, ev); const f = this.field; if (!p || !f) return; const { u, slice } = f.slices[s]; if (!slice.mask[p.q]) { this.onHover(`(${p.x.map((v) => v.toFixed(2)).join(', ')}): outside the room`); return; } const re = u[2 * p.q]; const im = u[2 * p.q + 1]; const db = 20 * Math.log10(Math.hypot(re, im) / f.ref); 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`); } draw(mode: Mode, phase: number) { const f = this.field; if (!f) return; const c = Math.cos(phase); const sn = Math.sin(phase); const map = mode === 'pressure' ? DIVERGING : SEQUENTIAL; f.slices.forEach(({ slice, u }, s) => { const img = this.img[s]; const n = f.ngrid; for (let row = 0; row < n; row++) { for (let col = 0; col < n; col++) { const q = row * n + col; const o = 4 * ((n - 1 - row) * n + col); if (!slice.mask[q]) { img.data[o + 3] = 0; continue; } const t = colorParam(u[2 * q], u[2 * q + 1], mode, c, sn, f.umax, f.ref); const i = Math.max(0, Math.min(255, Math.round(t * 255))) * 4; img.data[o] = map[i]; img.data[o + 1] = map[i + 1]; img.data[o + 2] = map[i + 2]; img.data[o + 3] = 255; } } const cv = this.canvases[s]; const ctx = cv.getContext('2d')!; const tmp = new OffscreenCanvas(n, n); tmp.getContext('2d')!.putImageData(img, 0, 0); ctx.clearRect(0, 0, cv.width, cv.height); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(tmp, 0, 0, cv.width, cv.height); // the source const [i, j] = slice.axes; const px = ((f.x0[i] + f.a) / (2 * f.a)) * cv.width; const py = (1 - (f.x0[j] + f.a) / (2 * f.a)) * cv.height; ctx.beginPath(); ctx.arc(px, py, 5, 0, 2 * Math.PI); ctx.lineWidth = 1.5; ctx.strokeStyle = '#111'; ctx.stroke(); ctx.beginPath(); ctx.arc(px, py, 6.5, 0, 2 * Math.PI); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke(); }); } }