import React, { useEffect, useMemo } from 'react'; import moment, { Moment } from 'moment-timezone'; import { keyBy, cloneDeep } from 'lodash'; import 'chartjs-adapter-moment'; import PropTypes from 'prop-types'; import { ThemeProvider } from 'styled-components'; import { Chart, ChartOptions, LineController, LineElement, PointElement, CategoryScale, LinearScale, TimeScale, Tooltip, Filler, } from 'chart.js'; import gradient from '../utils/chart-js-plugins/gradient'; import { DEFAULT_TIMEZONE } from '../commons/constant'; import { TimedFormattedDatapoint, TimedRawData, TimeRangedLineGraphProps, } from './interfaces'; import { Container, HeaderContainer, Title, ChartContainer, LegendContainer, RenderLegend, LegendLine, LegendLabel, CanvasChart, } from './styledComponents'; import { DEFAULT_LINE_CHART_DATASET, getStandardLineChartOptions, } from './utils/config'; import theme, { getTheme } from '../utils/theme'; Chart.register( LineController, LineElement, PointElement, CategoryScale, LinearScale, TimeScale, Tooltip, Filler, gradient ); const DASHED_YEAR_MONTH_DAY = 'YYYY-MM-DD'; const timeRangeToDateArray = ( startDate: string, endDate: string, format: string, timezone: string ): Moment[] => { const dateArray: Moment[] = []; const currentDate = moment(startDate, format).tz(timezone); const end = moment(endDate, format).tz(timezone); const diffDays = end.diff(currentDate, 'days'); for (let i = 0; i <= diffDays; i += 1) { dateArray.push(moment(currentDate.format(format), format).tz(timezone)); currentDate.add(1, 'day'); } return dateArray; }; const formatDataPointsOverTimeRange = ( rawData: TimedRawData[], dateArray: Moment[], format: string, yAxisPropertyKey: string, xAxisPropertyKey: string, timezone: string ): TimedFormattedDatapoint[] => { const rawDataKeyByMatchingDateKey = keyBy(rawData, xAxisPropertyKey); return dateArray.map((currentDate) => { const formattedCurrentDate = moment .tz(currentDate, timezone) .format(format); const result: TimedFormattedDatapoint = { x: currentDate, y: null, }; if (rawDataKeyByMatchingDateKey[formattedCurrentDate]) { Object.assign(result, { ...rawDataKeyByMatchingDateKey[formattedCurrentDate], y: rawDataKeyByMatchingDateKey[formattedCurrentDate][yAxisPropertyKey], }); } return result; }); }; const TimeRangedLineGraph: React.FC = ( props: TimeRangedLineGraphProps ) => { const { rawData, startDate, endDate, title, yAxisPropertyKey, xAxisPropertyKey, defaultLineChartDataset, legend, renderTooltipHeader, renderTooltipBody, renderEmptyState, tooltipTitlePropertyKey, tooltipMinWidth, chartName, timezone, } = props; const dateArray = timeRangeToDateArray( startDate, endDate, DASHED_YEAR_MONTH_DAY, timezone || DEFAULT_TIMEZONE ); const formattedDataPoints = formatDataPointsOverTimeRange( rawData, dateArray, DASHED_YEAR_MONTH_DAY, yAxisPropertyKey, xAxisPropertyKey, timezone || DEFAULT_TIMEZONE ); const normalizeChartStartAndEndValue = (dataPoints) => { if (!dataPoints.length) { return []; } // Use clone deep to avoid reverse of value after formatting const clonedDataPoints = cloneDeep(dataPoints); const firstValidDataPoint = clonedDataPoints.find( (dataPoint) => dataPoint.y != null && dataPoint.y >= 0 ); const lastValidDataPoint = clonedDataPoints .reverse() .find((dataPoint) => dataPoint.y != null && dataPoint.y >= 0); if (firstValidDataPoint) { Object.assign(dataPoints[0], { y: firstValidDataPoint.y }); } if (lastValidDataPoint) { Object.assign(dataPoints[dataPoints.length - 1], { y: lastValidDataPoint.y, }); } return dataPoints; }; const chartData = useMemo( () => ({ labels: dateArray, datasets: [ { ...defaultLineChartDataset, data: normalizeChartStartAndEndValue(formattedDataPoints), }, ], }), [dateArray, defaultLineChartDataset, formattedDataPoints] ); // Used as function to be able to pass function who going to render the tooltip on next step const chartOptions: ChartOptions<'line'> = getStandardLineChartOptions( startDate, endDate, timezone || DEFAULT_TIMEZONE, tooltipTitlePropertyKey, renderTooltipHeader, renderTooltipBody, xAxisPropertyKey, tooltipMinWidth ); useEffect(() => { if ((!chartOptions && !chartData) || !!renderEmptyState) { return; } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const ctx = document.getElementById(chartName).getContext('2d'); // eslint-disable-next-line no-new const customChart = new Chart(ctx, { type: 'line', data: chartData, options: chartOptions, plugins: { // Required disable TsLint because gradient plugin of librairie doesn't match the required type // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore gradient, }, }); // eslint-disable-next-line consistent-return return () => { customChart.destroy(); }; }); return ( <> {!!renderEmptyState && renderEmptyState()} {!renderEmptyState && ( <> {title} {!!legend && ( {legend} )} )} ); }; TimeRangedLineGraph.propTypes = { startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, yAxisPropertyKey: PropTypes.string.isRequired, title: PropTypes.string.isRequired, xAxisPropertyKey: PropTypes.string.isRequired, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore defaultLineChartDataset: PropTypes.shape({ backgroundColor: PropTypes.string, borderColor: PropTypes.string, spanGaps: PropTypes.bool, data: PropTypes.arrayOf(PropTypes.shape({})), }), legend: PropTypes.string, renderTooltipHeader: PropTypes.func, renderTooltipBody: PropTypes.func, renderEmptyState: PropTypes.func, tooltipTitlePropertyKey: PropTypes.string, tooltipMinWidth: PropTypes.string, chartName: PropTypes.string.isRequired, timezone: PropTypes.string, }; TimeRangedLineGraph.defaultProps = { defaultLineChartDataset: DEFAULT_LINE_CHART_DATASET, legend: '', renderTooltipHeader: undefined, renderTooltipBody: undefined, renderEmptyState: undefined, tooltipTitlePropertyKey: '', tooltipMinWidth: undefined, timezone: DEFAULT_TIMEZONE, }; TimeRangedLineGraph.displayName = 'TimeRangedLineGraph'; export default TimeRangedLineGraph;