/** * The three planes as 2D images, redrawn every frame so that the pressure can * be animated over one period. Clicking a plane moves the source (the listener, * for the cymbal) to that point, whose third coordinate is the plane's. For the * cymbal, its outline is drawn over the planes. */ import { DIVERGING, SEQUENTIAL } from './colormaps.ts'; import { colorParam, type Field, type Mode, type SliceField } 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]} = ${slice.value.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 [ea, eb] = slice.extent; const x: [number, number, number] = [0, 0, 0]; x[slice.fixed] = slice.value; x[slice.axes[0]] = ea[0] + (ea[1] - ea[0]) * fx; x[slice.axes[1]] = eb[0] + (eb[1] - eb[0]) * 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, ref } = f.slices[s]; const at = `(${p.x.map((v) => v.toFixed(2)).join(', ')})`; if (!slice.mask[p.q]) { this.onHover(`${at}: ${f.outline ? 'inside the cymbal' : '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) / (ref ? ref[p.q] : f.ref)); this.onHover(`${at}: u = ${re.toExponential(3)} ${im < 0 ? '−' : '+'} ${Math.abs(im).toExponential(3)}i, level ${db.toFixed(1)} dB ${f.level.label} · click to move the ${f.outline ? 'listener' : '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' || f.level.diverging ? DIVERGING : SEQUENTIAL; f.slices.forEach(({ slice, u, ref }, 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, ref ? ref[q] : f.ref, f.level); 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); const [i, j] = slice.axes; const [ea, eb] = slice.extent; const X = (a: number) => ((a - ea[0]) / (ea[1] - ea[0])) * cv.width; const Y = (b: number) => (1 - (b - eb[0]) / (eb[1] - eb[0])) * cv.height; if (f.outline) drawOutline(ctx, slice, f.outline, X, Y); // the source, when it lies on this plane if (Math.abs(f.x0[slice.fixed] - slice.value) > 1e-9) return; const px = X(f.x0[i]); const py = Y(f.x0[j]); 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(); }); } } /** The cymbal: its cross section on a vertical plane, the rim (dashed) on a horizontal one. */ export function drawOutline(ctx: CanvasRenderingContext2D, slice: SliceField['slice'], o: NonNullable, X: (a: number) => number, Y: (b: number) => number) { ctx.save(); ctx.strokeStyle = '#222'; ctx.fillStyle = 'rgba(40, 40, 40, 0.85)'; ctx.lineWidth = 1; if (slice.fixed === 2) { ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.arc(X(0), Y(0), Math.abs(X(o.radius) - X(0)), 0, 2 * Math.PI); ctx.stroke(); } else if (Math.abs(slice.value) < o.radius) { // the plane is at distance |value| from the axis: horizontal coordinate +-sqrt(r^2 - value^2) for (const sign of [1, -1]) { ctx.beginPath(); let started = false; for (let q = 0; q < o.r.length; q++) { if (o.r[q] < Math.abs(slice.value)) continue; const a = sign * Math.sqrt(o.r[q] ** 2 - slice.value ** 2); if (started) ctx.lineTo(X(a), Y(o.z[q])); else ctx.moveTo(X(a), Y(o.z[q])); started = true; } ctx.closePath(); ctx.fill(); } } ctx.restore(); }