/ tutorials / laplace-dirichlet
Sign in
tutorials / laplace-dirichlet
laplace-dirichlet / src / plot / Plot.tsx
224 lines · 6.9 KBCodeBlameHistory
088be4cAn interactive tutorial on the Dirichlet problem, accuracy against costJeremy Magland 1import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
2import { ink } from './palette.ts'
3import { decadeTicks, formatTick, niceTicks, type Frame } from './scales.ts'
5export interface PlotProps {
6 height: number
7 xDomain: [number, number]
8 yDomain: [number, number]
9 xLog?: boolean
10 yLog?: boolean
11 xLabel?: string
12 yLabel?: string
13 xTicks?: number[]
14 yTicks?: number[]
15 formatX?: (v: number) => string
16 formatY?: (v: number) => string
17 margin?: Partial<{ l: number; r: number; t: number; b: number }>
18 /** drawn under the data, inside the frame but outside the clip */
19 under?: (f: Frame) => ReactNode
20 /** drawn over the data and outside the clip, so labels may reach the margin */
21 over?: (f: Frame) => ReactNode
22 children: (f: Frame) => ReactNode
23 /** enables the crosshair; called with the data x under the pointer, or null */
24 onHoverX?: (x: number | null) => void
25 /** data x at which to draw the crosshair (usually what onHoverX last gave) */
26 hoverX?: number | null
27 /** rendered as an absolutely positioned box over the plot */
28 overlay?: ReactNode
30 * Pointer position in frame pixels, or null when it leaves. The caller is
31 * expected to keep it in state and use the frame it is handed in `over` to
32 * turn it back into data coordinates, which is how a plot picks the point
33 * nearest the cursor without either side owning the scales twice.
34 */
35 onPointer?: (p: { px: number; py: number } | null) => void
38/** Pointer position in frame pixels: what `onPointer` reports. */
39export interface PointerPx {
40 px: number
41 py: number
44const DEFAULT_MARGIN = { l: 54, r: 14, t: 10, b: 30 }
46/** Measures its own width so plots reflow with the panel. */
47function useWidth(): [(el: HTMLDivElement | null) => void, number] {
48 const [width, setWidth] = useState(640)
49 const obs = useRef<ResizeObserver | null>(null)
50 const ref = useCallback((el: HTMLDivElement | null) => {
51 obs.current?.disconnect()
52 if (!el) return
53 const ro = new ResizeObserver((entries) => {
54 const w = entries[0]?.contentRect.width
55 if (w && w > 0) setWidth(w)
56 })
57 ro.observe(el)
58 obs.current = ro
59 setWidth(el.clientWidth || 640)
60 }, [])
61 useEffect(() => () => obs.current?.disconnect(), [])
62 return [ref, width]
65export default function Plot(props: PlotProps) {
66 const {
67 height,
68 xDomain,
69 yDomain,
70 xLog = false,
71 yLog = false,
72 xLabel,
73 yLabel,
74 formatX = formatTick,
75 formatY = formatTick,
76 under,
77 over,
78 children,
79 onHoverX,
80 hoverX,
81 overlay,
84 const m = { ...DEFAULT_MARGIN, ...props.margin }
85 const [hostRef, width] = useWidth()
86 const clipId = useId().replace(/:/g, '')
87 const svgRef = useRef<SVGSVGElement>(null)
89 const iw = Math.max(10, width - m.l - m.r)
90 const ih = Math.max(10, height - m.t - m.b)
92 const fwd = (v: number, [lo, hi]: [number, number], log: boolean, span: number, flip: boolean) => {
93 const t = log
94 ? (Math.log10(Math.max(v, Number.MIN_VALUE)) - Math.log10(lo)) / (Math.log10(hi) - Math.log10(lo))
95 : (v - lo) / (hi - lo)
96 return flip ? span * (1 - t) : span * t
97 }
99 const f: Frame = {
100 sx: (v) => fwd(v, xDomain, xLog, iw, false),
101 sy: (v) => fwd(v, yDomain, yLog, ih, true),
102 ix: (px) => {
103 const t = px / iw
104 return xLog
105 ? Math.pow(10, Math.log10(xDomain[0]) + t * (Math.log10(xDomain[1]) - Math.log10(xDomain[0])))
106 : xDomain[0] + t * (xDomain[1] - xDomain[0])
107 },
108 iw,
109 ih,
110 xDomain,
111 yDomain,
112 }
114 const xt = props.xTicks ?? (xLog ? decadeTicks(xDomain[0], xDomain[1]) : niceTicks(xDomain[0], xDomain[1], 7))
115 const yt = props.yTicks ?? (yLog ? decadeTicks(yDomain[0], yDomain[1]) : niceTicks(yDomain[0], yDomain[1], 5))
117 const pointer = (e: React.PointerEvent) => {
6292f12A benchmark chapter, and offline benchmarks to compare it againstJeremy Magland 118 if (!onHoverX && !onPointer) return
088be4cAn interactive tutorial on the Dirichlet problem, accuracy against costJeremy Magland 119 const rect = svgRef.current?.getBoundingClientRect()
120 if (!rect) return
121 const px = e.clientX - rect.left - m.l
6292f12A benchmark chapter, and offline benchmarks to compare it againstJeremy Magland 122 const py = e.clientY - rect.top - m.t
123 onHoverX?.(px < -4 || px > iw + 4 ? null : f.ix(Math.min(iw, Math.max(0, px))))
124 // a little slack outside the frame, so a marker sitting on an axis is
125 // still reachable
126 const out = px < -10 || px > iw + 10 || py < -10 || py > ih + 10
127 onPointer?.(out ? null : { px, py })
128 }
130 const leave = () => {
131 onHoverX?.(null)
132 onPointer?.(null)
135 return (
136 <div className="plot-host" ref={hostRef}>
137 <svg
138 ref={svgRef}
139 width={width}
140 height={height}
141 role="img"
142 onPointerMove={pointer}
144 style={onPointer ? { cursor: 'crosshair' } : undefined}
146 <defs>
147 <clipPath id={clipId}>
148 <rect x={0} y={0} width={iw} height={ih} />
149 </clipPath>
150 </defs>
151 <g transform={`translate(${m.l},${m.t})`}>
152 {/* gridlines, recessive */}
153 {xt.map((v) => (
154 <line key={`gx${v}`} x1={f.sx(v)} x2={f.sx(v)} y1={0} y2={ih} stroke={ink.grid} strokeWidth={1} />
155 ))}
156 {yt.map((v) => (
157 <line key={`gy${v}`} x1={0} x2={iw} y1={f.sy(v)} y2={f.sy(v)} stroke={ink.grid} strokeWidth={1} />
158 ))}
160 {under?.(f)}
162 <g clipPath={`url(#${clipId})`}>{children(f)}</g>
164 {over?.(f)}
166 {hoverX != null && hoverX >= Math.min(...xDomain) && hoverX <= Math.max(...xDomain) && (
167 <line
168 x1={f.sx(hoverX)}
169 x2={f.sx(hoverX)}
170 y1={0}
171 y2={ih}
172 stroke={ink.secondary}
173 strokeWidth={1}
174 strokeDasharray="3 3"
175 opacity={0.7}
176 pointerEvents="none"
177 />
178 )}
180 {/* axes */}
181 <line x1={0} x2={iw} y1={ih} y2={ih} stroke={ink.axis} strokeWidth={1} />
182 <line x1={0} x2={0} y1={0} y2={ih} stroke={ink.axis} strokeWidth={1} />
183 {xt.map((v) => (
184 <text
185 key={`tx${v}`}
186 x={f.sx(v)}
187 y={ih + 15}
188 fill={ink.muted}
189 fontSize={11}
190 textAnchor="middle"
191 style={{ fontVariantNumeric: 'tabular-nums' }}
192 >
193 {formatX(v)}
194 </text>
195 ))}
196 {yt.map((v) => (
197 <text
198 key={`ty${v}`}
199 x={-7}
200 y={f.sy(v) + 4}
201 fill={ink.muted}
202 fontSize={11}
203 textAnchor="end"
204 style={{ fontVariantNumeric: 'tabular-nums' }}
205 >
206 {formatY(v)}
207 </text>
208 ))}
209 {xLabel && (
210 <text x={iw} y={ih + 27} fill={ink.muted} fontSize={11} textAnchor="end">
211 {xLabel}
212 </text>
213 )}
214 {yLabel && (
215 <text x={-m.l + 4} y={-1} fill={ink.muted} fontSize={11} textAnchor="start">
216 {yLabel}
217 </text>
218 )}
219 </g>
220 </svg>
221 {overlay}
222 </div>
223 )
↑↓ move↵openescclose