Published
Edited
Mar 27, 2020
2 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
chart = function*(options) {
options = options || {};
const w = options.width || width,
height = options.height || w * 0.7,
axisX = options.axisX || "date",
axisY = options.axisY || "confirmed",
perMillionX = axisX.indexOf("per million") >= 0,
perMillionY = axisY.indexOf("per million") >= 0,
groupBy = ((perMillionX || perMillionY) && options.groupBy !== "country" && options.groupBy !== "province") ? "country" : (options.groupBy || "country"),
useLog = options.useLog || false;

const svg = d3
.create("svg")
.attr("class", "chart")
.attr("viewBox", [0, 0, w, height]);

yield svg.node();

const maxDate = d3.timeFormat("%Y-%m-%d")(
Date.now()
// new Date(Date.now() - 24 * 3600 * 1000 * 1)
);

const series = d3
.rollups(
data.filter(d => d.ymd <= maxDate),
v => ({
confirmed:
d3.sum(v.filter(d => d.type === "confirmed"), d => d[valueField]) || 0,
"confirmed per million":
d3.sum(v.filter(d => d.type === `confirmed per million (${groupBy})`), d => d[valueField]) || 0,
"deaths per million":
d3.sum(v.filter(d => d.type === `death per million (${groupBy})`), d => d[valueField]) || 0,
deaths: d3.sum(v.filter(d => d.type === "death"), d => d[valueField]) || 0,
recovered:
d3.sum(v.filter(d => d.type === "recovered"), d => d[valueField]) || 0,
country: v[0].country,
population: v[0].population,
province: v[0].province,
region: v[0].region,
continent: v[0].continent,
ymd: v[0].ymd,
date: parseDate(v[0].ymd)
}),
d => groupBy === 'total' ? 'Total' : d[groupBy],
d => d.ymd
)
.reverse();

const x =
axisX === "date"
? d3.scaleTime().domain(d3.extent(data, d => d.date))
: d3[useLog ? "scaleLog" : "scaleLinear"]().domain([
perMillionX ? 0.001 : 1,
d3.max(series, ([, s]) => d3.max(s, ([, d]) => d[axisX]))
]),
y = d3[useLog ? "scaleLog" : "scaleLinear"]().domain([
perMillionY ? 0.001 : 1,
d3.max(series, ([, s]) => d3.max(s, ([, d]) => d[axisY]))
]);

x.range([120, w - 100]);
y.range([height - 90, 40]);

const zero = (perMillionX || perMillionY) ? 0.0006 : .6, // where to put our zeros on a log scale
zeroAxis = (perMillionX || perMillionY) ? 0.0004 : .4; // where to put the axis

const line = d3.line().curve(cumulative ? d3.curveMonotoneY : d3.curveLinear);

const s = svg
.selectAll("g")
.data(series)
.join("g");
const lines = s
.append("path")
.attr("d", d => {
let points = d[1].map(d => [
x(d[1][axisX] || zero),
y(d[1][axisY] || zero)
]);
let transform = useLog ? Math.log10 : d => d;
let pointsRaw = d[1].map(d => [
axisX === "date" ? Math.floor(d[1].date/8.64e7) : transform(d[1][axisX] || zero),
axisY === "date" ? Math.floor(d[1].date/8.64e7) : transform(d[1][axisY] || zero)
]);

if (useRegression)
points = d3.regressionLinear()(
points.slice(
Math.max(0, points.length - 1 - regressionDelay - regressionPeriod),
Math.max(0, points.length -regressionDelay)
)
);
d.regression = d3.regressionLinear()(
pointsRaw.slice(
Math.max(0, pointsRaw.length - 1 - regressionDelay - regressionPeriod),
Math.max(0, pointsRaw.length - regressionDelay)
)
);

return line(points);
})
.style("fill", "none")
.style("stroke-width", 1.5)
.style("stroke", d => color(groupBy === 'total' ? 1 : d[1][0][1][groupBy]));

const circles = s
.style("fill", d => color(groupBy === 'total' ? 1 : d[1][0][1][groupBy]))
.selectAll("circle")
.data(d => (showPoints ? d[1] : []))
.join("circle");
circles
.attr("r", 2.5)
.attr("cx", d => x(d[1][axisX] || zero))
.attr("cy", d => y(d[1][axisY] || zero));

const labels = svg.append("g");
labels
.selectAll("text")
.data(series)
.join("text")
.text(d => {
return `${
d[0].replace(/Mainland China:/, "").replace(/:$/, "")
} ${useRegression ? `${
(axisX === 'date' && useLog) ? (1/d.regression.a).toFixed(3) : d.regression.a.toFixed(3)
} ${
useLog
? axisX === 'date' ? `days per 10x ${axisY}` : `10x ${axisY} per 10x ${axisX}`
: axisX === 'date' ? `${axisY} per day` : `${axisY} per ${axisX}`
}` : ''}`;
})
.attr(
"transform",
d =>
`translate(${x(d[1][d[1].length - 1][1][axisX] || zero)},${y(
d[1][d[1].length - 1][1][axisY] || zero
) - 5})`
)
.attr("text-anchor", "middle")
.attr("dx", -6)
.attr("dy", -4)
.style("fill", d => color(groupBy === 'total' ? 1 : d[1][0][1][groupBy]));

labels.call(occlusion);
// try a few times to move occluded labels down their lines
// gives them a second chance at finding a spot
for (let i = 1; i < 14; i++) {
labels
.selectAll(".occluded")
.classed("occluded", false)
.attr("transform", d => {
const b = d[1].slice(-i)[0];
if (b)
return `translate(${x(b[1][axisX] || zero)},${y(
b[1][axisY] || zero
)})`;
});
labels.call(occlusion);
}

const axis = svg.append("g");
const axisB = d3.axisBottom(x),
axisL = d3.axisLeft(y);

const formatN = d3.format(","),
logTickFormat = d => ((Math.log10(d) - Math.floor(Math.log10(d))) === 0 ? formatN(d) : "");
if (useLog) {
axisL.tickFormat(logTickFormat);
if (axisX !== "date") axisB.tickFormat(logTickFormat);
}

axis
.append("g")
.call(axisB)
.attr("transform", `translate(0, ${y(zeroAxis)})`)
.append("text")
.text(axisX === "date" ? axisX : `${cumulative ? 'cumulative' : 'daily'} ${axisX}`)
.attr("fill", `currentColor`)
.attr("text-anchor", `end`)
.attr("transform", `translate(${x.range()[1]}, ${-2})`);
axis
.append("g")
.call(axisL)
.attr("transform", `translate(${x.range()[0] - 30}, 0)`)
.append("text")
.text(`${cumulative ? 'cumulative' : 'daily'} ${axisY}`)
.attr("fill", `currentColor`)
.attr("text-anchor", `start`)
.attr("transform", `translate(${2}, ${y.range()[1]})`);

const grid = svg.insert("g", "g");
grid
.append("g")
.call(axisL)
.call(g =>
g
.selectAll(".tick line")
.attr("x2", x.range()[0])
.attr("x1", x.range()[1])
.style("stroke", "#ddd")
.style("stroke-width", .5)
)
.call(g => g.selectAll(".domain,text").remove());
grid
.append("g")
.call(axisB)
.call(g =>
g
.selectAll(".tick line")
.attr("y2", y.range()[0])
.attr("y1", y.range()[1])
.style("stroke", "#ddd")
.style("stroke-width", .5)
)
.call(g => g.selectAll(".domain,text").remove());
const eventLines = svg.append('g');
eventLines
.selectAll('line')
.data(events)
.join('line')
.attr('x1', d => x(d.date))
.attr('x2', d => x(d.date))
.attr('y1', d => y(zeroAxis))
.attr('y2', d => y(y.domain()[1]))
.style('visibility', axisX === 'date' ? 'visible' : 'hidden')
.style('stroke', '#aaa')
.style('stroke-width', 1);

const eventLabels = svg.append('g');
eventLabels
.selectAll('text')
.data(events)
.join('text')
.text(d => d.name)
.style('visibility', axisX === 'date' ? 'visible' : 'hidden')
.attr('x', 4)
.attr('y', -4)
.attr('transform', d => `translate(${x(d.date)},${y(zeroAxis)})rotate(-90)`)
.style('fill', '#aaa');
const tooltip = svg.append("g"),
xy = [];
circles.each(function() {
xy.push([+this.getAttribute("cx"), +this.getAttribute("cy") + 2]);
});
const delaunay = d3.Delaunay.from(xy);
const numformat = d3.format(",d");
svg.on("touchmove mousemove", function() {
const m = d3.mouse(this),
i = delaunay.find(...m),
dist = Math.hypot(m[0] - xy[i][0], m[1] - xy[i][1]),
d = circles.data()[i];
if (dist < 10)
tooltip.attr("transform", `translate(${xy[i]})`).call(
callout,
`${groupBy === 'total' ? 'Total' : d[1][groupBy]}
${d[0]}
c: ${numformat(d[1].confirmed)}
d: ${numformat(d[1].deaths)}
r: ${numformat(d[1].recovered)}
cpm: ${numformat(d[1]['confirmed per million'])}
dpm: ${numformat(d[1]['deaths per million'])}`
);
else tooltip.call(callout, null);
});

svg.on("touchend mouseleave", () => tooltip.call(callout, null));
}
Insert cell
Insert cell
Insert cell
countries = rawData.map(d => d.country).filter((value, index, self) => self.indexOf(value) === index).sort();
Insert cell
countryRank = {
const rank = {};
countries.sort((a, b) => countryStats(b).pop2019 - countryStats(a).pop2019).forEach((d, i) => rank[d] = i);
return rank;
}
Insert cell
data = {
let raw = rawData.filter(
d =>
(
filter.includes("All") ||
filter.length === 0 ||
(filter.includes("Outside China") && d.country !== 'China') ||
filter.includes(d.country)
) &&
(
countryRank[d.country] < topPopulation
)
);
raw.forEach(d => {
d.continent = countryStats(d.country).continentalRegion;
d.region = countryStats(d.country).statisticalRegion;
});
let country = raw.map(d => {
let n = {...d};
n.type = `${d.type} per million (country)`;
n.caseCount = d.cases;
n.cases = 1e6 * d.cases / countryStats(d.country).pop2019;
n.total = 1e6 * d.total / countryStats(d.country).pop2019;
return n;
});
let province = raw.filter(d => provincePopulationMap[d.province] !== undefined || populationMap[d.province.replace(':','')] !== undefined).map(d => {
let n = {...d};
n.type = `${d.type} per million (province)`;
n.caseCount = d.cases;
n.cases = 1e6 * d.cases / (provincePopulationMap[d.province] || populationMap[d.province.replace(':','')].pop2019);
n.total = 1e6 * d.total / (provincePopulationMap[d.province] || populationMap[d.province.replace(':','')].pop2019);
return n;
});
return [...raw, ...country, ...province];
// return [...province];
}
Insert cell
events = [
// {
// name: 'China begins lockdown',
// date: parseDate('2020-01-23'),
// },
{
name: 'WHO declares pandemic',
date: parseDate('2020-03-11'),
},
// {
// name: 'US declares national emergency',
// date: parseDate('2020-03-13'),
// },
]
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
provincePopulation = d3.csvParse(provincePopulationData, d => { d.pop2019 = +(d.pop2019.replace(/,/g,'')); return d; });
Insert cell
provincePopulationMap = {
const map = {};
provincePopulation.forEach(d => map[`US:${d.state}`] = d.pop2019);
return map;
}
Insert cell
function countryStats(country) {
if (populationMap[country] !== undefined) {
return populationMap[country];
}
return {
pop2019: 1e3,
statisticalRegion: 'unknown',
continentalRegion: 'unknown',
};
}
Insert cell
Insert cell
viewof filter = select({
options: ["All", "Outside China", ...countries],
value: "all",
description: "Countries",
multiple: true,
size: 10
})
Insert cell
viewof topPopulation = slider({
min: 1,
max: 166,
value: 166,
step: 1,
description: "N most populous countries"
})
Insert cell
viewof axisY = select({
options: ["confirmed", "deaths", "recovered", "confirmed per million", "deaths per million"],
value: "confirmed",
description: "Y dimension"
})
Insert cell
viewof axisX = select({
options: ["date", "confirmed", "deaths", "recovered", "confirmed per million", "deaths per million"],
value: "date",
description: "X dimension"
})
Insert cell
viewof groupBy = select({
options: ["country", "province", "continent", "region", "total"],
description: "Group by"
})
Insert cell
viewof cumulative = checkbox({
options: ["Cumulative"],
value: ["Cumulative"]
})
Insert cell
valueField = cumulative ? 'total' : 'cases';
Insert cell
viewof useLog = checkbox({
options: ["Use log scales"],
value: ["Use log scales"]
})
Insert cell
viewof showPoints= checkbox({
options: ["Show points"],
value: ["Show points"]
})
Insert cell
viewof useRegression = checkbox({
options: ["Trend lines"],
value: []
})
Insert cell
viewof regressionPeriod = slider({
min: 3,
max: 30,
value: 0,
step: 1,
description: "Days"
})
Insert cell
viewof regressionDelay = slider({
min: 0,
max: 60,
value: 0,
step: 1,
description: "Days from latest"
})
Insert cell
Insert cell
color = (groupBy,
d3.scaleOrdinal(
d3.schemeCategory10.concat(d3.schemeSet2).concat(d3.schemeDark2)
))
Insert cell
parseDate = d3.timeParse("%Y-%m-%d")
Insert cell
d3 = require("d3@5", "d3-array@2", "d3-delaunay@5", "d3-regression")
Insert cell
import { checkbox, select, slider } from "@jashkenas/inputs"
Insert cell
import { occlusion } with { d3 } from "@fil/occlusion"
Insert cell
html`<style>
svg.chart { overflow: visible; }
svg.chart text.occluded { display: none; }
svg.chart text:not(.occluded) { stroke-width: 2px; stroke: white; paint-order: stroke; pointer-events: none; font-family: sans-serif; font-size: 10px }
</style>`
Insert cell
import { callout } from "@d3/line-chart-with-tooltip"
Insert cell
import { Scrubber } from "@mbostock/scrubber"
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more