import { useEffect, useMemo, useRef, useState } from 'react'; import './VerticalSlider.css'; type StepFn = (value: number) => number; type QuantizeFn = (value: number) => number; type Scale = 'linear' | 'log'; type VerticalSliderProps = { min: number; max: number; step?: StepFn; quantize?: QuantizeFn; /** * Optional discrete value set (must be sorted ascending). When provided, * this takes full precedence over `step`/`quantize`/`scale`: the slider * only ever lands on entries from this array. `min`/`max` are used to * slice out the applicable sub-range of `values` (e.g. per-filter-type * Q ranges that all share one master Q table) — both bounds should be * exact members of `values`. Thumb position and drag/keyboard/wheel * navigation are index-based rather than value-based, since spacing in * the array need not be uniform or log-uniform. */ values?: readonly number[]; value: number; onChange?: (value: number) => Promise; onInput?: (value: number) => void; unit?: string | ((value: number) => string); formatValue?: (value: number, decimals: number) => string; showTicks?: boolean; /** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. Ignored when `values` is set. */ scale?: Scale; 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; } // --- scale helpers ----------------------------------------------------- /** Guards against log(0) / log(negative); falls back to a tiny positive epsilon. */ function safeLogBound(n: number) { return n > 0 ? n : Number.EPSILON; } /** Binary search for the index of the array entry closest to v. Assumes arr is sorted ascending and non-empty. */ function closestIndex(arr: readonly number[], v: number) { let lo = 0; let hi = arr.length - 1; if (v <= arr[0]) return 0; if (v >= arr[hi]) return hi; while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid] === v) return mid; if (arr[mid] < v) lo = mid + 1; else hi = mid; } const a = arr[lo - 1]; const b = arr[lo]; return v - a <= b - v ? lo - 1 : lo; } /** Maps a real value to a 0..1 position, honoring the chosen scale (or index position within `list`). */ function valueToPercent( v: number, min: number, max: number, scale: Scale, list: readonly number[] | null ) { if (list && list.length > 1) { return closestIndex(list, v) / (list.length - 1); } if (scale === 'log') { const logMin = Math.log(safeLogBound(min)); const logMax = Math.log(safeLogBound(max)); if (logMax === logMin) return 0; const logV = Math.log(safeLogBound(v)); return (logV - logMin) / (logMax - logMin); } if (max === min) return 0; return (v - min) / (max - min); } /** Maps a 0..1 position back to a real value, honoring the chosen scale (or nearest index within `list`). */ function percentToValue( percent: number, min: number, max: number, scale: Scale, list: readonly number[] | null ) { if (list && list.length > 1) { const idx = Math.round(percent * (list.length - 1)); return list[Math.min(list.length - 1, Math.max(0, idx))]; } if (scale === 'log') { const logMin = Math.log(safeLogBound(min)); const logMax = Math.log(safeLogBound(max)); return Math.exp(logMin + percent * (logMax - logMin)); } return min + percent * (max - min); } /** * Builds a StepFn that yields roughly `points` evenly-spaced steps between * `min` and `max` on a logarithmic scale — e.g. makeLogStep(19.7, 20160, 300) * gives ~300 points across a 20 Hz - 20 kHz range, with step size growing * proportionally to the current value (fine resolution at low frequencies, * coarser at high ones — matches how the ear perceives frequency). * * This is an approximation used to size keyboard/wheel nudges — it does NOT * guarantee landing on the same exact grid a real device would produce. * Pair it with `quantize` (see makeLogQuantize) when you need the slider to * always land exactly on the device's own value set. */ export function makeLogStep(min: number, max: number, points: number): StepFn { const ratio = Math.pow(max / min, 1 / Math.max(1, points)); const stepSize = ratio - 1; return (value: number) => Math.max(min, value) * stepSize; } /** * Builds a QuantizeFn that snaps to the exact same log-spaced grid a device * using a `raw` 0..points index would produce, e.g. for a frequency raw * value stored as: * raw = round(log(Hz / min) / log(max / min) * points) * Hz = min * (max / min) ** (raw / points) * makeLogQuantize(19.7, 20160, 300) reproduces exactly that 301-point set * (raw 0..300), so the slider only ever settles on values the device itself * would encode/decode to — no drift from an approximate step size. */ export function makeLogQuantize(min: number, max: number, points: number): QuantizeFn { const logRatio = Math.log(max / min); return (value: number) => { const v = Math.max(min, Math.min(max, value)); const raw = Math.round((Math.log(v / min) / logRatio) * points); const clampedRaw = Math.min(points, Math.max(0, raw)); return min * Math.pow(max / min, clampedRaw / points); }; } export default function VerticalSlider({ min, max, step = defaultStep, quantize, values, value, onChange, onInput, unit = '', formatValue, showTicks = true, scale = 'linear', className, style, }: VerticalSliderProps) { const [editingText, setEditingText] = useState(null); const inputRef = useRef(null); const sliderRef = useRef(null); const trackRef = useRef(null); const keyboardValue = useRef(null); const [displayValue, setDisplayValue] = useState(value); const dragValue = useRef(value); const dragging = useRef(false); const thumbRef = useRef(null); const keyboardEditing = useRef(false); const wheelValue = useRef(null); const wheelEditing = useRef(false); const wheelCommitTimeout = useRef | null>(null); const [tickCount, setTickCount] = useState(MIN_TICKS); if (scale === 'log' && !values && (min <= 0 || max <= 0)) { // eslint-disable-next-line no-console console.warn( 'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.' ); } // Slice the discrete value set down to the applicable [min, max] sub-range. // Falls back to the full array if the slice is empty (e.g. misconfigured bounds). const listValues = useMemo(() => { if (!values || values.length === 0) return null; const filtered = values.filter((v) => v >= min && v <= max); return filtered.length > 0 ? filtered : values; }, [values, min, max]); useEffect(() => { if ( dragging.current || keyboardEditing.current || wheelEditing.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(() => { if (listValues) return Math.min(3, getDecimals(displayValue)); return Math.min(3, getDecimals(step(displayValue))); }, [listValues, step, displayValue]); const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals); const displayUnit = typeof unit === 'function' ? unit(displayValue) : unit; function normalizeValue(input: number) { const clamped = Math.min(max, Math.max(min, input)); if (listValues) { return listValues[closestIndex(listValues, clamped)]; } if (quantize) { return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6)); } 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) { if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { return; } keyboardEditing.current = true; e.preventDefault(); // Base off displayValue (our own last-known-good local state), not the // `value` prop: if onChange's round trip through the parent/audio engine // hasn't landed yet, `value` can still be stale, which made repeated // key presses appear to snap back to the pre-press position. const base = keyboardValue.current ?? displayValue; let next: number; if (listValues) { const idx = closestIndex(listValues, base); let nextIdx = idx; if (e.key === 'ArrowUp' || e.key === 'ArrowRight') nextIdx = Math.min(listValues.length - 1, idx + 1); if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') nextIdx = Math.max(0, idx - 1); next = listValues[nextIdx]; } else { const currentStep = step(base); 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 handleWheel(e: WheelEvent) { e.preventDefault(); wheelEditing.current = true; const base = wheelValue.current ?? displayValue; // deltaY < 0 means scrolling up/away from the user — treat that as // increasing the value, matching the vertical slider's "up = more". const direction = e.deltaY < 0 ? 1 : -1; let next: number; if (listValues) { const idx = closestIndex(listValues, base); const nextIdx = Math.min(listValues.length - 1, Math.max(0, idx + direction)); next = listValues[nextIdx]; } else { const currentStep = step(base); next = normalizeValue(base + direction * currentStep); } wheelValue.current = next; updateValue(next); if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current); wheelCommitTimeout.current = setTimeout(() => { void commitWheel(); }, 250); } async function commitWheel() { if (wheelValue.current == null) return; const next = wheelValue.current; const success = await onChange?.(next); wheelEditing.current = false; wheelValue.current = null; if (success === false) { setDisplayValue(value); dragValue.current = value; onInput?.(value); } } useEffect(() => { const el = sliderRef.current; if (!el) return; // React attaches onWheel passively by default, so preventDefault() // wouldn't stop the page from scrolling underneath the slider — attach // natively with { passive: false } instead. el.addEventListener('wheel', handleWheel, { passive: false }); return () => el.removeEventListener('wheel', handleWheel); }); useEffect(() => { return () => { if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current); }; }, []); function valueFromPointer(clientY: number) { if (!sliderRef.current) return; const rect = sliderRef.current.getBoundingClientRect(); const percent = 1 - (clientY - rect.top) / rect.height; const raw = percentToValue(percent, min, max, scale, listValues); updateValue(raw); } function startDrag(e: React.PointerEvent) { const target = e.currentTarget; dragging.current = true; target.setPointerCapture(e.pointerId); valueFromPointer(e.clientY); } function handlePointerMove(e: React.PointerEvent) { if (!dragging.current) return; valueFromPointer(e.clientY); } async function endDrag(e: React.PointerEvent) { if (!dragging.current) return; const target = e.currentTarget; const pointerId = e.pointerId; const finalValue = dragValue.current; const success = await onChange?.(finalValue); dragging.current = false; if (success === false) { setDisplayValue(value); dragValue.current = value; onInput?.(value); } if (target.hasPointerCapture(pointerId)) { target.releasePointerCapture(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 = valueToPercent(displayValue, min, max, scale, listValues); return (
{showTicks && (
{Array.from({ length: tickCount }).map((_, i) => ( ))}
)}
{showTicks && (
{Array.from({ length: tickCount }).map((_, i) => ( ))}
)}
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(); } }} /> {displayUnit && {' ' + displayUnit}}
); }