/ tutorials / wave-acoustics
Sign in
tutorials / wave-acoustics
wave-acoustics / src / plot / Field.tsx
234 lines · 8.2 KBBlameHistoryRaw
1// A scalar field on a rectangle, drawn as an image with a colour bar.
2//
3// The field is painted into a canvas one pixel per grid cell and then scaled to
4// the frame, which keeps a 512-by-512 grid as cheap to draw as a 16-by-16 one.
5// Cells outside the domain (an L-shaped region, the outside of a curve) are
6// passed as null and left as surface.
7import { useEffect, useRef, useState, useCallback } from 'react'
8import type { Num } from '../engine/types.ts'
9import { divColor, faded, ink, seqColor, surface } from './palette.ts'
10import { formatTick, niceTicks } from './scales.ts'
12export interface FieldProps {
13 /** z[i][j] is the value at (x_j, y_i); null is "not in the domain" */
14 z: Num[][]
15 xDomain: [number, number]
16 yDomain: [number, number]
17 height?: number
18 /** 'seq' for a magnitude, 'div' for a signed quantity, 'log' for |value| over decades */
19 kind?: 'seq' | 'div' | 'log'
20 /**
21 * Reverse the sequential ramp, so that zero is dark and large is light.
22 * The ramp runs light-to-dark, which is right on a white page; on this
23 * page's dark surface a field whose typical value is near zero comes out
24 * almost entirely pale unless it is turned round.
25 */
26 reverse?: boolean
27 /** upper end of the colour scale; taken from the data when omitted */
28 zmax?: number
29 /** how many decades the 'log' scale spans below zmax */
30 decades?: number
31 title?: string
32 /** polylines in data coordinates, drawn over the field (a boundary, a mesh) */
33 curves?: { x: number[]; y: number[]; color?: string; dash?: string }[]
34 points?: { x: number[]; y: number[]; color?: string; r?: number }[]
35 formatValue?: (v: number) => string
38function useWidth(): [(el: HTMLDivElement | null) => void, number] {
39 const [width, setWidth] = useState(560)
40 const obs = useRef<ResizeObserver | null>(null)
41 const ref = useCallback((el: HTMLDivElement | null) => {
42 obs.current?.disconnect()
43 if (!el) return
44 const ro = new ResizeObserver((entries) => {
45 const w = entries[0]?.contentRect.width
46 if (w && w > 0) setWidth(w)
47 })
48 ro.observe(el)
49 obs.current = ro
50 setWidth(el.clientWidth || 560)
51 }, [])
52 useEffect(() => () => obs.current?.disconnect(), [])
53 return [ref, width]
56const M = { l: 44, r: 74, t: 18, b: 30 }
58export default function Field(props: FieldProps) {
59 const { z, xDomain, yDomain, height = 340, kind = 'seq', decades = 8, title, curves, points, reverse } = props
60 // Reversed, the ramp also fades towards the page's surface at the bottom
61 // end, so that "nothing here" is background rather than a saturated blue.
62 const ramp = (t: number) => {
63 if (!reverse) return seqColor(t)
64 const u = Math.max(0, Math.min(1, t))
65 return faded(seqColor(1 - u), 1 - Math.pow(u, 0.55))
66 }
67 const [hostRef, width] = useWidth()
68 const canvasRef = useRef<HTMLCanvasElement>(null)
70 const nrow = z.length
71 const ncol = nrow > 0 ? z[0].length : 0
73 // colour scale limits
74 let lo = Infinity
75 let hi = -Infinity
76 for (const row of z) {
77 for (const v of row) {
78 if (v == null || !isFinite(v)) continue
79 if (v < lo) lo = v
80 if (v > hi) hi = v
81 }
82 }
83 if (!isFinite(lo)) {
84 lo = 0
85 hi = 1
86 }
87 const amax = props.zmax ?? Math.max(Math.abs(lo), Math.abs(hi))
88 const logHi = props.zmax ?? (hi > 0 ? Math.log10(hi) : 0)
89 const logLo = logHi - decades
91 const colorOf = (v: Num): string | null => {
92 if (v == null || !isFinite(v)) return null
93 if (kind === 'div') return divColor(amax > 0 ? v / amax : 0)
94 if (kind === 'log') {
95 const l = Math.log10(Math.max(Math.abs(v), Number.MIN_VALUE))
96 return ramp((l - logLo) / (logHi - logLo))
97 }
98 return ramp(hi > lo ? (v - lo) / (hi - lo) : 0.5)
99 }
101 const iw = Math.max(10, width - M.l - M.r)
102 const ih = Math.max(10, height - M.t - M.b)
104 useEffect(() => {
105 const cv = canvasRef.current
106 if (!cv || ncol === 0) return
107 cv.width = ncol
108 cv.height = nrow
109 const ctx = cv.getContext('2d')
110 if (!ctx) return
111 const img = ctx.createImageData(ncol, nrow)
112 for (let i = 0; i < nrow; i++) {
113 // row 0 of z is the bottom of the picture
114 const dst = (nrow - 1 - i) * ncol
115 for (let j = 0; j < ncol; j++) {
116 const c = colorOf(z[i][j])
117 const o = (dst + j) * 4
118 if (c == null) {
119 img.data[o] = 26
120 img.data[o + 1] = 26
121 img.data[o + 2] = 25
122 img.data[o + 3] = 255
123 } else {
124 const n = parseInt(c.slice(1), 16)
125 img.data[o] = (n >> 16) & 255
126 img.data[o + 1] = (n >> 8) & 255
127 img.data[o + 2] = n & 255
128 img.data[o + 3] = 255
129 }
130 }
131 }
132 ctx.putImageData(img, 0, 0)
133 })
135 const sx = (v: number) => ((v - xDomain[0]) / (xDomain[1] - xDomain[0])) * iw
136 const sy = (v: number) => ih - ((v - yDomain[0]) / (yDomain[1] - yDomain[0])) * ih
138 const xt = niceTicks(xDomain[0], xDomain[1], 5)
139 const yt = niceTicks(yDomain[0], yDomain[1], 5)
141 // colour bar: 24 steps from the same scale
142 const bar = Array.from({ length: 24 }, (_, k) => k / 23)
143 const barLabel = (t: number): string => {
144 if (kind === 'div') return formatTick(Number(((2 * t - 1) * amax).toPrecision(2)))
145 if (kind === 'log') {
146 const e = logLo + t * (logHi - logLo)
147 return `1e${Math.round(e)}`
148 }
149 return formatTick(Number((lo + t * (hi - lo)).toPrecision(2)))
150 }
152 return (
153 <div className="plot-host field-host" ref={hostRef}>
154 <div style={{ position: 'relative', width, height }}>
155 <canvas
156 ref={canvasRef}
157 style={{
158 position: 'absolute',
159 left: M.l,
160 top: M.t,
161 width: iw,
162 height: ih,
163 imageRendering: ncol < 96 ? 'pixelated' : 'auto',
164 }}
165 />
166 <svg width={width} height={height} style={{ position: 'absolute', left: 0, top: 0 }} role="img">
167 <defs>
168 <clipPath id={`fc-${nrow}-${ncol}`}>
169 <rect x={0} y={0} width={iw} height={ih} />
170 </clipPath>
171 </defs>
172 <g transform={`translate(${M.l},${M.t})`} clipPath={`url(#fc-${nrow}-${ncol})`}>
173 {curves?.map((c, k) => (
174 <path
175 key={`c${k}`}
176 d={c.x.map((xv, i) => `${i ? 'L' : 'M'}${sx(xv).toFixed(2)} ${sy(c.y[i]).toFixed(2)}`).join('')}
177 fill="none"
178 stroke={c.color ?? ink.node}
179 strokeWidth={1.5}
180 strokeDasharray={c.dash}
181 />
182 ))}
183 {points?.map((p, k) => (
184 <g key={`p${k}`}>
185 {p.x.map((xv, i) => (
186 <circle key={i} cx={sx(xv)} cy={sy(p.y[i])} r={p.r ?? 1.8} fill={p.color ?? ink.node} />
187 ))}
188 </g>
189 ))}
190 </g>
191 <g transform={`translate(${M.l},${M.t})`}>
192 <rect x={0} y={0} width={iw} height={ih} fill="none" stroke={ink.axis} strokeWidth={1} />
193 {xt.map((v) => (
194 <text key={`x${v}`} x={sx(v)} y={ih + 15} fill={ink.muted} fontSize={11} textAnchor="middle">
195 {formatTick(v)}
196 </text>
197 ))}
198 {yt.map((v) => (
199 <text key={`y${v}`} x={-7} y={sy(v) + 4} fill={ink.muted} fontSize={11} textAnchor="end">
200 {formatTick(v)}
201 </text>
202 ))}
203 {title && (
204 <text x={0} y={-6} fill={ink.secondary} fontSize={11}>
205 {title}
206 </text>
207 )}
208 {/* colour bar */}
209 <g transform={`translate(${iw + 12},0)`}>
210 {bar.map((t, k) => (
211 <rect
212 key={k}
213 x={0}
214 y={ih - ((k + 1) * ih) / bar.length}
215 width={12}
216 height={ih / bar.length + 0.5}
217 fill={kind === 'div' ? divColor(2 * t - 1) : ramp(t)}
218 />
219 ))}
220 <rect x={0} y={0} width={12} height={ih} fill="none" stroke={ink.axis} strokeWidth={1} />
221 {[0, 0.5, 1].map((t) => (
222 <text key={t} x={16} y={ih - t * ih + 4} fill={ink.muted} fontSize={10}>
223 {barLabel(t)}
224 </text>
225 ))}
226 </g>
227 </g>
228 </svg>
229 </div>
230 </div>
231 )
234export { surface }
↑↓ move↵openescclose