360 lines
9.7 KiB
TypeScript
360 lines
9.7 KiB
TypeScript
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<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,
|
|
style,
|
|
},
|
|
ref
|
|
) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(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 (
|
|
<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;
|