Files
website/src/routes/stats/gouda/+page.svelte

424 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

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

<script lang="ts">
import 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>