import { D3_TRANSITION_DURATION, PRIMARY_STATISTICS, STATISTIC_CONFIGS, } from '../constants'; import { capitalize, formatNumber, formatDate, getStatistic, parseIndiaDate, } from '../utils/commonFunctions'; import classnames from 'classnames'; import {bisector, extent} from 'd3-array'; import {axisBottom, axisRight} from 'd3-axis'; import {interpolatePath} from 'd3-interpolate-path'; import {scaleTime, scaleLinear, scaleLog} from 'd3-scale'; import {select, pointer} from 'd3-selection'; import {area, line, curveLinear, curveMonotoneX, curveStep} from 'd3-shape'; import 'd3-transition'; import {differenceInDays} from 'date-fns'; import equal from 'fast-deep-equal'; import {memo, useCallback, useEffect, useRef, useMemo, useState} from 'react'; import {useTranslation} from 'react-i18next'; import {useMeasure} from 'react-use'; // Chart margins const margin = {top: 15, right: 35, bottom: 25, left: 25}; // Buffer space along y-axis const yScaleShrinkFactor = 0.7; const numTicksX = (width) => (width < 480 ? 4 : 6); function Timeseries({ statistics, timeseries, dates, endDate, chartType, isUniform, isLog, isMovingAverage, noRegionHighlightedDistrictData, }) { const {t} = useTranslation(); const refs = useRef([]); const [wrapperRef, {width, height}] = useMeasure(); const [highlightedDate, setHighlightedDate] = useState( dates[dates.length - 1] ); useEffect(() => { setHighlightedDate(dates[dates.length - 1]); }, [dates]); const getTimeseriesStatistic = useCallback( (date, statistic, movingAverage = isMovingAverage) => { return getStatistic(timeseries?.[date], chartType, statistic, { movingAverage, }); }, [chartType, isMovingAverage, timeseries] ); const condenseChart = useMemo(() => { const T = dates.length; const days = differenceInDays( parseIndiaDate(dates[T - 1]), parseIndiaDate(dates[0]) ); // Chart extremes const chartRight = width - margin.right; // Bar widths const axisWidth = Math.max(0, chartRight - margin.left) / (1.25 * days); return axisWidth < 4; }, [width, dates]); const xScale = useMemo(() => { const T = dates.length; const chartRight = width - margin.right; return scaleTime() .clamp(true) .domain([ parseIndiaDate(dates[0] || endDate), parseIndiaDate(dates[T - 1] || endDate), ]) .range([margin.left, chartRight]); }, [width, endDate, dates]); const yScales = useMemo(() => { const chartBottom = height - margin.bottom; const addScaleBuffer = (scale, log = false) => { const domain = scale.domain(); if (log) { scale.domain([ domain[0], domain[0] * Math.pow(domain[1] / domain[0], 1 / yScaleShrinkFactor), ]); } else { scale.domain([ domain[0], domain[0] + (domain[1] - domain[0]) / yScaleShrinkFactor, ]); } return scale; }; const [uniformScaleMin, uniformScaleMax] = extent( dates.reduce((res, date) => { res.push( ...PRIMARY_STATISTICS.map((statistic) => getTimeseriesStatistic(date, statistic) ) ); return res; }, []) ); const yScaleUniformLinear = addScaleBuffer( scaleLinear() .clamp(true) .domain([Math.min(0, uniformScaleMin), Math.max(1, uniformScaleMax)]) .range([chartBottom, margin.top]) .nice(4) ); const yScaleUniformLog = addScaleBuffer( scaleLog() .clamp(true) .domain([1, Math.max(10, uniformScaleMax)]) .range([chartBottom, margin.top]) .nice(4), true ); return statistics.map((statistic) => { if (isUniform) { if ( chartType === 'total' && isLog && PRIMARY_STATISTICS.includes(statistic) ) { return yScaleUniformLog; } else if (PRIMARY_STATISTICS.includes(statistic)) { return yScaleUniformLinear; } } const [scaleMin, scaleMax] = extent(dates, (date) => getTimeseriesStatistic(date, statistic) ); if (chartType === 'total' && isLog) { return addScaleBuffer( scaleLog() .clamp(true) .domain([1, Math.max(10, scaleMax)]) .range([chartBottom, margin.top]) .nice(4), true ); } else { return addScaleBuffer( scaleLinear() .clamp(true) .domain([ Math.min(0, scaleMin), STATISTIC_CONFIGS[statistic].format === '%' ? Math.min(100, Math.max(1, scaleMax)) : Math.max(1, scaleMax), ]) .range([chartBottom, margin.top]) .nice(4) ); } }); }, [ height, chartType, isUniform, isLog, statistics, dates, getTimeseriesStatistic, ]); useEffect(() => { if (!width || !height) return; const T = dates.length; // Chart extremes const chartRight = width - margin.right; const chartBottom = height - margin.bottom; const isDiscrete = chartType === 'delta' && !isMovingAverage; const xAxis = (g) => g .attr('class', 'x-axis') .call(axisBottom(xScale).ticks(numTicksX(width))); const xAxis2 = (g, yScale) => { g.attr('class', 'x-axis2') .call(axisBottom(xScale).tickValues([]).tickSize(0)) .select('.domain') .style('transform', `translate3d(0, ${yScale(0)}px, 0)`); if (yScale(0) !== chartBottom) g.select('.domain').attr('opacity', 0.4); else g.select('.domain').attr('opacity', 0); }; const yAxis = (g, yScale, format) => g.attr('class', 'y-axis').call( axisRight(yScale) .ticks(4) .tickFormat((num) => formatNumber(num, format)) .tickPadding(4) ); function mousemove(event) { const xm = pointer(event)[0]; const date = xScale.invert(xm); if (!isNaN(date)) { const bisectDate = bisector((date) => parseIndiaDate(date)).left; const index = bisectDate(dates, date, 1); const dateLeft = dates[index - 1]; const dateRight = dates[index]; setHighlightedDate( date - parseIndiaDate(dateLeft) < parseIndiaDate(dateRight) - date ? dateLeft : dateRight ); } } function mouseout(event) { setHighlightedDate(dates[T - 1]); } /* Begin drawing charts */ statistics.forEach((statistic, i) => { const ref = refs.current[i]; const svg = select(ref); const t = svg.transition().duration(D3_TRANSITION_DURATION); const yScale = yScales[i]; const statisticConfig = STATISTIC_CONFIGS[statistic]; const format = STATISTIC_CONFIGS[statistic].format === '%' ? '%' : 'short'; const isNonLinear = !!STATISTIC_CONFIGS[statistic]?.nonLinear; /* X axis */ svg .select('.x-axis') .style('transform', `translate3d(0, ${chartBottom}px, 0)`) .transition(t) .call(xAxis); svg.select('.x-axis2').transition(t).call(xAxis2, yScale); /* Y axis */ svg .select('.y-axis') .style('transform', `translate3d(${chartRight}px, 0, 0)`) .transition(t) .call(yAxis, yScale, format); /* Path dots */ svg .selectAll('circle.normal') .data(condenseChart ? [] : dates, (date) => date) .join((enter) => enter .append('circle') .attr('class', 'normal') .attr('fill', statisticConfig?.color) .attr('stroke', statisticConfig?.color) .attr('cx', (date) => xScale(parseIndiaDate(date))) .attr('cy', (date) => yScale(isDiscrete ? 0 : getTimeseriesStatistic(date, statistic)) ) .attr('r', 2) ) .transition(t) .attr('cx', (date) => xScale(parseIndiaDate(date))) .attr('cy', (date) => yScale(getTimeseriesStatistic(date, statistic))); const areaPath = (dates, allZero = false) => area() .curve(curveStep) .x((date) => xScale(parseIndiaDate(date))) .y0(yScale(0)) .y1( allZero ? yScale(0) : (date) => yScale(getTimeseriesStatistic(date, statistic, false)) )(dates); svg .selectAll('.trend-area') .data( T && chartType === 'delta' && !isNonLinear && condenseChart ? [dates] : [] ) .join( (enter) => enter .append('path') .attr('class', 'trend-area') .call((enter) => enter .attr('d', (dates) => areaPath(dates, true)) .transition(t) .attr('d', areaPath) ), (update) => update.call((update) => update.transition(t).attrTween('d', function (dates) { const previous = select(this).attr('d'); const current = areaPath(dates); return interpolatePath(previous, current); }) ), (exit) => exit.call((exit) => exit .transition(t) .attr('d', (dates) => areaPath(dates, true)) .remove() ) ) .transition(t) .attr('opacity', isMovingAverage ? 0.3 : 1); svg .selectAll('.stem') .data( T && chartType === 'delta' && !isNonLinear && !condenseChart ? dates : [], (date) => date ) .join( (enter) => enter .append('line') .attr('class', 'stem') .attr('stroke-width', 4) .attr('x1', (date) => xScale(parseIndiaDate(date))) .attr('y1', yScale(0)) .attr('x2', (date) => xScale(parseIndiaDate(date))) .attr('y2', yScale(0)), (update) => update, (exit) => exit.call((exit) => exit .transition(t) .attr('x1', (date) => xScale(parseIndiaDate(date))) .attr('x2', (date) => xScale(parseIndiaDate(date))) .attr('y1', yScale(0)) .attr('y2', yScale(0)) .remove() ) ) .transition(t) .attr('x1', (date) => xScale(parseIndiaDate(date))) .attr('y1', yScale(0)) .attr('x2', (date) => xScale(parseIndiaDate(date))) .attr('y2', (date) => yScale(getTimeseriesStatistic(date, statistic, false)) ) .attr('opacity', isMovingAverage ? 0.2 : 1); const linePath = ( movingAverage = isMovingAverage, curve = curveMonotoneX ) => line() .curve(curve) .x((date) => xScale(parseIndiaDate(date))) .y((date) => yScale(getTimeseriesStatistic(date, statistic, movingAverage)) ); svg .select('.trend') .selectAll('path') .data( T && (chartType === 'total' || isNonLinear || isMovingAverage) ? [dates] : [] ) .join( (enter) => enter .append('path') .attr('class', 'trend') .attr('fill', 'none') .attr('stroke-width', 4) .attr('d', linePath()) .call((enter) => enter.transition(t).attr('opacity', 1)), (update) => update.call((update) => update .transition(t) .attrTween('d', function (date) { const previous = select(this).attr('d'); const current = linePath()(date); return interpolatePath(previous, current); }) .attr('opacity', 1) ), (exit) => exit.call((exit) => exit.transition(t).attr('opacity', 0).remove()) ) .attr('stroke', statisticConfig?.color + (condenseChart ? '99' : '50')); svg .select('.trend-background') .selectAll('path') .data( T && chartType === 'delta' && isNonLinear && isMovingAverage ? [dates] : [] ) .join( (enter) => enter .append('path') .attr('class', 'trend-background') .attr('fill', 'none') .attr('stroke-width', 4) .attr('d', linePath(false, curveLinear)) .call((enter) => enter.transition(t).attr('opacity', 0.2)), (update) => update.call((update) => update .transition(t) .attrTween('d', function (date) { const previous = select(this).attr('d'); const current = linePath(false, curveLinear)(date); return interpolatePath(previous, current); }) .attr('opacity', 0.2) ), (exit) => exit.call((exit) => exit.transition(t).attr('opacity', 0).remove()) ) .attr('stroke', statisticConfig?.color + (condenseChart ? '99' : '50')); svg.selectAll('*').attr('pointer-events', 'none'); svg .on('mousemove', mousemove) .on('touchmove', (event) => mousemove(event.touches[0])) .on('mouseout touchend', mouseout); }); }, [ width, height, chartType, isMovingAverage, condenseChart, xScale, yScales, statistics, getTimeseriesStatistic, dates, ]); useEffect(() => { statistics.forEach((statistic, i) => { const ref = refs.current[i]; const svg = select(ref); const statisticConfig = STATISTIC_CONFIGS[statistic]; const yScale = yScales[i]; const t = svg.transition().duration(D3_TRANSITION_DURATION); svg .selectAll('circle.condensed') .data( condenseChart && highlightedDate ? [highlightedDate] : [], (date) => date ) .join((enter) => enter .append('circle') .attr('class', 'condensed') .attr('fill', statisticConfig?.color) .attr('stroke', statisticConfig?.color) .attr('pointer-events', 'none') .attr('cx', (date) => xScale(parseIndiaDate(date))) .attr('cy', (date) => yScale(getTimeseriesStatistic(date, statistic)) ) .attr('r', 4) ) .transition(t) .attr('cx', (date) => xScale(parseIndiaDate(date))) .attr('cy', (date) => yScale(getTimeseriesStatistic(date, statistic))); if (!condenseChart) { svg .selectAll('circle') .attr('r', (date) => (date === highlightedDate ? 4 : 2)); } }); }, [ condenseChart, highlightedDate, xScale, yScales, statistics, getTimeseriesStatistic, ]); const getTimeseriesStatisticDelta = useCallback( (statistic) => { if (!highlightedDate) return; const currCount = getTimeseriesStatistic(highlightedDate, statistic); if (STATISTIC_CONFIGS[statistic]?.hideZero && currCount === 0) return; const prevDate = dates[dates.findIndex((date) => date === highlightedDate) - 1]; const prevCount = getTimeseriesStatistic(prevDate, statistic); return currCount - prevCount; }, [dates, highlightedDate, getTimeseriesStatistic] ); const trail = useMemo( () => statistics.map((statistic, index) => ({ animationDelay: `${index * 250}ms`, })), [statistics] ); return ( <div className="Timeseries"> {statistics.map((statistic, index) => { const total = getTimeseriesStatistic(highlightedDate, statistic); const delta = getTimeseriesStatisticDelta(statistic, index); const statisticConfig = STATISTIC_CONFIGS[statistic]; return ( <div key={statistic} className={classnames('svg-parent fadeInUp', `is-${statistic}`)} style={trail[index]} ref={index === 0 ? wrapperRef : null} > {highlightedDate && ( <div className={classnames('stats', `is-${statistic}`)}> <h5 className="title"> {t(capitalize(statisticConfig.displayName))} </h5> <h5>{formatDate(highlightedDate, 'dd MMMM')}</h5> <div className="stats-bottom"> <h2> {!noRegionHighlightedDistrictData || !statisticConfig?.hasPrimary ? formatNumber( total, statisticConfig.format !== 'short' ? statisticConfig.format : 'long', statistic ) : '-'} </h2> <h6> {!noRegionHighlightedDistrictData || !statisticConfig?.hasPrimary ? `${delta > 0 ? '+' : ''}${formatNumber( delta, statisticConfig.format !== 'short' ? statisticConfig.format : 'long', statistic )}` : ''} </h6> </div> </div> )} <svg ref={(element) => { refs.current[index] = element; }} preserveAspectRatio="xMidYMid meet" > <g className="x-axis" /> <g className="x-axis2" /> <g className="y-axis" /> <g className="trend-background" /> <g className="trend" /> </svg> </div> ); })} </div> ); } const isEqual = (prevProps, currProps) => { if (!equal(currProps.chartType, prevProps.chartType)) { return false; } else if (!equal(currProps.isUniform, prevProps.isUniform)) { return false; } else if (!equal(currProps.isLog, prevProps.isLog)) { return false; } else if (!equal(currProps.isMovingAverage, prevProps.isMovingAverage)) { return false; } else if ( !equal( currProps.regionHighlighted.stateCode, prevProps.regionHighlighted.stateCode ) ) { return false; } else if ( !equal( currProps.regionHighlighted.districtName, prevProps.regionHighlighted.districtName ) ) { return false; } else if ( !equal( currProps.noRegionHighlightedDistrictData, prevProps.noRegionHighlightedDistrictData ) ) { return false; } else if (!equal(currProps.endDate, prevProps.endDate)) { return false; } else if (!equal(currProps.statistics, prevProps.statistics)) { return false; } else if (!equal(currProps.dates, prevProps.dates)) { return false; } return true; }; export default memo(Timeseries, isEqual);