import { type Writable, get, writable, } from 'svelte/store'; /** * Model for a control value with min/max bounds */ export type ControlModel< TValue extends number = number, > = { value: TValue; min: TValue; max: TValue; step?: TValue; }; /** * Store model with methods for control manipulation */ export type ControlStoreModel< TValue extends number, > = & Writable> & { increase: () => void; decrease: () => void; /** Set a specific value */ setValue: (newValue: TValue) => void; isAtMax: () => boolean; isAtMin: () => boolean; }; /** * Create a writable store for numeric control values with bounds * * @template TValue - The value type (extends number) * @param initialState - Initial state containing value, min, and max */ /** * Get the number of decimal places in a number * * For example: * - 1 -> 0 * - 0.1 -> 1 * - 0.01 -> 2 * - 0.05 -> 2 * * @param step - The step number to analyze * @returns The number of decimal places */ function getDecimalPlaces(step: number): number { const str = step.toString(); const decimalPart = str.split('.')[1]; return decimalPart ? decimalPart.length : 0; } /** * Round a value to the precision of the given step * * This fixes floating-point precision errors that occur with decimal steps. * For example, with step=0.05, adding it repeatedly can produce values like * 1.3499999999999999 instead of 1.35. * * We use toFixed() to round to the appropriate decimal places instead of * Math.round(value / step) * step, which doesn't always work correctly * due to floating-point arithmetic errors. * * @param value - The value to round * @param step - The step to round to (defaults to 1) * @returns The rounded value */ function roundToStepPrecision(value: number, step: number = 1): number { if (step <= 0) { return value; } const decimals = getDecimalPlaces(step); return parseFloat(value.toFixed(decimals)); } export function createControlStore< TValue extends number = number, >( initialState: ControlModel, ): ControlStoreModel { const store = writable(initialState); const { subscribe, set, update } = store; const clamp = (value: number): TValue => { return Math.max(initialState.min, Math.min(value, initialState.max)) as TValue; }; return { subscribe, set, update, increase: () => update(m => { const step = m.step ?? 1; const newValue = clamp(m.value + step); return { ...m, value: roundToStepPrecision(newValue, step) as TValue }; }), decrease: () => update(m => { const step = m.step ?? 1; const newValue = clamp(m.value - step); return { ...m, value: roundToStepPrecision(newValue, step) as TValue }; }), setValue: (v: TValue) => { const step = initialState.step ?? 1; update(m => ({ ...m, value: roundToStepPrecision(clamp(v), step) as TValue })); }, isAtMin: () => get(store).value === initialState.min, isAtMax: () => get(store).value === initialState.max, }; }