Unknown Warming, any city: daily temperature anomalies since 1940
A Joy Division inspired plot of the daily temperature anomalies of any place on Earth, from the ERA5 reanalysis.
Abstract
Pick a city, type coordinates, or let your browser locate you, and the page draws the Joy Division plot of the daily temperature anomalies of that place since 1940. Each line is one year; its height shows how many days had a given anomaly relative to the normal temperature of the same calendar day (1991–2020 by default). If the climate were stable, all the lines would pile up on the same hump; the drift to the right is the local warming.
The data is the ERA5 reanalysis of the Copernicus Climate Change Service, served by Open-Meteo (CC BY 4.0, non-commercial use) and downloaded in your browser when you pick a place. ERA5 is a coarse grid (about 30 km): coastal and mountain places are smoothed compared with a single thermometer. For a ground-measured version of Bayonne, see the station edition.
Choose a place
Search for a city and pick one of the matches, enter coordinates and press Load coordinates, or use your location (the browser will ask for permission). The page address is updated with the coordinates so that you can bookmark or share a view (?lat=…&lon=…&name=…).
Unknown Warming: your place
Technical details
The processing is roughly divided into six steps:
- choosing a place and downloading its ERA5 daily series
- computing the normal temperature of each calendar day over the reference period
- subtracting it from every day to get the anomalies
- counting the anomalies per year
- applying a Gaussian blur to smooth out the graph
- offsetting the curve of each year to produce the Joy Division effect
Choosing the place
The place is kept in a mutable value. Three independent cells update it: picking a match in the geocoding results, clicking Load coordinates, or clicking Use my location. Whichever happened last wins. The geocoding comes from the Open-Meteo geocoding API (GeoNames data).
DEFAULT_PLACE = ({name: "Bayonne, France", lat: 43.4932, lon: -1.473})
initialPlace = {
const p = new URLSearchParams(location.search);
const lat = parseFloat(p.get("lat")), lon = parseFloat(p.get("lon"));
return (Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180)
? {name: p.get("name") ?? `${lat.toFixed(2)}°, ${lon.toFixed(2)}°`, lat, lon}
: DEFAULT_PLACE;
}
mutable place = initialPlace
labelOf = m => [m.name, m.admin1, m.country].filter(Boolean).join(", ")
candidates = query.trim()
? d3.json(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query.trim())}&count=8&language=en&format=json`)
.then(j => j.results ?? [])
: []// 1. a match was picked in the list
{
if (pick) mutable place = {name: labelOf(pick), lat: pick.latitude, lon: pick.longitude};
}// 2. the coordinates were loaded
{
if (loadCoords) mutable place = {name: `${loadCoords.lat.toFixed(2)}°, ${loadCoords.lon.toFixed(2)}°`, lat: loadCoords.lat, lon: loadCoords.lon};
}// 3. the browser location was requested
{
if (gps > 0) {
try {
const pos = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, {timeout: 15000}));
const {latitude, longitude} = pos.coords;
mutable place = {name: `My location (${latitude.toFixed(2)}°, ${longitude.toFixed(2)}°)`, lat: latitude, lon: longitude};
} catch (e) {
return html`<p style="color:#b00020">Could not get your location: ${e.message}</p>`;
}
}
}// keep the address in sync, so that a view can be bookmarked or shared
{
const u = new URL(location.href);
u.searchParams.set("lat", place.lat.toFixed(4));
u.searchParams.set("lon", place.lon.toFixed(4));
u.searchParams.set("name", place.name);
try { history.replaceState(null, "", u); } catch (e) {}
}Downloading the data
For the chosen place, the daily mean 2 m temperature from 1940 to the last complete year is requested from the Open-Meteo archive API, in three 30-year chunks fetched in parallel (the daily boundaries follow the local time zone of the place). The result is cached in the browser so that going back to a place is instant.
era5 = {
const p = place; // the cell runs again whenever the place changes
const last = new Date().getFullYear() - 1;
const key = `anycity-era5-v1-${p.lat.toFixed(3)},${p.lon.toFixed(3)}-${last}`;
try { const c = localStorage.getItem(key); if (c) return JSON.parse(c); } catch (e) {}
const ranges = [["1940-01-01", "1969-12-31"], ["1970-01-01", "1999-12-31"], ["2000-01-01", `${last}-12-31`]];
const url = ([a, b]) =>
`https://archive-api.open-meteo.com/v1/archive?latitude=${p.lat}&longitude=${p.lon}` +
`&start_date=${a}&end_date=${b}&daily=temperature_2m_mean&timezone=auto`;
const parts = await Promise.all(ranges.map(r => d3.json(url(r))));
const out = {
meta: {lat: parts[0].latitude, lon: parts[0].longitude, elev: parts[0].elevation},
time: parts.flatMap(j => j.daily.time),
temp: parts.flatMap(j => j.daily.temperature_2m_mean)
};
const save = () => localStorage.setItem(key, JSON.stringify(out));
try { save(); } catch (e) { // quota full: forget the older places and retry once
try {
Object.keys(localStorage).filter(k => k.startsWith("anycity-era5-")).forEach(k => localStorage.removeItem(k));
save();
} catch (e2) {}
}
return out;
}
rawData = era5.time
.map((d, i) => ({year: +d.slice(0, 4), slot: slotOf(d), t: era5.temp[i]}))
.filter(d => d.t != null && Number.isFinite(d.t))
placeName = place.name
sourceNote = `ERA5 grid cell ${era5.meta.lat.toFixed(2)}°, ${era5.meta.lon.toFixed(2)}°, ${Math.round(era5.meta.elev)} m`
Inputs.table(rawData)- 1
- The current year is not complete, so the series stops at the end of last year.
- 2
- The API returns the centre and the elevation of the grid cell actually used, which can be a few km away from the requested point.
Computing the daily normal
Unlike the global series, a single place has a strong seasonal cycle, so an “anomaly” must be measured against the normal temperature of that calendar day. For every day of the year we average the temperature over the reference period (1991–2020 by default). The window is a few days wide (±3 by default, adjustable in the sidebar) so that the normal is not a noisy single-day average, but narrow enough not to flatten the seasonal peaks.
Dates are mapped to a “slot” in a leap-year calendar, so that 29 February has a slot of its own and every later date keeps the same slot in all years.
CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]
slotOf = iso => CUM[+iso.slice(5, 7) - 1] + +iso.slice(8, 10) - 1
climatology = {
const [b0, b1] = baseline.split("-").map(Number);
const sum = new Float64Array(366), n = new Float64Array(366);
for (const d of rawData) {
if (d.year >= b0 && d.year <= b1) { sum[d.slot] += d.t; n[d.slot]++; }
}
const raw = Array.from(sum, (s, i) => n[i] ? s / n[i] : NaN);
return raw.map((_, i) => { // circular moving average, ±normalWindow days
let a = 0, c = 0;
for (let k = -normalWindow; k <= normalWindow; k++) {
const v = raw[(i + k + 366) % 366];
if (!Number.isNaN(v)) { a += v; c++; }
}
return c ? a / c : NaN;
});
}- 1
- Offsets of the first day of each month in a leap year.
Anomalies
The anomaly of a day is its temperature minus the normal of its slot. Only complete years (at least 330 days with data) are kept, which also drops the current, unfinished year.
anomalies = rawData
.map(d => ({year: d.year, slot: d.slot, anomaly: d.t - climatology[d.slot]}))
.filter(d => Number.isFinite(d.anomaly))
byYear = d3.group(anomalies, d => d.year)
years = [...byYear].filter(([y, v]) => v.length >= 330).map(([y]) => y).sort(d3.ascending)
minYear = years[0]
maxYear = years[years.length - 1]
yearStats = years.map(y => ({
year: y,
n: byYear.get(y).length,
mean: d3.mean(byYear.get(y), d => d.anomaly),
reference: maxYear - y
}))
yearStatsMap = new Map(yearStats.map(d => [d.year, d]))
labelledYears = yearStats.filter(d => d.year % 5 === 0 || d.year === maxYear)- 1
- The most recent complete year is drawn at the bottom.
- 2
- Vertical position (in rows) of the baseline of each year.
Grouping the anomalies by year
For each year we count the days falling in each bin of the anomaly. The bins are 0.5 °C wide, and the horizontal range adapts to the climate of the place: a continental winter swings much more than the Basque coast, so the range is set from the 99.9th percentile of the absolute anomalies, rounded up to a multiple of 4 °C (between ±8 and ±32 °C).
binWidth = 0.5
limit = {
const keep = new Set(years);
const a = anomalies.filter(d => keep.has(d.year)).map(d => Math.abs(d.anomaly)).sort(d3.ascending);
return Math.min(32, Math.max(8, Math.ceil(d3.quantileSorted(a, 0.999) / 4) * 4));
}
length = Math.round(2 * limit / binWidth)
indexF = x => Math.max(0, Math.min(length - 1, Math.floor((x + limit) / binWidth)))
indexF_1 = i => -limit + (i + 0.5) * binWidth
counts = new Map(years.map(y => {
const a = new Float64Array(length);
for (const d of byYear.get(y)) a[indexF(d.anomaly)]++;
return [y, a];
}))
xStep = limit <= 12 ? 2 : limit <= 20 ? 4 : 8
xTicks = d3.range(-Math.floor(limit / xStep) * xStep, limit + 1, xStep)- 1
- Anomaly to bin index (values beyond the range land in the outermost bins).
- 2
- Bin index to the anomaly at the centre of the bin.
Smoothing the data with a Gaussian blur
As in the original plot, the counts are smoothed with small binomial kernels (a discrete approximation of a Gaussian), keeping only the positive side of each kernel. With 0.5 °C bins, the default factor 2 corresponds to a standard deviation of about 0.5 °C.
kernels = [
[1],
[2, 1],
[6, 4, 1],
[20, 15, 6, 1],
[70, 56, 28, 8, 1],
];The smoothed counts are turned into a share of the days of the year, then scaled so that the tallest ridge of the whole chart is ridgeHeight rows high. This keeps the plot readable whatever the climate (a narrow distribution gives a high, thin hump, a broad one a low, wide hump), and each ridge is finally offset by its year.
joyData = {
const kernel = kernels[smoothFactor];
const smooth = y => {
const c = counts.get(y), n = byYear.get(y).length;
return Float64Array.from(c, (_, i) => {
let result = 0, weight = 0;
for (let j = 1 - kernel.length; j < kernel.length; j++) {
const m = i + j;
if (m >= 0 && m < length) {
const k = kernel[Math.abs(j)];
result += c[m] * k;
weight += k;
}
}
return result / weight / n; // share of the days of the year
});
};
const dens = years.map(y => ({y, s: smooth(y)}));
const peak = d3.max(dens, d => d3.max(d.s));
return dens.flatMap(({y, s}) => {
const st = yearStatsMap.get(y);
return Array.from(s, (v, i) => ({
year: y,
anomaly: indexF_1(i),
count: v,
height: st.reference + v / peak * ridgeHeight,
reference: st.reference,
yearMean: st.mean
}));
});
}- 1
- Offset by the year (reversed so that the latest year is at the bottom) to obtain the Joy Division effect.
Colouring the years
Colouring each year by its mean anomaly shows the warming at a glance. A standard diverging scale such as RdYlBu is pale yellow in the middle, which is nearly invisible on a white page (its contrast ratio against white is about 1.1:1). The interpolator below therefore caps the lightness of every colour in the perceptual Lab space, keeping hue and chroma: the ends of the scale are untouched, the middle turns into a darker khaki that stays visible (about 2.7:1 at the default maximum lightness of 65). Lower the “Max lightness” slider for more contrast, raise it to 100 to get the plain RdYlBu back.
divergingInterpolator = t => {
const c = d3.lab(d3.interpolateRdYlBu(1 - t)); // reversed: warm = red
c.l = Math.min(c.l, maxLightness);
return c.formatHex();
}
colorLimit = Math.max(0.5, Math.ceil(d3.max(yearStats, d => Math.abs(d.mean)) * 2) / 2)
colorScale = ({
type: "diverging",
pivot: 0,
domain: [-colorLimit, colorLimit],
interpolate: divergingInterpolator,
label: "Yearly mean anomaly (°C)"
})