424 lines
12 KiB
Svelte
424 lines
12 KiB
Svelte
<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';
|
||
import { epShareId, EP_LABELS, GOUDA_PC4, labelStackOption } from '#lib/stats/energylabels.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);
|
||
// EP-Online energy labels per Gouda postcode (PC4), latest monthly snapshot.
|
||
const epShares = (area: string) => EP_LABELS.map((l) => last(epShareId(l, area))?.[1] ?? 0);
|
||
const epPeriod = $derived(last(epShareId('A', 'GOUDA'))?.[0]);
|
||
const epRows = $derived([
|
||
{ name: 'Netherlands', shares: epShares('NL') },
|
||
{ name: 'Gouda (2800–2809)', shares: epShares('GOUDA') },
|
||
...GOUDA_PC4.filter((p) => s[epShareId('A', p)]?.points.length).map((p) => ({
|
||
name: p,
|
||
shares: epShares(p)
|
||
}))
|
||
]);
|
||
|
||
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 2016 so sizes compare.
|
||
// CBS reports historical borders, so start after the last merger (Krimpenerwaard, 2015).
|
||
const indexed = (all: StatPoint[]): StatPoint[] => {
|
||
const p = all.filter(([d]) => d >= '2016-01-01');
|
||
return p.map(([d, v]) => [d, Math.round((v / p[0][1]) * 1000) / 10] as StatPoint);
|
||
};
|
||
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 2016, Gouda and its neighbours"
|
||
subtitle="monthly population, January 2016 = 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={`total municipal housing charges, € per multi-person household · ${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 }
|
||
)}
|
||
/>
|
||
{#if epPeriod}
|
||
<StatChart
|
||
title="Energy labels by postcode"
|
||
subtitle={`% of homes with a registered label · ${period(epPeriod, 'M')}`}
|
||
sources={['eponline']}
|
||
attributions={a}
|
||
wide
|
||
height="440px"
|
||
note="Latest registered label per home in EP-Online's monthly total file (permit-stage labels left out). 2809 is mostly new-build (Westergouwe)."
|
||
option={(t) => labelStackOption(t, epRows)}
|
||
/>
|
||
{/if}
|
||
</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>
|