Added visits highlightning capabilities to line chart

This commit is contained in:
Alejandro Celaya 2020-09-20 11:43:24 +02:00
parent 749c757cbd
commit 78d97a64aa
5 changed files with 59 additions and 12 deletions

View file

@ -0,0 +1,5 @@
import { ChangeEvent, FC } from 'react';
export const pointerOnHover = ({ target }: ChangeEvent<HTMLElement>, chartElement: FC[]) => {
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
};

View file

@ -128,6 +128,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
visits={normalizedVisits}
highlightedVisits={highlightedVisits}
highlightedLabel={highlightedLabel}
setSelectedVisits={setSelectedVisits}
/>
</div>
<div className="col-xl-4 col-lg-6 mt-4">

View file

@ -1,4 +1,4 @@
import React, { ChangeEvent, useRef } from 'react';
import React, { useRef } from 'react';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import { keys, values } from 'ramda';
import classNames from 'classnames';
@ -7,6 +7,7 @@ import { fillTheGaps, renderDoughnutChartLabel, renderNonDoughnutChartLabel } fr
import { Stats } from '../types';
import { prettify } from '../../utils/helpers/numbers';
import './DefaultChart.scss';
import { pointerOnHover } from '../../utils/helpers/charts';
export interface DefaultChartProps {
title: Function | string;
@ -145,11 +146,7 @@ const DefaultChart = (
label: isBarChart ? renderNonDoughnutChartLabel('xLabel') : renderDoughnutChartLabel,
},
},
onHover: !isBarChart ? undefined : ((e: ChangeEvent<HTMLElement>, chartElement: HorizontalBar[] | Doughnut[]) => {
const { target } = e;
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
}) as any, // TODO Types seem to be incorrectly defined
onHover: !isBarChart ? undefined : (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
};
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData, highlightedLabel);
const height = determineHeight(isBarChart, labels);

View file

@ -11,7 +11,7 @@ import {
import { Line } from 'react-chartjs-2';
import { always, cond, reverse } from 'ramda';
import moment from 'moment';
import { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
import { NormalizedVisit, Stats } from '../types';
import { fillTheGaps, renderNonDoughnutChartLabel } from '../../utils/helpers/visits';
import { useToggle } from '../../utils/helpers/hooks';
@ -19,12 +19,14 @@ import { rangeOf } from '../../utils/utils';
import ToggleSwitch from '../../utils/ToggleSwitch';
import { prettify } from '../../utils/helpers/numbers';
import './LineChartCard.scss';
import { pointerOnHover } from '../../utils/helpers/charts';
interface LineChartCardProps {
title: string;
highlightedLabel?: string;
visits: NormalizedVisit[];
highlightedVisits: NormalizedVisit[];
setSelectedVisits?: (visits: NormalizedVisit[]) => void;
}
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
@ -71,13 +73,25 @@ const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => visi
(acc, visit) => {
const key = STEP_TO_DATE_FORMAT[step](visit.date);
acc[key] = acc[key] ? acc[key] + 1 : 1;
acc[key] = (acc[key] || 0) + 1;
return acc;
},
{},
);
const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) => visits.reduce(
(acc, visit) => {
const key = STEP_TO_DATE_FORMAT[step](visit.date);
acc[key] = acc[key] ?? [];
acc[key].push(visit);
return acc;
},
{} as Record<string, NormalizedVisit[]>,
);
const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
const unit = STEP_TO_DATE_UNIT_MAP[step];
const formatter = STEP_TO_DATE_FORMAT[step];
@ -115,12 +129,37 @@ const generateDataset = (data: number[], label: string, color: string): ChartDat
backgroundColor: color,
});
const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'Selected' }: LineChartCardProps) => {
let selectedLabel: string | null = null;
const chartElementAtEvent = (
datasetsByPoint: Record<string, NormalizedVisit[]>,
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
) => ([ chart ]: [{ _index: number; _chart: Chart }]) => {
if (!setSelectedVisits || !chart) {
return;
}
const { _index: index, _chart: { data } } = chart;
const { labels } = data as { labels: string[] };
if (selectedLabel === labels[index]) {
setSelectedVisits([]);
selectedLabel = null;
} else {
setSelectedVisits(labels[index] && datasetsByPoint[labels[index]] || []);
selectedLabel = labels[index] ?? null;
}
};
const LineChartCard = (
{ title, visits, highlightedVisits, highlightedLabel = 'Selected', setSelectedVisits }: LineChartCardProps,
) => {
const [ step, setStep ] = useState<Step>(
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
);
const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true);
const datasetsByPoint = useMemo(() => visitsToDatasetGroups(step, visits), [ step, visits ]);
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ step, visits ]);
const [ labels, groupedVisits ] = useMemo(
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
@ -166,6 +205,7 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
label: renderNonDoughnutChartLabel('yLabel'),
},
},
onHover: (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
};
return (
@ -193,7 +233,11 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
</div>
</CardHeader>
<CardBody className="line-chart-card__body">
<Line data={data} options={options} />
<Line
data={data}
options={options}
getElementAtEvent={chartElementAtEvent(datasetsByPoint, setSelectedVisits)}
/>
</CardBody>
</Card>
);

View file

@ -53,7 +53,7 @@ describe('<LineChartCard />', () => {
const wrapper = createWrapper();
const chart = wrapper.find(Line);
expect(chart.prop('options')).toEqual({
expect(chart.prop('options')).toEqual(expect.objectContaining({
maintainAspectRatio: false,
legend: { display: false },
scales: {
@ -72,7 +72,7 @@ describe('<LineChartCard />', () => {
intersect: false,
axis: 'x',
}),
});
}));
});
it.each([