import {h} from 'preact'; import { alignValue, clampValue, clampValueToSurroundingHandles, isValueOutOfRange, } from '../utils'; import AbstractSlider, { AbstractSliderProps, AbstractSliderState, SliderMarks, } from './AbstractSlider'; import Handle from './Handle'; import Track from './Track'; /** * Component Properties. */ export interface MultiSliderProps extends AbstractSliderProps { /** Initial values of handles */ defaultValue: number[]; /** Current values of handles */ value: number[]; /** How many ranges to render (handles count = count + 1) */ count: number; /** Allow pushing of surrounding handles when moving with this distance */ pushable: boolean | number; /** Allow handles to cross each other? */ allowCross: boolean; /** Triggered before value is start to change (on mouse down, etc) */ onBeforeChange( value: number[] ): void; /** Triggered while the value of Slider changing */ onChange( value: number[] ): void; /** Triggered after slider changes stop (on mouse up, etc) */ onAfterChange( value: number[] ): void; } /** * Component State. */ export interface MultiSliderState extends AbstractSliderState { handle: number | null; recent: number; bounds: number[]; } /** * Properties with default values. */ export interface MultiSliderDefaultProps extends AbstractSliderProps { count: number; pushable: boolean | number; allowCross: boolean; } /** * Cache of slider points. */ interface PointsCache { marks: SliderMarks; step: number; points: number[]; } /** * Slider with multiple handles. */ class MultiSlider extends AbstractSlider, MultiSliderState> { /** * Default property values. */ public static defaultProps: Readonly = { ...AbstractSlider.defaultProps, count: 1, allowCross: true, pushable: false, }; /** * Cache of slider points. */ private pointsCache: PointsCache; /** * When component recieve properties. */ public static getDerivedStateFromProps = ( nextProps: P, prevState: S, ): Partial => { if ( !( ( 'value' in nextProps ) || ( 'min' in nextProps ) || ( 'max' in nextProps ) ) ) { return {}; } const props = nextProps as unknown as MultiSliderProps const state = prevState as unknown as MultiSliderState; const {bounds} = state; const value = props.value || bounds; const nextBounds = value.map( ( singleValue ) => alignValue( clampValueToSurroundingHandles( clampValue( singleValue, props ), props, state, ), props, ) , ); if ( ( nextBounds.length === bounds.length ) && nextBounds.every( ( singleValue, index ) => ( singleValue === bounds[index] ), ) ) { return {}; } if ( bounds.some( ( singleValue ) => isValueOutOfRange( singleValue, props ), ) ) { props.onChange( nextBounds ); } return {bounds: nextBounds} as unknown as Partial; } /** * Slider with multiple handles. */ public constructor( props: MultiSliderProps ) { super( props ); const {count, min, max} = props; const initialValue = [...Array( count + 1 )].map( () => min ); const values = ( ( props.value != null ) ? props.value : ( ( props.defaultValue != null ) ? props.defaultValue : initialValue ) ); const bounds = values.map( ( value ) => this.clampAlignValue( value ) ); const recent = ( ( bounds[0] === max ) ? 0 : bounds.length - 1 ); this.state = { handle: null, recent, bounds, dragging: false, value: 0, toolTipDisplay: false, toolTipValue: 0, }; } /** * Render component. */ public render( { min, max, vertical, included, disabled, classesPrefix, }: MultiSliderProps, {handle, bounds}: MultiSliderState, ): JSX.Element { const offsets = bounds.map( ( value ) => this.calcOffset( value ), ); const handles = bounds.map( ( value, index ) => ( this.saveHandle( component, index )} key={`handle-${index}`} /> ), ); const tracks = bounds.slice( 0, -1 ).map( ( _value, index ) => { const nextIndex = index + 1; return ( ); }, ); return this.renderBase( tracks, handles ); } /** * Get current value. */ protected getValue(): number[] { return this.state.bounds; } /** * Get lower bound of current interval. */ protected getLowerBound(): number { return this.state.bounds[0]; } /** * Get upper bound of current interval. */ protected getUpperBound(): number { const {bounds} = this.state; return bounds[bounds.length - 1]; } /** * When value changed. */ protected onChange( state: Pick, ): void { const props = this.props as MultiSliderProps; const isNotControlled = !('value' in props); const hasHandle = ( s: Partial, ): s is Pick => typeof s.handle !== 'undefined'; if ( isNotControlled ) { this.setState( state ); } else if ( hasHandle(state) ) { this.setState( {handle: state.handle} ); } const data = {...this.state, ...(state as any as MultiSliderState)}; props.onChange( data.bounds ); } /** * On mouse/touch start. */ protected onStart( position: number ): void { const props = this.props as MultiSliderProps; const state = this.state; const bounds = this.getValue(); props.onBeforeChange( bounds ); const value = this.calcValueByPos( position ); const closestBound = this.getClosestBound( value ); const boundNeedMoving = this.getBoundNeedMoving( value, closestBound ); this.setState( { handle: boundNeedMoving, recent: boundNeedMoving, }, ); const prevValue = bounds[boundNeedMoving]; if ( value === prevValue ) { return; } const nextBounds = [...state.bounds]; nextBounds[boundNeedMoving] = value; this.onChange( {bounds: nextBounds} ); } protected onHover( position: number ): void { const value = this.calcValueByPos( position ); this.setState({ toolTipValue: value, toolTipDisplay: false, }); } /** * On mouse/touch move. */ protected onMove( position: number ): void { const props = this.props as MultiSliderProps; const state = this.state; if ( state.handle == null ) { return; } const value = this.calcValueByPos( position ); const oldValue = state.bounds[state.handle]; if ( value === oldValue ) { return; } const nextBounds = [...state.bounds]; nextBounds[state.handle] = value; let nextHandle = state.handle; if ( props.pushable !== false ) { const originalValue = state.bounds[nextHandle]; this.pushSurroundingHandles( nextBounds, nextHandle, originalValue ); } else if ( props.allowCross ) { nextBounds.sort( (a, b) => a - b ); nextHandle = nextBounds.indexOf( value ); } this.onChange( { handle: nextHandle, bounds: nextBounds, }, ); } /** * On mouse/touch end. */ protected onEnd(): void { this.setState( {handle: null} ); } /** * Clamp current value to min-max interval on align to available values * using step and marks. */ protected clampAlignValue( value: number, nextProps: Partial = {}, ): number { const mergedProps = {...this.props, ...nextProps} as MultiSliderProps; return alignValue( this.clampValueToSurroundingHandles( clampValue( value, mergedProps ), mergedProps, ), mergedProps, ); } /** * Get index of closes handle. */ private getClosestBound( value: number ): number { const {bounds} = this.state; let closestBound: number = 0; for ( let i = 1, n = bounds.length - 1; i < n; i++ ) { if ( value > bounds[i] ) { closestBound = i; } } if ( Math.abs( bounds[closestBound + 1] - value ) < Math.abs( bounds[closestBound] - value ) ) { closestBound += 1; } return closestBound; } /** * Get index of handle that should be moved. */ private getBoundNeedMoving( value: number, closestBound: number ): number { const {bounds, recent} = this.state; let boundNeedMoving: number = closestBound; const nextBound = closestBound + 1; const atTheSamePoint = ( bounds[nextBound] === bounds[closestBound] ); if ( atTheSamePoint ) { if ( bounds[recent] === bounds[closestBound] ) { boundNeedMoving = recent; } else { boundNeedMoving = nextBound; } if ( value !== bounds[nextBound] ) { boundNeedMoving = ( ( value < bounds[nextBound] ) ? closestBound : nextBound ); } } return boundNeedMoving; } /** * Push surrounding handles. */ private pushSurroundingHandles( bounds: number[], handle: number, originalValue: number, ): void { // tslint:disable-next-line:no-non-null-assertion const threshold = Number( this.props.pushable! ); const value = bounds[handle]; let direction = 0; if ( ( bounds[handle + 1] - value ) < threshold ) { // Push to right direction = +1; } if ( ( value - bounds[handle - 1] ) < threshold ) { // Push to left direction = -1; } if ( direction === 0 ) { return; } const nextHandle = handle + direction; const diffToNext = direction * (bounds[nextHandle] - value); if ( !this.pushHandle( bounds, nextHandle, direction, threshold - diffToNext, ) ) { // Revert to original value if pushing is impossible bounds[handle] = originalValue; } } /** * Push specific handle. */ private pushHandle( bounds: number[], handle: number, direction: number, amount: number, ): boolean { const originalValue = bounds[handle]; let currentValue = bounds[handle]; while ( (direction * (currentValue - originalValue)) < amount ) { if ( !this.pushHandleOnePoint( bounds, handle, direction ) ) { // Can't push handle enough to create the needed `amount` gap, // so we revert its position to the original value. bounds[handle] = originalValue; return false; } currentValue = bounds[handle]; } // The handle was pushed enough to create the needed `amount` gap return true; } /** * Push specific handle by one point. */ private pushHandleOnePoint( bounds: number[], handle: number, direction: number, ): boolean { const points = this.getPoints(); const pointIndex = points.indexOf( bounds[handle] ); const nextPointIndex = pointIndex + direction; if ( ( nextPointIndex >= points.length ) || ( nextPointIndex < 0 ) ) { // Reached the minimum or maximum available point, can't push // anymore. return false; } const nextHandle = handle + direction; const nextValue = points[nextPointIndex]; // tslint:disable-next-line:no-non-null-assertion const threshold = Number( this.props.pushable! ); const diffToNext = direction * (bounds[nextHandle] - nextValue); if ( !this.pushHandle( bounds, nextHandle, direction, threshold - diffToNext, ) ) { // Couldn't push next handle, so we won't push this one either return false; } // Push the handle bounds[handle] = nextValue; return true; } /** * Returns an array of possible slider points, taking into account both * `marks` and `step`. The result is cached. */ private getPoints(): number[] { const {marks, step, min, max} = this.props as MultiSliderProps; const cache = this.pointsCache; if ( !cache || ( cache.marks !== marks ) || ( cache.step !== step ) ) { const pointsObject = {...marks}; if ( ( step != null ) && ( step > 0 ) ) { for ( let point = min; point <= max; point += step ) { pointsObject[point] = String( point ); } } const points = Object.keys( pointsObject ).map( Number ); points.sort( ( a, b ) => a - b ); this.pointsCache = {marks, step, points}; } return this.pointsCache.points; } /** * Clamp value to interval of surrounding handles. */ private clampValueToSurroundingHandles( value: number, props: {allowCross: boolean}, ): number { return clampValueToSurroundingHandles(value, props, this.state); } } /** * Module. */ export { MultiSlider as default, // MultiSliderProps, // MultiSliderState, // MultiSliderDefaultProps };