chart = function*(options) {
options = options || {};
const w = options.width || width,
height = options.height || w * 0.7,
axisX = options.axisX || "date",
axisY = options.axisY || "confirmed",
groupBy = 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 dateDomain = d3.extent(
series
.map(d => d[1])
.flat()
.filter(d => d[1][axisX] >= -10 * DAY),
d => d[1][axisX]
);
const isDateX = ["date", "daterelative"].includes(axisX),
isDateY = ["date", "daterelative"].includes(axisY),
per100kX = axisX.match(/100/),
per100kY = axisY.match(/100/);
const x = isDateX
? axisX === "date"
? d3.scaleTime().domain(dateDomain)
: d3
.scaleLinear()
.domain(dateDomain)
.clamp(true)
: d3[useLog ? "scaleLog" : "scaleLinear"]().domain([
per100kX ? 0.0001 : 1,
d3.max(series, ([, s]) =>
Math.max(10, d3.max(s, ([, d]) => d[axisX]))
)
]),
y = d3[useLog ? "scaleLog" : "scaleLinear"]().domain([
per100kY ? 0.0001 : 1,
Math.max(10, d3.max(series, ([, s]) => d3.max(s, ([, d]) => d[axisY])))
]);
x.range([120, w - 100]);
y.range([height - 90, 40]);
const zero = y.invert(height - 50), // where to put our zeros on a log scale
zeroAxis = y.invert(height - 30); // where to put the axis
const line = d3.line().curve(d3.curveMonotoneY);
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 => [
isDateX ? d[1][axisX] / DAY : transform(d[1][axisX] || zero),
isDateY ? d[1][axisY] / DAY : 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)
)
);
else if (smooth) {
points = points.filter(d => d[1] < y(zero));
if (points.length > 4) {
points = smoothPoints(points);
line.curve(d3.curveMonotoneY);
}
}
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 => {
const base =
d[0] !== "Total"
? d[0].replace(/Mainland China:/, "").replace(/:$/, "")
: label_region();
return axisY.match(/_daily/)
? base
: `${base} ${
useRegression
? `${
isDateX && useLog
? (Math.log(2) / Math.log(10) / d.regression.a).toFixed(1)
: d.regression.a.toFixed(1)
} ${
useLog
? isDateX
? `days per 2x ${axisY}`
: `10x ${axisY} per 10x ${axisX}`
: isDateX
? `${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 (!isDateX) axisB.tickFormat(logTickFormat);
}
if (axisX === "daterelative") axisB.tickFormat(d => Math.round(+d / DAY));
axis
.append("g")
.call(axisB)
.attr("transform", `translate(0, ${y(zeroAxis)})`)
.append("text")
.text(isDateX ? axisX : `cumulative ${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(axisY.match(/_daily/) ? axisY : `cumulative ${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());
if (isDateX) {
const eventsG = svg
.append("g")
.selectAll("g")
.data(axisX === "daterelative" ? relativeevents : events)
.join("g");
eventsG
.append('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('stroke', '#aaa')
.style('stroke-width', 1);
eventsG
.append('text')
.text(d => d.name)
.attr('x', 4)
.attr('y', -4)
.attr(
'transform',
d => `translate(${x(d.date)},${y(zeroAxis)})rotate(-90)`
)
.style('fill', '#aaa');
}
// tooltip
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(event) {
const m = d3.pointer(event),
i = delaunay.find(...m),
dist = Math.hypot(m[0] - xy[i][0], m[1] - xy[i][1]),
d = circles.data()[i];
if (dist < 30)
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)}`
);
else tooltip.call(callout, null);
});
svg.on("touchend mouseleave", () => tooltip.call(callout, null));
}