// A scalar field on a rectangle, drawn as an image with a colour bar. // // The field is painted into a canvas one pixel per grid cell and then scaled to // the frame, which keeps a 512-by-512 grid as cheap to draw as a 16-by-16 one. // Cells outside the domain (an L-shaped region, the outside of a curve) are // passed as null and left as surface. import { useEffect, useRef, useState, useCallback } from 'react' import type { Num } from '../engine/types.ts' import { divColor, faded, ink, seqColor, surface } from './palette.ts' import { formatTick, niceTicks } from './scales.ts' export interface FieldProps { /** z[i][j] is the value at (x_j, y_i); null is "not in the domain" */ z: Num[][] xDomain: [number, number] yDomain: [number, number] height?: number /** 'seq' for a magnitude, 'div' for a signed quantity, 'log' for |value| over decades */ kind?: 'seq' | 'div' | 'log' /** * Reverse the sequential ramp, so that zero is dark and large is light. * The ramp runs light-to-dark, which is right on a white page; on this * page's dark surface a field whose typical value is near zero comes out * almost entirely pale unless it is turned round. */ reverse?: boolean /** upper end of the colour scale; taken from the data when omitted */ zmax?: number /** how many decades the 'log' scale spans below zmax */ decades?: number title?: string /** polylines in data coordinates, drawn over the field (a boundary, a mesh) */ curves?: { x: number[]; y: number[]; color?: string; dash?: string }[] points?: { x: number[]; y: number[]; color?: string; r?: number }[] formatValue?: (v: number) => string } function useWidth(): [(el: HTMLDivElement | null) => void, number] { const [width, setWidth] = useState(560) const obs = useRef(null) const ref = useCallback((el: HTMLDivElement | null) => { obs.current?.disconnect() if (!el) return const ro = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width if (w && w > 0) setWidth(w) }) ro.observe(el) obs.current = ro setWidth(el.clientWidth || 560) }, []) useEffect(() => () => obs.current?.disconnect(), []) return [ref, width] } const M = { l: 44, r: 74, t: 18, b: 30 } export default function Field(props: FieldProps) { const { z, xDomain, yDomain, height = 340, kind = 'seq', decades = 8, title, curves, points, reverse } = props // Reversed, the ramp also fades towards the page's surface at the bottom // end, so that "nothing here" is background rather than a saturated blue. const ramp = (t: number) => { if (!reverse) return seqColor(t) const u = Math.max(0, Math.min(1, t)) return faded(seqColor(1 - u), 1 - Math.pow(u, 0.55)) } const [hostRef, width] = useWidth() const canvasRef = useRef(null) const nrow = z.length const ncol = nrow > 0 ? z[0].length : 0 // colour scale limits let lo = Infinity let hi = -Infinity for (const row of z) { for (const v of row) { if (v == null || !isFinite(v)) continue if (v < lo) lo = v if (v > hi) hi = v } } if (!isFinite(lo)) { lo = 0 hi = 1 } const amax = props.zmax ?? Math.max(Math.abs(lo), Math.abs(hi)) const logHi = props.zmax ?? (hi > 0 ? Math.log10(hi) : 0) const logLo = logHi - decades const colorOf = (v: Num): string | null => { if (v == null || !isFinite(v)) return null if (kind === 'div') return divColor(amax > 0 ? v / amax : 0) if (kind === 'log') { const l = Math.log10(Math.max(Math.abs(v), Number.MIN_VALUE)) return ramp((l - logLo) / (logHi - logLo)) } return ramp(hi > lo ? (v - lo) / (hi - lo) : 0.5) } const iw = Math.max(10, width - M.l - M.r) const ih = Math.max(10, height - M.t - M.b) useEffect(() => { const cv = canvasRef.current if (!cv || ncol === 0) return cv.width = ncol cv.height = nrow const ctx = cv.getContext('2d') if (!ctx) return const img = ctx.createImageData(ncol, nrow) for (let i = 0; i < nrow; i++) { // row 0 of z is the bottom of the picture const dst = (nrow - 1 - i) * ncol for (let j = 0; j < ncol; j++) { const c = colorOf(z[i][j]) const o = (dst + j) * 4 if (c == null) { img.data[o] = 26 img.data[o + 1] = 26 img.data[o + 2] = 25 img.data[o + 3] = 255 } else { const n = parseInt(c.slice(1), 16) img.data[o] = (n >> 16) & 255 img.data[o + 1] = (n >> 8) & 255 img.data[o + 2] = n & 255 img.data[o + 3] = 255 } } } ctx.putImageData(img, 0, 0) }) const sx = (v: number) => ((v - xDomain[0]) / (xDomain[1] - xDomain[0])) * iw const sy = (v: number) => ih - ((v - yDomain[0]) / (yDomain[1] - yDomain[0])) * ih const xt = niceTicks(xDomain[0], xDomain[1], 5) const yt = niceTicks(yDomain[0], yDomain[1], 5) // colour bar: 24 steps from the same scale const bar = Array.from({ length: 24 }, (_, k) => k / 23) const barLabel = (t: number): string => { if (kind === 'div') return formatTick(Number(((2 * t - 1) * amax).toPrecision(2))) if (kind === 'log') { const e = logLo + t * (logHi - logLo) return `1e${Math.round(e)}` } return formatTick(Number((lo + t * (hi - lo)).toPrecision(2))) } return (
{curves?.map((c, k) => ( `${i ? 'L' : 'M'}${sx(xv).toFixed(2)} ${sy(c.y[i]).toFixed(2)}`).join('')} fill="none" stroke={c.color ?? ink.node} strokeWidth={1.5} strokeDasharray={c.dash} /> ))} {points?.map((p, k) => ( {p.x.map((xv, i) => ( ))} ))} {xt.map((v) => ( {formatTick(v)} ))} {yt.map((v) => ( {formatTick(v)} ))} {title && ( {title} )} {/* colour bar */} {bar.map((t, k) => ( ))} {[0, 0.5, 1].map((t) => ( {barLabel(t)} ))}
) } export { surface }