stats: /stats overview, economy, energy and Gouda pages; CBS series map; nav, homepage, /start and sitemap links
All checks were successful
Deploy production / deploy (push) Successful in 1m10s

- CBS_SERIES: CPI, household tariffs (2007-now), energy balance, renewables, heat pumps,
  solar, cars, population (monthly, since 1942, components), house prices, housing stock,
  municipal levies for Gouda and its neighbours
- shared StatChart/Kpi/chart helpers with per-chart source attribution
- drop Eurostat nrg_pc_20x_h (404; CBS covers that history)
This commit is contained in:
2026-10-25 20:50:47 +00:00
parent 6dcbcfee25
commit 76bfb3dcec
24 changed files with 3386 additions and 9 deletions

View File

@@ -4,7 +4,7 @@
import ProgressBar from '#lib/components/ProgressBar.svelte';
import ListeningCard from '#lib/music/ListeningCard.svelte';
import CatalogGrid from '#lib/components/CatalogGrid.svelte';
import { projects, visibleTrackers } from '#lib/projects.js';
import { projects, statsPages, visibleTrackers } from '#lib/projects.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import ExternalLink from 'lucide-svelte/icons/external-link';
import Mail from 'lucide-svelte/icons/mail';
@@ -144,6 +144,17 @@
/>
</section>
<section aria-labelledby="stats-heading" class="border-t border-[var(--site-border)] pt-8">
<h2 id="stats-heading" class="mb-4 text-2xl font-semibold tracking-tight">
<a
href={resolve('/stats')}
class="inline-flex items-center gap-1 hover:text-[var(--site-muted)]"
>stats <ArrowRight size={20} aria-hidden="true" /></a
>
</h2>
<CatalogGrid items={statsPages} subtitleClass="mt-1 block text-base text-[var(--site-muted)]" />
</section>
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">
<h2 id="lists-heading" class="mb-4 text-2xl font-semibold tracking-tight">
<a

View File

@@ -14,6 +14,10 @@ const staticPaths = [
'/trackers',
'/blog',
'/lists',
'/stats',
'/stats/economy',
'/stats/energy',
'/stats/gouda',
'/trackers/books',
'/trackers/spotting',
'/trackers/cars',

View File

@@ -47,6 +47,12 @@
{ name: 'Weigh-in', href: '/trackers/health#body' },
{ name: 'DB', href: '/lists/db' }
];
const statsHits = [
{ name: 'Stats', href: '/stats' },
{ name: 'Stats: economy', href: '/stats/economy' },
{ name: 'Stats: energy', href: '/stats/energy' },
{ name: 'Stats: Gouda', href: '/stats/gouda' }
];
const host = $derived(data.host);
@@ -328,7 +334,8 @@
...trackers
.flatMap((t) => [t, ...(t.children ?? [])])
.map((t) => ({ name: t.name, href: t.href, kind: 'tracker' })),
...extraHits.map((t) => ({ ...t, kind: 'tracker' }))
...extraHits.map((t) => ({ ...t, kind: 'tracker' })),
...statsHits.map((t) => ({ ...t, kind: 'stats' }))
]);
const hits = $derived.by(() => {
const q = query.trim().toLowerCase();
@@ -559,6 +566,7 @@
>{servicesDown.length ? `${servicesDown.length} down` : 'all up'}</span
>
{/if}
<a class="chip" href="/stats" title="Economy, energy and Gouda statistics">stats</a>
{#if data.weight}<a class="chip" href="/trackers/health#body" title={data.weight.date}
>{data.weight.kg} kg</a
>{/if}

View File

@@ -0,0 +1,11 @@
import { cached, loadSources } from '#lib/server/stats.js';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ setHeaders }) => {
setHeaders({ 'cache-control': 'public, max-age=300' });
const sources = await cached('stats:sources', loadSources);
return {
sources,
attributions: Object.fromEntries(Object.values(sources).map((s) => [s.id, s.attribution]))
};
};

View File

@@ -0,0 +1,217 @@
<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';
let { children } = $props();
const icons = {
overview: ChartLine,
economy: Landmark,
energy: Flame,
gouda: House,
countries: Globe
} as const;
const tabs = [
{ name: 'overview', href: '/stats' },
...statsPages.map((p) => ({ name: p.name, href: p.href }))
];
</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
>
{/each}
</nav>
{@render children()}
</div>
<style>
.stats-page {
max-width: 1600px;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.stats-tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
border-bottom: 1px solid var(--site-border);
padding-bottom: 0.6rem;
}
.stats-tabs a {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.85rem;
color: var(--site-muted);
white-space: nowrap;
text-decoration: none;
}
.stats-tabs a:hover {
color: var(--site-text);
}
.stats-tabs a.active {
background: var(--site-text);
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;
}
.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);
}
/* Shared page furniture for every /stats page. */
.stats-page :global(.stats-intro) {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stats-page :global(.stats-eyebrow) {
color: var(--site-muted);
font-size: 0.8rem;
text-transform: lowercase;
letter-spacing: 0.02em;
}
.stats-page :global(.stats-intro h1) {
font-size: clamp(1.8rem, 4vw, 2.6rem);
font-weight: 650;
letter-spacing: -0.05em;
line-height: 1.05;
}
.stats-page :global(.stats-intro p) {
color: var(--site-muted);
max-width: 70ch;
}
.stats-page :global(.stats-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 520px), 1fr));
gap: 1rem;
}
.stats-page :global(.stats-section-title) {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.04em;
margin-top: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.stats-page :global(.kpis) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 170px), 1fr));
gap: 0.75rem;
}
.stats-page :global(.kpi) {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
padding: 0.8rem 0.9rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
text-decoration: none;
color: inherit;
}
.stats-page :global(.kpi .kpi-label) {
color: var(--site-muted);
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.stats-page :global(.kpi .kpi-value) {
font-size: 1.45rem;
font-weight: 650;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
}
.stats-page :global(.kpi .kpi-sub) {
color: var(--site-muted);
font-size: 0.72rem;
}
.stats-page :global(.up) {
color: #16a34a;
}
.stats-page :global(.down) {
color: #dc2626;
}
.stats-page :global(.dot) {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
display: inline-block;
flex: none;
}
.stats-page :global(.stats-table) {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.stats-page :global(.stats-table th),
.stats-page :global(.stats-table td) {
text-align: left;
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
.stats-page :global(.stats-table th) {
color: var(--site-muted);
font-weight: 500;
font-size: 0.75rem;
}
.stats-page :global(.stats-table td.num) {
text-align: right;
font-variant-numeric: tabular-nums;
}
.stats-page :global(.table-wrap) {
overflow-x: auto;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
}
.stats-page :global(.stats-note) {
color: var(--site-muted);
font-size: 0.78rem;
}
.stats-page :global(.empty) {
color: var(--site-muted);
font-size: 0.85rem;
padding: 2rem 0;
text-align: center;
}
</style>

View File

@@ -0,0 +1,29 @@
import { cached, loadFreshness, loadSeries } from '#lib/server/stats.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const [series, freshness] = await Promise.all([
cached('stats:overview', () =>
loadSeries([
{ id: 'fred:DCOILBRENTEU', from: '2015-01-01', resample: 'W' },
{ id: 'fred:PNGASEUUSDM', from: '2015-01-01' },
{ id: 'bis:D:US', from: '2015-01-01', resample: 'W' },
{ id: 'bis:D:XM', from: '2015-01-01', resample: 'W' },
{ id: 'bis:D:KR', from: '2015-01-01', resample: 'W' },
{ id: 'eurostat:prc_hicp_minr:RCH_A.TOTAL.NL', from: '2015-01-01' },
{ id: 'eurostat:prc_hicp_minr:RCH_A.TOTAL.EA20', from: '2015-01-01' },
{ id: 'cbs:cpi:yoy', from: '2015-01-01' },
{ id: 'eurostat:irt_lt_mcby_m:MCBY.FR', from: '2015-01-01' },
{ id: 'eurostat:irt_lt_mcby_m:MCBY.DE', from: '2015-01-01' },
{ id: 'cbs:tariff:elec:variable', from: '2015-01-01' },
{ id: 'cbs:tariff:elec:tax', from: '2015-01-01' },
'cbs:renew:total:share',
{ id: 'cbs:pop:monthly:GM0513', from: '2015-01-01' },
'cbs:house:avgprice:GM0513',
'cbs:house:avgprice:NL01'
])
),
cached('stats:freshness', loadFreshness)
]);
return { series, freshness };
};

