Public
Edited
Mar 15, 2023
9 forks
12 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
titles = () => {
const wrapper = d3.create("div")
.style("font-family", franklinLight)
.style("text-align", width <= 768 ? "left" : "center")
wrapper.append("div")
.style("font-size", "18px")
.style("font-weight", "bold")
.text("Leaf appearance and temperature")
wrapper.append("div")
.text("Compared with 1981-2010 average");

return wrapper.node();
}
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 === "leaf" ? "Leaf appearance" : "Temperature, January to May")

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", d => d.type === "leaf" ? color(d.anomaly) : "#fff")
.attr("stroke", d => d.type === "leaf" ? d3.color(color(d.anomaly)).darker() : "#767676")
.attr("cx", d => x(d.year))
.attr("cy", d => data.find(d0 => d0.type === d.type).y(d.anomaly))
.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.anomaly) ]})`)
.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 => {
const abs = Math.round(Math.abs(d.anomaly));
if (d.type === "leaf") {
return abs === 0 ? "About average" : `${abs} day${abs === 1 ? "" : "s"} ${d.anomaly < 0 ? " early" : "late"}`
}
else {
return abs === 0 ? "About average" : `${abs}${d.anomaly < 0 ? "°F colder" : "°F warmer"}`
}
});
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("Data for contiguous U.S. Lines of best fit created using 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([1901, ...d3.range(1925, 2025, 25), 2021])
.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
colors = ["#286b1e", "#5e8f22", "#92b52f", "#c8db4d", "#ffffab", "#eac2d2", "#d095b7", "#b06c99", "#7d516f"]
Insert cell
domain = [-20, 0, 20]
Insert cell
color = d3.scaleDiverging(domain, interpolatePalette(colors));
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
loessBandwidth = 0.55
Insert cell
data = ["leaf", "temp"]
.map(type => {
const data = dataJoined.map(d => {
return {
type,
year: d.year,
anomaly: d[`${type}_anomaly`]
}
});

const y = d3.scaleLinear()
.domain(type === "leaf" ? [12, -12] : [-5, 5]) // leaf is swapped because earlier is negative
.range([chartheight, 0]);

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

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

const tickFormat = (d, i, e) => {
const abs = Math.abs(d);

if (type === "leaf") {
return `${abs || ""}${i === 0 ? " days earlier" : i === e.length - 1 ? " days later" : "Average"}`
}
else {
return `${abs || ""}${i === 0 ? "°F colder" : i === e.length - 1 ? "°F warmer" : "Average"}`
}
}

const yAxisGenerator = (g, axisType) => {
const generator = d3.axisLeft(y)
.tickFormat(tickFormat)
.tickSize(chartwidth + margin.left - 3)
.tickValues(type === "leaf" ? [-12, 0, 12] : [-5, 0, 5])

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 === "leaf") {
return i === 0 ? 86 : i === e.length - 1 ? 75 : 47;
}
else {
return i === 0 ? 60 : i === e.length - 1 ? 68 : 47;
}
}
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
// start at 1901 because that's when the EPA temperature data start
dataJoined = d3
.range(1901, 2022)
.map(year => {
const leaf = siXfl_l48_1900_2021_25_byyear.find(d => d.year === year);
const temp = noaa_jan_may.find(d => d.year === year);

return {
year,
leaf_anomaly: leaf.anomaly,
temp_anomaly: temp.anomaly
}
});
Insert cell
// Received from Mark D. Schwartz of the University of Wisconsin at Milwaukee in an email on March 7, 2023
siXfl_l48_1900_2021_25_byyear = {
const data = (await FileAttachment("SI-xFL_L48_1900_2021_25_byyear.dat").text())
.split("\n")
.map(d =>
d
.split(" ")
.map(d => +d.trim())
)
.filter(d => d[0])
.map(([year, leaf_mean]) => ({year, anomaly: leaf_mean}));

return data;
}
Insert cell
// Via the National Oceanographic and Atmospheric Administration
// URL: https://www.ncei.noaa.gov/access/monitoring/climate-at-a-glance/national/time-series/110/tavg/5/5/1901-2021?base_prd=true&begbaseyear=1901&endbaseyear=2000
noaa_jan_may = (await FileAttachment("1901-2021 (3).csv").csv({ typed: true }))
.filter(d => d[" Average Temperature"] && !isNaN(d[" Average Temperature"]))
.map(d => ({
year: +d["Contiguous U.S."].toString().slice(0, 4),
temp: d[" Average Temperature"],
anomaly: d[" January-May"]
}))
Insert cell
Insert cell
import { franklinLight } from "1dec0e3505bd3624"
Insert cell
import { interpolatePalette } from "@harrystevens/roll-your-own-color-palette-interpolator"
Insert cell
import { toc } from "@harrystevens/toc"
Insert cell
d3 = require("d3@7", "d3-regression@1")
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