Skip to main content

Temperature range

A year of daily highs and lows drawn as a band rather than two lines, with a fortnight's mean running through it. The question it answers isn't "how warm was it" — it's "how far apart were the two ends of the day", which is a shape you can't see at all when the extremes are separate lines.

The chart

Hover anywhere: a crosshair, a dot snapped to the nearest day, the date pinned to the time axis and the value pinned to the temperature axis. That's cursor="crosshair" and nothing else — this chart doesn't pan or zoom, and Options to try says which prop turns that on.

The band's width is the reading. It averages 5.4 °C in January and 12.9 °C in July — Seattle's summer is dry and cloudless enough that the ground radiates its heat away overnight, which a winter of marine overcast never lets it do. The widest single day of 2024 was 9 July, 18.9 °C between low and high; the narrowest was 23 August at 2.2 °C, a day that never got out from under the cloud.

There's a one-day notch in the band in late April. That isn't a rendering artefact — see The data.

The data

Real, measured, public domain. Daily maximum and minimum temperature for GHCN station USW00024233 — Seattle-Tacoma International Airport — for calendar 2024, from NOAA NCEI's GHCN-Daily daily-summaries service. A work of the US federal government, so it carries no copyright. The fetch and the reshaping live in website/scripts/fixtures/weather.mjs; it runs by hand and its output is committed, so the docs site fetches nothing.

[time, low, high, precip]; // °C, °C, mm — one row per calendar day

366 rows (2024 was a leap year), spanning −9.3 °C to 36.7 °C. Three quirks matter here:

  • low and high are optional columns, and the station's own reporting gaps stayed in rather than being interpolated. TMIN is missing for 2024-04-25, and that is the notch: the band has no lower edge for one day, so it doesn't draw one. A fixture that quietly filled it in would be teaching you to trust a number nobody measured.
  • A GHCN "day" is a local observing day, not a UTC one. The fixture keys each row at midnight UTC of the day's date — the key is the day's name rather than an instant. At this zoom the distinction never reaches a pixel; at an hourly zoom it would.
  • The centreline is derived, not supplied. GHCN gives the two extremes and nothing between them.

Build it

The whole chart is one <BandChart> naming the two columns that bound it:

<ChartContainer range={SEA_BOUNDS} width={640} theme={theme}>
<ChartRow height={220}>
<YAxis
id="c"
side="right"
label="temperature (°C)"
format=",.0f"
width={44}
/>
<Layers>
<BandChart series={daily} lower="low" upper="high" axis="c" as="outer" />
</Layers>
</ChartRow>
</ChartContainer>

lower / upper is the whole API — no stacking, no ordering rule, just the two columns that bound the fill. as="outer" picks the wider of the theme's two band roles, so the colour comes from the theme rather than from a literal here.

That gets you the envelope, but a year of daily extremes is noisy enough that the seasons are hard to find in it. The centreline is two pond operators:

// The midpoint of the day's two extremes — the definition the US climate
// record has always used where hourly readings don't exist. Missing either
// extreme leaves it missing, rather than quietly halving one number.
const mid = highs.map((high, i) =>
high === null || lows[i] === null ? undefined : (high + lows[i]!) / 2,
);

const series = daily.withColumn('mid', mid).smooth('mid', 'movingAverage', {
window: '15d',
alignment: 'centered',
output: 'trend',
});

alignment: 'centered' is what makes it a trend rather than a lagging average — aligned to the window's end it would put July's peak in mid-August. The default missing: 'bridge' is right here too: the one-day hole in mid is far smaller than the 15-day window, and a trend line that stuttered at it would be telling you about the recorder, not the weather. The result runs from 1.3 °C to 23.0 °C across the year.

Then draw it over the band, in the same row and against the same axis:

<Layers>
<BandChart series={series} lower="low" upper="high" axis="c" as="outer" />
<LineChart series={series} column="trend" axis="c" />
</Layers>

Order inside <Layers> is z-order: band first, line on top of it.

One thing the Gallery card does that this page doesn't — it pins the axis with min/max to the year's extremes. That's only needed because the card animates a window across the year, and an auto-fitted axis would rescale on every frame, which reads as the chart juddering rather than as the year passing.

Options to try

OptionWhat it doesReach for it when
panZoom="panZoom" + a controlled rangeDrag to pan, wheel to zoomThe reader should go looking — needs onTimeRangeChange to hold the range in state
A second <BandChart as="inner">A tighter band nested inside the wide oneYou have percentiles as well as extremes — p25/p75 inside p5/p95
<Baseline value={0}>A reference line in the annotation register, not a data hueFreezing point, an SLA, a budget — a threshold the data is judged against
curve="basis" on the lineRounds the centreline's cornersThe line is already an average and its corners are sampling artefacts
cursor="flag"A per-layer value chip instead of one axis pillYou want every layer's number at once — note it draws one chip per layer
format on the <YAxis>Any d3-format specifierFahrenheit, a decimal place, a degree suffix

See also