Public
Edited
Mar 21, 2024
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
chart = () => {
const wrapper = d3.create("div")
.style("font-family", franklinLight)
.style("pointer-events", "none");

wrapper.append("style").html(css);

const chart = wrapper.selectAll(".chart")
.data(data)
.join("div")
.attr("class", "div")
.style("display", "inline-block")
.style("margin-left", (d, i) => width <= breakpoint ? 0 : i == 0 ? 0 : "24px")
.style("margin-right", (d, i) => width <= breakpoint ? 0 : i === 0 ? "24px" : 0)
.style("margin-bottom", (d, i) => width <= breakpoint && i === 0 ? "24px" : 0)
.style("width", `${basewidth}px)`);

chart.append("div")
.style("margin-bottom", "16px")
.text(d => d.type === "bloom_day" ? "Peak bloom date" : `Average temperature, Jan. 1 - ${formatDate(ghcnLatest)}`)

const svg = chart.append("svg")
.attr("width", chartwidth + margin.left + margin.right)
.attr("height", chartheight + margin.top + margin.bottom)
.style("overflow", "visible");

const g = svg.append("g")
.attr("transform", `translate(${[margin.left, margin.top]})`);

g.append("rect")
.attr("x", -r)
.attr("width", chartwidth + (r * 2))
.attr("height", chartheight)
.style("pointer-events", "all")
.style("fill", "white")
.on("mousemove", (ev) => {
const year = Math.round(x.invert(ev.offsetX - margin.left));
tipLabel.style("display", d => d.year === year ? "block" : "none");
})
.on("mouseout", () => {
tipLabel.style("display", (d, i, e) => i === e.length - 1 ? "block" : "none");
});

g.append("g").call(xAxis);

g.append("g")
.each((d, i, e) => {
d3.select(e[i]).call(g => d.yAxisGenerator(g, "line"));
});

g.selectAll("circle")
.data(d => d.data)
.join("circle")
.attr("fill", "#fff")
.attr("stroke", "#767676")
.attr("cx", d => x(d.year))
.attr("cy", d => data.find(d0 => d0.type === d.type).y(d.value))
.attr("r", r);

g.append("g")
.each((d, i, e) => {
d3.select(e[i]).call(g => d.yAxisGenerator(g, "text"));
});

g.append("path")
.attr("fill", "none")
.attr("stroke", "black")
.attr("stroke-width", 2)
.attr("d", d => d.line(d.loess));

// Labels
const tipLabel = g.selectAll(".tip-label")
.data(d => d.data)
.join("g")
.attr("class", "tip-label")
.attr("transform", d => `translate(${[ x(d.year), data.find(d0 => d.type === d0.type).y(d.value) ]})`)
.style("display", (d, i, e) => i === e.length - 1 ? "block" : "none");

tipLabel.append("circle")
.attr("r", r);

tipLabel.append("polyline")
.attr("points", [[0, -r], [0, -r - 5]]);

const tipLabelText = tipLabel.append("g")
.attr("class", "label-text-g")
.attr("text-anchor", d => d.year >= 2010 ? "end" : d.year <= 1930 ? "start" : "middle")
.attr("transform", d => `translate(${d.year >= 2010 ? 5 : d.year <= 1930 ? -5 : 0}, -28)`)

tipLabelText.append("text")
.attr("class", "year")
.text(d => d.year);

tipLabelText.append("text")
.attr("y", 14)
.text(d => d.tip);
return wrapper.node();
}
Insert cell
note = () => {
const wrapper = d3.create("div")
.style("font-family", franklinLight);

wrapper.append("div")
.text("Hover on the chart to explore the data")

wrapper.append("div")
.style("color", "#666")
.style("font-size", "14px")
.text("Trend lines created with LOESS smoothing.")
return wrapper.node();
}
Insert cell
Insert cell
css = `
.tip-label circle {
fill: none;
stroke: black;
}

.tip-label polyline {
stroke: black;
}

.tip-label text {
paint-order: stroke fill;
stroke: white;
stroke-linejoin: round;
stroke-opacity: 0.8;
stroke-width: 4px;
}

.tip-label .label-text-g {
font-size: 14px;
}

.tip-label .label-text-g .year {
font-weight: bold;
}
`
Insert cell
Insert cell
xAxis = g => {
const generator = d3.axisBottom(x)
.tickFormat(d => d)
.tickValues([1921, ...d3.range(1940, 2040, 20)])
.tickSize(12);

const axis = g
.attr("transform", `translate(0, ${chartheight + 8})`)
.call(generator);

axis.select(".domain").remove();

const tick = g.selectAll(".tick")

tick.select("text")
.attr("fill", "#222")
.attr("font-family", franklinLight)
.attr("font-size", 14)
tick.select("line")
.attr("stroke", "#ccc");

return axis;
}
Insert cell
Insert cell
x = d3.scaleLinear()
.domain(d3.extent(dataJoined, d => d.year))
.range([0, chartwidth])
Insert cell
r = 3
Insert cell
Insert cell
breakpoint = 480
Insert cell
margin = ({ left: 50, right: 16, top: 5, bottom: 35 })
Insert cell
basewidth = width <= breakpoint ? width : width / 2 - 24
Insert cell
chartwidth = basewidth - margin.left - margin.right
Insert cell
chartheight = Math.max(300, chartwidth * 9 / 16) - margin.top - margin.bottom
Insert cell
Insert cell
bandwidth = 0.3
Insert cell
// Each chart needs its own dataset with different y scale and axis
data = ["bloom_day", "tavg"]
.map(type => {
const data = dataJoined.map(d => {
return {
type,
year: d.year,
value: d[type],
tip: type === "bloom_day" ? formatDate(d.bloom_date) : `${d[type].toFixed(1)}°F`
}
});

const domain = type === "bloom_day" ? [110, 70] : [30, 50] // bloom_day is swapped because earlier is negative
const y = d3.scaleLinear()
.domain(domain)
.range([chartheight, 0]);

const loess = d3.regressionLoess()
.x(d => d.year)
.y(d => d.value)
.bandwidth(bandwidth)
(data);

const line = d3.line()
.x(d => x(d[0]))
.y(d => y(d[1]));

const tickFormat = (d, i, e) => {
if (type === "bloom_day") {
const date = dayOfYearToDate(2023, d);
const months = ["Jan.", "Feb.", "March", "April"]
return `${months[date.getUTCMonth()]} ${date.getUTCDate()}`
}
else {
return `${d}°F`
}
}

let tickValues;
if (type === "bloom_day") {
tickValues = [
new Date("2023-03-15"),
new Date("2023-04-01"),
new Date("2023-04-15")
].map(dateToDayOfYear);
}
else {
tickValues = [32, 40, 48];
}

const yAxisGenerator = (g, axisType) => {
const generator = d3.axisLeft(y)
.tickFormat(tickFormat)
.tickSize(chartwidth + margin.left - 3)
.tickValues(tickValues)

const axis = g.call(generator)
.attr("transform", `translate(${chartwidth})`);

axis.select(".domain").remove();
const ticks = axis.selectAll(".tick");

if (axisType === "text"){
ticks.select("text")
.attr("fill", "#444")
.attr("font-family", franklinLight)
.attr("font-size", 14)
.attr("text-anchor", "start");

ticks.select("line").remove();
}
else {
ticks.select("text").remove();

const offset = (d, i, e) => {
if (type === "bloom_day") {
return [58, 38, 46][i]
}
else {
return 30;
}
}
ticks.select("line")
.attr("stroke", "#e2e2e2")
.attr("x2", (d, i, e) => {
const currX2 = +d3.select(e[i]).attr("x2");
return currX2 + offset(d, i, e)
});
}

return axis;
}

return {
type,
data,
line,
loess,
y,
yAxisGenerator
}
})
Insert cell
// Join the peak bloom and temperature data into one array
dataJoined = d3.range(bloomStartYear, 2025)
.map(year => {
const bloom = bloomData.find(d => d.year === year);
const temp = dcTemp.find(d => d.year === year);

const o = {
year,
bloom_day: bloom.day,
bloom_date: bloom.date,
tavg: temp.tavg
}

return o;
});
Insert cell
dcDaily = {
const data = [];

// Get the missing dates from the CMU dataset
dcDailyCMU
.filter(d => d.Date.getTime() < ghcnStart.getTime())
.filter(d => d.Date.getUTCFullYear() >= bloomStartYear)
.forEach(d => {
data.push(makeCMUDatum(d));
});

// Fill in the rest from GHCN
dcDailyGHCN
.forEach(d => {
data.push(makeGHCNDatum(d));
})

return data;
}
Insert cell
// Annual D.C. average temperature at the start of the year
dcTemp = d3
.groups(dcDaily, d => d.date.getUTCFullYear())
.map(d => {
const o = {};
o.year = d[0];

const toDate = d[1].filter(d0 => {
const month = d0.date.getUTCMonth();

if (month < ghcnLatest.getUTCMonth()) {
return true;
}
else if (month === ghcnLatest.getUTCMonth() && d0.day <= ghcnLatestDayOfYear) {
return true;
}
else {
return false;
}
});

o.tmax = d3.mean(toDate, d => d.tmax);
o.tmin = d3.mean(toDate, d => d.tmin);
o.tavg = d3.mean(toDate, d => d.tavg);
o.source = d[1][0].source;
return o;
})
Insert cell
ghcnStart = dcDailyGHCN[0].DATE;
Insert cell
ghcnLatest = new Date("2024-03-17")
Insert cell
ghcnLatestDayOfYear = moment(toDateString(ghcnLatest), "YYYY-MM-DD").dayOfYear()
Insert cell
bloomStartYear = bloomData[0].year;
Insert cell
// https://kilthub.cmu.edu/articles/dataset/Compiled_daily_temperature_and_precipitation_data_for_the_U_S_cities/7890488?file=32874233
// Carnegie-Mellon has DC data preceding GHCN
dcDailyCMU = FileAttachment("USW00013743.csv").csv({ typed: true })
Insert cell
// https://www.ncei.noaa.gov/data/global-historical-climatology-network-daily/access/USW00013743.csv
dcDailyGHCN = FileAttachment("USW00013743@3.csv").csv({ typed: true })
Insert cell
bloomData = FileAttachment("dc_peak_bloom.csv").csv({ typed: true })
Insert cell
Insert cell
function makeGHCNDatum(d) {
const hasTAVG = typeof d.TAVG === "number";
const hasTMAX = typeof d.TMAX === "number";
const hasTMIN = typeof d.TMIN === "number";

let tavg;
if (hasTAVG) {
// In 8 of the dates, the average reported temperature
// is either greater than the max. or less than the min.
// For those ones, we use the midpoint of max. and min.
if (hasTMAX && hasTMIN) {
if (d.TAVG >= d.TMAX || d.TAVG <= d.TMIN) {
tavg = (d.TMAX + d.TMIN) / 2;
}
else {
tavg = d.TAVG;
}
}
else {
tavg = d.TAVG;
}
}
else if (hasTMAX && hasTMIN) {
tavg = (d.TMAX + d.TMIN) / 2;
}
else {
tavg = null;
}

return {
date: d.DATE,
day: moment(toDateString(d.DATE), "YYYY-MM-DD").dayOfYear(),
tmin: hasTMIN ? convertTemp(d.TMIN / 10, { input: "c", output: "f" }) : undefined,
tmax: hasTMAX ? convertTemp(d.TMAX / 10, { input: "c", output: "f" }) : undefined,
tavg: convertTemp(tavg / 10, { input: "c", output: "f" }),
source: "ghcn"
}
}
Insert cell
makeCMUDatum = d => {
const { Date, tmin, tmax } = d;
return {
date: Date,
day: moment(toDateString(Date), "YYYY-MM-DD").dayOfYear(),
tmin,
tmax,
tavg: (tmin + tmax) / 2,
source: "cmu"
}
}
Insert cell
// 1-indexed by default, so 1 is Jan. 1
dayOfYearToDate = (year, dayOfYear, zeroIndex = 1) => {
const dayMs = 1e3 * 60 * 60 * 24;
const yearStart = new Date(`${year}-01-01`);
return new Date(yearStart.getTime() + dayMs * (dayOfYear - zeroIndex));
}
Insert cell
toDateString = date => {
return `${date.getUTCFullYear()}-${(date.getUTCMonth() + 1).toString().padStart(2, 0)}-${date.getUTCDate().toString().padStart(2, 0)}`
}
Insert cell
dateToDayOfYear = date => {
return moment(toDateString(date)).dayOfYear()
}
Insert cell
formatDate = date => {
const months = ["Jan.", "Feb.", "March", "April"];
return `${months[date.getUTCMonth()]} ${date.getUTCDate()}`
}
Insert cell
Insert cell
import { convertTemp } from "@climatelab/temperature-converter@157"
Insert cell
import { franklinLight } from "@climatelab/fonts@46"
Insert cell
import { interpolatePalette } from "@harrystevens/roll-your-own-color-palette-interpolator"
Insert cell
import { toc } from "@climatelab/toc@45"
Insert cell
d3 = require("d3@7", "d3-regression@1")
Insert cell
moment = require("moment@2.30.1/moment.js")
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