/ tutorials / laplace-dirichlet
Sign in
tutorials / laplace-dirichlet
laplace-dirichlet / src / plot / Plot.tsx
199 lines · 6.0 KBBlameHistoryRaw
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
31const DEFAULT_MARGIN = { l: 54, r: 14, t: 10, b: 30 }
33/** Measures its own width so plots reflow with the panel. */
34function useWidth(): [(el: HTMLDivElement | null) => void, number] {
35 const [width, setWidth] = useState(640)
36 const obs = useRef<ResizeObserver | null>(null)
37 const ref = useCallback((el: HTMLDivElement | null) => {
38 obs.current?.disconnect()
39 if (!el) return
40 const ro = new ResizeObserver((entries) => {
41 const w = entries[0]?.contentRect.width
42 if (w && w > 0) setWidth(w)
43 })
44 ro.observe(el)
45 obs.current = ro
46 setWidth(el.clientWidth || 640)
47 }, [])
48 useEffect(() => () => obs.current?.disconnect(), [])
49 return [ref, width]
52export default function Plot(props: PlotProps) {
53 const {
54 height,
55 xDomain,
56 yDomain,
57 xLog = false,
58 yLog = false,
59 xLabel,
60 yLabel,
61 formatX = formatTick,
62 formatY = formatTick,
63 under,
64 over,
65 children,
66 onHoverX,
67 hoverX,
68 overlay,
69 } = props
70 const m = { ...DEFAULT_MARGIN, ...props.margin }
71 const [hostRef, width] = useWidth()
72 const clipId = useId().replace(/:/g, '')
73 const svgRef = useRef<SVGSVGElement>(null)
75 const iw = Math.max(10, width - m.l - m.r)
76 const ih = Math.max(10, height - m.t - m.b)
78 const fwd = (v: number, [lo, hi]: [number, number], log: boolean, span: number, flip: boolean) => {
79 const t = log
80 ? (Math.log10(Math.max(v, Number.MIN_VALUE)) - Math.log10(lo)) / (Math.log10(hi) - Math.log10(lo))
81 : (v - lo) / (hi - lo)
82 return flip ? span * (1 - t) : span * t
83 }
85 const f: Frame = {
86 sx: (v) => fwd(v, xDomain, xLog, iw, false),
87 sy: (v) => fwd(v, yDomain, yLog, ih, true),
88 ix: (px) => {
89 const t = px / iw
90 return xLog
91 ? Math.pow(10, Math.log10(xDomain[0]) + t * (Math.log10(xDomain[1]) - Math.log10(xDomain[0])))
92 : xDomain[0] + t * (xDomain[1] - xDomain[0])
93 },
94 iw,
95 ih,
96 xDomain,
97 yDomain,
98 }
100 const xt = props.xTicks ?? (xLog ? decadeTicks(xDomain[0], xDomain[1]) : niceTicks(xDomain[0], xDomain[1], 7))
101 const yt = props.yTicks ?? (yLog ? decadeTicks(yDomain[0], yDomain[1]) : niceTicks(yDomain[0], yDomain[1], 5))
103 const pointer = (e: React.PointerEvent) => {
104 if (!onHoverX) return
105 const rect = svgRef.current?.getBoundingClientRect()
106 if (!rect) return
107 const px = e.clientX - rect.left - m.l
108 onHoverX(px < -4 || px > iw + 4 ? null : f.ix(Math.min(iw, Math.max(0, px))))
109 }
111 return (
112 <div className="plot-host" ref={hostRef}>
113 <svg
114 ref={svgRef}
115 width={width}
116 height={height}
117 role="img"
118 onPointerMove={pointer}
119 onPointerLeave={() => onHoverX?.(null)}
120 >
121 <defs>
122 <clipPath id={clipId}>
123 <rect x={0} y={0} width={iw} height={ih} />
124 </clipPath>
125 </defs>
126 <g transform={`translate(${m.l},${m.t})`}>
127 {/* gridlines, recessive */}
128 {xt.map((v) => (
129 <line key={`gx${v}`} x1={f.sx(v)} x2={f.sx(v)} y1={0} y2={ih} stroke={ink.grid} strokeWidth={1} />
130 ))}
131 {yt.map((v) => (
132 <line key={`gy${v}`} x1={0} x2={iw} y1={f.sy(v)} y2={f.sy(v)} stroke={ink.grid} strokeWidth={1} />
133 ))}
135 {under?.(f)}
137 <g clipPath={`url(#${clipId})`}>{children(f)}</g>
139 {over?.(f)}
141 {hoverX != null && hoverX >= Math.min(...xDomain) && hoverX <= Math.max(...xDomain) && (
142 <line
143 x1={f.sx(hoverX)}
144 x2={f.sx(hoverX)}
145 y1={0}
146 y2={ih}
147 stroke={ink.secondary}
148 strokeWidth={1}
149 strokeDasharray="3 3"
150 opacity={0.7}
151 pointerEvents="none"
152 />
153 )}
155 {/* axes */}
156 <line x1={0} x2={iw} y1={ih} y2={ih} stroke={ink.axis} strokeWidth={1} />
157 <line x1={0} x2={0} y1={0} y2={ih} stroke={ink.axis} strokeWidth={1} />
158 {xt.map((v) => (
159 <text
160 key={`tx${v}`}
161 x={f.sx(v)}
162 y={ih + 15}
163 fill={ink.muted}
164 fontSize={11}
165 textAnchor="middle"
166 style={{ fontVariantNumeric: 'tabular-nums' }}
167 >
168 {formatX(v)}
169 </text>
170 ))}
171 {yt.map((v) => (
172 <text
173 key={`ty${v}`}
174 x={-7}
175 y={f.sy(v) + 4}
176 fill={ink.muted}
177 fontSize={11}
178 textAnchor="end"
179 style={{ fontVariantNumeric: 'tabular-nums' }}
180 >
181 {formatY(v)}
182 </text>
183 ))}
184 {xLabel && (
185 <text x={iw} y={ih + 27} fill={ink.muted} fontSize={11} textAnchor="end">
186 {xLabel}
187 </text>
188 )}
189 {yLabel && (
190 <text x={-m.l + 4} y={-1} fill={ink.muted} fontSize={11} textAnchor="start">
191 {yLabel}
192 </text>
193 )}
194 </g>
195 </svg>
196 {overlay}
197 </div>
198 )
↑↓ move↵openescclose