shlink-web-client/src/visits/charts/LineChartCard.tsx

270 lines
8.6 KiB
TypeScript
Raw Normal View History

2022-05-02 12:35:05 +03:00
import { useState, useMemo, MutableRefObject, useRef } from 'react';
import {
Card,
CardHeader,
CardBody,
UncontrolledDropdown,
DropdownToggle,
DropdownMenu,
DropdownItem,
} from 'reactstrap';
2022-05-02 12:35:05 +03:00
import { getElementAtEvent, Line } from 'react-chartjs-2';
import { always, cond, countBy, reverse } from 'ramda';
import {
add,
differenceInDays,
differenceInHours,
differenceInMonths,
differenceInWeeks,
parseISO,
format,
startOfISOWeek,
endOfISOWeek,
} from 'date-fns';
2022-05-02 12:35:05 +03:00
import { ChartData, ChartDataset, ChartOptions, InteractionItem } 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';
import { ToggleSwitch } from '../../utils/ToggleSwitch';
import { prettify } from '../../utils/helpers/numbers';
import { pointerOnHover, renderChartLabel } 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';
import { STANDARD_DATE_FORMAT } from '../../utils/helpers/date';
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[];
setSelectedVisits?: (visits: NormalizedVisit[]) => void;
}
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',
};
2021-06-25 20:33:18 +03:00
const STEP_TO_DURATION_MAP: Record<Step, (amount: number) => Duration> = {
hourly: (hours: number) => ({ hours }),
daily: (days: number) => ({ days }),
weekly: (weeks: number) => ({ weeks }),
monthly: (months: number) => ({ months }),
};
const STEP_TO_DIFF_FUNC_MAP: Record<Step, (dateLeft: Date, dateRight: Date) => number> = {
hourly: differenceInHours,
daily: differenceInDays,
weekly: differenceInWeeks,
monthly: differenceInMonths,
2020-05-30 18:39:08 +03:00
};
const STEP_TO_DATE_FORMAT: Record<Step, (date: Date) => string> = {
hourly: (date) => format(date, 'yyyy-MM-dd HH:00'),
daily: (date) => format(date, STANDARD_DATE_FORMAT),
weekly(date) {
const firstWeekDay = format(startOfISOWeek(date), STANDARD_DATE_FORMAT);
const lastWeekDay = format(endOfISOWeek(date), STANDARD_DATE_FORMAT);
return `${firstWeekDay} - ${lastWeekDay}`;
},
monthly: (date) => format(date, 'yyyy-MM'),
};
const determineInitialStep = (oldestVisitDate: string): Step => {
const now = new Date();
const oldestDate = parseISO(oldestVisitDate);
const matcher = cond<never, Step | undefined>([
2022-03-26 14:17:42 +03:00
[() => differenceInDays(now, oldestDate) <= 2, always<Step>('hourly')], // Less than 2 days
[() => differenceInMonths(now, oldestDate) <= 1, always<Step>('daily')], // Between 2 days and 1 month
[() => differenceInMonths(now, oldestDate) <= 6, always<Step>('weekly')], // Between 1 and 6 months
2020-05-31 21:16:15 +03:00
]);
return matcher() ?? 'monthly';
};
const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy(
(visit) => STEP_TO_DATE_FORMAT[step](parseISO(visit.date)),
visits,
2020-09-05 09:49:18 +03:00
);
const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) =>
visits.reduce<Record<string, NormalizedVisit[]>>(
(acc, visit) => {
const key = STEP_TO_DATE_FORMAT[step](parseISO(visit.date));
acc[key] = acc[key] ?? [];
acc[key].push(visit);
return acc;
},
{},
);
2020-09-06 11:22:21 +03:00
const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
const diffFunc = STEP_TO_DIFF_FUNC_MAP[step];
const formatter = STEP_TO_DATE_FORMAT[step];
const newerDate = parseISO(visits[0].date);
const oldestDate = parseISO(visits[visits.length - 1].date);
const size = diffFunc(newerDate, oldestDate);
const duration = STEP_TO_DURATION_MAP[step];
2020-05-30 18:39:08 +03:00
return [
formatter(oldestDate),
2021-06-25 20:33:18 +03:00
...rangeOf(size, (num) => formatter(add(oldestDate, duration(num)))),
2020-05-30 18:39:08 +03:00
];
};
const generateLabelsAndGroupedVisits = (
2020-09-06 11:22:21 +03:00
visits: NormalizedVisit[],
groupedVisitsWithGaps: Stats,
step: Step,
skipNoElements: boolean,
): [string[], number[]] => {
2020-05-30 18:39:08 +03:00
if (skipNoElements) {
2022-03-26 14:17:42 +03:00
return [Object.keys(groupedVisitsWithGaps), Object.values(groupedVisitsWithGaps)];
2020-05-30 18:39:08 +03:00
}
const labels = generateLabels(step, visits);
2022-03-26 14:17:42 +03:00
return [labels, fillTheGaps(groupedVisitsWithGaps, labels)];
2020-05-30 18:39:08 +03:00
};
const generateDataset = (data: number[], label: string, color: string): ChartDataset => ({
label,
data,
fill: false,
tension: 0.2,
borderColor: color,
backgroundColor: color,
});
let selectedLabel: string | null = null;
const chartElementAtEvent = (
labels: string[],
datasetsByPoint: Record<string, NormalizedVisit[]>,
2022-05-02 12:35:05 +03:00
[chart]: InteractionItem[],
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
2022-05-02 12:35:05 +03:00
) => {
if (!setSelectedVisits || !chart) {
return;
}
const { index } = chart;
if (selectedLabel === labels[index]) {
setSelectedVisits([]);
selectedLabel = null;
} else {
2022-05-02 12:35:05 +03:00
setSelectedVisits(labels[index] && datasetsByPoint[labels[index]] ? datasetsByPoint[labels[index]] : []);
selectedLabel = labels[index] ?? null;
}
};
export const LineChartCard = (
{ title, visits, highlightedVisits, highlightedLabel = 'Selected', setSelectedVisits }: LineChartCardProps,
) => {
2022-03-26 14:17:42 +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',
);
2022-03-26 14:17:42 +03:00
const [skipNoVisits, toggleSkipNoVisits] = useToggle(true);
2022-05-02 12:35:05 +03:00
const refWithHighlightedVisits = useRef(null);
const refWithoutHighlightedVisits = useRef(null);
2020-05-30 18:39:08 +03:00
2022-03-26 14:17:42 +03:00
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),
2022-03-26 14:17:42 +03:00
[visits, step, skipNoVisits],
2020-05-30 18:39:08 +03:00
);
const groupedHighlighted = useMemo(
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
2022-03-26 14:17:42 +03:00
[highlightedVisits, step, labels],
);
2021-09-18 13:07:05 +03:00
const generateChartDatasets = (): ChartDataset[] => {
const mainDataset = generateDataset(groupedVisits, 'Visits', MAIN_COLOR);
2021-09-18 13:07:05 +03:00
if (highlightedVisits.length === 0) {
2022-03-26 14:17:42 +03:00
return [mainDataset];
2021-09-18 13:07:05 +03:00
}
const highlightedDataset = generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR);
2022-03-26 14:17:42 +03:00
return [mainDataset, highlightedDataset];
};
2021-09-18 13:07:05 +03:00
const generateChartData = (): ChartData => ({ labels, datasets: generateChartDatasets() });
const options: ChartOptions = {
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
intersect: false,
axis: 'x',
callbacks: { label: renderChartLabel },
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
callback: prettify,
2020-05-30 18:39:08 +03:00
},
},
x: {
title: { display: true, text: STEPS_MAP[step] },
},
},
2021-09-18 13:07:05 +03:00
onHover: pointerOnHover,
};
2022-05-02 12:35:05 +03:00
const renderLineChart = (theRef: MutableRefObject<any>) => (
2021-09-18 13:07:05 +03:00
<Line
2022-05-02 12:35:05 +03:00
ref={theRef}
2022-03-07 19:39:03 +03:00
data={generateChartData() as any}
options={options as any}
2022-05-02 12:35:05 +03:00
onClick={(e) =>
chartElementAtEvent(labels, datasetsByPoint, getElementAtEvent(theRef.current, e), setSelectedVisits)}
2021-09-18 13:07:05 +03:00
/>
);
return (
<Card>
<CardHeader role="heading">
{title}
<div className="float-end">
<UncontrolledDropdown>
<DropdownToggle caret color="link" className="btn-sm p-0">
Group by
</DropdownToggle>
2022-03-11 18:37:41 +03:00
<DropdownMenu end>
2022-03-26 14:17:42 +03:00
{Object.entries(STEPS_MAP).map(([value, menuText]) => (
<DropdownItem key={value} active={step === value} onClick={() => setStep(value as Step)}>
{menuText}
</DropdownItem>
))}
</DropdownMenu>
</UncontrolledDropdown>
</div>
<div className="float-end me-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>
</CardHeader>
<CardBody className="line-chart-card__body">
2021-09-18 13:07:05 +03:00
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
2022-05-02 12:35:05 +03:00
{highlightedVisits.length > 0 && renderLineChart(refWithHighlightedVisits)}
{highlightedVisits.length === 0 && renderLineChart(refWithoutHighlightedVisits)}
</CardBody>
</Card>
);
};