View File

@@ -0,0 +1,323 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Kpi from '#lib/stats/Kpi.svelte';
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 ArrowRight from 'lucide-svelte/icons/arrow-right';
let { data } = $props();
const s = $derived(data.series);
const a = $derived(data.attributions);
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
const last = (id: string) => latest(s[id]?.points);
const delta = (id: string, back: number) => change(pts(id), back)?.abs ?? null;
const frde = $derived(
spread(pts('eurostat:irt_lt_mcby_m:MCBY.FR'), pts('eurostat:irt_lt_mcby_m:MCBY.DE'))
);
const elec = $derived.by(() => {
const tax = new Map(pts('cbs:tariff:elec:tax'));
return pts('cbs:tariff:elec:variable')
.filter(([d]) => tax.has(d))
.map(([d, v]) => [d, Math.round((v + tax.get(d)!) * 1000) / 1000] as StatPoint);
});
const k = $derived({
brent: last('fred:DCOILBRENTEU'),
ttf: last('fred:PNGASEUUSDM'),
fed: last('bis:D:US'),
ecb: last('bis:D:XM'),
bok: last('bis:D:KR'),
cpi: last('cbs:cpi:yoy'),
ea: last('eurostat:prc_hicp_minr:RCH_A.TOTAL.EA20'),
frde: latest(frde),
elec: latest(elec),
renew: last('cbs:renew:total:share'),
pop: last('cbs:pop:monthly:GM0513'),
house: last('cbs:house:avgprice:GM0513'),
houseNl: last('cbs:house:avgprice:NL01')
});
const SECTIONS = [
{
href: '/stats/economy',
title: 'Economy',
icon: Landmark,
grad: 'linear-gradient(135deg,#3b82f6,#a855f7)',
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)',
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)',
text: 'Population since 1942, house prices, housing stock, municipal levies and neighbourhoods, next to the surrounding towns.'
}
] as const;
</script>
<svelte:head>
<title>stats · Stan Runge</title>
<meta
name="description"
content="Public statistics I follow, refreshed automatically from CBS, Eurostat, FRED and the BIS: the economy, energy and my home town Gouda."
/>
</svelte:head>
<section class="stats-intro">
<span class="stats-eyebrow">stats</span>
<h1>Statistics</h1>
<p>
Numbers I like to keep an eye on, pulled straight from official open data (CBS, Eurostat, FRED,
the BIS) and refreshed on a schedule. Every chart names its source.
</p>
</section>
<div class="kpis">
<Kpi
href="/stats/energy"
label="Brent crude"
value={num(k.brent?.[1], 1)}
unit="$/bbl"
sub={` 4 wk · ${period(k.brent?.[0], 'D')}`}
delta={delta('fred:DCOILBRENTEU', 4)}
invert
color="#64748b"
/>
<Kpi
href="/stats/energy"
label="EU gas (TTF)"
value={num(k.ttf?.[1], 1)}
unit="$/MMBtu"
sub={` 1 mo · ${period(k.ttf?.[0])}`}
delta={delta('fred:PNGASEUUSDM', 1)}
invert
color="#3b82f6"
/>
<Kpi
href="/stats/energy"
label="NL electricity"
value={num(k.elec?.[1], 3)}
unit="€/kWh"
sub={`variable + tax · ${period(k.elec?.[0])}`}
color="#eab308"
/>
<Kpi
href="/stats/energy"
label="Renewable electricity"
value={num(k.renew?.[1], 0)}
unit="%"
sub={period(k.renew?.[0], 'A')}
color="#10b981"
/>
<Kpi
href="/stats/economy"
label="Fed / ECB / BoK"
value={`${num(k.fed?.[1], 2)} / ${num(k.ecb?.[1], 2)} / ${num(k.bok?.[1], 2)}`}
unit="%"
sub={`policy rates · ${period(k.fed?.[0], 'D')}`}
color="#a855f7"
/>
<Kpi
href="/stats/economy"
label="Dutch inflation"
value={num(k.cpi?.[1], 1)}
unit="%"
sub={`CPI y/y · euro area ${num(k.ea?.[1], 1)}% · ${period(k.cpi?.[0])}`}
color="#e11d48"
/>
<Kpi
href="/stats/economy"
label="France – Germany 10y"
value={num(k.frde ? k.frde[1] * 100 : null, 0)}
unit="bp"
sub={period(k.frde?.[0])}
color="#06b6d4"
/>
<Kpi
href="/stats/gouda"
label="Gouda inhabitants"
value={num(k.pop?.[1], 0)}
sub={` in a year · ${period(k.pop?.[0])}`}
delta={delta('cbs:pop:monthly:GM0513', 12)}
color="#f97316"
/>
<Kpi
href="/stats/gouda"
label="Gouda avg house price"
value={`€${compact(k.house?.[1])}`}
sub={`${period(k.house?.[0], 'A')} · NL €${compact(k.houseNl?.[1])}`}
color="#84cc16"
/>
</div>
<div class="sections">
{#each SECTIONS as sec (sec.href)}
{@const Icon = sec.icon}
<a class="section-card" href={resolve(sec.href)}>
<span class="section-icon" style:background={sec.grad}><Icon size={20} /></span>
<span class="section-body">
<strong>{sec.title}</strong>
<span>{sec.text}</span>
</span>
<span class="section-arrow"><ArrowRight size={16} /></span>
</a>
{/each}
</div>
<div class="stats-grid">
<StatChart
title="Policy rates since 2015"
subtitle="% · Fed, ECB deposit rate, Bank of Korea"
sources={['bis']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Fed', points: pts('bis:D:US'), color: '#3b82f6', kind: 'step' },
{ name: 'ECB', points: pts('bis:D:XM'), color: '#eab308', kind: 'step' },
{ name: 'Bank of Korea', points: pts('bis:D:KR'), color: '#e11d48', kind: 'step' }
],
{ unit: '%', decimals: 2 }
)}
/>
<StatChart
title="Inflation: Netherlands vs euro area"
subtitle="% year on year (HICP)"
sources={['eurostat']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Netherlands',
points: pts('eurostat:prc_hicp_minr:RCH_A.TOTAL.NL'),
color: '#f97316'
},
{
name: 'Euro area',
points: pts('eurostat:prc_hicp_minr:RCH_A.TOTAL.EA20'),
color: '#3b82f6'
}
],
{ unit: '%' }
)}
/>
<StatChart
title="Brent crude and EU gas"
subtitle="$/barrel (weekly) · $/MMBtu (monthly)"
sources={['fred']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Brent', points: pts('fred:DCOILBRENTEU'), color: '#64748b' },
{ name: 'EU gas (TTF)', points: pts('fred:PNGASEUUSDM'), color: '#3b82f6', right: true }
],
{ unit: '$/bbl', rightUnit: '$/MMBtu' }
)}
/>
<StatChart
title="Gouda's population"
subtitle="inhabitants, monthly"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[{ name: 'Gouda', points: pts('cbs:pop:monthly:GM0513'), color: '#f97316', kind: 'area' }],
{ decimals: 0, legend: false, min: 'dataMin' }
)}
/>
</div>
<h2 class="stats-section-title">Sources and freshness</h2>
<div class="table-wrap">
<table class="stats-table">
<thead>
<tr
><th>Source</th><th class="num">Series</th><th class="num">Observations</th><th
>Latest data</th
><th>Last refresh</th></tr
>
</thead>
<tbody>
{#each data.freshness.filter((f) => f.series > 0) as f (f.sourceId)}
<tr>
<td><strong>{f.name}</strong></td>
<td class="num">{num(f.series, 0)}</td>
<td class="num">{num(f.observations, 0)}</td>
<td>{period(f.latestPeriod, 'D')}</td>
<td
><span class="dot" style:background={f.lastRunOk === false ? '#e11d48' : '#10b981'}
></span>
{ago(f.lastOk)}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="stats-note">
Data is fetched by scheduled jobs with a polite rate limit and stored as-is; charts are rendered
from the stored copy. Licences: CBS and Eurostat CC BY 4.0, FRED and BIS per their terms of use.
</p>
<style>
.sections {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
.section-card {
display: flex;
gap: 0.85rem;
align-items: flex-start;
padding: 1rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
color: var(--site-text);
text-decoration: none;
transition: border-color 0.15s;
}
.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;
font-size: 0.85rem;
color: var(--site-muted);
}
.section-body strong {
font-size: 1rem;
color: var(--site-text);
}
.section-arrow {
margin-left: auto;
color: var(--site-muted);
}
</style>

View File

@@ -0,0 +1,40 @@
import { cached, loadSeries } from '#lib/server/stats.js';
import type { PageServerLoad } from './$types';
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020', 'EA20'];
const YIELDS = ['NL', 'DE', 'FR', 'IT', 'ES', 'BE', 'AT', 'EA'];
export const load: PageServerLoad = async () => ({
series: await cached('stats:economy', () =>
loadSeries([
...['US', 'XM', 'KR', 'CN', 'GB', 'JP'].map((a) => ({
id: `bis:M:${a}`,
from: '1990-01-01'
})),
...EU.map((g) => `eurostat:prc_hicp_minr:RCH_A.TOTAL.${g}`),
{ id: 'fred:CPIAUCSL', from: '1989-01-01' },
...YIELDS.map((g) => `eurostat:irt_lt_mcby_m:MCBY.${g}`),
{ id: 'fred:DGS10', from: '1990-01-01', resample: 'M' },
{ id: 'fred:DGS2', from: '1990-01-01', resample: 'M' },
'fred:IRLTLT01KRM156N',
...['NL', 'KR', 'US', 'CN'].map((c) => ({
id: `fred:SPASTT01${c}M661N`,
from: '1990-01-01'
})),
{ id: 'fred:SP500', resample: 'W' },
{ id: 'fred:NASDAQCOM', from: '1990-01-01', resample: 'W' },
...EU.map((g) => `eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.${g}`),
...EU.map((g) => `eurostat:nama_10_gdp:CP_MEUR.B1GQ.${g}`),
'fred:GDPC1',
'fred:UNRATE',
...EU.flatMap((g) => [
`eurostat:gov_10dd_edpt1:PC_GDP.S13.GD.${g}`,
`eurostat:gov_10dd_edpt1:PC_GDP.S13.B9.${g}`,
`eurostat:gov_10a_main:PC_GDP.S13.TE.${g}`,
`eurostat:gov_10a_main:PC_GDP.S13.TR.${g}`,
`eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.${g}`,
`eurostat:demo_pjan:NR.TOTAL.T.${g}`
])
])
)
});

View File

@@ -0,0 +1,406 @@
<script lang="ts">
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import { barRankOption, latest, lineOption, spread, yoy } from '#lib/stats/chart.js';
import { num, period } from '#lib/stats/format.js';
import { COUNTRY } from '#lib/stats/regions.js';
let { data } = $props();
const s = $derived(data.series);
const a = $derived(data.attributions);
const pts = (id: string) => s[id]?.points ?? [];
const RATES = [
['US', 'Fed', '#3b82f6'],
['XM', 'ECB', '#eab308'],
['KR', 'Bank of Korea', '#e11d48'],
['GB', 'Bank of England', '#a855f7'],
['JP', 'Bank of Japan', '#10b981'],
['CN', 'PBoC', '#f97316']
] as const;
const usCpi = $derived(yoy(pts('fred:CPIAUCSL')).filter(([d]) => d >= '1997-01-01'));
const hicp = (g: string) => pts(`eurostat:prc_hicp_minr:RCH_A.TOTAL.${g}`);
const y = (g: string) => pts(`eurostat:irt_lt_mcby_m:MCBY.${g}`);
const frde = $derived(spread(y('FR'), y('DE')));
const itde = $derived(spread(y('IT'), y('DE')));
const nlde = $derived(spread(y('NL'), y('DE')));
const us10 = $derived(pts('fred:DGS10'));
const curve = $derived(spread(us10, pts('fred:DGS2')));
const EU_LIST = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020', 'EA20'];
const lastOf = (id: string) => latest(s[id]?.points);
const lastYear = (prefix: string) =>
EU_LIST.map((g) => ({ g, p: lastOf(`${prefix}.${g}`) }))
.filter((r) => r.p)
.map((r) => ({
label: COUNTRY[r.g] ?? r.g,
value: r.p![1],
highlight: r.g === 'NL',
year: r.p![0].slice(0, 4)
}));
const debt = $derived(lastYear('eurostat:gov_10dd_edpt1:PC_GDP.S13.GD'));
const deficit = $derived(lastYear('eurostat:gov_10dd_edpt1:PC_GDP.S13.B9'));
const migrationPerK = $derived(
EU_LIST.filter((g) => !g.startsWith('E'))
.map((g) => {
const m = lastOf(`eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.${g}`);
const pop = pts(`eurostat:demo_pjan:NR.TOTAL.T.${g}`).find(([d]) => m && d === m[0]);
return m && pop
? { label: COUNTRY[g] ?? g, value: (m[1] / pop[1]) * 1000, highlight: g === 'NL' }
: null;
})
.filter((r) => r != null)
);
const k = (id: string) => lastOf(id);
const fed = $derived(k('bis:M:US'));
const ecb = $derived(k('bis:M:XM'));
const bok = $derived(k('bis:M:KR'));
const nlInfl = $derived(k('eurostat:prc_hicp_minr:RCH_A.TOTAL.NL'));
const eaInfl = $derived(k('eurostat:prc_hicp_minr:RCH_A.TOTAL.EA20'));
const usInfl = $derived(latest(usCpi));
const spreadNow = $derived(latest(frde));
const us10Now = $derived(latest(us10));
</script>
<svelte:head>
<title>economy · stats · Stan Runge</title>
<meta
name="description"
content="Interest rates, inflation, bond spreads, stock indices, GDP, public debt and migration: the Netherlands, Europe, the US and Korea."
/>
</svelte:head>
<section class="stats-intro">
<span class="stats-eyebrow">stats / economy</span>
<h1>Economy</h1>
<p>
Central bank rates, inflation, 10-year bond yields and spreads, stock markets, growth, public
finances and migration. The Netherlands next to Europe, the US and South Korea, refreshed daily
from open sources.
</p>
</section>
<div class="kpis">
<Kpi
label="Fed policy rate"
value={num(fed?.[1], 2)}
unit="%"
sub={period(fed?.[0])}
color="#3b82f6"
/>
<Kpi
label="ECB policy rate"
value={num(ecb?.[1], 2)}
unit="%"
sub={period(ecb?.[0])}
color="#eab308"
/>
<Kpi
label="Bank of Korea"
value={num(bok?.[1], 2)}
unit="%"
sub={period(bok?.[0])}
color="#e11d48"
/>
<Kpi
label="NL inflation (HICP)"
value={num(nlInfl?.[1])}
unit="%"
sub={period(nlInfl?.[0])}
color="#f97316"
/>
<Kpi
label="Euro area inflation"
value={num(eaInfl?.[1])}
unit="%"
sub={period(eaInfl?.[0])}
color="#10b981"
/>
<Kpi
label="US inflation (CPI)"
value={num(usInfl?.[1])}
unit="%"
sub={period(usInfl?.[0])}
color="#06b6d4"
/>
<Kpi
label="France–Germany 10y spread"
value={num(spreadNow ? spreadNow[1] * 100 : null, 0)}
unit="bp"
sub={period(spreadNow?.[0])}
color="#a855f7"
/>
<Kpi
label="US 10-year yield"
value={num(us10Now?.[1], 2)}
unit="%"
sub={period(us10Now?.[0])}
color="#64748b"
/>
</div>
<h2 class="stats-section-title">Rates and inflation</h2>
<div class="stats-grid">
<StatChart
title="Central bank policy rates"
subtitle="% · monthly"
sources={['bis']}
attributions={a}
option={(t) =>
lineOption(
t,
RATES.map(([c, name, color]) => ({ name, color, kind: 'step', points: pts(`bis:M:${c}`) })),
{ unit: '%', zoom: true }
)}
/>
<StatChart
title="Inflation"
subtitle="% year on year · HICP (Europe), CPI-U (US)"
sources={['eurostat', 'fred']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Netherlands', points: hicp('NL'), color: '#f97316' },
{ name: 'Euro area', points: hicp('EA20'), color: '#10b981' },
{ name: 'Germany', points: hicp('DE'), color: '#64748b' },
{ name: 'United States', points: usCpi, color: '#3b82f6' }
],
{ unit: '%', zoom: true }
)}
/>
</div>
<h2 class="stats-section-title">Bond markets</h2>
<div class="stats-grid">
<StatChart
title="10-year government bond yields"
subtitle="% · monthly (EMU convergence criterion yields; US Treasury, Korea via OECD)"
sources={['eurostat', 'fred']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Netherlands', points: y('NL'), color: '#f97316' },
{ name: 'Germany', points: y('DE'), color: '#64748b' },
{ name: 'France', points: y('FR'), color: '#3b82f6' },
{ name: 'Italy', points: y('IT'), color: '#10b981' },
{ name: 'Spain', points: y('ES'), color: '#eab308' },
{ name: 'United States', points: us10, color: '#a855f7' },
{ name: 'South Korea', points: pts('fred:IRLTLT01KRM156N'), color: '#e11d48' }
],
{ unit: '%', zoom: true }
)}
/>
<StatChart
title="Spreads over Germany"
subtitle="percentage points · 10-year yield minus the German Bund"
sources={['eurostat']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'France − Germany', points: frde, color: '#3b82f6', kind: 'area' },
{ name: 'Italy − Germany', points: itde, color: '#10b981' },
{ name: 'Netherlands − Germany', points: nlde, color: '#f97316' }
],
{ unit: 'pp', zoom: true }
)}
/>
<StatChart
title="US yield curve: 10-year minus 2-year"
subtitle="percentage points · monthly average · below zero = inverted"
sources={['fred']}
attributions={a}
option={(t) =>
lineOption(t, [{ name: '10y − 2y', points: curve, color: '#a855f7', kind: 'area' }], {
unit: 'pp',
zoom: true,
legend: false
})}
/>
<StatChart
title="Share prices"
subtitle="index 2015 = 100 · OECD main economic indicators"
sources={['fred']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Netherlands', points: pts('fred:SPASTT01NLM661N'), color: '#f97316' },
{ name: 'United States', points: pts('fred:SPASTT01USM661N'), color: '#3b82f6' },
{ name: 'South Korea', points: pts('fred:SPASTT01KRM661N'), color: '#e11d48' },
{ name: 'China', points: pts('fred:SPASTT01CNM661N'), color: '#eab308' }
],
{ zoom: true }
)}
/>
<StatChart
title="S&P 500 and NASDAQ Composite"
subtitle="index points · weekly average"
sources={['fred']}
attributions={a}
wide
option={(t) =>
lineOption(
t,
[
{ name: 'S&P 500', points: pts('fred:SP500'), color: '#3b82f6' },
{ name: 'NASDAQ Composite', points: pts('fred:NASDAQCOM'), color: '#10b981', right: true }
],
{ zoom: true, unit: 'S&P', rightUnit: 'NASDAQ' }
)}
/>
</div>
<h2 class="stats-section-title">Growth and jobs</h2>
<div class="stats-grid">
<StatChart
title="Real GDP growth"
subtitle="% change on the previous year"
sources={['eurostat']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Netherlands',
points: pts('eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.NL'),
color: '#f97316',
kind: 'bar'
},
{
name: 'EU 27',
points: pts('eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.EU27_2020'),
color: '#3b82f6'
},
{
name: 'Germany',
points: pts('eurostat:nama_10_gdp:CLV_PCH_PRE.B1GQ.DE'),
color: '#64748b'
}
],
{ unit: '%' }
)}
/>
<StatChart
title="US real GDP and unemployment"
subtitle="billions of chained 2017 USD (SAAR) · unemployment %"
sources={['fred']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Real GDP', points: pts('fred:GDPC1'), color: '#3b82f6', kind: 'area' },
{ name: 'Unemployment rate', points: pts('fred:UNRATE'), color: '#e11d48', right: true }
],
{ unit: 'USD bn', rightUnit: '%', zoom: true }
)}
/>
</div>
<h2 class="stats-section-title">Public finances and migration</h2>
<div class="stats-grid">
<StatChart
title="Government debt"
subtitle={`% of GDP · ${debt[0]?.year ?? ''}`}
sources={['eurostat']}
attributions={a}
height="360px"
option={(t) => barRankOption(t, debt, { unit: '% of GDP', color: '#a855f7' })}
/>
<StatChart
title="Government balance"
subtitle={`surplus (+) or deficit (−), % of GDP · ${deficit[0]?.year ?? ''}`}
sources={['eurostat']}
attributions={a}
height="360px"
option={(t) => barRankOption(t, deficit, { unit: '% of GDP', color: '#10b981' })}
/>
<StatChart
title="Dutch government: spending vs revenue"
subtitle="% of GDP · general government"
sources={['eurostat']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Spending',
points: pts('eurostat:gov_10a_main:PC_GDP.S13.TE.NL'),
color: '#e11d48'
},
{
name: 'Revenue',
points: pts('eurostat:gov_10a_main:PC_GDP.S13.TR.NL'),
color: '#10b981'
},
{
name: 'EU 27 spending',
points: pts('eurostat:gov_10a_main:PC_GDP.S13.TE.EU27_2020'),
color: '#e11d48',
dashed: true
},
{
name: 'EU 27 revenue',
points: pts('eurostat:gov_10a_main:PC_GDP.S13.TR.EU27_2020'),
color: '#10b981',
dashed: true
}
],
{ unit: '% of GDP', min: 'dataMin' }
)}
/>
<StatChart
title="Immigration"
subtitle="people arriving per year"
sources={['eurostat']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Netherlands',
points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.NL'),
color: '#f97316',
kind: 'area'
},
{
name: 'Belgium',
points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.BE'),
color: '#eab308'
},
{
name: 'Sweden',
points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.SE'),
color: '#3b82f6'
},
{
name: 'Austria',
points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.AT'),
color: '#e11d48'
}
],
{ decimals: 0 }
)}
/>
<StatChart
title="Immigration per 1,000 inhabitants"
subtitle="latest year"
sources={['eurostat']}
attributions={a}
height="340px"
option={(t) => barRankOption(t, migrationPerK, { unit: 'per 1,000', color: '#06b6d4' })}
/>
</div>
<p class="stats-note">
Policy rates: BIS central bank policy rates (month end). Yields for the euro countries are
Eurostat's EMU convergence-criterion series.
</p>

