Published
Edited
Nov 4, 2021
7 forks
Importers
33 stars
Insert cell
Insert cell
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",
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]
);

// return yield { dateDomain, series };

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));
}
Insert cell
Insert cell
Insert cell
data = rawData
.filter(
d =>
countryFilter.includes("All") ||
countryFilter.length === 0 ||
(countryFilter.includes("Outside China") && d.country !== 'China') ||
countryFilter.includes(d.country) ||
(population.has(d.country) &&
(countryFilter.includes(population.get(d.country).continentalRegion) ||
countryFilter.includes(population.get(d.country).statisticalRegion)))
)
.filter(
d =>
!(+populationThreshold > 0) ||
(population.has(d.country) &&
population.get(d.country).pop2019 > +populationThreshold * 1e6)
)
Insert cell
countries = rawData.map(d => d.country).filter((value, index, self) => self.indexOf(value) === index).sort();
Insert cell
Insert cell
population = FileAttachment("population.csv")
.text()
.then(d3.csvParse)
.then(data => new Map(data.map(d => [d.country, d])))
Insert cell
regions = [
...new Set(
[...population.values()]
.map(d => [d.continentalRegion, d.statisticalRegion])
.flat()
)
].filter(d => d)
Insert cell
allSeries = {
const maxDate = d3.timeFormat("%Y-%m-%d")(Date.now());

return d3
.rollups(
data.filter(d => d.ymd <= maxDate),
v => {
const confirmed_daily =
d3.sum(v.filter(d => d.type === "confirmed"), d => d.cases) || 0,
deaths_daily =
d3.sum(v.filter(d => d.type === "death"), d => d.cases) || 0,
confirmed =
d3.sum(v.filter(d => d.type === "confirmed"), d => d.total) || 0,
deaths = d3.sum(v.filter(d => d.type === "death"), d => d.total) || 0,
recovered =
d3.sum(v.filter(d => d.type === "recovered"), d => d.total) || 0,
pop100k =
groupBy === 'country'
? (population.has(v[0].country)
? population.get(v[0].country).pop2019
: 1e8) /* arbitrary value for bad data */ / 100000
: Infinity;
return {
confirmed,
deaths,
recovered,
confirmed_daily,
deaths_daily,
"confirmed per 100k": confirmed / pop100k,
"deaths per 100k": deaths / pop100k,
country: v[0].country,
province: v[0].province,
ymd: v[0].ymd,
date: parseDate(v[0].ymd)
};
},
d => (groupBy === 'total' ? 'Total' : d[groupBy]),
d => d.ymd
)
.reverse();
}
Insert cell
dateThreshold = new Map(
allSeries
.map(d => [
d[0],
d3.min(d[1].filter(e => e[1].deaths >= threshold), e => e[1].date)
])
.filter(d => d[1])
)
Insert cell
series = {
const series = allSeries.filter(d => dateThreshold.has(d[0]));

for (const d of series) {
d[1] = d[1].map(e => {
e[1].daterelative = e[1].date - +dateThreshold.get(d[0]);
return e;
});
}

return series;
}
Insert cell
events = [
{
name: 'WHO declares pandemic',
date: parseDate('2020-03-11')
}
/*,
{
name: 'US declares national emergency',
date: parseDate('2020-03-13'),
},*/
]
Insert cell
relativeevents = [
{
name: 'Relative date reference',
date: 0
}
/*,
{
name: 'US declares national emergency',
date: parseDate('2020-03-13'),
},*/
]
Insert cell
// Total
function label_region() {
return countryFilter.includes("All") ? "Total" : countryFilter.join(", ");
}
Insert cell
Insert cell
viewof countryFilter = select({
options: [
"All",
/* "Outside China", */
"——————",
...regions,
"——————",
...countries
],
value: preset.countryFilter || ["All"],
description: "Countries",
multiple: true,
size: 10
})
Insert cell
viewof axisX = select({
options: [
"date",
"daterelative",
"confirmed",
"deaths",
"recovered",
"confirmed per 100k",
"deaths per 100k"
],
value: preset.axisX || (threshold ? "daterelative" : "date"),
description: "X dimension"
})
Insert cell
viewof axisY = select({
options: [
"confirmed",
"deaths",
"recovered",
"confirmed_daily",
"deaths_daily",
"confirmed per 100k",
"deaths per 100k"
],
value: preset.axisY || "deaths",
description: "Y dimension"
})
Insert cell
viewof groupBy = select({
options: ["country", "province", "total"],
value: preset.groupBy || "country",
description: "group by"
})
Insert cell
viewof useLog = checkbox({
options: ["Use log scales"],
value: preset.useLog || false
})
Insert cell
viewof showPoints = checkbox({
options: ["Show points"],
value: preset.showPoints === false ? false : ["Show points"]
})
Insert cell
viewof useRegression = checkbox({
options: ["Trend lines"],
value: preset.useRegression ? ["Trend lines"] : []
})
Insert cell
viewof regressionPeriod = slider({
min: 3,
max: 30,
value: preset.regressionPeriod || 0,
step: 1,
description: "Days"
})
Insert cell
viewof regressionDelay = slider({
min: 0,
max: 30,
value: preset.regressionDelay||0,
step: 1,
description: "Days from latest"
})
Insert cell
viewof thresholdLog10 = slider({
min: -.5,
max: Math.log10(300),
value: Math.log10(preset.deathsThreshold) || -.5,
step: 0.01,
format: v => `${Math.round(10 ** v)}`,
description: "align on deaths #"
})
Insert cell
viewof populationThreshold = select({
options: ["—", "100", "50", "20", "10", "5", "1"],
value: preset.populationThreshold || "—",
description: "Population threshold (millions)"
})
Insert cell
Insert cell
Insert cell
viewof smoothGaussian = checkbox({
options: ["gaussian kernel"],
value: preset.smoothGaussian && ["gaussian kernel"]
})
Insert cell
Insert cell
import { bookmark } from "@severo/bookmark"
Insert cell
preset = bookmark.read()
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
DAY = 8.64e7
Insert cell
d3 = require("d3@6", "d3-regression@1")
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) { text-shadow: 1px 1px 1px #fff; pointer-events: none; font-family: sans-serif; font-size: 10px }
</style>`
Insert cell
// import { callout } from "@d3/line-chart-with-tooltip"
callout = (g, value) => {
if (!value) return g.style("display", "none");

g.style("display", null)
.style("pointer-events", "none")
.style("font", "10px sans-serif");

const path = g
.selectAll("path")
.data([null])
.join("path")
.attr("fill", "white")
.attr("stroke", "black");

const text = g
.selectAll("text")
.data([null])
.join("text")
.call((text) =>
text
.selectAll("tspan")
.data((value + "").split(/\n/))
.join("tspan")
.attr("x", 0)
.attr("y", (d, i) => `${i * 1.1}em`)
.style("font-weight", (_, i) => (i ? null : "bold"))
.text((d) => d)
);

const { x, y, width: w, height: h } = text.node().getBBox();

text.attr("transform", `translate(${-w / 2},${15 - y})`);
path.attr(
"d",
`M${-w / 2 - 10},5H-5l5,-5l5,5H${w / 2 + 10}v${h + 20}h-${w + 20}z`
);
}
Insert cell
import { Scrubber } from "@mbostock/scrubber"
Insert cell
import { movingAverage } from "@d3/moving-average"
Insert cell
precision = 1e-4
Insert cell
function applyKernel(points, w) {
const values = new Float64Array(points.length).fill(0),
total = new Float64Array(points.length).fill(0);
let p = 1;
for (let d = 0; p > precision; d++) {
p = w(d);
for (let i = 0; i < points.length; i++) {
if (i + d < points.length) {
values[i + d] += p * points[i];
total[i + d] += p;
}
if (i - d >= 0) {
values[i - d] += p * points[i];
total[i - d] += p;
}
}
}
for (let i = 0; i < values.length; i++) {
values[i] /= total[i];
}
return values;
}
Insert cell
function smoothPoints(points) {
if (smoothGaussian) {
const w = d => Math.exp(-((d / smooth) ** 2));
const mm = applyKernel(points.map(d => d[1]), w);
points = points.map((d, i) => [d[0], mm[i]]).filter(d => d[1]);
} else {
const mm = movingAverage(points.map(d => d[1]), 2 * smooth + 1);
points = points.map((d, i) => [d[0], mm[i + smooth]]).filter(d => d[1]);
}
return points;
}
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more