stats: /stats/countries comparison and /stats/countries/[iso3] profiles (Curaçao via World Bank), more WB indicators, Countries + Curaçao cards, nav/sitemap/start links
All checks were successful
Deploy production / deploy (push) Successful in 1m13s

This commit is contained in:
2026-10-25 21:22:20 +00:00
parent d19313035f
commit 2a4466cb82
12 changed files with 887 additions and 5 deletions

View File

@@ -49,7 +49,59 @@ export const WB_INDICATORS: { id: string; title: string; unit: string; theme: st
theme: 'budget' theme: 'budget'
}, },
{ id: 'GC.XPN.TOTL.GD.ZS', title: 'Government expense', unit: '% of GDP', theme: 'budget' }, { id: 'GC.XPN.TOTL.GD.ZS', title: 'Government expense', unit: '% of GDP', theme: 'budget' },
{ id: 'GC.DOD.TOTL.GD.ZS', title: 'Central government debt', unit: '% of GDP', theme: 'budget' } { id: 'GC.DOD.TOTL.GD.ZS', title: 'Central government debt', unit: '% of GDP', theme: 'budget' },
// Country profiles (/stats/countries/[iso3]); most of these also cover Curaçao.
{
id: 'NY.GNP.PCAP.CD',
title: 'GNI per capita (Atlas)',
unit: 'current USD',
theme: 'countries'
},
{
id: 'SL.UEM.TOTL.ZS',
title: 'Unemployment (ILO modelled)',
unit: '% of labour force',
theme: 'countries'
},
{ id: 'SP.DYN.LE00.IN', title: 'Life expectancy at birth', unit: 'years', theme: 'countries' },
{ id: 'SP.DYN.TFRT.IN', title: 'Fertility rate', unit: 'births per woman', theme: 'countries' },
{
id: 'SP.POP.65UP.TO.ZS',
title: 'Population aged 65+',
unit: '% of population',
theme: 'countries'
},
{
id: 'NE.EXP.GNFS.ZS',
title: 'Exports of goods and services',
unit: '% of GDP',
theme: 'countries'
},
{
id: 'NE.IMP.GNFS.ZS',
title: 'Imports of goods and services',
unit: '% of GDP',
theme: 'countries'
},
{ id: 'NV.SRV.TOTL.ZS', title: 'Services, value added', unit: '% of GDP', theme: 'countries' },
{
id: 'ST.INT.ARVL',
title: 'International tourism, arrivals',
unit: 'arrivals',
theme: 'countries'
},
{
id: 'ST.INT.RCPT.CD',
title: 'International tourism, receipts',
unit: 'current USD',
theme: 'countries'
},
{
id: 'EG.ELC.RNEW.ZS',
title: 'Renewable electricity output',
unit: '% of electricity',
theme: 'energy'
}
]; ];
type WbRow = { type WbRow = {

View File

@@ -51,6 +51,12 @@ export const statsPages = [
subtitle: 'population, housing and neighbourhoods in Gouda', subtitle: 'population, housing and neighbourhoods in Gouda',
href: '/stats/gouda', href: '/stats/gouda',
external: false external: false
},
{
name: 'countries',
subtitle: 'the Netherlands, Curaçao, the US, Korea, China and more side by side',
href: '/stats/countries',
external: false
} }
] as const; ] as const;

View File

