Public
Edited
Mar 12, 2024
1 star
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" ? colorScale(d.anomaly) : "#fff")
.attr("stroke", d => d.type === "leaf" ? d3.color(colorScale(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([...d3.range(1900, 1925, 2025, 25), 2023])
.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
colorsLater = [
// l95 - l75
"#f6eff6", "#f1e3f1", "#ecd7ec", "#e7cbe7", "#e2c0e1", "#dcb4dc", "#d7a8d7",

// l71.67 - l51.67
"#a2a1ce", "#9796c8", "#8d8ac2", "#827fbd", "#7774b7", "#6b6ab1", "#605fab"
]
Insert cell
colorsEarlier = [
// l95 - l75
"#ffffe5", "#fcfcd7", "#faf9ca", "#f6f5bc", "#f3f2ae", "#f0efa1", "#ecec93",

// l71.67 - l51.67
"#c5e085", "#beda7c", "#b7d473", "#b0ce6a", "#a9c860", "#a2c257", "#9bbc4e",

// l48.33 - l28.33
"#68a550", "#61994a", "#598d44", "#52823f", "#4b7639", "#446b33", "#3d602e",

// l25 - l5
"#375629", "#2f4a24", "#273f1f", "#1f341a", "#172a15", "#101f10", "#031607"
]
Insert cell
colorScale = value => {
const fl = Math.floor(Math.abs(value));
if (value < 0) {
return colorsEarlier[fl] || colorsEarlier[colorsEarlier.length - 1];
}
else {
return colorsLater[fl] || colorsLater[colorsLater.length - 1];
}
}
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
variable = "leaf"
Insert cell
dataJoined = d3.range(1900, 2024)
.map(year => {
const spring = springIndexData.find(d => d.year === year)
const temp = tempData.find(d => d.year === year);

const o = {
year,
temp_anomaly: temp.anomaly
}

o[`${variable}_anomaly`] = spring[`six_f${variable}_diff_mean`];

return o;
});
Insert cell
// Received from Mark D. Schwartz of the University of Wisconsin at Milwaukee in an email on March 7, 2024
springIndexData = {
const data = await FileAttachment("SI-x_L48_1900_2023_25_byyear.csv").csv()
data.forEach(d => {
data.columns.forEach((c, i) => {
d[c.toLowerCase()] = +d[c];
delete d[c];
});
return d;
});
return data;
}
Insert cell
tempData = {
// https://www.ncei.noaa.gov/access/monitoring/climate-at-a-glance/national/time-series/110/tavg/5/5/1900-2023.csv?base_prd=true&begbaseyear=1981&endbaseyear=2010
const text = await FileAttachment("1900-2023.csv").text()
const rows = text.split("\n").slice(4)
const cols = rows[0].split(",").map(s => s.toLowerCase());
const tempData = rows.slice(1)
.map(d => {
const o = {};
const vals = d.split(",");
cols.forEach((c, i) => {
const v = vals[i];
o[c] = i < 1 ? v : +v;
});
o.year = +o.date.slice(0, 4);
return o;
})
.filter(d => d.year);
return tempData
}
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

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