mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-09 01:37:24 +03:00
Added visits highlightning capabilities to line chart
This commit is contained in:
parent
749c757cbd
commit
78d97a64aa
5 changed files with 59 additions and 12 deletions
5
src/utils/helpers/charts.ts
Normal file
5
src/utils/helpers/charts.ts
Normal 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';
|
||||
};
|
|
@ -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">
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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([
|
||||
|
|
Loading…
Reference in a new issue