stats: place × theme matrix (/stats/<place>/<theme>), sticky place/theme selector, generic metric engine with comparisons, 301s from the old section URLs
All checks were successful
Deploy production / deploy (push) Successful in 1m19s

This commit is contained in:
2026-10-26 17:37:47 +00:00
parent 7ba26f132d
commit f2f7bd0a19
51 changed files with 2234 additions and 837 deletions

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import type { CountriesViewData } from '#lib/server/stats-views.js';
import type { StatsCommon } from '#lib/stats/matrix.js';
import { resolve } from '$app/paths';
import StatChart from '#lib/stats/StatChart.svelte';
import { barRankOption, lineOption, type StatPoint } from '#lib/stats/chart.js';
import { compact, num } from '#lib/stats/format.js';
import { COUNTRY } from '#lib/stats/regions.js';
import { COMPARE_COLUMNS, COUNTRY_COLOR, FLAG } from '#lib/stats/countries.js';
let { data }: { data: CountriesViewData & StatsCommon } = $props();
const s = $derived(data.series);
const a = $derived(data.attributions);
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
const name = (c: string) => COUNTRY[c] ?? c;
const HIGHLIGHT = ['NLD', 'CUW', 'USA', 'KOR', 'CHN', 'WLD'];
function show(v: { value: number } | null, fmt: string) {
if (!v) return '–';
if (fmt === 'compact') return compact(v.value);
if (fmt === 'usd') return `$${compact(v.value)}`;
if (fmt === 'years') return num(v.value, 1);
return `${num(v.value, 1)}%`;
}
const rank = (key: string) =>
data.rows
.filter((r) => r.values[key])
.map((r) => ({
label: `${FLAG[r.code] ?? ''} ${name(r.code)}`,
value: r.values[key]!.value,
highlight: r.code === 'NLD' || r.code === 'CUW'
}));
const gdpLines = $derived(
HIGHLIGHT.map((c) => ({
name: name(c),
points: pts(`wb:NY.GDP.PCAP.PP.KD:${c}`),
color: COUNTRY_COLOR[c]
})).filter((l) => l.points.length)
);
const lifeLines = $derived(
HIGHLIGHT.map((c) => ({
name: name(c),
points: pts(`wb:SP.DYN.LE00.IN:${c}`),
color: COUNTRY_COLOR[c]
})).filter((l) => l.points.length)
);
const newestYear = (key: string) =>
data.rows.reduce((m, r) => {
const p = r.values[key]?.period;
return p && p > m ? p : m;
}, '');
</script>
<section class="stats-intro">
<span class="stats-eyebrow">stats / countries</span>
<h1>Countries</h1>
<p>
The places I compare most, side by side. Latest actual values (no forecasts) from the World Bank
and the IMF World Economic Outlook; the IMF is used where it covers a country. Click a country
for its full profile.
</p>
</section>
<div class="table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Country</th>
{#each COMPARE_COLUMNS as col (col.key)}<th class="num">{col.label}</th>{/each}
</tr>
</thead>
<tbody>
{#each data.rows as r (r.code)}
<tr class:hl={r.code === 'NLD' || r.code === 'CUW'}>
<td>
<a
class="country-link"
href={resolve(`/stats/${r.code.toLowerCase()}/overview` as '/stats')}
><span class="flag">{FLAG[r.code]}</span>{name(r.code)}</a
>
</td>
{#each COMPARE_COLUMNS as col (col.key)}
{@const v = r.values[col.key]}
<td class="num" title={v ? `${v.period.slice(0, 4)} · ${v.source}` : ''}
>{show(v, col.fmt)}<small>{v ? ` '${v.period.slice(2, 4)}` : ''}</small></td
>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="stats-note">
Small numbers after each value are the year it refers to. GDP per capita in constant 2021
international dollars (PPP). Growth, inflation, unemployment and debt from the IMF where
available, otherwise the World Bank.
</p>
<div class="stats-grid">
<StatChart
title="GDP per capita, PPP"
subtitle={`constant 2021 international $ · ${newestYear('gdppc').slice(0, 4)}`}
sources={['worldbank']}
attributions={a}
height="420px"
option={(t) => barRankOption(t, rank('gdppc'), { decimals: 0, unit: '$', color: '#10b981' })}
/>
<StatChart
title="Life expectancy at birth"
subtitle={`years · ${newestYear('life').slice(0, 4)}`}
sources={['worldbank']}
attributions={a}
height="420px"
option={(t) => barRankOption(t, rank('life'), { decimals: 1, unit: 'yr', color: '#a855f7' })}
/>
<StatChart
title="GDP per capita over time"
subtitle="PPP, constant 2021 international $"
sources={['worldbank']}
attributions={a}
option={(t) => lineOption(t, gdpLines, { unit: '$', decimals: 0 })}
/>
<StatChart
title="Life expectancy since 1960"
subtitle="years at birth"
sources={['worldbank']}
attributions={a}
option={(t) => lineOption(t, lifeLines, { unit: 'years', decimals: 1, min: 'dataMin' })}
/>
<StatChart
title="Government debt"
subtitle="% of GDP · IMF where available, else World Bank (central government)"
sources={['imf', 'worldbank']}
attributions={a}
height="420px"
option={(t) => barRankOption(t, rank('debt'), { decimals: 0, unit: '%', color: '#e11d48' })}
/>
<StatChart
title="Inflation"
subtitle="% consumer prices, latest actual year"
sources={['imf', 'worldbank']}
attributions={a}
height="420px"
option={(t) => barRankOption(t, rank('infl'), { decimals: 1, unit: '%', color: '#f97316' })}
/>
</div>
<style>
.country-link {
display: inline-flex;
gap: 0.5rem;
align-items: center;
color: var(--site-text);
text-decoration: none;
font-weight: 550;
}
.country-link:hover {
text-decoration: underline;
}
.flag {
font-size: 1.1rem;
}
tr.hl td {
background: color-mix(in srgb, var(--site-text) 4%, transparent);
}
td small {
color: var(--site-muted);
font-size: 0.68rem;
}
</style>

View File

@@ -0,0 +1,731 @@
<script lang="ts">
import type { CountryViewData } from '#lib/server/stats-views.js';
import type { StatsCommon } from '#lib/stats/matrix.js';
import { resolve } from '$app/paths';
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import {
barRankOption,
change,
latest,
lineOption,
type Line,
type StatPoint
} from '#lib/stats/chart.js';
import { CW_INDUSTRIES, CW_NIGHT_MARKETS, EU_TRADE } from '#lib/stats/curacao.js';
import { compact, num, period } from '#lib/stats/format.js';
import { COUNTRY } from '#lib/stats/regions.js';
import { COUNTRY_COLOR, FLAG } from '#lib/stats/countries.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import PlaceHeritage from '#lib/stats/PlaceHeritage.svelte';
let { data }: { data: CountryViewData & StatsCommon } = $props();
const c = $derived(data.code);
const s = $derived(data.series);
const a = $derived(data.attributions);
const name = $derived(COUNTRY[c] ?? c);
const color = $derived(COUNTRY_COLOR[c] ?? '#3b82f6');
const has = (id: string) => (s[`${id}:${c}`]?.points.length ?? 0) > 0;
const pts = (id: string, code = c): StatPoint[] => s[`${id}:${code}`]?.points ?? [];
const forecastFrom = (id: string) => s[`${id}:${c}`]?.forecastFrom;
/** IMF series split into outcome (solid) and WEO projection (dashed). */
function imfLines(id: string, label: string, col: string): Line[] {
const all = pts(id);
const from = forecastFrom(id);
if (!from) return [{ name: label, points: all, color: col }];
const cut = `${from}-01-01`;
const past = all.filter(([d]) => d < cut);
const future = all.filter(([d]) => d >= cut);
return [
{ name: label, points: past, color: col },
{
name: `${label} (IMF forecast)`,
points: past.length ? [past[past.length - 1], ...future] : future,
color: col,
dashed: true
}
];
}
/** IMF if it covers the country, else the World Bank fallback. */
const pick = (imf: string, wb: string) => (has(imf) ? imf : wb);
const lastOf = (id: string) => {
const from = forecastFrom(id);
const p = pts(id).filter(([d]) => !from || d < `${from}-01-01`);
return latest(p);
};
const kPop = $derived(lastOf('wb:SP.POP.TOTL'));
const kGdp = $derived(lastOf('wb:NY.GDP.PCAP.PP.KD'));
const kGrowth = $derived(lastOf(pick('imf:NGDP_RPCH', 'wb:NY.GDP.MKTP.KD.ZG')));
const kInfl = $derived(lastOf(pick('imf:PCPIPCH', 'wb:FP.CPI.TOTL.ZG')));
const kUnemp = $derived(lastOf(pick('imf:LUR', 'wb:SL.UEM.TOTL.ZS')));
const kLife = $derived(lastOf('wb:SP.DYN.LE00.IN'));
const kTour = $derived(lastOf('wb:ST.INT.ARVL'));
const kDebt = $derived(lastOf(pick('imf:GGXWDG_NGDP', 'wb:GC.DOD.TOTL.GD.ZS')));
const popDelta = $derived(change(pts('wb:SP.POP.TOTL'), 10)?.pct ?? null);
const imfCovered = $derived(has('imf:NGDP_RPCH'));
// §8 Curaçao detail series: stored without the `:CUW` suffix.
const cw = (id: string): StatPoint[] => s[id]?.points ?? [];
const isCw = $derived(c === 'CUW' && cw('cw:cpi:index').length > 0);
const cwGdpYear = $derived(latest(cw('cw:gdp:gdp'))?.[0]);
const cwIndustries = $derived(
Object.entries(CW_INDUSTRIES)
.map(([k, label]) => ({
label,
value: cw(`cw:gdp:${k}`).find(([d]) => d === cwGdpYear)?.[1] ?? NaN
}))
.filter((r) => Number.isFinite(r.value))
);
const cwCpi = $derived(latest(cw('cw:cpi:yoy')));
const cwPop = $derived(latest(cw('cw:pop:jan1')));
const cwUnemp = $derived(latest(cw('cw:lfs:unemployment_rate_pct:total')));
const cwGrowth = $derived(latest(cw('cw:gdp:real_growth_pct')));
</script>
<section class="stats-intro">
<a class="back" href={resolve('/stats/core/overview' as '/stats')}
><ArrowLeft size={14} /> countries</a
>
<span class="stats-eyebrow">stats / countries / {c}</span>
<h1><span class="flag">{FLAG[c]}</span> {name}</h1>
<p>
{#if c === 'CUW'}
Curaçao has been a country within the Kingdom of the Netherlands since 2010, with about
158,000 inhabitants. The IMF's World Economic Outlook doesn't cover it, so the headline
numbers come from the World Bank; the detail further down comes from CBS Curaçao (the island's
statistics office), Eurostat, ILOSTAT, Our World in Data and the UN. Amounts in guilders (ANG,
pegged at 1.79 to the US dollar). The central bank's balance-of-payments tables can't be
fetched automatically, so they aren't here yet.
{:else}
{name} at a glance, from the World Bank's development indicators and the IMF World Economic Outlook{imfCovered
? ' (dashed lines are IMF forecasts)'
: ''}.
{/if}
</p>
</section>
<div class="kpis">
<Kpi
label="Population"
value={compact(kPop?.[1])}
sub={` 10 yr · ${period(kPop?.[0], 'A')}`}
delta={popDelta}
deltaUnit="%"
{color}
/>
<Kpi
label="GDP per capita, PPP"
value={`$${compact(kGdp?.[1])}`}
sub={`constant 2021 int. $ · ${period(kGdp?.[0], 'A')}`}
color="#10b981"
/>
<Kpi
label="Real growth"
value={num(kGrowth?.[1], 1)}
unit="%"
sub={period(kGrowth?.[0], 'A')}
color="#3b82f6"
/>
<Kpi
label="Inflation"
value={num(kInfl?.[1], 1)}
unit="%"
sub={period(kInfl?.[0], 'A')}
color="#e11d48"
/>
{#if kUnemp}
<Kpi
label="Unemployment"
value={num(kUnemp[1], 1)}
unit="%"
sub={period(kUnemp[0], 'A')}
color="#a855f7"
/>
{/if}
{#if kDebt}
<Kpi
label="Government debt"
value={num(kDebt[1], 0)}
unit="% GDP"
sub={period(kDebt[0], 'A')}
color="#f97316"
/>
{/if}
<Kpi
label="Life expectancy"
value={num(kLife?.[1], 1)}
unit="yr"
sub={period(kLife?.[0], 'A')}
color="#06b6d4"
/>
{#if kTour}
<Kpi
label="Tourist arrivals"
value={compact(kTour[1])}
sub={period(kTour[0], 'A')}
color="#eab308"
/>
{/if}
</div>
<h2 class="stats-section-title">People</h2>
<div class="stats-grid">
<StatChart
title="Population"
subtitle="inhabitants · net migration on the right"
sources={['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Population', points: pts('wb:SP.POP.TOTL'), color, kind: 'area' },
{
name: 'Net migration',
points: pts('wb:SM.POP.NETM'),
color: '#64748b',
kind: 'bar',
right: true
}
],
{ decimals: 0, min: 'dataMin', rightUnit: 'people' }
)}
/>
<StatChart
title="Life expectancy"
subtitle="years at birth, next to the Netherlands and the world"
sources={['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name, points: pts('wb:SP.DYN.LE00.IN'), color },
...(c !== 'NLD'
? [
{
name: 'Netherlands',
points: pts('wb:SP.DYN.LE00.IN', 'NLD'),
color: '#f97316',
dashed: true
}
]
: []),
...(c !== 'WLD'
? [
{
name: 'World',
points: pts('wb:SP.DYN.LE00.IN', 'WLD'),
color: '#94a3b8',
dashed: true
}
]
: [])
],
{ unit: 'years', decimals: 1, min: 'dataMin' }
)}
/>
<StatChart
title="Fertility and ageing"
subtitle="births per woman · share aged 65+ on the right"
sources={['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Fertility rate', points: pts('wb:SP.DYN.TFRT.IN'), color: '#ec4899' },
{
name: 'Aged 65+ (%)',
points: pts('wb:SP.POP.65UP.TO.ZS'),
color: '#64748b',
right: true
}
],
{ unit: 'births', rightUnit: '%', decimals: 2 }
)}
/>
</div>
<h2 class="stats-section-title">Economy</h2>
<div class="stats-grid">
<StatChart
title="GDP per capita"
subtitle="PPP, constant 2021 international $ · vs the Netherlands and the world"
sources={['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name, points: pts('wb:NY.GDP.PCAP.PP.KD'), color },
...(c !== 'NLD'
? [
{
name: 'Netherlands',
points: pts('wb:NY.GDP.PCAP.PP.KD', 'NLD'),
color: '#f97316',
dashed: true
}
]
: []),
...(c !== 'WLD'
? [
{
name: 'World',
points: pts('wb:NY.GDP.PCAP.PP.KD', 'WLD'),
color: '#94a3b8',
dashed: true
}
]
: [])
],
{ unit: '$', decimals: 0 }
)}
/>
<StatChart
title="Real GDP growth"
subtitle={has('imf:NGDP_RPCH') ? '% a year · IMF WEO incl. forecasts' : '% a year · World Bank'}
sources={has('imf:NGDP_RPCH') ? ['imf'] : ['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
has('imf:NGDP_RPCH')
? imfLines('imf:NGDP_RPCH', 'Growth', '#3b82f6')
: [
{ name: 'Growth', points: pts('wb:NY.GDP.MKTP.KD.ZG'), color: '#3b82f6', kind: 'bar' }
],
{ unit: '%', decimals: 1, legend: has('imf:NGDP_RPCH') }
)}
/>
<StatChart
title="Inflation"
subtitle="% consumer prices, a year"
sources={has('imf:PCPIPCH') ? ['imf', 'worldbank'] : ['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
[
...(has('imf:PCPIPCH')
? imfLines('imf:PCPIPCH', name, '#e11d48')
: [{ name, points: pts('wb:FP.CPI.TOTL.ZG'), color: '#e11d48' }]),
...(c !== 'NLD'
? [
{
name: 'Netherlands',
points: pts('wb:FP.CPI.TOTL.ZG', 'NLD'),
color: '#f97316',
dashed: true
}
]
: [])
],
{ unit: '%', decimals: 1 }
)}
/>
{#if has('imf:LUR') || has('wb:SL.UEM.TOTL.ZS')}
<StatChart
title="Unemployment"
subtitle="% of the labour force"
sources={has('imf:LUR') ? ['imf'] : ['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
has('imf:LUR')
? imfLines('imf:LUR', 'Unemployment', '#a855f7')
: [{ name: 'Unemployment', points: pts('wb:SL.UEM.TOTL.ZS'), color: '#a855f7' }],
{ unit: '%', decimals: 1 }
)}
/>
{/if}
{#if has('wb:NE.EXP.GNFS.ZS') || has('wb:NE.TRD.GNFS.ZS')}
<StatChart
title="Trade"
subtitle="exports and imports of goods and services, % of GDP"
sources={['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Exports', points: pts('wb:NE.EXP.GNFS.ZS'), color: '#10b981' },
{ name: 'Imports', points: pts('wb:NE.IMP.GNFS.ZS'), color: '#e11d48' },
{
name: 'Services value added',
points: pts('wb:NV.SRV.TOTL.ZS'),
color: '#64748b',
dashed: true
}
],
{ unit: '% GDP', decimals: 1 }
)}
/>
{/if}
{#if has('wb:ST.INT.ARVL') || has('wb:ST.INT.RCPT.CD')}
<StatChart
title="Tourism"
subtitle="international arrivals · receipts (US$) on the right"
sources={['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Arrivals', points: pts('wb:ST.INT.ARVL'), color: '#eab308', kind: 'bar' },
{ name: 'Receipts', points: pts('wb:ST.INT.RCPT.CD'), color: '#06b6d4', right: true }
],
{ decimals: 0, rightUnit: 'US$' }
)}
/>
{/if}
</div>
<h2 class="stats-section-title">Government and energy</h2>
<div class="stats-grid">
{#if !isCw || has('imf:GGXWDG_NGDP') || has('wb:GC.DOD.TOTL.GD.ZS')}
<StatChart
title="Government finances"
subtitle={has('imf:GGXWDG_NGDP')
? '% of GDP · IMF (general government)'
: '% of GDP · World Bank (central government)'}
sources={has('imf:GGXWDG_NGDP') ? ['imf'] : ['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
has('imf:GGXWDG_NGDP')
? [
...imfLines('imf:GGXWDG_NGDP', 'Debt', '#e11d48'),
...imfLines('imf:GGR_G01_GDP_PT', 'Revenue', '#10b981'),
...imfLines('imf:G_X_G01_GDP_PT', 'Spending', '#3b82f6')
]
: [
{ name: 'Debt', points: pts('wb:GC.DOD.TOTL.GD.ZS'), color: '#e11d48' },
{ name: 'Revenue', points: pts('wb:GC.REV.XGRT.GD.ZS'), color: '#10b981' },
{ name: 'Expense', points: pts('wb:GC.XPN.TOTL.GD.ZS'), color: '#3b82f6' }
],
{ unit: '% GDP', decimals: 1 }
)}
note={!has('imf:GGXWDG_NGDP') && !has('wb:GC.DOD.TOTL.GD.ZS')
? 'No government finance data for this country yet.'
: ''}
/>
{/if}
<StatChart
title="Energy"
subtitle="net energy imports (% of use) · renewable share of electricity"
sources={['worldbank']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Net energy imports', points: pts('wb:EG.IMP.CONS.ZS'), color: '#64748b' },
{ name: 'Renewable electricity', points: pts('wb:EG.ELC.RNEW.ZS'), color: '#10b981' }
],
{ unit: '%', decimals: 1 }
)}
/>
</div>
{#if isCw}
<h2 class="stats-section-title">Curaçao in detail</h2>
<div class="kpis">
<Kpi
label="Population, 1 Jan"
value={compact(cwPop?.[1])}
sub={`CBS Curaçao · ${period(cwPop?.[0], 'A')}`}
{color}
/>
<Kpi
label="Inflation"
value={num(cwCpi?.[1], 1)}
unit="%"
sub={`year on year · ${period(cwCpi?.[0], 'M')}`}
color="#e11d48"
/>
<Kpi
label="Real growth"
value={num(cwGrowth?.[1], 1)}
unit="%"
sub={period(cwGrowth?.[0], 'A')}
color="#3b82f6"
/>
<Kpi
label="Unemployment"
value={num(cwUnemp?.[1], 1)}
unit="%"
sub={`labour force survey · ${period(cwUnemp?.[0], 'A')}`}
color="#a855f7"
/>
</div>
<div class="stats-grid">
<StatChart
title="Consumer prices since 1970"
subtitle="CPI (2023 = 100) · inflation year on year (%) on the right"
sources={['cbscw']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'CPI', points: cw('cw:cpi:index'), color: '#64748b', kind: 'area' },
{ name: 'Inflation', points: cw('cw:cpi:yoy'), color: '#e11d48', right: true }
],
{ unit: 'index', rightUnit: '%', decimals: 1, zoom: true }
)}
/>
<StatChart
title="GDP"
subtitle="nominal, million guilders · real growth (%) on the right"
sources={['cbscw']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'GDP', points: cw('cw:gdp:gdp'), color, kind: 'bar' },
{
name: 'Real growth',
points: cw('cw:gdp:real_growth_pct'),
color: '#3b82f6',
right: true
}
],
{ unit: 'mln ANG', rightUnit: '%', decimals: 1 }
)}
/>
<StatChart
title="GDP by industry"
subtitle={`gross value added, million guilders · ${period(cwGdpYear, 'A')}`}
sources={['cbscw']}
attributions={a}
height="420px"
option={(t) => barRankOption(t, cwIndustries, { decimals: 0, unit: 'mln ANG', color })}
/>
<StatChart
title="Population since 1900"
subtitle="1 January (31 December before 1992) and census counts · UN estimate dashed"
sources={['cbscw', 'owid']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Population', points: cw('cw:pop:jan1'), color, kind: 'area' },
{ name: 'Census', points: cw('cw:pop:census'), color: '#0f172a', kind: 'bar' },
{
name: 'UN WPP (via OWID)',
points: cw('owid:cw:population'),
color: '#94a3b8',
dashed: true
}
],
{ decimals: 0, unit: 'people' }
)}
/>
<StatChart
title="Unemployment"
subtitle="% of the labour force · CBS labour force survey (no survey in 2021 and 2023), ILO dashed"
sources={['cbscw', 'ilostat']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Total', points: cw('cw:lfs:unemployment_rate_pct:total'), color: '#a855f7' },
{ name: 'Men', points: cw('cw:lfs:unemployment_rate_pct:m'), color: '#3b82f6' },
{ name: 'Women', points: cw('cw:lfs:unemployment_rate_pct:f'), color: '#ec4899' },
{
name: 'Aged 15–24',
points: cw('cw:lfs:unemployment_rate_pct:15-24'),
color: '#f97316'
},
{
name: 'ILO estimate (15+)',
points: cw('ilostat:une_deap_sex_age_rt:total:15plus'),
color: '#94a3b8',
dashed: true
}
],
{ unit: '%', decimals: 1 }
)}
/>
<StatChart
title="Tourism"
subtitle="cruise passengers and daytrippers · stay-over nights on the right"
sources={['cbscw']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Cruise passengers',
points: cw('cw:tour:cruise_pax'),
color: '#06b6d4',
kind: 'bar'
},
{
name: 'Daytrippers',
points: cw('cw:tour:daytrippers'),
color: '#eab308',
kind: 'bar'
},
{
name: 'Stay-over nights',
points: cw('cw:tour:nights'),
color: '#e11d48',
right: true
}
],
{ decimals: 0, rightUnit: 'nights' }
)}
/>
<StatChart
title="Stay-over nights by market"
subtitle="tourist nights by region of residence"
sources={['cbscw']}
attributions={a}
option={(t) =>
lineOption(
t,
Object.entries(CW_NIGHT_MARKETS).map(([k, label], i) => ({
name: label,
points: cw(`cw:tour:nights:${k}`),
color: ['#3b82f6', '#e11d48', '#10b981', '#eab308', '#94a3b8'][i],
kind: 'bar' as const,
stack: 'nights'
})),
{ decimals: 0, unit: 'nights' }
)}
/>
<StatChart
title="Government revenue and expenditure"
subtitle="million guilders · balance as bars (recent years provisional)"
sources={['cbscw']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Revenue', points: cw('cw:gov:r.total'), color: '#10b981' },
{ name: 'Expenditure', points: cw('cw:gov:e.total'), color: '#3b82f6' },
{ name: 'Balance', points: cw('cw:gov:balance'), color: '#e11d48', kind: 'bar' }
],
{ unit: 'mln ANG', decimals: 0 }
)}
/>
<StatChart
title="Trade with the EU"
subtitle="EU27 goods exports to and imports from Curaçao, million €"
sources={['eurostat']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'EU exports to Curaçao',
points: cw(EU_TRADE('MIO_EXP_VAL')),
color: '#3b82f6'
},
{
name: 'EU imports from Curaçao',
points: cw(EU_TRADE('MIO_IMP_VAL')),
color: '#f97316'
},
{
name: 'EU balance',
points: cw(EU_TRADE('MIO_BAL_VAL')),
color: '#94a3b8',
kind: 'bar'
}
],
{ unit: 'mln €', decimals: 0 }
)}
/>
<StatChart
title="Goods trade (as reported by Curaçao)"
subtitle="US$ · only 2017–2020 are reported to the UN"
sources={['comtrade']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Imports',
points: cw('comtrade:cw:imports:world'),
color: '#e11d48',
kind: 'bar'
},
{
name: 'Exports',
points: cw('comtrade:cw:exports:world'),
color: '#10b981',
kind: 'bar'
},
{
name: 'Imports from the US',
points: cw('comtrade:cw:imports:usa'),
color: '#3b82f6'
},
{ name: 'Imports from NL', points: cw('comtrade:cw:imports:nld'), color: '#f97316' }
],
{ decimals: 0, unit: 'US$' }
)}
/>
<StatChart
title="Climate and energy"
subtitle="CO₂ per person (t) · renewable share of final energy (%) on the right"
sources={['owid', 'unsdg']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'CO₂ per person',
points: cw('owid:cw:co-emissions-per-capita'),
color: '#64748b'
},
{
name: 'Renewable share',
points: cw('unsdg:eg_fec_rnew'),
color: '#10b981',
right: true
}
],
{ unit: 't', rightUnit: '%', decimals: 1 }
)}
/>
</div>
{/if}
{#if data.heritage}
<PlaceHeritage h={data.heritage} attributions={a} />
{/if}
<style>
.back {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--site-muted);
font-size: 0.8rem;
text-decoration: none;
width: fit-content;
}
.back:hover {
color: var(--site-text);
}
.flag {
font-size: 0.9em;
}
</style>