@@ -25,6 +25,8 @@ export type StatSeries = {
region: string | null; region: string | null;
sourceId: string; sourceId: string;
points: StatPoint[]; points: StatPoint[];
/** First forecast year (IMF WEO vintages); points from that year on are projections. */
forecastFrom?: number;
}; };
export type SeriesRequest = { export type SeriesRequest = {
id: string; id: string;
@@ -48,8 +50,10 @@ export async function loadSeries(
freq: string; freq: string;
region_code: string | null; region_code: string | null;
source_id: string; source_id: string;
forecast_from: string | null;
}[] }[]
>`select series_id, title, unit, freq, region_code, source_id from stats.series >`select series_id, title, unit, freq, region_code, source_id, meta->>'forecastFrom' as forecast_from
from stats.series
where series_id = any(${reqs.map((r) => r.id)})`; where series_id = any(${reqs.map((r) => r.id)})`;
const out: Record<string, StatSeries> = {}; const out: Record<string, StatSeries> = {};
await Promise.all( await Promise.all(
@@ -72,6 +76,7 @@ export async function loadSeries(
freq: m.freq, freq: m.freq,
region: m.region_code, region: m.region_code,
sourceId: m.source_id, sourceId: m.source_id,
...(m.forecast_from ? { forecastFrom: Number(m.forecast_from) } : {}),
points: rows.map((r) => [r.period, Math.round(r.value * 1e6) / 1e6]) points: rows.map((r) => [r.period, Math.round(r.value * 1e6) / 1e6])
}; };
}) })
@@ -181,3 +186,15 @@ export async function loadCbsCross(
} }
return [...byCode.values()].sort((a, b) => a.code.localeCompare(b.code)); return [...byCode.values()].sort((a, b) => a.code.localeCompare(b.code));
} }
export type LatestValue = { period: string; value: number };
/** Latest observed (non-forecast) value per series; missing ids are absent. */
export async function loadLatest(ids: string[]): Promise<Record<string, LatestValue>> {
if (!ids.length) return {};
const rows = await client<{ series_id: string; period: string; value: number }[]>`
select distinct on (series_id) series_id, to_char(period, 'YYYY-MM-DD') as period, value
from stats.obs
where series_id = any(${ids}) and value is not null and status is distinct from 'forecast'
order by series_id, period desc`;
return Object.fromEntries(rows.map((r) => [r.series_id, { period: r.period, value: r.value }]));
}

130
src/lib/stats/countries.ts Normal file
View File

@@ -0,0 +1,130 @@
// Country profiles for /stats/countries and /stats/countries/[iso3]: which regions get a page,
// and which normalised series (World Bank, IMF WEO) feed each indicator. IMF first where it
// exists (it has forecasts); the World Bank fills in for places the IMF skips, like Curaçao.
export const COUNTRY_PAGES = [
'NLD',
'CUW',
'USA',
'KOR',
'CHN',
'DEU',
'FRA',
'BEL',
'GBR',
'JPN',
'IND',
'BRA',
'RUS',
'EU27',
'WLD'
] as const;
export type CountryCode = (typeof COUNTRY_PAGES)[number];
export const isCountryPage = (code: string): code is CountryCode =>
(COUNTRY_PAGES as readonly string[]).includes(code);
export const FLAG: Record<string, string> = {
NLD: '🇳🇱',
CUW: '🇨🇼',
USA: '🇺🇸',
KOR: '🇰🇷',
CHN: '🇨🇳',
DEU: '🇩🇪',
FRA: '🇫🇷',
BEL: '🇧🇪',
GBR: '🇬🇧',
JPN: '🇯🇵',
IND: '🇮🇳',
BRA: '🇧🇷',
RUS: '🇷🇺',
EU27: '🇪🇺',
WLD: '🌍'
};
export const COUNTRY_COLOR: Record<string, string> = {
NLD: '#f97316',
CUW: '#06b6d4',
USA: '#3b82f6',
KOR: '#e11d48',
CHN: '#eab308',
DEU: '#64748b',
FRA: '#6366f1',
BEL: '#a16207',
GBR: '#a855f7',
JPN: '#ec4899',
IND: '#10b981',
BRA: '#84cc16',
RUS: '#0d9488',
EU27: '#1d4ed8',
WLD: '#94a3b8'
};
/** Comparison columns: series id candidates in order of preference ({c} = region code). */
export const COMPARE_COLUMNS = [
{ key: 'pop', label: 'Population', ids: ['wb:SP.POP.TOTL:{c}'], fmt: 'compact' },
{ key: 'gdppc', label: 'GDP/cap PPP', ids: ['wb:NY.GDP.PCAP.PP.KD:{c}'], fmt: 'usd' },
{
key: 'growth',
label: 'Growth',
ids: ['imf:NGDP_RPCH:{c}', 'wb:NY.GDP.MKTP.KD.ZG:{c}'],
fmt: 'pct'
},
{
key: 'infl',
label: 'Inflation',
ids: ['imf:PCPIPCH:{c}', 'wb:FP.CPI.TOTL.ZG:{c}'],
fmt: 'pct'
},
{
key: 'unemp',
label: 'Unemployment',
ids: ['imf:LUR:{c}', 'wb:SL.UEM.TOTL.ZS:{c}'],
fmt: 'pct'
},
{
key: 'debt',
label: 'Gov. debt',
ids: ['imf:GGXWDG_NGDP:{c}', 'wb:GC.DOD.TOTL.GD.ZS:{c}'],
fmt: 'pct'
},
{ key: 'life', label: 'Life exp.', ids: ['wb:SP.DYN.LE00.IN:{c}'], fmt: 'years' }
] as const;
export const seriesIds = (c: string, ids: readonly string[]) => ids.map((i) => i.replace('{c}', c));
/** Indicators shown on a country page (World Bank ids unless prefixed). */
export const PROFILE_SERIES = [
'wb:SP.POP.TOTL',
'wb:SM.POP.NETM',
'wb:SP.DYN.LE00.IN',
'wb:SP.DYN.TFRT.IN',
'wb:SP.POP.65UP.TO.ZS',
'wb:NY.GDP.PCAP.KD',
'wb:NY.GDP.PCAP.PP.KD',
'wb:NY.GNP.PCAP.CD',
'wb:NY.GDP.MKTP.CD',
'wb:NY.GDP.MKTP.KD.ZG',
'wb:FP.CPI.TOTL.ZG',
'wb:SL.UEM.TOTL.ZS',
'wb:NE.TRD.GNFS.ZS',
'wb:NE.EXP.GNFS.ZS',
'wb:NE.IMP.GNFS.ZS',
'wb:NV.SRV.TOTL.ZS',
'wb:ST.INT.ARVL',
'wb:ST.INT.RCPT.CD',
'wb:EG.IMP.CONS.ZS',
'wb:EG.ELC.RNEW.ZS',
'wb:TM.TAX.MRCH.WM.AR.ZS',
'wb:GC.REV.XGRT.GD.ZS',
'wb:GC.XPN.TOTL.GD.ZS',
'wb:GC.DOD.TOTL.GD.ZS',
'imf:NGDP_RPCH',
'imf:NGDPDPC',
'imf:PPPPC',
'imf:PCPIPCH',
'imf:LUR',
'imf:GGXWDG_NGDP',
'imf:GGXCNL_NGDP',
'imf:GGR_G01_GDP_PT',
'imf:G_X_G01_GDP_PT'
];

