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

@@ -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>