feat: add keep alive
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
.gain-section {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
height: var(--dsp-control-heigth);
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--space-4);
|
||||
|
||||
background: var(--bg-panel);
|
||||
|
||||
z-index: 20;
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
}
|
||||
|
||||
.gain-panels {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.gain-panel-frame {
|
||||
flex: 0 0 auto;
|
||||
height: calc(100% - 2 * var(--space-1));
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.gain-panel-viewport {
|
||||
position: relative;
|
||||
aspect-ratio: 90 / 400;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gain-panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center center;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
box-sizing: border-box;
|
||||
gap: var(--space-2);
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.gain-panel__title {
|
||||
flex: 0 0 auto; /* NEW: never let this shrink */
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding-top: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: var(--border-width) solid var(--border-hairline);
|
||||
}
|
||||
|
||||
.channel-group__label {
|
||||
font-size: calc(var(--tab-close-font) * var(--panel-scale, 1));
|
||||
}
|
||||
|
||||
/* --- graph strip, sits above the gain section ---
|
||||
Requires the shared parent (whatever renders <GainSection />) to be
|
||||
position: relative — it already must be, for .gain-section's absolute
|
||||
bottom:0 to anchor correctly. */
|
||||
.linear-graph-section {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--dsp-control-heigth);
|
||||
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-4);
|
||||
|
||||
background: var(--bg-panel);
|
||||
z-index: 10; /* stays below .gain-section's z-index: 20 */
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
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
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(plot.x, plot.y, plot.width, plot.height);
|
||||
ctx.clip();
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user