/** * WordPress dependencies */ import { useEffect, useRef, useState } from '@safe-wordpress/element'; import { _x, sprintf } from '@safe-wordpress/i18n'; /** * External dependencies */ import { findIndex, findLastIndex, zip } from 'lodash'; import { usePluginSetting } from '@nab/data'; import { getLetter, sum, transformAlternativeChancesToAllocatedSlots, } from '@nab/utils'; import type { Alternative, Maybe } from '@nab/types'; /** * Internal dependencies */ import './style.scss'; import { Tooltip } from '../tooltip'; export type AlternativeDistributionSettingProps = { readonly alternatives: ReadonlyArray< Alternative >; readonly updateCuts?: ( alternatives: ReadonlyArray< number > ) => void; }; export const AlternativeDistributionSetting = ( { alternatives, updateCuts, }: AlternativeDistributionSettingProps ): JSX.Element | null => { const visible = usePluginSetting( 'isAlternativeDistributionAllowed' ); const maxCombinations = usePluginSetting( 'maxCombinations' ); const barRef = useRef< HTMLDivElement | null >( null ); const [ barWidth, setBarWidth ] = useState( 0 ); useEffect( () => { const element = barRef.current; if ( ! element ) { return; } const updateWidth = () => { setBarWidth( element.getBoundingClientRect().width ); }; updateWidth(); const resizeObserver = new ResizeObserver( updateWidth ); resizeObserver.observe( element ); return () => { resizeObserver.disconnect(); }; }, [] ); const sizes = getDistribution( alternatives, maxCombinations ); const allocatedSlots = transformAlternativeChancesToAllocatedSlots( sizes, maxCombinations ); if ( ! visible || allocatedSlots.length !== alternatives.length ) { return null; } const alternativeIds = alternatives.map( ( a ) => a.id ); const data = zip( sizes, alternativeIds, allocatedSlots ); const onMoveLeft = ( index: number ) => { if ( ! updateCuts ) { return undefined; } if ( index >= data.length - 1 ) { return undefined; } const left = allocatedSlots.slice( 0, index + 1 ); if ( sum( left ) <= left.length ) { return undefined; } return () => { const lastIndex = findLastIndex( left, ( v ) => v > 1 ); const newAllocatedSlots = [ ...allocatedSlots ]; // @ts-expect-error Index is within range. newAllocatedSlots[ lastIndex ]--; // @ts-expect-error Index is within range. newAllocatedSlots[ index + 1 ]++; updateCuts( newAllocatedSlots ); }; }; const onMoveRightIndex = ( index: number ) => { if ( ! updateCuts ) { return undefined; } if ( index >= data.length - 1 ) { return undefined; } const right = allocatedSlots.slice( index + 1, allocatedSlots.length ); if ( sum( right ) <= right.length ) { return undefined; } return () => { const firstIndex = findIndex( right, ( v ) => v > 1 ); const newAllocatedSlots = [ ...allocatedSlots ]; // @ts-expect-error Index is within range. newAllocatedSlots[ index ]++; // @ts-expect-error Index is within range. newAllocatedSlots[ index + firstIndex + 1 ]--; updateCuts( newAllocatedSlots ); }; }; return (
{ data.map( ( [ size, id, cut ], index ) => ( ) ) }
); }; const DistributionBar = ( { label, letter, step, width, onMoveLeft, onMoveRight, }: { readonly label: string; readonly letter: string; readonly step: number; readonly width: Maybe< number >; readonly onMoveRight: Maybe< () => void >; readonly onMoveLeft: Maybe< () => void >; } ) => { const handleRef = useRef< HTMLDivElement | null >( null ); const hasHandle = !! onMoveLeft || !! onMoveRight; const dragStartXRef = useRef< number | null >( null ); const dragOffsetRef = useRef( 0 ); const handleKeyDown = ( event: React.KeyboardEvent< HTMLDivElement > ) => { switch ( event.key ) { case 'ArrowLeft': case '-': event.preventDefault(); return onMoveLeft?.(); case 'ArrowRight': case '+': event.preventDefault(); return onMoveRight?.(); } }; function handlePointerDown( event: React.PointerEvent< HTMLDivElement > ) { dragStartXRef.current = event.clientX; dragOffsetRef.current = 0; event.currentTarget.setPointerCapture( event.pointerId ); } function handlePointerMove( event: React.PointerEvent< HTMLDivElement > ) { if ( dragStartXRef.current === null ) { return; } const currentX = event.clientX; const totalDistance = currentX - dragStartXRef.current; const pendingOffset = totalDistance - dragOffsetRef.current; if ( pendingOffset >= step ) { const steps = Math.floor( pendingOffset / step ); for ( let i = 0; i < steps; i += 1 ) { onMoveRight?.(); } dragOffsetRef.current += steps * step; } if ( pendingOffset <= -step ) { const steps = Math.floor( Math.abs( pendingOffset ) / step ); for ( let i = 0; i < steps; i += 1 ) { onMoveLeft?.(); } dragOffsetRef.current -= steps * step; } } function handlePointerUp( event: React.PointerEvent< HTMLDivElement > ) { dragStartXRef.current = null; dragOffsetRef.current = 0; event.currentTarget.releasePointerCapture( event.pointerId ); } return (
{ hasHandle && (
) }
); }; // ======= // HELPERS // ======= function getDistribution( alternatives: ReadonlyArray< Alternative >, slots: number ): ReadonlyArray< number > { const [ control, ...rest ] = alternatives; const minPercentage = ( 1 / slots ) * 100; const restSizes = rest.map( ( a ) => Math.max( 'number' === typeof a.attributes.chance ? a.attributes.chance : 0, minPercentage ) ); const aChance = control?.attributes.chance; const bChance = rest[ 0 ]?.attributes.chance; const controlSize = ( 'number' === typeof aChance ? aChance : 0 ) || ( 'number' === typeof bChance ? Math.max( 100 - sum( restSizes ), minPercentage ) : minPercentage ); const sizes = [ controlSize, ...restSizes ]; const allocatedSlots = transformAlternativeChancesToAllocatedSlots( sizes, slots ); return allocatedSlots.map( ( c ) => ( c / slots ) * 100 ); }