import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; export interface LinearGraphHelpers { toPx: (x: number, y: number) => [number, number]; toPxX: (x: number) => number; toPxY: (y: number) => number; toData: (px: number, py: number) => [number, number]; plot: { x: number; y: number; width: number; height: number }; ctx: CanvasRenderingContext2D; } export type DrawFn = (helpers: LinearGraphHelpers) => void; export interface LinearGraphHandle { redraw: () => void; getCanvas: () => HTMLCanvasElement | null; } export interface LinearGraphProps { xMin: number; xMax: number; xStep?: number; yMin: number; yMax: number; yStep?: number; draw?: DrawFn; width?: number; height?: number; padding?: { top?: number; right?: number; bottom?: number; left?: number }; showGrid?: boolean; showAxes?: boolean; showTicks?: boolean; xLabel?: string; yLabel?: string; formatX?: (v: number) => string; formatY?: (v: number) => string; className?: string; style?: React.CSSProperties; } const defaultFormat = (v: number) => { if (Math.abs(v) < 1e-9) return '0'; const abs = Math.abs(v); if (abs >= 1000 || abs < 0.01) return v.toExponential(1); return parseFloat(v.toFixed(4)).toString(); }; function niceStep(min: number, max: number, targetTicks = 8): number { const range = Math.abs(max - min) || 1; const raw = range / targetTicks; const mag = Math.pow(10, Math.floor(Math.log10(raw))); const norm = raw / mag; const step = norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10; return step * mag; } function range(min: number, max: number, step: number): number[] { if (step <= 0) return []; const out: number[] = []; const start = Math.ceil((min - 1e-9) / step) * step; for (let v = start; v <= max + 1e-9; v += step) { out.push(Math.round(v / step) * step); } return out; } export const LinearGraph = forwardRef(function LinearGraph( { xMin, xMax, xStep, yMin, yMax, yStep, draw, width, height, padding, showGrid = true, showAxes = true, showTicks = true, xLabel, yLabel, formatX = defaultFormat, formatY = defaultFormat, style, }, ref ) { const containerRef = useRef(null); const canvasRef = useRef(null); const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 }); // "container query" units, computed from the actual rendered size — // 1 cqw = 1% of width, 1 cqh = 1% of height, cqmin = smaller of the two. const cq = useMemo(() => { const w = size.width || 1; const h = size.height || 1; return { w: w / 100, h: h / 100, min: Math.min(w, h) / 100 }; }, [size.width, size.height]); const pad = useMemo(() => { const topBase = cq.h * 3; const rightBase = cq.w * 5; const bottomBase = cq.h * 4; const leftBase = cq.w * 2; const xLabelSpace = xLabel ? cq.h * 7 : 0; const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0; return { top: padding?.top ?? topBase, right: padding?.right ?? rightBase, bottom: padding?.bottom ?? bottomBase + xLabelSpace, left: padding?.left ?? leftBase + yLabelSpace, }; }, [padding, xLabel, yLabel, cq]); // font / stroke / tick metrics, all derived from container size const metrics = useMemo( () => ({ tickFont: cq.min * 3, labelFont: cq.min * 4, tickLen: cq.min * 1.2, axisLineWidth: cq.min * 0.4, yLabelOffset: cq.w * 2, }), [cq] ); const effXStep = xStep ?? niceStep(xMin, xMax); const effYStep = yStep ?? niceStep(yMin, yMax); useEffect(() => { if (width && height) return; const el = containerRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; const { width: w, height: h } = entry.contentRect; if (w > 0 && h > 0) { setSize({ width: width ?? w, height: height ?? h }); } }); ro.observe(el); return () => ro.disconnect(); }, [width, height]); const paint = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const dpr = window.devicePixelRatio || 1; const cssW = size.width; const cssH = size.height; if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) { canvas.width = cssW * dpr; canvas.height = cssH * dpr; } const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); const plot = { x: pad.left + cq.w * 2, y: pad.top, width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2), height: Math.max(1, cssH - pad.top - pad.bottom), }; const toPxX = (x: number) => plot.x + ((x - xMin) / (xMax - xMin || 1)) * plot.width; const toPxY = (y: number) => plot.y + plot.height - ((y - yMin) / (yMax - yMin || 1)) * plot.height; const toPx = (x: number, y: number): [number, number] => [toPxX(x), toPxY(y)]; const toData = (px: number, py: number): [number, number] => [ xMin + ((px - plot.x) / plot.width) * (xMax - xMin), yMin + (1 - (py - plot.y) / plot.height) * (yMax - yMin), ]; const css = getComputedStyle(canvas); const gridColor = css.getPropertyValue('--graph-grid').trim() || '#2a2f3a'; const axisColor = css.getPropertyValue('--graph-axis').trim() || '#6b7280'; const textColor = css.getPropertyValue('--graph-text').trim() || '#9ca3af'; const bgColor = css.getPropertyValue('--graph-bg').trim(); if (bgColor) { ctx.fillStyle = bgColor; ctx.fillRect(plot.x, plot.y, plot.width, plot.height); } const clipPad = 2; ctx.save(); ctx.beginPath(); ctx.rect( plot.x - clipPad, plot.y - clipPad, plot.width + clipPad * 2, plot.height + clipPad * 2 ); ctx.clip(); ctx.strokeStyle = axisColor; ctx.lineWidth = 1; ctx.strokeRect( Math.round(plot.x) + 0.5, Math.round(plot.y) + 0.5, Math.round(plot.width) - 1, Math.round(plot.height) - 1 ); if (showGrid) { ctx.strokeStyle = gridColor; ctx.lineWidth = 1; ctx.beginPath(); for (const gx of range(xMin, xMax, effXStep)) { const px = Math.round(toPxX(gx)) + 0.5; ctx.moveTo(px, plot.y); ctx.lineTo(px, plot.y + plot.height); } for (const gy of range(yMin, yMax, effYStep)) { const py = Math.round(toPxY(gy)) + 0.5; ctx.moveTo(plot.x, py); ctx.lineTo(plot.x + plot.width, py); } ctx.stroke(); } if (showAxes) { ctx.strokeStyle = axisColor; ctx.lineWidth = metrics.axisLineWidth; ctx.beginPath(); if (yMin <= 0 && yMax >= 0) { const py = Math.round(toPxY(0)) + 0.5; ctx.moveTo(plot.x, py); ctx.lineTo(plot.x + plot.width, py); } if (xMin <= 0 && xMax >= 0) { const px = Math.round(toPxX(0)) + 0.5; ctx.moveTo(px, plot.y); ctx.lineTo(px, plot.y + plot.height); } ctx.stroke(); } draw?.({ toPx, toPxX, toPxY, toData, plot, ctx }); ctx.restore(); ctx.strokeStyle = axisColor; ctx.lineWidth = 1; ctx.strokeRect( Math.round(plot.x) + 0.5, Math.round(plot.y) + 0.5, Math.round(plot.width) - 1, Math.round(plot.height) - 1 ); if (showTicks) { ctx.fillStyle = textColor; ctx.font = `${metrics.tickFont}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (const gx of range(xMin, xMax, effXStep)) { const px = toPxX(gx); ctx.beginPath(); ctx.moveTo(px, plot.y + plot.height); ctx.lineTo(px, plot.y + plot.height + metrics.tickLen); ctx.strokeStyle = axisColor; ctx.stroke(); ctx.fillText(formatX(gx), px, plot.y + plot.height + metrics.tickLen + 2); } ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (const gy of range(yMin, yMax, effYStep)) { const py = toPxY(gy); ctx.beginPath(); ctx.moveTo(plot.x - metrics.tickLen, py); ctx.lineTo(plot.x, py); ctx.strokeStyle = axisColor; ctx.stroke(); ctx.fillText(formatY(gy), plot.x - metrics.tickLen - 2, py); } } if (xLabel) { ctx.fillStyle = textColor; ctx.font = `${metrics.labelFont}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(xLabel, plot.x + plot.width / 2, cssH - 4); } if (yLabel) { ctx.save(); ctx.fillStyle = textColor; ctx.font = `${metrics.labelFont}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.translate(metrics.yLabelOffset, plot.y + plot.height / 2); ctx.rotate(-Math.PI / 2); ctx.fillText(yLabel, 0, 0); ctx.restore(); } }, [ size, pad, metrics, xMin, xMax, effXStep, yMin, yMax, effYStep, showGrid, showAxes, showTicks, xLabel, yLabel, formatX, formatY, draw, ]); useEffect(() => { paint(); }, [paint]); useImperativeHandle(ref, () => ({ redraw: paint, getCanvas: () => canvasRef.current, })); return (
); }); export default LinearGraph;