doctype html head title= $title + ' | Covid-19' meta(charset="utf8") link(rel="stylesheet" href="/bootstrap.css") script(src="/Chart.bundle.js") style. table td { vertical-align: middle !important; } th.sorted, td.sorted { background-color: #e0eefd; } .table-sm { font-size: 80%; } .table-sm code { font-size: 110%; color: inherit; } script. Chart.pluginService.register({ beforeDraw: (chart) => { const ctx = chart.chart.ctx; ctx.save(); ctx.fillStyle = 'white'; ctx.fillRect(0, 0, chart.width, chart.height); ctx.restore(); } }); // https://stackoverflow.com/a/45172506 Chart.defaults.lineVerticalTooltip = Chart.defaults.line; Chart.controllers.lineVerticalTooltip = Chart.controllers.line.extend({ draw: function(ease) { Chart.controllers.line.prototype.draw.call(this, ease); if (this.chart.tooltip._active && this.chart.tooltip._active.length) { const activePoint = this.chart.tooltip._active[0]; const ctx = this.chart.ctx; const x = activePoint.tooltipPosition().x; const topY = this.chart.legend.bottom; const bottomY = this.chart.chartArea.bottom; ctx.save(); ctx.beginPath(); ctx.moveTo(x, topY); ctx.lineTo(x, bottomY); ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(96, 96, 96, 0.75)'; ctx.stroke(); ctx.restore(); } } }); Chart.Tooltip.positioners.middle = function(elements, eventPosition) { const chartArea = this._chartInstance.chartArea; return { x: eventPosition.x, y: chartArea.top + ((chartArea.bottom - chartArea.top) / 2), }; }; const charts = { logarithmic: false, heroChart: null, trends: [], }; function makeSparkline(id, data) { const canvas = document.getElementById(id); const maxValue = data.reduce((max, value) => Math.max(max, value), 0); const max = maxValue > 0 ? Math.pow(10, Math.ceil(Math.log10(maxValue))) : 0 const chart = new Chart(canvas.getContext('2d'), { type: 'line', data: { labels: new Array(data.length), datasets: [{ data: data, borderColor: 'rgb(53, 120, 193)', borderWidth: 1, backgroundColor: 'rgba(148, 193, 250, 0.50)', }], }, options: { responsive: false, legend: { display: false, }, elements: { point: { radius: 0, }, }, tooltips: { enabled: false, }, scales: { yAxes: [ { display: false, type: 'logarithmic', ticks: { precision: 0, beginAtZero: true, min: 0, max: Math.max(max, 2), // this is necessary for some reason callback: value => Number(value.toString()), } }, ], xAxes: [ { display: false, }, ], } } }); charts.trends.push({ chart, maxValue, }); } function setAxisType(type) { if (charts.heroChart) { const axis = charts.heroChart.options.scales.yAxes[0]; if (type === 'logarithmic') { axis.type = 'logarithmic'; const maxLogPower = Math.ceil(Math.log10(charts.heroMaxValue)); axis.ticks.max = Math.pow(10, maxLogPower); axis.ticks.callback = value => Number(value.toString()).toLocaleString(); } else { axis.type = 'linear'; delete axis.ticks.max; axis.ticks.callback = value => value; } charts.heroChart.update(); const selector = '.set-axis-' + type; const otherSelector = type === 'linear' ? '.set-axis-logarithmic' : '.set-axis-linear'; document.querySelector(selector).disabled = true; document.querySelector(otherSelector).disabled = false; } charts.trends.forEach((data) => { const axis = data.chart.options.scales.yAxes[0]; if (type === 'logarithmic') { axis.type = 'logarithmic'; const maxLogPower = Math.ceil(Math.log10(data.maxValue)); axis.ticks.max = Math.pow(10, maxLogPower); axis.ticks.callback = value => Number(value.toString()).toLocaleString(); } else { axis.type = 'linear'; delete axis.ticks.max; axis.ticks.callback = value => value; } data.chart.update(); }); } function makeHeroChart(id, title, labels, totalDeaths, newDeaths, rollingAverage, doubling) { const canvas = document.getElementById(id); charts.heroMaxValue = totalDeaths[totalDeaths.length - 1]; const firstNonZeroDeathIndex = totalDeaths.findIndex(value => value > 0); const start = Math.max(0, firstNonZeroDeathIndex - 2); const end = totalDeaths.length; 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 months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]; const realLabels = labels .slice(start, end) .map((date) => date.replace(/\d{4}-(\d\d?)-(\d\d?)/, (_, m, d) => `${months[Number(m) - 1]} ${Number(d)}`)); charts.heroChart = new Chart(canvas.getContext('2d'), { type: 'lineVerticalTooltip', data: { labels: realLabels, datasets: [ { label: 'Cumulative', data: totalData, fill: '1', borderColor: 'rgb(196, 64, 64)', borderWidth: 1, backgroundColor: 'rgba(196, 128, 128, 0.25)', }, { label: 'New (rolling)', data: rollingData, fill: 'origin', borderColor: 'rgb(20,24,59)', borderWidth: 1, backgroundColor: 'rgba(96, 96, 164, 0.25)', }, { label: 'New', data: newData, fill: false, borderColor: 'rgb(96, 96, 96, 0.25)', borderWidth: 1, }, { label: 'Days to 2x', data: doublingData, fill: false, borderColor: 'rgb(187,40,193, 0.5)', borderWidth: 2, pointRadius: 0, } ], }, options: { responsive: false, title: { display: true, position: 'top', text: title, fontSize: 18, }, tooltips: { intersect: false, position: 'middle', axis: 'x', }, scales: { yAxes: [ { display: true, type: 'logarithmic', ticks: { precision: 0, beginAtZero: true, min: 0, max: Math.pow(10, Math.ceil(Math.log10(charts.heroMaxValue))), callback: value => Number(value.toString()).toLocaleString(), }, afterBuildTicks: (axis, ticks) => { if (axis.type === 'logarithmic') { return ticks.filter((value) => { if (value > 0 && value < 10) { return false; } const logValue = Math.log10(value); return Math.round(logValue) === logValue; }); } return ticks; }, }, ], xAxes: [ { display: true, }, ], } } }); } body mixin formatNumber(num) = Number(num).toLocaleString() mixin sortableLinks(col, label) div.d-inline-flex span.sortables.mr-2.d-inline-flex.flex-column(style="font-size: 50%") a(href="#sort:" + col + ":asc") ▲ a(href="#sort:" + col + ":desc") ▼ span.d-inline-block.text-truncate block mixin heroChart() div.card.mb-4 div.card-body.position-relative div.position-absolute(style="top: 10px; right: 10px") div.btn-group button.btn.btn-secondary.btn-sm.set-axis-linear( type="button" onclick="setAxisType('linear')" autocomplete="off" ) Linear button.btn.btn-secondary.btn-sm.set-axis-logarithmic( type="button" onclick="setAxisType('logarithmic')" autocomplete="off" disabled ) Logarithmic canvas.mx-auto(id="main-chart" width="800" height="450") - const growthRate = '+' + (data.deathGrowthRate * 100).toFixed(2) + '%'; const population = 'pop. ' + data.population.toLocaleString(); const deathsPerMillion = Math.round(data.deathsPerMillion).toLocaleString() + '/MM'; const heroTitle = [ 'Covid-19 Deaths: ' + data.name, `${population} | ${deathsPerMillion} | ${growthRate}` ]; script. makeHeroChart( 'main-chart', !{JSON.stringify(heroTitle)}, !{JSON.stringify(data.timeSeriesDaily.map(x => x.key))}, !{JSON.stringify(data.timeSeriesDaily.map(x => x.value))}, !{JSON.stringify(data.timeSeriesDaily.map(x => x.delta))}, !{JSON.stringify(data.rollingAverageDaily.map(x => x.delta))}, !{JSON.stringify(data.doublingDaily.map(x => x.value))}, ); mixin dataTable(items, label, type) - const hasPopulation = type !== 'state' || data.name === 'United States'; div#table.table-responsive: table.table.table-sm.table-hover thead: tr th # th(data-col="name"): +sortableLinks("name")= label if hasPopulation th.text-center(data-col="population"): +sortableLinks("population") Population th.text-center(data-col="million"): +sortableLinks("million") per 1M th.text-center(data-col="total"): +sortableLinks("total") Total th.text-center.sorted(data-col="today"): +sortableLinks("today") Today th.text-center(data-col="yesterday"): +sortableLinks("yesterday") Yesterday th.text-center(data-col="last7"): +sortableLinks("last7") Last 7 th.text-center(data-col="last30"): +sortableLinks("last30") Last 30 th.text-center(data-col="growth"): +sortableLinks("growth") Growth th.text-center Trend - items.sort((a, b) => { const yesterdayA = a.timeSeriesDaily[a.timeSeriesDaily.length - 1].delta; const yesterdayB = b.timeSeriesDaily[b.timeSeriesDaily.length - 1].delta; if (yesterdayA === yesterdayB) { return a.name && b.name ? a.name.localeCompare(b.name) : 0; } return yesterdayA < yesterdayB ? 1 : -1; }); tbody: each item, i in items - const getValue = offset => (item.timeSeriesDaily[item.timeSeriesDaily.length - offset] || {}).value || 0; const getDelta = offset => (item.timeSeriesDaily[item.timeSeriesDaily.length - offset] || {}).delta || 0; const today = getDelta(1); const yesterday = getDelta(2); const last7 = getValue(1) - getValue(7); const last30 = getValue(1) - getValue(30); tr( id=("row-" + (item.safeName || '_')) data-name=(item.name || '_') data-population=item.population data-total=item.total data-million=item.deathsPerMillion data-today=today data-yesterday=yesterday data-last7=last7 data-last30=last30 data-growth=item.deathGrowthRate ) td.sort-order= i + 1 td: +renderItemName(item) if hasPopulation td.text-right: code: +formatNumber(item.population) td.text-right: code: +formatNumber(Math.round(item.deathsPerMillion)) td.text-right: code: +formatNumber(item.total) td.text-right.sorted: code: +formatNumber(today) td.text-right: code: +formatNumber(yesterday) td.text-right: code: +formatNumber(last7) td.text-right: code: +formatNumber(last30) td.text-right: code= Number(item.deathGrowthRate * 100).toFixed(2) + '%' td canvas.mx-auto(id="sparkline-" + i width="200" height="50") script. makeSparkline( "sparkline-#{i}", #{JSON.stringify(item.rollingAverageDaily.slice(-14).map(x => x.delta))} ); div.container.mt-2 h1.text-center Covid-19 Death Data div.d-flex.justify-content-around.font-italic.small div - const generationDate = new Date().toISOString(); | Data from #[a(href="https://github.com/CSSEGISandData/COVID-19") Johns Hopkins CSSE] div | Generated: #[time.generation-date(datetime=generationDate title=generationDate)= generationDate] div - const lastUpdateISO = lastUpdate.toISOString(); | Data updated: #[time.update-date(datetime=lastUpdateISO title=lastUpdateISO)= lastUpdateISO] hr div.main-content.mt-4.position-relative block main script. (function() { const table = document.getElementById('table'); const headerRow = table.querySelector('thead tr'); const headers = [].slice.call(headerRow.querySelectorAll('th')); const tbody = table.querySelector('tbody'); const allRows = [].slice.call(tbody.querySelectorAll('tbody tr')); const resortTable = (col) => { let nextChild = null; const highlightedIndex = headers.findIndex(cell => cell.getAttribute('data-col') === col); headers.forEach((cell, i) => { if (i !== highlightedIndex) { cell.classList.remove('sorted'); } else { cell.classList.add('sorted'); } }); for (let i = allRows.length - 1; i >= 0; i--) { const row = allRows[i]; if (!row) { continue; } const cells = [].slice.call(row.querySelectorAll('td')); cells.forEach((cell, i) => { if (i !== highlightedIndex) { cell.classList.remove('sorted'); } else { cell.classList.add('sorted'); } }); if (row === nextChild) { continue; } tbody.insertBefore(row, nextChild); row.querySelector('.sort-order').textContent = (i + 1).toString(); nextChild = row; } }; const handleSort = (value, dir) => { const newSortDir = dir === 'desc' ? 'desc' : 'asc'; const sortByNumberThenName = (attr) => { allRows.sort((a, b) => { const aValue = Number(a.getAttribute('data-' + attr)); const bValue = Number(b.getAttribute('data-' + attr)); if (aValue === bValue) { const aName = a.getAttribute('data-name'); const bName = b.getAttribute('data-name'); return aName.localeCompare(bName); } return aValue < bValue ? (newSortDir === 'asc' ? -1 : 1) : (newSortDir === 'asc' ? 1 : -1); }); resortTable(value); }; switch (value) { case 'name': allRows.sort((a, b) => { const aName = a.getAttribute('data-name'); const bName = b.getAttribute('data-name'); if (newSortDir === 'asc') { return aName.localeCompare(bName); } return bName.localeCompare(aName); }); resortTable('name'); break; case 'total': case 'today': case 'yesterday': case 'last7': case 'last30': case 'population': case 'million': case 'growth': sortByNumberThenName(value); break; } }; const handleHash = (hash) => { const sortValue = hash.replace(/^#sort:/, '').split(':'); handleSort(sortValue[0], sortValue[1]); }; window.addEventListener('hashchange', () => { handleHash(window.location.hash); }); handleHash(window.location.hash); const setDate = (selector) => { const node = document.querySelector(selector); node.textContent = new Date(node.getAttribute('datetime')).toLocaleString(); }; setDate('.generation-date'); setDate('.update-date'); }());