View File

@@ -0,0 +1,58 @@
import { cached, loadSeries } from '#lib/server/stats.js';
import type { PageServerLoad } from './$types';
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020'];
const RENEW = ['total', 'wind-onshore', 'wind-offshore', 'solar', 'biomass', 'hydro'];
export const load: PageServerLoad = async () => ({
series: await cached('stats:energy', () =>
loadSeries([
...['variable', 'tax', 'transport', 'fixed', 'taxcredit', 'dynamic'].map(
(k) => `cbs:tariff:elec:${k}`
),
...['variable', 'tax', 'transport', 'fixed'].map((k) => `cbs:tariff:gas:${k}`),
...['variable', 'tax', 'ode'].flatMap((k) => [
`cbs:tariff1823:elec:${k}`,
`cbs:tariff1823:gas:${k}`
]),
'cbs:price0723:elec',
'cbs:price0723:gas',
'cbs:cpi:energy:yoy',
...EU.flatMap((g) => [
`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.I_TAX.EUR.${g}`,
`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.I_TAX.EUR.${g}`,
`eurostat:nrg_ind_id:TOTAL.PC.${g}`
]),
...['X_TAX', 'X_VAT'].flatMap((t) => [
`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.${t}.EUR.NL`,
`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.${t}.EUR.NL`
]),
{ id: 'fred:APU000072610', from: '2007-01-01' },
{ id: 'fred:DCOILBRENTEU', resample: 'W' },
'fred:PNGASEUUSDM',
'cbs:energy:tpes',
'cbs:energy:netimport',
'cbs:energy:production',
'cbs:energy:gas:production',
'cbs:energy:gas:netimport',
'cbs:energy:gas:tpes',
...RENEW.flatMap((k) => [`cbs:renew:${k}:share`, `cbs:renew:${k}:mw`]),
'cbs:heatpumps:homes',
'cbs:heatpumps:homes:air',
'cbs:heatpumps:homes:ground',
'cbs:heatpumps:new',
...['NL01', 'GM0513'].flatMap((r) => [
`cbs:solar:homes:kwp:${r}`,
`cbs:solar:homes:n:${r}`,
`cbs:solar:kwp:${r}`
]),
'cbs:pop:jan1:GM0513',
'cbs:cars:total',
'cbs:cars:electric',
'cbs:cars:petrol',
'cbs:cars:diesel',
'eurostat:road_eqr_carpda:NR.ELC.NL',
'eurostat:road_eqr_carpda:NR.TOTAL.NL'
])
)
});

