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
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:
396
src/routes/stats/gouda/+page.svelte
Normal file
396
src/routes/stats/gouda/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user