Published
Edited
Dec 3, 2021
7 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
rawCountyData = d3.csv(
'https://raw.githubusercontent.com/nytimes/covid-19-data/master/us-counties.csv',
d => {
// NYT's data doesn't assign NYC a FIPS, so we manually assign if the FIPS the census
// uses for "New York County"
if (d.county === "New York City") d.fips = "36061"
// Parse the time _not_ using UTC midnight
d.date = d3.timeParse('%Y-%m-%d')(d.date)
// Convert cases and deaths to numbers
d.cases = +d.cases
d.deaths = +d.deaths
return d
}
)
Insert cell
Insert cell
countyData = d3.nest().key(d => d.fips).entries(rawCountyData)
Insert cell
Insert cell
countyPopulations = d3.csv(
await FileAttachment("ACSDT5Y2018.B01003_data_with_overlays_2020-06-12T132213.csv").url(),
d => {
if (d.GEO_ID === 'id') return null
return {
fips: d.GEO_ID.replace('0500000US', ''),
name: d.NAME,
population: +d.B01003_001E,
}
}
)
Insert cell
countyPopulations.filter(c => c.name.includes('New York'))
Insert cell
Insert cell
annotatedCounties = countyData.map(({ key, values }) => {
const latestDate = d3.max(values, v => v.date)

// We get 15 days worth of data so we can calculate deltas for 14 days
const startDate = dfns.sub(latestDate, { weeks: 2, days: 1 })

const countyPopulationObj = countyPopulations.find(c => +c.fips === +key)
const totalPopulation = countyPopulationObj ? +countyPopulationObj.population : null
// Annotate the data with deltas
const valuesWithDeltas = values.filter(v => v.date > startDate).sort((a, b) => a.date - b.date).map(
// Add new cases and new deaths as difference between previous day. Return null for day -15
({ cases, deaths, ...rest }, i, arr) => (i > 0 ? {
...rest,
cases,
newCases: cases - arr[i-1].cases,
deaths,
newDeaths: deaths - arr[i-1].deaths,
} : null)
// Remove the null value representing day -15
).filter(v => !!v)

// Use these points to calculate a regression of new cases per capita
const regressionPoints = valuesWithDeltas.filter(v => v.date > startDate).map(v => [
dfns.differenceInDays(v.date, startDate),
v.newCases / totalPopulation
])
// Total all cases for the past two weeks
const totalCases = d3.sum(valuesWithDeltas, d => d.newCases)

// Return data annotated with deltas and the regression
return {
county: valuesWithDeltas.length > 0 ? valuesWithDeltas[0].county : null,
state: valuesWithDeltas.length > 0 ? valuesWithDeltas[0].state : null,
fips: key,
days: valuesWithDeltas,
totalPopulation,
regressionPoints,
regression: totalCases < caseThreshold ? null : ss.linearRegression(regressionPoints),
}
})
Insert cell
Insert cell
Insert cell
colorScale = {
// The following code generates a linear scale with endpoints based on the data's
// 80% and 20% percentiles. I've retired it in favor of a static 10-log scale, but
// have left the code intact in case I decide to bring it back.
//
// const stepCount = 11
// const scaleMax = Math.max(
// d3.quantile(annotatedCounties, 0.8, d => d.regression.m),
// Math.abs(d3.quantile(annotatedCounties, 0.2, d => d.regression.m))
// )
// // How many steps are in half the scale?
// const stepSize = scaleMax * 2 / (stepCount)
// // Define the domain as an array of thresholds
// // We increase the max value a little bit to ensure that the max value doesn't get
// // left out. Float arithmetic can be a little tricky sometimes.
// const domain = d3.range(-scaleMax + stepSize, scaleMax - (stepSize / 2), stepSize)
return d3.scaleThreshold(
[
-1/100000,
-1/1000000,
-1/10000000,
1/10000000,
1/1000000,
1/100000,
],
[...d3.schemeRdBu[7]].reverse()
)
}
Insert cell
Insert cell
countyChart = datum => {
const chartWidth = 400
const chartHeight = chartWidth * 0.5
const margin = { left: 40, right: 40, top: 10, bottom: 20 }
const formatNumber = d3.format('.2r')
const xScale = d3.scaleTime()
.domain(d3.extent(datum.days, d => d.date))
.range([margin.left, chartWidth - margin.right])
const yScale = d3.scaleLinear()
.domain([0, d3.max(datum.days, d => d.newCases)])
.range([chartHeight - margin.bottom, margin.top])
// Undo the conversion to per-capita while generating the linear regression line
const linearRegressionLine = ss.linearRegressionLine({
m: datum.regression.m * datum.totalPopulation,
b: datum.regression.b * datum.totalPopulation,
})
// Get linear regression in the form of x more cases per day per day for display
const perDay = datum.regression.m * datum.totalPopulation
// Get slope as rendered in degrees for rendering text along the regression line
const slope = Math.atan(
(yScale(linearRegressionLine(0)) - yScale(linearRegressionLine(14)))
/
(margin.left - chartWidth + margin.right)
) * 180 / Math.PI
// Prepare grid measurements
const xLabelTicks = xScale.ticks(d3.timeDay.every(2))
const xGridTicks = xScale.ticks(d3.timeDay.every(1))
return html`
<svg viewbox="0 0 ${chartWidth} ${chartHeight}">
<!-- grid -->
<g>
${xLabelTicks.map(date => svg`
<text
x="${xScale(date)}"
y="${chartHeight - margin.bottom + 15}"
font-size="11"
font-family="var(--sans-serif)"
text-anchor="middle"
>${d3.timeFormat('%-m/%-d')(date)}</text>
`)}
${xGridTicks.map(date => svg`
<line
x1="${xScale(date)}"
x2="${xScale(date)}"
y1="0"
y2="${xLabelTicks.find(d => dfns.isEqual(d, date)) ? chartHeight - margin.bottom : chartHeight}"
stroke="#EEE"
/>
`)}
</g>

<!-- data points -->
<g>
${datum.days.map(d => svg`
<circle cx="${xScale(d.date)}" cy="${yScale(d.newCases)}" r="3" fill="#666" />
<text
x="${xScale(d.date) + 6}"
y="${yScale(d.newCases) + 4}"
font-size="11"
font-family="var(--sans-serif)"
fill="#CCC"
>${d.newCases}</text>
`)}
</g>

<!-- regression line -->
<line
x1="${margin.left}"
x2="${chartWidth - margin.right}"
y1="${yScale(linearRegressionLine(0))}"
y2="${yScale(linearRegressionLine(14))}"
stroke="#444"
stroke-dasharray="3"
/>

<!-- regression label -->

<text
x="${margin.left + chartWidth / 2 - margin.right}"
y="${yScale(linearRegressionLine(7)) - 7}"
transform-origin="${margin.left + chartWidth / 2 - margin.right} ${yScale(linearRegressionLine(7)) - 7}"
text-anchor="middle"
transform="rotate(${slope})"
font-size="12"
font-family="var(--sans-serif)"
paint-order="stroke"
stroke="#FFF"
stroke-width="4"
>
daily cases ${perDay > 0 ? 'rising' : 'falling'} by
<tspan font-weight="bold">
~${formatNumber(Math.abs(perDay))}
</tspan>
per day
</text>

<!-- start and end indicators -->

<circle cx="${margin.left}" cy="${yScale(linearRegressionLine(0))}" r="2" fill="#444" />
<text
x="${margin.left - 5}"
y="${yScale(linearRegressionLine(0)) + 5}"
text-anchor="end"
font-size="12"
font-family="sans-serif"
>${formatNumber(linearRegressionLine(0))}</text>

<circle cx="${chartWidth - margin.right}" cy="${yScale(linearRegressionLine(14))}" r="2" fill="#444" />
<text
x="${chartWidth - margin.right + 5}"
y="${yScale(linearRegressionLine(14)) + 3}"
font-size="12"
font-family="sans-serif"
text-anchor="start"
>${formatNumber(linearRegressionLine(14))}</text>
</svg>
`
}
Insert cell
countyTable = (days) => html`
<table class="county-tables__table">
${days.slice(0, 7).map(d => html`
<tr>
<th>
${d3.timeFormat('%-m/%-d')(d.date)}
</th>
<td>
${d.newCases}
</td>
<td>
</td>
</tr>
`)}
</table>
`
Insert cell
countyTables = (datum) => html`
<div class="county-tables">
<header class="county-tables__header">
<h2 class="county-tables__name">${datum.county}, ${datum.state}</h2>
<div class="county-tables__population">Pop. ${d3.format(',d')(datum.totalPopulation)}</div>
</header>
<div class="county-tables__tables">
${[
countyTable(datum.days.slice(0, 7)),
countyTable(datum.days.slice(7, 14)),
]}
</div>
</div>
`
Insert cell
countySummary = (datum) => html`
<div class="county-summary">
<div class="county-summary__columns">
${[countyTables(datum), html`<div class="county-chart">${countyChart(datum)}</div>`]}
</div>
</div>
`
Insert cell
Insert cell
Insert cell
Insert cell
html`<style type="text/css">
.county-summary__columns {
display: flex;
flex-direction: row;
}

@media(max-width: 600px) {
.county-summary__columns {
flex-direction: column;
}

.county-chart {
width: 100%;
margin-top: 20px;
flex-grow: 1;
}
}

.county-chart {
margin-right: 20px;
width: 400px;
flex-grow: 0;
}

.county-tables {
flex-grow: 1;
}

.county-tables__name, .county-tables__population {
display: inline;
}
.county-tables__population {
font-family: var(--sans-serif);
color: #666;
}

.county-tables__tables {
display: flex;
flex-direction: row;
justify-content: space-between;
}

.county-tables__table {
width: 48%;
}
</style>`
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
us = FileAttachment("counties-albers-10m.json").json()
Insert cell
Insert cell
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