diff --git a/generate.js b/generate.js index 8a8c25f..5755364 100755 --- a/generate.js +++ b/generate.js @@ -239,6 +239,27 @@ const processRecords = async () => { return calcGrowthRate(ts, ts.length - 1, 7); }; + const convertTsObjectToArray = (obj) => { + return Object.keys(obj).sort().map((date) => { + return { + key: date, + value: obj[date].value, + delta: obj[date].delta, + }; + }) + }; + + const mergeTsArrayIntoObject = (source, target) => { + source.forEach((ts) => { + target[ts.key] = target[ts.key] || { + value: 0, + delta: 0, + }; + target[ts.key].value += ts.value; + target[ts.key].delta += ts.delta; + }); + }; + const normalizeRecord = (record) => { record.timeSeriesDaily = []; record.timeSeriesMonthly = []; @@ -508,41 +529,11 @@ const processRecords = async () => { stateItem.total += record.total; stateItem.cases.total += record.cases.total; - record.timeSeriesDaily.forEach((ts) => { - stateItem.timeSeriesDaily[ts.key] = stateItem.timeSeriesDaily[ts.key] || { - value: 0, - delta: 0, - }; - stateItem.timeSeriesDaily[ts.key].value += ts.value; - stateItem.timeSeriesDaily[ts.key].delta += ts.delta; - }); - record.timeSeriesMonthly.forEach((ts) => { - stateItem.timeSeriesMonthly[ts.key] = stateItem.timeSeriesMonthly[ts.key] || { - value: 0, - delta: 0, - }; - stateItem.timeSeriesMonthly[ts.key].value += ts.value; - stateItem.timeSeriesMonthly[ts.key].delta += ts.delta; - }); - - record.cases.timeSeriesDaily.forEach((ts) => { - stateItem.cases.timeSeriesDaily[ts.key] = stateItem.cases.timeSeriesDaily[ts.key] || { - value: 0, - delta: 0, - }; - stateItem.cases.timeSeriesDaily[ts.key].value += ts.value; - stateItem.cases.timeSeriesDaily[ts.key].delta += ts.delta; - }); - - record.cases.timeSeriesMonthly.forEach((ts) => { - stateItem.cases.timeSeriesMonthly[ts.key] = stateItem.cases.timeSeriesMonthly[ts.key] || { - value: 0, - delta: 0, - }; - stateItem.cases.timeSeriesMonthly[ts.key].value += ts.value; - stateItem.cases.timeSeriesMonthly[ts.key].delta += ts.delta; - }); + mergeTsArrayIntoObject(record.timeSeriesDaily, stateItem.timeSeriesDaily); + mergeTsArrayIntoObject(record.timeSeriesMonthly, stateItem.timeSeriesMonthly); + mergeTsArrayIntoObject(record.cases.timeSeriesDaily, stateItem.cases.timeSeriesDaily); + mergeTsArrayIntoObject(record.cases.timeSeriesMonthly, stateItem.cases.timeSeriesMonthly); stateItem.counties.push(record); } else { @@ -556,41 +547,10 @@ const processRecords = async () => { item.total += record.total; item.cases.total += record.cases.total; - record.timeSeriesDaily.forEach((ts) => { - item.timeSeriesDaily[ts.key] = item.timeSeriesDaily[ts.key] || { - value: 0, - delta: 0, - }; - item.timeSeriesDaily[ts.key].value += ts.value; - item.timeSeriesDaily[ts.key].delta += ts.delta; - }); - - record.timeSeriesMonthly.forEach((ts) => { - item.timeSeriesMonthly[ts.key] = item.timeSeriesMonthly[ts.key] || { - value: 0, - delta: 0, - }; - item.timeSeriesMonthly[ts.key].value += ts.value; - item.timeSeriesMonthly[ts.key].delta += ts.delta; - }); - - record.cases.timeSeriesDaily.forEach((ts) => { - item.cases.timeSeriesDaily[ts.key] = item.cases.timeSeriesDaily[ts.key] || { - value: 0, - delta: 0, - }; - item.cases.timeSeriesDaily[ts.key].value += ts.value; - item.cases.timeSeriesDaily[ts.key].delta += ts.delta; - }); - - record.cases.timeSeriesMonthly.forEach((ts) => { - item.cases.timeSeriesMonthly[ts.key] = item.cases.timeSeriesMonthly[ts.key] || { - value: 0, - delta: 0, - }; - item.cases.timeSeriesMonthly[ts.key].value += ts.value; - item.cases.timeSeriesMonthly[ts.key].delta += ts.delta; - }); + mergeTsArrayIntoObject(record.timeSeriesDaily, item.timeSeriesDaily); + mergeTsArrayIntoObject(record.timeSeriesMonthly, item.timeSeriesMonthly); + mergeTsArrayIntoObject(record.cases.timeSeriesDaily, item.cases.timeSeriesDaily); + mergeTsArrayIntoObject(record.cases.timeSeriesMonthly, item.cases.timeSeriesMonthly); }); Object.keys(perStateTotals).forEach((stateName) => { @@ -608,36 +568,12 @@ const processRecords = async () => { population: item.population, deathsPerMillion: item.population > 0 ? item.total / item.population * 1000000 : 0, casesPerMillion: item.population > 0 ? item.cases.total / item.population * 1000000 : 0, - timeSeriesDaily: Object.keys(item.timeSeriesDaily).sort().map((date) => { - return { - key: date, - value: item.timeSeriesDaily[date].value, - delta: item.timeSeriesDaily[date].delta, - }; - }), - timeSeriesMonthly: Object.keys(item.timeSeriesMonthly).sort().map((date) => { - return { - key: date, - value: item.timeSeriesMonthly[date].value, - delta: item.timeSeriesMonthly[date].delta, - }; - }), + timeSeriesDaily: convertTsObjectToArray(item.timeSeriesDaily), + timeSeriesMonthly: convertTsObjectToArray(item.timeSeriesMonthly), cases: { total: item.cases.total, - timeSeriesDaily: Object.keys(item.cases.timeSeriesDaily).sort().map((date) => { - return { - key: date, - value: item.cases.timeSeriesDaily[date].value, - delta: item.cases.timeSeriesDaily[date].delta, - }; - }), - timeSeriesMonthly: Object.keys(item.cases.timeSeriesMonthly).sort().map((date) => { - return { - key: date, - value: item.cases.timeSeriesMonthly[date].value, - delta: item.cases.timeSeriesMonthly[date].delta, - }; - }), + timeSeriesDaily: convertTsObjectToArray(item.cases.timeSeriesDaily), + timeSeriesMonthly: convertTsObjectToArray(item.cases.timeSeriesMonthly), }, }; @@ -646,6 +582,7 @@ const processRecords = async () => { stateItem.rollingAverageDaily = getRollingAverage(stateItem); stateItem.doublingDaily = getDoublingTime(stateItem); stateItem.cases.rollingAverageDaily = getRollingAverage(stateItem.cases); + stateItem.cases.doublingDaily = getDoublingTime(stateItem.cases); // insert into states array for the country perCountryTotals[item.country].states.push(stateItem); @@ -672,36 +609,12 @@ const processRecords = async () => { population: item.population, deathsPerMillion: item.population > 0 ? item.total / item.population * 1000000 : 0, casesPerMillion: item.population > 0 ? item.cases.total / item.population * 1000000 : 0, - timeSeriesDaily: Object.keys(item.timeSeriesDaily).sort().map((date) => { - return { - key: date, - value: item.timeSeriesDaily[date].value, - delta: item.timeSeriesDaily[date].delta, - }; - }), - timeSeriesMonthly: Object.keys(item.timeSeriesMonthly).sort().map((date) => { - return { - key: date, - value: item.timeSeriesMonthly[date].value, - delta: item.timeSeriesMonthly[date].delta, - }; - }), + timeSeriesDaily: convertTsObjectToArray(item.timeSeriesDaily), + timeSeriesMonthly: convertTsObjectToArray(item.timeSeriesMonthly), cases: { total: item.cases.total, - timeSeriesDaily: Object.keys(item.cases.timeSeriesDaily).sort().map((date) => { - return { - key: date, - value: item.cases.timeSeriesDaily[date].value, - delta: item.cases.timeSeriesDaily[date].delta, - }; - }), - timeSeriesMonthly: Object.keys(item.cases.timeSeriesMonthly).sort().map((date) => { - return { - key: date, - value: item.cases.timeSeriesMonthly[date].value, - delta: item.cases.timeSeriesMonthly[date].delta, - }; - }), + timeSeriesDaily: convertTsObjectToArray(item.cases.timeSeriesDaily), + timeSeriesMonthly: convertTsObjectToArray(item.cases.timeSeriesMonthly), }, }; @@ -710,6 +623,7 @@ const processRecords = async () => { countryItem.rollingAverageDaily = getRollingAverage(countryItem); countryItem.doublingDaily = getDoublingTime(countryItem); countryItem.cases.rollingAverageDaily = getRollingAverage(countryItem.cases); + countryItem.cases.doublingDaily = getDoublingTime(countryItem.cases); return countryItem; }); @@ -731,77 +645,23 @@ const processRecords = async () => { worldData.total += countryData.total; worldData.cases.total += countryData.cases.total; - countryData.timeSeriesDaily.forEach((ts) => { - worldData.timeSeriesDaily[ts.key] = worldData.timeSeriesDaily[ts.key] || { - value: 0, - delta: 0, - }; - worldData.timeSeriesDaily[ts.key].value += ts.value; - worldData.timeSeriesDaily[ts.key].delta += ts.delta; - }); - - countryData.timeSeriesMonthly.forEach((ts) => { - worldData.timeSeriesMonthly[ts.key] = worldData.timeSeriesMonthly[ts.key] || { - value: 0, - delta: 0, - }; - worldData.timeSeriesMonthly[ts.key].value += ts.value; - worldData.timeSeriesMonthly[ts.key].delta += ts.delta; - }); - - countryData.cases.timeSeriesDaily.forEach((ts) => { - worldData.cases.timeSeriesDaily[ts.key] = worldData.cases.timeSeriesDaily[ts.key] || { - value: 0, - delta: 0, - }; - worldData.cases.timeSeriesDaily[ts.key].value += ts.value; - worldData.cases.timeSeriesDaily[ts.key].delta += ts.delta; - }); - - countryData.cases.timeSeriesMonthly.forEach((ts) => { - worldData.cases.timeSeriesMonthly[ts.key] = worldData.cases.timeSeriesMonthly[ts.key] || { - value: 0, - delta: 0, - }; - worldData.cases.timeSeriesMonthly[ts.key].value += ts.value; - worldData.cases.timeSeriesMonthly[ts.key].delta += ts.delta; - }); + mergeTsArrayIntoObject(countryData.timeSeriesDaily, worldData.timeSeriesDaily); + mergeTsArrayIntoObject(countryData.timeSeriesMonthly, worldData.timeSeriesMonthly); + mergeTsArrayIntoObject(countryData.cases.timeSeriesDaily, worldData.cases.timeSeriesDaily); + mergeTsArrayIntoObject(countryData.cases.timeSeriesMonthly, worldData.cases.timeSeriesMonthly); }); - worldData.timeSeriesDaily = Object.keys(worldData.timeSeriesDaily).sort().map((date) => { - return { - key: date, - value: worldData.timeSeriesDaily[date].value, - delta: worldData.timeSeriesDaily[date].delta, - }; - }); - worldData.timeSeriesMonthly = Object.keys(worldData.timeSeriesMonthly).sort().map((date) => { - return { - key: date, - value: worldData.timeSeriesMonthly[date].value, - delta: worldData.timeSeriesMonthly[date].delta, - }; - }); - worldData.cases.timeSeriesDaily = Object.keys(worldData.cases.timeSeriesDaily).sort().map((date) => { - return { - key: date, - value: worldData.cases.timeSeriesDaily[date].value, - delta: worldData.cases.timeSeriesDaily[date].delta, - }; - }); - worldData.cases.timeSeriesMonthly = Object.keys(worldData.cases.timeSeriesMonthly).sort().map((date) => { - return { - key: date, - value: worldData.cases.timeSeriesMonthly[date].value, - delta: worldData.cases.timeSeriesMonthly[date].delta, - }; - }); + worldData.timeSeriesDaily = convertTsObjectToArray(worldData.timeSeriesDaily); + worldData.timeSeriesMonthly = convertTsObjectToArray(worldData.timeSeriesMonthly); + worldData.cases.timeSeriesDaily = convertTsObjectToArray(worldData.cases.timeSeriesDaily); + worldData.cases.timeSeriesMonthly = convertTsObjectToArray(worldData.cases.timeSeriesMonthly); worldData.deathGrowthRate = getGrowthRate(worldData.timeSeriesDaily); worldData.casesGrowthRate = getGrowthRate(worldData.cases.timeSeriesDaily); worldData.rollingAverageDaily = getRollingAverage(worldData); worldData.doublingDaily = getDoublingTime(worldData); worldData.cases.rollingAverageDaily = getRollingAverage(worldData.cases); + worldData.cases.doublingDaily = getDoublingTime(worldData.cases); worldData.population = 7781841000; worldData.deathsPerMillion = worldData.total / worldData.population * 1000000; diff --git a/tmpl/master.pug b/tmpl/master.pug index 698db39..99363fa 100644 --- a/tmpl/master.pug +++ b/tmpl/master.pug @@ -25,6 +25,22 @@ html bottom: -1px; border-bottom: 2px solid #b5b5b5; } + #hero-tooltip table { + border-collapse: collapse; + } + #hero-tooltip th, #hero-tooltip td { + padding: 2px 4px; + } + [class^="tooltip-color-"] { + width: 10px; + height: 10px; + border: 2px solid black; + display: inline-block; + } + [class^="tooltip-value-"] { + text-align: right; + font-family: monospace; + } .geo-bg-dark { background-color: #8e8e8e; color: white; @@ -223,6 +239,7 @@ html doubling, totalCases, newCases, + doublingCases, ) { const canvas = document.getElementById(id); charts.heroMaxValue = totalCases.reduce((max, value) => Math.max(max, value), 0); @@ -234,7 +251,8 @@ html const totalData = totalDeaths.slice(start, end); const newData = newDeaths.slice(start, end); const rollingData = rollingAverage.slice(start, end); - const doublingData = doubling.slice(start, end); + const deathDoublingData = doubling.slice(start, end); + const caseDoublingData = doublingCases.slice(start, end); const totalCaseData = totalCases.slice(start, end); const newCaseData = newCases.slice(start, end); @@ -286,19 +304,30 @@ html label: 'New Deaths', data: newData, fill: false, - borderColor: 'rgb(96, 96, 96, 0.25)', - backgroundColor: 'rgb(96, 96, 96, 0.25)', + borderColor: 'rgb(20,24,59, 0.15)', + backgroundColor: 'rgb(20,24,59, 0.15)', borderWidth: 1, }, { label: 'Days to 2x deaths', - data: doublingData, + data: deathDoublingData, fill: false, - borderColor: 'rgb(187,40,193, 0.5)', - backgroundColor: 'rgb(187,40,193, 0.5)', + borderColor: 'rgba(127,30,75,0.5)', + backgroundColor: 'rgb(127,30,75, 0.5)', borderWidth: 2, pointRadius: 0, - } + borderDash: [ 4, 4 ], + }, + { + label: 'Days to 2x cases', + data: caseDoublingData, + fill: false, + borderColor: 'rgba(87,86,38,0.5)', + backgroundColor: 'rgb(87,86,38, 0.5)', + borderWidth: 2, + pointRadius: 0, + borderDash: [ 4, 4 ], + }, ], }, options: { @@ -313,6 +342,102 @@ html intersect: false, position: 'middle', axis: 'x', + enabled: false, + custom: function(tooltipModel) { + let tooltipEl = document.getElementById('hero-tooltip'); + + // Create element on first render + if (!tooltipEl) { + tooltipEl = document.createElement('div'); + tooltipEl.id = 'hero-tooltip'; + const border = '1px solid #606060'; + tooltipEl.innerHTML = ` +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Total Cases
New Cases
Total Deaths
New Deaths
+
+ Deaths 2x every
+ Cases 2x every
+
+ `; + tooltipEl.style.zIndex = '1000'; + document.body.appendChild(tooltipEl); + } + + if (tooltipModel.opacity === 0) { + tooltipEl.style.opacity = '0'; + return; + } + + tooltipEl.style.backgroundImage = 'linear-gradient(to bottom, rgba(52, 52, 52, 0.75), rgba(24, 24, 24, 0.75))'; + tooltipEl.style.color = 'white'; + tooltipEl.style.textShadow = '1px 1px 1px black'; + tooltipEl.style.borderRadius = '2px'; + tooltipEl.style.boxShadow = '2px 2px 3px rgba(0, 0, 0, 0.75)'; + + if (tooltipModel.dataPoints) { + tooltipEl.querySelector('.tooltip-title').textContent = tooltipModel.title.join(' '); + const setData = (cls, index, colorIndex) => { + colorIndex = typeof(colorIndex) === 'number' ? colorIndex : index; + const color = tooltipModel.labelColors[colorIndex]; + const value = Number(tooltipModel.dataPoints[index].value).toLocaleString(); + tooltipEl.querySelector('.tooltip-value-' + cls).textContent = value; + const colorEl = tooltipEl.querySelector('.tooltip-color-' + cls); + colorEl.style.backgroundColor = color.backgroundColor; + colorEl.style.borderColor = color.borderColor; + }; + + setData('cases-total', 0); + setData('cases-new', 1); + setData('deaths-total', 2); + setData('deaths-new', 4, 3); + + const daysToDoubleDeaths = Number(tooltipModel.dataPoints[5].value).toLocaleString(); + const daysToDoubleCases = Number(tooltipModel.dataPoints[6].value).toLocaleString(); + tooltipEl.querySelector('.tooltip-2x-deaths').textContent = daysToDoubleDeaths + ' days'; + tooltipEl.querySelector('.tooltip-2x-cases').textContent = daysToDoubleCases + ' days'; + } + + const position = this._chart.canvas.getBoundingClientRect(); + + // Display, position, and set styles for font + tooltipEl.style.opacity = '1'; + tooltipEl.style.position = 'absolute'; + + tooltipEl.style.fontFamily = tooltipModel._bodyFontFamily; + tooltipEl.style.fontSize = tooltipModel.bodyFontSize + 'px'; + tooltipEl.style.fontStyle = tooltipModel._bodyFontStyle; + tooltipEl.style.padding = tooltipModel.yPadding + 'px ' + tooltipModel.xPadding + 'px'; + tooltipEl.style.pointerEvents = 'none'; + + const tooltipSize = tooltipEl.getBoundingClientRect(); + const chartArea = this._chart.chartArea; + const chartHeight = chartArea.bottom - chartArea.top; + tooltipEl.style.left = (position.left + window.pageXOffset + tooltipModel.x) + 'px'; + tooltipEl.style.top = (position.top + window.pageYOffset + chartArea.top + (chartHeight / 2) - (tooltipSize.height / 2)) + 'px'; + } }, scales: { yAxes: [ @@ -400,6 +525,7 @@ html !{JSON.stringify(data.doublingDaily.map(x => x.value))}, !{JSON.stringify(data.cases.timeSeriesDaily.map(x => x.value))}, !{JSON.stringify(data.cases.timeSeriesDaily.map(x => x.delta))}, + !{JSON.stringify(data.cases.doublingDaily.map(x => x.value))}, ); mixin dataTable(items, label, type)