Skip to content

Commit

Permalink
feat: Add TimeSlider + filtering of data based on it
Browse files Browse the repository at this point in the history
  • Loading branch information
bprusinowski committed Nov 1, 2022
1 parent a213267 commit d4bdccd
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 17 deletions.
6 changes: 6 additions & 0 deletions app/charts/column/chart-column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
FilterValueSingle,
InteractiveFiltersConfig,
} from "@/configurator";
import { TimeSlider } from "@/configurator/interactive-filters/time-slider";
import { Observation } from "@/domain/data";
import {
DimensionMetadataFragment,
Expand Down Expand Up @@ -162,6 +163,11 @@ export const ChartColumns = memo(
</ChartSvg>
<Tooltip type="single" />
</ChartContainer>
{interactiveFiltersConfig?.timeSlider.componentIri && (
<TimeSlider
componentIri={interactiveFiltersConfig.timeSlider.componentIri}
/>
)}
</ColumnChart>
)}
</>
Expand Down
62 changes: 47 additions & 15 deletions app/charts/shared/chart-helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,36 +118,68 @@ export const useDataAfterInteractiveFilters = ({
getX?: (d: Observation) => Date;
getSegment?: (d: Observation) => string;
}) => {
const [interactiveFilters] = useInteractiveFilters();
const { from, to } = interactiveFilters.timeRange;
const { categories } = interactiveFilters;
const activeInteractiveFilters = Object.keys(categories);
const [IFState] = useInteractiveFilters();

// time range
const fromTime = IFState.timeRange.from?.getTime();
const toTime = IFState.timeRange.to?.getTime();

// time slider
const getTime = useTemporalVariable(
interactiveFiltersConfig?.timeSlider.componentIri || ""
);
const timeSliderValue = IFState.timeSlider.value;

// legend
const legendItems = Object.keys(IFState.categories);

const allFilters = useMemo(() => {
const timeRangeFilter: ValuePredicate | null =
getX && from && to && interactiveFiltersConfig?.timeRange.active
? (d: Observation) =>
getX(d).getTime() >= from.getTime() &&
getX(d).getTime() <= to.getTime()
const timeRangeFilter =
getX && fromTime && toTime && interactiveFiltersConfig?.timeRange.active
? (d: Observation) => {
const time = getX(d).getTime();
return time >= fromTime && time <= toTime;
}
: null;
const legendFilter: ValuePredicate | null =
const timeSliderFilter =
interactiveFiltersConfig?.timeSlider.componentIri && timeSliderValue
? (d: Observation) => {
return getTime(d).getTime() === timeSliderValue.getTime();
}
: null;
const legendFilter =
interactiveFiltersConfig?.legend.active && getSegment
? (d: Observation) => !activeInteractiveFilters.includes(getSegment(d))
? (d: Observation) => {
return !legendItems.includes(getSegment(d));
}
: null;
return overEvery([timeRangeFilter, legendFilter].filter(truthy));

return overEvery(
(
[
timeRangeFilter,
timeSliderFilter,
legendFilter,
] as (ValuePredicate | null)[]
).filter(truthy)
);
}, [
activeInteractiveFilters,
from,
legendItems,
getSegment,
getX,
to,
getTime,
fromTime,
toTime,
interactiveFiltersConfig?.legend.active,
interactiveFiltersConfig?.timeRange.active,
interactiveFiltersConfig?.timeSlider.componentIri,
timeSliderValue,
]);

const preparedData = useMemo(() => {
return sortedData.filter(allFilters);
}, [allFilters, sortedData]);

return preparedData;
};

