1/**
2 * The three planes side by side as one GIF: animated over one period for the
3 * pressure (24 frames, looping), a single frame for the level. The planes are
4 * sampled per pixel from the complex field (bilinear, as in the 3D view), so
5 * each frame maps directly to colormap indices. Captions, the source marker,
6 * the cymbal's outline and the colorbar labels are drawn once on a canvas and
7 * overlaid.
8 */
9import { DIVERGING, SEQUENTIAL } from './colormaps.ts';
10import { encodeGif } from './gif.ts';
11import type { Field, Mode } from './field.ts';
12import { drawOutline } from './slices.ts';
14const AXIS = ['x', 'y', 'z'];
15/** colormap levels in the palette; the rest are fixed colors */
16const NCMAP = 240;
17const FIXED: [number, number, number][] = [
18 [255, 255, 255], // 240 background
19 [17, 17, 17], // 241 black
20 [40, 40, 40], // 242 cymbal
21 [226, 229, 233], // 243 no field (outside the room, inside the cymbal)
22 [87, 96, 106], // 244 text
23 [160, 166, 173], // 245 frame
24];
25const BG = NCMAP;
26const NOFIELD = NCMAP + 3;
28export interface GifOptions {
29 /** panel size in pixels */
30 size?: number;
31 frames?: number;
32 title?: string;
33}
35export function fieldGif(f: Field, mode: Mode, opts: GifOptions = {}): Uint8Array {
36 const S = opts.size ?? 300;
37 const nframes = mode === 'pressure' ? (opts.frames ?? 24) : 1;
38 const pad = 12;
39 const titleH = opts.title ? 24 : 0;
40 const capH = 20;
41 const cbarH = 36;
42 const np = f.slices.length;
43 const W = pad + np * (S + pad);
44 const H = pad + titleH + S + capH + cbarH + pad;
45 const diverging = mode === 'pressure' || f.level.diverging;
46 const lut = diverging ? DIVERGING : SEQUENTIAL;
48 // the palette: colormap levels, then the fixed colors
49 const palette = new Uint8Array(768);
50 for (let i = 0; i < NCMAP; i++) {
51 const j = Math.round((i * 255) / (NCMAP - 1)) * 4;
52 palette.set([lut[j], lut[j + 1], lut[j + 2]], 3 * i);
53 }
54 FIXED.forEach((c, i) => palette.set(c, 3 * (NCMAP + i)));
56 // per pixel of each panel: the interpolated field (re, im) and level reference, NaN where there is none
57 const x0p = pad;
58 const y0p = pad + titleH;
59 const re = new Float32Array(np * S * S);
60 const im = new Float32Array(np * S * S);
61 const lv = new Float32Array(np * S * S);
62 f.slices.forEach(({ u, ref, slice }, s) => {
63 const n = f.ngrid;
64 for (let py = 0; py < S; py++) {
65 const gy = (1 - py / (S - 1)) * (n - 1);
66 const r0 = Math.min(n - 2, Math.floor(gy));
67 const fy = gy - r0;
68 for (let px = 0; px < S; px++) {
69 const gx = (px / (S - 1)) * (n - 1);
70 const c0 = Math.min(n - 2, Math.floor(gx));
71 const fx = gx - c0;
72 const q = [r0 * n + c0, r0 * n + c0 + 1, (r0 + 1) * n + c0, (r0 + 1) * n + c0 + 1];
73 const w = [(1 - fx) * (1 - fy), fx * (1 - fy), (1 - fx) * fy, fx * fy];
74 let a = 0;
75 let b = 0;
76 let rf = 0;
77 let ok = true;
78 for (let t = 0; t < 4; t++) {
79 if (!slice.mask[q[t]]) ok = false;
80 a += w[t] * u[2 * q[t]];
81 b += w[t] * u[2 * q[t] + 1];
82 rf += w[t] * (ref ? ref[q[t]] : f.ref);
83 }
84 const o = s * S * S + py * S + px;
85 re[o] = ok ? a : NaN;
86 im[o] = ok ? b : NaN;
87 lv[o] = ok ? (20 * Math.log10(Math.hypot(a, b) / rf) - f.level.min) / (f.level.max - f.level.min) : NaN;
88 }
89 }
90 });
92 // the static overlay
93 const cv = new OffscreenCanvas(W, H);
94 const ctx = cv.getContext('2d')!;
95 ctx.font = '12px system-ui, sans-serif';
96 ctx.textBaseline = 'middle';
97 if (opts.title) {
98 ctx.fillStyle = 'rgb(17,17,17)';
99 ctx.font = '13px system-ui, sans-serif';
100 ctx.fillText(opts.title, pad, pad + titleH / 2 - 2);
101 ctx.font = '12px system-ui, sans-serif';
102 }
103 f.slices.forEach(({ slice }, s) => {
104 const left = x0p + s * (S + pad);
105 const [ea, eb] = slice.extent;
106 const X = (a: number) => left + ((a - ea[0]) / (ea[1] - ea[0])) * (S - 1);
107 const Y = (b: number) => y0p + (1 - (b - eb[0]) / (eb[1] - eb[0])) * (S - 1);
108 ctx.save();
109 ctx.beginPath();
110 ctx.rect(left, y0p, S, S);
111 ctx.clip();
112 if (f.outline) drawOutline(ctx as unknown as CanvasRenderingContext2D, slice, f.outline, X, Y);
113 if (Math.abs(f.x0[slice.fixed] - slice.value) < 1e-9) {
114 const [i, j] = slice.axes;
115 ctx.beginPath();
116 ctx.arc(X(f.x0[i]), Y(f.x0[j]), 5, 0, 2 * Math.PI);
117 ctx.lineWidth = 1.5;
118 ctx.strokeStyle = 'rgb(17,17,17)';
119 ctx.stroke();
120 ctx.beginPath();
121 ctx.arc(X(f.x0[i]), Y(f.x0[j]), 6.5, 0, 2 * Math.PI);
122 ctx.lineWidth = 1;
123 ctx.strokeStyle = 'rgb(255,255,255)';
124 ctx.stroke();
125 }
126 ctx.restore();
127 ctx.strokeStyle = 'rgb(160,166,173)';
128 ctx.lineWidth = 1;
129 ctx.strokeRect(left - 0.5, y0p - 0.5, S + 1, S + 1);
130 ctx.fillStyle = 'rgb(87,96,106)';
131 const [i, j] = slice.axes;
132 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);
133 });
134 // colorbar: a strip of colormap levels, with its limits
135 const cbW = Math.min(360, W - 2 * pad - 180);
136 const cbX = Math.round((W - cbW) / 2);
137 const cbY = y0p + S + capH + 6;
138 const cbH = 10;
139 ctx.fillStyle = 'rgb(87,96,106)';
140 const lo = mode === 'pressure' ? `−${f.umax.toPrecision(2)}` : `${f.level.min} dB`;
141 const hi = mode === 'pressure' ? `+${f.umax.toPrecision(2)}` : `+${f.level.max} dB`;
142 const label = mode === 'pressure' ? 'pressure Re(u e^−iωt)' : `level (dB ${f.level.label})`;
143 ctx.textAlign = 'right';
144 ctx.fillText(lo, cbX - 6, cbY + cbH / 2);
145 ctx.textAlign = 'left';
146 ctx.fillText(hi, cbX + cbW + 6, cbY + cbH / 2);
147 ctx.textAlign = 'center';
148 ctx.fillText(label, cbX + cbW / 2, cbY + cbH + 11);
149 const ov = ctx.getImageData(0, 0, W, H).data;
150 // overlay pixels as the nearest fixed color
151 const overlay = new Int16Array(W * H).fill(-1);
152 for (let p = 0; p < W * H; p++) {
153 if (ov[4 * p + 3] < 110) continue;
154 let best = 0;
155 let bd = Infinity;
156 FIXED.forEach((c, i) => {
157 const d = (c[0] - ov[4 * p]) ** 2 + (c[1] - ov[4 * p + 1]) ** 2 + (c[2] - ov[4 * p + 2]) ** 2;
158 if (d < bd) {
159 bd = d;
160 best = i;
161 }
162 });
163 overlay[p] = NCMAP + best;
164 }
166 const frames: Uint8Array[] = [];
167 const level = (t: number) => Math.max(0, Math.min(NCMAP - 1, Math.round(t * (NCMAP - 1))));
168 for (let fr = 0; fr < nframes; fr++) {
169 const ph = (2 * Math.PI * fr) / nframes;
170 const c = Math.cos(ph);
171 const sn = Math.sin(ph);
172 const img = new Uint8Array(W * H).fill(BG);
173 for (let s = 0; s < np; s++) {
174 const left = x0p + s * (S + pad);
175 for (let py = 0; py < S; py++) {
176 for (let px = 0; px < S; px++) {
177 const o = s * S * S + py * S + px;
178 const a = re[o];
179 let idx: number;
180 if (Number.isNaN(a)) idx = NOFIELD;
181 else if (mode === 'pressure') idx = level(0.5 + (0.5 * (a * c + im[o] * sn)) / f.umax);
182 else idx = level(lv[o]);
183 img[(y0p + py) * W + left + px] = idx;
184 }
185 }
186 }
187 for (let x = 0; x < cbW; x++) {
188 const idx = level(x / (cbW - 1));
189 for (let y = 0; y < cbH; y++) img[(cbY + y) * W + cbX + x] = idx;
190 }
191 for (let p = 0; p < W * H; p++) if (overlay[p] >= 0) img[p] = overlay[p];
192 frames.push(img);
193 }
194 // one period in 2 s, as in the page
195 return encodeGif(W, H, palette, frames, Math.max(2, Math.round(200 / nframes)));
196}