feat: add keep alive

This commit is contained in:
2026-07-26 17:56:25 +02:00
parent a157b5f198
commit 31f645ebb2
21 changed files with 1205 additions and 261 deletions
@@ -39,5 +39,5 @@
flex-direction: row;
align-items: stretch;
height: 100%;
gap: var(--space-4);
gap: var(--space-2);
}
@@ -1,11 +1,10 @@
.gain-section {
position: absolute; /* was: fixed — now relative to .dsp-content, which is the fixed+inset parent */
left: 0; /* was: var(--sidebar-width-collapsed) — .dsp-content already excludes the sidebar */
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--gain-section-height);
min-height: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
@@ -18,65 +17,77 @@
z-index: 20;
border-top: 1px solid var(--border-hairline);
border-top: var(--border-width) solid var(--border-hairline);
}
.gain-panels {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: safe center; /* centers when it fits, falls back to start-aligned scroll when it overflows */
gap: var(--space-4);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
gap: var(--space-4);
overflow-x: auto;
overflow-y: hidden;
}
.gain-panel-frame {
flex: 0 0 auto;
height: calc(100% - 8px);
aspect-ratio: var(--panel-w) / var(--panel-h); /* now driven by JS, not duplicated */
margin: 4px 0;
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-panels::-webkit-scrollbar {
height: 6px;
}
.gain-panels::-webkit-scrollbar-thumb {
background: var(--border-hairline);
border-radius: var(--radius-pill);
}
.gain-panels::-webkit-scrollbar-track {
background: transparent;
}
.gain-panel {
/* width/height now set inline from DESIGN_WIDTH/HEIGHT, drop the fixed px here */
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: 1px solid var(--border-hairline);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
padding: 8px;
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: 12px;
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);
}
padding-bottom: 6px;
.channel-group__label {
font-size: calc(var(--tab-close-font) * var(--panel-scale, 1));
}
border-bottom: 1px solid var(--border-hairline);
/* --- 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 */
}
@@ -3,13 +3,37 @@ import { invoke } from '@tauri-apps/api/core';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import { NotificationType } from '../../../types/types';
import { useState } from 'react';
import Button from '../../Button/Button';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import { useEffect, useRef, useState } from 'react';
import './GainSection.css';
const DESIGN_WIDTH = 90;
const DESIGN_HEIGHT = 380;
const PANEL_DESIGN_WIDTH = 90;
const PANEL_DESIGN_HEIGHT = 400;
function ChannelGroupScaled({ title, children }: { title: string; children: React.ReactNode }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const { height } = entries[0].contentRect;
if (height === 0) return;
setScale(height / PANEL_DESIGN_HEIGHT); // height is the only free axis here
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div ref={wrapperRef} style={{ height: '100%', '--panel-scale': scale } as React.CSSProperties}>
<ChannelGroup title={title}>{children}</ChannelGroup>
</div>
);
}
function GainPanel({
dsp,
@@ -78,6 +102,8 @@ function GainPanel({
const [muted, setMuted] = useState(false);
const [inverted, setInverted] = useState(false);
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const toggleMute = async () => {
const next = !muted;
@@ -91,9 +117,28 @@ function GainPanel({
await setInverse(next);
};
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width === 0 || height === 0) return;
setScale(Math.min(width / PANEL_DESIGN_WIDTH, height / PANEL_DESIGN_HEIGHT));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div className="gain-panel-frame">
<div className="gain-panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}>
<div className="gain-panel-viewport" ref={viewportRef}>
<div
className="gain-panel"
style={{
width: PANEL_DESIGN_WIDTH,
height: PANEL_DESIGN_HEIGHT,
transform: `translate(-50%, -50%) scale(${scale})`,
}}
>
<div className="gain-panel__title">{title}</div>
<VerticalSlider
@@ -104,12 +149,12 @@ function GainPanel({
switchStep={0.1}
unit=" dB"
onChange={setGain}
style={{ width: 70, height: 260, flex: '0 0 auto' }} // pin to design size, disable its own auto-shrink
/>
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute}>
{muted ? 'Muted' : 'Mute'}
</Button>
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
@@ -148,21 +193,48 @@ function GainSection({
];
return (
<div className="gain-section">
<div className="gain-panels">
<ChannelGroup title="Input">
{inputs.map(({ channel, title }) => (
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} />
))}
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} />
))}
</ChannelGroup>
<>
<div className="linear-graph-section">
<LinearGraph
xMin={20}
xMax={20000}
yMin={-24}
yMax={24}
yStep={6}
xLabel="Hz"
yLabel="dB"
draw={({ ctx, toPx }) => {
// placeholder flat 0 dB response — swap for real EQ/filter data.
// NOTE: this maps x linearly; for a real frequency response you'll
// likely want a log-frequency x-axis instead (see chat notes).
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0);
const [x1, y1] = toPx(20000, 0);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}}
/>
</div>
</div>
<div className="gain-section">
<div className="gain-panels">
<ChannelGroup title="Input">
{inputs.map(({ channel, title }) => (
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} />
))}
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} />
))}
</ChannelGroup>
</div>
</div>
</>
);
}
@@ -1,118 +1,4 @@
.gate-section {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--gain-section-height);
min-height: 0;
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: 1px solid var(--border-hairline);
}
.gate-panels {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: safe center;
gap: var(--space-4);
width: 100%;
.rerere {
height: 100%;
min-width: 0;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
}
.gate-panel-frame {
flex: 0 0 auto;
height: calc(100% - 8px);
aspect-ratio: var(--panel-w) / var(--panel-h);
margin: 4px 0;
box-sizing: border-box;
}
.gate-panels::-webkit-scrollbar {
height: 6px;
}
.gate-panels::-webkit-scrollbar-thumb {
background: var(--border-hairline);
border-radius: var(--radius-pill);
}
.gate-panels::-webkit-scrollbar-track {
background: transparent;
}
.gate-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
padding: 8px;
background: var(--bg-void);
}
.gate-panel__title {
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 12px;
font-weight: var(--font-weight-semibold);
padding-bottom: 6px;
border-bottom: 1px solid var(--border-hairline);
}
.gate-panel__sliders {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: center;
gap: var(--space-2);
width: 100%;
flex: 1;
min-height: 0;
}
.gate-panel__slider-col {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 0;
min-width: 0;
}
.gate-panel__slider-label {
font-family: var(--font-ui);
font-size: 10px;
font-weight: var(--font-weight-medium);
text-align: center;
padding-top: 4px;
color: var(--text-secondary);
}
/* GateSection.css */
.gate-panels .channel-group__items {
display: grid;
grid-template-columns: repeat(2, auto);
grid-template-rows: repeat(2, 1fr);
gap: var(--space-2);
align-items: stretch;
width: 10%;
}
@@ -0,0 +1,35 @@
import VerticalSlider from '../Slider/VerticalSlider';
import { invoke } from '@tauri-apps/api/core';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import { NotificationType } from '../../../types/types';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import './GateSection.css';
function GateSection({
dsp,
notify,
}: {
dsp: DSP;
notify: (message: string, type?: NotificationType, dspName?: string) => void;
}) {
console.log(dsp, notify);
return (
<div className="rerere">
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
onChange={(_f: number) => {
return;
}}
/>
</div>
);
}
export default GateSection;
@@ -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;
+30 -29
View File
@@ -1,25 +1,27 @@
.gauge-viewport {
container-type: inline-size;
container-name: gauge;
position: relative;
width: 100%;
flex: 1 1 0;
min-height: 0;
width: auto;
aspect-ratio: 70 / 260;
box-sizing: border-box;
overflow: hidden;
}
.gauge-container {
width: 100%;
height: 100%;
position: absolute;
top: 50%;
left: 50%;
padding-top: 28.571cqw; /* 20px */
padding-bottom: 14.286cqw; /* 10px */
width: 70px;
height: 240px;
display: flex;
flex-direction: column;
align-items: center;
transform-origin: top center;
transform-origin: center center;
box-sizing: border-box;
}
@@ -27,23 +29,21 @@
.gauge-track {
position: relative;
width: 100%;
height: 300cqw; /* 210px */
width: 70px;
height: 210px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 11.429cqw; /* 8px */
box-sizing: border-box;
margin-bottom: 8px;
}
.slider {
position: relative;
width: 34.286cqw; /* 24px */
width: 24px;
height: 100%;
cursor: pointer;
@@ -60,7 +60,7 @@
transform: translateX(-50%);
width: 2.857cqw; /* 2px */
width: 2px;
height: 100%;
@@ -74,7 +74,7 @@
left: 50%;
width: 25.714cqw; /* 18px */
width: 18px;
aspect-ratio: 1;
@@ -106,9 +106,9 @@
}
.ticks span {
width: 14.286cqw; /* 10px */
width: 10px;
height: 2.857cqw; /* 2px */
height: 2px;
background: var(--border-hairline);
@@ -116,22 +116,22 @@
}
.left {
left: 11.429cqw; /* 8px */
left: 8px;
}
.right {
right: 11.429cqw; /* 8px */
right: 8px;
}
.value {
width: 100%;
margin-top: 5.714cqw; /* 4px */
margin-top: 4px;
padding-top: 2.857cqw; /* 2px */
padding-bottom: 2.857cqw; /* 2px */
padding-right: 5.714cqw; /* 4px */
padding-left: 5.714cqw; /* 4px */
padding-top: 2px;
padding-bottom: 2px;
padding-right: 4px;
padding-left: 4px;
box-sizing: border-box;
@@ -146,9 +146,10 @@
color: var(--accent-brand);
font-size: 22.857cqw;
font-size: var(--font-xxs);
font-family: var(--font-ui);
font-family: var(--font-mono);
font-weight: var(--font-weight-medium);
box-shadow: var(--shadow-sm);
@@ -167,7 +168,7 @@
background: transparent;
border: 1px solid var(--border-hairline);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
+29 -28
View File
@@ -2,12 +2,8 @@ import { useMemo, useRef, useState, useEffect } from 'react';
import './VerticalSlider.css';
// Design size 260*70 (height*width) — kept as the reference ratio.
// All CSS dimensions are expressed in container-query width units (cqw)
// relative to this DESIGN_WIDTH, so the whole component scales together
// and preserves this aspect ratio at any rendered size.
const DESIGN_WIDTH = 70;
const DESIGN_HEIGHT = 240;
type VerticalSliderProps = {
min: number;
@@ -59,6 +55,24 @@ export default function VerticalSlider({
const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false);
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
const { width, height } = entry.contentRect;
if (width === 0 || height === 0) return;
setScale(Math.min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (keyboardEditing.current) return;
@@ -76,26 +90,6 @@ export default function VerticalSlider({
[step, switchStep]
);
const maxChars = useMemo(() => {
const minStr = min.toFixed(decimals);
const maxStr = max.toFixed(decimals);
return Math.max(minStr.length, maxStr.length) + unit.length;
}, [min, max, decimals, unit]);
// Computed in "design pixels" (i.e. assuming the component is rendered at
// DESIGN_WIDTH px wide), then converted below to cqw so it scales with
// the actual rendered size instead of staying a fixed pixel value.
const valueFontSize = useMemo(() => {
const availableWidth = DESIGN_WIDTH - 8;
const AVG_CHAR_WIDTH_RATIO = 0.6;
const sizeByWidth = availableWidth / (maxChars * AVG_CHAR_WIDTH_RATIO);
return Math.min(16, Math.max(8, sizeByWidth));
}, [maxChars]);
// Convert the "design px" font size into cqw units relative to the
// component's own width, so it scales proportionally with everything else.
const valueFontSizeCqw = useMemo(() => (valueFontSize / DESIGN_WIDTH) * 100, [valueFontSize]);
function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input));
const snapped = Math.round(clamped / currentStep) * currentStep;
@@ -202,8 +196,15 @@ export default function VerticalSlider({
const percent = (value - min) / (max - min);
return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
<div className="gauge-container">
<div
className={['gauge-viewport', className].filter(Boolean).join(' ')}
style={style}
ref={viewportRef}
>
<div
className="gauge-container"
style={{ transform: `translate(-50%, -50%) scale(${scale})` }}
>
<div className="gauge-track">
<div className="ticks left">
{Array.from({ length: 15 }).map((_, i) => (
@@ -233,7 +234,7 @@ export default function VerticalSlider({
</div>
</div>
<div className="value" style={{ fontSize: `${valueFontSizeCqw}cqw` }}>
<div className="value">
<input
ref={inputRef}
type="text"