feat: add demo mode
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
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 type AxisScale = 'linear' | 'log';
|
||||
|
||||
export interface LinearGraphProps {
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
xStep?: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
yStep?: number;
|
||||
/** 'log' requires xMin > 0. Ticks are drawn at 1/2/5-per-decade mantissas, with decade (power-of-10) lines highlighted. */
|
||||
xScale?: AxisScale;
|
||||
/** 'log' requires yMin > 0. */
|
||||
yScale?: AxisScale;
|
||||
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;
|
||||
}
|
||||
|
||||
interface Tick {
|
||||
value: number;
|
||||
/** Decade lines (powers of 10) get major=true for a bolder/brighter grid line. */
|
||||
major: boolean;
|
||||
}
|
||||
|
||||
// Linear ticks: every step is equally weighted, so mark every Nth (default 5th) as "major"
|
||||
// purely for label emphasis — grid highlighting logic only really matters for log mode.
|
||||
function linearTicks(min: number, max: number, step: number): Tick[] {
|
||||
return range(min, max, step).map((value) => ({ value, major: false }));
|
||||
}
|
||||
|
||||
// Log ticks: standard 1/2/3/4/5/6/7/8/9 mantissas within each decade between min and max.
|
||||
// Mantissa === 1 (i.e. the decade itself: ...0.1, 1, 10, 100, 1000...) is flagged major
|
||||
// so it can be drawn as a highlighted line distinct from the minor in-decade ticks.
|
||||
function logTicks(min: number, max: number): Tick[] {
|
||||
if (min <= 0 || max <= 0 || max <= min) return [];
|
||||
const startDecade = Math.floor(Math.log10(min));
|
||||
const endDecade = Math.ceil(Math.log10(max));
|
||||
const out: Tick[] = [];
|
||||
for (let d = startDecade; d <= endDecade; d++) {
|
||||
const decadeValue = Math.pow(10, d);
|
||||
for (const mantissa of [1, 2, 3, 4, 5, 6, 7, 8, 9]) {
|
||||
const value = decadeValue * mantissa;
|
||||
if (value < min - 1e-9 || value > max + 1e-9) continue;
|
||||
out.push({ value, major: mantissa === 1 });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
|
||||
{
|
||||
xMin,
|
||||
xMax,
|
||||
xStep,
|
||||
yMin,
|
||||
yMax,
|
||||
yStep,
|
||||
xScale = 'linear',
|
||||
yScale = 'linear',
|
||||
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);
|
||||
|
||||
const xLogValid = xScale === 'log' && xMin > 0 && xMax > xMin;
|
||||
const yLogValid = yScale === 'log' && yMin > 0 && yMax > yMin;
|
||||
|
||||
const xTicks = useMemo(
|
||||
() => (xLogValid ? logTicks(xMin, xMax) : linearTicks(xMin, xMax, effXStep)),
|
||||
[xLogValid, xMin, xMax, effXStep]
|
||||
);
|
||||
const yTicks = useMemo(
|
||||
() => (yLogValid ? logTicks(yMin, yMax) : linearTicks(yMin, yMax, effYStep)),
|
||||
[yLogValid, yMin, yMax, effYStep]
|
||||
);
|
||||
|
||||
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 logXMin = xLogValid ? Math.log10(xMin) : 0;
|
||||
const logXMax = xLogValid ? Math.log10(xMax) : 0;
|
||||
const logYMin = yLogValid ? Math.log10(yMin) : 0;
|
||||
const logYMax = yLogValid ? Math.log10(yMax) : 0;
|
||||
|
||||
const toPxX = (x: number) => {
|
||||
if (xLogValid) {
|
||||
const lx = Math.log10(Math.max(x, 1e-9));
|
||||
return plot.x + ((lx - logXMin) / (logXMax - logXMin || 1)) * plot.width;
|
||||
}
|
||||
return plot.x + ((x - xMin) / (xMax - xMin || 1)) * plot.width;
|
||||
};
|
||||
const toPxY = (y: number) => {
|
||||
if (yLogValid) {
|
||||
const ly = Math.log10(Math.max(y, 1e-9));
|
||||
return plot.y + plot.height - ((ly - logYMin) / (logYMax - logYMin || 1)) * plot.height;
|
||||
}
|
||||
return 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] => {
|
||||
const xRatio = (px - plot.x) / plot.width;
|
||||
const yRatio = 1 - (py - plot.y) / plot.height;
|
||||
const x = xLogValid
|
||||
? Math.pow(10, logXMin + xRatio * (logXMax - logXMin))
|
||||
: xMin + xRatio * (xMax - xMin);
|
||||
const y = yLogValid
|
||||
? Math.pow(10, logYMin + yRatio * (logYMax - logYMin))
|
||||
: yMin + yRatio * (yMax - yMin);
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
const css = getComputedStyle(canvas);
|
||||
const gridColor = css.getPropertyValue('--graph-grid').trim() || '#2a2f3a';
|
||||
const gridMajorColor = css.getPropertyValue('--graph-grid-major').trim() || '#4b5563';
|
||||
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) {
|
||||
// minor grid lines first (thin), then major/decade lines drawn on top so
|
||||
// they aren't obscured and read clearly as the "multiples of 10" markers.
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (const t of xTicks) {
|
||||
if (t.major) continue;
|
||||
const px = Math.round(toPxX(t.value)) + 0.5;
|
||||
ctx.moveTo(px, plot.y);
|
||||
ctx.lineTo(px, plot.y + plot.height);
|
||||
}
|
||||
for (const t of yTicks) {
|
||||
if (t.major) continue;
|
||||
const py = Math.round(toPxY(t.value)) + 0.5;
|
||||
ctx.moveTo(plot.x, py);
|
||||
ctx.lineTo(plot.x + plot.width, py);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const majorX = xTicks.filter((t) => t.major);
|
||||
const majorY = yTicks.filter((t) => t.major);
|
||||
if (majorX.length || majorY.length) {
|
||||
ctx.strokeStyle = gridMajorColor;
|
||||
ctx.lineWidth = Math.max(1, metrics.axisLineWidth * 0.6);
|
||||
ctx.beginPath();
|
||||
for (const t of majorX) {
|
||||
const px = Math.round(toPxX(t.value)) + 0.5;
|
||||
ctx.moveTo(px, plot.y);
|
||||
ctx.lineTo(px, plot.y + plot.height);
|
||||
}
|
||||
for (const t of majorY) {
|
||||
const py = Math.round(toPxY(t.value)) + 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 (!yLogValid && 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 (!xLogValid && 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.font = `${metrics.tickFont}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
for (const t of xTicks) {
|
||||
// In log mode, only label the decade (major) ticks — labeling every
|
||||
// 2,3,4...9 mantissa gets unreadably dense.
|
||||
if (xLogValid && !t.major) continue;
|
||||
const px = toPxX(t.value);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, plot.y + plot.height);
|
||||
ctx.lineTo(px, plot.y + plot.height + metrics.tickLen);
|
||||
ctx.strokeStyle = axisColor;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = t.major && xLogValid ? textColor : textColor;
|
||||
ctx.fillText(formatX(t.value), px, plot.y + plot.height + metrics.tickLen + 2);
|
||||
}
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (const t of yTicks) {
|
||||
if (yLogValid && !t.major) continue;
|
||||
const py = toPxY(t.value);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(plot.x - metrics.tickLen, py);
|
||||
ctx.lineTo(plot.x, py);
|
||||
ctx.strokeStyle = axisColor;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.fillText(formatY(t.value), 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,
|
||||
xLogValid,
|
||||
yMin,
|
||||
yMax,
|
||||
yLogValid,
|
||||
xTicks,
|
||||
yTicks,
|
||||
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;
|
||||
Reference in New Issue
Block a user