Files
website/src/routes/projects/energy/+page.svelte
Stan Runge 0cf5d44062 Add energy tracker (/projects/energy)
Public, read-only page over the ned-collector energy schema: latest-hour
mix and per-source utilization, generation vs consumption with CO2
intensity (day/week/month/12 months/all years), NED forecast, gas storage
by year and per site, and gas-fired power share vs storage fill.
Incomplete hours (sources not summing to the NED total) are left out and
partial days/months/years are marked.
2026-10-23 23:11:27 +00:00

897 lines
25 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import * as Tabs from '#lib/components/ui/tabs/index.js';
import EnergyChart, { type ChartTheme } from '#lib/energy/EnergyChart.svelte';
import {
SOURCES,
fillGaps,
gaps,
latestSnapshot,
seasonalTime,
toBuckets,
type Bucket
} from '#lib/energy/data.js';
import { relativeTime } from '#lib/relative-time.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let energy = $derived(data.energy);
const HOUR = 3_600_000;
const zone = 'Europe/Amsterdam';
const formatHour = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
const formatTick = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
const formatDay = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
weekday: 'short',
day: 'numeric',
month: 'short'
});
const formatDate = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: zone });
const formatMonth = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
month: 'short',
year: 'numeric'
});
const formatYear = new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric' });
const formatSeason = new Intl.DateTimeFormat('en-GB', {
timeZone: 'UTC',
day: 'numeric',
month: 'short'
});
const whole = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 0 });
const one = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 });
const percent = new Intl.NumberFormat('en-GB', { style: 'percent', maximumFractionDigits: 0 });
const escapeHtml = (value: string) =>
value.replace(
/[&<>"']/g,
(character) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!
);
let hourly = $derived(toBuckets(energy.hourly));
let daily = $derived(toBuckets(energy.daily));
let monthly = $derived(toBuckets(energy.monthly));
let yearly = $derived(toBuckets(energy.yearly));
let forecast = $derived(toBuckets(energy.forecast));
let now = $derived(latestSnapshot(hourly));
let incompleteHours = $derived(hourly.filter((bucket) => bucket.incomplete));
let hourGaps = $derived(gaps(hourly.map((bucket) => bucket.t)));
let latestGas = $derived(energy.gas.at(-1));
let latestPeriod = $derived(energy.coverage.lastHour ?? Date.now());
type Range = 'day' | 'week' | 'month' | 'year' | 'years';
let range = $state<Range>('week');
const rangeLabels: Record<Range, string> = {
day: 'day',
week: 'week',
month: 'month',
year: '12 months',
years: 'all years'
};
function axis(theme: ChartTheme) {
return {
axisLine: { lineStyle: { color: theme.border } },
axisLabel: { color: theme.muted },
splitLine: { lineStyle: { color: theme.border } }
};
}
function tooltip(theme: ChartTheme, extra: Record<string, unknown> = {}) {
return {
backgroundColor: theme.surface,
borderColor: theme.border,
confine: true,
textStyle: { color: theme.text },
...extra
};
}
function legend(theme: ChartTheme) {
return { top: 0, type: 'scroll', textStyle: { color: theme.muted }, itemWidth: 14 };
}
// YYYY-MM-DD of an instant in Amsterdam, used to compare calendar periods.
const localDay = new Intl.DateTimeFormat('en-CA', { timeZone: zone });
const monthKey = (t: number) => localDay.format(t).slice(0, 7);
const yearKey = (t: number) => localDay.format(t).slice(0, 4);
/** Is the bucket's calendar period still running (current month/year)? */
const isPartial = (bucket: Partial<Bucket>, key: (t: number) => string) =>
key(bucket.t!) === key(latestPeriod);
let trend = $derived.by(() => {
if (range === 'day' || range === 'week') {
const from = latestPeriod - (range === 'day' ? 24 : 24 * 7) * HOUR;
const points = fillGaps(
hourly.filter((bucket) => bucket.t > from),
HOUR
).map((bucket) => ('missing' in bucket || bucket.incomplete ? { t: bucket.t } : bucket));
return { kind: 'hour' as const, unit: 'MWh', scale: 1, points: points as Partial<Bucket>[] };
}
if (range === 'month')
return { kind: 'day' as const, unit: 'GWh', scale: 1000, points: daily as Partial<Bucket>[] };
if (range === 'year')
return {
kind: 'month' as const,
unit: 'GWh',
scale: 1,
points: monthly as Partial<Bucket>[]
};
return {
kind: 'year' as const,
unit: 'TWh',
scale: 1000,
// Before 2021 NED only publishes wind and solar per year, so those totals would mislead.
points: yearly.filter((bucket) => bucket.mix !== null) as Partial<Bucket>[]
};
});
function trendLabel(t: number) {
const kind = trend.kind;
if (kind === 'day') return formatDay.format(t);
if (kind === 'month') return formatMonth.format(t);
return formatYear.format(t);
}
function partialNote(bucket: Partial<Bucket>) {
if (trend.kind === 'day' && bucket.hours !== undefined && bucket.expected !== undefined)
if (bucket.hours < bucket.expected) return `${bucket.hours}/${bucket.expected} h`;
if (trend.kind === 'month' && isPartial(bucket, monthKey)) return 'so far';
if (trend.kind === 'year' && isPartial(bucket, yearKey)) return 'so far';
return '';
}
let trendOption = $derived((theme: ChartTheme) => {
const { kind, unit, scale, points } = trend;
const hourlyAxis = kind === 'hour';
const value = (raw: number | null | undefined) =>
raw === null || raw === undefined ? null : raw / scale;
const x = (bucket: Partial<Bucket>) => (hourlyAxis ? bucket.t! : trendLabel(bucket.t!));
const categories = hourlyAxis
? undefined
: points.map((bucket) => {
const note = partialNote(bucket);
return note ? `${trendLabel(bucket.t!)}*` : trendLabel(bucket.t!);
});
const series: Record<string, unknown>[] = SOURCES.map((source) => ({
name: source.label,
type: hourlyAxis ? 'line' : 'bar',
stack: 'mix',
areaStyle: hourlyAxis ? { opacity: 0.85 } : undefined,
showSymbol: false,
lineStyle: { width: 0 },
itemStyle: { color: source.color },
emphasis: { focus: 'series' },
data: points.map((bucket, index) =>
hourlyAxis
? [x(bucket), value(bucket.sources?.[source.type]?.value)]
: [categories![index], value(bucket.sources?.[source.type]?.value)]
)
}));
series.push({
name: 'Consumption',
type: 'line',
showSymbol: !hourlyAxis,
symbolSize: 5,
lineStyle: { width: 2, type: 'dashed', color: theme.text },
itemStyle: { color: theme.text },
data: points.map((bucket, index) => [
hourlyAxis ? x(bucket) : categories![index],
value(bucket.load)
])
});
const showCo2 = kind !== 'year';
if (showCo2)
series.push({
name: 'CO₂ intensity',
type: 'line',
yAxisIndex: 1,
showSymbol: false,
lineStyle: { width: 1.5, color: '#ef4444' },
itemStyle: { color: '#ef4444' },
data: points.map((bucket, index) => [
hourlyAxis ? x(bucket) : categories![index],
bucket.ef === null || bucket.ef === undefined ? null : Math.round(bucket.ef * 1000)
])
});
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 64, right: showCo2 ? 64 : 20, top: 48, bottom: hourlyAxis ? 64 : 36 },
xAxis: hourlyAxis
? {
type: 'time',
...axis(theme),
splitLine: { show: false },
axisLabel: {
color: theme.muted,
hideOverlap: true,
formatter: (t: number) => formatTick.format(t)
}
}
: { type: 'category', data: categories, ...axis(theme), splitLine: { show: false } },
yAxis: [
{
type: 'value',
name: unit,
nameTextStyle: { color: theme.muted },
...axis(theme),
axisLabel: { color: theme.muted, formatter: (v: number) => whole.format(v) }
},
...(showCo2
? [
{
type: 'value',
name: 'g CO₂/kWh',
nameTextStyle: { color: theme.muted },
...axis(theme),
splitLine: { show: false },
min: 0
}
]
: [])
],
dataZoom: hourlyAxis
? [
{ type: 'inside' },
{
type: 'slider',
bottom: 10,
height: 18,
borderColor: theme.border,
textStyle: { color: theme.muted },
fillerColor: 'rgba(127,127,127,.15)'
}
]
: [],
tooltip: tooltip(theme, {
trigger: 'axis',
formatter: (
params: { seriesName: string; marker: string; value: [number | string, number | null] }[]
) => {
if (!params.length) return '';
const head = params[0].value[0];
const index = hourlyAxis ? -1 : categories!.indexOf(String(head));
const bucket = hourlyAxis ? points.find((point) => point.t === head) : points[index];
const title = hourlyAxis ? formatHour.format(Number(head)) : trendLabel(bucket!.t!);
const note = bucket ? partialNote(bucket) : '';
const lines = params
.filter((param) => param.value[1] !== null && param.value[1] !== undefined)
.map((param) => {
const suffix = param.seriesName === 'CO₂ intensity' ? ' g/kWh' : ` ${unit}`;
return `${param.marker}${escapeHtml(param.seriesName)}: <b>${one.format(param.value[1]!)}${suffix}</b>`;
});
if (!lines.length) return `${title}<br>no data for this hour`;
const total = bucket?.sum
? `<br>Generation: <b>${one.format(bucket.sum / scale)} ${unit}</b>`
: '';
return `${title}${note ? ` (${note})` : ''}${total}<br>${lines.join('<br>')}`;
}
}),
series
};
});
let mixOption = $derived((theme: ChartTheme) => ({
animation: false,
backgroundColor: 'transparent',
tooltip: tooltip(theme, {
trigger: 'item',
formatter: (param: { name: string; value: number; percent: number }) =>
`${escapeHtml(param.name)}<br><b>${whole.format(param.value)} MWh</b> · ${one.format(param.percent)}%`
}),
series: [
{
type: 'pie',
radius: ['48%', '78%'],
itemStyle: { borderColor: theme.surface, borderWidth: 2 },
label: { color: theme.muted, formatter: '{b}\n{d}%' },
data: (now?.sources ?? [])
.filter((source) => source.value > 0)
.map((source) => ({
name: source.label,
value: Math.round(source.value),
itemStyle: { color: source.color }
}))
}
]
}));
let utilizationOption = $derived((theme: ChartTheme) => {
const sources = [...(now?.sources ?? [])].reverse();
return {
animation: false,
backgroundColor: 'transparent',
grid: { left: 96, right: 48, top: 8, bottom: 28 },
xAxis: {
type: 'value',
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
yAxis: {
type: 'category',
data: sources.map((source) => source.label),
...axis(theme),
splitLine: { show: false }
},
tooltip: tooltip(theme, {
trigger: 'item',
formatter: (param: { name: string; value: number }) =>
`${escapeHtml(param.name)}<br>running at <b>${one.format(param.value)}%</b> of capacity`
}),
series: [
{
type: 'bar',
barMaxWidth: 16,
label: {
show: true,
position: 'right',
color: theme.muted,
formatter: (param: { value: number }) => `${whole.format(param.value)}%`
},
data: sources.map((source) => ({
value: source.utilization === null ? 0 : Math.round(source.utilization * 1000) / 10,
itemStyle: { color: source.color }
}))
}
]
};
});
let forecastOption = $derived((theme: ChartTheme) => {
const weather = SOURCES.filter((source) =>
['WindOffshoreC', 'Wind', 'Solar'].includes(source.type)
);
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 64, right: 20, top: 48, bottom: 32 },
xAxis: {
type: 'time',
...axis(theme),
splitLine: { show: false },
axisLabel: {
color: theme.muted,
hideOverlap: true,
formatter: (t: number) => formatDay.format(t)
}
},
yAxis: { type: 'value', name: 'MWh', nameTextStyle: { color: theme.muted }, ...axis(theme) },
tooltip: tooltip(theme, {
trigger: 'axis',
valueFormatter: (v: number | null) => (v === null ? '–' : `${whole.format(v)} MWh`)
}),
series: [
...weather.map((source) => ({
name: source.label,
type: 'line',
stack: 'weather',
areaStyle: { opacity: 0.85 },
showSymbol: false,
lineStyle: { width: 0 },
itemStyle: { color: source.color },
data: forecast.map((bucket) => [bucket.t, bucket.sources[source.type]?.value ?? null])
})),
{
name: 'Total generation',
type: 'line',
showSymbol: false,
lineStyle: { width: 1.5, color: theme.muted },
itemStyle: { color: theme.muted },
data: forecast.map((bucket) => [bucket.t, bucket.mix])
},
{
name: 'Consumption',
type: 'line',
showSymbol: false,
lineStyle: { width: 2, type: 'dashed', color: theme.text },
itemStyle: { color: theme.text },
data: forecast.map((bucket) => [bucket.t, bucket.load])
}
]
};
});
let seasonOption = $derived((theme: ChartTheme) => {
const years: Record<string, [number, number][]> = {};
for (const row of energy.gas)
(years[row.day.slice(0, 4)] ??= []).push([seasonalTime(row.day), row.fill]);
const list = Object.entries(years).filter(([, points]) => points.length > 30);
const current = list.at(-1)?.[0];
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 48, right: 20, top: 48, bottom: 32 },
xAxis: {
type: 'time',
min: Date.UTC(2000, 0, 1),
max: Date.UTC(2000, 11, 31),
...axis(theme),
splitLine: { show: false },
axisLabel: {
color: theme.muted,
formatter: (t: number) => formatSeason.format(t).split(' ')[1]
}
},
yAxis: {
type: 'value',
min: 0,
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
tooltip: tooltip(theme, {
trigger: 'axis',
axisPointer: {
label: { formatter: ({ value }: { value: number }) => formatSeason.format(value) }
},
valueFormatter: (v: number) => `${one.format(v)}%`
}),
series: list.map(([year, points], index) => ({
name: year,
type: 'line',
showSymbol: false,
data: points,
lineStyle: { width: year === current ? 3 : 1.3, opacity: year === current ? 1 : 0.7 },
itemStyle: {
color:
year === current
? '#f97316'
: `hsl(${200 + index * 22} 55% ${theme.dark ? '68%' : '42%'})`
},
z: year === current ? 3 : 2
}))
};
});
let gasPowerOption = $derived((theme: ChartTheme) => {
const months = monthly.filter((bucket) => bucket.sum > 0);
// Fill level on the last reported day of each month.
const fillAt = (t: number) =>
energy.gas.findLast((row) => row.day.startsWith(monthKey(t)))?.fill ?? null;
const labels = months.map((bucket) =>
isPartial(bucket, monthKey)
? `${formatMonth.format(bucket.t)}*`
: formatMonth.format(bucket.t)
);
const share = (bucket: Bucket, type: 'FossilGasPower' | 'WKK Total') =>
Math.round(((bucket.sources[type]?.value ?? 0) / bucket.sum) * 1000) / 10;
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 48, right: 20, top: 48, bottom: 32 },
xAxis: { type: 'category', data: labels, ...axis(theme), splitLine: { show: false } },
yAxis: {
type: 'value',
min: 0,
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
tooltip: tooltip(theme, {
trigger: 'axis',
valueFormatter: (v: number | null) => (v === null ? '–' : `${one.format(v)}%`)
}),
series: [
{
name: 'Fossil gas power (share of generation)',
type: 'bar',
stack: 'gas',
itemStyle: { color: '#f97316' },
data: months.map((bucket) => share(bucket, 'FossilGasPower'))
},
{
name: 'CHP (WKK, mostly gas)',
type: 'bar',
stack: 'gas',
itemStyle: { color: '#fdba74' },
data: months.map((bucket) => share(bucket, 'WKK Total'))
},
{
name: 'Gas storage fill (month end)',
type: 'line',
symbolSize: 6,
lineStyle: { width: 2.5, color: '#0ea5e9' },
itemStyle: { color: '#0ea5e9' },
data: months.map((bucket) => fillAt(bucket.t))
}
]
};
});
let sitesOption = $derived((theme: ChartTheme) => {
const sites = [...energy.gasSites].reverse();
return {
animation: false,
backgroundColor: 'transparent',
grid: { left: 96, right: 96, top: 8, bottom: 28 },
xAxis: {
type: 'value',
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
yAxis: {
type: 'category',
data: sites.map((site) => site.site),
...axis(theme),
splitLine: { show: false }
},
tooltip: tooltip(theme, {
trigger: 'item',
formatter: (param: { dataIndex: number }) => {
const site = sites[param.dataIndex];
return `${escapeHtml(site.site)} · ${site.day}<br><b>${one.format(site.fill)}%</b> full · ${one.format(site.twh)} TWh`;
}
}),
series: [
{
type: 'bar',
barMaxWidth: 16,
itemStyle: { color: '#0ea5e9' },
label: {
show: true,
position: 'right',
color: theme.muted,
formatter: (param: { dataIndex: number }) =>
`${whole.format(sites[param.dataIndex].fill)}% · ${one.format(sites[param.dataIndex].twh)} TWh`
},
data: sites.map((site) => site.fill)
}
]
};
});
let missingHours = $derived(hourGaps.reduce((sum, gap) => sum + gap.hours, 0));
let clock = $state(Date.now());
$effect(() => {
const timer = setInterval(() => (clock = Date.now()), 60_000);
return () => clearInterval(timer);
});
</script>
<svelte:head>
<title>Energy · Stan Runge</title>
<meta
name="description"
content="Dutch electricity mix, utilization, trends and gas storage from NED.nl data, updated hourly."
/>
</svelte:head>
<div class="energy-page">
<header class="intro">
<div>
<p class="eyebrow">grid archive / netherlands</p>
<h1>Energy</h1>
<p class="lede">
Where Dutch electricity comes from hour by hour, how hard each source is running, and how
full the gas storages are. Data from NED.nl, collected hourly.
</p>
</div>
<div class="stats">
{#if now}
<div><strong>{percent.format(now.renewableShare)}</strong><span>renewable now</span></div>
{#if now.ef !== null}
<div><strong>{whole.format(now.ef * 1000)}</strong><span>g CO₂ / kWh</span></div>
{/if}
{/if}
{#if latestGas}
<div><strong>{one.format(latestGas.fill)}%</strong><span>gas storage full</span></div>
{/if}
</div>
</header>
{#if !now}
<section class="panel empty">No electricity data has been collected yet.</section>
{:else}
<section class="grid two">
<div class="panel">
<div class="panel-heading">
<h2>Current mix</h2>
<span class="muted">{formatHour.format(now.t)} · {whole.format(now.total)} MWh</span>
</div>
<EnergyChart
option={mixOption}
label="Electricity generation mix for the latest hour"
height="320px"
/>
<dl class="facts">
<div>
<dt>Low-carbon</dt>
<dd>{percent.format(now.lowCarbonShare)}</dd>
</div>
<div>
<dt>Fossil</dt>
<dd>{percent.format(now.fossilShare)}</dd>
</div>
{#if now.load !== null}
<div>
<dt>{now.load > now.total ? 'Net import' : 'Net export'}</dt>
<dd>{whole.format(Math.abs(now.total - now.load))} MWh</dd>
</div>
{/if}
</dl>
</div>
<div class="panel">
<div class="panel-heading">
<h2>Utilization</h2>
<span class="muted">share of installed capacity in use</span>
</div>
<EnergyChart
option={utilizationOption}
label="Utilization of each electricity source for the latest hour"
height="320px"
/>
<p class="note">
NED's load factor per source. Nuclear and "other" are reported near 100% almost
constantly, so treat those as estimates.
</p>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<h2>Generation and consumption</h2>
<Tabs.Root bind:value={range}>
<Tabs.List>
{#each Object.entries(rangeLabels) as [value, label] (value)}
<Tabs.Trigger {value}>{label}</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
</div>
<EnergyChart
option={trendOption}
label={`Electricity generation by source, ${rangeLabels[range]}`}
/>
<p class="note">
Stacked: generation by source. Dashed: consumption (gap above it is export). Red: grid CO₂
intensity. * = incomplete (still running or hours missing).
{#if range === 'month' && daily.length < 28}
Hourly collection started {formatDate.format(energy.coverage.firstHour ?? now.t)}, so this
fills up over the coming weeks.
{/if}
</p>
</section>
{#if forecast.length}
<section class="panel">
<div class="panel-heading">
<h2>Next days</h2>
<span class="muted">NED forecast for wind, solar, generation and consumption</span>
</div>
<EnergyChart
option={forecastOption}
label="Forecast of wind and solar generation and consumption"
height="320px"
/>
</section>
{/if}
{/if}
<section class="grid two">
<div class="panel">
<div class="panel-heading">
<h2>Gas storage by year</h2>
{#if latestGas}
<span class="muted"
>{one.format(latestGas.twh)} TWh on {formatDate.format(new Date(latestGas.day))}</span
>
{/if}
</div>
<EnergyChart
option={seasonOption}
label="Dutch gas storage fill level per year"
height="340px"
/>
</div>
<div class="panel">
<div class="panel-heading">
<h2>Gas vs electricity</h2>
<span class="muted">monthly</span>
</div>
<EnergyChart
option={gasPowerOption}
label="Share of electricity from gas compared with gas storage fill"
height="340px"
/>
<p class="note">
NED has no gas consumption series here, so this compares how much electricity is made from
gas with how full storage is.
</p>
</div>
</section>
{#if energy.gasSites.length}
<section class="panel">
<div class="panel-heading">
<h2>Storage sites</h2>
<span class="muted">latest day per site</span>
</div>
<EnergyChart
option={sitesOption}
label="Fill level per Dutch gas storage site"
height="240px"
/>
</section>
{/if}
<footer class="coverage">
<p>
{#if energy.coverage.firstHour && energy.coverage.lastHour}
Hourly data {formatHour.format(energy.coverage.firstHour)} – {formatHour.format(
energy.coverage.lastHour
)} ({whole.format(energy.coverage.hours)} hours{missingHours
? `, ${missingHours} missing in the last week`
: ''}{incompleteHours.length
? `, ${incompleteHours.length} still incomplete and left out`
: ''}).
{/if}
{#if monthly.length}
Monthly from {formatMonth.format(monthly[0].t)}, yearly from {formatYear.format(
yearly[0]?.t ?? monthly[0].t
)}.
{/if}
{#if energy.gas.length}
Gas storage from {formatDate.format(new Date(energy.gas[0].day))}.
{/if}
</p>
<p>
Source: <a href="https://ned.nl" rel="external noopener">NED.nl</a>.
{#if energy.collector.lastOk}
Last successful collection {relativeTime(energy.collector.lastOk, clock)}{energy.collector
.failedWeek
? ` · ${energy.collector.failedWeek} failed runs this week`
: ''}.
{/if}
</p>
</footer>
</div>
<style>
.energy-page {
max-width: 1600px;
margin: 0 auto;
padding: 3rem clamp(1rem, 4vw, 4rem) 5rem;
color: var(--site-text);
}
.intro {
display: flex;
justify-content: space-between;
align-items: end;
gap: 2rem;
margin-bottom: 2.5rem;
}
.eyebrow {
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.67rem;
font-weight: 700;
}
h1 {
font-size: clamp(2.8rem, 6vw, 5.2rem);
line-height: 1;
letter-spacing: -0.075em;
margin: 0.7rem 0 1rem;
}
h2 {
font-size: 1.25rem;
letter-spacing: -0.04em;
}
.lede {
color: var(--site-muted);
max-width: 40rem;
font-size: 0.95rem;
}
.stats {
display: flex;
gap: 2.5rem;
white-space: nowrap;
}
.stats div {
display: flex;
flex-direction: column;
align-items: end;
}
.stats strong {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.06em;
}
.stats span,
.facts dt {
color: var(--site-muted);
font-size: 0.69rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.grid {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid > .panel {
margin-bottom: 0;
}
.panel {
border: 1px solid var(--site-border);
background: var(--site-surface);
border-radius: 8px;
padding: 1.2rem;
margin-bottom: 1rem;
min-width: 0;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin-bottom: 1rem;
}
.muted,
.note,
.coverage {
color: var(--site-muted);
font-size: 0.75rem;
}
.note {
margin-top: 0.75rem;
}
.empty {
color: var(--site-muted);
}
.facts {
display: flex;
gap: 2rem;
margin-top: 0.5rem;
}
.facts div {
display: flex;
flex-direction: column;
}
.facts dd {
font-size: 1.1rem;
font-weight: 600;
}
.coverage {
margin-top: 2rem;
display: grid;
gap: 0.4rem;
}
.coverage a {
text-decoration: underline;
}
@media (max-width: 900px) {
.intro {
flex-direction: column;
align-items: start;
}
.stats div {
align-items: start;
}
.grid.two {
grid-template-columns: minmax(0, 1fr);
}
}
</style>