Day-of-year overlay
Forty-five years of one number — sea-surface temperature in the patch of equatorial Pacific that defines El Niño — every year drawn on the same Jan–Dec axis so you can see where the current one sits against all the others. The x axis is a day of the year, not a date, and the current year's line stops partway across because that is where the record stops.
The chart
Drag to pan, wheel to zoom — bounded to the year, so both stop at 1 January and 31 December rather than running out into empty canvas. May to July is worth a closer look: that is where the current year leaves the pack.
Move the pointer across the plot and the vertical line marks a day of the year, while the strip above reports that date, the three named years' anomalies, and where 2026 ranks among all 45 on it. That is the whole hover story, and the Build it section explains why it is deliberately not more — with 45 overlapping lines there is no honest way for an in-chart pill to say which one you are on.
What the shape says: 2026 opened cooler than its own climatology (January averaged −0.42 °C), crossed zero in March, and has climbed every month since — +0.5 on 14 April, +1.0 on 30 April, +1.5 on 5 June, +2.0 on 10 July. At the end of the record, 3 August, it stands at +2.56 °C: the warmest 3 August of the 45, ahead of 1997 (+1.62) and 2015 (+1.45) on the same date, against a pack whose coldest year on that day is −1.67.
Those two named years are the comparison because they are the two biggest El Niños in the record — 1997 peaked at +2.62 °C on 24 November and 2015 at +3.03 °C on 19 November, which is the highest daily value anywhere in the 45 years. Both peaked in late autumn. 2026 is at +2.56 in early August.
Chart style after Zeke Hausfather's day-of-year overlays at The Climate Brink — the idea of stacking every year on one seasonal axis and letting the current one run out into open space is his. This is a reconstruction from the same public data, not a reproduction of his chart: the climatology below is our own implementation and the numbers are ours.
The data
Real, measured, public domain. NOAA's
OISST v2.1 —
the daily, 0.25°, satellite-plus-buoy optimum-interpolation sea-surface
temperature analysis — averaged over the Niño 3.4 box, 5°S–5°N and
170°W–120°W. A US government work, so public domain. Retrieved
2026-08-05 by
website/scripts/fixtures/nino34.mjs,
which runs by hand and commits its output.
[day, sst]; // one day, box-mean SST in °C — 16,275 of them, 1982-01-01 → 2026-08-03
- 8,241 cells per day, all of them. The box is 41 latitudes × 201 longitudes at 0.25°, and each value is the mean of every cell — not a sample of them. (Striding the grid is the obvious economy and costs about 0.014 °C at every-4th-cell; it turned out to save nothing here, because the server's cost is reading the year file rather than returning the cells.)
- Longitude is degrees east, so 170°W–120°W is
190–240. Read as a negative pair it returns a different ocean, silently. - 29 February is dropped. That is what makes an x slot a calendar date: with the leap day gone, slot 59 is 1 March in every year, and the years stack with no drift. Aligning on the raw ordinal day instead slides every leap year's second half one day left of every common year's. The cost is the 11 leap days in the record, dropped before the 16,275 that are kept.
- The last year is short. The record ends 3 August 2026, so 2026 has 215 days against everyone else's 365, and its line ends in open plot.
- Values are raw SST, not anomalies. The fixture stores what the instrument measured; the climatology and the anomaly are computed in the page. That is the point of the card, and it is cheap — see Build it.
Which NOAA server, and why it matters
NOAA serves this analysis through more than one door and they are not equally complete. The obvious one, NOAA CoastWatch's ERDDAP, is a clean CSV API that subsets the box in one request — and is missing 1,196 days, roughly every other day from October 1992 to July 1998. 1994 has 138 of its 365; 1997 has 176. Six years of this overlay, including one of the two years it names, would have been combs.
So the fixture comes from NOAA
PSL's
OPeNDAP server, whose per-year files are complete, and ERDDAP is kept as the
cross-check: the generator pulls five dates spread across the record from
both servers and asserts they agree. They match to every digit either one
prints — 2015-12-01 is 29.4282 °C from both. The generator also asserts
that every day carries all 8,241 cells and that no day is missing, so a future
re-pull that quietly loses data fails loudly instead.
What's committed
63 KB, as one delta-coded integer array. The absolute values sit between 23.44 and 30.62 °C — four digits each in hundredths — while a day moves at most 0.55 °C, so the day-to-day differences are one or two digits. Storing differences and undoing them with a running sum is 48% off the committed file for a decode of one line.
Build it
Three things have to happen before there is a chart: get every year onto one axis, work out what "normal" is for each day of the year, and subtract.
One axis for 45 years
The clean trick is to stop thinking of this as a special axis. Map every year's days onto one common non-leap reference year and each year becomes an ordinary series on an ordinary time axis — month ticks, cursor, everything, for free:
const REFERENCE_YEAR = 2001; // non-leap: the fixture has no 29 February
const DAY_TIMES = Array.from({ length: 365 }, (_, d) =>
new Date(REFERENCE_YEAR, 0, 1 + d).getTime(),
);
Then timeFormat="%b" prints month abbreviations and nothing on the chart
mentions 2001. The tick ladder thins them to fit — on the plot above that is
Jan / Apr / Jul / Oct, four quarterly labels, not twelve. Zoom in
and it re-ladders down to individual months and days.
Local midnights, not UTC. The tick ladder places month ticks on local
month boundaries, so a UTC-midnight axis sits a few hours off them — and a
reader far enough east of UTC loses the Jan tick entirely, because local
1 January falls before the range starts. The observations are still UTC days;
this is the carrier, not the data.
The climatology, in two collapses
Every year is measured against its own 30-year day-of-year climatology,
centred on it — [y − 14, y + 15], which is the convention NOAA's
ONI
uses — and clamped at the ends of the record, because neither 1981 nor 2027
exists to average. So 1997 is measured against 1983–2012, and 2015 and 2026 both
against 1996–2025.
This is what removes the long-term warming trend, and it is the reason comparing 1982 with 2026 means anything at all. On raw SST the last decade simply sits above the first and the chart becomes a picture of global warming rather than of El Niño.
The shape that makes it a two-liner is one wide series: 365 rows, one column per year.
const wide = TimeSeries.fromColumns({
name: 'nino34-sst',
schema: [
{ name: 'time', kind: 'time' },
...YEARS.map((year) => ({ name: `y${year}`, kind: 'number' as const })),
],
columns: {
time: DAY_TIMES,
// one 365-long array per year; `null` past the end of the record
...columnsByYear,
},
});
A day-of-year climatology is then a row-wise mean across a window of
columns — which is exactly what collapse does:
const own = `y${year}`;
const base = climatologyWindow(year).map((y) => `y${y}`); // 30 column names
/** Mean of the base-period columns present in this row. */
const dayMean = (row: Record<string, unknown>) => {
let sum = 0;
let n = 0;
for (const column of base) {
const value = row[column];
if (typeof value === 'number') {
sum += value;
n++;
}
}
return sum / n;
};
const anomaly = wide
// the 30 base-period columns → `clim`, the mean SST on that day of the year.
// `append: true` keeps everything else, which is how `own` survives to the
// next step.
.collapse(base, 'clim', dayMean, { append: true })
// `own` and `clim` → their difference. `collapse` drops the two it consumed.
.collapse([own, 'clim'], 'anomaly', (row) => row[own] - row.clim);
Two passes, 45 times over. The whole module — decode 16,275 values, build 45 climatologies and 45 anomaly series, then sweep them all for the y domain — is a median 32 ms (seven cold runs under Node 22), once, at import. There is no reason to bake that into the fixture, and good reason not to: a stored anomaly is a stored choice of base period, and this way the choice is visible and one edit away.
Two notes for anyone doing this with their own runtime-named columns. select
is variadic — handed an array it matches nothing and hands back a series of
just the key column, with no error — and it is not needed here anyway, because
collapse reads only the columns it was given and keeps the rest by reference.
And declare the wide schema's value columns as { name: string; kind: 'number' }
rather than reaching for TimeSeries<SeriesSchema>: on the latter the data
column names resolve to never and every call fails to compile.
The pack, and the three that aren't
Forty-two lines exist to be a texture — something you read the other three through. That is a theme register, not a colour:
<Layers>
{BACKDROP.map((year) => (
<LineChart
key={year}
series={anomalySeries(year)}
column="anomaly"
axis="anom"
as="ensemble"
legend={false}
/>
))}
{NAMED.map(({ year, role, label }) => (
<LineChart
key={year}
series={anomalySeries(year)}
column="anomaly"
axis="anom"
as={role} // highlight1 = 2026, highlight2 = 1997, highlight3 = 2015
legend={label}
/>
))}
</Layers>
ensemble and highlight1…3 are a pair, added to the site theme for this
shape and only meaningful together: dozens of traces of one quantity drawn as a
single texture, with a handful lifted out by name. A spaghetti plot of model
runs, a fan of scenarios, every year of a record on a shared seasonal axis.
The obvious first reach was the existing line.muted, and it was wrong, in a
way worth measuring. muted is --pond-muted at 55% — tuned for one
backdrop trace with a highlight drawn through it. Alpha compounds where strokes
cross, so forty-two of them at that weight stack into a mass that competes with
the years in front. Sampling the rendered canvas and converting to L*, with the
highlights held constant:
| pack role | pack ink (mean L* over background) | highlight ink | separation |
|---|---|---|---|
muted (55%) | 16.4 | 49.4 | 3.0× |
| first try (16%) | 6.2 | 49.5 | 8.0× |
ensemble (30%) | 8.9 | 33.1 | 3.7× |
The window is narrower than it looks, and we overshot it once. At 16% the separation measured beautifully and the chart was worse: the pack stopped reading as forty-odd separate traces and became a wash, which loses the one thing an ensemble is for. The number to optimise is not the ratio — it is whether you can still follow a single grey line with your eye while the named years stay obviously in front. 30% is where that holds, and it lands at 3.7×.
The pack covers ~22% of the plot at that weight, so it is legible as texture without arguing with the years in front.
The highlights needed the other half of the hierarchy: weight, not just hue.
The categorical palette is mid-saturation by design and three lines at the
shared 1.5px default read as three more members of the pack. highlight1 is the
subject slot at 2.75px — the current year, the thing the chart is about —
and 2/3 are the named comparisons at 1.75px. That asymmetry is deliberate;
three equal highlights would say "these are the same kind of thing", which is
exactly what this chart is not saying. Measured, the change lifts highlight
coverage from 1.8% to 2.4% of the plot, about 30% more highlight ink.
They are also the only layers with a legend name, so the legend card has
exactly three rows. Layer order is JSX order, so the pack is declared first and
the named years draw over it.
What 45 lines costs
Less than it looks. Instrumenting the canvas and forcing repaints by flipping
the site theme: one full repaint at full extent issues 16,248 lineTo calls
— every sample of every line — in a median 6 ms (20 repaints, 1.8–27 ms).
16,248 is the number to notice, because it says M4 decimation never engages
here. <LineChart decimate> defaults on, but it only takes over once the
visible data is denser than about two samples per device pixel, and 365 points
across a ~1,500-device-pixel plot is well under a quarter of that. The cost of
this chart is 45 layers, not the points in them — the opposite of the
seismograph card in the same track, where one line carries 12,800 samples and
decimation does all the work.
Zooming makes it cheaper, not dearer, which is the opposite of the usual
worry. Off-screen samples are clipped before they reach the path, so the same
repaint measured at successive zoom levels issues 16,248 → 13,619 → 9,517
lineTo calls, with the time staying inside the same 3–17 ms band. Zooming in
lowers the samples-per-pixel density, so decimation stays dormant there too.
Width, and why it isn't a number
<ChartContainer> takes an explicit pixel width, and for this chart a constant
would be wrong at every viewport but one — 45 lines want every pixel there is,
and the docs column is a different width with the sidebar open, closed, and on
a phone. So the chart measures its own box:
const [boxRef, measured] = useMeasuredWidth<HTMLDivElement>();
const width = fixedWidth ?? measured;
// …
<div ref={boxRef} style={{ width: fixedWidth ?? '100%' }}>
{width <= 0 ? <div style={{ height }} /> : <ChartContainer width={width} …>}
The guard matters more here than on a one-line chart: at width 0 the container registers 45 layers against a degenerate scale and rebuilds every one of them a frame later. Rendering nothing until the box is measured skips that entirely, and the reserved height stops the page jumping when the chart arrives. Measured on this page: 599px of plot with the sidebar open, 764px without.
Height is a plain prop and it is deliberately generous. Forty-five overlapping lines need vertical room or the pack compresses into a band and the highlights have nothing to separate from — the same argument as the faint pack colour, in the other axis.
The partial year takes care of itself
2026's column is null after 3 August. The anomaly reducer returns NaN
there, which is how pond spells "no value", and <LineChart>'s default
gaps="empty" breaks the line rather than bridging it. Nothing needs trimming
and nothing needs a special case — the line ends where the data does. A
<Marker> at that day says so out loud, rather than leaving a reader to wonder
whether the line was cut off by the plot.
The threshold lines
The four El Niño strength labels — weak, moderate, strong, very strong, at +0.5 / +1.0 / +1.5 / +2.0 — go in the annotation register, not into a data hue:
<Layers>
{/* …the 45 line layers… */}
{THRESHOLDS.map((t) => (
<Baseline
key={t.label}
value={t.value}
axis="anom"
label={t.label}
labelSide="right"
labelPosition="above"
selectable={false}
/>
))}
</Layers>
labelSide="right" is not a detail. The chips default to the left, which on
this chart is January — where all three named years start, so the labels land
straight on them. December puts them where the named years are at their peaks
above the top two thresholds rather than on them, over a pack faint enough to
annotate across.
They are <Baseline> lines and not shaded bands because there is no y-span
annotation in @pond-ts/charts — <Region>'s from/to are x
positions, so it shades a span of the axis, not a range of values. Four
reference lines say the same thing; a shaded band would say it better.
They are also reference marks, not a classification, and the difference matters. ONI is a three-month running mean of monthly anomalies; this is a daily series, which crosses a line and comes back. 12 of the 45 years touch +2.0 on at least one day, which is nothing like 12 very strong El Niños. Read the bands as "where this year is sitting", not as a verdict. The La Niña thresholds are the same numbers negated and are not drawn — 36 of the 45 years reach −0.5 at some point, and eight more lines would bury the pack.
What hover can honestly do
cursor="line" — the default — draws the shared vertical rule and no values.
That is the right mode here, and not a compromise: crosshair pins one
value pill per row, and with 45 lines under the pointer that pill would be
reading one of them essentially at random. There is no in-chart readout that can
identify which grey line you are near, so the chart doesn't pretend to have one.
What it does instead is take the readout off the chart, keyed on the cursor's time:
<ChartContainer cursor="line" onTrackerChanged={setTracker}>
onTrackerChanged hands over { time, values }; the strip above the chart uses
time alone, converts it to a day-of-year slot, and looks the numbers up in the
same fixture the chart drew. It reports the date, the three named years,
and 2026's rank among all 45 on that date — all of which are true of a day
rather than of a line, which is the only thing a pointer position can honestly
identify here. It is the same door the
climate stripes card goes through, for the same
reason.
The same data as a heat map
The wide series built in One axis for 45 years — a
column per year, a row per day-of-year — was shaped for the climatology
collapse. It happens to be exactly a heat map's shape as well, with the
year columns as rows. So the same 16,275 values, re-encoded:
The three buttons change only the x binning. The rows are the same 45 years in all three, because rows are columns and the columns are fixed by the data:
const ROWS = NINO34_YEARS.map(yearColumn); // ['y1982', … 'y2026']
const MEAN_BY_YEAR = Object.fromEntries(ROWS.map((c) => [c, 'avg']));
const series =
grain === 'day'
? wide // 365 cells per row, as built
: wide.aggregate(
grain === 'month'
? Sequence.calendar('month') // 12 cells
: Sequence.every('370d', { anchor: YEAR_START }), // 1 cell
MEAN_BY_YEAR,
);
<HeatMap series={series} columns={ROWS} colors={ramp} axis="yr" />;
That is the layer's constraint paying for itself. <HeatMap> has no opinion
about x — it draws whatever bins the series arrives with — so "change the
resolution" is not a chart prop but an ordinary aggregate, and everything
pond can already bin by is available for free. The reducer map is built at
runtime because the columns are: 45 of them, one per year in the record.
Two more knobs, neither of them the layer
Measure swaps the series, not the chart. The anomaly grid is assembled from
the same per-year anomaly columns the line chart draws, reshaped wide:
const columns = { time: DAY_TIMES };
for (const year of NINO34_YEARS) columns[yearColumn(year)] = anomalies(year);
It is worth being clear that this is a reshape, not a recomputation — the climatology has already been computed per-year for the lines, so the grid costs one pass over 45 cached columns. And it cross-checks the line chart: hover the 2015 row in November on the day binning and the cell reads +3.00 °C on 17 November, against the +3.03 °C on 19 November peak quoted at the top of this page.
Palette is just the colors array, which is the whole point of colour being
a prop rather than a theme slot:
| Ramp | What it is | Suits |
|---|---|---|
| Site | useSequentialRamp(), follows the theme | Fitting the page; the only one that flips light/dark |
| Heat | Inferno — dark → red → orange → near-white | Absolute temperature, where the metaphor is literal |
| Diverging | ColorBrewer RdBu reversed, nine steps | The anomaly, which straddles a meaningful zero |
Two details that are easy to get wrong. The diverging ramp has an odd number of steps so its neutral band can straddle zero — with an even count, zero falls on a boundary and there is no "no anomaly" colour. And it only works against a pinned symmetric domain:
<HeatMap … colors={DIVERGING} domain={[-m, +m]} />
Without domain, the layer takes the extent of whatever is on screen, so the
neutral band drifts off zero and every colour changes meaning as the binning
changes — silently, because a colour scale has no tick labels to show that it
moved. That is exactly the failure domain exists to prevent.
The heat and diverging ramps are hard-coded hex and do not follow the light/dark toggle, unlike everything else on this site. That is deliberate: a heat ramp is a specific set of colours, and re-tinting it per theme would change what the reader reads.
Two axis choices carry over unchanged from the chart above. timeFormat="%b",
because the reference year is a carrier, not information — without it the
first tick reads 2001, announcing a synthetic year the data has no opinion
about. And the y axis names only the years the line chart names, via explicit
ticks:
const NAMED_TICKS = NINO34_NAMED.flatMap((n) => {
const row = ROWS.indexOf(yearColumn(n.year));
return row < 0 ? [] : [{ at: row + 0.5, label: n.label }];
});
<YAxis id="yr" ticks={NAMED_TICKS} label="" />;
{ at, label } ticks override the layer's binCategories, which would
otherwise label all 45 rows into an unreadable stack. at is the row's
centre (row + 0.5) because the y scale runs over unit slots, one per
column — the same convention a category axis uses. 2015 is worth finding on
the grid: it is the light band, and it is the same year the chart above draws
in highlight3.
Four things this surfaced that are worth naming:
- SST bands vertically; anomaly bands horizontally. On absolute SST the loudest thing in the grid is the seasonal cycle — warm through April–June, cool through November–January — and ENSO is buried underneath it. Switch to Anomaly and the vertical banding vanishes: subtracting each year's own climatology removes the seasonal cycle and the warming trend, and what is left is horizontal, where an El Niño year is a warm row. 1997 and 2015 become obvious; the blue rows between them are La Niñas. Nothing about the layer changes — it is the same component with a different series.
Sequence.calendarhas no'year'unit — it stops atmonth. The whole-year bucket is therefore a fixed step ('370d') anchored at 1 January and deliberately sized past the record's end, so exactly one bucket covers it. It works, but it is a workaround: a calendar year is not a fixed number of days.- The top row is short in every mode. 2026 is still in progress, so its column runs out partway along — the same partial-year honesty the line chart gets for free, and for the same reason: the value simply isn't there, so nothing is drawn.
- Year mode is the climate-stripes chart stood on end — one cell per row, 45 rows, colour carrying the value. Same layer, same props, transposed because the data was.
Compare it with climate stripes, which is the
single-row case (columns.length === 1) of this same layer.
Options to try
| Option | What it does | Reach for it when |
|---|---|---|
| A fixed base period instead of a centred one | Every year measured against the same 30 years | You want the warming trend left in — it's a legitimate different question |
curve="monotone" | Smooths the path between days without touching the values | Daily noise is distracting and you want the seasonal shape |
smooth('anomaly', 'movingAverage', …) | Smooths the values — a 5-day mean is the usual ENSO practice | The daily wobble is genuinely noise, and you'd rather denoise the data than the drawing |
<Legend placement="top-left"> | Moves the three-row key | The bottom-left corner has data in it for your record |
bounds wider than the year | Lets a drag run past 1 January into blank plot | Never, on a day-of-year axis — but a real calendar axis may want the slack |
minDuration | A zoom-in floor, the sibling of bounds' zoom-out ceiling | You'd rather a reader not get down to a two-day window where the lines are noise |
as="seq2" on the backdrop | The sequential ramp's second step instead of neutral grey | You want the pack tinted rather than grey — check it still reads as a backdrop |
Drop the <Marker> | No vertical rule at the end of the record | The reader already knows the year is in progress |
See also
<LineChart>—gaps,decimate,curveand the rest<Region>,<Baseline>,<Marker>— the annotation register these thresholds live in- Theming — roles, including
line.muted, and why there are no literals above - Cursors and readouts — the cursor modes and
onTrackerChanged - Climate stripes — the other card whose readout has to live off-chart
- Storybook — the systematic knob walk