covid19/tmpl/master.pug

518 lines
15 KiB
Plaintext
Raw Normal View History

2020-04-27 03:04:16 +00:00
doctype
html
head
title= $title + ' | Covid-19'
2020-04-27 03:04:16 +00:00
meta(charset="utf8")
link(rel="stylesheet" href="/bootstrap.css")
script(src="/Chart.bundle.js")
style.
table td {
vertical-align: middle !important;
}
2020-04-27 15:54:26 +00:00
th.sorted, td.sorted {
background-color: #e0eefd;
}
.table-sm {
font-size: 80%;
}
2020-04-30 02:51:23 +00:00
.table-sm code {
font-size: 110%;
color: inherit;
}
2020-04-27 15:54:26 +00:00
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();
}
});
2020-05-01 06:28:21 +00:00
// 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),
};
};
2020-04-29 05:12:53 +00:00
const charts = {
logarithmic: false,
heroChart: null,
trends: [],
};
2020-04-27 15:54:26 +00:00
function makeSparkline(id, data) {
const canvas = document.getElementById(id);
2020-05-01 06:28:21 +00:00
const maxValue = data.reduce((max, value) => Math.max(max, value), 0);
2020-04-27 15:54:26 +00:00
const chart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
2020-04-30 02:51:23 +00:00
labels: new Array(data.length),
2020-04-29 05:12:53 +00:00
datasets: [{
data: data,
2020-04-30 02:51:23 +00:00
borderColor: 'rgb(53, 120, 193)',
2020-04-29 05:12:53 +00:00
borderWidth: 1,
2020-04-30 02:51:23 +00:00
backgroundColor: 'rgba(148, 193, 250, 0.50)',
2020-04-29 05:12:53 +00:00
}],
2020-04-27 15:54:26 +00:00
},
options: {
responsive: false,
legend: {
display: false,
},
elements: {
point: {
radius: 0,
},
},
tooltips: {
enabled: false,
},
scales: {
yAxes: [
{
display: false,
2020-04-30 02:51:23 +00:00
type: 'logarithmic',
2020-04-27 15:54:26 +00:00
ticks: {
precision: 0,
beginAtZero: true,
2020-05-01 06:28:21 +00:00
min: 0,
max: maxValue > 0 ? Math.pow(10, Math.ceil(Math.log10(maxValue))) : 0,
2020-04-30 02:51:23 +00:00
callback: value => Number(value.toString()),
2020-04-27 15:54:26 +00:00
}
},
],
xAxes: [
{
display: false,
},
],
}
}
});
2020-04-29 05:12:53 +00:00
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();
2020-04-29 05:12:53 +00:00
} 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];
2020-04-30 02:51:23 +00:00
if (type === 'logarithmic') {
2020-04-29 05:12:53 +00:00
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();
2020-04-29 05:12:53 +00:00
} else {
axis.type = 'linear';
delete axis.ticks.max;
axis.ticks.callback = value => value;
}
data.chart.update();
});
2020-04-27 15:54:26 +00:00
}
function makeHeroChart(id, title, labels, totalDeaths, newDeaths, rollingAverage, doubling) {
2020-04-27 15:54:26 +00:00
const canvas = document.getElementById(id);
2020-04-29 05:12:53 +00:00
charts.heroMaxValue = totalDeaths[totalDeaths.length - 1];
2020-05-01 06:28:21 +00:00
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)}`));
2020-04-29 05:12:53 +00:00
charts.heroChart = new Chart(canvas.getContext('2d'), {
2020-05-01 06:28:21 +00:00
type: 'lineVerticalTooltip',
2020-04-27 15:54:26 +00:00
data: {
2020-05-01 06:28:21 +00:00
labels: realLabels,
2020-04-27 15:54:26 +00:00
datasets: [
{
2020-04-30 02:51:23 +00:00
label: 'Cumulative',
2020-05-01 06:28:21 +00:00
data: totalData,
2020-04-30 02:51:23 +00:00
fill: '1',
2020-04-27 15:54:26 +00:00
borderColor: 'rgb(196, 64, 64)',
borderWidth: 1,
backgroundColor: 'rgba(196, 128, 128, 0.25)',
},
{
2020-04-30 02:51:23 +00:00
label: 'New (rolling)',
2020-05-01 06:28:21 +00:00
data: rollingData,
2020-04-30 02:51:23 +00:00
fill: 'origin',
borderColor: 'rgb(20,24,59)',
borderWidth: 1,
backgroundColor: 'rgba(96, 96, 164, 0.25)',
},
{
label: 'New',
2020-05-01 06:28:21 +00:00
data: newData,
2020-04-30 02:51:23 +00:00
fill: false,
borderColor: 'rgb(96, 96, 96, 0.25)',
2020-04-27 15:54:26 +00:00
borderWidth: 1,
},
{
label: 'Days to 2x',
2020-05-01 06:28:21 +00:00
data: doublingData,
fill: false,
borderColor: 'rgb(187,40,193, 0.5)',
borderWidth: 2,
pointRadius: 0,
2020-04-27 15:54:26 +00:00
}
],
},
options: {
responsive: false,
title: {
display: true,
position: 'top',
text: title,
2020-04-27 23:10:38 +00:00
fontSize: 18,
2020-04-27 15:54:26 +00:00
},
tooltips: {
intersect: false,
position: 'middle',
2020-04-27 15:54:26 +00:00
axis: 'x',
},
scales: {
yAxes: [
{
display: true,
2020-04-30 02:51:23 +00:00
type: 'logarithmic',
2020-04-27 15:54:26 +00:00
ticks: {
precision: 0,
beginAtZero: true,
2020-04-30 02:51:23 +00:00
min: 0,
max: Math.pow(10, Math.ceil(Math.log10(charts.heroMaxValue))),
callback: value => Number(value.toString()).toLocaleString(),
2020-04-29 05:12:53 +00:00
},
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;
},
2020-04-27 15:54:26 +00:00
},
],
xAxes: [
{
display: true,
},
],
}
}
});
}
2020-04-27 03:04:16 +00:00
body
mixin formatNumber(num)
= Number(num).toLocaleString()
2020-04-27 15:54:26 +00:00
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") ▼
2020-04-28 03:02:31 +00:00
span.d-inline-block.text-truncate
2020-04-27 15:54:26 +00:00
block
2020-04-29 05:22:17 +00:00
mixin heroChart()
2020-04-27 23:10:38 +00:00
div.card.mb-4
2020-04-29 05:12:53 +00:00
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"
2020-04-30 02:51:23 +00:00
disabled
2020-04-29 05:12:53 +00:00
) Logarithmic
2020-04-27 23:10:38 +00:00
canvas.mx-auto(id="main-chart" width="800" height="450")
2020-04-29 05:22:17 +00:00
-
const growthRate = '+' + (data.deathGrowthRate * 100).toFixed(2) + '%';
const population = 'pop. ' + data.population.toLocaleString();
2020-04-30 02:51:23 +00:00
const deathsPerMillion = Math.round(data.deathsPerMillion).toLocaleString() + '/MM';
const heroTitle = [
'Covid-19 Deaths: ' + data.name,
`${population} | ${deathsPerMillion} | ${growthRate}`
];
2020-04-27 23:10:38 +00:00
script.
makeHeroChart(
'main-chart',
2020-04-29 05:22:17 +00:00
!{JSON.stringify(heroTitle)},
2020-04-27 23:10:38 +00:00
!{JSON.stringify(data.timeSeriesDaily.map(x => x.key))},
!{JSON.stringify(data.timeSeriesDaily.map(x => x.value))},
!{JSON.stringify(data.timeSeriesDaily.map(x => x.delta))},
2020-04-30 02:51:23 +00:00
!{JSON.stringify(data.rollingAverageDaily.map(x => x.delta))},
!{JSON.stringify(data.doublingDaily.map(x => x.value))},
2020-04-27 23:10:38 +00:00
);
2020-04-29 00:58:53 +00:00
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
2020-04-29 01:17:03 +00:00
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
2020-04-30 02:51:23 +00:00
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
2020-04-29 01:17:03 +00:00
th.text-center Trend
2020-04-29 00:58:53 +00:00
-
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
2020-04-29 01:17:03 +00:00
-
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);
2020-04-30 02:51:23 +00:00
const yesterday = getDelta(2);
2020-04-29 01:17:03 +00:00
const last7 = getValue(1) - getValue(7);
const last30 = getValue(1) - getValue(30);
2020-04-29 00:58:53 +00:00
tr(
id=("row-" + (item.safeName || '_'))
data-name=(item.name || '_')
2020-04-29 00:58:53 +00:00
data-population=item.population
data-total=item.total
data-million=item.deathsPerMillion
2020-04-29 01:17:03 +00:00
data-today=today
2020-04-30 02:51:23 +00:00
data-yesterday=yesterday
2020-04-29 01:17:03 +00:00
data-last7=last7
data-last30=last30
2020-04-29 00:58:53 +00:00
data-growth=item.deathGrowthRate
)
td.sort-order= i + 1
td: +renderItemName(item)
if hasPopulation
2020-04-29 01:17:03 +00:00
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)
2020-04-30 02:51:23 +00:00
td.text-right: code: +formatNumber(yesterday)
2020-04-29 01:17:03 +00:00
td.text-right: code: +formatNumber(last7)
td.text-right: code: +formatNumber(last30)
td.text-right: code= Number(item.deathGrowthRate * 100).toFixed(2) + '%'
2020-04-29 00:58:53 +00:00
td
canvas.mx-auto(id="sparkline-" + i width="200" height="50")
script.
makeSparkline(
"sparkline-#{i}",
2020-05-01 06:28:21 +00:00
#{JSON.stringify(item.timeSeriesDaily.slice(-14).map(x => x.delta))},
2020-04-29 00:58:53 +00:00
);
2020-04-29 05:12:53 +00:00
2020-04-29 00:38:11 +00:00
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]
2020-04-27 03:04:16 +00:00
2020-04-29 00:38:11 +00:00
hr
2020-04-29 05:12:53 +00:00
div.main-content.mt-4.position-relative
2020-04-27 03:04:16 +00:00
block main
2020-04-27 15:54:26 +00:00
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);
2020-04-27 15:54:26 +00:00
}
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':
2020-04-29 01:17:03 +00:00
case 'today':
2020-04-30 02:51:23 +00:00
case 'yesterday':
2020-04-29 01:17:03 +00:00
case 'last7':
case 'last30':
2020-04-28 03:02:31 +00:00
case 'population':
case 'million':
case 'growth':
sortByNumberThenName(value);
2020-04-27 15:54:26 +00:00
break;
}
};
const handleHash = (hash) => {
const sortValue = hash.replace(/^#sort:/, '').split(':');
handleSort(sortValue[0], sortValue[1]);
};
window.addEventListener('hashchange', () => {
handleHash(window.location.hash);
});
2020-04-27 03:04:16 +00:00
2020-04-27 15:54:26 +00:00
handleHash(window.location.hash);
2020-04-29 00:38:11 +00:00
const setDate = (selector) => {
const node = document.querySelector(selector);
node.textContent = new Date(node.getAttribute('datetime')).toLocaleString();
};
setDate('.generation-date');
setDate('.update-date');
2020-04-27 15:54:26 +00:00
}());