/** * The three planes side by side as one GIF: animated over one period for the * pressure (24 frames, looping), a single frame for the level. The planes are * sampled per pixel from the complex field (bilinear, as in the 3D view), so * each frame maps directly to colormap indices. Captions, the source marker, * the cymbal's outline and the colorbar labels are drawn once on a canvas and * overlaid. */ import { DIVERGING, SEQUENTIAL } from './colormaps.ts'; import { encodeGif } from './gif.ts'; import type { Field, Mode } from './field.ts'; import { drawOutline } from './slices.ts'; const AXIS = ['x', 'y', 'z']; /** colormap levels in the palette; the rest are fixed colors */ const NCMAP = 240; const FIXED: [number, number, number][] = [ [255, 255, 255], // 240 background [17, 17, 17], // 241 black [40, 40, 40], // 242 cymbal [226, 229, 233], // 243 no field (outside the room, inside the cymbal) [87, 96, 106], // 244 text [160, 166, 173], // 245 frame ]; const BG = NCMAP; const NOFIELD = NCMAP + 3; export interface GifOptions { /** panel size in pixels */ size?: number; frames?: number; title?: string; } export function fieldGif(f: Field, mode: Mode, opts: GifOptions = {}): Uint8Array { const S = opts.size ?? 300; const nframes = mode === 'pressure' ? (opts.frames ?? 24) : 1; const pad = 12; const titleH = opts.title ? 24 : 0; const capH = 20; const cbarH = 36; const np = f.slices.length; const W = pad + np * (S + pad); const H = pad + titleH + S + capH + cbarH + pad; const diverging = mode === 'pressure' || f.level.diverging; const lut = diverging ? DIVERGING : SEQUENTIAL; // the palette: colormap levels, then the fixed colors const palette = new Uint8Array(768); for (let i = 0; i < NCMAP; i++) { const j = Math.round((i * 255) / (NCMAP - 1)) * 4; palette.set([lut[j], lut[j + 1], lut[j + 2]], 3 * i); } FIXED.forEach((c, i) => palette.set(c, 3 * (NCMAP + i))); // per pixel of each panel: the interpolated field (re, im) and level reference, NaN where there is none const x0p = pad; const y0p = pad + titleH; const re = new Float32Array(np * S * S); const im = new Float32Array(np * S * S); const lv = new Float32Array(np * S * S); f.slices.forEach(({ u, ref, slice }, s) => { const n = f.ngrid; for (let py = 0; py < S; py++) { const gy = (1 - py / (S - 1)) * (n - 1); const r0 = Math.min(n - 2, Math.floor(gy)); const fy = gy - r0; for (let px = 0; px < S; px++) { const gx = (px / (S - 1)) * (n - 1); const c0 = Math.min(n - 2, Math.floor(gx)); const fx = gx - c0; const q = [r0 * n + c0, r0 * n + c0 + 1, (r0 + 1) * n + c0, (r0 + 1) * n + c0 + 1]; const w = [(1 - fx) * (1 - fy), fx * (1 - fy), (1 - fx) * fy, fx * fy]; let a = 0; let b = 0; let rf = 0; let ok = true; for (let t = 0; t < 4; t++) { if (!slice.mask[q[t]]) ok = false; a += w[t] * u[2 * q[t]]; b += w[t] * u[2 * q[t] + 1]; rf += w[t] * (ref ? ref[q[t]] : f.ref); } const o = s * S * S + py * S + px; re[o] = ok ? a : NaN; im[o] = ok ? b : NaN; lv[o] = ok ? (20 * Math.log10(Math.hypot(a, b) / rf) - f.level.min) / (f.level.max - f.level.min) : NaN; } } }); // the static overlay const cv = new OffscreenCanvas(W, H); const ctx = cv.getContext('2d')!; ctx.font = '12px system-ui, sans-serif'; ctx.textBaseline = 'middle'; if (opts.title) { ctx.fillStyle = 'rgb(17,17,17)'; ctx.font = '13px system-ui, sans-serif'; ctx.fillText(opts.title, pad, pad + titleH / 2 - 2); ctx.font = '12px system-ui, sans-serif'; } f.slices.forEach(({ slice }, s) => { const left = x0p + s * (S + pad); const [ea, eb] = slice.extent; const X = (a: number) => left + ((a - ea[0]) / (ea[1] - ea[0])) * (S - 1); const Y = (b: number) => y0p + (1 - (b - eb[0]) / (eb[1] - eb[0])) * (S - 1); ctx.save(); ctx.beginPath(); ctx.rect(left, y0p, S, S); ctx.clip(); if (f.outline) drawOutline(ctx as unknown as CanvasRenderingContext2D, slice, f.outline, X, Y); if (Math.abs(f.x0[slice.fixed] - slice.value) < 1e-9) { const [i, j] = slice.axes; ctx.beginPath(); ctx.arc(X(f.x0[i]), Y(f.x0[j]), 5, 0, 2 * Math.PI); ctx.lineWidth = 1.5; ctx.strokeStyle = 'rgb(17,17,17)'; ctx.stroke(); ctx.beginPath(); ctx.arc(X(f.x0[i]), Y(f.x0[j]), 6.5, 0, 2 * Math.PI); ctx.lineWidth = 1; ctx.strokeStyle = 'rgb(255,255,255)'; ctx.stroke(); } ctx.restore(); ctx.strokeStyle = 'rgb(160,166,173)'; ctx.lineWidth = 1; ctx.strokeRect(left - 0.5, y0p - 0.5, S + 1, S + 1); ctx.fillStyle = 'rgb(87,96,106)'; const [i, j] = slice.axes; ctx.fillText(`${slice.name}-plane at ${AXIS[slice.fixed]} = ${slice.value.toFixed(2)} (${AXIS[i]} across, ${AXIS[j]} up)`, left, y0p + S + capH / 2 + 2); }); // colorbar: a strip of colormap levels, with its limits const cbW = Math.min(360, W - 2 * pad - 180); const cbX = Math.round((W - cbW) / 2); const cbY = y0p + S + capH + 6; const cbH = 10; ctx.fillStyle = 'rgb(87,96,106)'; const lo = mode === 'pressure' ? `−${f.umax.toPrecision(2)}` : `${f.level.min} dB`; const hi = mode === 'pressure' ? `+${f.umax.toPrecision(2)}` : `+${f.level.max} dB`; const label = mode === 'pressure' ? 'pressure Re(u e^−iωt)' : `level (dB ${f.level.label})`; ctx.textAlign = 'right'; ctx.fillText(lo, cbX - 6, cbY + cbH / 2); ctx.textAlign = 'left'; ctx.fillText(hi, cbX + cbW + 6, cbY + cbH / 2); ctx.textAlign = 'center'; ctx.fillText(label, cbX + cbW / 2, cbY + cbH + 11); const ov = ctx.getImageData(0, 0, W, H).data; // overlay pixels as the nearest fixed color const overlay = new Int16Array(W * H).fill(-1); for (let p = 0; p < W * H; p++) { if (ov[4 * p + 3] < 110) continue; let best = 0; let bd = Infinity; FIXED.forEach((c, i) => { const d = (c[0] - ov[4 * p]) ** 2 + (c[1] - ov[4 * p + 1]) ** 2 + (c[2] - ov[4 * p + 2]) ** 2; if (d < bd) { bd = d; best = i; } }); overlay[p] = NCMAP + best; } const frames: Uint8Array[] = []; const level = (t: number) => Math.max(0, Math.min(NCMAP - 1, Math.round(t * (NCMAP - 1)))); for (let fr = 0; fr < nframes; fr++) { const ph = (2 * Math.PI * fr) / nframes; const c = Math.cos(ph); const sn = Math.sin(ph); const img = new Uint8Array(W * H).fill(BG); for (let s = 0; s < np; s++) { const left = x0p + s * (S + pad); for (let py = 0; py < S; py++) { for (let px = 0; px < S; px++) { const o = s * S * S + py * S + px; const a = re[o]; let idx: number; if (Number.isNaN(a)) idx = NOFIELD; else if (mode === 'pressure') idx = level(0.5 + (0.5 * (a * c + im[o] * sn)) / f.umax); else idx = level(lv[o]); img[(y0p + py) * W + left + px] = idx; } } } for (let x = 0; x < cbW; x++) { const idx = level(x / (cbW - 1)); for (let y = 0; y < cbH; y++) img[(cbY + y) * W + cbX + x] = idx; } for (let p = 0; p < W * H; p++) if (overlay[p] >= 0) img[p] = overlay[p]; frames.push(img); } // one period in 2 s, as in the page return encodeGif(W, H, palette, frames, Math.max(2, Math.round(200 / nframes))); }