Refactored components used to render charts for visits

This commit is contained in:
Alejandro Celaya
2021-09-18 19:05:28 +02:00
parent 27c4bd792b
commit 3c23016028
17 changed files with 350 additions and 320 deletions

View File

@@ -0,0 +1,4 @@
.chart-card__footer--sticky {
position: sticky;
bottom: 0;
}

View File

@@ -0,0 +1,16 @@
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
import { FC, ReactNode } from 'react';
import './ChartCard.scss';
interface ChartCardProps {
title: Function | string;
footer?: ReactNode;
}
export const ChartCard: FC<ChartCardProps> = ({ title, footer, children }) => (
<Card>
<CardHeader className="chart-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{children}</CardBody>
{footer && <CardFooter className="chart-card__footer--sticky">{footer}</CardFooter>}
</Card>
);

View File

@@ -0,0 +1,72 @@
import { FC, useState, memo } from 'react';
import { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js';
import { keys, values } from 'ramda';
import { Doughnut } from 'react-chartjs-2';
import { renderPieChartLabel } from '../../utils/helpers/charts';
import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '../../utils/theme';
import { Stats } from '../types';
import { DoughnutChartLegend } from './DoughnutChartLegend';
interface DoughnutChartProps {
stats: Stats;
}
const generateChartDatasets = (data: number[]): ChartDataset[] => [
{
data,
backgroundColor: [
'#97BBCD',
'#F7464A',
'#46BFBD',
'#FDB45C',
'#949FB1',
'#57A773',
'#414066',
'#08B2E3',
'#B6C454',
'#DCDCDC',
'#463730',
],
borderColor: isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
borderWidth: 2,
},
];
const generateChartData = (labels: string[], data: number[]): ChartData => ({
labels,
datasets: generateChartDatasets(data),
});
export const DoughnutChart: FC<DoughnutChartProps> = memo(({ stats }) => {
const [ chartRef, setChartRef ] = useState<Chart | undefined>(); // Cannot use useRef here
const labels = keys(stats);
const data = values(stats);
const options: ChartOptions = {
plugins: {
legend: { display: false },
tooltip: {
intersect: true,
callbacks: { label: renderPieChartLabel },
},
},
};
const chartData = generateChartData(labels, data);
return (
<div className="row">
<div className="col-sm-12 col-md-7">
<Doughnut
height={300}
data={chartData}
options={options}
ref={(element) => {
setChartRef(element ?? undefined);
}}
/>
</div>
<div className="col-sm-12 col-md-5">
{chartRef && <DoughnutChartLegend chart={chartRef} />}
</div>
</div>
);
});

View File

@@ -0,0 +1,15 @@
import { FC } from 'react';
import { Stats } from '../types';
import { DoughnutChart } from './DoughnutChart';
import { ChartCard } from './ChartCard';
interface DoughnutChartCardProps {
title: string;
stats: Stats;
}
export const DoughnutChartCard: FC<DoughnutChartCardProps> = ({ title, stats }) => (
<ChartCard title={title}>
<DoughnutChart stats={stats} />
</ChartCard>
);

View File

@@ -0,0 +1,29 @@
@import '../../utils/base';
.doughnut-chart-legend {
list-style-type: none;
padding: 0;
margin: 0;
@media (max-width: $smMax) {
margin-top: 1rem;
}
}
.doughnut-chart-legend__item:not(:first-child) {
margin-top: .3rem;
}
.doughnut-chart-legend__item-color {
width: 20px;
min-width: 20px;
height: 20px;
margin-right: 5px;
border-radius: 10px;
}
.doughnut-chart-legend__item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,28 @@
import { FC } from 'react';
import { Chart } from 'chart.js';
import './DoughnutChartLegend.scss';
interface DoughnutChartLegendProps {
chart: Chart;
}
export const DoughnutChartLegend: FC<DoughnutChartLegendProps> = ({ chart }) => {
const { config } = chart;
const { labels = [], datasets = [] } = config.data ?? {};
const [{ backgroundColor: colors }] = datasets;
const { defaultColor } = config.options ?? {} as any;
return (
<ul className="doughnut-chart-legend">
{(labels as string[]).map((label, index) => (
<li key={label} className="doughnut-chart-legend__item d-flex">
<div
className="doughnut-chart-legend__item-color"
style={{ backgroundColor: (colors as string[])[index] ?? defaultColor }}
/>
<small className="doughnut-chart-legend__item-text flex-fill">{label}</small>
</li>
))}
</ul>
);
};

View File

@@ -0,0 +1,131 @@
import { FC } from 'react';
import { ChartData, ChartDataset, ChartOptions } from 'chart.js';
import { keys, values } from 'ramda';
import { Bar } from 'react-chartjs-2';
import { fillTheGaps } from '../../utils/helpers/visits';
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
import { prettify } from '../../utils/helpers/numbers';
import { Stats } from '../types';
import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../utils/theme';
export interface HorizontalBarChartProps {
stats: Stats;
max?: number;
highlightedStats?: Stats;
highlightedLabel?: string;
onClick?: (label: string) => void;
}
const dropLabelIfHidden = (label: string) => label.startsWith('hidden') ? '' : label;
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
const determineHeight = (labels: string[]): number | undefined => labels.length > 20 ? labels.length * 10 : undefined;
const generateChartDatasets = (
data: number[],
highlightedData: number[],
highlightedLabel?: string,
): ChartDataset[] => {
const mainDataset: ChartDataset = {
data,
label: highlightedLabel ? 'Non-selected' : 'Visits',
backgroundColor: MAIN_COLOR_ALPHA,
borderColor: MAIN_COLOR,
borderWidth: 2,
};
if (highlightedData.every((value) => value === 0)) {
return [ mainDataset ];
}
const highlightedDataset: ChartDataset = {
label: highlightedLabel ?? 'Selected',
data: highlightedData,
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
borderColor: HIGHLIGHTED_COLOR,
borderWidth: 2,
};
return [ mainDataset, highlightedDataset ];
};
const generateChartData = (
labels: string[],
data: number[],
highlightedData: number[],
highlightedLabel?: string,
): ChartData => ({
labels,
datasets: generateChartDatasets(data, highlightedData, highlightedLabel),
});
type ClickedCharts = [{ index: number }] | [];
const chartElementAtEvent = (labels: string[], onClick?: (label: string) => void) => ([ chart ]: ClickedCharts) => {
if (!onClick || !chart) {
return;
}
onClick(labels[chart.index]);
};
export const HorizontalBarChart: FC<HorizontalBarChartProps> = (
{ stats, highlightedStats, highlightedLabel, onClick, max },
) => {
const labels = keys(stats).map(dropLabelIfHidden);
const data = values(
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
if (acc[highlightedKey]) {
acc[highlightedKey] -= highlightedStats[highlightedKey];
}
return acc;
}, { ...stats }),
);
const highlightedData = fillTheGaps(highlightedStats ?? {}, labels);
const options: ChartOptions = {
plugins: {
legend: { display: false },
tooltip: {
mode: 'y',
// Do not show tooltip on items with empty label when in a bar chart
filter: ({ label }) => label !== '',
callbacks: { label: renderChartLabel },
},
},
scales: {
x: {
beginAtZero: true,
stacked: true,
max,
ticks: {
precision: 0,
callback: prettify,
},
},
y: { stacked: true },
},
onHover: pointerOnHover,
indexAxis: 'y',
};
const chartData = generateChartData(labels, data, highlightedData, highlightedLabel);
const height = determineHeight(labels);
// Provide a key based on the height, to force re-render every time the dataset changes (example, due to pagination)
const renderChartComponent = (customKey: string) => (
<Bar
key={`${height}_${customKey}`}
data={chartData}
options={options}
height={height}
getElementAtEvent={chartElementAtEvent(labels, onClick) as any}
/>
);
return (
<>
{/* 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 */}
{highlightedStats !== undefined && renderChartComponent('with_stats')}
{highlightedStats === undefined && renderChartComponent('without_stats')}
</>
);
};

