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.
| Column | Meaning |
|---|---|
time | 15-minute instant, UTC ms |
other, lignite, hardCoal, gas | Dispatchable generation, GW |
biomass, hydro, wind, solar | The rest of the mix, GW |
load | Total 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.00for 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
| Option | What it does | Reach for it when |
|---|---|---|
legend={label} | Names the layer's <Legend> row | Always, on a stack — s6 means nothing to a reader |
<Legend placement="top-left"> | Draws the key inside the plot | The band order is the legend order, so it doubles as a reading guide |
cursor="crosshair" | Snaps a reticle + value pill to a band edge | Reading the top edge as a running total; "line" (the default) has no pill |
curve="monotone" | Smooths every outline | Coarser cadences, where 15-minute steps would read as noise |
gaps="none" | Bridges straight across missing samples | A feed with dropouts, where a hole in the middle of a stack reads as bad data |
as="seqN" order | Which band is dark and which is pale | Flip 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 thebaselineforms<Legend>— the key, and the headless variant- Theming —
AreaStyle.flatFilland theseq1…seq8ramp - Renewables vs demand — the same weekend, two overlapping areas instead of eight stacked ones
- Storybook — the systematic knob walk