import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { ink } from './palette.ts' import { decadeTicks, formatTick, niceTicks, type Frame } from './scales.ts' export interface PlotProps { height: number xDomain: [number, number] yDomain: [number, number] xLog?: boolean yLog?: boolean xLabel?: string yLabel?: string xTicks?: number[] yTicks?: number[] formatX?: (v: number) => string formatY?: (v: number) => string margin?: Partial<{ l: number; r: number; t: number; b: number }> /** drawn under the data, inside the frame but outside the clip */ under?: (f: Frame) => ReactNode /** drawn over the data and outside the clip, so labels may reach the margin */ over?: (f: Frame) => ReactNode children: (f: Frame) => ReactNode /** enables the crosshair; called with the data x under the pointer, or null */ onHoverX?: (x: number | null) => void /** data x at which to draw the crosshair (usually what onHoverX last gave) */ hoverX?: number | null /** rendered as an absolutely positioned box over the plot */ overlay?: ReactNode /** * Pointer position in frame pixels, or null when it leaves. The caller is * expected to keep it in state and use the frame it is handed in `over` to * turn it back into data coordinates, which is how a plot picks the point * nearest the cursor without either side owning the scales twice. */ onPointer?: (p: { px: number; py: number } | null) => void } /** Pointer position in frame pixels: what `onPointer` reports. */ export interface PointerPx { px: number py: number } const DEFAULT_MARGIN = { l: 54, r: 14, t: 10, b: 30 } /** Measures its own width so plots reflow with the panel. */ function useWidth(): [(el: HTMLDivElement | null) => void, number] { const [width, setWidth] = useState(640) 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 || 640) }, []) useEffect(() => () => obs.current?.disconnect(), []) return [ref, width] } export default function Plot(props: PlotProps) { const { height, xDomain, yDomain, xLog = false, yLog = false, xLabel, yLabel, formatX = formatTick, formatY = formatTick, under, over, children, onHoverX, hoverX, overlay, onPointer, } = props const m = { ...DEFAULT_MARGIN, ...props.margin } const [hostRef, width] = useWidth() const clipId = useId().replace(/:/g, '') const svgRef = useRef(null) const iw = Math.max(10, width - m.l - m.r) const ih = Math.max(10, height - m.t - m.b) const fwd = (v: number, [lo, hi]: [number, number], log: boolean, span: number, flip: boolean) => { const t = log ? (Math.log10(Math.max(v, Number.MIN_VALUE)) - Math.log10(lo)) / (Math.log10(hi) - Math.log10(lo)) : (v - lo) / (hi - lo) return flip ? span * (1 - t) : span * t } const f: Frame = { sx: (v) => fwd(v, xDomain, xLog, iw, false), sy: (v) => fwd(v, yDomain, yLog, ih, true), ix: (px) => { const t = px / iw return xLog ? Math.pow(10, Math.log10(xDomain[0]) + t * (Math.log10(xDomain[1]) - Math.log10(xDomain[0]))) : xDomain[0] + t * (xDomain[1] - xDomain[0]) }, iw, ih, xDomain, yDomain, } const xt = props.xTicks ?? (xLog ? decadeTicks(xDomain[0], xDomain[1]) : niceTicks(xDomain[0], xDomain[1], 7)) const yt = props.yTicks ?? (yLog ? decadeTicks(yDomain[0], yDomain[1]) : niceTicks(yDomain[0], yDomain[1], 5)) const pointer = (e: React.PointerEvent) => { if (!onHoverX && !onPointer) return const rect = svgRef.current?.getBoundingClientRect() if (!rect) return const px = e.clientX - rect.left - m.l const py = e.clientY - rect.top - m.t onHoverX?.(px < -4 || px > iw + 4 ? null : f.ix(Math.min(iw, Math.max(0, px)))) // a little slack outside the frame, so a marker sitting on an axis is // still reachable const out = px < -10 || px > iw + 10 || py < -10 || py > ih + 10 onPointer?.(out ? null : { px, py }) } const leave = () => { onHoverX?.(null) onPointer?.(null) } return (
{/* gridlines, recessive */} {xt.map((v) => ( ))} {yt.map((v) => ( ))} {under?.(f)} {children(f)} {over?.(f)} {hoverX != null && hoverX >= Math.min(...xDomain) && hoverX <= Math.max(...xDomain) && ( )} {/* axes */} {xt.map((v) => ( {formatX(v)} ))} {yt.map((v) => ( {formatY(v)} ))} {xLabel && ( {xLabel} )} {yLabel && ( {yLabel} )} {overlay}
) }