import { useCallback } from 'react' import { useCanvas } from '../hooks/useCanvas' import { AXIS, FONT, FRAME, GRID, formatTick, niceTicks } from '../plot' export type Series = { x: ArrayLike y: ArrayLike color: string width?: number /** draw points instead of a line */ dots?: boolean } type Props = { series: Series[] xRange: [number, number] /** fixed y range; otherwise fitted to the data, symmetric if `symmetric` */ yRange?: [number, number] symmetric?: boolean xLabel?: string yLabel?: string /** a shaded horizontal band, e.g. a threshold */ band?: { y0: number; y1: number; color: string; label?: string } zeroLine?: boolean className?: string } const M = { l: 48, r: 12, t: 8, b: 30 } /** A plain x-y plot with ticks, an optional shaded band, and a zero line. */ export function LinePlot({ series, xRange, yRange, symmetric, xLabel, yLabel, band, zeroLine, className }: Props) { const draw = useCallback( (ctx: CanvasRenderingContext2D, w: number, h: number) => { const pw = w - M.l - M.r const ph = h - M.t - M.b let [y0, y1] = yRange ?? [Infinity, -Infinity] if (!yRange) { for (const s of series) for (let i = 0; i < s.y.length; i++) { const v = s.y[i] if (!Number.isFinite(v)) continue if (v < y0) y0 = v if (v > y1) y1 = v } if (band) { y0 = Math.min(y0, band.y0) y1 = Math.max(y1, band.y1) } if (!(y1 > y0)) { y0 = Number.isFinite(y0) ? y0 - 1 : -1 y1 = Number.isFinite(y1) ? y1 + 1 : 1 } if (symmetric) { const m = Math.max(Math.abs(y0), Math.abs(y1)) y0 = -m y1 = m } const padY = (y1 - y0) * 0.06 y0 -= padY y1 += padY } const [x0, x1] = xRange const toX = (v: number) => M.l + ((v - x0) / (x1 - x0)) * pw const toY = (v: number) => M.t + ph - ((v - y0) / (y1 - y0)) * ph ctx.font = FONT ctx.lineWidth = 1 if (band) { ctx.fillStyle = band.color const by0 = Math.max(M.t, Math.min(M.t + ph, toY(band.y1))) const by1 = Math.max(M.t, Math.min(M.t + ph, toY(band.y0))) ctx.fillRect(M.l, by0, pw, by1 - by0) } ctx.fillStyle = AXIS ctx.strokeStyle = GRID ctx.textAlign = 'right' ctx.textBaseline = 'middle' for (const v of niceTicks(y0, y1, 4)) { const y = Math.round(toY(v)) + 0.5 if (y < M.t || y > M.t + ph) continue ctx.beginPath() ctx.moveTo(M.l, y) ctx.lineTo(M.l + pw, y) ctx.stroke() ctx.fillText(formatTick(v), M.l - 5, y) } ctx.textAlign = 'center' ctx.textBaseline = 'top' for (const v of niceTicks(x0, x1, 6)) { const x = Math.round(toX(v)) + 0.5 if (x < M.l || x > M.l + pw) continue ctx.beginPath() ctx.moveTo(x, M.t) ctx.lineTo(x, M.t + ph) ctx.stroke() ctx.fillText(formatTick(v), x, M.t + ph + 4) } if (zeroLine && y0 < 0 && y1 > 0) { ctx.strokeStyle = '#b8bcc2' const y = Math.round(toY(0)) + 0.5 ctx.beginPath() ctx.moveTo(M.l, y) ctx.lineTo(M.l + pw, y) ctx.stroke() } ctx.strokeStyle = FRAME ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph) if (xLabel) { ctx.textAlign = 'center' ctx.textBaseline = 'bottom' ctx.fillText(xLabel, M.l + pw / 2, h - 2) } if (yLabel) { ctx.save() ctx.translate(11, M.t + ph / 2) ctx.rotate(-Math.PI / 2) ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(yLabel, 0, 0) ctx.restore() } if (band?.label) { ctx.fillStyle = '#2f7d32' ctx.textAlign = 'right' ctx.textBaseline = 'bottom' ctx.fillText(band.label, M.l + pw - 4, Math.max(M.t + 12, toY(band.y1)) - 2) } ctx.save() ctx.beginPath() ctx.rect(M.l, M.t, pw, ph) ctx.clip() for (const s of series) { ctx.strokeStyle = s.color ctx.fillStyle = s.color ctx.lineWidth = s.width ?? 1.5 ctx.lineJoin = 'round' if (s.dots) { for (let i = 0; i < s.x.length; i++) { ctx.beginPath() ctx.arc(toX(s.x[i]), toY(s.y[i]), 1.8, 0, 2 * Math.PI) ctx.fill() } } else { ctx.beginPath() let pen = false for (let i = 0; i < s.x.length; i++) { const v = s.y[i] if (!Number.isFinite(v)) { pen = false continue } const px = toX(s.x[i]) const py = toY(v) if (!pen) ctx.moveTo(px, py) else ctx.lineTo(px, py) pen = true } ctx.stroke() } } ctx.restore() }, [series, xRange, yRange, symmetric, xLabel, yLabel, band, zeroLine], ) const ref = useCanvas(draw) return }