import { useState, useMemo, useRef, type HTMLAttributes, useEffect, } from 'react'; import cn from 'classnames'; import { brushX, select, event, scaleLinear, type BrushBehavior } from 'd3'; import Histogram, { type Range } from './histogram'; import useSize from '../hooks/useSize'; import '../styles/components/histogram-filter.scss'; type Props = { /** * The left-most, smallest, value the histogram starts at irrespective of the * array values. Defaults to min(values). */ min?: number; /** * The right-most, largest, value the histogram ends at irrespective of the * array values. Defaults to max(values). */ max?: number; /** * An array of values which the histogram is based on. */ values: number[]; /** * An array of unfiltered values which the histogram is based on. * (useful to calculate max bin height) */ unfilteredValues?: number[]; /** * A value which specifies the start and end points selected by the user. */ selectedRange: Range; /** * A callback that returns the selected and final (ie after drag) range. */ onChange: (range: Range) => unknown; /** * Number of bins (intervals) which the values are allocated to. * Each interval is of the size (max - min) / nBins. Defaults to 50. */ nBins?: number; /** * The height in pixels of the bin with the most values. Defaults to 300. */ height?: number; /** * Display a shadow of the unfiltered data (opacity value) */ unfilteredValuesShadow?: number; /** * Additional CSS classnames to apply (eg secondary, tertiary) */ className?: string; }; const HistogramFilter = ({ min: minOrUndef, max: maxOrUndef, values, unfilteredValues, unfilteredValuesShadow, selectedRange, onChange, nBins = 30, height = 50, className, ...props }: Props & Omit, 'onChange'>) => { const d3ContainerRef = useRef(null); const [size] = useSize(d3ContainerRef); const brushRef = useRef | null>(null); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; const [min, max] = useMemo(() => { // Assign sensible default values if not provided const innerMin = minOrUndef === undefined ? Math.min(...values) : minOrUndef; const innerMax = maxOrUndef === undefined ? Math.max(...values) : maxOrUndef; return [innerMin, innerMax]; }, [maxOrUndef, minOrUndef, values]); const [startInput, setStartInput] = useState(`${min}`); const [endInput, setEndInput] = useState(`${max}`); useEffect(() => { if (!size) { return; // Can't position the brush correctly until we have a size } // Scale const scale = scaleLinear().domain([min, max]).range([0, size.width]); // On brush event const getOnBrush = (type?: string) => () => { const range = event.selection; // Update only if an event caused this to be called (not programmatic) if (event.sourceEvent && range) { const start = +`${scale.invert(range[0]).toPrecision(4)}`; const end = +`${scale.invert(range[1]).toPrecision(4)}`; setStartInput(`${start}`); setEndInput(`${end}`); if (type === 'end') { // Only when user stops brushing, send the new values onChangeRef.current([start, end]); } } }; // Brush brushRef.current = brushX() .extent([ [0, 0], [size.width, size.height], ]) .on('start brush', getOnBrush()) .on('end', getOnBrush('end')); // Tie the brush to the DOM const selection = select(d3ContainerRef.current).append('g'); brushRef.current(selection); // eslint-disable-next-line consistent-return return () => { // Unbind listeners brushRef.current?.on('start brush end', null); // Remove selection selection.remove(); }; }, [size, min, max]); // Update the brush programatically when props are changed useEffect(() => { if (!size) { return; // Can't position the brush correctly until we have a size } const scale = scaleLinear().domain([min, max]).range([0, size.width]); // If the brush corresponds to the full range, remove it completely if (selectedRange[0] === min && selectedRange[1] === max) { brushRef.current?.move(select(d3ContainerRef.current).select('g'), null); } else { brushRef.current?.move(select(d3ContainerRef.current).select('g'), [ scale(selectedRange[0])!, scale(selectedRange[1])!, ]); } }, [min, max, selectedRange, size]); return (
{/* Brush container */}
{ const textValue = e.target.value; // Always update the input text state, regardless of if valid or not setStartInput(e.target.value); const numberValue = +textValue; // Only if the number if valid do we keep it in the range state if ( !Number.isNaN(numberValue) && numberValue < selectedRange[1] && numberValue >= min ) { onChange([numberValue, selectedRange[1]]); } }} // On blur, set the input text state to whatever value is in range // state and move the brush onBlur={() => setStartInput(`${selectedRange[0]}`)} value={startInput} style={{ width: `${startInput.length + 2}ch` }} /> { const textValue = e.target.value; // Always update the input text state, regardless of if valid or not setEndInput(e.target.value); const numberValue = +textValue; // Only if the number if valid do we keep it in the range state if ( !Number.isNaN(numberValue) && numberValue > selectedRange[0] && numberValue <= max ) { onChange([selectedRange[0], numberValue]); } }} // On blur, reset the input text state to whatever value is in range onBlur={() => setEndInput(`${selectedRange[1]}`)} value={endInput} style={{ width: `${endInput.length + 2}ch` }} />
); }; export default HistogramFilter;