2020-11-14 00:44:26 +03:00
|
|
|
import { useState, useMemo } from 'react';
|
2020-05-30 10:57:21 +03:00
|
|
|
import {
|
|
|
|
Card,
|
|
|
|
CardHeader,
|
|
|
|
CardBody,
|
|
|
|
UncontrolledDropdown,
|
|
|
|
DropdownToggle,
|
|
|
|
DropdownMenu,
|
|
|
|
DropdownItem,
|
|
|
|
} from 'reactstrap';
|
2020-05-30 10:25:15 +03:00
|
|
|
import { Line } from 'react-chartjs-2';
|
2020-05-31 21:16:15 +03:00
|
|
|
import { always, cond, reverse } from 'ramda';
|
2020-05-30 10:25:15 +03:00
|
|
|
import moment from 'moment';
|
2020-09-20 12:43:24 +03:00
|
|
|
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
|
2020-09-06 11:22:21 +03:00
|
|
|
import { NormalizedVisit, Stats } from '../types';
|
2020-09-20 12:58:40 +03:00
|
|
|
import { fillTheGaps } from '../../utils/helpers/visits';
|
2020-05-30 18:39:08 +03:00
|
|
|
import { useToggle } from '../../utils/helpers/hooks';
|
|
|
|
import { rangeOf } from '../../utils/utils';
|
2020-07-14 17:05:00 +03:00
|
|
|
import ToggleSwitch from '../../utils/ToggleSwitch';
|
2020-09-13 12:11:17 +03:00
|
|
|
import { prettify } from '../../utils/helpers/numbers';
|
2020-09-20 12:46:07 +03:00
|
|
|
import { pointerOnHover, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
|
2020-12-20 14:17:12 +03:00
|
|
|
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../utils/theme';
|
2020-09-15 23:22:56 +03:00
|
|
|
import './LineChartCard.scss';
|
2020-05-30 10:25:15 +03:00
|
|
|
|
2020-09-04 19:43:26 +03:00
|
|
|
interface LineChartCardProps {
|
|
|
|
title: string;
|
|
|
|
highlightedLabel?: string;
|
2020-09-06 11:22:21 +03:00
|
|
|
visits: NormalizedVisit[];
|
2020-09-05 09:49:18 +03:00
|
|
|
highlightedVisits: NormalizedVisit[];
|
2020-09-20 12:43:24 +03:00
|
|
|
setSelectedVisits?: (visits: NormalizedVisit[]) => void;
|
2020-09-04 19:43:26 +03:00
|
|
|
}
|
2020-05-30 10:25:15 +03:00
|
|
|
|
2020-09-04 19:43:26 +03:00
|
|
|
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
|
|
|
|
|
|
|
|
const STEPS_MAP: Record<Step, string> = {
|
2020-05-30 18:39:08 +03:00
|
|
|
monthly: 'Month',
|
|
|
|
weekly: 'Week',
|
|
|
|
daily: 'Day',
|
|
|
|
hourly: 'Hour',
|
|
|
|
};
|
|
|
|
|
2020-09-04 19:43:26 +03:00
|
|
|
const STEP_TO_DATE_UNIT_MAP: Record<Step, moment.unitOfTime.Diff> = {
|
2020-05-30 18:39:08 +03:00
|
|
|
hourly: 'hour',
|
|
|
|
daily: 'day',
|
|
|
|
weekly: 'week',
|
|
|
|
monthly: 'month',
|
|
|
|
};
|
2020-05-30 10:57:21 +03:00
|
|
|
|
2020-09-04 19:43:26 +03:00
|
|
|
const STEP_TO_DATE_FORMAT: Record<Step, (date: moment.Moment | string) => string> = {
|
2020-05-30 10:57:21 +03:00
|
|
|
hourly: (date) => moment(date).format('YYYY-MM-DD HH:00'),
|
|
|
|
daily: (date) => moment(date).format('YYYY-MM-DD'),
|
|
|
|
weekly(date) {
|
|
|
|
const firstWeekDay = moment(date).isoWeekday(1).format('YYYY-MM-DD');
|
|
|
|
const lastWeekDay = moment(date).isoWeekday(7).format('YYYY-MM-DD');
|
|
|
|
|
|
|
|
return `${firstWeekDay} - ${lastWeekDay}`;
|
|
|
|
},
|
|
|
|
monthly: (date) => moment(date).format('YYYY-MM'),
|
2020-05-30 10:25:15 +03:00
|
|
|
};
|
|
|
|
|
2020-09-04 19:43:26 +03:00
|
|
|
const determineInitialStep = (oldestVisitDate: string): Step => {
|
2020-05-31 21:03:59 +03:00
|
|
|
const now = moment();
|
|
|
|
const oldestDate = moment(oldestVisitDate);
|
2020-09-04 19:43:26 +03:00
|
|
|
const matcher = cond<never, Step | undefined>([
|
|
|
|
[ () => now.diff(oldestDate, 'day') <= 2, always<Step>('hourly') ], // Less than 2 days
|
|
|
|
[ () => now.diff(oldestDate, 'month') <= 1, always<Step>('daily') ], // Between 2 days and 1 month
|
|
|
|
[ () => now.diff(oldestDate, 'month') <= 6, always<Step>('weekly') ], // Between 1 and 6 months
|
2020-05-31 21:16:15 +03:00
|
|
|
]);
|
2020-05-31 21:03:59 +03:00
|
|
|
|
2020-09-04 19:43:26 +03:00
|
|
|
return matcher() ?? 'monthly';
|
2020-05-31 21:03:59 +03:00
|
|
|
};
|
|
|
|
|
2020-09-06 11:22:21 +03:00
|
|
|
const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => visits.reduce<Stats>(
|
2020-09-05 09:49:18 +03:00
|
|
|
(acc, visit) => {
|
|
|
|
const key = STEP_TO_DATE_FORMAT[step](visit.date);
|
2020-05-30 10:25:15 +03:00
|
|
|
|
2020-09-20 12:43:24 +03:00
|
|
|
acc[key] = (acc[key] || 0) + 1;
|
2020-05-30 10:25:15 +03:00
|
|
|
|
2020-09-05 09:49:18 +03:00
|
|
|
return acc;
|
|
|
|
},
|
|
|
|
{},
|
|
|
|
);
|
2020-05-30 10:25:15 +03:00
|
|
|
|
2020-09-20 12:43:24 +03:00
|
|
|
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[]>,
|
|
|
|
);
|
|
|
|
|
2020-09-06 11:22:21 +03:00
|
|
|
const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
|
2020-05-30 18:43:13 +03:00
|
|
|
const unit = STEP_TO_DATE_UNIT_MAP[step];
|
|
|
|
const formatter = STEP_TO_DATE_FORMAT[step];
|
2020-05-30 18:39:08 +03:00
|
|
|
const newerDate = moment(visits[0].date);
|
|
|
|
const oldestDate = moment(visits[visits.length - 1].date);
|
2020-05-30 18:43:13 +03:00
|
|
|
const size = newerDate.diff(oldestDate, unit);
|
2020-05-30 18:39:08 +03:00
|
|
|
|
|
|
|
return [
|
2020-05-30 18:43:13 +03:00
|
|
|
formatter(oldestDate),
|
|
|
|
...rangeOf(size, () => formatter(oldestDate.add(1, unit))),
|
2020-05-30 18:39:08 +03:00
|
|
|
];
|
|
|
|
};
|
|
|
|
|
2020-09-04 19:43:26 +03:00
|
|
|
const generateLabelsAndGroupedVisits = (
|
2020-09-06 11:22:21 +03:00
|
|
|
visits: NormalizedVisit[],
|
2020-09-04 19:43:26 +03:00
|
|
|
groupedVisitsWithGaps: Stats,
|
|
|
|
step: Step,
|
|
|
|
skipNoElements: boolean,
|
|
|
|
): [string[], number[]] => {
|
2020-05-30 18:39:08 +03:00
|
|
|
if (skipNoElements) {
|
2020-09-04 19:43:26 +03:00
|
|
|
return [ Object.keys(groupedVisitsWithGaps), Object.values(groupedVisitsWithGaps) ];
|
2020-05-30 18:39:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const labels = generateLabels(step, visits);
|
|
|
|
|
2020-05-31 09:55:52 +03:00
|
|
|
return [ labels, fillTheGaps(groupedVisitsWithGaps, labels) ];
|
2020-05-30 18:39:08 +03:00
|
|
|
};
|
|
|
|
|
2020-09-04 19:43:26 +03:00
|
|
|
const generateDataset = (data: number[], label: string, color: string): ChartDataSets => ({
|
2020-05-30 10:25:15 +03:00
|
|
|
label,
|
2020-09-04 19:43:26 +03:00
|
|
|
data,
|
2020-05-30 10:25:15 +03:00
|
|
|
fill: false,
|
|
|
|
lineTension: 0.2,
|
|
|
|
borderColor: color,
|
|
|
|
backgroundColor: color,
|
|
|
|
});
|
|
|
|
|
2020-09-20 12:43:24 +03:00
|
|
|
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,
|
|
|
|
) => {
|
2020-09-04 19:43:26 +03:00
|
|
|
const [ step, setStep ] = useState<Step>(
|
2020-08-22 09:06:41 +03:00
|
|
|
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
|
2020-05-31 21:03:59 +03:00
|
|
|
);
|
2020-05-30 18:39:08 +03:00
|
|
|
const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true);
|
|
|
|
|
2020-09-20 12:43:24 +03:00
|
|
|
const datasetsByPoint = useMemo(() => visitsToDatasetGroups(step, visits), [ step, visits ]);
|
2020-05-31 09:55:52 +03:00
|
|
|
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ step, visits ]);
|
2020-05-30 18:39:08 +03:00
|
|
|
const [ labels, groupedVisits ] = useMemo(
|
2020-05-31 09:55:52 +03:00
|
|
|
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
|
2020-08-22 09:06:41 +03:00
|
|
|
[ visits, step, skipNoVisits ],
|
2020-05-30 18:39:08 +03:00
|
|
|
);
|
2020-05-30 10:25:15 +03:00
|
|
|
const groupedHighlighted = useMemo(
|
|
|
|
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
|
2020-08-22 09:10:31 +03:00
|
|
|
[ highlightedVisits, step, labels ],
|
2020-05-30 10:25:15 +03:00
|
|
|
);
|
|
|
|
|
2020-09-04 19:43:26 +03:00
|
|
|
const data: ChartData = {
|
2020-05-30 10:25:15 +03:00
|
|
|
labels,
|
|
|
|
datasets: [
|
2020-12-20 14:17:12 +03:00
|
|
|
generateDataset(groupedVisits, 'Visits', MAIN_COLOR),
|
|
|
|
highlightedVisits.length > 0 && generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR),
|
2020-09-04 19:43:26 +03:00
|
|
|
].filter(Boolean) as ChartDataSets[],
|
2020-05-30 10:25:15 +03:00
|
|
|
};
|
2020-09-13 12:11:17 +03:00
|
|
|
const options: ChartOptions = {
|
2020-05-30 11:26:52 +03:00
|
|
|
maintainAspectRatio: false,
|
2020-05-30 10:25:15 +03:00
|
|
|
legend: { display: false },
|
|
|
|
scales: {
|
|
|
|
yAxes: [
|
|
|
|
{
|
2020-09-13 12:11:17 +03:00
|
|
|
ticks: {
|
|
|
|
beginAtZero: true,
|
|
|
|
// @ts-expect-error
|
|
|
|
precision: 0,
|
|
|
|
callback: prettify,
|
|
|
|
},
|
2020-05-30 10:25:15 +03:00
|
|
|
},
|
|
|
|
],
|
2020-05-30 18:39:08 +03:00
|
|
|
xAxes: [
|
|
|
|
{
|
|
|
|
scaleLabel: { display: true, labelString: STEPS_MAP[step] },
|
|
|
|
},
|
|
|
|
],
|
|
|
|
},
|
|
|
|
tooltips: {
|
|
|
|
intersect: false,
|
|
|
|
axis: 'x',
|
2020-09-13 12:11:17 +03:00
|
|
|
callbacks: {
|
2020-09-15 23:27:01 +03:00
|
|
|
label: renderNonDoughnutChartLabel('yLabel'),
|
2020-09-13 12:11:17 +03:00
|
|
|
},
|
2020-05-30 10:25:15 +03:00
|
|
|
},
|
2020-09-20 12:43:24 +03:00
|
|
|
onHover: (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
|
2020-05-30 10:25:15 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Card>
|
2020-05-30 10:57:21 +03:00
|
|
|
<CardHeader>
|
|
|
|
{title}
|
|
|
|
<div className="float-right">
|
|
|
|
<UncontrolledDropdown>
|
|
|
|
<DropdownToggle caret color="link" className="btn-sm p-0">
|
|
|
|
Group by
|
|
|
|
</DropdownToggle>
|
|
|
|
<DropdownMenu right>
|
2020-05-30 18:39:08 +03:00
|
|
|
{Object.entries(STEPS_MAP).map(([ value, menuText ]) => (
|
2020-09-04 19:43:26 +03:00
|
|
|
<DropdownItem key={value} active={step === value} onClick={() => setStep(value as Step)}>
|
2020-05-30 10:57:21 +03:00
|
|
|
{menuText}
|
|
|
|
</DropdownItem>
|
|
|
|
))}
|
|
|
|
</DropdownMenu>
|
|
|
|
</UncontrolledDropdown>
|
|
|
|
</div>
|
2020-05-30 18:39:08 +03:00
|
|
|
<div className="float-right mr-2">
|
2020-07-14 17:05:00 +03:00
|
|
|
<ToggleSwitch checked={skipNoVisits} onChange={toggleSkipNoVisits}>
|
2020-05-30 18:39:08 +03:00
|
|
|
<small>Skip dates with no visits</small>
|
2020-07-14 17:05:00 +03:00
|
|
|
</ToggleSwitch>
|
2020-05-30 18:39:08 +03:00
|
|
|
</div>
|
2020-05-30 10:57:21 +03:00
|
|
|
</CardHeader>
|
2020-05-30 11:26:52 +03:00
|
|
|
<CardBody className="line-chart-card__body">
|
2020-09-20 12:43:24 +03:00
|
|
|
<Line
|
|
|
|
data={data}
|
|
|
|
options={options}
|
|
|
|
getElementAtEvent={chartElementAtEvent(datasetsByPoint, setSelectedVisits)}
|
|
|
|
/>
|
2020-05-30 10:25:15 +03:00
|
|
|
</CardBody>
|
|
|
|
</Card>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export default LineChartCard;
|