Skip to main content

Grid mix

Where did a country's electricity come from, minute by minute? A stacked area answers it in one shape: the top edge is total generation, and each band's thickness is one source's contribution to it.

The chart

Hover it and a crosshair snaps to the nearest band edge under the pointer, printing that edge's value against the axis. On a stack every edge is a cumulative total, so the top one is total generation: run the pointer along it and it reads 23 GW in the small hours of Monday and 55 GW at the Sunday midday peak. Almost all of that difference is the pale band on top. (The pill shares the axis's ,.0f tick format — one formatter serves both, by design — so it rounds; the fixture's own numbers are 23.36 and 54.72 GW.)

The data

Real measured data. The German power system over Easter weekend 2025 — Saturday 19 to Monday 21 April, at the grid's native 15-minute cadence (288 rows). From energy-charts.info (Fraunhofer ISE), CC BY 4.0, whose upstream is ENTSO-E / SMARD.

Easter Sunday the 20th is the day worth plotting: it had the lowest average demand of any day in 2025 (39.59 GW), and a public holiday's worth of idle industry met a 38.48 GW solar peak.

ColumnMeaning
time15-minute instant, UTC ms
other, lignite, hardCoal, gasDispatchable generation, GW
biomass, hydro, wind, solarThe rest of the mix, GW
loadTotal demand, GW (used by the next card)

The API reports fifteen production types. Eight bands is what the tonal ramp has steps for, so the six smallest — waste, pumped-storage discharge, coal-derived gas, oil, geothermal and the API's own "Others" — are folded into other rather than dropped, and the two wind channels and the two hydro channels each merge into one band. Every production type lands in exactly one band, so the bands sum to total domestic generation rather than to an arbitrary top-eight. Over the weekend they split: solar 31.6%, lignite 17.0%, wind 12.2%, biomass 11.9%, other 9.6%, gas 8.1%, hard coal 4.8%, hydro 4.7%.

Quirks that survived on purpose:

  • Solar is exactly 0.00 for 82 of the 288 samples. Night is a real zero, not a floor, and the stack has to look right when a band vanishes.
  • Gas and hard coal collapse at the solar peak — 1.22 GW and 0.98 GW at 12:30 Sunday, against weekend maxima of 4.92 and 4.64. That squeeze is the merit order being pushed off the system, and it is the reason the next two cards exist.
  • There is no nuclear band. Germany's last reactors shut down in April 2023, so the column simply isn't in the data.
  • The time axis renders in your browser's timezone; every clock time on this page is CEST, the grid's own.

Build it

A stacked area is not a chart type in @pond-ts/charts — it's a stack of plain areas over cumulative columns. Two steps, and the first one is pond, not charts.

1. Build the running totals. collapse reduces named columns into a new one; { append: true } keeps the originals, so each call widens the same series by one column:

const stacked = gridMix()
.collapse(['other', 'lignite'], 's2', (v) => v.other + v.lignite, {
append: true,
})
.collapse(['s2', 'hardCoal'], 's3', (v) => v.s2 + v.hardCoal, {
append: true,
})
.collapse(['s3', 'gas'], 's4', (v) => v.s3 + v.gas, { append: true });
// …through s8, which is total generation

s4 is "everything up to and including gas". other is already its own cumulative total, so only seven collapse calls are needed for eight bands.

2. Draw the cumulative columns top-down. Every area fills from 0 to its own cumulative total, so a later, smaller one paints over the lower part of the one before it. What is left showing for band k is exactly the slab between cumulative k−1 and cumulative k:

<Layers>
<AreaChart series={stacked} column="s8" as="seq8" baseline={0} />
<AreaChart series={stacked} column="s7" as="seq7" baseline={0} />
{/* … */}
<AreaChart series={stacked} column="other" as="seq1" baseline={0} />
</Layers>

That covering trick only works because the seq1…seq8 roles fill flat. An area's default fill is a gradient that fades to transparent at the baseline — right for a single elevation profile, fatal for a stack, where it lets all eight bands show through each other. The ramp roles set AreaStyle.flatFill and go fully opaque for exactly this reason.

3. Colour is the role, not a prop. Eight series is past the point where distinct hues help — --pond-viz-1…5 is the categorical set and stays five wide. seq1…seq8 step tonally through one brand hue instead, darkest at the bottom, so the stack reads as one family and solar ends up the brightest slab on top.

Eight tonal steps is the ramp's stress case, so it is worth saying whether they actually separate rather than asserting that they do. Read back off this chart's own canvas, the eight painted fills on the light theme are #0c444b #0d5c60 #0d7474 #0e8f86 #27a79a #38c2af #7ed6c8 #bbe8df — a near-uniform lightness ladder from L* 25.9 to 88.7 whose closest pair of neighbours is ΔE₇₆ 9.2 apart (mean 11.9). The dark theme's ramp is the same shape: L* 27.2 to 85.1, closest pair 9.0. Either way that is several times the just-noticeable difference for two adjacent large fields, so the bands really are tellable apart, top to bottom.

The honest caveat is that lightness is doing all the work — every step is the same hue — so the ramp needs the bands to touch. Adjacent slabs read; two non-adjacent ones (is that seq3 or seq5?) do not, and neither does a lone band against the page. That is why this chart carries a <Legend> whose row order is the stack order: the legend lists layers in paint order, which here is top-down, so the key doubles as the reading guide.

Options to try

OptionWhat it doesReach for it when
legend={label}Names the layer's <Legend> rowAlways, on a stack — s6 means nothing to a reader
<Legend placement="top-left">Draws the key inside the plotThe band order is the legend order, so it doubles as a reading guide
cursor="crosshair"Snaps a reticle + value pill to a band edgeReading the top edge as a running total; "line" (the default) has no pill
curve="monotone"Smooths every outlineCoarser cadences, where 15-minute steps would read as noise
gaps="none"Bridges straight across missing samplesA feed with dropouts, where a hole in the middle of a stack reads as bad data
as="seqN" orderWhich band is dark and which is paleFlip it when the interesting band is at the bottom rather than the top

Three things a stack can't do here. Negative bands — a source that consumes, like pumped storage while pumping — have no place in a cumulative column. Nothing here is clickable: <AreaChart> has no id prop and registers no hit-test, so selection is a bar/scatter/candle affordance, not an area one. And the crosshair reads cumulative totals, not per-band output; the number under the wind edge is "everything except solar", not wind. Getting all eight bands' own values at one instant means an off-chart readout fed by <ChartContainer onTrackerChanged>, with each layer given a readout column naming its raw band.

See also

  • <AreaChart> — every prop, and the baseline forms
  • <Legend> — the key, and the headless variant
  • ThemingAreaStyle.flatFill and the seq1…seq8 ramp
  • Renewables vs demand — the same weekend, two overlapping areas instead of eight stacked ones
  • Storybook — the systematic knob walk