mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-09 09:47:28 +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}
|
visits={normalizedVisits}
|
||||||
highlightedVisits={highlightedVisits}
|
highlightedVisits={highlightedVisits}
|
||||||
highlightedLabel={highlightedLabel}
|
highlightedLabel={highlightedLabel}
|
||||||
|
setSelectedVisits={setSelectedVisits}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-xl-4 col-lg-6 mt-4">
|
<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 { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
||||||
import { keys, values } from 'ramda';
|
import { keys, values } from 'ramda';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
@ -7,6 +7,7 @@ import { fillTheGaps, renderDoughnutChartLabel, renderNonDoughnutChartLabel } fr
|
||||||
import { Stats } from '../types';
|
import { Stats } from '../types';
|
||||||
import { prettify } from '../../utils/helpers/numbers';
|
import { prettify } from '../../utils/helpers/numbers';
|
||||||
import './DefaultChart.scss';
|
import './DefaultChart.scss';
|
||||||
|
import { pointerOnHover } from '../../utils/helpers/charts';
|
||||||
|
|
||||||
export interface DefaultChartProps {
|
export interface DefaultChartProps {
|
||||||
title: Function | string;
|
title: Function | string;
|
||||||
|
@ -145,11 +146,7 @@ const DefaultChart = (
|
||||||
label: isBarChart ? renderNonDoughnutChartLabel('xLabel') : renderDoughnutChartLabel,
|
label: isBarChart ? renderNonDoughnutChartLabel('xLabel') : renderDoughnutChartLabel,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onHover: !isBarChart ? undefined : ((e: ChangeEvent<HTMLElement>, chartElement: HorizontalBar[] | Doughnut[]) => {
|
onHover: !isBarChart ? undefined : (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
|
||||||
const { target } = e;
|
|
||||||
|
|
||||||
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
|
|
||||||
}) as any, // TODO Types seem to be incorrectly defined
|
|
||||||
};
|
};
|
||||||
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData, highlightedLabel);
|
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData, highlightedLabel);
|
||||||
const height = determineHeight(isBarChart, labels);
|
const height = determineHeight(isBarChart, labels);
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { always, cond, reverse } from 'ramda';
|
import { always, cond, reverse } from 'ramda';
|
||||||
import moment from 'moment';
|
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 { NormalizedVisit, Stats } from '../types';
|
||||||
import { fillTheGaps, renderNonDoughnutChartLabel } from '../../utils/helpers/visits';
|
import { fillTheGaps, renderNonDoughnutChartLabel } from '../../utils/helpers/visits';
|
||||||
import { useToggle } from '../../utils/helpers/hooks';
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
|
@ -19,12 +19,14 @@ import { rangeOf } from '../../utils/utils';
|
||||||
import ToggleSwitch from '../../utils/ToggleSwitch';
|
import ToggleSwitch from '../../utils/ToggleSwitch';
|
||||||
import { prettify } from '../../utils/helpers/numbers';
|
import { prettify } from '../../utils/helpers/numbers';
|
||||||
import './LineChartCard.scss';
|
import './LineChartCard.scss';
|
||||||
|
import { pointerOnHover } from '../../utils/helpers/charts';
|
||||||
|
|
||||||
interface LineChartCardProps {
|
interface LineChartCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
highlightedLabel?: string;
|
highlightedLabel?: string;
|
||||||
visits: NormalizedVisit[];
|
visits: NormalizedVisit[];
|
||||||
highlightedVisits: NormalizedVisit[];
|
highlightedVisits: NormalizedVisit[];
|
||||||
|
setSelectedVisits?: (visits: NormalizedVisit[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
|
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
|
||||||
|
@ -71,13 +73,25 @@ const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => visi
|
||||||
(acc, visit) => {
|
(acc, visit) => {
|
||||||
const key = STEP_TO_DATE_FORMAT[step](visit.date);
|
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;
|
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 generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
|
||||||
const unit = STEP_TO_DATE_UNIT_MAP[step];
|
const unit = STEP_TO_DATE_UNIT_MAP[step];
|
||||||
const formatter = STEP_TO_DATE_FORMAT[step];
|
const formatter = STEP_TO_DATE_FORMAT[step];
|
||||||
|
@ -115,12 +129,37 @@ const generateDataset = (data: number[], label: string, color: string): ChartDat
|
||||||
backgroundColor: color,
|
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>(
|
const [ step, setStep ] = useState<Step>(
|
||||||
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
|
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
|
||||||
);
|
);
|
||||||
const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true);
|
const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true);
|
||||||
|
|
||||||
|
const datasetsByPoint = useMemo(() => visitsToDatasetGroups(step, visits), [ step, visits ]);
|
||||||
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ step, visits ]);
|
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ step, visits ]);
|
||||||
const [ labels, groupedVisits ] = useMemo(
|
const [ labels, groupedVisits ] = useMemo(
|
||||||
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
|
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
|
||||||
|
@ -166,6 +205,7 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
|
||||||
label: renderNonDoughnutChartLabel('yLabel'),
|
label: renderNonDoughnutChartLabel('yLabel'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
onHover: (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -193,7 +233,11 @@ const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'S
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="line-chart-card__body">
|
<CardBody className="line-chart-card__body">
|
||||||
<Line data={data} options={options} />
|
<Line
|
||||||
|
data={data}
|
||||||
|
options={options}
|
||||||
|
getElementAtEvent={chartElementAtEvent(datasetsByPoint, setSelectedVisits)}
|
||||||
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
@ -53,7 +53,7 @@ describe('<LineChartCard />', () => {
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
const chart = wrapper.find(Line);
|
const chart = wrapper.find(Line);
|
||||||
|
|
||||||
expect(chart.prop('options')).toEqual({
|
expect(chart.prop('options')).toEqual(expect.objectContaining({
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
scales: {
|
scales: {
|
||||||
|
@ -72,7 +72,7 @@ describe('<LineChartCard />', () => {
|
||||||
intersect: false,
|
intersect: false,
|
||||||
axis: 'x',
|
axis: 'x',
|
||||||
}),
|
}),
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|
Loading…
Reference in a new issue