import { clamp } from "./clamp"; import { getPushedThumbValues } from "./get-pushed-thumb-values"; /** * Strategy for how a moving thumb interacts with its neighbours. * * - `"none"`: thumbs cannot cross; the pressed thumb is clamped at its * neighbour's boundary. * - `"push"`: the pressed thumb pushes its neighbours along. * - `"swap"`: thumbs swap identities when they cross. */ export type ThumbCollisionBehavior = "none" | "push" | "swap"; export interface ResolveThumbCollisionParams { behavior: ThumbCollisionBehavior; values: readonly number[]; currentValues?: readonly number[] | null | undefined; initialValues?: readonly number[] | null | undefined; pressedIndex: number; nextValue: number; min: number; max: number; step: number; minStepsBetweenValues: number; } export interface ResolveThumbCollisionResult { value: number | number[]; thumbIndex: number; didSwap: boolean; } /** * Applies the chosen collision `behavior` to compute the new thumb values * when the thumb at `pressedIndex` attempts to move to `nextValue`. Returns * both the updated values and the resolved thumb index (swap may change it). */ export function resolveThumbCollision({ behavior, values, currentValues, initialValues, pressedIndex, nextValue, min, max, step, minStepsBetweenValues, }: ResolveThumbCollisionParams): ResolveThumbCollisionResult { const activeValues = currentValues ?? values; const baselineValues = initialValues ?? values; const range = activeValues.length > 1; if (!range) { return { value: nextValue, thumbIndex: 0, didSwap: false, }; } const minValueDifference = step * minStepsBetweenValues; switch (behavior) { case "swap": { const pressedInitialValue = activeValues[pressedIndex]; const epsilon = 1e-7; const candidateValues = activeValues.slice(); const previousNeighbor = candidateValues[pressedIndex - 1]; const nextNeighbor = candidateValues[pressedIndex + 1]; const lowerBound = previousNeighbor != null ? previousNeighbor + minValueDifference : min; const upperBound = nextNeighbor != null ? nextNeighbor - minValueDifference : max; const constrainedValue = clamp(nextValue, lowerBound, upperBound); const pressedValueAfterClamp = Number(constrainedValue.toFixed(12)); candidateValues[pressedIndex] = pressedValueAfterClamp; const movingForward = pressedInitialValue != null && nextValue > pressedInitialValue; const movingBackward = pressedInitialValue != null && nextValue < pressedInitialValue; const shouldSwapForward = movingForward && nextNeighbor != null && nextValue >= nextNeighbor - epsilon; const shouldSwapBackward = movingBackward && previousNeighbor != null && nextValue <= previousNeighbor + epsilon; if (!shouldSwapForward && !shouldSwapBackward) { return { value: candidateValues, thumbIndex: pressedIndex, didSwap: false, }; } const targetIndex = shouldSwapForward ? pressedIndex + 1 : pressedIndex - 1; const initialValuesForPush = candidateValues.map((_, index) => { if (index === pressedIndex) { return pressedValueAfterClamp; } const baseline = baselineValues[index]; if (baseline != null) { return baseline; } // biome-ignore lint/style/noNonNullAssertion: index is within activeValues bounds return activeValues[index]!; }); let nextValueForTarget = nextValue; if (shouldSwapForward) { // biome-ignore lint/style/noNonNullAssertion: targetIndex = pressedIndex + 1 is in range for swap-forward nextValueForTarget = Math.max(nextValue, candidateValues[targetIndex]!); } else { // biome-ignore lint/style/noNonNullAssertion: targetIndex = pressedIndex - 1 is in range for swap-backward nextValueForTarget = Math.min(nextValue, candidateValues[targetIndex]!); } const adjustedValues = getPushedThumbValues({ values: candidateValues, index: targetIndex, nextValue: nextValueForTarget, min, max, step, minStepsBetweenValues, initialValues: initialValuesForPush, }); const neighborIndex = shouldSwapForward ? targetIndex - 1 : targetIndex + 1; if (neighborIndex >= 0 && neighborIndex < adjustedValues.length) { const previousValue = adjustedValues[neighborIndex - 1]; const nextValueAfter = adjustedValues[neighborIndex + 1]; let neighborLowerBound = previousValue != null ? previousValue + minValueDifference : min; neighborLowerBound = Math.max( neighborLowerBound, min + neighborIndex * minValueDifference, ); let neighborUpperBound = nextValueAfter != null ? nextValueAfter - minValueDifference : max; neighborUpperBound = Math.min( neighborUpperBound, max - (adjustedValues.length - 1 - neighborIndex) * minValueDifference, ); const restoredValue = clamp( pressedValueAfterClamp, neighborLowerBound, neighborUpperBound, ); adjustedValues[neighborIndex] = Number(restoredValue.toFixed(12)); } return { value: adjustedValues, thumbIndex: targetIndex, didSwap: true, }; } case "push": { const nextValues = getPushedThumbValues({ values: activeValues, index: pressedIndex, nextValue, min, max, step, minStepsBetweenValues, }); return { value: nextValues, thumbIndex: pressedIndex, didSwap: false, }; } default: { const candidateValues = activeValues.slice(); const previousNeighbor = candidateValues[pressedIndex - 1]; const nextNeighbor = candidateValues[pressedIndex + 1]; const lowerBound = previousNeighbor != null ? previousNeighbor + minValueDifference : min; const upperBound = nextNeighbor != null ? nextNeighbor - minValueDifference : max; const constrainedValue = clamp(nextValue, lowerBound, upperBound); candidateValues[pressedIndex] = Number(constrainedValue.toFixed(12)); return { value: candidateValues, thumbIndex: pressedIndex, didSwap: false, }; } } }