View File

@@ -0,0 +1,592 @@
<script lang="ts">
import type { EconomyViewData } from '#lib/server/stats-views.js';
import type { StatsCommon } from '#lib/stats/matrix.js';
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import CountryPicker from '#lib/stats/CountryPicker.svelte';
import * as Tabs from '#lib/components/ui/tabs/index.js';
import { barRankOption, latest, lineOption, spread, type Line } from '#lib/stats/chart.js';
import { num, period } from '#lib/stats/format.js';
import { COUNTRY } from '#lib/stats/regions.js';
import { COUNTRY_COLOR } from '#lib/stats/countries.js';
import {
ANNUAL,
CORE7,
CPI_BY_CATEGORY,
CPI_CATS,
CPI_CAT_LABEL,
CPI_TOTAL,
POLICY_RATE,
rebase,
yoyByDate,
type CpiCat
} from '#lib/stats/econ.js';
let { data }: { data: EconomyViewData & StatsCommon } = $props();
const s = $derived(data.series);
const a = $derived(data.attributions);
const pts = (id: string) => s[id]?.points ?? [];
// Country selection shared by every multi-country chart.
let selected = $state<string[]>([...CORE7]);
let eu = $state(true);
const regions = $derived([...selected, ...(eu ? ['EU27'] : [])]);
const nameOf = (c: string) =>
c === 'EU27' ? 'EU 27' : c === 'EA' ? 'Euro area' : (COUNTRY[c] ?? c);
const colorOf = (c: string) => COUNTRY_COLOR[c] ?? '#64748b';
const missing = (list: string[]) =>
list.length ? `No data for ${list.map(nameOf).join(', ')}.` : '';
// CPI: index level rebased to a common year, or year-on-year %.
let cpiMode = $state<'level' | 'yoy'>('level');
let baseYear = $state(2015);
const BASE_YEARS = [2000, 2010, 2015, 2020, 2025];
const cpiLine = (id: string): [string, number][] | null => {
const p = pts(id);
if (!p.length) return null;
return cpiMode === 'yoy' ? yoyByDate(p) : rebase(p, baseYear);
};
const cpiLines = $derived.by(() => {
const lines: Line[] = [];
const gaps: string[] = [];
for (const c of regions) {
const src = CPI_TOTAL[c];
const points = src ? cpiLine(src.id) : null;
if (!points?.length) gaps.push(c);
else lines.push({ name: nameOf(c), points, color: colorOf(c), dashed: c === 'EU27' });
}
return { lines, gaps };
});
const cpiSources = $derived([
...new Set(regions.map((c) => CPI_TOTAL[c]?.source).filter(Boolean))
] as string[]);
let catCountry = $state('NLD');
const CAT_COLORS: Record<CpiCat, string> = {
total: '#64748b',
food: '#10b981',
housing: '#a855f7',
energy: '#f97316',
core: '#3b82f6'
};
const catLines = $derived.by(() => {
const def = CPI_BY_CATEGORY[catCountry];
return CPI_CATS.flatMap((k) => {
const id = def?.ids[k];
const points = id ? cpiLine(id) : null;
return points?.length
? [{ name: CPI_CAT_LABEL[k], points, color: CAT_COLORS[k], dashed: k === 'total' }]
: [];
});
});
const catMissing = $derived(
CPI_CATS.filter((k) => !CPI_BY_CATEGORY[catCountry]?.ids[k]).map((k) =>
CPI_CAT_LABEL[k].toLowerCase()
)
);
// Policy rates (BIS), euro members follow the ECB.
const rateLines = $derived.by(() => {
const seen: string[] = [];
const lines: Line[] = [];
const gaps: string[] = [];
for (const c of [...selected, ...(eu ? ['EA'] : [])]) {
const area = POLICY_RATE[c] ?? (['DEU', 'FRA', 'BEL'].includes(c) ? 'XM' : undefined);
const p = area ? pts(`bis:M:${area}`) : [];
if (!p.length) gaps.push(c);
else if (!seen.includes(area!)) {
seen.push(area!);
lines.push({
name: area === 'XM' ? 'ECB (euro area)' : nameOf(c),
points: p,
color: area === 'XM' ? '#1d4ed8' : colorOf(c),
kind: 'step'
});
}
}
return { lines, gaps };
});
const yieldLines = $derived.by(() => {
const lines: Line[] = [];
const gaps: string[] = [];
for (const c of [...selected, ...(eu ? ['EA'] : [])]) {
const p = pts(`oecd:irlt:${c}`);
if (!p.length) gaps.push(c);
else lines.push({ name: nameOf(c), points: p, color: colorOf(c), dashed: c === 'EA' });
}
return { lines, gaps };
});
// Annual indicators: IMF WEO (outcomes only, forecasts cut), World Bank where the IMF has none.
const annual = (key: keyof typeof ANNUAL) => {
const ind = ANNUAL[key];
const lines: Line[] = [];
const gaps: string[] = [];
const used: string[] = [];
for (const c of regions) {
const imf = ind.imf ? s[`imf:${ind.imf}:${c}`] : undefined;
const wb = s[`wb:${ind.wb}:${c}`];
const cw = c === 'CUW' && ind.cw ? s[ind.cw] : undefined;
const pick = imf?.points.length
? imf
: cw?.points.length
? cw
: wb?.points.length
? wb
: undefined;
if (!pick) {
gaps.push(c);
continue;
}
if (!used.includes(pick.sourceId)) used.push(pick.sourceId);
const cut = pick.forecastFrom ? `${pick.forecastFrom}-01-01` : '9999';
lines.push({
name: nameOf(c),
points: pick.points.filter(([d]) => d < cut),
color: colorOf(c),
dashed: c === 'EU27'
});
}
return { lines, gaps, sources: used };
};
const growth = $derived(annual('growth'));
const unemployment = $derived(annual('unemployment'));
const govDebt = $derived(annual('debt'));
const trade = $derived(annual('trade'));
// Europe-only charts (Eurostat).
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 boc = $derived(k('bis:M:CA'));
const boj = $derived(k('bis:M:JP'));
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(yoyByDate(pts('fred:CPIAUCSL'))));
const caInfl = $derived(latest(yoyByDate(pts('statcan:cpi:total'))));
</script>
<section class="stats-intro">
<span class="stats-eyebrow">stats / economy</span>
<h1>Economy</h1>
<p>
Consumer prices, central bank rates, bond yields, growth, jobs, debt and trade for Canada, South
Korea, China, Japan, the US, the Netherlands and Curaçao, with the EU as a comparison line. Add
other countries with the picker. Refreshed from open sources (CBS, BLS, Statistics Canada, CBS
Curaçao, BIS, OECD, IMF, World Bank, Eurostat).
</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 Canada"
value={num(boc?.[1], 2)}
unit="%"
sub={period(boc?.[0])}
color="#dc2626"
/>
<Kpi
label="Bank of Japan"
value={num(boj?.[1], 2)}
unit="%"
sub={period(boj?.[0])}
color="#ec4899"
/>
<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="Canada inflation (CPI)"
value={num(caInfl?.[1])}
unit="%"
sub={period(caInfl?.[0])}
color="#dc2626"
/>
</div>
<CountryPicker bind:selected bind:eu />
<h2 class="stats-section-title">Prices and rates</h2>
<div class="stats-grid">
<StatChart
title="Consumer prices"
subtitle={cpiMode === 'level'
? `index, ${baseYear} average = 100 · monthly`
: '% change on the same month a year earlier'}
sources={cpiSources}
attributions={a}
height="400px"
wide
note={`National CPI for the US (BLS CPI-U), Canada (Statistics Canada) and Curaçao (CBS Curaçao); BIS long series (national CPIs) for the Netherlands, Japan, South Korea, China and the added countries; EU 27 is the HICP. Each source keeps its own base year; the chart divides by the ${baseYear} average.${cpiMode === 'level' ? ' Series that do not cover the base year are left out.' : ''} ${missing(cpiLines.gaps)}`}
option={(t) =>
lineOption(t, cpiLines.lines, { unit: cpiMode === 'level' ? 'index' : '%', zoom: true })}
>
<div class="controls">
<Tabs.Root bind:value={cpiMode}>
<Tabs.List>
<Tabs.Trigger value="level">index level</Tabs.Trigger>
<Tabs.Trigger value="yoy">year on year %</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
{#if cpiMode === 'level'}
<label class="select">
base year
<select bind:value={baseYear}>
{#each BASE_YEARS as yr (yr)}<option value={yr}>{yr}</option>{/each}
</select>
</label>
{/if}
</div>
</StatChart>
<StatChart
title={`Consumer prices by category: ${nameOf(catCountry)}`}
subtitle={cpiMode === 'level'
? `index, ${baseYear} average = 100 · monthly`
: '% change on the same month a year earlier'}
sources={[CPI_BY_CATEGORY[catCountry]?.source ?? 'eurostat']}
attributions={a}
height="380px"
wide
note={`${CPI_BY_CATEGORY[catCountry]?.note ?? ''}.${catMissing.length ? ` Not published here: ${catMissing.join(', ')}.` : ''} Category definitions differ a little per source (Canada's housing is shelter; the HICP and OECD use COICOP 04, housing including utilities).`}
option={(t) =>
lineOption(t, catLines, { unit: cpiMode === 'level' ? 'index' : '%', zoom: true })}
>
<div class="controls">
<label class="select">
country
<select bind:value={catCountry}>
{#each Object.keys(CPI_BY_CATEGORY) as c (c)}<option value={c}>{nameOf(c)}</option>{/each}
</select>
</label>
<span class="hint">uses the index / year-on-year switch and base year above</span>
</div>
</StatChart>
<StatChart
title="Central bank policy rates"
subtitle="% · monthly · BIS"
sources={['bis']}
attributions={a}
note={`Euro members follow the ECB. ${missing(rateLines.gaps)}${rateLines.gaps.includes('CUW') ? ' Curaçao: the CBCS pegs the guilder to the US dollar and is not in the BIS policy rate set.' : ''}`}
option={(t) => lineOption(t, rateLines.lines, { unit: '%', zoom: true })}
/>
<StatChart
title="10-year government bond yields"
subtitle="% · monthly · OECD long-term interest rates"
sources={['oecd']}
attributions={a}
note={missing(yieldLines.gaps)}
option={(t) => lineOption(t, yieldLines.lines, { unit: '%', zoom: true })}
/>
</div>
<h2 class="stats-section-title">Growth, jobs and debt</h2>
<div class="stats-grid">
<StatChart
title="Real GDP growth"
subtitle="% change on the previous year · outcomes only, no forecasts"
sources={growth.sources}
attributions={a}
note={`IMF World Economic Outlook; CBS Curaçao (real growth, 2011→) for Curaçao. ${missing(growth.gaps)}`}
option={(t) => lineOption(t, growth.lines, { unit: '%', zoom: true })}
/>
<StatChart
title="Unemployment rate"
subtitle="% of the labour force · annual"
sources={unemployment.sources}
attributions={a}
note={`IMF WEO; CBS Curaçao labour force survey (2014→) for Curaçao. ${missing(unemployment.gaps)}`}
option={(t) => lineOption(t, unemployment.lines, { unit: '%', zoom: true })}
/>
<StatChart
title="Government debt"
subtitle="general government gross debt, % of GDP · annual"
sources={govDebt.sources}
attributions={a}
note={`IMF WEO; World Bank central government debt where the IMF has none. ${missing(govDebt.gaps)}`}
option={(t) => lineOption(t, govDebt.lines, { unit: '% of GDP', zoom: true })}
/>
<StatChart
title="Trade openness"
subtitle="exports + imports of goods and services, % of GDP · annual"
sources={trade.sources}
attributions={a}
note={missing(trade.gaps)}
option={(t) => lineOption(t, trade.lines, { unit: '% of GDP', zoom: true })}
/>
</div>
<h2 class="stats-section-title">Bond and stock markets</h2>
<div class="stats-grid">
<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">United States</h2>
<div class="stats-grid">
<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">Europe: 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). Spreads use Eurostat's EMU
convergence-criterion yields. The country picker drives the charts in the first two sections; the
European and US sections keep their own fixed sets.
</p>
<style>
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.select {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.select select {
border: 1px solid var(--site-border);
border-radius: 0.5rem;
background: var(--site-bg);
color: var(--site-text);
padding: 0.2rem 0.4rem;
}
.hint {
font-size: 0.75rem;
color: var(--site-muted);
}
</style>

View File

@@ -0,0 +1,446 @@
<script lang="ts">
import type { ElectionsViewData } from '#lib/server/stats-views.js';
import type { StatsCommon } from '#lib/stats/matrix.js';
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import { barRankOption, lineOption, type StatPoint } from '#lib/stats/chart.js';
import { compact, num } from '#lib/stats/format.js';
import { FAMILIES, familyOf, PARTY_COLORS, tally } from '#lib/stats/parties.js';
let {
data,
focus = 'all'
}: { data: ElectionsViewData & StatsCommon; focus?: 'all' | 'nld' | 'gouda' | 'usa' } = $props();
const show = (f: 'nld' | 'gouda' | 'usa') => focus === 'all' || focus === f;
const a = $derived(data.attributions);
const year = (d: string) => d.slice(0, 4);
const share = (v: number | null, total: number) =>
v == null || !total ? 0 : Math.round((v / total) * 1000) / 10;
// ---- Netherlands, national ------------------------------------------------------------
const nlElections = $derived(
[...new Set(data.nl.map((r) => r.election))]
.map((id) => {
const rows = data.nl.filter((r) => r.election === id);
const { parties, total } = tally(rows);
return { id, date: rows[0].date, total, parties };
})
.sort((x, y) => x.date.localeCompare(y.date))
);
const familyLines = $derived([
...FAMILIES.map((f) => ({
name: f.name,
color: f.color,
kind: 'bar' as const,
stack: 'f',
points: nlElections.map(
(e) =>
[
year(e.date),
share(
e.parties
.filter((p) => familyOf(p.party) === f.name)
.reduce((s, p) => s + (p.votes ?? 0), 0),
e.total
)
] as StatPoint
)
})),
{
name: 'Other',
color: '#cbd5e1',
kind: 'bar' as const,
stack: 'f',
points: nlElections.map(
(e) =>
[
year(e.date),
share(
e.parties
.filter((p) => familyOf(p.party) === 'Other')
.reduce((s, p) => s + (p.votes ?? 0), 0),
e.total
)
] as StatPoint
)
}
]);
const turnout = $derived(
data.turnout
.filter((t) => t.election.startsWith('NL-') && t.ballots && t.electorate)
.map((t) => [year(t.date), share(t.ballots, t.electorate!)] as StatPoint)
);
const recent = $derived(nlElections.filter((e) => e.date >= '2010'));
const latestNl = $derived(recent.at(-1));
const seatParties = $derived(
[...new Set(recent.flatMap((e) => e.parties.filter((p) => p.seats).map((p) => p.party)))].sort(
(x, y) =>
(latestNl?.parties.find((p) => p.party === y)?.seats ?? 0) -
(latestNl?.parties.find((p) => p.party === x)?.seats ?? 0) || x.localeCompare(y)
)
);
const seatsOf = (e: (typeof recent)[number], p: string) =>
e.parties.find((x) => x.party === p)?.seats ?? null;
const biggest = $derived(
latestNl ? [...latestNl.parties].sort((x, y) => (y.seats ?? 0) - (x.seats ?? 0))[0] : null
);
const latestTurnout = $derived(data.turnout.filter((t) => t.election.startsWith('NL-')).at(-1));
// ---- Gouda ------------------------------------------------------------------------------
const goudaBy = $derived(
[...new Set(data.gouda.map((r) => r.election))].sort().map((id) => {
const rows = data.gouda.filter((r) => r.election === id);
const { votes, total } = tally(rows);
return { id, date: rows[0].date, total, votes };
})
);
const goudaLatest = $derived(goudaBy.at(-1));
const nlForGouda = $derived(nlElections.find((e) => e.id === goudaLatest?.id));
const goudaTop = $derived(
goudaLatest
? [...goudaLatest.votes.entries()]
.sort((x, y) => y[1] - x[1])
.slice(0, 12)
.map(([p]) => p)
: []
);
const goudaVsNl = $derived([
{
name: 'Gouda',
color: '#f97316',
kind: 'bar' as const,
points: goudaTop.map(
(p) => [p, share(goudaLatest?.votes.get(p) ?? 0, goudaLatest?.total ?? 0)] as StatPoint
)
},
{
name: 'Netherlands',
color: '#0f172a',
kind: 'bar' as const,
points: goudaTop.map(
(p) =>
[
p,
share(
nlForGouda?.parties.find((x) => x.party === p)?.votes ?? 0,
nlForGouda?.total ?? 0
)
] as StatPoint
)
}
]);
const goudaTrend = $derived(
goudaTop.slice(0, 8).map((p) => ({
name: p,
color: PARTY_COLORS[p],
points: goudaBy
.filter((e) => e.votes.get(p))
.map((e) => [year(e.date), share(e.votes.get(p) ?? 0, e.total)] as StatPoint)
}))
);
// Polling stations, latest election.
const stations = $derived(
[...new Set(data.stations.map((s) => s.code))].map((code) => {
const rows = data.stations.filter((s) => s.code === code);
const { votes: m, total } = tally(rows);
const top = [...m.entries()].sort((x, y) => y[1] - x[1])[0];
return {
code,
name: rows[0].name
.replace(/^(Stembureau\s+)+/i, '')
.replace(/\s*\(postcode:\s*([^)]*)\)/i, ' · $1'),
total,
top: top?.[0] ?? '',
shares: m
};
})
);
const stationCols = $derived(goudaTop.slice(0, 6));
const stationRows = $derived(
[...stations].sort((x, y) => x.code.localeCompare(y.code, 'en', { numeric: true }))
);
const stationBars = (p: string) =>
stations
.filter((s) => s.total >= 100)
.map((s) => ({
label: s.name.split(' · ')[0].slice(0, 34),
value: share(s.shares.get(p) ?? 0, s.total)
}));
const goudaWinner = $derived(goudaTop[0]);
// ---- United States ----------------------------------------------------------------------
const usBy = $derived(
[...new Set(data.us.map((r) => r.election))].sort().map((id) => {
const rows = data.us.filter((r) => r.election === id);
const total = rows.reduce((s, r) => s + (r.votes ?? 0), 0);
const v = (p: string) => rows.find((r) => r.party === p)?.votes ?? 0;
return {
id,
date: rows[0].date,
total,
dem: share(v('DEMOCRAT'), total),
rep: share(v('REPUBLICAN'), total)
};
})
);
const usLatest = $derived(usBy.at(-1));
const states = $derived(
[...new Set(data.usStates.map((s) => s.code))]
.map((code) => {
const rows = data.usStates.filter((s) => s.code === code);
const total = rows.reduce((s, r) => s + (r.votes ?? 0), 0);
const v = (p: string) =>
rows.filter((r) => r.party === p).reduce((s, r) => s + (r.votes ?? 0), 0);
const dem = share(v('DEMOCRAT'), total);
const rep = share(v('REPUBLICAN'), total);
const name = rows[0].name.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
return { code, name, total, dem, rep, margin: Math.round((rep - dem) * 10) / 10 };
})
.sort((x, y) => x.margin - y.margin)
);
</script>
<section class="stats-intro">
<span class="stats-eyebrow">stats / elections</span>
<h1>Elections</h1>
<p>
Every Tweede Kamer election since universal suffrage in 1918, how Gouda votes down to the
polling station, and the US presidential vote by state.
</p>
</section>
<div class="kpis">
{#if show('nld')}
<Kpi
label={`Largest party, TK ${latestNl ? year(latestNl.date) : ''}`}
value={biggest?.party ?? '–'}
sub={`${biggest?.seats ?? '–'} of 150 seats · ${num(share(biggest?.votes ?? 0, latestNl?.total ?? 0), 1)}% of the vote`}
color="#06b6d4"
/>
{/if}
{#if show('nld')}
<Kpi
label="Turnout"
value={num(
latestTurnout?.ballots && latestTurnout.electorate
? (latestTurnout.ballots / latestTurnout.electorate) * 100
: null,
1
)}
unit="%"
sub={`${compact(latestTurnout?.ballots)} ballots · ${latestTurnout ? year(latestTurnout.date) : ''}`}
color="#3b82f6"
/>
{/if}
{#if show('gouda')}
<Kpi
label="Gouda's largest party"
value={goudaWinner ?? '–'}
sub={`${num(share(goudaLatest?.votes.get(goudaWinner ?? '') ?? 0, goudaLatest?.total ?? 0), 1)}% · ${stations.length} polling stations`}
color="#f97316"
/>
{/if}
{#if show('usa')}
<Kpi
label={`US popular vote ${usLatest ? year(usLatest.date) : ''}`}
value={usLatest
? usLatest.rep > usLatest.dem
? `R +${num(usLatest.rep - usLatest.dem, 1)}`
: `D +${num(usLatest.dem - usLatest.rep, 1)}`
: '–'}
sub={`R ${num(usLatest?.rep, 1)}% · D ${num(usLatest?.dem, 1)}%`}
color="#e11d48"
/>
{/if}
</div>
{#if show('nld')}
<h2 class="stats-section-title">The Netherlands</h2>
<div class="stats-grid">
<StatChart
title="A century of Dutch elections by party family"
subtitle="% of valid votes, Tweede Kamer"
sources={['cbs', 'kiesraad']}
attributions={a}
wide
height="420px"
note="Christian democrats: ARP, CHU, RKSP/KVP and from 1977 the CDA. Labour & green left includes the joint GL-PvdA list (2023, 2025). Populist & radical right: CD, LPF, PVV, FvD, JA21 and BBB. CBS publishes shares to one decimal for 1918–2006; the Kiesraad counts are used from 2010."
option={(t) => lineOption(t, familyLines, { category: true, unit: '%', decimals: 1, min: 0 })}
/>
<StatChart
title="Turnout"
subtitle="% of the electorate that cast a ballot"
sources={['cbs', 'kiesraad']}
attributions={a}
note="Voting was compulsory until 1970; turnout fell from 95% in 1967 to 79% in 1971."
option={(t) =>
lineOption(t, [{ name: 'Turnout', points: turnout, color: '#3b82f6', kind: 'area' }], {
category: true,
unit: '%',
decimals: 1,
legend: false,
min: 50
})}
/>
<StatChart
title={`Seats, TK ${latestNl ? year(latestNl.date) : ''}`}
subtitle="of 150"
sources={['kiesraad']}
attributions={a}
option={(t) =>
barRankOption(
t,
(latestNl?.parties ?? [])
.filter((p) => p.seats)
.map((p) => ({ label: p.party, value: p.seats ?? 0 })),
{ decimals: 0, color: '#06b6d4' }
)}
/>
</div>
<div class="table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Seats</th>
{#each recent as e (e.id)}<th class="num">{year(e.date)}</th>{/each}
</tr>
</thead>
<tbody>
{#each seatParties as p (p)}
<tr>
<td>
<span class="dot" style:background={PARTY_COLORS[p] ?? '#94a3b8'}></span>{p}
</td>
{#each recent as e (e.id)}<td class="num">{seatsOf(e, p) ?? ''}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if show('gouda')}
<h2 class="stats-section-title">Gouda</h2>
{#if goudaLatest}
<div class="stats-grid">
<StatChart
title={`Gouda vs the Netherlands, TK ${year(goudaLatest.date)}`}
subtitle="% of valid votes, largest parties in Gouda"
sources={['kiesraad']}
attributions={a}
option={(t) => lineOption(t, goudaVsNl, { category: true, unit: '%', decimals: 1 })}
/>
<StatChart
title="Gouda's vote since 2010"
subtitle="% of valid votes"
sources={['kiesraad']}
attributions={a}
option={(t) => lineOption(t, goudaTrend, { category: true, unit: '%', decimals: 1 })}
/>
{#each stationCols.slice(0, 2) as p (p)}
<StatChart
title={`${p} by polling station`}
subtitle={`% of valid votes · TK ${year(goudaLatest.date)} · stations with 100+ votes`}
sources={['kiesraad']}
attributions={a}
height="760px"
option={(t) =>
barRankOption(t, stationBars(p), {
decimals: 1,
unit: '%',
color: PARTY_COLORS[p] ?? '#3b82f6'
})}
/>
{/each}
</div>
<div class="table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Polling station, TK {year(goudaLatest.date)}</th>
<th class="num">Votes</th>
<th>Largest</th>
{#each stationCols as p (p)}<th class="num">{p} %</th>{/each}
</tr>
</thead>
<tbody>
{#each stationRows as s (s.code)}
<tr>
<td>{s.name}</td>
<td class="num">{num(s.total, 0)}</td>
<td>{s.top}</td>
{#each stationCols as p (p)}<td class="num"
>{num(share(s.shares.get(p) ?? 0, s.total), 1)}</td
>{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="stats-note">
{a.kiesraad ?? ''} · Postal and mobile stations have few votes; votes are counted where they are
cast, not where voters live.
</p>
{:else}
<p class="empty">Gouda's results are not loaded yet.</p>
{/if}
{/if}
{#if show('usa')}
<h2 class="stats-section-title">United States</h2>
<div class="stats-grid">
<StatChart
title="US presidential popular vote"
subtitle="% of all votes cast"
sources={['mitelection']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Democrat',
color: '#2563eb',
points: usBy.map((e) => [year(e.date), e.dem] as StatPoint)
},
{
name: 'Republican',
color: '#dc2626',
points: usBy.map((e) => [year(e.date), e.rep] as StatPoint)
}
],
{ category: true, unit: '%', decimals: 1, min: 30 }
)}
/>
<StatChart
title={`Margin by state, ${usLatest ? year(usLatest.date) : ''}`}
subtitle="Republican minus Democratic share, percentage points"
sources={['mitelection']}
attributions={a}
height="900px"
note="Positive: Republican lead; negative: Democratic lead."
option={(t) =>
barRankOption(
t,
states.map((s) => ({
label: s.name,
value: s.margin,
highlight: Math.abs(s.margin) < 3
})),
{ decimals: 1, unit: 'pp', color: '#64748b' }
)}
/>
</div>
{/if}
<style>
.dot {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 999px;
margin-right: 0.4rem;
}
</style>

View File

@@ -0,0 +1,627 @@
<script lang="ts">
import type { EnergyViewData } from '#lib/server/stats-views.js';
import type { StatsCommon } from '#lib/stats/matrix.js';
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 {
aOrBetter,
epHomesId,
epShareId,
EP_LABELS,
labelStackOption
} from '#lib/stats/energylabels.js';
import EnergyLive from '#lib/stats/EnergyLive.svelte';
let { data }: { data: EnergyViewData & StatsCommon } = $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: share of homes per energy label, latest monthly snapshot.
const epShares = (area: string) => EP_LABELS.map((l) => last(epShareId(l, area))?.[1] ?? 0);
const epHomes = $derived(last(epHomesId('NL')));
const epAorBetter = $derived(aOrBetter(pts, 'NL'));
const epGoudaAorBetter = $derived(aOrBetter(pts, 'GOUDA'));
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'))
);
/** Current band, with the pre-2007 band (old methodology) stitched in before it starts. */
function stitch(now: StatPoint[], old: StatPoint[]): StatPoint[] {
const start = now[0]?.[0] ?? 9999;
return [...old.filter(([d]) => d < start), ...now];
}
const elecPrice = (g: string) =>
stitch(
pts(`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.I_TAX.EUR.${g}`),
pts(`eurostat:nrg_pc_204:E7000.KWH3500.KWH.I_TAX.EUR.${g}`)
);
const gasPrice = (g: string) =>
stitch(
pts(`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.I_TAX.EUR.${g}`),
pts(`eurostat:nrg_pc_202:G3000.GJ83P70.GJ_GCV.I_TAX.EUR.${g}`).map(
([d, v]) => [d, Math.round((v / 277.778) * 10000) / 10000] as StatPoint
)
);
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>
<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>
<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>
<EnergyLive energy={data.energy} attributions={a} />
<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 (supply only)',
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 € (2,500–5,000 kWh; 3,500 kWh band before 2007), 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>
{#if epHomes}
<h2 class="stats-section-title">Energy labels of homes</h2>
<div class="stats-grid">
<StatChart
title="Homes by energy label"
subtitle={`% of homes with a registered label · ${num(epHomes[1], 0)} homes in NL · ${period(epHomes[0], 'M')}`}
sources={['eponline']}
attributions={a}
wide
height="220px"
note="Latest registered label per home (BAG dwelling) in EP-Online's monthly total file; labels on building-permit designs are left out. Gouda = postcodes 2800–2809."
option={(t) =>
labelStackOption(t, [
{ name: 'Netherlands', shares: epShares('NL') },
{ name: 'Gouda', shares: epShares('GOUDA') }
])}
/>
{#if epAorBetter.length > 1}
<StatChart
title="Homes labelled A or better"
subtitle="% of homes with a registered label, monthly"
sources={['eponline']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'Netherlands', points: epAorBetter, color: '#0f172a', dashed: true },
{ name: 'Gouda', points: epGoudaAorBetter, color: '#f97316' }
],
{ unit: '%', decimals: 1 }
)}
/>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,420 @@
<script lang="ts">
import type { GoudaViewData } from '#lib/server/stats-views.js';
import type { StatsCommon } from '#lib/stats/matrix.js';
import StatChart from '#lib/stats/StatChart.svelte';
import PlaceHeritage from '#lib/stats/PlaceHeritage.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 }: { data: GoudaViewData & StatsCommon } = $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>
<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}
<PlaceHeritage h={data.heritage} attributions={a} />
<style>
tr.total td {
font-weight: 600;
border-top: 1px solid var(--site-border);
}
</style>

View File

@@ -0,0 +1,197 @@
<script lang="ts">
import type { HistoryViewData } from '#lib/server/stats-views.js';
import type { StatsCommon } from '#lib/stats/matrix.js';
import { resolve } from '$app/paths';
import StatChart from '#lib/stats/StatChart.svelte';
import EventTimeline from '#lib/stats/EventTimeline.svelte';
import { barRankOption, lineOption, type Line, type StatPoint } from '#lib/stats/chart.js';
import { HERITAGE, indexTo } from '#lib/stats/heritage.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
let { data }: { data: HistoryViewData & StatsCommon } = $props();
const s = $derived(data.series);
const a = $derived(data.attributions);
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
const COUNTRIES = ['NLD', 'USA', 'KOR'] as const;
const WORLD = { name: 'World', color: '#94a3b8', dashed: true };
const lines = (prefix: string, withCw = false, cwId = ''): Line[] => [
...COUNTRIES.map((c) => ({
name: HERITAGE[c].name,
points: pts(`${prefix}:${c}`),
color: HERITAGE[c].color
})),
...(withCw
? [{ name: 'Curaçao', points: pts(cwId || `${prefix}:CUW`), color: HERITAGE.CUW.color }]
: []),
{ ...WORLD, points: pts(`${prefix}:WLD`) }
];
const popIndex = $derived([
...COUNTRIES.map((c) => ({
name: HERITAGE[c].name,
points: indexTo(pts(`owid:hist:population:${c}`), '1950'),
color: HERITAGE[c].color
})),
{
name: 'Curaçao',
points: indexTo(pts('owid:cw:population'), '1950'),
color: HERITAGE.CUW.color
},
{
name: 'Gouda',
points: indexTo(pts('cbs:pop:jan1:GM0513'), '1950'),
color: HERITAGE.GM0513.color
},
{ ...WORLD, points: indexTo(pts('owid:hist:population:WLD'), '1950') }
]);
const unaffiliated = $derived(
(['NLD', 'USA', 'KOR', 'CUW', 'WLD'] as const)
.map((c) => ({
label: c === 'WLD' ? 'World' : HERITAGE[c].name,
value:
pts(`pew:religion:religiously_unaffiliated:${c}`).find(([d]) =>
d.startsWith('2020')
)?.[1] ?? NaN,
highlight: c === 'NLD'
}))
.filter((r) => Number.isFinite(r.value))
);
const places = Object.values(HERITAGE);
</script>
<section class="stats-intro">
<span class="stats-eyebrow">stats / history</span>
<h1>History and culture</h1>
<p>
Five places over the long run: the Netherlands, Gouda, Curaçao, the United States and South
Korea. Long series come from the Maddison Project, HYDE and the UN via Our World in Data, CBS
and Pew; the timeline is curated, and every event links to its source. Each place's own page has
its office holders, holidays, UNESCO sites, well-known people and food.
</p>
</section>
<div class="places">
{#each places as p (p.code)}
<a class="place" href={`${resolve(p.href as '/stats')}#history`} style:--c={p.color}>
<strong>{p.name}</strong>
<span>history, politics and culture <ArrowRight size={13} /></span>
</a>
{/each}
</div>
<h2 class="stats-section-title">The long run</h2>
<div class="stats-grid">
<StatChart
title="GDP per person, long run"
subtitle="Maddison Project Database 2023 · international $ at 2011 prices"
sources={['owid']}
attributions={a}
note="Curaçao is not in the Maddison database."
option={(t) => lineOption(t, lines('owid:maddison'), { unit: '$', decimals: 0, zoom: true })}
/>
<StatChart
title="Life expectancy, long run"
subtitle="years at birth · Riley, Zijdeman et al., HMD and UN WPP via Our World in Data"
sources={['owid']}
attributions={a}
option={(t) =>
lineOption(t, lines('owid:hist:life-expectancy', true, 'owid:cw:life-expectancy'), {
unit: 'years',
decimals: 1
})}
/>
<StatChart
title="Urbanisation since 1500"
subtitle="% living in urban areas · HYDE 2023 via Our World in Data"
sources={['owid']}
attributions={a}
option={(t) => lineOption(t, lines('owid:hist:urban-long'), { unit: '%', decimals: 1 })}
/>
<StatChart
title="Population growth since 1950"
subtitle="index, 1950 = 100 · OWID (UN WPP), CBS for Gouda"
sources={['owid', 'cbs']}
attributions={a}
option={(t) => lineOption(t, popIndex, { decimals: 0 })}
/>
<StatChart
title="Population since 1500"
subtitle="inhabitants · HYDE, Gapminder and UN via Our World in Data"
sources={['owid']}
attributions={a}
option={(t) =>
lineOption(
t,
COUNTRIES.map((c) => ({
name: HERITAGE[c].name,
points: pts(`owid:hist:population:${c}`),
color: HERITAGE[c].color
})),
{ decimals: 0 }
)}
/>
<StatChart
title="No religion, 2020"
subtitle="% of the population religiously unaffiliated · Pew Research Center"
sources={['pew']}
attributions={a}
height="240px"
option={(t) => barRankOption(t, unaffiliated, { unit: '%', decimals: 1, color: '#64748b' })}
/>
<StatChart
title="The Netherlands loses its religion"
subtitle="% of adults without religious affiliation, and Catholics · CBS since 1849"
sources={['cbs']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{ name: 'No religion', points: pts('cbs:religion:37944:none:NL01'), color: '#64748b' },
{
name: 'Roman Catholic',
points: pts('cbs:religion:37944:catholic:NL01'),
color: '#e11d48'
},
{
name: 'Dutch Reformed',
points: pts('cbs:religion:37944:hervormd:NL01'),
color: '#f97316'
}
],
{ unit: '%', decimals: 1 }
)}
/>
</div>
<h2 class="stats-section-title">Timeline</h2>
<EventTimeline events={data.events} showPlaces />
<p class="stats-note">
{data.events.length} curated events, each summarised from and linked to its source (mostly Wikipedia,
CC BY-SA). Click an event for the summary and source; filter by theme or place.
</p>
<style>
.places {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 190px), 1fr));
gap: 0.75rem;
}
.place {
border: 1px solid var(--site-border);
border-left: 4px solid var(--c);
border-radius: 0.75rem;
padding: 0.7rem 0.9rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
text-decoration: none;
color: inherit;
}
.place span {
color: var(--site-muted);
font-size: 0.78rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
</style>

View File

@@ -0,0 +1,606 @@
<script lang="ts">
import type { OpinionsViewData } from '#lib/server/stats-views.js';
import type { StatsCommon } from '#lib/stats/matrix.js';
import { resolve } from '$app/paths';
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import { barRankOption, lineOption, type ChartTheme, type StatPoint } from '#lib/stats/chart.js';
import { num, period } from '#lib/stats/format.js';
import { PARTY_COLORS } from '#lib/stats/parties.js';
import {
chesParties,
ebLatest,
ebTrend,
gdeltAverages,
GDELT_THEMES,
vpartyLines
} from '#lib/stats/opinions.js';
import type { EChartsCoreOption } from 'echarts/core';
let { data }: { data: OpinionsViewData & StatsCommon } = $props();
const a = $derived(data.attributions);
const day = (d: string | undefined) =>
d
? new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
: '';
// ---- Polls ------------------------------------------------------------------------------
const nl = $derived(
data.nl
.filter((o) => o.option !== 'Others')
.sort((x, y) => (y.last?.[1] ?? 0) - (x.last?.[1] ?? 0))
);
const nlLatest = $derived(data.nlLatest);
const nlCols = $derived(nl.map((o) => o.option));
const us = $derived(data.us);
const usApprove = $derived(us.find((o) => o.option === 'Approve'));
const usDisapprove = $derived(us.find((o) => o.option === 'Disapprove'));
const usAgg = $derived(data.usAgg);
const kr = $derived(data.kr);
const krApprove = $derived(kr.find((o) => o.option === 'Approve'));
const krParty = $derived(
data.krParty.filter((o) => o.n > 50).sort((x, y) => (y.last?.[1] ?? 0) - (x.last?.[1] ?? 0))
);
const KR_COLORS: Record<string, string> = {
DPK: '#2563eb',
PPP: '#e11d48',
RKP: '#f97316',
RP: '#eab308',
PP: '#0ea5e9',
'Und./no ans.': '#94a3b8',
Others: '#cbd5e1'
};
// ---- Eurobarometer ----------------------------------------------------------------------
const miiCountry = $derived(ebLatest(data.eb, 'mii_country'));
const miiPersonal = $derived(ebLatest(data.eb, 'mii_personal'));
const trust = $derived(ebLatest(data.eb, 'trust'));
const trustEu = $derived(ebLatest(data.eb, 'trust_eu'));
const topIssues = $derived(
miiCountry.items.filter((i) => !/^(None|Other|Don)/.test(i.item)).slice(0, 6)
);
const issueTrend = $derived(
ebTrend(
data.eb,
'mii_country',
'NL',
topIssues.map((i) => i.item)
)
);
const trustTrend = $derived([
...ebTrend(data.eb, 'trust', 'NL', ['National government', 'European Union']).map((t) => ({
...t,
item: `${t.item}, NL`
})),
...ebTrend(data.eb, 'trust', 'EU27', ['National government', 'European Union']).map((t) => ({
...t,
item: `${t.item}, EU27`,
dashed: true
}))
]);
// Category charts with long labels: show every label, wrapped.
const wrapLabels = (o: EChartsCoreOption, width = 90): EChartsCoreOption => {
const x = o.xAxis as { axisLabel?: object };
x.axisLabel = { ...x.axisLabel, interval: 0, width, overflow: 'break', fontSize: 10 };
(o.grid as { bottom: number }).bottom = 40;
return o;
};
const issueBars = (items: { item: string; nl: number | null; eu: number | null }[]) => [
{
name: 'Netherlands',
color: '#f97316',
kind: 'bar' as const,
points: items.map((i) => [i.item, i.nl ?? 0] as StatPoint)
},
{
name: 'EU27',
color: '#3b82f6',
kind: 'bar' as const,
points: items.map((i) => [i.item, i.eu ?? 0] as StatPoint)
}
];
// ---- World Happiness Report ---------------------------------------------------------------
const whrYear = $derived(Math.max(...data.whr.map((w) => w.year)));
const FOCUS = ['NLD', 'USA', 'KOR', 'DNK', 'FIN', 'DEU', 'CHN'];
const whrLatest = $derived(
data.whr
.filter((w) => w.year === whrYear && w.ladder != null)
.sort((x, y) => (x.rank ?? 999) - (y.rank ?? 999))
);
const whrRows = $derived(
whrLatest
.filter((w, i) => i < 20 || FOCUS.includes(w.iso3 ?? ''))
.map((w) => ({
label: `${w.rank}. ${w.country}`,
value: w.ladder ?? 0,
highlight: FOCUS.includes(w.iso3 ?? '')
}))
);
const nlWhr = $derived(whrLatest.find((w) => w.iso3 === 'NLD'));
const whrTrend = $derived(
FOCUS.map((iso, i) => ({
name: data.whr.find((w) => w.iso3 === iso)?.country ?? iso,
color: ['#f97316', '#e11d48', '#3b82f6', '#10b981', '#06b6d4', '#64748b', '#eab308'][i],
points: data.whr
.filter((w) => w.iso3 === iso && w.ladder != null)
.map((w) => [`${w.year}-01-01`, w.ladder!] as StatPoint)
})).filter((l) => l.points.length)
);
// ---- Google Trends and GDELT -------------------------------------------------------------
const GEOS: Record<string, string> = {
NL: 'Netherlands',
US: 'United States',
KR: 'South Korea'
};
const trendGeos = $derived([...new Set(data.trends.map((t) => t.geo))]);
const gAvg = $derived(gdeltAverages(data.gdelt));
const gCountries = $derived([...new Set(data.gdelt.map((g) => g.country))].sort());
const gThemes = $derived([...new Set(data.gdelt.map((g) => g.theme))]);
const gDays = $derived([...new Set(data.gdelt.map((g) => g.date))].sort());
// ---- Party positions -----------------------------------------------------------------------
const ches = $derived(chesParties(data.ches));
const chesYear = $derived(data.ches[0]?.year);
const scatter = (t: ChartTheme): EChartsCoreOption => ({
animation: false,
grid: { left: 40, right: 24, top: 24, bottom: 40 },
tooltip: {
backgroundColor: t.surface,
borderColor: t.border,
textStyle: { color: t.text, fontSize: 12 },
formatter: (p: { data: { value: [number, number, number, string] } }) =>
`${p.data.value[3]}<br>economic ${num(p.data.value[0], 1)} · GAL–TAN ${num(p.data.value[1], 1)}<br>${num(p.data.value[2], 1)}% vote`
},
xAxis: {
type: 'value',
min: 0,
max: 10,
name: 'economic left ← → right',
nameLocation: 'middle',
nameGap: 26,
nameTextStyle: { color: t.muted, fontSize: 11 },
axisLabel: { color: t.muted, fontSize: 11 },
splitLine: { lineStyle: { color: t.border, opacity: 0.6 } }
},
yAxis: {
type: 'value',
min: 0,
max: 10,
name: 'GAL ↓ TAN ↑',
nameTextStyle: { color: t.muted, fontSize: 11 },
axisLabel: { color: t.muted, fontSize: 11 },
splitLine: { lineStyle: { color: t.border, opacity: 0.6 } }
},
series: [
{
type: 'scatter',
data: ches
.filter((p) => p.dims.lrecon != null && p.dims.galtan != null)
.map((p) => ({
value: [p.dims.lrecon, p.dims.galtan, p.share ?? 0, p.label],
itemStyle: { color: PARTY_COLORS[p.label] ?? '#64748b', opacity: 0.85 }
})),
symbolSize: (v: [number, number, number]) => 8 + Math.sqrt(v[2] ?? 0) * 5,
label: {
show: true,
position: 'right',
color: t.text,
fontSize: 11,
formatter: (p: { data: { value: [number, number, number, string] } }) => p.data.value[3]
}
}
]
});
const vparty = $derived(vpartyLines(data.vparty, 10));
</script>
<section class="stats-intro">
<span class="stats-eyebrow">stats / opinions</span>
<h1>Opinions</h1>
<p>
What people think, from polls and surveys: Dutch seat polls, approval of Trump and Lee
Jae-myung, Eurobarometer worries and trust, the World Happiness Report, and where parties stand.
For values, what words mean and a party compass across 57 countries see
<a href={resolve('/stats/world/politics')}>world opinions</a>.
</p>
</section>
<div class="kpis">
<Kpi
label="Leading Dutch party in the polls"
value={nl[0]?.option ?? '–'}
sub={`${num(nl[0]?.last?.[1], 0)} seats, 5-poll average · ${day(nl[0]?.last?.[0])}`}
color="#06b6d4"
/>
<Kpi
label="Trump approval"
value={num(usApprove?.last?.[1], 1)}
unit="%"
sub={`net ${num((usApprove?.last?.[1] ?? 0) - (usDisapprove?.last?.[1] ?? 0), 1)} · 10-poll average`}
color="#e11d48"
/>
<Kpi
label="Lee Jae-myung approval"
value={num(krApprove?.last?.[1], 1)}
unit="%"
sub={`10-poll average · ${day(krApprove?.last?.[0])}`}
color="#3b82f6"
/>
<Kpi
label="Dutch life satisfaction"
value={num(nlWhr?.ladder, 2)}
unit="/10"
sub={`#${nlWhr?.rank ?? '–'} of ${whrLatest.length} · WHR ${whrYear}`}
color="#10b981"
/>
</div>
<h2 class="stats-section-title">Polls</h2>
<div class="stats-grid">
<StatChart
title="Dutch seat polls since the 2025 election"
subtitle="seats out of 150, 5-poll average · dots are single polls"
sources={['wikipedia']}
attributions={a}
wide
height="420px"
option={(t) => {
const o = lineOption(
t,
nl.map((p) => ({ name: p.option, color: PARTY_COLORS[p.option], points: p.avg })),
{ decimals: 1, zoom: true }
) as { series: object[] };
o.series.push(
...nl.map((p) => ({
type: 'scatter',
name: p.option,
data: p.raw,
symbolSize: 4,
itemStyle: { color: PARTY_COLORS[p.option] ?? '#94a3b8', opacity: 0.35 },
tooltip: { show: false }
}))
);
return o;
}}
/>
<StatChart
title="Trump approval"
subtitle="% of adults, 10-poll average · dots are single polls"
sources={['wikipedia']}
attributions={a}
option={(t) => {
const o = lineOption(
t,
[
{ name: 'Approve', color: '#10b981', points: usApprove?.avg ?? [] },
{ name: 'Disapprove', color: '#e11d48', points: usDisapprove?.avg ?? [] }
],
{ unit: '%', decimals: 1, min: 'dataMin' }
) as { series: object[] };
o.series.push(
...[usApprove, usDisapprove].map((p, i) => ({
type: 'scatter',
name: p?.option,
data: p?.raw ?? [],
symbolSize: 3,
itemStyle: { color: i ? '#e11d48' : '#10b981', opacity: 0.3 },
tooltip: { show: false }
}))
);
return o;
}}
/>
<StatChart
title="Lee Jae-myung approval"
subtitle="% approve / disapprove, 10-poll average"
sources={['wikipedia']}
attributions={a}
option={(t) =>
lineOption(
t,
kr.map((p) => ({
name: p.option,
color:
p.option === 'Approve' ? '#10b981' : p.option === 'Disapprove' ? '#e11d48' : '#94a3b8',
points: p.avg
})),
{ unit: '%', decimals: 1, zoom: true }
)}
/>
<StatChart
title="South Korean party support"
subtitle="%, 10-poll average"
sources={['wikipedia']}
attributions={a}
option={(t) =>
lineOption(
t,
krParty.map((p) => ({ name: p.option, color: KR_COLORS[p.option], points: p.avg })),
{ unit: '%', decimals: 1, zoom: true }
)}
/>
<StatChart
title="Trump approval by aggregator"
subtitle={`% approve · ${day(usAgg[0]?.date)}`}
sources={['wikipedia']}
attributions={a}
option={(t) =>
barRankOption(
t,
usAgg.map((r) => ({ label: r.pollster, value: r.values.Approve ?? 0 })),
{ decimals: 1, unit: '%', color: '#e11d48' }
)}
/>
</div>
{#if nlLatest.length}
<div class="table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Latest Dutch polls</th>
{#each nlCols as p (p)}<th class="num">{p}</th>{/each}
</tr>
</thead>
<tbody>
{#each nlLatest as r (r.date + r.pollster)}
<tr>
<td>{r.pollster} · {day(r.date)}</td>
{#each nlCols as p (p)}<td class="num">{num(r.values[p], 0)}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<h2 class="stats-section-title">Eurobarometer</h2>
<div class="stats-grid">
<StatChart
title="Most important issues facing the country"
subtitle={`% naming the issue (up to two) · ${period(miiCountry.date, 'M')}`}
sources={['eurobarometer']}
attributions={a}
height="420px"
option={(t) =>
wrapLabels(
lineOption(t, issueBars(miiCountry.items.slice(0, 10)), {
category: true,
unit: '%',
decimals: 0
})
)}
/>
<StatChart
title="Most important issues for you personally"
subtitle={`% naming the issue (up to two) · ${period(miiPersonal.date, 'M')}`}
sources={['eurobarometer']}
attributions={a}
height="420px"
option={(t) =>
wrapLabels(
lineOption(t, issueBars(miiPersonal.items.slice(0, 10)), {
category: true,
unit: '%',
decimals: 0
})
)}
/>
<StatChart
title="What the Dutch see as the country's main issues"
subtitle="% naming the issue, by survey wave"
sources={['eurobarometer']}
attributions={a}
option={(t) =>
lineOption(
t,
issueTrend.map((i) => ({ name: i.item, points: i.points })),
{ unit: '%', decimals: 0 }
)}
/>
<StatChart
title="Trust in government and the EU"
subtitle="% who tend to trust"
sources={['eurobarometer']}
attributions={a}
option={(t) =>
lineOption(
t,
trustTrend.map((i) => ({
name: i.item,
points: i.points,
color: i.item.startsWith('National') ? '#f97316' : '#3b82f6',
dashed: 'dashed' in i
})),
{ unit: '%', decimals: 0, min: 0 }
)}
/>
<StatChart
title="Whom people tend to trust"
subtitle={`% who tend to trust · ${period(trust.date, 'M')}`}
sources={['eurobarometer']}
attributions={a}
wide
height="380px"
option={(t) =>
wrapLabels(
lineOption(t, issueBars([...trust.items, ...trustEu.items]), {
category: true,
unit: '%',
decimals: 0
}),
70
)}
/>
</div>
<h2 class="stats-section-title">Happiness</h2>
<div class="stats-grid">
<StatChart
title={`World Happiness Report ${whrYear + 1}`}
subtitle={`life evaluation (Cantril ladder, 0–10), 3-year average to ${whrYear} · top 20 and highlighted countries`}
sources={['whr']}
attributions={a}
height="720px"
option={(t) => barRankOption(t, whrRows, { decimals: 2, color: '#94a3b8' })}
/>
<StatChart
title="Life evaluation over time"
subtitle="0–10 ladder, report year's 3-year average"
sources={['whr']}
attributions={a}
option={(t) => lineOption(t, whrTrend, { decimals: 2, min: 'dataMin' })}
/>
</div>
<h2 class="stats-section-title">What people search and read</h2>
<div class="trends">
{#each trendGeos as g (g)}
{@const rows = data.trends.filter((t) => t.geo === g)}
<div class="trend-col">
<h3>{GEOS[g] ?? g}</h3>
<ol>
{#each rows.slice(0, 10) as r (r.query)}
<li>
<span>{r.query}</span>{#if r.traffic}<span class="muted">{num(r.traffic, 0)}+</span
>{/if}
</li>
{/each}
</ol>
<p class="muted">Google Trends daily searches · {day(rows[0]?.date)}</p>
</div>
{/each}
</div>
{#if gThemes.length}
<div class="stats-grid">
<StatChart
title="News themes"
subtitle={`% of online news coverage mentioning the theme, by country of the outlet · ${day(gDays[0])} – ${day(gDays.at(-1))} average`}
sources={['gdelt']}
attributions={a}
wide
height="380px"
note="GDELT's theme tagging is automatic and coarse; shares compare countries, not absolute attention."
option={(t) =>
lineOption(
t,
gCountries.map((c, i) => ({
name: c,
kind: 'bar' as const,
color: [
'#f97316',
'#e11d48',
'#3b82f6',
'#10b981',
'#a855f7',
'#eab308',
'#06b6d4',
'#64748b',
'#84cc16',
'#ec4899'
][i % 10],
points: gThemes.map((th) => [GDELT_THEMES[th] ?? th, gAvg(c, th)] as StatPoint)
})),
{ category: true, unit: '%', decimals: 3 }
)}
/>
</div>
{/if}
<h2 class="stats-section-title" id="parties">Where Dutch parties stand</h2>
<div class="stats-grid">
{#if ches.length}
<StatChart
title={`Dutch parties, CHES ${chesYear}`}
subtitle="expert placements, 0–10 · dot size is vote share"
sources={['ches']}
attributions={a}
height="460px"
note="GAL: green, alternative, libertarian; TAN: traditional, authoritarian, nationalist."
option={scatter}
/>
{/if}
{#if vparty.length}
<StatChart
title="Populism of Dutch parties since 1946"
subtitle="V-Party populism index, 0–1 · parties that ever won 10% or more"
sources={['ches']}
attributions={a}
height="460px"
note="V-Party scores each party at each election; the index combines anti-elitism and people-centrism."
option={(t) =>
lineOption(
t,
vparty.map((p) => ({ name: p.label, color: PARTY_COLORS[p.label], points: p.points })),
{ decimals: 2, zoom: true }
)}
/>
{/if}
</div>
{#if ches.length}
<div class="table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Party, CHES {chesYear}</th>
<th class="num">Vote %</th>
<th class="num">Left–right</th>
<th class="num">Economic</th>
<th class="num">GAL–TAN</th>
<th class="num">EU (1–7)</th>
<th class="num">Immigration</th>
</tr>
</thead>
<tbody>
{#each ches as p (p.id)}
<tr>
<td>{p.label}</td>
<td class="num">{num(p.share, 1)}</td>
<td class="num">{num(p.dims.lrgen, 1)}</td>
<td class="num">{num(p.dims.lrecon, 1)}</td>
<td class="num">{num(p.dims.galtan, 1)}</td>
<td class="num">{num(p.dims.eu_position, 1)}</td>
<td class="num">{num(p.dims.immigrate_policy, 1)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="stats-note">
{a.ches ?? ''} · Immigration: 0 strongly for a liberal policy, 10 strongly for a restrictive one.
</p>
{/if}
<style>
.trends {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.trend-col {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
padding: 1rem;
}
.trend-col h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.trend-col ol {
margin: 0;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.trend-col li span:first-child {
margin-right: 0.5rem;
}
.muted {
color: var(--site-muted);
font-size: 0.8rem;
}
</style>

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import type { WorldOpinionsViewData } from '#lib/server/stats-views.js';
import type { StatsCommon } from '#lib/stats/matrix.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import './world-opinions.css';
import CountryPanel from '#lib/world-opinions/CountryPanel.svelte';
import CultureMap from '#lib/world-opinions/CultureMap.svelte';
import PartyCompass from '#lib/world-opinions/PartyCompass.svelte';
import WorldMap, { type MapMode } from '#lib/world-opinions/WorldMap.svelte';
import { buildWorldOpinions, provideWorldOpinions } from '#lib/world-opinions/data.js';
import { Button } from '#lib/components/ui/button/index.js';
import { resolve } from '$app/paths';
let { data }: { data: WorldOpinionsViewData & StatsCommon } = $props();
// Static per load (stats.wo_*), so it is read once and shared with the components.
const wo = provideWorldOpinions(buildWorldOpinions(data.wo));
const { countries, countryById, narratives, sources } = wo;
const modes: { id: MapMode; label: string }[] = [
{ id: 'liberal', label: 'what "liberal" means' },
{ id: 'divide', label: 'what left vs right is about' },
{ id: 'tradition', label: 'traditional ↔ secular' },
{ id: 'expression', label: 'survival ↔ self-expression' }
];
const sorted = [...countries].sort((a, b) => a.name.localeCompare(b.name));
let mode = $state<MapMode>('liberal');
let countryId = $state('us');
let eventIndex = $state(0);
let narrative = $derived(narratives[eventIndex]);
function select(id: string) {
countryId = id;
document.getElementById('country')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
</script>
<section class="stats-intro">
<span class="stats-eyebrow">stats / opinions / world</span>
<h1>World opinions</h1>
<p>
Same words, different politics. Hover the map, click a country. For measured party positions
(Chapel Hill Expert Survey, V-Party) see <a href={resolve('/stats/core/politics') + '#parties'}
>where Dutch parties stand</a
>; for polls, happiness and Eurobarometer see
<a href={resolve('/stats/core/politics')}>opinions</a>.
</p>
</section>
<div class="wo">
<section aria-label="World map">
<div class="mb-4 flex flex-wrap gap-2" role="tablist" aria-label="Map layer">
{#each modes as item (item.id)}
<Button
variant={mode === item.id ? 'secondary' : 'outline'}
size="sm"
role="tab"
aria-selected={mode === item.id}
onclick={() => (mode = item.id)}>{item.label}</Button
>
{/each}
</div>
<WorldMap {mode} selected={countryId} onselect={select} />
</section>
<section id="country" class="scroll-mt-20 pt-10" aria-label="Country">
<label class="mb-3 block max-w-xs text-sm">
<span class="sr-only">Country</span>
<NativeSelect
bind:value={countryId}
class="w-full rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-3 py-2"
>
{#each sorted as country (country.id)}
<option value={country.id}>{country.name}</option>
{/each}
</NativeSelect>
</label>
<CountryPanel id={countryId} />
</section>
<section class="mt-12 border-t border-[var(--site-border)] py-10">
<h2 class="stats-section-title mb-1">Party compass</h2>
<p class="mb-5 text-[var(--site-muted)]">
Every party on one shared scale, hand-placed for comparison. Measured CHES and V-Party
positions for Dutch parties are on <a
class="underline underline-offset-2"
href={resolve('/stats/core/politics') + '#parties'}>opinions</a
>.
</p>
<PartyCompass focus={countryId} />
</section>
<section class="border-t border-[var(--site-border)] py-10">
<h2 class="stats-section-title mb-1">Values map</h2>
<p class="mb-5 text-[var(--site-muted)]">Both value axes at once.</p>
<CultureMap selected={countryId} onselect={select} />
</section>
<section class="border-t border-[var(--site-border)] py-10">
<h2 class="stats-section-title mb-4">One event, many stories</h2>
<div class="mb-5 flex flex-wrap gap-2" role="tablist" aria-label="Event">
{#each narratives as item, index (item.event)}
<Button
variant={eventIndex === index ? 'secondary' : 'outline'}
size="sm"
role="tab"
aria-selected={eventIndex === index}
onclick={() => (eventIndex = index)}
>{item.event.toLowerCase()} <span class="opacity-60">{item.years}</span></Button
>
{/each}
</div>
<div role="tabpanel">
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each narrative.views as view (view.country)}
<li>
<button class="wo-card wo-quote w-full text-left" onclick={() => select(view.country)}>
<span class="text-sm text-[var(--site-muted)]"
>{countryById.get(view.country)?.name}</span
>
<span class="block text-lg">“{view.story}”</span>
</button>
</li>
{/each}
</ul>
</div>
</section>
<footer class="border-t border-[var(--site-border)] pt-6 text-sm text-[var(--site-muted)]">
<p class="mb-2">Approximate, hand-placed positions for comparison. Based on:</p>
<p class="flex flex-wrap gap-x-4 gap-y-1">
{#each sources as source (source.href)}
<a
class="underline underline-offset-2 hover:text-[var(--site-text)]"
href={source.href}
rel="external">{source.name}</a
>
{/each}
</p>
</footer>
</div>

View File

@@ -0,0 +1,302 @@
.wo {
--wo-s1: #3987e5;
--wo-s2: #d95926;
--wo-s3: #199e70;
--wo-dot: #6b6b68;
--wo-dot-dim: #3a3a38;
--wo-grid: #1c1c1b;
--wo-axis: #3a3a38;
}
:root[data-theme='light'] .wo {
--wo-s1: #2a78d6;
--wo-s2: #eb6834;
--wo-s3: #1baf7a;
--wo-dot: #9a9994;
--wo-dot-dim: #dcdbd6;
--wo-grid: #f0efec;
--wo-axis: #cfcec9;
}
.wo-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.3rem 0.8rem;
font-size: 0.875rem;
color: var(--site-muted);
}
.wo-chip:hover:not(:disabled) {
background: var(--site-hover);
color: var(--site-text);
}
.wo-chip[aria-pressed='true'],
.wo-chip[aria-selected='true'] {
border-color: var(--site-text);
color: var(--site-text);
}
.wo-chip:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.wo-swatch {
display: inline-block;
width: 0.65rem;
height: 0.65rem;
border-radius: 999px;
}
.wo-link {
font-size: 0.875rem;
color: var(--site-muted);
text-decoration: underline;
text-underline-offset: 3px;
}
.wo-link:hover {
color: var(--site-text);
}
.wo-chart svg text {
font-family: inherit;
}
.wo-grid {
stroke: var(--wo-grid);
stroke-width: 1;
}
.wo-axis {
stroke: var(--wo-axis);
stroke-width: 1;
}
.wo-axis-label {
fill: var(--site-muted);
font-size: 12px;
}
.wo-label {
fill: var(--site-muted);
font-size: 11px;
}
.wo-label-strong {
fill: var(--site-text);
font-size: 12px;
font-weight: 600;
}
.wo-dot {
fill: var(--wo-dot);
stroke: var(--site-bg);
stroke-width: 2;
}
.wo-dot-dim {
fill: var(--wo-dot-dim);
stroke: var(--site-bg);
stroke-width: 2;
}
.wo-dot-active {
fill: var(--wo-s1);
stroke: var(--site-bg);
stroke-width: 2;
}
.wo-ring {
stroke: var(--site-bg);
stroke-width: 2;
}
.wo-chart g:focus-visible circle:last-of-type {
stroke: var(--site-text);
}
.wo-tooltip {
position: absolute;
transform: translate(-50%, calc(-100% - 14px));
display: flex;
flex-direction: column;
gap: 0.1rem;
pointer-events: none;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
background: var(--site-surface);
padding: 0.5rem 0.7rem;
font-size: 0.8rem;
color: var(--site-muted);
white-space: nowrap;
z-index: 10;
}
.wo-tooltip strong {
color: var(--site-text);
}
.wo-tooltip .max-w-64 {
white-space: normal;
width: 16rem;
}
.wo-table {
width: 100%;
font-size: 0.875rem;
border-collapse: collapse;
}
.wo-table th,
.wo-table td {
border-top: 1px solid var(--site-border);
padding: 0.4rem 0.5rem 0.4rem 0;
text-align: left;
}
.wo-table th {
color: var(--site-muted);
font-weight: 500;
}
.wo-card {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 1rem 1.1rem;
}
.wo {
--wo-pos: #3987e5;
--wo-neg: #d95926;
--wo-mid: #4a4a47;
--wo-none: #7a7a76;
--wo-nodata: #161615;
}
:root[data-theme='light'] .wo {
--wo-pos: #2a78d6;
--wo-neg: #eb6834;
--wo-mid: #d9d8d3;
--wo-none: #a3a29c;
--wo-nodata: #f1f1ef;
}
.wo-country {
stroke: var(--site-bg);
stroke-width: 0.5;
transition: opacity 120ms;
}
.wo-country-hover {
stroke: var(--site-text);
stroke-width: 1;
}
.wo-country-selected {
fill: none;
stroke: var(--site-text);
stroke-width: 1.75;
pointer-events: none;
}
.wo-tooltip-note {
margin-top: 0.2rem;
width: 15rem;
white-space: normal;
}
.wo-gradient {
display: inline-block;
width: 9rem;
height: 0.6rem;
border-radius: 999px;
background: linear-gradient(to right, var(--wo-neg), var(--wo-mid), var(--wo-pos));
}
.wo-swatch-nodata {
background: var(--wo-nodata);
outline: 1px solid var(--site-border);
}
.wo-gauge {
position: relative;
height: 0.5rem;
border-radius: 999px;
background: linear-gradient(to right, var(--wo-neg), var(--wo-mid), var(--wo-pos));
}
.wo-gauge-marker {
position: absolute;
top: 50%;
width: 1rem;
height: 1rem;
border-radius: 999px;
transform: translate(-50%, -50%);
background: var(--site-text);
border: 3px solid var(--site-surface);
transition: left 300ms ease;
}
.wo-tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 999px;
background: var(--site-hover);
padding: 0.2rem 0.7rem;
font-size: 0.875rem;
}
.wo-tag-dashed {
background: transparent;
border: 1px dashed var(--site-border);
color: var(--site-muted);
}
.wo-timeline {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
position: relative;
}
.wo-timeline::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 1.85rem;
border-top: 1px solid var(--site-border);
}
.wo-timeline li {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.35rem;
padding: 0 0.25rem;
}
.wo-timeline-year {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.wo-timeline-dot {
position: relative;
width: 0.6rem;
height: 0.6rem;
border-radius: 999px;
background: var(--site-text);
}
.wo-timeline-label {
font-size: 0.8rem;
color: var(--site-muted);
}
@media (max-width: 640px) {
.wo-timeline {
grid-auto-flow: row;
gap: 0.6rem;
}
.wo-timeline::before {
display: none;
}
.wo-timeline li {
flex-direction: row;
text-align: left;
}
.wo-timeline-year {
width: 3.2rem;
}
}
.wo-quote:hover {
background: var(--site-hover);
}
.wo-tag-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-right: 0.25rem;
font-size: 0.8rem;
color: var(--site-muted);
}