diff --git a/packages/libs/components/src/components/plotControls/AxisRangeControl.tsx b/packages/libs/components/src/components/plotControls/AxisRangeControl.tsx index ccffa18c62..0853cdfae3 100755 --- a/packages/libs/components/src/components/plotControls/AxisRangeControl.tsx +++ b/packages/libs/components/src/components/plotControls/AxisRangeControl.tsx @@ -24,6 +24,8 @@ export interface AxisRangeControlProps logScale?: boolean; /** specify step for increment/decrement buttons in MUI number inputs; MUI's default is 1 */ step?: number; + /** specify the height of the input element */ + inputHeight?: number; } export default function AxisRangeControl({ @@ -36,6 +38,7 @@ export default function AxisRangeControl({ disabled = false, logScale = false, step = undefined, + inputHeight, }: AxisRangeControlProps) { const validator = useCallback( ( @@ -79,6 +82,7 @@ export default function AxisRangeControl({ validator={validator} // add disabled prop to disable input fields disabled={disabled} + inputHeight={inputHeight} /> ) : ( ) ) : null; diff --git a/packages/libs/components/src/components/plotControls/TimeSlider.tsx b/packages/libs/components/src/components/plotControls/TimeSlider.tsx index 4931760d91..6312cdff73 100755 --- a/packages/libs/components/src/components/plotControls/TimeSlider.tsx +++ b/packages/libs/components/src/components/plotControls/TimeSlider.tsx @@ -91,46 +91,6 @@ function TimeSlider(props: TimeSliderProps) { const getXData = (d: TimeSliderDataProp) => new Date(d.x); const getYData = (d: TimeSliderDataProp) => d.y; - const onBrushChange = useMemo( - () => - debounce((domain: Bounds | null) => { - if (!domain) return; - const { x0, x1 } = domain; - - // computing the offset of 2 pixel (SAFE_PIXEL) in domain (milliseconds) - // https://github.com/airbnb/visx/blob/86a851cb3bf622b013b186f02f955bcd6548a87f/packages/visx-brush/src/Brush.tsx#L14 - const brushOffset = - xBrushScale.invert(2).getTime() - xBrushScale.invert(0).getTime(); - - // compensating the offset - // x0 and x1 are millisecond value - const startDate = millisecondTodate(x0 + brushOffset); - const endDate = millisecondTodate(x1 - brushOffset); - - setSelectedRange({ - // don't let range go outside the xAxisRange, if provided - start: xAxisRange - ? startDate < xAxisRange.start - ? xAxisRange.start - : startDate - : startDate, - end: xAxisRange - ? endDate > xAxisRange.end - ? xAxisRange.end - : endDate - : endDate, - }); - }, debounceRateMs), - [setSelectedRange, xAxisRange] - ); - - // Cancel any pending onBrushChange requests when this component is unmounted - useEffect(() => { - return () => { - onBrushChange.cancel(); - }; - }, []); - // bounds const xBrushMax = Math.max(width - margin.left - margin.right, 0); // take 70 % of given height considering axis tick/tick labels at the bottom @@ -176,10 +136,50 @@ function TimeSlider(props: TimeSliderProps) { ); // `brushKey` makes/fakes the brush as a controlled component, - const brushKey = 'not_fake_controlled'; - // selectedRange != null - // ? selectedRange.start + ':' + selectedRange.end - // : 'no_brush'; + const brushKey = + selectedRange != null + ? selectedRange.start + ':' + selectedRange.end + : 'no_brush'; + + const onBrushChange = useMemo( + () => + debounce((domain: Bounds | null) => { + if (!domain) return; + const { x0, x1 } = domain; + + // computing the offset of 2 pixel (SAFE_PIXEL) in domain (milliseconds) + // https://github.com/airbnb/visx/blob/86a851cb3bf622b013b186f02f955bcd6548a87f/packages/visx-brush/src/Brush.tsx#L14 + const brushOffset = + xBrushScale.invert(2).getTime() - xBrushScale.invert(0).getTime(); + + // compensating the offset + // x0 and x1 are millisecond value + const startDate = millisecondTodate(x0 + brushOffset); + const endDate = millisecondTodate(x1 - brushOffset); + + setSelectedRange({ + // don't let range go outside the xAxisRange, if provided + start: xAxisRange + ? startDate < xAxisRange.start + ? xAxisRange.start + : startDate + : startDate, + end: xAxisRange + ? endDate > xAxisRange.end + ? xAxisRange.end + : endDate + : endDate, + }); + }, debounceRateMs), + [setSelectedRange, xAxisRange, debounceRateMs, xBrushScale] + ); + + // Cancel any pending onBrushChange requests when this component is unmounted + useEffect(() => { + return () => { + onBrushChange.cancel(); + }; + }, [onBrushChange]); return (
= { disabled?: boolean; /** Style the Text Field with the warning color and bold stroke */ applyWarningStyles?: boolean; + /** specify the height of the input element */ + inputHeight?: number; }; export type NumberInputProps = BaseProps & { step?: number }; @@ -86,6 +88,8 @@ function BaseInput({ displayRangeViolationWarnings = true, disabled = false, applyWarningStyles = false, + // default value is 36.5 + inputHeight = 36.5, ...props }: BaseInputProps) { if (validator && (required || minValue != null || maxValue != null)) @@ -102,7 +106,7 @@ function BaseInput({ const classes = makeStyles({ root: { - height: 36.5, // default height is 56 and is waaaay too tall + height: inputHeight, // default height is 56 and is waaaay too tall // 34.5 is the height of the reset button, but 36.5 lines up better // set width for date width: valueType === 'date' ? 165 : '', diff --git a/packages/libs/components/src/components/widgets/NumberAndDateRangeInputs.tsx b/packages/libs/components/src/components/widgets/NumberAndDateRangeInputs.tsx index cdc625cb07..d1d6159b0e 100755 --- a/packages/libs/components/src/components/widgets/NumberAndDateRangeInputs.tsx +++ b/packages/libs/components/src/components/widgets/NumberAndDateRangeInputs.tsx @@ -43,6 +43,8 @@ export type BaseProps = { clearButtonLabel?: string; /** add disabled prop to disable input fields */ disabled?: boolean; + /** specify the height of the input element */ + inputHeight?: number; }; export type NumberRangeInputProps = BaseProps & { step?: number }; @@ -86,6 +88,7 @@ function BaseInput({ clearButtonLabel = 'Clear', // add disabled prop to disable input fields disabled = false, + inputHeight, ...props }: BaseInputProps) { if (validator && required) @@ -175,7 +178,9 @@ function BaseInput({ {label} )} -
+
{valueType === 'number' ? ( ) : ( )}
@@ -241,6 +248,7 @@ function BaseInput({ // add disabled prop to disable input fields disabled={disabled} step={step} + inputHeight={inputHeight} /> ) : ( )} {showClearButton && ( diff --git a/packages/libs/components/src/stories/plotControls/TimeSlider.stories.tsx b/packages/libs/components/src/stories/plotControls/TimeSlider.stories.tsx index eaf144135b..3a230f73b6 100755 --- a/packages/libs/components/src/stories/plotControls/TimeSlider.stories.tsx +++ b/packages/libs/components/src/stories/plotControls/TimeSlider.stories.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { Story, Meta } from '@storybook/react/types-6-0'; import { LinePlotProps } from '../../plots/LinePlot'; import TimeSlider, { @@ -6,6 +6,9 @@ import TimeSlider, { } from '../../components/plotControls/TimeSlider'; import { DraggablePanel } from '@veupathdb/coreui/lib/components/containers'; +import AxisRangeControl from '../../components/plotControls/AxisRangeControl'; +import { NumberOrDateRange } from '../../types/general'; + export default { title: 'Plot Controls/TimeSlider', component: TimeSlider, @@ -301,7 +304,56 @@ export const TimeFilter: Story = (args: any) => { // set constant values const defaultSymbolSize = 0.8; - const defaultColor = '#333'; + + // control selectedRange + const handleAxisRangeChange = useCallback( + (newRange?: NumberOrDateRange) => { + if (newRange) + setSelectedRange({ + start: newRange.min as string, + end: newRange.max as string, + }); + }, + [setSelectedRange] + ); + + const handleArrowClick = useCallback( + (arrow: string) => { + // let's assume that selectedRange has the format of 'yyyy-mm-dd' + if ( + selectedRange && + selectedRange.start != null && + selectedRange.end != null + ) { + const selectedRangeArray = + arrow === 'left' + ? selectedRange.start.split('-') + : selectedRange.end.split('-'); + const addSubtractYear = + arrow === 'left' + ? String(Number(selectedRangeArray[0]) - 1) + : String(Number(selectedRangeArray[0]) + 1); + const changeYear = + addSubtractYear + + '-' + + selectedRangeArray[1] + + '-' + + selectedRangeArray[2]; + setSelectedRange((prev) => { + return arrow === 'left' + ? { + start: changeYear as string, + end: prev?.end as string, + } + : { + start: prev?.start as string, + end: changeYear as string, + }; + }); + } + }, + [selectedRange, setSelectedRange] + ); return ( = (args: any) => { >
- {/* display start to end value */} -
- {selectedRange?.start} ~ {selectedRange?.end} +
+ +
+ {/* add axis range control */} + +
+
- selectedRange.end - ? variableMetadata.variable.distributionDefaults.rangeMax - : selectedRange.end, - } - : undefined; + const extendedDisplayRange = useMemo( + () => + variableMetadata && DateVariable.is(variableMetadata.variable) + ? selectedRange == null + ? { + start: variableMetadata.variable.distributionDefaults.rangeMin, + end: variableMetadata.variable.distributionDefaults.rangeMax, + } + : { + start: + variableMetadata.variable.distributionDefaults.rangeMin < + selectedRange.start + ? variableMetadata.variable.distributionDefaults.rangeMin + : selectedRange.start, + end: + variableMetadata.variable.distributionDefaults.rangeMax > + selectedRange.end + ? variableMetadata.variable.distributionDefaults.rangeMax + : selectedRange.end, + } + : undefined, + [variableMetadata, selectedRange] + ); // converting old usePromise code to useQuery in an efficient manner const { enabled, queryKey, queryFn } = useMemo(() => { @@ -129,12 +140,12 @@ export default function TimeSliderQuickFilter({ }, }; }, [ - variableMetadata?.variable, variable, subsettingClient, filters, - extendedDisplayRange?.start, - extendedDisplayRange?.end, + extendedDisplayRange, + studyId, + variableMetadata, ]); const timeSliderData = useQuery({ @@ -178,9 +189,6 @@ export default function TimeSliderQuickFilter({ }); } - // (easily) centering the variable picker requires two same-width divs either side - const sideElementStyle = { width: '70px' }; - const sliderHeight = minimized ? 50 : 75; const background = @@ -235,6 +243,8 @@ export default function TimeSliderQuickFilter({ change the date variable (currently{' '} {variableMetadata?.variable.displayName}) +
  • set start and end dates precisely
  • +
  • step the window forwards and backwards through the timeline
  • toggle the temporary time window filter on/off
  • @@ -249,6 +259,80 @@ export default function TimeSliderQuickFilter({
    ); + // disable arrow button + const [disableLeftArrow, setDisableLeftArrow] = useState(false); + const [disableRightArrow, setDisableRightArrow] = useState(false); + + // control selectedRange + const handleAxisRangeChange = useCallback( + (newRange?: NumberOrDateRange) => { + if (newRange) { + const newSelectedRange = { + start: newRange.min as string, + end: newRange.max as string, + }; + updateConfig({ ...config, selectedRange: newSelectedRange }); + } + }, + [config, updateConfig] + ); + + // step buttons + const handleArrowClick = useCallback( + (arrow: string) => { + if ( + selectedRange && + selectedRange.start != null && + selectedRange.end != null + ) { + const newSelectedRange = newArrowRange(selectedRange, arrow); + updateConfig({ ...config, selectedRange: newSelectedRange }); + } + }, + [config, updateConfig, selectedRange] + ); + + // enabling/disabling date range arrows + useEffect(() => { + if (extendedDisplayRange && selectedRange) { + const diff = + new Date(selectedRange.end).getTime() - + new Date(selectedRange.start).getTime(); + + const expectedStartDate = new Date( + new Date(selectedRange.start).getTime() - diff + ) + .toISOString() + .split('T')[0]; + + const expectedEndDate = new Date( + new Date(selectedRange.end).getTime() + diff + ) + .toISOString() + .split('T')[0]; + + // left arrow + if ( + new Date(expectedStartDate).getTime() < + new Date(extendedDisplayRange.start).getTime() + ) { + setDisableLeftArrow(true); + } else { + setDisableLeftArrow(false); + } + + // right arrow + if ( + new Date(expectedEndDate).getTime() > + new Date(extendedDisplayRange.end).getTime() + ) { + setDisableRightArrow(true); + } else { + setDisableRightArrow(false); + } + } + }, [extendedDisplayRange, selectedRange]); + // if no variable in a study is suitable to time slider, do not show time slider return variable != null && variableMetadata != null ? (
    @@ -292,17 +376,16 @@ export default function TimeSliderQuickFilter({
    +
    {!minimized && ( <> -
    -
    +
    + +
    + {/* add axis range control */} + +
    + +
    +
    ) : null; } + +// compute new range by step button +function newArrowRange( + selectedRange: selectedRangeProp | undefined, + arrow: string +) { + if (selectedRange) { + const diff = + new Date(selectedRange.end).getTime() - + new Date(selectedRange.start).getTime(); + + const diffSign = arrow === 'right' ? diff : -diff; + + const newSelectedRange = { + start: new Date(new Date(selectedRange.start).getTime() + diffSign) + .toISOString() + .split('T')[0], + end: new Date(new Date(selectedRange.end).getTime() + diffSign) + .toISOString() + .split('T')[0], + }; + + // unit in milliseconds + const deltaLeapDaysMS = + (countLeapDays(newSelectedRange) - countLeapDays(selectedRange)) * + (1000 * 3600 * 24); + + return { + start: new Date( + new Date(newSelectedRange.start).getTime() - + (arrow === 'left' ? deltaLeapDaysMS : 0) + ) + .toISOString() + .split('T')[0], + end: new Date( + new Date(newSelectedRange.end).getTime() + + (arrow === 'right' ? deltaLeapDaysMS : 0) + ) + .toISOString() + .split('T')[0], + }; + } else { + return undefined; + } +} + +// check whether leap year is +function isLeapYear(year: number) { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +// compute the number of days of leap years for a date range +function countLeapDays(dateRange: selectedRangeProp) { + const startDate = new Date(dateRange.start); + const endDate = new Date(dateRange.end); + const startYear = startDate.getFullYear(); + const endYear = endDate.getFullYear(); + + let leapDayCount = 0; + + for (let year = startYear; year <= endYear; year++) { + if (isLeapYear(year)) { + const leapDay = new Date(year, 1, 29); + if (leapDay >= startDate && leapDay <= endDate) { + leapDayCount++; + } + } + } + + return leapDayCount; +}