stats: world opinions moves to /stats/opinions/world, the energy tracker merges into /stats/energy (live NED section), 301s from /projects/world-opinions and /projects/energy, tracker-style icons for the stats header, nav, sub-tabs and home grid
All checks were successful
Deploy production / deploy (push) Successful in 1m18s

This commit is contained in:
2026-10-26 00:16:08 +00:00
parent be3473caa0
commit d71e39f40a
25 changed files with 405 additions and 2235 deletions

View File

@@ -152,7 +152,11 @@
>stats <ArrowRight size={20} aria-hidden="true" /></a
>
</h2>
<CatalogGrid items={statsPages} subtitleClass="mt-1 block text-base text-[var(--site-muted)]" />
<CatalogGrid
items={statsPages}
variant="tracker"
subtitleClass="mt-1 block text-base text-[var(--site-muted)]"
/>
</section>
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">

View File

@@ -1,4 +0,0 @@
import { loadEnergy } from '#lib/server/energy.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => ({ energy: await loadEnergy() });

View File

@@ -1,896 +0,0 @@
<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>

View File

@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// Merged into /stats (2026-10-26): every old URL, subpaths included, moves permanently.
export const GET: RequestHandler = ({ url }) => {
redirect(301, `/stats/energy${url.search}#live`);
};

View File

@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// Merged into /stats (2026-10-26): every old URL, subpaths included, moves permanently.
export const GET: RequestHandler = ({ url }) => {
redirect(301, `/stats/opinions/world${url.search}`);
};

View File

