// Pure math helpers for ParamSlider input handling. Extracted so the
// modifier-key behaviour (Shift = fine, Alt = coarse) can be tested
// without a DOM. The component glues these to pointer / wheel / key
// events.
export interface Range {
readonly min: number;
readonly max: number;
readonly step?: number;
}
export type DragMode = 'absolute' | 'fine' | 'coarse';
/** Shift = fine (10× slower), Alt = coarse (4× faster). Plain = absolute. */
export function dragModeFrom(e: { shiftKey: boolean; altKey: boolean }): DragMode {
if (e.shiftKey) return 'fine';
if (e.altKey) return 'coarse';
return 'absolute';
}
/** Snap to step if present and >0. Otherwise pass through. */
export function snapToStep(v: number, step: number | undefined): number {
if (step && step > 0) return Math.round(v / step) * step;
return v;
}
export function clampToRange(v: number, range: Range): number {
return Math.max(range.min, Math.min(range.max, v));
}
/** Maps a pointer x-coordinate inside the track to the absolute value. */
export function valueFromAbsolute(
clientX: number,
rectLeft: number,
rectWidth: number,
range: Range,
): number {
if (rectWidth <= 0) return range.min;
const ratio = Math.max(0, Math.min(1, (clientX - rectLeft) / rectWidth));
const raw = range.min + ratio * (range.max - range.min);
return clampToRange(snapToStep(raw, range.step), range);
}
/**
* Incremental drag mode: the value moves relative to where the pointer
* was when the drag started. Shift = ×0.1, Alt = ×4. The "base rate" is
* the same as absolute mode (track width covers the full range), so a
* full drag across the track equals a full sweep in default mode, 0.1
* sweeps in fine mode, and 4 sweeps in coarse mode.
*/
export function valueFromIncremental(args: {
clientX: number;
startX: number;
startValue: number;
rectWidth: number;
mode: 'fine' | 'coarse';
range: Range;
}): number {
if (args.rectWidth <= 0) return clampToRange(args.startValue, args.range);
const baseRate = (args.range.max - args.range.min) / args.rectWidth;
const mult = args.mode === 'fine' ? 0.1 : 4;
const delta = (args.clientX - args.startX) * baseRate * mult;
return clampToRange(snapToStep(args.startValue + delta, args.range.step), args.range);
}
/** Wheel: 1 step at default, 0.1× with Shift (fine), 10× with Alt (coarse). */
export function valueFromWheel(
current: number,
deltaY: number,
modifier: { shiftKey: boolean; altKey: boolean },
range: Range,
): number {
const step = range.step ?? (range.max - range.min) / 100;
const mult = modifier.shiftKey ? 0.1 : modifier.altKey ? 10 : 1;
// Wheel up (negative deltaY) = increase value, the way browser sliders behave.
const delta = -Math.sign(deltaY) * step * mult;
return clampToRange(snapToStep(current + delta, range.step), range);
}
/**
* Keyboard arrows / Home / End. Returns the new value or `null` if the
* key isn't a slider control. Caller is responsible for preventDefault.
*/
export function valueFromKey(
current: number,
key: string,
modifier: { shiftKey: boolean; altKey: boolean },
range: Range,
): number | null {
if (key === 'Home') return clampToRange(range.min, range);
if (key === 'End') return clampToRange(range.max, range);
const step = range.step ?? (range.max - range.min) / 100;
const mult = modifier.shiftKey ? 0.1 : modifier.altKey ? 10 : 1;
let dir = 0;
if (key === 'ArrowLeft' || key === 'ArrowDown') dir = -1;
else if (key === 'ArrowRight' || key === 'ArrowUp') dir = 1;
else return null;
return clampToRange(snapToStep(current + dir * step * mult, range.step), range);
}