Expand Down
14 changes: 13 additions & 1 deletion app/charts/shared/use-interactive-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ type InteractiveFiltersStateAction =
type: "SET_TIME_RANGE_FILTER";
value: [Date, Date];
}
| {
type: "SET_TIME_SLIDER_FILTER";
value: Date;
}
| {
type: "RESET_TIME_SLIDER_FILTER";
}
| {
type: "RESET_DATA_FILTER";
}
Expand Down Expand Up @@ -67,6 +74,12 @@ const InteractiveFiltersStateReducer = (
case "SET_TIME_RANGE_FILTER":
draft.timeRange = { from: action.value[0], to: action.value[1] };
return draft;
case "SET_TIME_SLIDER_FILTER":
draft.timeSlider = { value: action.value };
return draft;
case "RESET_TIME_SLIDER_FILTER":
draft.timeSlider.value = undefined;
return draft;
case "RESET_DATA_FILTER":
draft.dataFilters = {};
return draft;
Expand Down Expand Up @@ -114,7 +127,6 @@ export const InteractiveFiltersProvider = ({
const [state, dispatch] = useImmerReducer<
InteractiveFiltersState,
InteractiveFiltersStateAction
// @ts-ignore
>(InteractiveFiltersStateReducer, INTERACTIVE_FILTERS_INITIAL_STATE);

return (
Expand Down
13 changes: 12 additions & 1 deletion app/charts/shared/use-sync-interactive-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const useSyncInteractiveFilters = (chartConfig: ChartConfig) => {
const [IFstate, dispatch] = useInteractiveFilters();
const { interactiveFiltersConfig } = chartConfig;

// Time filter
// Time range filter
const presetFrom =
interactiveFiltersConfig?.timeRange.presets.from &&
parseDate(interactiveFiltersConfig?.timeRange.presets.from.toString());
Expand All @@ -42,6 +42,17 @@ const useSyncInteractiveFilters = (chartConfig: ChartConfig) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, presetFromStr, presetToStr]);

// Time slider filter
const timeSliderFilter = interactiveFiltersConfig?.timeSlider;
useEffect(() => {
if (
timeSliderFilter?.componentIri === "" &&
IFstate.timeSlider.value !== undefined
) {
dispatch({ type: "RESET_TIME_SLIDER_FILTER" });
}
}, [IFstate.timeSlider.value, timeSliderFilter?.componentIri, dispatch]);

// Data Filters
const componentIris = interactiveFiltersConfig?.dataFilters.componentIris;
useEffect(() => {
Expand Down
78 changes: 78 additions & 0 deletions app/configurator/interactive-filters/time-slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { bisect, scaleLinear } from "d3";
import React, { ChangeEvent } from "react";

import { ChartState, useChartState } from "@/charts/shared/use-chart-state";
import { useInteractiveFilters } from "@/charts/shared/use-interactive-filters";
import { TableChartState } from "@/charts/table/table-state";
import { Slider } from "@/components/form";
import { parseDate } from "@/configurator/components/ui-helpers";
import useEvent from "@/utils/use-event";

export const TimeSlider = ({ componentIri }: { componentIri?: string }) => {
const [IFState, dispatch] = useInteractiveFilters();
const [t, setT] = React.useState(0);
const chartState = useChartState() as NonNullable<
Exclude<ChartState, TableChartState>
>;

const sortedMs = React.useMemo(() => {
// FIXME: enable interactive filters for maps!
if (componentIri && chartState.chartType !== "map") {
const uniqueValues = [
...new Set(
chartState.allData.map((d) => d[componentIri]).filter(Boolean)
),
] as string[];
const sortedMs = uniqueValues
.sort()
.map(parseDate)
.map((d) => d.getTime());

return sortedMs;
}

return [];
// @ts-ignore - allData is not yet there for the maps
}, [chartState.chartType, chartState.allData, componentIri]);

const msScale = React.useMemo(() => {
if (sortedMs.length) {
const [min, max] = [sortedMs[0], sortedMs[sortedMs.length - 1]];
return scaleLinear().range([min, max]);
}
}, [sortedMs]);

const onChange = useEvent((e: ChangeEvent<HTMLInputElement>) => {
const t = +e.target.value;
setT(t);

if (msScale) {
const tMs = msScale(t);
const i = bisect(sortedMs, tMs);
const updateMs = sortedMs[i - 1];

if (IFState.timeSlider.value?.getTime() !== updateMs) {
dispatch({
type: "SET_TIME_SLIDER_FILTER",
value: new Date(updateMs),
});
}
}
});

React.useEffect(() => {
onChange({ target: { value: "0" } } as ChangeEvent<HTMLInputElement>);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortedMs]);

return (
<Slider
name="time-slider"
min={0}
max={1}
step={0.0001}
value={t}
onChange={onChange}
/>
);
};

0 comments on commit d4bdccd

Please sign in to comment.