View File

@@ -31,5 +31,8 @@ export const COUNTRY: Record<string, string> = {
BEL: 'Belgium', BEL: 'Belgium',
GBR: 'United Kingdom', GBR: 'United Kingdom',
JPN: 'Japan', JPN: 'Japan',
IND: 'India' IND: 'India',
BRA: 'Brazil',
RUS: 'Russia',
EU27: 'European Union'
}; };

View File

@@ -18,6 +18,8 @@ const staticPaths = [
'/stats/economy', '/stats/economy',
'/stats/energy', '/stats/energy',
'/stats/gouda', '/stats/gouda',
'/stats/countries',
'/stats/countries/CUW',
'/trackers/books', '/trackers/books',
'/trackers/spotting', '/trackers/spotting',
'/trackers/cars', '/trackers/cars',

View File

@@ -51,7 +51,9 @@
{ name: 'Stats', href: '/stats' }, { name: 'Stats', href: '/stats' },
{ name: 'Stats: economy', href: '/stats/economy' }, { name: 'Stats: economy', href: '/stats/economy' },
{ name: 'Stats: energy', href: '/stats/energy' }, { name: 'Stats: energy', href: '/stats/energy' },
{ name: 'Stats: Gouda', href: '/stats/gouda' } { name: 'Stats: Gouda', href: '/stats/gouda' },
{ name: 'Stats: countries', href: '/stats/countries' },
{ name: 'Stats: Curaçao', href: '/stats/countries/CUW' }
]; ];
const host = $derived(data.host); const host = $derived(data.host);

View File