View File

@@ -0,0 +1,605 @@
<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';
import { compact, num, period } from '#lib/stats/format.js';
import { COUNTRY } from '#lib/stats/regions.js';
import Zap from 'lucide-svelte/icons/zap';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
let { data } = $props();
const s = $derived(data.series);
const a = $derived(data.attributions);
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
const last = (id: string) => latest(s[id]?.points);
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020'];
/** a ÷ b × k on shared dates. */
function ratio(x: StatPoint[], y: StatPoint[], k = 100): StatPoint[] {
const m = new Map(y);
return x
.filter(([d]) => m.get(d))
.map(([d, v]) => [d, Math.round((v / m.get(d)!) * k * 100) / 100]);
}
function sum(...series: StatPoint[][]): StatPoint[] {
const maps = series.map((x) => new Map(x));
return series[0]
.filter(([d]) => maps.every((m) => m.has(d)))
.map(([d]) => [d, Math.round(maps.reduce((t, m) => t + m.get(d)!, 0) * 10000) / 10000]);
}
const elecAllIn = $derived(sum(pts('cbs:tariff:elec:variable'), pts('cbs:tariff:elec:tax')));
const gasAllIn = $derived(sum(pts('cbs:tariff:gas:variable'), pts('cbs:tariff:gas:tax')));
const elec1823 = $derived(
sum(pts('cbs:tariff1823:elec:variable'), pts('cbs:tariff1823:elec:tax'))
);
const gas1823 = $derived(sum(pts('cbs:tariff1823:gas:variable'), pts('cbs:tariff1823:gas:tax')));
const importDep = $derived(ratio(pts('cbs:energy:netimport'), pts('cbs:energy:tpes')));
const evShare = $derived(ratio(pts('cbs:cars:electric'), pts('cbs:cars:total')));
const evNewShare = $derived(
ratio(pts('eurostat:road_eqr_carpda:NR.ELC.NL'), pts('eurostat:road_eqr_carpda:NR.TOTAL.NL'))
);
const elecPrice = (g: string) => pts(`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.I_TAX.EUR.${g}`);
const gasPrice = (g: string) => pts(`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.I_TAX.EUR.${g}`);
const ranking = (f: (g: string) => StatPoint[]) => {
const rows = EU.map((g) => ({ g, p: latest(f(g)) })).filter((r) => r.p);
const newest = rows.reduce((m, r) => (r.p![0] > m ? r.p![0] : m), '');
return {
when: newest,
rows: rows
.filter((r) => r.p![0] === newest)
.map((r) => ({ label: COUNTRY[r.g] ?? r.g, value: r.p![1], highlight: r.g === 'NL' }))
};
};
const elecRank = $derived(ranking(elecPrice));
const gasRank = $derived(ranking(gasPrice));
const depRank = $derived(ranking((g) => pts(`eurostat:nrg_ind_id:TOTAL.PC.${g}`)));
const usElec = $derived(pts('fred:APU000072610'));
const giga = (p: StatPoint[], k: number) =>
p.map(([d, v]) => [d, Math.round((v / k) * 100) / 100] as StatPoint);
const goudaSolarPerK = $derived(
ratio(pts('cbs:solar:homes:n:GM0513'), pts('cbs:pop:jan1:GM0513'), 1000)
);
const kE = $derived(latest(elecAllIn));
const kG = $derived(latest(gasAllIn));
const kBrent = $derived(last('fred:DCOILBRENTEU'));
const kTtf = $derived(last('fred:PNGASEUUSDM'));
const kRenew = $derived(last('cbs:renew:total:share'));
const kHp = $derived(last('cbs:heatpumps:homes'));
const kEv = $derived(last('cbs:cars:electric'));
const kDep = $derived(latest(importDep));
</script>
<svelte:head>
<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."
/>
</svelte:head>
<section class="stats-intro">
<span class="stats-eyebrow">stats / energy</span>
<h1>Energy</h1>
<p>
What Dutch households pay for electricity and gas, how that compares with Europe and the US, and
how fast the Netherlands is switching: renewables, heat pumps, solar panels and electric cars.
</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"
value={num(kE?.[1], 3)}
unit="€/kWh"
sub={period(kE?.[0])}
color="#eab308"
/>
<Kpi
label="Gas, variable + tax"
value={num(kG?.[1], 2)}
unit="€/m³"
sub={period(kG?.[0])}
color="#f97316"
/>
<Kpi
label="Brent crude"
value={num(kBrent?.[1], 1)}
unit="$/bbl"
sub={`week of ${period(kBrent?.[0], 'D')}`}
color="#64748b"
/>
<Kpi
label="EU gas (TTF)"
value={num(kTtf?.[1], 1)}
unit="$/MMBtu"
sub={period(kTtf?.[0])}
color="#3b82f6"
/>
<Kpi
label="Renewable electricity"
value={num(kRenew?.[1], 0)}
unit="%"
sub={`of consumption, ${period(kRenew?.[0], 'A')}`}
color="#10b981"
/>
<Kpi
label="Import dependence"
value={num(kDep?.[1], 0)}
unit="%"
sub={`net imports / supply, ${period(kDep?.[0], 'A')}`}
color="#e11d48"
/>
<Kpi
label="Heat pumps in homes"
value={compact(kHp?.[1])}
sub={`end of ${period(kHp?.[0], 'A')}`}
color="#06b6d4"
/>
<Kpi
label="Electric cars"
value={compact(kEv?.[1])}
sub={`1 Jan ${period(kEv?.[0], 'A')}`}
color="#a855f7"
/>
</div>
<h2 class="stats-section-title">Household tariffs in the Netherlands</h2>
<div class="stats-grid">
<StatChart
title="Electricity price per kWh"
subtitle="€/kWh incl. VAT · variable supply + energy tax (CBS) and all-in average 2007–2022"
sources={['cbs']}
attributions={a}
note="Since 2023 the energy tax reduction (≈ €600 a year) is a fixed credit, so the marginal kWh is pricier than the average bill. The 2023 price cap is not included."
option={(t) =>
lineOption(
t,
[
{
name: 'Average price 2007–2022 (2.5–5 MWh)',
points: pts('cbs:price0723:elec'),
color: '#64748b'
},
{ name: 'Variable + tax 2018–2022', points: elec1823, color: '#eab308', dashed: true },
{ name: 'Variable + tax (new contracts)', points: elecAllIn, color: '#eab308' },
{ name: 'Dynamic contract', points: pts('cbs:tariff:elec:dynamic'), color: '#10b981' }
],
{ unit: '€/kWh', decimals: 3, zoom: true }
)}
/>
<StatChart
title="Gas price per m³"
subtitle="€/m³ incl. VAT · variable supply + energy tax"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Variable + tax 2018–2022', points: gas1823, color: '#f97316', dashed: true },
{ name: 'Variable + tax (new contracts)', points: gasAllIn, color: '#f97316' },
{
name: 'of which energy tax',
points: pts('cbs:tariff:gas:tax'),
color: '#e11d48',
kind: 'area'
}
],
{ unit: '€/m³', decimals: 3, zoom: true }
)}
/>
<StatChart
title="What a kWh is made of"
subtitle="€/kWh incl. VAT · supply vs taxes"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Supply (variable)',
points: [
...pts('cbs:tariff1823:elec:variable').filter(([d]) => d < '2021-01-01'),
...pts('cbs:tariff:elec:variable')
],
color: '#eab308',
kind: 'area',
stack: 'kwh'
},
{
name: 'Energy tax',
points: [
...pts('cbs:tariff1823:elec:tax').filter(([d]) => d < '2021-01-01'),
...pts('cbs:tariff:elec:tax')
],
color: '#e11d48',
kind: 'area',
stack: 'kwh'
},
{
name: 'ODE surcharge',
points: pts('cbs:tariff1823:elec:ode'),
color: '#a855f7',
kind: 'area',
stack: 'kwh'
}
],
{ unit: '€/kWh', decimals: 3 }
)}
/>
<StatChart
title="Fixed costs per year"
subtitle="€ per year incl. VAT · electricity connection"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Network (transport) tariff',
points: pts('cbs:tariff:elec:transport'),
color: '#3b82f6',
kind: 'step'
},
{
name: 'Fixed supply tariff',
points: pts('cbs:tariff:elec:fixed'),
color: '#eab308',
kind: 'step'
},
{
name: 'Energy tax reduction',
points: pts('cbs:tariff:elec:taxcredit'),
color: '#10b981',
kind: 'step'
},
{
name: 'Gas network tariff',
points: pts('cbs:tariff:gas:transport'),
color: '#f97316',
kind: 'step'
}
],
{ unit: '€/year', decimals: 0 }
)}
/>
</div>
<h2 class="stats-section-title">Compared with Europe and the US</h2>
<div class="stats-grid">
<StatChart
title="Household electricity price, Europe"
subtitle={`€/kWh incl. all taxes · 2,500–5,000 kWh a year · ${period(elecRank.when, 'S')}`}
sources={['eurostat']}
attributions={a}
height="360px"
option={(t) =>
barRankOption(t, elecRank.rows, { decimals: 3, unit: '€/kWh', color: '#eab308' })}
/>
<StatChart
title="Electricity: Netherlands, Germany, EU and the US"
subtitle="per kWh incl. taxes · Europe in €, US city average in $ (BLS)"
sources={['eurostat', 'fred']}
attributions={a}
height="360px"
option={(t) =>
lineOption(
t,
[
{ name: 'Netherlands (€)', points: elecPrice('NL'), color: '#f97316', kind: 'step' },
{ name: 'Germany (€)', points: elecPrice('DE'), color: '#64748b', kind: 'step' },
{ name: 'EU 27 (€)', points: elecPrice('EU27_2020'), color: '#3b82f6', kind: 'step' },
{ name: 'United States ($)', points: usElec, color: '#e11d48' }
],
{ unit: 'per kWh', decimals: 3 }
)}
/>
<StatChart
title="Household gas price, Europe"
subtitle={`€/kWh incl. all taxes · 20–200 GJ a year · ${period(gasRank.when, 'S')}`}
sources={['eurostat']}
attributions={a}
height="360px"
option={(t) => barRankOption(t, gasRank.rows, { decimals: 3, unit: '€/kWh', color: '#f97316' })}
/>
<StatChart
title="Dutch household prices: taxes vs the rest"
subtitle="€/kWh · Eurostat semesters"
sources={['eurostat']}
attributions={a}
height="360px"
option={(t) =>
lineOption(
t,
[
{
name: 'Electricity excl. taxes',
points: pts('eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.X_TAX.EUR.NL'),
color: '#eab308',
kind: 'step',
dashed: true
},
{
name: 'Electricity incl. taxes',
points: elecPrice('NL'),
color: '#eab308',
kind: 'step'
},
{
name: 'Gas excl. taxes',
points: pts('eurostat:nrg_pc_202:G3000.GJ20-199.KWH.X_TAX.EUR.NL'),
color: '#f97316',
kind: 'step',
dashed: true
},
{ name: 'Gas incl. taxes', points: gasPrice('NL'), color: '#f97316', kind: 'step' }
],
{ unit: '€/kWh', decimals: 3 }
)}
/>
</div>
<h2 class="stats-section-title">Oil, gas and imports</h2>
<div class="stats-grid">
<StatChart
title="Brent crude and EU natural gas"
subtitle="Brent $/barrel (weekly) · TTF $/MMBtu (monthly, IMF)"
sources={['fred']}
attributions={a}
wide
option={(t) =>
lineOption(
t,
[
{ name: 'Brent', points: pts('fred:DCOILBRENTEU'), color: '#64748b' },
{ name: 'EU gas (TTF)', points: pts('fred:PNGASEUUSDM'), color: '#3b82f6', right: true }
],
{ unit: '$/bbl', rightUnit: '$/MMBtu', zoom: true }
)}
/>
<StatChart
title="Import dependence since 1960"
subtitle="net energy imports as % of total primary supply"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(t, [{ name: 'Netherlands', points: importDep, color: '#e11d48', kind: 'area' }], {
unit: '%',
legend: false
})}
/>
<StatChart
title="Natural gas: production vs imports"
subtitle="PJ per year · the Groningen field from boom to shutdown"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Production',
points: pts('cbs:energy:gas:production'),
color: '#10b981',
kind: 'area'
},
{ name: 'Net imports', points: pts('cbs:energy:gas:netimport'), color: '#e11d48' },
{ name: 'Supply', points: pts('cbs:energy:gas:tpes'), color: '#64748b', dashed: true }
],
{ unit: 'PJ', decimals: 0 }
)}
/>
<StatChart
title="Energy import dependency, Europe"
subtitle={`% of gross available energy · ${period(depRank.when, 'A')}`}
sources={['eurostat']}
attributions={a}
height="340px"
option={(t) => barRankOption(t, depRank.rows, { unit: '%', color: '#e11d48' })}
/>
</div>
<h2 class="stats-section-title">The switch</h2>
<div class="stats-grid">
<StatChart
title="Renewable electricity"
subtitle="% of electricity consumption · normalised for wind and water"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Onshore wind',
points: pts('cbs:renew:wind-onshore:share'),
color: '#06b6d4',
kind: 'area',
stack: 'r'
},
{
name: 'Offshore wind',
points: pts('cbs:renew:wind-offshore:share'),
color: '#3b82f6',
kind: 'area',
stack: 'r'
},
{
name: 'Solar',
points: pts('cbs:renew:solar:share'),
color: '#eab308',
kind: 'area',
stack: 'r'
},
{
name: 'Biomass',
points: pts('cbs:renew:biomass:share'),
color: '#84cc16',
kind: 'area',
stack: 'r'
},
{
name: 'Hydro',
points: pts('cbs:renew:hydro:share'),
color: '#a855f7',
kind: 'area',
stack: 'r'
}
],
{ unit: '%' }
)}
/>
<StatChart
title="Installed solar and wind capacity"
subtitle="GW, end of year"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Solar',
points: giga(pts('cbs:renew:solar:mw'), 1000),
color: '#eab308',
kind: 'bar'
},
{
name: 'Onshore wind',
points: giga(pts('cbs:renew:wind-onshore:mw'), 1000),
color: '#06b6d4',
kind: 'bar'
},
{
name: 'Offshore wind',
points: giga(pts('cbs:renew:wind-offshore:mw'), 1000),
color: '#3b82f6',
kind: 'bar'
}
],
{ unit: 'GW' }
)}
/>
<StatChart
title="Heat pumps in Dutch homes"
subtitle="installed at the end of each year"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Air source',
points: pts('cbs:heatpumps:homes:air'),
color: '#06b6d4',
kind: 'area',
stack: 'hp'
},
{
name: 'Ground / water source',
points: pts('cbs:heatpumps:homes:ground'),
color: '#10b981',
kind: 'area',
stack: 'hp'
},
{
name: 'New per year (all buildings)',
points: pts('cbs:heatpumps:new'),
color: '#e11d48',
kind: 'bar',
right: true
}
],
{ decimals: 0, rightUnit: 'new' }
)}
/>
<StatChart
title="Solar panels on homes: Netherlands and Gouda"
subtitle="installed panel capacity, MWp · Gouda on the right axis"
sources={['cbs']}
attributions={a}
note={goudaSolarPerK.length
? `Gouda: ${num(latest(goudaSolarPerK)?.[1], 0)} home installations per 1,000 inhabitants in ${period(latest(goudaSolarPerK)?.[0], 'A')}.`
: ''}
option={(t) =>
lineOption(
t,
[
{
name: 'Netherlands',
points: giga(pts('cbs:solar:homes:kwp:NL01'), 1000),
color: '#eab308',
kind: 'bar'
},
{
name: 'Gouda',
points: giga(pts('cbs:solar:homes:kwp:GM0513'), 1000),
color: '#f97316',
right: true
}
],
{ unit: 'NL MWp', rightUnit: 'Gouda MWp', decimals: 1 }
)}
/>
<StatChart
title="Electric cars"
subtitle="share of the passenger car fleet (1 January) and of new registrations"
sources={['cbs', 'eurostat']}
attributions={a}
wide
option={(t) =>
lineOption(
t,
[
{ name: 'Share of all cars on the road', points: evShare, color: '#a855f7', kind: 'bar' },
{ name: 'Share of new cars registered', points: evNewShare, color: '#10b981' }
],
{ unit: '%' }
)}
/>
</div>
<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

