1// Renders a scalar field on the visualization grid as a masked heatmap
2// with a colorbar. Two modes: "diverging" for signed fields (the solution)
3// and "logmag" for magnitudes on a log scale (the error).
5import { useEffect, useRef } from "react";
6import type { Laplace2dInstance } from "../../problems/laplace2d/spec";
7import { insideDomain, vizGrid, VIZ_NGRID } from "../../problems/laplace2d/exact";
8import { cssColor, divergingColor, isDarkMode, sequentialColor } from "../colors";
10export interface FieldViewProps {
11 inst: Laplace2dInstance;
12 /** Values on the viz grid, index p = ix * ngrid + iy. */
13 values: Float64Array;
14 mode: "diverging" | "logmag";
15 title: string;
16 caption?: string;
17 /** Fixed color-scale range; when absent, the range comes from the data.
18 * For diverging mode this should be symmetric about zero. */
19 range?: { lo: number; hi: number };
20 /** Points drawn on top of the field (e.g. the evaluation points). */
21 overlayPoints?: { x: number; y: number }[];
22}
24/** Max |v| over the grid points inside the domain, for building a shared
25 * diverging range across several fields. */
26export function fieldAbsMax(
27 inst: Laplace2dInstance,
28 values: Float64Array
29): number {
30 const { xs } = vizGrid(inst);
31 let m = 0;
32 for (let ix = 0; ix < VIZ_NGRID; ix++) {
33 for (let iy = 0; iy < VIZ_NGRID; iy++) {
34 if (!insideDomain(inst, xs[ix], xs[iy])) continue;
35 const v = values[ix * VIZ_NGRID + iy];
36 if (isFinite(v)) m = Math.max(m, Math.abs(v));
37 }
38 }
39 return m;
40}
42export function FieldView({
43 inst,
44 values,
45 mode,
46 title,
47 caption,
48 range,
49 overlayPoints,
50}: FieldViewProps) {
51 const canvasRef = useRef<HTMLCanvasElement>(null);
52 const overlayRef = useRef<HTMLCanvasElement>(null);
53 const barRef = useRef<HTMLCanvasElement>(null);
54 const rangeRef = useRef<HTMLDivElement>(null);
56 useEffect(() => {
57 const canvas = canvasRef.current;
58 const bar = barRef.current;
59 if (!canvas || !bar) return;
61 const draw = () => {
62 const dark = isDarkMode();
63 const ngrid = VIZ_NGRID;
64 const { xs } = vizGrid(inst);
65 // range
66 let vmin = Infinity;
67 let vmax = -Infinity;
68 for (let ix = 0; ix < ngrid; ix++) {
69 for (let iy = 0; iy < ngrid; iy++) {
70 if (!insideDomain(inst, xs[ix], xs[iy])) continue;
71 const v = values[ix * ngrid + iy];
72 if (!isFinite(v)) continue;
73 vmin = Math.min(vmin, v);
74 vmax = Math.max(vmax, v);
75 }
76 }
77 let lo: number;
78 let hi: number;
79 let scale: (v: number) => number;
80 if (mode === "diverging") {
81 const vabs = range
82 ? Math.max(Math.abs(range.lo), Math.abs(range.hi), 1e-300)
83 : Math.max(Math.abs(vmin), Math.abs(vmax), 1e-300);
84 lo = -vabs;
85 hi = vabs;
86 scale = (v) => v / vabs; // [-1, 1]
87 } else {
88 hi = range ? range.hi : Math.max(vmax, 1e-300);
89 lo = range
90 ? Math.max(range.lo, 1e-300)
91 : Math.max(vmin, hi * 1e-8, 1e-300);
92 const llo = Math.log10(lo);
93 const lhi = Math.log10(hi);
94 scale = (v) =>
95 (Math.log10(Math.min(Math.max(v, lo), hi)) - llo) / Math.max(lhi - llo, 1e-12);
96 }
98 canvas.width = ngrid;
99 canvas.height = ngrid;
100 const ctx = canvas.getContext("2d");
101 if (!ctx) return;
102 const img = ctx.createImageData(ngrid, ngrid);
103 for (let ix = 0; ix < ngrid; ix++) {
104 for (let iy = 0; iy < ngrid; iy++) {
105 const px = ix;
106 const py = ngrid - 1 - iy;
107 const o = (py * ngrid + px) * 4;
108 if (!insideDomain(inst, xs[ix], xs[iy])) {
109 img.data[o + 3] = 0;
110 continue;
111 }
112 const v = values[ix * ngrid + iy];
113 const rgb =
114 mode === "diverging"
115 ? divergingColor(scale(v), dark)
116 : sequentialColor(scale(v), dark);
117 img.data[o] = Math.round(rgb[0]);
118 img.data[o + 1] = Math.round(rgb[1]);
119 img.data[o + 2] = Math.round(rgb[2]);
120 img.data[o + 3] = 255;
121 }
122 }
123 ctx.putImageData(img, 0, 0);
125 // colorbar
126 const bw = 220;
127 const bh = 10;
128 bar.width = bw;
129 bar.height = bh;
130 const bctx = bar.getContext("2d");
131 if (!bctx) return;
132 for (let i = 0; i < bw; i++) {
133 const t = i / (bw - 1);
134 const rgb =
135 mode === "diverging"
136 ? divergingColor(2 * t - 1, dark)
137 : sequentialColor(t, dark);
138 bctx.fillStyle = cssColor(rgb);
139 bctx.fillRect(i, 0, 1, bh);
140 }
141 if (rangeRef.current) {
142 const fmt = (v: number) =>
143 mode === "logmag" ? v.toExponential(1) : v.toPrecision(3);
144 rangeRef.current.textContent = `${fmt(lo)} … ${fmt(hi)}`;
145 }
147 // overlay: marked points (crisp, at display resolution)
148 const overlay = overlayRef.current;
149 if (overlay) {
150 const disp = 300;
151 const dpr = window.devicePixelRatio || 1;
152 overlay.width = disp * dpr;
153 overlay.height = disp * dpr;
154 const octx = overlay.getContext("2d");
155 if (octx) {
156 octx.scale(dpr, dpr);
157 octx.clearRect(0, 0, disp, disp);
158 if (overlayPoints && overlayPoints.length > 0) {
159 const { R } = vizGrid(inst);
160 const tok = (name: string) =>
161 getComputedStyle(document.documentElement)
162 .getPropertyValue(name)
163 .trim();
164 octx.fillStyle = tok("--series-2");
165 octx.strokeStyle = tok("--surface");
166 octx.lineWidth = 1;
167 for (const p of overlayPoints) {
168 const px = ((p.x + R) / (2 * R)) * disp;
169 const py = disp - ((p.y + R) / (2 * R)) * disp;
170 octx.beginPath();
171 octx.arc(px, py, 2.2, 0, 2 * Math.PI);
172 octx.fill();
173 octx.stroke();
174 }
175 }
176 }
177 }
178 };
180 draw();
181 const mq = window.matchMedia("(prefers-color-scheme: dark)");
182 mq.addEventListener("change", draw);
183 return () => mq.removeEventListener("change", draw);
184 }, [inst, values, mode, range, overlayPoints]);
186 return (
187 <figure style={{ margin: 0 }}>
188 <div className="small" style={{ fontWeight: 600, marginBottom: 4 }}>
189 {title}
190 </div>
191 <div style={{ position: "relative", width: 300, height: 300 }}>
192 <canvas
193 ref={canvasRef}
194 style={{ width: 300, height: 300, imageRendering: "auto" }}
195 />
196 <canvas
197 ref={overlayRef}
198 style={{
199 position: "absolute",
200 inset: 0,
201 width: 300,
202 height: 300,
203 pointerEvents: "none",
204 }}
205 />
206 </div>
207 <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 4 }}>
208 <canvas ref={barRef} style={{ width: 220, height: 10, borderRadius: 3 }} />
209 </div>
210 <div ref={rangeRef} className="small muted" />
211 {caption && <figcaption className="field-caption" style={{ maxWidth: 300 }}>{caption}</figcaption>}
212 </figure>
213 );
214}