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
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
This commit is contained in:
@@ -49,7 +49,59 @@ export const WB_INDICATORS: { id: string; title: string; unit: string; theme: st
|
||||
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 = {
|
||||
|
||||
@@ -51,6 +51,12 @@ export const statsPages = [
|
||||
subtitle: 'population, housing and neighbourhoods in Gouda',
|
||||
href: '/stats/gouda',
|
||||
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;
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ export type StatSeries = {
|
||||
region: string | null;
|
||||
sourceId: string;
|
||||
points: StatPoint[];
|
||||
/** First forecast year (IMF WEO vintages); points from that year on are projections. */
|
||||
forecastFrom?: number;
|
||||
};
|
||||
export type SeriesRequest = {
|
||||
id: string;
|
||||
@@ -48,8 +50,10 @@ export async function loadSeries(
|
||||
freq: string;
|
||||
region_code: string | null;
|
||||
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)})`;
|
||||
const out: Record<string, StatSeries> = {};
|
||||
await Promise.all(
|
||||
@@ -72,6 +76,7 @@ export async function loadSeries(
|
||||
freq: m.freq,
|
||||
region: m.region_code,
|
||||
sourceId: m.source_id,
|
||||
...(m.forecast_from ? { forecastFrom: Number(m.forecast_from) } : {}),
|
||||
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));
|
||||
}
|
||||
|
||||
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
130
src/lib/stats/countries.ts
Normal 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'
|
||||
];
|
||||
@@ -31,5 +31,8 @@ export const COUNTRY: Record<string, string> = {
|
||||
BEL: 'Belgium',
|
||||
GBR: 'United Kingdom',
|
||||
JPN: 'Japan',
|
||||
IND: 'India'
|
||||
IND: 'India',
|
||||
BRA: 'Brazil',
|
||||
RUS: 'Russia',
|
||||
EU27: 'European Union'
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ const staticPaths = [
|
||||
'/stats/economy',
|
||||
'/stats/energy',
|
||||
'/stats/gouda',
|
||||
'/stats/countries',
|
||||
'/stats/countries/CUW',
|
||||
'/trackers/books',
|
||||
'/trackers/spotting',
|
||||
'/trackers/cars',
|
||||
|
||||
@@ -51,7 +51,9 @@
|
||||
{ name: 'Stats', href: '/stats' },
|
||||
{ name: 'Stats: economy', href: '/stats/economy' },
|
||||
{ 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);
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import Landmark from 'lucide-svelte/icons/landmark';
|
||||
import Flame from 'lucide-svelte/icons/flame';
|
||||
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';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -62,6 +64,20 @@
|
||||
icon: House,
|
||||
grad: 'linear-gradient(135deg,#10b981,#06b6d4)',
|
||||
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;
|
||||
</script>
|
||||
@@ -164,7 +180,7 @@
|
||||
<div class="sections">
|
||||
{#each SECTIONS as sec (sec.href)}
|
||||
{@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-body">
|
||||
<strong>{sec.title}</strong>
|
||||
|
||||
29
src/routes/stats/countries/+page.server.ts
Normal file
29
src/routes/stats/countries/+page.server.ts
Normal 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 };
|
||||
});
|
||||
174
src/routes/stats/countries/+page.svelte
Normal file
174
src/routes/stats/countries/+page.svelte
Normal 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>
|
||||
23
src/routes/stats/countries/[iso3]/+page.server.ts
Normal file
23
src/routes/stats/countries/[iso3]/+page.server.ts
Normal 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 };
|
||||
};
|
||||
428
src/routes/stats/countries/[iso3]/+page.svelte
Normal file
428
src/routes/stats/countries/[iso3]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user