import { useEffect, useMemo, useRef } from "react"; import { frameOf, getColormap, rangeOf, rasterizeField, symmetricRange, type Field2D, type FieldFrames, type PlateShape, type Range, } from "@plate-lab/core"; import { Colorbar, fmt } from "./Colorbar"; export interface FieldMarker { x: number; y: number; label: string; color?: string; } export interface FieldViewProps { /** Either a single field or a frame stack with an index */ field?: Field2D; frames?: FieldFrames; frameIndex?: number; /** Physical extents of the bounding box [m] */ Lx: number; Ly: number; /** Outline of the plate within the box; default rectangle */ shape?: PlateShape; title?: string; unit?: string; /** Display multiplier applied to values in labels (e.g. 1e-6 for MPa) */ scale?: number; colormap?: string; /** "frame": rescale per frame; "global": one range over all frames; or a fixed range */ range?: "frame" | "global" | Range; /** Force a symmetric range about zero (for signed fields) */ symmetric?: boolean; markers?: FieldMarker[]; /** Canvas width in CSS pixels; height follows the aspect ratio */ width?: number; caption?: string; } /** * Paints a field on the plate outline. Values pass straight from the solver * through a colormap; nothing here changes them. Nodes the solver marked NaN * (outside an elliptical plate) come out transparent, and the image is clipped * to the plate's true outline. */ export function FieldView(props: FieldViewProps) { const { frames, frameIndex = 0, Lx, Ly, shape = "rectangle", title, unit, scale = 1, colormap, symmetric, markers = [], width = 420, caption, } = props; const field: Field2D | undefined = props.field ?? (frames ? frameOf(frames, Math.min(frameIndex, frames.nf - 1)) : undefined); const cmId = colormap ?? (symmetric ? "bwr" : "viridis"); const ellipse = shape === "ellipse"; const globalRange = useMemo(() => { const r = frames ? rangeOf(frames.data) : field ? rangeOf(field.data) : { min: 0, max: 1 }; return symmetric ? symmetricRange(r) : r; }, [frames, field, symmetric]); const range: Range = useMemo(() => { const mode = props.range ?? "global"; if (typeof mode === "object") return mode; if (mode === "global" || !field) return globalRange; const r = rangeOf(field.data); return symmetric ? symmetricRange(r) : r; }, [props.range, field, globalRange, symmetric]); const canvasRef = useRef(null); const offRef = useRef(null); const pixRef = useRef(undefined); const height = Math.max(60, Math.round((width * Ly) / Lx)); useEffect(() => { const canvas = canvasRef.current; if (!canvas || !field) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); // Rasterize at grid resolution, then scale with smoothing onto the plate outline if (!offRef.current) offRef.current = document.createElement("canvas"); const off = offRef.current; off.width = field.nx; off.height = field.ny; const pix = rasterizeField(field, range, getColormap(cmId), pixRef.current); pixRef.current = pix; const octx = off.getContext("2d"); if (!octx) return; octx.putImageData( new ImageData(pix as Uint8ClampedArray, field.nx, field.ny), 0, 0, ); ctx.save(); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, width, height); ctx.imageSmoothingEnabled = true; const outline = () => { ctx.beginPath(); ctx.ellipse(width / 2, height / 2, width / 2 - 0.75, height / 2 - 0.75, 0, 0, 2 * Math.PI); }; if (ellipse) { ctx.save(); outline(); ctx.clip(); } // Grid nodes sit at cell centres of the image; map node (0..nx-1) to the full extent const sx = width / (field.nx - 1); const sy = height / (field.ny - 1); ctx.drawImage(off, -sx / 2, -sy / 2, width + sx, height + sy); if (ellipse) ctx.restore(); ctx.strokeStyle = "#333"; ctx.lineWidth = 1; if (ellipse) { outline(); ctx.stroke(); } else { ctx.strokeRect(0.5, 0.5, width - 1, height - 1); } for (const m of markers) { const px = (m.x / Lx) * width; const py = height - (m.y / Ly) * height; ctx.beginPath(); ctx.arc(px, py, 5, 0, 2 * Math.PI); ctx.fillStyle = "rgba(255,255,255,0.85)"; ctx.fill(); ctx.strokeStyle = m.color ?? "#000"; ctx.lineWidth = 1.5; ctx.stroke(); ctx.font = "11px system-ui, sans-serif"; ctx.fillStyle = "#000"; ctx.strokeStyle = "rgba(255,255,255,0.9)"; ctx.lineWidth = 3; ctx.strokeText(m.label, px + 8, py - 6); ctx.fillText(m.label, px + 8, py - 6); } ctx.restore(); }, [field, range, cmId, width, height, markers, Lx, Ly, ellipse]); const peak = field ? rangeOf(field.data) : null; return (
{title} {peak && ( {fmt(peak.min * scale)} to {fmt(peak.max * scale)} {unit} )}
{caption &&
{caption}
}
); }