@@ -0,0 +1,44 @@
import { cached, loadCbsCross, loadSeries } from '#lib/server/stats.js';
import type { PageServerLoad } from './$types';
// Gouda (GM0513) and its neighbours; NL01 is the national total.
const RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];
const KWB_TABLE = '85984NED'; // Kerncijfers wijken en buurten 2024
const KWB_MEASURES = [
'T001036',
'M000100',
'M001642',
'1014850_2',
'A047047',
'M000224',
'M008349',
'M000221_2',
'M000219_2',
'M008295',
'M008297'
];
export const load: PageServerLoad = async () => {
const [series, wijken] = await Promise.all([
cached('stats:gouda', () =>
loadSeries([
...['NL01', ...RING].flatMap((r) => [
`cbs:pop:monthly:${r}`,
`cbs:pop:jan1:${r}`,
`cbs:house:avgprice:${r}`,
...['total', 'owner', 'social', 'private'].map((k) => `cbs:stock:${k}:${r}`)
]),
...RING.flatMap((r) => ['total', 'ozb', 'sewer', 'waste'].map((k) => `cbs:levy:${k}:${r}`)),
...['births', 'deaths', 'immigration', 'emigration', 'in', 'out', 'growth'].map(
(k) => `cbs:pop:${k}:GM0513`
),
'cbs:house:hpi:cr28',
'cbs:house:hpi:nl'
])
),
cached('stats:gouda:kwb', () =>
loadCbsCross(KWB_TABLE, KWB_MEASURES, 'WijkenEnBuurten', ['WK0513', 'GM0513', 'NL00'])
)
]);
return { series, wijken, kwbYear: 2024 };
};

