363 lines
9.9 KiB
TypeScript
363 lines
9.9 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import styles from './LinearGraph.css';
|
|
|
|
/**
|
|
* Coordinate-space helpers passed into your draw callback.
|
|
* Everything here works in DATA units (e.g. seconds, Hz, volts) —
|
|
* you never have to think about pixels or devicePixelRatio.
|
|
*/
|
|
export interface LinearGraphHelpers {
|
|
/** data (x, y) -> canvas pixel (px, py) */
|
|
toPx: (x: number, y: number) => [number, number];
|
|
/** data x -> pixel x */
|
|
toPxX: (x: number) => number;
|
|
/** data y -> pixel y */
|
|
toPxY: (y: number) => number;
|
|
/** pixel (px, py) -> data (x, y). Useful for handling clicks/drags. */
|
|
toData: (px: number, py: number) => [number, number];
|
|
/** size of the plot area in CSS pixels (inside the axes/padding) */
|
|
plot: { x: number; y: number; width: number; height: number };
|
|
ctx: CanvasRenderingContext2D;
|
|
}
|
|
|
|
export type DrawFn = (helpers: LinearGraphHelpers) => void;
|
|
|
|
export interface LinearGraphHandle {
|
|
/** Force a redraw (e.g. after mutating a ref-held buffer without a re-render) */
|
|
redraw: () => void;
|
|
/** Access the raw canvas, e.g. for exporting a PNG */
|
|
getCanvas: () => HTMLCanvasElement | null;
|
|
}
|
|
|
|
export interface LinearGraphProps {
|
|
xMin: number;
|
|
xMax: number;
|
|
/** grid + tick spacing on x. Omit to auto-pick a "nice" step. */
|
|
xStep?: number;
|
|
yMin: number;
|
|
yMax: number;
|
|
yStep?: number;
|
|
|
|
/** Your drawing code. Called every time the graph needs to repaint. */
|
|
draw?: DrawFn;
|
|
|
|
width?: number;
|
|
height?: number;
|
|
/** padding (px) reserved for axis labels, in CSS pixels */
|
|
padding?: { top?: number; right?: number; bottom?: number; left?: number };
|
|
|
|
showGrid?: boolean;
|
|
showAxes?: boolean;
|
|
showTicks?: boolean;
|
|
xLabel?: string;
|
|
yLabel?: string;
|
|
/** format a tick value, e.g. (v) => v.toFixed(1) or Hz->kHz */
|
|
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();
|
|
};
|
|
|
|
/** Pick a "nice" step (1/2/5 * 10^n) that yields ~targetTicks divisions */
|
|
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) {
|
|
// snap to avoid float drift like 0.30000000000000004
|
|
out.push(Math.round(v / step) * step);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
|
|
{
|
|
xMin,
|
|
xMax,
|
|
xStep,
|
|
yMin,
|
|
yMax,
|
|
yStep,
|
|
draw,
|
|
width,
|
|
height,
|
|
padding,
|
|
showGrid = true,
|
|
showAxes = true,
|
|
showTicks = true,
|
|
xLabel,
|
|
yLabel,
|
|
formatX = defaultFormat,
|
|
formatY = defaultFormat,
|
|
className,
|
|
style,
|
|
},
|
|
ref
|
|
) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 });
|
|
|
|
const pad = useMemo(
|
|
() => ({
|
|
top: padding?.top ?? 16,
|
|
right: padding?.right ?? 16,
|
|
bottom: padding?.bottom ?? (xLabel ? 44 : 28),
|
|
left: padding?.left ?? (yLabel ? 56 : 44),
|
|
}),
|
|
[padding, xLabel, yLabel]
|
|
);
|
|
|
|
const effXStep = xStep ?? niceStep(xMin, xMax);
|
|
const effYStep = yStep ?? niceStep(yMin, yMax);
|
|
|
|
// Responsive sizing when width/height aren't fixed
|
|
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,
|
|
y: pad.top,
|
|
width: Math.max(1, cssW - pad.left - pad.right),
|
|
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);
|
|
}
|
|
|
|
// clip to plot area so user drawing can't bleed into labels
|
|
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();
|
|
|
|
// draw the frame border first, so data drawn on the edge sits on top of it
|
|
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 = 1.5;
|
|
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();
|
|
}
|
|
|
|
// hand off to user drawing code, still clipped to plot area
|
|
draw?.({ toPx, toPxX, toPxY, toData, plot, ctx });
|
|
|
|
ctx.restore();
|
|
|
|
// border
|
|
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
|
|
);
|
|
|
|
// ticks + labels (outside clip)
|
|
if (showTicks) {
|
|
ctx.fillStyle = textColor;
|
|
ctx.font = '11px 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 + 4);
|
|
ctx.strokeStyle = axisColor;
|
|
ctx.stroke();
|
|
ctx.fillText(formatX(gx), px, plot.y + plot.height + 6);
|
|
}
|
|
ctx.textAlign = 'right';
|
|
ctx.textBaseline = 'middle';
|
|
for (const gy of range(yMin, yMax, effYStep)) {
|
|
const py = toPxY(gy);
|
|
ctx.beginPath();
|
|
ctx.moveTo(plot.x - 4, py);
|
|
ctx.lineTo(plot.x, py);
|
|
ctx.strokeStyle = axisColor;
|
|
ctx.stroke();
|
|
ctx.fillText(formatY(gy), plot.x - 6, py);
|
|
}
|
|
}
|
|
|
|
if (xLabel) {
|
|
ctx.fillStyle = textColor;
|
|
ctx.font = '12px 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 = '12px system-ui, sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
ctx.translate(12, plot.y + plot.height / 2);
|
|
ctx.rotate(-Math.PI / 2);
|
|
ctx.fillText(yLabel, 0, 0);
|
|
ctx.restore();
|
|
}
|
|
}, [
|
|
size,
|
|
pad,
|
|
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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={'container'}
|
|
style={{
|
|
width: width ?? '100%',
|
|
height: height ?? '100%',
|
|
...style,
|
|
}}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className={'canvas'}
|
|
style={{ width: size.width, height: size.height }}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default LinearGraph;
|