@@ -22,6 +22,7 @@ const staticPaths = [
'/stats/countries/CUW',
'/stats/elections',
'/stats/opinions',
'/stats/opinions/world',
'/stats/history',
'/stats/countries/NLD',
'/stats/countries/USA',
@@ -47,10 +48,8 @@ const staticPaths = [
'/projects/game-tracker/osu',
'/projects/game-tracker/pokemon/locations',
'/projects/animaldex',
'/projects/energy',
'/projects/derby',
'/projects/spotify-beatmap-finder',
'/projects/world-opinions',
'/projects/language-learner',
...COURSES.map((c) => `/projects/language-learner/${c.code}`)
];

View File

@@ -1,44 +1,43 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { statsPages } from '#lib/projects.js';
import ChartLine from 'lucide-svelte/icons/chart-line';
import Flame from 'lucide-svelte/icons/flame';
import House from 'lucide-svelte/icons/house';
import Landmark from 'lucide-svelte/icons/landmark';
import Globe from 'lucide-svelte/icons/globe';
import Vote from 'lucide-svelte/icons/vote';
import MessageCircle from 'lucide-svelte/icons/message-circle';
import ScrollText from 'lucide-svelte/icons/scroll-text';
import { statsPages, statsSubPages } from '#lib/projects.js';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
let { children } = $props();
const icons = {
overview: ChartLine,
economy: Landmark,
energy: Flame,
gouda: House,
countries: Globe,
elections: Vote,
opinions: MessageCircle,
history: ScrollText
} as const;
const tabs = [
{ name: 'overview', href: '/stats' },
...statsPages.map((p) => ({ name: p.name, href: p.href }))
{ name: 'overview', href: '/stats', icon: 'stats-overview' },
...statsPages.map((p) => ({ name: p.name, href: p.href, icon: p.icon }))
];
const path = $derived(page.url.pathname.replace(/\/$/, '') || '/');
const active = (href: string) =>
href === '/stats' ? path === href : path === href || path.startsWith(`${href}/`);
const section = $derived(tabs.find((t) => t.href !== '/stats' && active(t.href))?.href);
const subTabs = $derived(section ? statsSubPages[section] : undefined);
</script>
<div class="stats-page">
<nav class="stats-tabs" aria-label="Stats sections">
{#each tabs as tab (tab.href)}
{@const Icon = icons[tab.name as keyof typeof icons] ?? ChartLine}
<a
href={resolve(tab.href as '/stats')}
class:active={page.url.pathname === tab.href}
aria-current={page.url.pathname === tab.href ? 'page' : undefined}
><span class="tab-icon tab-{tab.name}"><Icon size={14} /></span>{tab.name}</a
class:active={active(tab.href)}
aria-current={path === tab.href ? 'page' : undefined}
><TrackerIcon name={tab.icon} class="tab-icon size-6" />{tab.name}</a
>
{/each}
</nav>
{#if subTabs}
<nav class="stats-tabs stats-subtabs" aria-label="Section pages">
{#each subTabs as tab (tab.href)}
<a
href={resolve(tab.href as '/stats')}
class:active={path === tab.href}
aria-current={path === tab.href ? 'page' : undefined}
><TrackerIcon name={tab.icon} class="tab-icon size-5" />{tab.name}</a
>
{/each}
</nav>
{/if}
{@render children()}
</div>
@@ -79,37 +78,13 @@
border-color: var(--site-text);
color: var(--site-bg);
}
.tab-icon {
display: inline-grid;
place-items: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 999px;
color: #fff;
.stats-subtabs {
margin-top: -0.9rem;
border-bottom-style: dashed;
}
.tab-overview {
background: linear-gradient(135deg, #3b82f6, #6366f1);
}
.tab-economy {
background: linear-gradient(135deg, #10b981, #0d9488);
}
.tab-energy {
background: linear-gradient(135deg, #f97316, #e11d48);
}
.tab-gouda {
background: linear-gradient(135deg, #eab308, #f97316);
}
.tab-countries {
background: linear-gradient(135deg, #a855f7, #ec4899);
}
.tab-elections {
background: linear-gradient(135deg, #06b6d4, #3b82f6);
}
.tab-history {
background: linear-gradient(135deg, #b45309, #eab308);
}
.tab-opinions {
background: linear-gradient(135deg, #ec4899, #f97316);
.stats-subtabs a {
font-size: 0.8rem;
padding: 0.25rem 0.65rem 0.25rem 0.3rem;
}
/* Shared page furniture for every /stats page. */
.stats-page :global(.stats-intro) {

View File

@@ -4,15 +4,8 @@
import StatChart from '#lib/stats/StatChart.svelte';
import { change, latest, lineOption, spread, type StatPoint } from '#lib/stats/chart.js';
import { ago, compact, num, period } from '#lib/stats/format.js';
import Landmark from 'lucide-svelte/icons/landmark';
import Flame from 'lucide-svelte/icons/flame';
import House from 'lucide-svelte/icons/house';
import Globe from 'lucide-svelte/icons/globe';
import Palmtree from 'lucide-svelte/icons/tree-palm';
import Vote from 'lucide-svelte/icons/vote';
import MessageCircle from 'lucide-svelte/icons/message-circle';
import ScrollText from 'lucide-svelte/icons/scroll-text';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
let { data } = $props();
const s = $derived(data.series);
@@ -50,57 +43,49 @@
{
href: '/stats/economy',
title: 'Economy',
icon: Landmark,
grad: 'linear-gradient(135deg,#3b82f6,#a855f7)',
icon: 'stats-economy',
text: 'Central bank rates, inflation, bond yields and spreads, stock markets, growth, government debt and deficits, migration.'
},
{
href: '/stats/energy',
title: 'Energy',
icon: Flame,
grad: 'linear-gradient(135deg,#eab308,#f97316)',
icon: 'stats-energy',
text: 'Dutch household tariffs, Europe vs the US, oil and gas, import dependence, renewables, heat pumps, solar and EVs.'
},
{
href: '/stats/gouda',
title: 'Gouda',
icon: House,
grad: 'linear-gradient(135deg,#10b981,#06b6d4)',
icon: 'stats-gouda',
text: 'Population since 1942, house prices, housing stock, municipal levies and neighbourhoods, next to the surrounding towns.'
},
{
href: '/stats/countries',
title: 'Countries',
icon: Globe,
grad: 'linear-gradient(135deg,#a855f7,#ec4899)',
icon: 'stats-countries',
text: 'The Netherlands, Curaçao, the US, South Korea, China and more side by side: income, growth, inflation, debt and life expectancy.'
},
{
href: '/stats/countries/CUW',
title: 'Curaçao',
icon: Palmtree,
grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)',
icon: 'stats-curacao',
text: 'Population, income, inflation, tourism, trade and government finances of Curaçao.'
},
{
href: '/stats/elections',
title: 'Elections',
icon: Vote,
grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)',
icon: 'stats-elections',
text: 'Every Dutch general election since 1918, Gouda down to the polling station, and the US presidential vote by state.'
},
{
href: '/stats/history',
title: 'History and culture',
icon: ScrollText,
grad: 'linear-gradient(135deg,#b45309,#eab308)',
icon: 'stats-history',
text: 'The Netherlands, Gouda, Curaçao, the US and Korea over the long run, a sourced timeline, office holders, religion, holidays, UNESCO sites and famous people.'
},
{
href: '/stats/opinions',
title: 'Opinions',
icon: MessageCircle,
grad: 'linear-gradient(135deg,#ec4899,#f97316)',
icon: 'stats-opinions',
text: 'Seat and approval polls, Eurobarometer worries and trust, world happiness, search trends, news themes and party positions.'
}
] as const;
@@ -203,9 +188,8 @@
<div class="sections">
{#each SECTIONS as sec (sec.href)}
{@const Icon = sec.icon}
<a class="section-card" href={resolve(sec.href as '/stats')}>
<span class="section-icon" style:background={sec.grad}><Icon size={20} /></span>
<TrackerIcon name={sec.icon} class="size-10" />
<span class="section-body">
<strong>{sec.title}</strong>
<span>{sec.text}</span>
@@ -337,15 +321,6 @@
.section-card:hover {
border-color: var(--site-text);
}
.section-icon {
display: inline-grid;
place-items: center;
flex: none;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
color: #fff;
}
.section-body {
display: grid;
gap: 0.2rem;

View File

@@ -1,4 +1,5 @@
import { cached, loadSeries } from '#lib/server/stats.js';
import { loadEnergy } from '#lib/server/energy.js';
import { epSeriesIds } from '#lib/stats/energylabels.js';
import type { PageServerLoad } from './$types';
@@ -6,6 +7,8 @@ const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_20
const RENEW = ['total', 'wind-onshore', 'wind-offshore', 'solar', 'biomass', 'hydro'];
export const load: PageServerLoad = async () => ({
// Live NED grid + gas storage (read-only, ned-collector's `energy` schema).
energy: await cached('stats:energy:live', loadEnergy),
series: await cached('stats:energy', () =>
loadSeries([
...['variable', 'tax', 'transport', 'fixed', 'taxcredit', 'dynamic'].map(

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { resolve } from '$app/paths';
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import { barRankOption, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
@@ -12,8 +11,7 @@
EP_LABELS,
labelStackOption
} from '#lib/stats/energylabels.js';
import Zap from 'lucide-svelte/icons/zap';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import EnergyLive from '#lib/stats/EnergyLive.svelte';
let { data } = $props();
const s = $derived(data.series);
@@ -102,7 +100,7 @@
<title>energy · stats · Stan Runge</title>
<meta
name="description"
content="Dutch household energy tariffs, European and US prices, oil and gas, import dependence, renewables, heat pumps, solar and electric cars."
content="The live Dutch grid mix and gas storage, household energy tariffs, European and US prices, oil and gas, import dependence, renewables, heat pumps, solar and electric cars."
/>
</svelte:head>
@@ -115,15 +113,6 @@
</p>
</section>
<a class="live-link" href={resolve('/projects/energy')}>
<span class="live-icon"><Zap size={16} /></span>
<span
><strong>Live grid data</strong> · hourly Dutch production, consumption and the energy mix in the
energy tracker</span
>
<span class="live-arrow"><ArrowRight size={16} /></span>
</a>
<div class="kpis">
<Kpi
label="Electricity, variable + tax"
@@ -181,6 +170,8 @@
/>
</div>
<EnergyLive energy={data.energy} attributions={a} />
<h2 class="stats-section-title">Household tariffs in the Netherlands</h2>
<div class="stats-grid">
<StatChart
@@ -640,34 +631,3 @@
{/if}
</div>
{/if}
<style>
.live-link {
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
padding: 0.75rem 1rem;
font-size: 0.9rem;
color: var(--site-text);
text-decoration: none;
}
.live-link:hover {
border-color: var(--site-text);
}
.live-arrow {
margin-left: auto;
display: inline-flex;
}
.live-icon {
display: inline-grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.6rem;
background: linear-gradient(135deg, #eab308, #f97316);
color: #fff;
flex: none;
}
</style>

View File

@@ -208,7 +208,8 @@
<p>
What people think, from polls and surveys: Dutch seat polls, approval of Trump and Lee
Jae-myung, Eurobarometer worries and trust, the World Happiness Report, and where parties stand.
For values by country see <a href={resolve('/projects/world-opinions')}>world opinions</a>.
For values, what words mean and a party compass across 57 countries see
<a href={resolve('/stats/opinions/world')}>world opinions</a>.
</p>
</section>
@@ -514,7 +515,7 @@
</div>
{/if}
<h2 class="stats-section-title">Where Dutch parties stand</h2>
<h2 class="stats-section-title" id="parties">Where Dutch parties stand</h2>
<div class="stats-grid">
{#if ches.length}
<StatChart

View File

@@ -7,6 +7,7 @@
import WorldMap, { type MapMode } from '#lib/world-opinions/WorldMap.svelte';
import { buildWorldOpinions, provideWorldOpinions } from '#lib/world-opinions/data.js';
import { Button } from '#lib/components/ui/button/index.js';
import { resolve } from '$app/paths';
let { data } = $props();
// Static per load (stats.wo_*), so it is read once and shared with the components.
@@ -32,14 +33,26 @@
}
</script>
<div class="wo mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<header class="mb-8">
<h1 class="mb-3 text-4xl font-semibold tracking-tight sm:text-5xl">world opinions</h1>
<p class="text-lg text-[var(--site-muted)]">
Same words, different politics. Hover the map, click a country.
</p>
</header>
<svelte:head>
<title>world opinions · stats · Stan Runge</title>
<meta
name="description"
content="How political words, parties, values and national histories differ across 57 countries: what liberal means, what left and right are about, a shared party compass and a values map."
/>
</svelte:head>
<section class="stats-intro">
<span class="stats-eyebrow">stats / opinions / world</span>
<h1>World opinions</h1>
<p>
Same words, different politics. Hover the map, click a country. For measured party positions
(Chapel Hill Expert Survey, V-Party) see <a href={resolve('/stats/opinions') + '#parties'}
>where Dutch parties stand</a
>; for polls, happiness and Eurobarometer see <a href={resolve('/stats/opinions')}>opinions</a>.
</p>
</section>
<div class="wo">
<section aria-label="World map">
<div class="mb-4 flex flex-wrap gap-2" role="tablist" aria-label="Map layer">
{#each modes as item (item.id)}
@@ -70,20 +83,26 @@
<CountryPanel id={countryId} />
</section>
<section class="border-t border-[var(--site-border)] mt-12 py-10">
<h2 class="mb-1 text-2xl font-semibold">party compass</h2>
<p class="mb-5 text-[var(--site-muted)]">Every party on one shared scale.</p>
<section class="mt-12 border-t border-[var(--site-border)] py-10">
<h2 class="stats-section-title mb-1">Party compass</h2>
<p class="mb-5 text-[var(--site-muted)]">
Every party on one shared scale, hand-placed for comparison. Measured CHES and V-Party
positions for Dutch parties are on <a
class="underline underline-offset-2"
href={resolve('/stats/opinions') + '#parties'}>opinions</a
>.
</p>
<PartyCompass focus={countryId} />
</section>
<section class="border-t border-[var(--site-border)] py-10">
<h2 class="mb-1 text-2xl font-semibold">values map</h2>
<h2 class="stats-section-title mb-1">Values map</h2>
<p class="mb-5 text-[var(--site-muted)]">Both value axes at once.</p>
<CultureMap selected={countryId} onselect={select} />
</section>
<section class="border-t border-[var(--site-border)] py-10">
<h2 class="mb-4 text-2xl font-semibold">one event, many stories</h2>
<h2 class="stats-section-title mb-4">One event, many stories</h2>
<div class="mb-5 flex flex-wrap gap-2" role="tablist" aria-label="Event">
{#each narratives as item, index (item.event)}
<Button