View File

@@ -0,0 +1,396 @@
<script lang="ts">
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import { barRankOption, change, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
import { compact, num, period } from '#lib/stats/format.js';
let { data } = $props();
const s = $derived(data.series);
const a = $derived(data.attributions);
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
const last = (id: string) => latest(s[id]?.points);
const NAMES: Record<string, string> = {
NL01: 'Netherlands',
GM0513: 'Gouda',
GM0627: 'Waddinxveen',
GM1901: 'Bodegraven-Reeuwijk',
GM1892: 'Zuidplas',
GM1931: 'Krimpenerwaard',
GM0484: 'Alphen aan den Rijn',
GM0632: 'Woerden'
};
const RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];
const COLORS: Record<string, string> = {
GM0513: '#f97316',
NL01: '#0f172a',
GM0627: '#3b82f6',
GM1901: '#10b981',
GM1892: '#a855f7',
GM1931: '#06b6d4',
GM0484: '#eab308',
GM0632: '#e11d48'
};
const pop = $derived(last('cbs:pop:monthly:GM0513'));
const popYoy = $derived(change(pts('cbs:pop:monthly:GM0513'), 12)?.abs ?? null);
const price = $derived(last('cbs:house:avgprice:GM0513'));
const priceNl = $derived(last('cbs:house:avgprice:NL01'));
const hpi = $derived(pts('cbs:house:hpi:cr28'));
const hpiYoy = $derived(hpi.length > 4 ? (hpi.at(-1)![1] / hpi.at(-5)![1] - 1) * 100 : null);
const stock = $derived(last('cbs:stock:total:GM0513'));
const social = $derived(last('cbs:stock:social:GM0513'));
const levy = $derived(last('cbs:levy:total:GM0513'));
// Index every ring municipality's monthly population to 100 in Jan 2002 so sizes compare.
const indexed = (p: StatPoint[]): StatPoint[] =>
p.length ? p.map(([d, v]) => [d, Math.round((v / p[0][1]) * 1000) / 10]) : [];
const levyRank = $derived(
RING.map((r) => ({ r, p: last(`cbs:levy:total:${r}`) }))
.filter((x) => x.p)
.map((x) => ({ label: NAMES[x.r], value: x.p![1], highlight: x.r === 'GM0513' }))
);
const priceRank = $derived(
['NL01', ...RING]
.map((r) => ({ r, p: last(`cbs:house:avgprice:${r}`) }))
.filter((x) => x.p)
.map((x) => ({
label: NAMES[x.r],
value: Math.round(x.p![1] / 1000),
highlight: x.r === 'GM0513'
}))
);
const socialShare = $derived(
['NL01', ...RING]
.map((r) => {
const t = last(`cbs:stock:total:${r}`);
const so = last(`cbs:stock:social:${r}`);
return t && so
? {
label: NAMES[r],
value: Math.round((so[1] / t[1]) * 1000) / 10,
highlight: r === 'GM0513'
}
: null;
})
.filter((x) => x != null)
);
const wijken = $derived(data.wijken.filter((w) => w.code.startsWith('WK')));
const totals = $derived(data.wijken.filter((w) => !w.code.startsWith('WK')));
const COLS: { key: string; label: string; decimals: number; unit?: string }[] = [
{ key: 'T001036', label: 'Inhabitants', decimals: 0 },
{ key: 'M001642', label: 'Avg WOZ', decimals: 0, unit: 'k€' },
{ key: '1014850_2', label: 'Rented', decimals: 0, unit: '%' },
{ key: 'A047047', label: 'Social housing', decimals: 0, unit: '%' },
{ key: 'M000224', label: 'Income / inhabitant', decimals: 1, unit: 'k€' },
{ key: 'M008349', label: 'In poverty', decimals: 1, unit: '%' },
{ key: 'M000219_2', label: 'Gas use', decimals: 0, unit: 'm³' },
{ key: 'M008297', label: 'Solar homes', decimals: 0, unit: '%' }
];
const wijkBars = (key: string) =>
wijken
.filter((w) => w.values[key] != null)
.map((w) => ({ label: w.title, value: w.values[key] as number }));
</script>
<svelte:head>
<title>Gouda · stats · Stan Runge</title>
<meta
name="description"
content="Gouda in numbers: population since 1942, births, deaths and migration, house prices, housing stock, municipal levies and neighbourhoods, compared with the surrounding municipalities."
/>
</svelte:head>
<section class="stats-intro">
<span class="stats-eyebrow">stats / gouda</span>
<h1>Gouda</h1>
<p>
My home town in numbers, next to its neighbours: Waddinxveen, Bodegraven-Reeuwijk, Zuidplas,
Krimpenerwaard, Alphen aan den Rijn and Woerden.
</p>
</section>
<div class="kpis">
<Kpi
label="Inhabitants"
value={num(pop?.[1], 0)}
sub={` in a year · ${period(pop?.[0])}`}
delta={popYoy}
color="#f97316"
/>
<Kpi
label="Average sale price"
value={`€${compact(price?.[1])}`}
sub={`${period(price?.[0], 'A')} · NL €${compact(priceNl?.[1])}`}
color="#e11d48"
/>
<Kpi
label="House prices, region"
value={num(hpi.at(-1)?.[1], 0)}
unit="index"
sub={` in a year · ${period(hpi.at(-1)?.[0], 'Q')}`}
delta={hpiYoy}
deltaUnit="%"
invert
color="#a855f7"
/>
<Kpi
label="Homes"
value={num(stock?.[1], 0)}
sub={`${num(social && stock ? (social[1] / stock[1]) * 100 : null, 0)}% social rent · ${period(stock?.[0], 'A')}`}
color="#3b82f6"
/>
<Kpi
label="Municipal levies"
value={`€${num(levy?.[1], 0)}`}
sub={`multi-person household, ${period(levy?.[0], 'A')}`}
color="#10b981"
/>
</div>
<h2 class="stats-section-title">People</h2>
<div class="stats-grid">
<StatChart
title="Population of Gouda since 1942"
subtitle="inhabitants on 1 January"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[{ name: 'Gouda', points: pts('cbs:pop:jan1:GM0513'), color: '#f97316', kind: 'area' }],
{ decimals: 0, legend: false }
)}
/>
<StatChart
title="Growth since 2002, Gouda and its neighbours"
subtitle="monthly population, January 2002 = 100"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
['NL01', ...RING].map((r) => ({
name: NAMES[r],
points: indexed(pts(`cbs:pop:monthly:${r}`)),
color: COLORS[r],
dashed: r === 'NL01'
})),
{ decimals: 1, zoom: true }
)}
/>
<StatChart
title="Births, deaths and migration in Gouda"
subtitle="people per year"
sources={['cbs']}
attributions={a}
wide
note="Moving in/out counts moves to and from other Dutch municipalities; immigration and emigration are moves across the border."
option={(t) =>
lineOption(
t,
[
{ name: 'Births', points: pts('cbs:pop:births:GM0513'), color: '#10b981' },
{ name: 'Deaths', points: pts('cbs:pop:deaths:GM0513'), color: '#64748b' },
{ name: 'Immigration', points: pts('cbs:pop:immigration:GM0513'), color: '#3b82f6' },
{ name: 'Emigration', points: pts('cbs:pop:emigration:GM0513'), color: '#a855f7' },
{ name: 'Moved in', points: pts('cbs:pop:in:GM0513'), color: '#eab308', dashed: true },
{ name: 'Moved out', points: pts('cbs:pop:out:GM0513'), color: '#e11d48', dashed: true },
{
name: 'Net growth',
points: pts('cbs:pop:growth:GM0513'),
color: '#f97316',
kind: 'bar'
}
],
{ decimals: 0, zoom: true }
)}
/>
</div>
<h2 class="stats-section-title">Housing</h2>
<div class="stats-grid">
<StatChart
title="Average sale price of existing homes"
subtitle="€ per year · Gouda vs neighbours and the Netherlands"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
['NL01', ...RING].map((r) => ({
name: NAMES[r],
points: pts(`cbs:house:avgprice:${r}`),
color: COLORS[r],
dashed: r === 'NL01'
})),
{ unit: '€', decimals: 0 }
)}
/>
<StatChart
title="House price index"
subtitle="2020 = 100 · Oost-Zuid-Holland (Gouda's COROP region, quarterly) vs NL (monthly)"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Oost-Zuid-Holland', points: pts('cbs:house:hpi:cr28'), color: '#f97316' },
{ name: 'Netherlands', points: pts('cbs:house:hpi:nl'), color: '#0f172a', dashed: true }
],
{ decimals: 1, zoom: true }
)}
/>
<StatChart
title="Average sale price, latest year"
subtitle={`thousand € · ${period(price?.[0], 'A')}`}
sources={['cbs']}
attributions={a}
option={(t) => barRankOption(t, priceRank, { decimals: 0, unit: 'k€', color: '#64748b' })}
/>
<StatChart
title="Gouda's homes by ownership"
subtitle="dwellings on 1 January"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Owner-occupied',
points: pts('cbs:stock:owner:GM0513'),
color: '#3b82f6',
kind: 'bar',
stack: 's'
},
{
name: 'Social rent (housing corporation)',
points: pts('cbs:stock:social:GM0513'),
color: '#f97316',
kind: 'bar',
stack: 's'
},
{
name: 'Private rent',
points: pts('cbs:stock:private:GM0513'),
color: '#a855f7',
kind: 'bar',
stack: 's'
}
],
{ decimals: 0 }
)}
/>
<StatChart
title="Share of social rented homes"
subtitle={`% of dwellings · ${period(stock?.[0], 'A')}`}
sources={['cbs']}
attributions={a}
option={(t) => barRankOption(t, socialShare, { unit: '%', color: '#64748b' })}
/>
<StatChart
title="Municipal levies"
subtitle={`€ per multi-person household (property tax, sewer, waste) · ${period(levy?.[0], 'A')}`}
sources={['cbs']}
attributions={a}
option={(t) => barRankOption(t, levyRank, { decimals: 0, unit: '€', color: '#64748b' })}
/>
<StatChart
title="Gouda's levies over time"
subtitle="€ per multi-person household"
sources={['cbs']}
attributions={a}
wide
option={(t) =>
lineOption(
t,
[
{
name: 'Property tax (OZB)',
points: pts('cbs:levy:ozb:GM0513'),
color: '#3b82f6',
kind: 'bar',
stack: 'l'
},
{
name: 'Sewer charge',
points: pts('cbs:levy:sewer:GM0513'),
color: '#06b6d4',
kind: 'bar',
stack: 'l'
},
{
name: 'Waste charge',
points: pts('cbs:levy:waste:GM0513'),
color: '#10b981',
kind: 'bar',
stack: 'l'
}
],
{ unit: '€', decimals: 0 }
)}
/>
</div>
<h2 class="stats-section-title">Neighbourhoods ({data.kwbYear})</h2>
{#if wijken.length}
<div class="stats-grid">
<StatChart
title="Average WOZ value by neighbourhood"
subtitle={`thousand € · ${data.kwbYear}`}
sources={['cbs']}
attributions={a}
option={(t) =>
barRankOption(t, wijkBars('M001642'), { decimals: 0, unit: 'k€', color: '#e11d48' })}
/>
<StatChart
title="Income per inhabitant by neighbourhood"
subtitle={`thousand € a year · ${data.kwbYear}`}
sources={['cbs']}
attributions={a}
option={(t) =>
barRankOption(t, wijkBars('M000224'), { decimals: 1, unit: 'k€', color: '#10b981' })}
/>
</div>
<div class="table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Neighbourhood</th>
{#each COLS as c (c.key)}<th class="num">{c.label}{c.unit ? ` (${c.unit})` : ''}</th
>{/each}
</tr>
</thead>
<tbody>
{#each wijken as w (w.code)}
<tr>
<td>{w.title}</td>
{#each COLS as c (c.key)}<td class="num">{num(w.values[c.key], c.decimals)}</td>{/each}
</tr>
{/each}
{#each totals as w (w.code)}
<tr class="total">
<td>{w.title}</td>
{#each COLS as c (c.key)}<td class="num">{num(w.values[c.key], c.decimals)}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="stats-note">
{data.attributions.cbs ?? ''} · Kerncijfers wijken en buurten {data.kwbYear}. Blank cells are
suppressed by CBS for privacy.
</p>
{:else}
<p class="empty">Neighbourhood figures are not loaded yet.</p>
{/if}
<style>
tr.total td {
font-weight: 600;
border-top: 1px solid var(--site-border);
}
</style>