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