import { useEffect, useMemo, useRef } from "react"; import { frameOf, getColormap, rangeOf, rasterizeField, symmetricRange, type Field2D, type FieldFrames, type Range, } from "@plate-dyn/core"; import { Colorbar, fmt } from "./Colorbar"; export interface FieldMarker { x: number; y: number; label: string; color?: string; } /** A rectangle drawn on the plate, in plate coordinates [m] */ export interface FieldRect { x0: number; y0: number; x1: number; y1: number; label?: string; color?: string; } /** An arrow drawn from a point, in plate coordinates, with a direction in the plate's plane */ export interface FieldArrow { x: number; y: number; dx: number; dy: 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; 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[]; rects?: FieldRect[]; arrow?: FieldArrow; /** 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. Non-finite nodes come out * transparent. Coordinates run from 0 to Lx and 0 to Ly across the box. */ export function FieldView(props: FieldViewProps) { const { frames, frameIndex = 0, Lx, Ly, title, unit, scale = 1, colormap, symmetric, markers = [], rects = [], arrow, 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 = false; 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); } const toPx = (x: number, y: number): [number, number] => [ (x / Lx) * width, height - (y / Ly) * height, ]; for (const r of rects) { const [x0, y0] = toPx(r.x0, r.y1); const [x1, y1] = toPx(r.x1, r.y0); ctx.fillStyle = "rgba(60,60,70,0.18)"; ctx.fillRect(x0, y0, x1 - x0, y1 - y0); ctx.strokeStyle = r.color ?? "#333"; ctx.lineWidth = 1; ctx.strokeRect(x0, y0, x1 - x0, y1 - y0); if (r.label) { ctx.font = "11px system-ui, sans-serif"; ctx.fillStyle = "#000"; ctx.strokeStyle = "rgba(255,255,255,0.9)"; ctx.lineWidth = 3; ctx.strokeText(r.label, x0 + 3, y1 - 4); ctx.fillText(r.label, x0 + 3, y1 - 4); } } if (arrow && (arrow.dx !== 0 || arrow.dy !== 0)) { const [x0, y0] = toPx(arrow.x, arrow.y); const len = 0.18 * Math.min(width, height); const n = Math.hypot(arrow.dx, arrow.dy); const ux = arrow.dx / n; const uy = -arrow.dy / n; // screen y is down const x1 = x0 + ux * len; const y1 = y0 + uy * len; ctx.strokeStyle = arrow.color ?? "#d1495b"; ctx.fillStyle = arrow.color ?? "#d1495b"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x1 - ux * 8 - uy * 5, y1 - uy * 8 + ux * 5); ctx.lineTo(x1 - ux * 8 + uy * 5, y1 - uy * 8 - ux * 5); ctx.closePath(); ctx.fill(); if (arrow.label) { ctx.font = "11px system-ui, sans-serif"; ctx.fillStyle = "#000"; ctx.strokeStyle = "rgba(255,255,255,0.9)"; ctx.lineWidth = 3; ctx.strokeText(arrow.label, x1 + 6, y1 + 4); ctx.fillText(arrow.label, x1 + 6, y1 + 4); } } for (const m of markers) { const [px, py] = toPx(m.x, m.y); 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, rects, arrow, 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}
}
); }