View File

@@ -0,0 +1,142 @@
import { FC, useState } from 'react';
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
import { OrderDir, rangeOf } from '../../utils/utils';
import SimplePaginator from '../../common/SimplePaginator';
import { roundTen } from '../../utils/helpers/numbers';
import SortingDropdown from '../../utils/SortingDropdown';
import PaginationDropdown from '../../utils/PaginationDropdown';
import { Stats, StatsRow } from '../types';
import { HorizontalBarChart, HorizontalBarChartProps } from './HorizontalBarChart';
import { ChartCard } from './ChartCard';
interface SortableBarChartCardProps extends Omit<HorizontalBarChartProps, 'max'> {
title: Function | string;
sortingItems: Record<string, string>;
withPagination?: boolean;
extraHeaderContent?: Function;
}
const toLowerIfString = (value: any) => type(value) === 'String' ? toLower(value) : value; // eslint-disable-line @typescript-eslint/no-unsafe-return
const pickKeyFromPair = ([ key ]: StatsRow) => key;
const pickValueFromPair = ([ , value ]: StatsRow) => value;
export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
stats,
highlightedStats,
title,
sortingItems,
extraHeaderContent,
withPagination = true,
...rest
}) => {
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({
orderField: undefined,
orderDir: undefined,
});
const [ currentPage, setCurrentPage ] = useState(1);
const [ itemsPerPage, setItemsPerPage ] = useState(50);
const getSortedPairsForStats = (stats: Stats, sortingItems: Record<string, string>) => {
const pairs = toPairs(stats);
const sortedPairs = !order.orderField ? pairs : sortBy(
pipe<StatsRow, string | number, string | number>(
order.orderField === Object.keys(sortingItems)[0] ? pickKeyFromPair : pickValueFromPair,
toLowerIfString,
),
pairs,
);
return !order.orderDir || order.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
};
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
const page = pages[currentPage - 1];
if (currentPage < pages.length) {
return page;
}
const firstPageLength = pages[0].length;
// Using the "hidden" key, the chart will just replace the label by an empty string
return [ ...page, ...rangeOf(firstPageLength - page.length, (i): StatsRow => [ `hidden_${i}`, 0 ]) ];
};
const renderPagination = (pagesCount: number) =>
<SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
const determineStats = (stats: Stats, highlightedStats: Stats | undefined, sortingItems: Record<string, string>) => {
const sortedPairs = getSortedPairsForStats(stats, sortingItems);
const sortedKeys = sortedPairs.map(pickKeyFromPair);
// The highlighted stats have to be ordered based on the regular stats, not on its own values
const sortedHighlightedPairs = highlightedStats && toPairs(
{ ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...highlightedStats },
);
if (sortedPairs.length <= itemsPerPage) {
return {
currentPageStats: fromPairs(sortedPairs),
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
};
}
const pages = splitEvery(itemsPerPage, sortedPairs);
const highlightedPages = sortedHighlightedPairs && splitEvery(itemsPerPage, sortedHighlightedPairs);
return {
currentPageStats: fromPairs(determineCurrentPagePairs(pages)),
currentPageHighlightedStats: highlightedPages && fromPairs(determineCurrentPagePairs(highlightedPages)),
pagination: renderPagination(pages.length),
max: roundTen(Math.max(...sortedPairs.map(pickValueFromPair))),
};
};
const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats(
stats,
highlightedStats && Object.keys(highlightedStats).length > 0 ? highlightedStats : undefined,
sortingItems,
);
const activeCities = Object.keys(currentPageStats);
const computeTitle = () => (
<>
{title}
<div className="float-right">
<SortingDropdown
isButton={false}
right
items={sortingItems}
orderField={order.orderField}
orderDir={order.orderDir}
onChange={(orderField, orderDir) => {
setOrder({ orderField, orderDir });
setCurrentPage(1);
}}
/>
</div>
{withPagination && Object.keys(stats).length > 50 && (
<div className="float-right">
<PaginationDropdown
toggleClassName="btn-sm p-0 mr-3"
ranges={[ 50, 100, 200, 500 ]}
value={itemsPerPage}
setValue={(itemsPerPage) => {
setItemsPerPage(itemsPerPage);
setCurrentPage(1);
}}
/>
</div>
)}
{extraHeaderContent && (
<div className="float-right">
{extraHeaderContent(pagination ? activeCities : undefined)}
</div>
)}
</>
);
return (
<ChartCard
title={computeTitle}
footer={pagination}
>
<HorizontalBarChart stats={currentPageStats} highlightedStats={currentPageHighlightedStats} max={max} {...rest} />
</ChartCard>
);
};