feat: add demo mode

This commit is contained in:
2026-08-11 22:02:46 +02:00
parent b572e1c794
commit d91bcaa20f
28 changed files with 3904 additions and 1876 deletions
@@ -0,0 +1,147 @@
.gauge-viewport {
position: relative;
flex: 1 1 0;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.gauge-track {
position: relative;
flex: 1 1 auto;
min-height: 0;
width: 100%;
display: flex;
align-items: stretch;
justify-content: center;
gap: 1.2cqh;
padding-bottom: 1.5cqh;
padding-top: 1.2cqh;
}
.gauge-track.no-ticks {
padding-left: calc(2.75cqh);
padding-right: calc(2.75cqh);
}
.ticks {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
pointer-events: none;
}
.ticks span {
width: 2.8cqh;
height: 0.55cqh;
min-height: 1px;
background: var(--border-hairline);
border-radius: 999px;
}
.slider {
position: relative;
flex: 0 0 auto;
width: 6.5cqh;
height: 100%;
cursor: pointer;
touch-action: none;
user-select: none;
}
.track {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
width: 0.55cqh;
min-width: 1px;
height: 100%;
background: var(--border-hairline);
border-radius: 999px;
}
.thumb {
position: absolute;
left: 50%;
width: 5cqh;
aspect-ratio: 1;
transform: translate(-50%, 50%);
border-radius: 50%;
background: var(--bg-panel);
border: 0.55cqh solid var(--accent-brand);
box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
}
.value {
flex: 0 0 auto;
width: 100%;
margin-top: 0.8cqh;
padding: 0.5cqh 1cqh;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
gap: 0.25em;
white-space: nowrap;
color: var(--accent-brand);
font-size: 3.2cqh;
font-family: var(--font-mono);
font-weight: var(--font-weight-medium);
box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
border-radius: 1.6cqh;
overflow: hidden;
}
.value.no-ticks {
padding-left: 0;
padding-right: 0;
}
.value input {
width: auto;
min-width: 0;
height: 1.4em;
text-align: center;
background: transparent;
border: 0.3cqh solid var(--border-hairline);
border-radius: 1.2cqh;
appearance: none;
}
.value input,
.value-unit {
font-size: inherit;
line-height: 1;
font-family: inherit;
color: inherit;
}
@@ -0,0 +1,274 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import './VerticalSlider.css';
type StepFn = (value: number) => number;
type VerticalSliderProps = {
min: number;
max: number;
step?: StepFn;
value: number;
onChange?: (value: number) => Promise<boolean>;
onInput?: (value: number) => void;
unit?: string;
formatValue?: (value: number, decimals: number) => string;
showTicks?: boolean;
className?: string;
style?: React.CSSProperties;
};
const defaultStep: StepFn = () => 0.1;
function formatValueDefault(v: number, decimals: number) {
return v.toFixed(decimals);
}
const MIN_TICKS = 3;
const MAX_TICK_SPACING_PX = 16;
function getDecimals(n: number) {
const s = n.toString();
const i = s.indexOf('.');
return i === -1 ? 0 : s.length - i - 1;
}
export default function VerticalSlider({
min,
max,
step = defaultStep,
value,
onChange,
onInput,
unit = '',
formatValue,
showTicks = true,
className,
style,
}: VerticalSliderProps) {
const [editingText, setEditingText] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const keyboardValue = useRef<number | null>(null);
const [displayValue, setDisplayValue] = useState(value);
const dragValue = useRef(value);
const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false);
const [tickCount, setTickCount] = useState(MIN_TICKS);
useEffect(() => {
if (dragging.current || keyboardEditing.current || editingText !== null) {
return;
}
setDisplayValue(value);
dragValue.current = value;
}, [value]);
useEffect(() => {
const el = trackRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const recompute = () => {
const h = el.clientHeight;
if (!h) return;
const n = Math.max(MIN_TICKS, Math.ceil(h / MAX_TICK_SPACING_PX) + 1);
setTickCount(n);
};
recompute();
const observer = new ResizeObserver(recompute);
observer.observe(el);
return () => observer.disconnect();
}, []);
const decimals = useMemo(
() => Math.min(3, getDecimals(step(displayValue))),
[step, displayValue]
);
const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals);
function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input));
const currentStep = step(clamped);
const snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
}
function updateValue(input: number) {
const next = normalizeValue(input);
dragValue.current = next;
setDisplayValue(next);
onInput?.(next);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
return;
}
keyboardEditing.current = true;
e.preventDefault();
const base = keyboardValue.current ?? value;
const currentStep = step(base);
let next = base;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
next = normalizeValue(next);
keyboardValue.current = next;
updateValue(next);
}
async function handleKeyUp() {
if (keyboardValue.current == null) return;
const next = keyboardValue.current;
const success = await onChange?.(next);
keyboardEditing.current = false;
keyboardValue.current = null;
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
}
function valueFromPointer(clientY: number) {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const percent = 1 - (clientY - rect.top) / rect.height;
const raw = min + percent * (max - min);
updateValue(raw);
}
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
const target = e.currentTarget;
dragging.current = true;
target.setPointerCapture(e.pointerId);
valueFromPointer(e.clientY);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return;
valueFromPointer(e.clientY);
}
async function endDrag(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return;
const finalValue = dragValue.current;
const success = await onChange?.(finalValue);
dragging.current = false;
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
async function commitEditing() {
if (editingText !== null && editingText !== '' && editingText !== '-') {
const parsed = Number(editingText);
if (!Number.isNaN(parsed)) {
const next = normalizeValue(parsed);
setDisplayValue(next);
onInput?.(next);
const success = await onChange?.(next);
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
}
}
setEditingText(null);
}
const percent = (displayValue - min) / (max - min);
return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
<div
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
ref={trackRef}
>
{showTicks && (
<div className="ticks left">
{Array.from({ length: tickCount }).map((_, i) => (
<span key={i} />
))}
</div>
)}
<div
className="slider"
ref={sliderRef}
tabIndex={0}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPointerDown={startDrag}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="track" />
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
</div>
{showTicks && (
<div className="ticks right">
{Array.from({ length: tickCount }).map((_, i) => (
<span key={i} />
))}
</div>
)}
</div>
<div className={['value', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}>
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={editingText ?? formattedValue}
style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
onChange={(e) => setEditingText(e.target.value)}
onBlur={commitEditing}
onKeyDown={(e) => {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setEditingText(null);
inputRef.current?.blur();
}
}}
/>
{unit && <span className="value-unit">{unit}</span>}
</div>
</div>
);
}