import PropTypes from 'prop-types'; import React from 'react'; import ToggleButton from '@material-ui/lab/ToggleButton'; import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'; import { LineChart, ReferenceLine, ReferenceArea, Label, CartesianGrid, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { fade, makeStyles } from '@material-ui/core/styles'; import { scaleSymlog } from 'd3-scale'; import { myShortNumber } from '../../Util'; import { DateRangeSlider } from "../../DateRangeSlider" import { useStickyState } from '../../Util'; import { SectionHeader } from "../../CovidUI" import Typography from '@material-ui/core/Typography' import { DataSeries } from '../../models/DataSeries'; import axisScales from '../../graphs/GraphAxisScales' const moment = require('moment'); const baseToggleButtonStyles = { height: 'initial', textTransform: 'initial', }; // This scale for logs works consistenly, whereas setting ReCharts to use the // scale 'log' only works sometimes under certain mystery situations. const logScale = scaleSymlog().domain([0, 'dataMax']); const useStyles = makeStyles(theme => ({ options: { display: 'flex', flexWrap: 'wrap', marginBottom: '16px', '& > *': { margin: '0 8px 8px 0', }, }, slider: { display: "flex", width: 200, alignItems: "center", }, expand: { flexGrow: 1, }, })); const cookieStaleWhen = (cookie) => !cookie.verticalScale || !cookie.showPastDays; /** A graph that allows the user to click series on and off. */ export const AdvancedGraph = (props) => { const classes = useStyles(); const [state, setStateSticky] = useStickyState({ defaultValue: { verticalScale: axisScales.linear, showPastDays: 30, }, cookieId: "AdvanceGraphPreference1", isCookieStale: cookieStaleWhen }); const handleLogScaleToggle = (newScale) => { setStateSticky({ ...state, verticalScale: newScale, }); }; const handleSliderValueChange = (value) => { let newstate = { ...state, showPastDays: value } setStateSticky(newstate) } function filterData(data) { const cutoff = moment().subtract(state.showPastDays, 'days').unix(); const future = moment().add(14, 'days').unix(); return data.filter((p) => p.timestamp >= cutoff && p.timestamp <= future); } const scales = new Map([ ['Linear', { label: 'Linear', scale: 'Linear', }], ['Log', { label: 'Log', scale: 'Log', }], ]); const scale = state.verticalScale; // Expands series that are supposed to have trend lines into an entry for the // original series and one for the trend line. const expandedSerieses = expandSeriesesToMap(props.serieses); // Okay, here's where it gets terrible. We have raw serieses and envelopes. // We want the user to be able to toggle serieses and evelopes on and off one // by one, but not to toggle the serieses inside of a envelope. So for the // purposes of Recharts we're going to decompose the envelopes to be raw // serieses. But for our code, we're going to merge them and be confused a // lot. const allSerieses = [...expandedSerieses.values()]; let { data, timestampFormatter } = (props.alignT0) ? DataSeries.alignT0([...allSerieses.values()].map(({ series }) => series)) : DataSeries.flatten([...allSerieses.values()].map(({ series }) => series)); let yAxisFormatter = (props.yAxisFormatter) ? props.yAxisFormatter : myShortNumber; const seriesesAndEnvelopes = [...expandedSerieses.entries()]; const allLabels = seriesesAndEnvelopes.map(([label,]) => label); const [known, setKnown] = React.useState(allLabels); const [selected, setSelected] = React.useState( () => seriesesAndEnvelopes.filter((item) => item[1].initial !== 'off') .map(([label,]) => label)); // As the user switches pages, graphs that were previously unknown may become // available. So turn them off if they default to on when they appear. if (known.join() !== allLabels.join()) { const add = []; for (const [key, { initial }] of seriesesAndEnvelopes) { if (!known.includes(key) && !selected.includes(key) && initial !== 'off') { add.push(key); } } if (add.length > 0) { // We might as well just do this in here, even though technically we // should probably do it in the else branch too. setKnown(allLabels); setSelected(selected.concat(add)); } } return ( <div className={props.className}> {props.title && <SectionHeader> <Typography variant="h5" noWrap> {props.title} <Typography variant="body1" noWrap> {props.subtitle} </Typography> </Typography> </SectionHeader> } {props.showControls && <div className={classes.options}> <Display displays={scales} selected={scale} onChange={handleLogScaleToggle} /> <div className={classes.slider} > <div> Date:</div> <DateRangeSlider currentDate={moment()} startDate={moment("02/01/2020", "MM/DD/YYYY")} valueChanged={handleSliderValueChange} defaultValue={state.showPastDays} /> </div> <div className={classes.expand} /> <Legend spec={seriesesAndEnvelopes} selected={selected} onChange={setSelected} /> </div> } <Chart data={filterData(data)} scale={scales.get(scale).scale} timestampFormatter={timestampFormatter} yAxisFormatter={yAxisFormatter} specs={ seriesesAndEnvelopes .filter(([label,]) => selected.includes(label)) .map(([label, s]) => ({ label, ...s })) } vRefLines={props.vRefLines} hRefLines={props.hRefLines} /> </div>); }; AdvancedGraph.propTypes = { className: PropTypes.string, serieses: PropTypes.arrayOf( PropTypes.exact({ series: PropTypes.instanceOf(DataSeries).isRequired, color: PropTypes.string.isRequired, initial: PropTypes.oneOf([undefined, 'off', 'on']), trend: PropTypes.string, stipple: PropTypes.bool, rightAxis: PropTypes.bool, lastDayIncomplete: PropTypes.bool, covidspecial: PropTypes.bool, showMovingAverage: PropTypes.bool, })).isRequired, showControls: PropTypes.bool, }; AdvancedGraph.defaultProps = { showControls: true, }; function expandSeriesesToMap(serieses) { const expanded = serieses.flatMap(s => { const result = []; if (s.covidspecial) { let s_for_display; if (s.showMovingAverage) { s_for_display = s.series.nDayAverage(7); } else { s_for_display = s.series; } let main = { ...s, series: s_for_display.dropLastPoint(), stipple: false, }; let last = { ...s, series: s_for_display.last2PointSeries().suffixLabel("*"), stipple: true, derived: true, } result.push(main); result.push(last); if (s.showMovingAverage) { let original = { ...s, series: s.series, // derived: true, stipple: true, } result.push(original); } } else { result.push(s); } return result; }); return new Map(expanded.map((seriesInfo) => { let series = seriesInfo.series; let label = "empty"; if (series) { label = series.label(); } return [label, seriesInfo]; })); } const useDisplayStyles = makeStyles(theme => ({ options: { display: 'initial', }, option: { ...baseToggleButtonStyles, }, })); const Display = (props) => { const classes = useDisplayStyles(); return ( <ToggleButtonGroup exclusive value={props.selected} onChange={(event, desired) => desired && props.onChange(desired)} className={classes.options}> {[...props.displays.entries()].map(([key, data]) => <ToggleButton key={key} value={key} className={classes.option}> {data.label} </ToggleButton> )} </ToggleButtonGroup> ); }; const useLegendStyles = makeStyles(theme => ({ serieses: { border: `1px solid ${fade(theme.palette.action.active, 0.12)}`, display: 'flex', flexWrap: 'wrap', maxWidth: '500px', }, series: { border: 'none', color: fade(theme.palette.action.active, 0.12), '&.selected': { backgroundColor: 'initial', color: fade(theme.palette.action.active, 0.8), fontWeight: 'initial', }, ...baseToggleButtonStyles, }, icon: { paddingRight: '4px', }, })); const Legend = (props) => { const classes = useLegendStyles(); return ( <ToggleButtonGroup value={props.selected} onChange={(event, desired) => props.onChange(desired)} className={classes.serieses}> {props.spec .filter(([label, { derived }]) => !derived) .map(([label, { color, stipple }]) => <ToggleButton key={label} value={label} classes={{ root: classes.series, selected: 'selected' }}> <span className={classes.icon} style={ props.selected.includes(label) ? { color } : {} }> {stipple ? '···' : '—'} </span> {label} </ToggleButton> )} </ToggleButtonGroup> ); }; const Chart = (props) => { const ordered = (props.specs || []).sort((a, b) => { if (a.derived && !b.derived) { return -1; } else if (!a.derived && b.derived) { return 1; } else { return a.label < b.label ? -1 : 1; } }); let YAxis0Color = "black"; let YAxis1Color = undefined; for (const s of ordered) { if (s.rightAxis) { YAxis1Color = s.color; } else { YAxis0Color = s.color; } } function getvRefLines(lines) { let result = (lines || []).map((l, idx) => { return <ReferenceLine key={`vrefline${idx}`} x={l.date} stroke="#e3e3e3" strokeWidth={1} > <Label value={l.label} position={"insideTop"} fill="#b3b3b3" /> </ReferenceLine> } ); return result; } function getvRefAreas(lines) { let result = (lines || []).map((l, idx) => { const startdate = l.date; const today = moment().unix(); let enddate = startdate + 14 * 24 * 60 * 60; while (enddate > today) { enddate -= 24 * 60 * 60; } return <ReferenceArea key={`vrefarea${idx}`} x1={startdate} x2={enddate} // stroke="red" // strokeOpacity={0.3} fillOpacity={0.15} /> } ); return result; } function gethRefLines(lines) { let result = (lines || []).map((l, idx) => { return <ReferenceLine key={`hrefline${idx}`} y={l.value} stroke="#e3e3e3" strokeWidth={1} > <Label value={l.label} position={"insideLeft"} ></Label> </ReferenceLine> } ); return result; } let vRefLines = getvRefLines(props.vRefLines); let hRefLines = gethRefLines(props.hRefLines); return ( <ResponsiveContainer height={300}> <LineChart data={props.data} margin={{ left: -4, right: 8 }}> {vRefLines} {hRefLines} {getvRefAreas(props.vRefLines)} <Tooltip formatter={valueFormatter} labelFormatter={props.timestampFormatter} /> <XAxis dataKey="timestamp" tickFormatter={props.timestampFormatter} /> <YAxis yAxisId={0} tick={{ fill: YAxis0Color }} scale={props.scale === 'Log' ? logScale : props.scale} width={50} tickFormatter={props.yAxisFormatter} /> {YAxis1Color && <YAxis yAxisId={1} tickFormatter={props.yAxisFormatter} width={35} tick={{ fill: YAxis1Color }} orientation="right" /> } <CartesianGrid stroke="#d5d5d5" strokeDasharray="5 5" /> {ordered.flatMap(spec => specToElements(spec))} </LineChart> </ResponsiveContainer> ); }; function specToElements(spec) { return [lineForSpec(spec)]; }; function lineForSpec(spec) { return ( <Line key={spec.label} baseLine={10000} type="monotone" dataKey={spec.label} isAnimationActive={false} fill={spec.color} stroke={spec.color} strokeDasharray={spec.stipple ? '1 2' : undefined} dot={false} strokeWidth={2} yAxisId={spec.rightAxis ? 1 : 0} /> ); }; function valueFormatter(value) { if (isNaN(value)) { return 'unknown'; } else { if (value < 1) { return (value * 100).toFixed(1) + "%"; } return value.toFixed(1).replace(/\.?0+$/, ''); } }