feat: add compressor + fix ui for all sections
This commit is contained in:
@@ -7,23 +7,12 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import './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;
|
||||
}
|
||||
@@ -31,38 +20,28 @@ export interface LinearGraphHelpers {
|
||||
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;
|
||||
}
|
||||
@@ -74,7 +53,6 @@ const defaultFormat = (v: number) => {
|
||||
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;
|
||||
@@ -89,12 +67,13 @@ function range(min: number, max: number, step: number): number[] {
|
||||
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;
|
||||
}
|
||||
|
||||
const clamp = (min: number, val: number, max: number) => Math.min(max, Math.max(min, val));
|
||||
|
||||
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
|
||||
{
|
||||
xMin,
|
||||
@@ -122,20 +101,45 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 });
|
||||
|
||||
const pad = useMemo(
|
||||
// "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 ? cq.w * 6 : 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(
|
||||
() => ({
|
||||
top: padding?.top ?? 16,
|
||||
right: padding?.right ?? 16,
|
||||
bottom: padding?.bottom ?? (xLabel ? 44 : 28),
|
||||
left: padding?.left ?? (yLabel ? 56 : 44),
|
||||
tickFont: cq.min * 3,
|
||||
labelFont: cq.min * 4,
|
||||
tickLen: cq.min * 1.2,
|
||||
axisLineWidth: cq.min * 0.4,
|
||||
yLabelOffset: cq.w * 2,
|
||||
}),
|
||||
[padding, xLabel, yLabel]
|
||||
[cq]
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -194,7 +198,6 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
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();
|
||||
@@ -206,7 +209,6 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
);
|
||||
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(
|
||||
@@ -235,7 +237,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
|
||||
if (showAxes) {
|
||||
ctx.strokeStyle = axisColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineWidth = metrics.axisLineWidth;
|
||||
ctx.beginPath();
|
||||
if (yMin <= 0 && yMax >= 0) {
|
||||
const py = Math.round(toPxY(0)) + 0.5;
|
||||
@@ -250,12 +252,10 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
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(
|
||||
@@ -265,37 +265,36 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
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.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 + 4);
|
||||
ctx.lineTo(px, plot.y + plot.height + metrics.tickLen);
|
||||
ctx.strokeStyle = axisColor;
|
||||
ctx.stroke();
|
||||
ctx.fillText(formatX(gx), px, plot.y + plot.height + 6);
|
||||
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 - 4, py);
|
||||
ctx.moveTo(plot.x - metrics.tickLen, py);
|
||||
ctx.lineTo(plot.x, py);
|
||||
ctx.strokeStyle = axisColor;
|
||||
ctx.stroke();
|
||||
ctx.fillText(formatY(gy), plot.x - 6, py);
|
||||
ctx.fillText(formatY(gy), plot.x - metrics.tickLen - 2, py);
|
||||
}
|
||||
}
|
||||
|
||||
if (xLabel) {
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
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);
|
||||
@@ -303,10 +302,10 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
if (yLabel) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '12px system-ui, sans-serif';
|
||||
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.translate(12, plot.y + plot.height / 2);
|
||||
ctx.translate(metrics.yLabelOffset, plot.y + plot.height / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText(yLabel, 0, 0);
|
||||
ctx.restore();
|
||||
@@ -314,6 +313,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
}, [
|
||||
size,
|
||||
pad,
|
||||
metrics,
|
||||
xMin,
|
||||
xMax,
|
||||
effXStep,
|
||||
|
||||
Reference in New Issue
Block a user