Public
Edited
May 1
Insert cell
d3 = require("d3@6");
Insert cell
data = await d3.csv("https://raw.githubusercontent.com/michael-2956/mean-sd/refs/heads/main/mean_SD_by_country_by_month.csv")
Insert cell
parseDate = d3.timeParse("%Y-%m-%d")
Insert cell
formatYear = d3.timeFormat("%Y")
Insert cell
dataByYear = data.map(d => ({
country: d.country,
year: formatYear(parseDate(d.Month)), // only keep the year (for future aggregation)
mean_SD: +d.mean_SD
}))
Insert cell
yearlyData = d3.rollup(
dataByYear,
v => d3.mean(v, d => d.mean_SD), // calculate yearly mean precipitation.
d => d.country, // per country
d => d.year // per year
)
Insert cell
// row labels, which are countries
rows = Array.from(yearlyData.keys())
Insert cell
// column labels, which are years
cols = Array.from(
new Set(dataByYear.map(d => d.year))
).sort().slice(1, -1) // no 2000 and 2025 since they are incomplete
Insert cell
grid = {
const values = [] // our grid
for (const country of rows) {
const row = [] // each country
const m = yearlyData.get(country)
for (const year of cols) { // each year
row.push(+m.get(year)) // append column
}
values.push(row) // append row
}
return values
}
Insert cell
viewof heatmap = {
// these margins are useful because the heatmap will lie within them,
// labels will be outside
const margin = { top: 60, right: 120, bottom: 80, left: 140 };
// size of each cell is 20
const width = cols.length * 20;
const height = rows.length * 20;

// we will work with an svg output
const svg = d3.create("svg")
.attr("width", width + margin.left + margin.right) // the margins are added to the width
.attr("height", height + margin.top + margin.bottom); // and height

svg.append("text")
.attr("x", margin.left + width / 2) // title position x
.attr("y", margin.top / 2) // title position y
.attr("text-anchor", "middle") // this is the middle coordinate
.attr("font-size", "20px")
.text("Mean Snow Depth by Country and Year");

// cell columns and rows. Should span width and height without the margins!
// we will use it later to convert every year into cell coordinate
const x = d3.scaleBand().domain(cols).range([0, width]);
const y = d3.scaleBand().domain(rows).range([0, height]);

// this is the heatmap coloring, which we do with blue color,
// from the minimum value of precipitation to the maximum
const color = d3.scaleSequential()
.domain([d3.min(grid.flat()), d3.max(grid.flat())])
.interpolator(d3.interpolateCividis);
const g = svg.append("g")
// this creates an SVG element that would have 0,0 at our margins
// will be useful when we draw the heatmap itself to not add them every time
.attr("transform", `translate(${margin.left},${margin.top})`);

rows.forEach((country, i) => {
cols.forEach((year, j) => {
g.append("rect") // this is every individual cell rectangle
.attr("x", x(year)) // use the pre-defined conversion
.attr("y", y(country))
.attr("width", x.bandwidth()) // the scaleBand() provides the bandwidth() method which
.attr("height", y.bandwidth()) // helps determine the cell width automatically
.attr("fill", color(grid[i][j]));
});
});

// element that will contain the column labels
const colaxisG = g.append("g")
// this becomes margin.top + height
.attr("transform", `translate(0,${height})`)
// add the labels first
d3.axisBottom(x)(colaxisG)
// select all labels
colaxisG.selectAll("text")
// rotate them 45 degrees
.attr("transform", "rotate(-45)")
// this is added to the css of the element,
// so that the coordinate is interpreted
// as the end coordinate.
// same as we had for the title.
.style("text-anchor", "end");

// the label. This is simple
g.append("text")
.attr("x", width / 2)
.attr("y", height + 50)
.attr("text-anchor", "middle")
.text("Year");

// thankfully the y labels just work (sigh...)
d3.axisLeft(y)(g.append("g"))
// label, but we rotate it 90 degrees
g.append("text")
.attr("x", -height / 2)
.attr("y", -100)
.attr("text-anchor", "middle")
.attr("transform", "rotate(-90)")
.text("Country");

// this is for the gradient on the right
const gradient = svg.append("linearGradient") // this name is reserved for gradients
.attr("id", "legend-gradient")
.attr("x1", "0%").attr("y1", "100%") // bottom of the gradient
.attr("x2", "0%").attr("y2", "0%"); // top of it. And no change left-right.
// we append 3 stop elements at 0%, 50%, 100% offsets
// with just 0, 1 -- doesn't work
[0, 0.5, 1.0].forEach(t => {
gradient.append("stop")
.attr("offset", `${t*100}%`)
// the colors are the same as for the heatmap.
// the interpolation inbetween stops is actually
// done by the linearGradient itself
.attr("stop-color", d3.interpolateCividis(t));
});

// legend position
const legendX = margin.left + width + 40;
const legendY = margin.top;
const legend = svg.append("g")
.attr("transform", `translate(${legendX},${legendY})`);

const legendHeight = 200;
const legendWidth = 20;
legend.append("rect") // create the rectangle
.attr("width", legendWidth)
.attr("height", legendHeight)
.style("fill", "url(#legend-gradient)"); // use the gradient we defined to fill it up

const legendScale = d3.scaleLinear()
.domain(color.domain()) // now we can use the range of values from the heatmap
.range([legendHeight, 0]); // and label it from highest to lowest
d3.axisRight(legendScale)(
legend.append("g").attr("transform", `translate(${legendWidth},0)`)
); // as before

legend.append("text")
.attr("x", -height / 2)
.attr("y", -10)
.attr("text-anchor", "start")
.attr("transform", "rotate(-90)")
.attr("font-size", "15px")
.text("Precipitation scale");

svg.append("a")
.attr("href", "https://www.ecmwf.int/") // make it clickable
.append("text")
.attr("x", margin.left)
.attr("y", margin.top + height + 70)
.attr("font-size", "12px")
.text("Data source: ERA5 (ECMWF, https://www.ecmwf.int/)");

return svg.node();
}
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