@@ -7,6 +7,8 @@
import Landmark from 'lucide-svelte/icons/landmark'; import Landmark from 'lucide-svelte/icons/landmark';
import Flame from 'lucide-svelte/icons/flame'; import Flame from 'lucide-svelte/icons/flame';
import House from 'lucide-svelte/icons/house'; import House from 'lucide-svelte/icons/house';
import Globe from 'lucide-svelte/icons/globe';
import Palmtree from 'lucide-svelte/icons/tree-palm';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
let { data } = $props(); let { data } = $props();
@@ -62,6 +64,20 @@
icon: House, icon: House,
grad: 'linear-gradient(135deg,#10b981,#06b6d4)', grad: 'linear-gradient(135deg,#10b981,#06b6d4)',
text: 'Population since 1942, house prices, housing stock, municipal levies and neighbourhoods, next to the surrounding towns.' text: 'Population since 1942, house prices, housing stock, municipal levies and neighbourhoods, next to the surrounding towns.'
},
{
href: '/stats/countries',
title: 'Countries',
icon: Globe,
grad: 'linear-gradient(135deg,#a855f7,#ec4899)',
text: 'The Netherlands, Curaçao, the US, South Korea, China and more side by side: income, growth, inflation, debt and life expectancy.'
},
{
href: '/stats/countries/CUW',
title: 'Curaçao',
icon: Palmtree,
grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)',
text: 'Population, income, inflation, tourism, trade and government finances of Curaçao.'
} }
] as const; ] as const;
</script> </script>
@@ -164,7 +180,7 @@
<div class="sections"> <div class="sections">
{#each SECTIONS as sec (sec.href)} {#each SECTIONS as sec (sec.href)}
{@const Icon = sec.icon} {@const Icon = sec.icon}
<a class="section-card" href={resolve(sec.href)}> <a class="section-card" href={resolve(sec.href as '/stats')}>
<span class="section-icon" style:background={sec.grad}><Icon size={20} /></span> <span class="section-icon" style:background={sec.grad}><Icon size={20} /></span>
<span class="section-body"> <span class="section-body">
<strong>{sec.title}</strong> <strong>{sec.title}</strong>

View File

@@ -0,0 +1,29 @@
import { cached, loadLatest, loadSeries } from '#lib/server/stats.js';
import { COMPARE_COLUMNS, COUNTRY_PAGES, seriesIds } from '#lib/stats/countries.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () =>
cached('stats:countries', async () => {
const ids = COUNTRY_PAGES.flatMap((c) =>
COMPARE_COLUMNS.flatMap((col) => seriesIds(c, col.ids))
);
const [latest, series] = await Promise.all([
loadLatest(ids),
loadSeries(
COUNTRY_PAGES.flatMap((c) => [
{ id: `wb:NY.GDP.PCAP.PP.KD:${c}` },
{ id: `wb:SP.DYN.LE00.IN:${c}`, from: '1960-01-01' }
])
)
]);
const rows = COUNTRY_PAGES.map((c) => ({
code: c,
values: Object.fromEntries(
COMPARE_COLUMNS.map((col) => {
const id = seriesIds(c, col.ids).find((i) => latest[i]);
return [col.key, id ? { ...latest[id], source: id.split(':')[0] } : null];
})
)
}));
return { rows, series };
});

View File

@@ -0,0 +1,174 @@
<script lang="ts">
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 } = $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>
<svelte:head>
<title>countries · stats · Stan Runge</title>
<meta
name="description"
content="Countries side by side: the Netherlands, Curaçao, the US, South Korea, China and more, on income, growth, inflation, debt, population and life expectancy."
/>
</svelte:head>
<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/countries/${r.code}` 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,23 @@
import { error, redirect } from '@sveltejs/kit';
import { cached, loadSeries } from '#lib/server/stats.js';
import { isCountryPage, PROFILE_SERIES } from '#lib/stats/countries.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, url }) => {
const code = params.iso3.toUpperCase();
if (code !== params.iso3 && isCountryPage(code))
redirect(308, `/stats/countries/${code}${url.search}`);
if (!isCountryPage(code)) error(404, 'Not Found');
const series = await cached(`stats:country:${code}`, () =>
loadSeries([
...PROFILE_SERIES.map((id) => `${id}:${code}`),
// Benchmarks for the "next to the Netherlands and the world" lines.
...['NLD', 'WLD'].flatMap((c) => [
`wb:NY.GDP.PCAP.PP.KD:${c}`,
`wb:SP.DYN.LE00.IN:${c}`,
`wb:FP.CPI.TOTL.ZG:${c}`
])
])
);
return { code, series };
};

View File

@@ -0,0 +1,428 @@
<script lang="ts">
import { resolve } from '$app/paths';
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import { change, latest, lineOption, type Line, type StatPoint } from '#lib/stats/chart.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';
let { data } = $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'));
</script>
<svelte:head>
<title>{name} · countries · stats · Stan Runge</title>
<meta
name="description"
content={`${name} in numbers: population, income, growth, inflation, trade, tourism, government finances and life expectancy, from the World Bank and the IMF.`}
/>
</svelte:head>
<section class="stats-intro">
<a class="back" href={resolve('/stats/countries' 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
150,000 inhabitants. The IMF's World Economic Outlook doesn't cover it, so the headline
numbers here come from the World Bank; detailed CBS Curaçao and central bank series are on
their way.
{: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">
<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.'
: ''}
/>
<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>
<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>