Skip to content

Commit

Permalink
Merge pull request #505 from VEuPathDB/timeslider-mini-graphical
Browse files Browse the repository at this point in the history
Proposal for graphics-centric minimized time slider
  • Loading branch information
bobular authored Nov 9, 2023
2 parents a12ee26 + c38eb9c commit 5c2a920
Show file tree
Hide file tree
Showing 9 changed files with 442 additions and 284 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,33 @@ import { millisecondTodate } from '../../utils/date-format-change';
import { Bar } from '@visx/shape';
import { debounce } from 'lodash';

export type EZTimeFilterDataProp = {
export type TimeSliderDataProp = {
x: string;
y: number;
};

export type EzTimeFilterProps = {
export type TimeSliderProps = {
/** Ez time filter data */
data: EZTimeFilterDataProp[];
data: TimeSliderDataProp[];
/** current state of selectedRange */
selectedRange: { start: string; end: string } | undefined;
/** update function selectedRange */
setSelectedRange: (
selectedRange: { start: string; end: string } | undefined
) => void;
/** optional xAxisRange - will limit the selection to be within this */
xAxisRange?: { start: string; end: string };
/** width */
width?: number;
/** height */
height?: number;
/** color of the selected range */
/** color of the 'has data' bars - default is black */
barColor?: string;
/** color of the selected range - default is lightblue */
brushColor?: string;
/** axis tick and tick label color */
/** axis tick and tick label color - default is black */
axisColor?: string;
/** opacity of selected brush */
/** opacity of selected brush - default is 0.4 */
brushOpacity?: number;
/** debounce rate in millisecond */
debounceRateMs?: number;
Expand All @@ -43,20 +47,22 @@ export type EzTimeFilterProps = {
};

// using forwardRef
function EzTimeFilter(props: EzTimeFilterProps) {
function TimeSlider(props: TimeSliderProps) {
const {
data,
// set default width and height
width = 720,
height = 100,
brushColor = 'lightblue',
barColor = '#333',
axisColor = '#000',
brushOpacity = 0.4,
selectedRange,
setSelectedRange,
// set a default debounce time in milliseconds
debounceRateMs = 500,
disabled = false,
xAxisRange,
} = props;

const resizeTriggerAreas: ResizeTriggerAreas[] = disabled
Expand All @@ -82,25 +88,32 @@ function EzTimeFilter(props: EzTimeFilterProps) {
};

// accessors for data
const getXData = (d: EZTimeFilterDataProp) => new Date(d.x);
const getYData = (d: EZTimeFilterDataProp) => d.y;
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;

const selectedDomain = {
// x0 and x1 are millisecond value
start: millisecondTodate(x0),
end: millisecondTodate(x1),
};

setSelectedRange(selectedDomain);
// x0 and x1 are millisecond value
const startDate = millisecondTodate(x0);
const endDate = millisecondTodate(x1);
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]
[setSelectedRange, xAxisRange]
);

// Cancel any pending onBrushChange requests when this component is unmounted
Expand All @@ -112,8 +125,8 @@ function EzTimeFilter(props: EzTimeFilterProps) {

// bounds
const xBrushMax = Math.max(width - margin.left - margin.right, 0);
// take 80 % of given height considering axis tick/tick labels at the bottom
const yBrushMax = Math.max(0.8 * height - margin.top - margin.bottom, 0);
// take 70 % of given height considering axis tick/tick labels at the bottom
const yBrushMax = Math.max(0.7 * height - margin.top - margin.bottom, 0);

// scaling
const xBrushScale = useMemo(
Expand Down Expand Up @@ -143,24 +156,22 @@ function EzTimeFilter(props: EzTimeFilterProps) {
() =>
selectedRange != null
? {
// If we reenable the fake controlled behaviour of the <Brush> component using the key prop
// then we'll need to figure out why both brush handles drift every time you adjust one of them.
// The issue is something to do with the round-trip conversion of pixel/date/millisecond positions.
// A good place to start looking is here.
start: { x: xBrushScale(new Date(selectedRange.start)) },
end: { x: xBrushScale(new Date(selectedRange.end)) },
}
: undefined,
[selectedRange, xBrushScale]
);

// compute bar width manually as scaleTime is used for Bar chart
const barWidth = xBrushMax / data.length;

// data bar color
const defaultColor = '#333';

// this makes/fakes the brush as a controlled component
const brushKey =
initialBrushPosition != null
? initialBrushPosition.start + ':' + initialBrushPosition.end
: 'no_brush';
// `brushKey` makes/fakes the brush as a controlled component,
const brushKey = 'not_fake_controlled';
// selectedRange != null
// ? selectedRange.start + ':' + selectedRange.end
// : 'no_brush';

return (
<div
Expand All @@ -175,11 +186,18 @@ function EzTimeFilter(props: EzTimeFilterProps) {
{/* use Bar chart */}
{data.map((d, i) => {
const barHeight = yBrushMax - yBrushScale(getYData(d));
const x = xBrushScale(getXData(d));
// calculate the width using the next bin's date:
// subtract a constant to create separate bars for each bin
const barWidth =
i + 1 >= data.length
? 0
: xBrushScale(getXData(data[i + 1])) - x - 1;
return (
<React.Fragment key={i}>
<Bar
key={`bar-${i.toString()}`}
x={xBrushScale(getXData(d))}
x={x}
// In SVG bar chart, y-coordinate increases downward, i.e.,
// y-coordinates of the top and bottom of the bars are 0 and yBrushMax, respectively
// Also, under current yBrushScale, dataY = 0 -> barHeight = 0; dataY = 1 -> barHeight = 60
Expand All @@ -190,8 +208,8 @@ function EzTimeFilter(props: EzTimeFilterProps) {
y={barHeight === 0 ? yBrushMax : (1 / 4) * yBrushMax}
height={(1 / 2) * barHeight}
// set the last data's barWidth to be 0 so that it does not overflow to dragging area
width={i === data.length - 1 ? 0 : barWidth}
fill={defaultColor}
width={barWidth}
fill={barColor}
/>
</React.Fragment>
);
Expand All @@ -210,9 +228,8 @@ function EzTimeFilter(props: EzTimeFilterProps) {
yScale={yBrushScale}
width={xBrushMax}
height={yBrushMax}
margin={margin}
handleSize={8}
// resize
margin={margin /* prevents brushing offset */}
resizeTriggerAreas={resizeTriggerAreas}
brushDirection="horizontal"
initialBrushPosition={initialBrushPosition}
Expand Down Expand Up @@ -249,4 +266,4 @@ function BrushHandle({ x, height, isBrushActive }: BrushHandleRenderProps) {
);
}

export default EzTimeFilter;
export default TimeSlider;
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import { LinePlotProps } from '../../plots/LinePlot';
import EzTimeFilter, {
EZTimeFilterDataProp,
} from '../../components/plotControls/EzTimeFilter';
import TimeSlider, {
TimeSliderDataProp,
} from '../../components/plotControls/TimeSlider';
import { DraggablePanel } from '@veupathdb/coreui/lib/components/containers';

export default {
title: 'Plot Controls/EzTimeFilter',
component: EzTimeFilter,
title: 'Plot Controls/TimeSlider',
component: TimeSlider,
} as Meta;

// GEMS1 Case Control; x: Enrollment date; y: Weight
Expand Down Expand Up @@ -246,7 +246,7 @@ const LineplotData = {

export const TimeFilter: Story<LinePlotProps> = (args: any) => {
// converting lineplot data to visx format
const timeFilterData: EZTimeFilterDataProp[] = LineplotData.series[0].x.map(
const timeFilterData: TimeSliderDataProp[] = LineplotData.series[0].x.map(
(value, index) => {
// return { x: value, y: LineplotData.series[0].y[index] };
return { x: value, y: LineplotData.series[0].y[index] >= 9 ? 1 : 0 };
Expand Down Expand Up @@ -334,7 +334,7 @@ export const TimeFilter: Story<LinePlotProps> = (args: any) => {
{selectedRange?.start} ~ {selectedRange?.end}
</div>
</div>
<EzTimeFilter
<TimeSlider
data={timeFilterData}
selectedRange={selectedRange}
setSelectedRange={setSelectedRange}
Expand Down
29 changes: 27 additions & 2 deletions packages/libs/eda/src/lib/core/hooks/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
useFlattenedFields,
} from '../components/variableTrees/hooks';
import { findFirstVariable } from '../../workspace/Utils';
import * as DateMath from 'date-arithmetic';
import { TimeUnit } from '../types/general';

/** Return the study identifier and a hierarchy of the study entities. */
export function useStudyMetadata(): StudyMetadata {
Expand Down Expand Up @@ -143,6 +145,8 @@ export function useStudyEntities(filters?: Filter[]) {
variable.type === 'number' ||
variable.type === 'integer'
)
// TO DO? recalculate binWidth for numeric variables?
// (it's less critical than for dates due to time slider)
return {
...variable,
vocabulary,
Expand All @@ -160,7 +164,26 @@ export function useStudyEntities(filters?: Filter[]) {
.rangeMax as number),
},
};
else if (variable.type === 'date')
else if (variable.type === 'date') {
// recalculate bin width and units
// to keep it simple let's keep the width at 1 and just try different units
const binWidth = 1;
const binUnits = (['year', 'month', 'week', 'day'].find(
(unit) => {
if (filterRange) {
const diff = DateMath.diff(
new Date(filterRange.min as string),
new Date(filterRange.max as string),
unit as DateMath.Unit
);
// 12 is somewhat arbitrary, but it basically
// means if there are >= 12 years, use year bins.
// Otherwise if >= 12 months, use month bins, etc
return diff >= 12;
}
}
) ?? 'day') as TimeUnit;

return {
...variable,
vocabulary,
Expand All @@ -176,9 +199,11 @@ export function useStudyEntities(filters?: Filter[]) {
? (filterRange.max as string)
: (variable.distributionDefaults
.rangeMax as string),
binUnits,
binWidth,
},
};
else
} else
return {
...variable,
vocabulary,
Expand Down
12 changes: 12 additions & 0 deletions packages/libs/eda/src/lib/core/utils/trim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { dropRightWhile, dropWhile } from 'lodash';

/**
* Something that should be in lodash! Oh, it nearly is.
*
* Trim an array from the start and end, removing elements for which the filter function returns true
*
* Unused as of 2023-10-20
*/
export function trimArray<T>(array: Array<T>, filter: (val: T) => boolean) {
return dropRightWhile(dropWhile(array, filter), filter);
}
Loading

0 comments on commit 5c2a920

Please sign in to comment.