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>