Published
Edited
May 5, 2022
4 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
statesSummary = d3.csv(`https://api.covidactnow.org/v2/states.csv?apiKey=${API_KEY}`)
Insert cell
data = d3.csv(`https://api.covidactnow.org/v2/states.timeseries.csv?apiKey=${API_KEY}`, d3.autoType)
Insert cell
groupedData = d3.group(data, d => d.state)
Insert cell
validMinimaPair = d => (
// Make sure this point is a case minimum and there's a death minimum after it
d.caseMinimum &&
d.nextDeathMinimum &&
// Exclude peaks that seem either too soon or too late to be related
d.daysTillNextDeathMinimum > minDeathDelay &&
d.daysTillNextDeathMinimum < maxDeathDelay
)
Insert cell
blur = arr => ArrayBlur.blur().radius(blurRadius)(arr)
Insert cell
annotateStateData = (singleStateData) => {
const blurredCases = blur(singleStateData.map(d => d['actuals.newCases']))
const blurredDeaths = blur(singleStateData.map(d => d['actuals.newDeaths']))

// A container to store state during map iterations
const memo = {
vaccinesInitiatedRatioCursor: 0,
vaccinesCompletedRatioCursor: 0,
caseWaveCursor: 0,
deathWaveCursor: 0,
}
return singleStateData.map((d, i, arr) => ({
date: d.date,
newCases: d['actuals.newCases'],
newDeaths: d['actuals.newDeaths'],
newCasesSmooth: blurredCases[i],
newDeathsSmooth: blurredDeaths[i],
caseMinimum: blurredCases[i - 1] > blurredCases[i] && blurredCases[i + 1] > blurredCases[i],
deathMinimum: blurredDeaths[i - 1] > blurredDeaths[i] && blurredDeaths[i + 1] > blurredDeaths[i],
vaccinesInitiatedRatio: d['metrics.vaccinationsInitiatedRatio'],
vaccinesCompletedRatio: d['metrics.vaccinationsCompletedRatio'],
state: d.state,
}))
.map((d, i, all) => {
// This function fills empty vaccine data with the previous value
memo.vaccinesInitiatedRatioCursor = d.vaccinesInitiatedRatio ? d.vaccinesInitiatedRatio : memo.vaccinesInitiatedRatioCursor
memo.vaccinesCompletedRatioCursor = d.vaccinesCompletedRatio ? d.vaccinesCompletedRatio : memo.vaccinesCompletedRatioCursor
return {
...d,
vaccinesInitiatedRatio: memo.vaccinesInitatedRatioCursor,
vaccinesCompletedRatio: memo.vaccinesCompletedRatioCursor,
}
})
.map((d, i, arr) => ({
...d,
// Find the next death peak data point and record it's index in the dataset
nextDeathMinimumIdx: (
arr.slice(i).findIndex(d => d.deathMinimum) !== -1 ?
i + arr.slice(i).findIndex(d => d.deathMinimum) :
null
),
}))
.map((d, i, arr) => ({
...d,
// Fetch the next death minimum date
nextDeathMinimum: (
d.nextDeathMinimumIdx ?
arr[d.nextDeathMinimumIdx].date :
null
)
}))
.map((d, i, arr) => ({
...d,
// Calculate the time in days until the next death minimum
daysTillNextDeathMinimum: (
d.nextDeathMinimum ?
d3.timeDay.range(d.date, d.nextDeathMinimum).length :
null
)
}))
.map((d, i, arr) => ({
...d,
// Mark all case minima that have a corresponding death minimum
isValidCaseMinimum: validMinimaPair(d),
}))
.map((d, i, arr) => ({
...d,
// Mark all death minima that have a corresponding case minimum
isValidDeathMinimum: !!arr.find(p => p.isValidCaseMinimum && p.nextDeathMinimumIdx === i)
}))
.map((d, i, arr) => {
// This function annotates datapoints with what wave they're part of
// When it encounters a new minimum, it increments the wave number
if (d.isValidCaseMinimum) memo.caseWaveCursor += 1
if (d.isValidDeathMinimum) memo.deathWaveCursor += 1
return {
...d,
// Break datapoints into discrete wave categories according to how many case or death minima precede that datapoint
caseWave: memo.caseWaveCursor,
deathWave: memo.deathWaveCursor,
}
})
}
Insert cell
annotatedStates = Array.from(groupedData).map(([state, d]) => {
return [state, annotateStateData(d)]
})
Insert cell
annotatedStateData = annotatedStates
.find(([state_, d]) => state_ === state)[1]
.filter(d => d.date > d3.timeParse('%Y-%m-%d')(startDate))
Insert cell
stateVaccineMilestones = {
return annotatedStates.map(([state, data]) => {
return [
state,
d3.range(0.1, 1, 0.1).map(p => {
const closestPoint = data[d3.bisectLeft(data.map(d => d.vaccinesCompletedRatio), p)]
return {
state,
approxVaccineRatio: p,
actualVaccineRatio: closestPoint ? closestPoint.vaccinesCompletedRatio : null,
date: closestPoint ? closestPoint.date : null,
}
})
]
})
}
Insert cell
Insert cell
waveCfrChart = (data, width = 300, height = 200) => {
const waves = Array.from(new Set(data.map(d => d.caseWave)))
.map(w => ({
wave: w,
waveLabel: `Wave ${w}`,
caseDates: d3.extent(data.filter(d => d.caseWave === w), d => d.date),
deathDates: d3.extent(data.filter(d => d.deathWave === w), d => d.date),
caseCount: d3.sum(data.filter(d => d.caseWave === w), d => d.newCases),
deathCount: d3.sum(data.filter(d => d.deathWave === w), d => d.newDeaths),
}))
.map(d => ({
...d,
cfr: d.deathCount / d.caseCount,
}))

const vaccineMilestones = stateVaccineMilestones.find(([state, _]) => state === data[0].state)[1]
const chart = Plot.plot({
color: { type: 'categorical', scheme: COLOR_SCHEME },
y: { label: 'CFR ↑', tickFormat: '.1%' },
width,
height,
marks: [
// Vaccine Milestone Ticks
Plot.tickX(vaccineMilestones, {
x: 'date',
stroke: '#000',
strokeOpacity: 0.15,
}),
// Vaccine Milestone % Labels
width > 400 ? Plot.text(vaccineMilestones.filter((_, i) => i % 2 === 0), {
x: 'date',
y: d3.max(waves, w => w.cfr),
text: d => d3.format('.0%')(d.approxVaccineRatio),
dy: -5,
}) : null,
// Vaccine Milestone Label
width > 400 ? Plot.text(vaccineMilestones, Plot.selectFirst({
x: 'date',
y: d3.max(waves, w => w.cfr),
text: d => 'Fully vaccinated population:',
textAnchor: 'end',
fontWeight: 'bold',
dy: -5,
dx: -15
})) : null,
// Wave rectangles
Plot.rect(waves, {
x1: d => d.caseDates[0],
x2: d => d.caseDates[1],
y1: d => 0,
y2: 'cfr',
fill: 'wave',
}),
// Wave % labels
Plot.text(waves, {
x: d => d.caseDates[0],
y: d => d.cfr,
text: (d, i) => d3.format('.1%')(d.cfr) + (i + 1 < waves.length ? '' : '*'),
textAnchor: 'start',
fill: '#000',
opacity: (d, i) => i + 1 < waves.length ? 1 : 0.5,
stroke: '#FFF',
strokeWidth: 4,
dx: 4,
dy: -5,
}),
Plot.ruleY([0])
]
})

// Replace the last rectangle with a textured color
let fillColor
try {
fillColor = d3.select(chart).selectAll('rect:last-of-type').attr('fill')
} catch {
fillColor = '#000'
}
const texture = textures.lines().size(10).thicker().stroke(fillColor).background('white')
d3.select(chart).call(texture)
d3.select(chart).selectAll('rect:last-of-type').attr('fill', texture.url())

// Adjust the strokes on the text to be under the text
d3.select(chart).selectAll('text').attr('paint-order', 'stroke')
return chart
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
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