Files
dsp-thomann-interface/src/components/dsp408/LinearGraph/LinearGraph.tsx
T
2026-07-31 13:24:24 +02:00

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;