stats: place × theme matrix (/stats/<place>/<theme>), sticky place/theme selector, generic metric engine with comparisons, 301s from the old section URLs
Deploy production / deploy (push) Successful in 1m19s
Deploy production / deploy (push) Successful in 1m19s
This commit is contained in:
1 parent
7ba26f132d
commit
f2f7bd0a19
51 files changed
+2234
-837
No files matched your search
@@ -3,5 +3,5 @@ import type { RequestHandler } from './$types';
|
||||
|
||||
// Merged into /stats (2026-10-26): every old URL, subpaths included, moves permanently.
|
||||
export const GET: RequestHandler = ({ url }) => {
|
||||
redirect(301, `/stats/energy${url.search}#live`);
|
||||
redirect(301, `/stats/nld/energy${url.search}#live`);
|
||||
};
|
||||
@@ -3,5 +3,5 @@ import type { RequestHandler } from './$types';
|
||||
|
||||
// Merged into /stats (2026-10-26): every old URL, subpaths included, moves permanently.
|
||||
export const GET: RequestHandler = ({ url }) => {
|
||||
redirect(301, `/stats/opinions/world${url.search}`);
|
||||
redirect(301, `/stats/world/politics${url.search}`);
|
||||
};
|
||||
@@ -17,18 +17,18 @@ const staticPaths = [
|
||||
'/blog',
|
||||
'/lists',
|
||||
'/stats',
|
||||
'/stats/economy',
|
||||
'/stats/energy',
|
||||
'/stats/gouda',
|
||||
'/stats/countries',
|
||||
'/stats/countries/CUW',
|
||||
'/stats/elections',
|
||||
'/stats/opinions',
|
||||
'/stats/opinions/world',
|
||||
'/stats/history',
|
||||
'/stats/countries/NLD',
|
||||
'/stats/countries/USA',
|
||||
'/stats/countries/KOR',
|
||||
'/stats/core/economy',
|
||||
'/stats/nld/energy',
|
||||
'/stats/gouda/overview',
|
||||
'/stats/core/overview',
|
||||
'/stats/cuw/overview',
|
||||
'/stats/nld/politics',
|
||||
'/stats/core/politics',
|
||||
'/stats/world/politics',
|
||||
'/stats/world/history',
|
||||
'/stats/nld/overview',
|
||||
'/stats/usa/overview',
|
||||
'/stats/kor/overview',
|
||||
'/trackers/books',
|
||||
'/trackers/ai',
|
||||
'/trackers/spotting',
|
||||
|
||||
@@ -49,11 +49,11 @@
|
||||
];
|
||||
const statsHits = [
|
||||
{ name: 'Stats', href: '/stats' },
|
||||
{ name: 'Stats: economy', href: '/stats/economy' },
|
||||
{ name: 'Stats: energy', href: '/stats/energy' },
|
||||
{ name: 'Stats: Gouda', href: '/stats/gouda' },
|
||||
{ name: 'Stats: countries', href: '/stats/countries' },
|
||||
{ name: 'Stats: Curaçao', href: '/stats/countries/CUW' }
|
||||
{ name: 'Stats: economy', href: '/stats/core/economy' },
|
||||
{ name: 'Stats: energy', href: '/stats/nld/energy' },
|
||||
{ name: 'Stats: Gouda', href: '/stats/gouda/overview' },
|
||||
{ name: 'Stats: countries', href: '/stats/core/overview' },
|
||||
{ name: 'Stats: Curaçao', href: '/stats/cuw/overview' }
|
||||
];
|
||||
|
||||
const host = $derived(data.host);
|
||||
|
||||
@@ -1,43 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { statsPages, statsSubPages } from '#lib/projects.js';
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
// Every /stats page: the sticky place × theme selector above the page.
|
||||
import MatrixSelector from '#lib/stats/MatrixSelector.svelte';
|
||||
let { children } = $props();
|
||||
const tabs = [
|
||||
{ name: 'overview', href: '/stats', icon: 'stats-overview' },
|
||||
...statsPages.map((p) => ({ name: p.name, href: p.href, icon: p.icon }))
|
||||
];
|
||||
const path = $derived(page.url.pathname.replace(/\/$/, '') || '/');
|
||||
const active = (href: string) =>
|
||||
href === '/stats' ? path === href : path === href || path.startsWith(`${href}/`);
|
||||
const section = $derived(tabs.find((t) => t.href !== '/stats' && active(t.href))?.href);
|
||||
const subTabs = $derived(section ? statsSubPages[section] : undefined);
|
||||
</script>
|
||||
|
||||
<div class="stats-page">
|
||||
<nav class="stats-tabs" aria-label="Stats sections">
|
||||
{#each tabs as tab (tab.href)}
|
||||
<a
|
||||
href={resolve(tab.href as '/stats')}
|
||||
class:active={active(tab.href)}
|
||||
aria-current={path === tab.href ? 'page' : undefined}
|
||||
><TrackerIcon name={tab.icon} class="tab-icon size-6" />{tab.name}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if subTabs}
|
||||
<nav class="stats-tabs stats-subtabs" aria-label="Section pages">
|
||||
{#each subTabs as tab (tab.href)}
|
||||
<a
|
||||
href={resolve(tab.href as '/stats')}
|
||||
class:active={path === tab.href}
|
||||
aria-current={path === tab.href ? 'page' : undefined}
|
||||
><TrackerIcon name={tab.icon} class="tab-icon size-5" />{tab.name}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
<MatrixSelector />
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -50,42 +18,6 @@
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.stats-tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
.stats-tabs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
.stats-tabs a:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.stats-tabs a.active {
|
||||
background: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.stats-subtabs {
|
||||
margin-top: -0.9rem;
|
||||
border-bottom-style: dashed;
|
||||
}
|
||||
.stats-subtabs a {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.65rem 0.25rem 0.3rem;
|
||||
}
|
||||
/* Shared page furniture for every /stats page. */
|
||||
.stats-page :global(.stats-intro) {
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { cached, loadFreshness, loadSeries } from '#lib/server/stats.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { parsePlaces, statsHref, THEME_BY_SLUG, type ThemeSlug } from '#lib/stats/matrix.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
// /stats?places=NLD,KOR&theme=economy → /stats/nld,kor/economy
|
||||
const places = url.searchParams.get('places');
|
||||
if (places) {
|
||||
const sel = parsePlaces(places);
|
||||
const theme = THEME_BY_SLUG.get((url.searchParams.get('theme') ?? 'overview') as ThemeSlug);
|
||||
if (sel) redirect(307, statsHref(sel.slug, theme?.slug ?? 'overview'));
|
||||
}
|
||||
const [series, freshness] = await Promise.all([
|
||||
cached('stats:overview', () =>
|
||||
loadSeries([
|
||||
|
||||
@@ -41,49 +41,55 @@
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
href: '/stats/economy',
|
||||
href: '/stats/core/economy',
|
||||
title: 'Economy',
|
||||
icon: 'stats-economy',
|
||||
text: 'Central bank rates, inflation, bond yields and spreads, stock markets, growth, government debt and deficits, migration.'
|
||||
},
|
||||
{
|
||||
href: '/stats/energy',
|
||||
href: '/stats/core/jobs',
|
||||
title: 'Jobs & cost of living',
|
||||
icon: 'stats-jobs',
|
||||
text: 'For recent grads: unemployment, how hard it is to find a job, graduate outcomes, job quality, wages, rents and what life costs.'
|
||||
},
|
||||
{
|
||||
href: '/stats/nld/energy',
|
||||
title: 'Energy',
|
||||
icon: 'stats-energy',
|
||||
text: 'Dutch household tariffs, Europe vs the US, oil and gas, import dependence, renewables, heat pumps, solar and EVs.'
|
||||
},
|
||||
{
|
||||
href: '/stats/gouda',
|
||||
href: '/stats/gouda/overview',
|
||||
title: 'Gouda',
|
||||
icon: 'stats-gouda',
|
||||
text: 'Population since 1942, house prices, housing stock, municipal levies and neighbourhoods, next to the surrounding towns.'
|
||||
},
|
||||
{
|
||||
href: '/stats/countries',
|
||||
href: '/stats/core/overview',
|
||||
title: 'Countries',
|
||||
icon: 'stats-countries',
|
||||
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',
|
||||
href: '/stats/cuw/overview',
|
||||
title: 'Curaçao',
|
||||
icon: 'stats-curacao',
|
||||
text: 'Population, income, inflation, tourism, trade and government finances of Curaçao.'
|
||||
},
|
||||
{
|
||||
href: '/stats/elections',
|
||||
href: '/stats/nld/politics',
|
||||
title: 'Elections',
|
||||
icon: 'stats-elections',
|
||||
text: 'Every Dutch general election since 1918, Gouda down to the polling station, and the US presidential vote by state.'
|
||||
},
|
||||
{
|
||||
href: '/stats/history',
|
||||
href: '/stats/world/history',
|
||||
title: 'History and culture',
|
||||
icon: 'stats-history',
|
||||
text: 'The Netherlands, Gouda, Curaçao, the US and Korea over the long run, a sourced timeline, office holders, religion, holidays, UNESCO sites and famous people.'
|
||||
},
|
||||
{
|
||||
href: '/stats/opinions',
|
||||
href: '/stats/core/politics',
|
||||
title: 'Opinions',
|
||||
icon: 'stats-opinions',
|
||||
text: 'Seat and approval polls, Eurobarometer worries and trust, world happiness, search trends, news themes and party positions.'
|
||||
@@ -104,13 +110,14 @@
|
||||
<h1>Statistics</h1>
|
||||
<p>
|
||||
Numbers I like to keep an eye on, pulled straight from official open data (CBS, Eurostat, FRED,
|
||||
the BIS) and refreshed on a schedule. Every chart names its source.
|
||||
the BIS) and refreshed on a schedule. Every chart names its source. Pick a place (or compare
|
||||
several) and a theme above; the shortcuts below jump to the richest pages.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="kpis">
|
||||
<Kpi
|
||||
href="/stats/energy"
|
||||
href="/stats/nld/energy"
|
||||
label="Brent crude"
|
||||
value={num(k.brent?.[1], 1)}
|
||||
unit="$/bbl"
|
||||
@@ -120,7 +127,7 @@
|
||||
color="#64748b"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/energy"
|
||||
href="/stats/nld/energy"
|
||||
label="EU gas (TTF)"
|
||||
value={num(k.ttf?.[1], 1)}
|
||||
unit="$/MMBtu"
|
||||
@@ -130,7 +137,7 @@
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/energy"
|
||||
href="/stats/nld/energy"
|
||||
label="NL electricity"
|
||||
value={num(k.elec?.[1], 3)}
|
||||
unit="€/kWh"
|
||||
@@ -138,7 +145,7 @@
|
||||
color="#eab308"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/energy"
|
||||
href="/stats/nld/energy"
|
||||
label="Renewable electricity"
|
||||
value={num(k.renew?.[1], 0)}
|
||||
unit="%"
|
||||
@@ -146,7 +153,7 @@
|
||||
color="#10b981"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/economy"
|
||||
href="/stats/core/economy"
|
||||
label="Fed / ECB / BoK"
|
||||
value={`${num(k.fed?.[1], 2)} / ${num(k.ecb?.[1], 2)} / ${num(k.bok?.[1], 2)}`}
|
||||
unit="%"
|
||||
@@ -154,7 +161,7 @@
|
||||
color="#a855f7"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/economy"
|
||||
href="/stats/core/economy"
|
||||
label="Dutch inflation"
|
||||
value={num(k.cpi?.[1], 1)}
|
||||
unit="%"
|
||||
@@ -162,7 +169,7 @@
|
||||
color="#e11d48"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/economy"
|
||||
href="/stats/core/economy"
|
||||
label="France – Germany 10y"
|
||||
value={num(k.frde ? k.frde[1] * 100 : null, 0)}
|
||||
unit="bp"
|
||||
@@ -170,7 +177,7 @@
|
||||
color="#06b6d4"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/gouda"
|
||||
href="/stats/gouda/overview"
|
||||
label="Gouda inhabitants"
|
||||
value={num(k.pop?.[1], 0)}
|
||||
sub={` in a year · ${period(k.pop?.[0])}`}
|
||||
@@ -178,7 +185,7 @@
|
||||
color="#f97316"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/gouda"
|
||||
href="/stats/gouda/overview"
|
||||
label="Gouda avg house price"
|
||||
value={`€${compact(k.house?.[1])}`}
|
||||
sub={`${period(k.house?.[0], 'A')} · NL €${compact(k.houseNl?.[1])}`}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { LEGACY, parsePlaces, statsHref } from '#lib/stats/matrix.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// /stats/<place> → its overview; the old section URLs (/stats/economy, …) move permanently.
|
||||
export const GET: RequestHandler = ({ params, url }) => {
|
||||
const legacy = LEGACY[params.place.toLowerCase()];
|
||||
if (legacy) redirect(301, `${legacy}${url.search}`);
|
||||
const sel = parsePlaces(params.place);
|
||||
if (!sel) error(404, 'Not Found');
|
||||
redirect(307, `${statsHref(sel.slug, 'overview')}${url.search}`);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { loadMetrics, loadView } from '#lib/server/stats-matrix.js';
|
||||
import {
|
||||
PLACE_BY_CODE,
|
||||
parsePlaces,
|
||||
PROFILE_CODES,
|
||||
statsHref,
|
||||
THEME_BY_SLUG,
|
||||
viewsFor,
|
||||
type ThemeSlug
|
||||
} from '#lib/stats/matrix.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const place = params.place.toLowerCase();
|
||||
const theme = params.theme.toLowerCase();
|
||||
// Old nested URLs: /stats/countries/NLD and /stats/opinions/world.
|
||||
if (place === 'countries') {
|
||||
const code = theme.toUpperCase();
|
||||
if (PROFILE_CODES.includes(code))
|
||||
redirect(301, `${statsHref(PLACE_BY_CODE.get(code)!.slug, 'overview')}${url.search}`);
|
||||
error(404, 'Not Found');
|
||||
}
|
||||
if (place === 'opinions' && theme === 'world')
|
||||
redirect(301, `${statsHref('world', 'politics')}${url.search}`);
|
||||
|
||||
const sel = parsePlaces(params.place);
|
||||
const t = THEME_BY_SLUG.get(theme as ThemeSlug);
|
||||
if (!sel || !t) error(404, 'Not Found');
|
||||
if (sel.slug !== params.place || t.slug !== params.theme)
|
||||
redirect(308, `${statsHref(sel.slug, t.slug)}${url.search}`);
|
||||
|
||||
const [metrics, views] = await Promise.all([
|
||||
loadMetrics(sel, t),
|
||||
Promise.all(viewsFor(sel, t.slug).map(loadView))
|
||||
]);
|
||||
return {
|
||||
place: sel.slug,
|
||||
theme: t.slug,
|
||||
metrics,
|
||||
views,
|
||||
seo: { title: `${t.name} · ${sel.label}`, description: `${sel.label}: ${t.blurb}` }
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
// One cell of the /stats matrix: a place (or comparison) × a theme. Rich hand-built views
|
||||
// render where one fits the combination; otherwise the generic metric charts do.
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import MatrixMetrics from '#lib/stats/MatrixMetrics.svelte';
|
||||
import EconomyView from '#lib/stats/views/EconomyView.svelte';
|
||||
import EnergyView from '#lib/stats/views/EnergyView.svelte';
|
||||
import GoudaView from '#lib/stats/views/GoudaView.svelte';
|
||||
import CountriesView from '#lib/stats/views/CountriesView.svelte';
|
||||
import CountryView from '#lib/stats/views/CountryView.svelte';
|
||||
import ElectionsView from '#lib/stats/views/ElectionsView.svelte';
|
||||
import OpinionsView from '#lib/stats/views/OpinionsView.svelte';
|
||||
import WorldOpinionsView from '#lib/stats/views/WorldOpinionsView.svelte';
|
||||
import HistoryView from '#lib/stats/views/HistoryView.svelte';
|
||||
import PlaceHeritage from '#lib/stats/PlaceHeritage.svelte';
|
||||
import { parsePlaces, THEME_BY_SLUG } from '#lib/stats/matrix.js';
|
||||
|
||||
let { data } = $props();
|
||||
const sel = $derived(parsePlaces(data.place)!);
|
||||
const theme = $derived(THEME_BY_SLUG.get(data.theme)!);
|
||||
const common = $derived({ sources: data.sources, attributions: data.attributions });
|
||||
</script>
|
||||
|
||||
{#if !data.views.length}
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / {sel.label.toLowerCase()} / {theme.name.toLowerCase()}</span
|
||||
>
|
||||
<h1 class="matrix-title">
|
||||
<TrackerIcon name={theme.icon} class="size-9" />
|
||||
<span>{theme.name} <span class="where">· {sel.label}</span></span>
|
||||
</h1>
|
||||
<p>{theme.blurb}</p>
|
||||
</section>
|
||||
<MatrixMetrics metrics={data.metrics} codes={sel.codes} attributions={data.attributions} />
|
||||
{:else}
|
||||
{#each data.views as v, i (i)}
|
||||
{#if v.kind === 'economy'}
|
||||
<EconomyView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'energy'}
|
||||
<EnergyView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'gouda'}
|
||||
<GoudaView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'countries'}
|
||||
<CountriesView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'country'}
|
||||
<CountryView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'elections'}
|
||||
<ElectionsView data={{ ...v.data, ...common }} focus={v.focus} />
|
||||
{:else if v.kind === 'opinions'}
|
||||
<OpinionsView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'worldOpinions'}
|
||||
<WorldOpinionsView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'history'}
|
||||
<HistoryView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'heritage' && v.data.heritage}
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / {sel.label.toLowerCase()} / history</span>
|
||||
<h1 class="matrix-title">
|
||||
<TrackerIcon name={theme.icon} class="size-9" />
|
||||
<span>History <span class="where">· {sel.label}</span></span>
|
||||
</h1>
|
||||
</section>
|
||||
<PlaceHeritage h={v.data.heritage} attributions={data.attributions} />
|
||||
{/if}
|
||||
{/each}
|
||||
{#if data.metrics.some((m) => m.lines.length) && !['overview', 'economy', 'energy'].includes(data.theme)}
|
||||
<h2 class="stats-section-title">{theme.name} at a glance · {sel.label}</h2>
|
||||
<MatrixMetrics metrics={data.metrics} codes={sel.codes} attributions={data.attributions} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.matrix-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.where {
|
||||
color: var(--site-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -1,29 +0,0 @@
|
||||
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 };
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
<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>
|
||||
@@ -1,29 +0,0 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { cached, loadSeries } from '#lib/server/stats.js';
|
||||
import { isCountryPage, PROFILE_SERIES } from '#lib/stats/countries.js';
|
||||
import { CW_SERIES } from '#lib/stats/curacao.js';
|
||||
import { isHeritage } from '#lib/stats/heritage.js';
|
||||
import { loadHeritage } from '#lib/server/stats-history.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 heritage = isHeritage(code) ? loadHeritage(code) : null;
|
||||
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}`
|
||||
]),
|
||||
// §8: Curaçao detail from CBS Curaçao and the international APIs (ids carry no code suffix).
|
||||
...(code === 'CUW' ? CW_SERIES : [])
|
||||
])
|
||||
);
|
||||
return { code, series, heritage: heritage ? await heritage : null };
|
||||
};
|
||||
@@ -1,737 +0,0 @@
|
||||
<script lang="ts">
|
||||
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 } = $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>
|
||||
|
||||
<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
|
||||
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>
|
||||
@@ -1,59 +0,0 @@
|
||||
import { cached, loadSeries } from '#lib/server/stats.js';
|
||||
import {
|
||||
ANNUAL,
|
||||
CPI_BY_CATEGORY,
|
||||
CPI_TOTAL,
|
||||
ECON_COUNTRIES,
|
||||
POLICY_RATE
|
||||
} from '#lib/stats/econ.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020', 'EA20'];
|
||||
const YIELDS = ['NL', 'DE', 'FR', 'IT', 'ES', 'BE', 'AT', 'EA'];
|
||||
const REGIONS = [...ECON_COUNTRIES, 'EU27', 'EA'];
|
||||
|
||||
export const load: PageServerLoad = async () => ({
|
||||
series: await cached('stats:economy', () =>
|
||||
loadSeries([
|
||||
...[...new Set(Object.values(POLICY_RATE))].map((a) => ({
|
||||
id: `bis:M:${a}`,
|
||||
from: '1990-01-01'
|
||||
})),
|
||||
...EU.map((g) => `eurostat:prc_hicp_minr:RCH_A.TOTAL.${g}`),
|
||||
// CPI index levels (all items, then the categories)
|
||||
...[
|
||||
...new Set([
|
||||
...Object.values(CPI_TOTAL).map((c) => c.id),
|
||||
...Object.values(CPI_BY_CATEGORY).flatMap((c) => Object.values(c.ids))
|
||||
])
|
||||
].map((id) => ({ id, from: '1960-01-01' })),
|
||||
...REGIONS.map((c) => ({ id: `oecd:irlt:${c}`, from: '1980-01-01' })),
|
||||
...YIELDS.map((g) => `eurostat:irt_lt_mcby_m:MCBY.${g}`),
|
||||
{ id: 'fred:DGS10', from: '1990-01-01', resample: 'M' },
|
||||
{ id: 'fred:DGS2', from: '1990-01-01', resample: 'M' },
|
||||
...['NL', 'KR', 'US', 'CN'].map((c) => ({
|
||||
id: `fred:SPASTT01${c}M661N`,
|
||||
from: '1990-01-01'
|
||||
})),
|
||||
{ id: 'fred:SP500', resample: 'W' },
|
||||
{ id: 'fred:NASDAQCOM', from: '1990-01-01', resample: 'W' },
|
||||
...Object.values(ANNUAL).flatMap((ind) =>
|
||||
REGIONS.flatMap((c) => [
|
||||
...(ind.imf ? [{ id: `imf:${ind.imf}:${c}`, from: '1980-01-01' }] : []),
|
||||
{ id: `wb:${ind.wb}:${c}`, from: '1980-01-01' }
|
||||
])
|
||||
),
|
||||
...Object.values(ANNUAL).flatMap((ind) => (ind.cw ? [ind.cw] : [])),
|
||||
'fred:GDPC1',
|
||||
'fred:UNRATE',
|
||||
...EU.flatMap((g) => [
|
||||
`eurostat:gov_10dd_edpt1:PC_GDP.S13.GD.${g}`,
|
||||
`eurostat:gov_10dd_edpt1:PC_GDP.S13.B9.${g}`,
|
||||
`eurostat:gov_10a_main:PC_GDP.S13.TE.${g}`,
|
||||
`eurostat:gov_10a_main:PC_GDP.S13.TR.${g}`,
|
||||
`eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.${g}`,
|
||||
`eurostat:demo_pjan:NR.TOTAL.T.${g}`
|
||||
])
|
||||
])
|
||||
)
|
||||
});
|
||||
@@ -1,598 +0,0 @@
|
||||
<script lang="ts">
|
||||
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 } = $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>
|
||||
|
||||
<svelte:head>
|
||||
<title>economy · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Consumer prices (index levels and inflation by category), interest rates, bond yields, growth, unemployment, debt and trade for Canada, South Korea, China, Japan, the US, the Netherlands and Curaçao, with the EU as a comparison."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<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>
|
||||
@@ -1,83 +0,0 @@
|
||||
import { cached } from '#lib/server/stats.js';
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export type ElecRow = {
|
||||
election: string;
|
||||
date: string;
|
||||
party: string;
|
||||
votes: number | null;
|
||||
seats: number | null;
|
||||
};
|
||||
|
||||
const rows = (r: readonly Record<string, unknown>[]): ElecRow[] =>
|
||||
r.map((x) => ({
|
||||
election: x.election as string,
|
||||
date: x.date as string,
|
||||
party: x.party as string,
|
||||
votes: x.votes as number | null,
|
||||
seats: (x.seats as number | null) ?? null
|
||||
}));
|
||||
|
||||
export const load: PageServerLoad = async () =>
|
||||
cached('stats:elections', async () => {
|
||||
const [elections, nl, turnout, gouda, stations, us, usStates] = await Promise.all([
|
||||
client`select election_id, country, date::text as date, name, total_seats from stats.elec_election order by date`,
|
||||
client`select r.election_id as election, e.date::text as date, r.party, r.votes::float8 as votes, r.seats
|
||||
from stats.elec_result r join stats.elec_election e using (election_id)
|
||||
where e.country = 'NLD' and r.level = 'national'`,
|
||||
client`select t.election_id as election, e.date::text as date, t.electorate::float8 as electorate,
|
||||
t.ballots::float8 as ballots, t.valid::float8 as valid
|
||||
from stats.elec_turnout t join stats.elec_election e using (election_id)
|
||||
where t.level = 'national' order by e.date`,
|
||||
client`select r.election_id as election, e.date::text as date, r.party, r.votes::float8 as votes
|
||||
from stats.elec_result r join stats.elec_election e using (election_id)
|
||||
where r.level = 'muni' and r.area_code = 'GM0513'`,
|
||||
client`select r.area_code, r.area_name, r.party, r.votes::float8 as votes
|
||||
from stats.elec_result r
|
||||
where r.level = 'station' and r.election_id = (
|
||||
select max(election_id) from stats.elec_result where level = 'station')`,
|
||||
client`select r.election_id as election, e.date::text as date, r.party, r.votes::float8 as votes
|
||||
from stats.elec_result r join stats.elec_election e using (election_id)
|
||||
where e.country = 'USA' and r.level = 'national'`,
|
||||
client`select r.area_code, r.area_name, r.party, r.votes::float8 as votes
|
||||
from stats.elec_result r
|
||||
where r.level = 'state' and r.election_id = (
|
||||
select max(election_id) from stats.elec_election where country = 'USA')`
|
||||
]);
|
||||
const stationElection = (
|
||||
await client`select max(election_id) as id from stats.elec_result where level = 'station'`
|
||||
)[0]?.id as string | null;
|
||||
return {
|
||||
elections: elections.map((e) => ({
|
||||
id: e.election_id as string,
|
||||
country: e.country as string,
|
||||
date: e.date as string,
|
||||
name: e.name as string,
|
||||
seats: e.total_seats as number | null
|
||||
})),
|
||||
nl: rows(nl),
|
||||
turnout: turnout.map((t) => ({
|
||||
election: t.election as string,
|
||||
date: t.date as string,
|
||||
electorate: t.electorate as number | null,
|
||||
ballots: t.ballots as number | null,
|
||||
valid: t.valid as number | null
|
||||
})),
|
||||
gouda: rows(gouda),
|
||||
stationElection,
|
||||
stations: stations.map((s) => ({
|
||||
code: s.area_code as string,
|
||||
name: s.area_name as string,
|
||||
party: s.party as string,
|
||||
votes: s.votes as number | null
|
||||
})),
|
||||
us: rows(us),
|
||||
usStates: usStates.map((s) => ({
|
||||
code: s.area_code as string,
|
||||
name: s.area_name as string,
|
||||
party: s.party as string,
|
||||
votes: s.votes as number | null
|
||||
}))
|
||||
};
|
||||
});
|
||||
@@ -1,430 +0,0 @@
|
||||
<script lang="ts">
|
||||
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 } = $props();
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>Elections · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Dutch general elections since 1918, Gouda per polling station, and US presidential results by state since 1976."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<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">
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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}
|
||||
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,65 +0,0 @@
|
||||
import { cached, loadSeries } from '#lib/server/stats.js';
|
||||
import { loadEnergy } from '#lib/server/energy.js';
|
||||
import { epSeriesIds } from '#lib/stats/energylabels.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020'];
|
||||
const RENEW = ['total', 'wind-onshore', 'wind-offshore', 'solar', 'biomass', 'hydro'];
|
||||
|
||||
export const load: PageServerLoad = async () => ({
|
||||
// Live NED grid + gas storage (read-only, ned-collector's `energy` schema).
|
||||
energy: await cached('stats:energy:live', loadEnergy),
|
||||
series: await cached('stats:energy', () =>
|
||||
loadSeries([
|
||||
...['variable', 'tax', 'transport', 'fixed', 'taxcredit', 'dynamic'].map(
|
||||
(k) => `cbs:tariff:elec:${k}`
|
||||
),
|
||||
...['variable', 'tax', 'transport', 'fixed'].map((k) => `cbs:tariff:gas:${k}`),
|
||||
...['variable', 'tax', 'ode'].flatMap((k) => [
|
||||
`cbs:tariff1823:elec:${k}`,
|
||||
`cbs:tariff1823:gas:${k}`
|
||||
]),
|
||||
'cbs:price0723:elec',
|
||||
'cbs:price0723:gas',
|
||||
'cbs:cpi:energy:yoy',
|
||||
...EU.flatMap((g) => [
|
||||
`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_pc_204:E7000.KWH3500.KWH.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_pc_202:G3000.GJ83P70.GJ_GCV.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_ind_id:TOTAL.PC.${g}`
|
||||
]),
|
||||
...['X_TAX', 'X_VAT'].flatMap((t) => [
|
||||
`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.${t}.EUR.NL`,
|
||||
`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.${t}.EUR.NL`
|
||||
]),
|
||||
{ id: 'fred:APU000072610', from: '2007-01-01' },
|
||||
{ id: 'fred:DCOILBRENTEU', resample: 'W' },
|
||||
'fred:PNGASEUUSDM',
|
||||
'cbs:energy:tpes',
|
||||
'cbs:energy:netimport',
|
||||
'cbs:energy:production',
|
||||
'cbs:energy:gas:production',
|
||||
'cbs:energy:gas:netimport',
|
||||
'cbs:energy:gas:tpes',
|
||||
...RENEW.flatMap((k) => [`cbs:renew:${k}:share`, `cbs:renew:${k}:mw`]),
|
||||
'cbs:heatpumps:homes',
|
||||
'cbs:heatpumps:homes:air',
|
||||
'cbs:heatpumps:homes:ground',
|
||||
'cbs:heatpumps:new',
|
||||
...['NL01', 'GM0513'].flatMap((r) => [
|
||||
`cbs:solar:homes:kwp:${r}`,
|
||||
`cbs:solar:homes:n:${r}`,
|
||||
`cbs:solar:kwp:${r}`
|
||||
]),
|
||||
'cbs:pop:jan1:GM0513',
|
||||
'cbs:cars:total',
|
||||
'cbs:cars:electric',
|
||||
'cbs:cars:petrol',
|
||||
'cbs:cars:diesel',
|
||||
'eurostat:road_eqr_carpda:NR.ELC.NL',
|
||||
'eurostat:road_eqr_carpda:NR.TOTAL.NL',
|
||||
...epSeriesIds(['NL', 'GOUDA'])
|
||||
])
|
||||
)
|
||||
});
|
||||
@@ -1,633 +0,0 @@
|
||||
<script lang="ts">
|
||||
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 } = $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>
|
||||
|
||||
<svelte:head>
|
||||
<title>energy · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="The live Dutch grid mix and gas storage, household energy tariffs, European and US prices, oil and gas, import dependence, renewables, heat pumps, solar and electric cars."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<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}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { cached, loadCbsCross, loadSeries } from '#lib/server/stats.js';
|
||||
import { epSeriesIds, GOUDA_PC4 } from '#lib/stats/energylabels.js';
|
||||
import { loadHeritage } from '#lib/server/stats-history.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Gouda (GM0513) and its neighbours; NL01 is the national total.
|
||||
const RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];
|
||||
const KWB_TABLE = '85984NED'; // Kerncijfers wijken en buurten 2024
|
||||
const KWB_MEASURES = [
|
||||
'T001036',
|
||||
'M000100',
|
||||
'M001642',
|
||||
'1014850_2',
|
||||
'A047047',
|
||||
'M000224',
|
||||
'M008349',
|
||||
'M000221_2',
|
||||
'M000219_2',
|
||||
'M008295',
|
||||
'M008297'
|
||||
];
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const [series, wijken, heritage] = await Promise.all([
|
||||
cached('stats:gouda', () =>
|
||||
loadSeries([
|
||||
...['NL01', ...RING].flatMap((r) => [
|
||||
`cbs:pop:monthly:${r}`,
|
||||
`cbs:pop:jan1:${r}`,
|
||||
`cbs:house:avgprice:${r}`,
|
||||
...['total', 'owner', 'social', 'private'].map((k) => `cbs:stock:${k}:${r}`)
|
||||
]),
|
||||
...RING.flatMap((r) => ['total', 'ozb', 'sewer', 'waste'].map((k) => `cbs:levy:${k}:${r}`)),
|
||||
...['births', 'deaths', 'immigration', 'emigration', 'in', 'out', 'growth'].map(
|
||||
(k) => `cbs:pop:${k}:GM0513`
|
||||
),
|
||||
'cbs:house:hpi:cr28',
|
||||
'cbs:house:hpi:nl',
|
||||
...epSeriesIds(['NL', 'GOUDA', ...GOUDA_PC4])
|
||||
])
|
||||
),
|
||||
cached('stats:gouda:kwb', () =>
|
||||
loadCbsCross(KWB_TABLE, KWB_MEASURES, 'WijkenEnBuurten', ['WK0513', 'GM0513', 'NL00'])
|
||||
),
|
||||
loadHeritage('GM0513')
|
||||
]);
|
||||
return { series, wijken, kwbYear: 2024, heritage };
|
||||
};
|
||||
@@ -1,426 +0,0 @@
|
||||
<script lang="ts">
|
||||
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 } = $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>
|
||||
|
||||
<svelte:head>
|
||||
<title>Gouda · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Gouda in numbers: population since 1942, births, deaths and migration, house prices, housing stock, municipal levies and neighbourhoods, compared with the surrounding municipalities."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / gouda</span>
|
||||
<h1>Gouda</h1>
|
||||
<p>
|
||||
My home town in numbers, next to its neighbours: Waddinxveen, Bodegraven-Reeuwijk, Zuidplas,
|
||||
Krimpenerwaard, Alphen aan den Rijn and Woerden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="kpis">
|
||||
<Kpi
|
||||
label="Inhabitants"
|
||||
value={num(pop?.[1], 0)}
|
||||
sub={` in a year · ${period(pop?.[0])}`}
|
||||
delta={popYoy}
|
||||
color="#f97316"
|
||||
/>
|
||||
<Kpi
|
||||
label="Average sale price"
|
||||
value={`€${compact(price?.[1])}`}
|
||||
sub={`${period(price?.[0], 'A')} · NL €${compact(priceNl?.[1])}`}
|
||||
color="#e11d48"
|
||||
/>
|
||||
<Kpi
|
||||
label="House prices, region"
|
||||
value={num(hpi.at(-1)?.[1], 0)}
|
||||
unit="index"
|
||||
sub={` in a year · ${period(hpi.at(-1)?.[0], 'Q')}`}
|
||||
delta={hpiYoy}
|
||||
deltaUnit="%"
|
||||
invert
|
||||
color="#a855f7"
|
||||
/>
|
||||
<Kpi
|
||||
label="Homes"
|
||||
value={num(stock?.[1], 0)}
|
||||
sub={`${num(social && stock ? (social[1] / stock[1]) * 100 : null, 0)}% social rent · ${period(stock?.[0], 'A')}`}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<Kpi
|
||||
label="Municipal levies"
|
||||
value={`€${num(levy?.[1], 0)}`}
|
||||
sub={`multi-person household, ${period(levy?.[0], 'A')}`}
|
||||
color="#10b981"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">People</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Population of Gouda since 1942"
|
||||
subtitle="inhabitants on 1 January"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[{ name: 'Gouda', points: pts('cbs:pop:jan1:GM0513'), color: '#f97316', kind: 'area' }],
|
||||
{ decimals: 0, legend: false }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Growth since 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>
|
||||
@@ -1,4 +0,0 @@
|
||||
import { loadHistoryOverview } from '#lib/server/stats-history.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => loadHistoryOverview();
|
||||
@@ -1,203 +0,0 @@
|
||||
<script lang="ts">
|
||||
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 } = $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>
|
||||
|
||||
<svelte:head>
|
||||
<title>History · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="The Netherlands, Gouda, Curaçao, the US and South Korea over the long run: population, income, life expectancy, urbanisation and religion, plus a sourced timeline of key events."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<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>
|
||||
@@ -1,101 +0,0 @@
|
||||
import { cached } from '#lib/server/stats.js';
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import { ebLabel, latestPolls, pollAverages, type Poll } from '#lib/stats/opinions.js';
|
||||
|
||||
// Averages are computed here so the page gets a few hundred points per series, not every poll row.
|
||||
function pollData(polls: Poll[]) {
|
||||
const noRaw = (s: ReturnType<typeof pollAverages>) => s.map((o) => ({ ...o, raw: [] }));
|
||||
return {
|
||||
nl: pollAverages(polls, 'nl-tk-seats', 5),
|
||||
nlLatest: latestPolls(polls, 'nl-tk-seats', 8),
|
||||
us: pollAverages(polls, 'us-trump-approval', 10),
|
||||
usAgg: latestPolls(polls, 'us-trump-approval-aggregate', 20),
|
||||
kr: noRaw(pollAverages(polls, 'kr-lee-approval', 10)),
|
||||
krParty: noRaw(pollAverages(polls, 'kr-party', 10))
|
||||
};
|
||||
}
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () =>
|
||||
cached('stats:opinions', async () => {
|
||||
const [polls, eb, whr, trends, gdelt, ches, vparty] = await Promise.all([
|
||||
client`select series, poll_key, fieldwork_end::text as date, pollster, sample, option, value
|
||||
from stats.op_poll where value is not null order by fieldwork_end`,
|
||||
client`select wave, fieldwork_end::text as date, topic, item, country, share
|
||||
from stats.eb_item where country in ('NL', 'EU27') and share is not null`,
|
||||
client`select year, country, iso3, rank, ladder, gdp, social, health, freedom, generosity, corruption
|
||||
from stats.whr_score order by year`,
|
||||
client`select geo, date::text as date, rank, query, approx_traffic
|
||||
from stats.gt_trending t where date = (select max(date) from stats.gt_trending where geo = t.geo)
|
||||
order by geo, rank`,
|
||||
client`select date::text as date, country, theme, vol_pct from stats.gdelt_theme_vol
|
||||
where date > now() - interval '45 days' order by date`,
|
||||
client`select p.party_id, coalesce(p.abbrev, p.name) as label, p.name, p.family, pp.year, pp.dim,
|
||||
pp.value, pp.vote_share
|
||||
from stats.wo_party_position pp join stats.wo_party p using (party_id)
|
||||
where p.iso3 = 'NLD' and pp.source = 'ches'
|
||||
and pp.year = (select max(year) from stats.wo_party_position where source = 'ches')`,
|
||||
client`select p.party_id, coalesce(p.abbrev, p.name) as label, pp.year, pp.value, pp.vote_share
|
||||
from stats.wo_party_position pp join stats.wo_party p using (party_id)
|
||||
where p.iso3 = 'NLD' and pp.source = 'vparty' and pp.dim = 'v2xpa_popul' and pp.year >= 1946
|
||||
order by pp.year`
|
||||
]);
|
||||
return {
|
||||
...pollData(
|
||||
polls.map((p): Poll => ({
|
||||
series: p.series as string,
|
||||
key: p.poll_key as string,
|
||||
date: p.date as string,
|
||||
pollster: p.pollster as string,
|
||||
sample: p.sample as number | null,
|
||||
option: p.option as string,
|
||||
value: Number(p.value)
|
||||
}))
|
||||
),
|
||||
eb: eb.map((r) => ({
|
||||
wave: r.wave as string,
|
||||
date: r.date as string,
|
||||
topic: r.topic as string,
|
||||
item: ebLabel(r.topic as string, r.item as string),
|
||||
country: r.country as string,
|
||||
share: Math.round(Number(r.share) * 1000) / 10
|
||||
})),
|
||||
whr: whr.map((r) => ({
|
||||
year: r.year as number,
|
||||
country: r.country as string,
|
||||
iso3: r.iso3 as string | null,
|
||||
rank: r.rank as number | null,
|
||||
ladder: r.ladder == null ? null : Number(r.ladder)
|
||||
})),
|
||||
trends: trends.map((r) => ({
|
||||
geo: r.geo as string,
|
||||
date: r.date as string,
|
||||
rank: r.rank as number,
|
||||
query: r.query as string,
|
||||
traffic: r.approx_traffic as number | null
|
||||
})),
|
||||
gdelt: gdelt.map((r) => ({
|
||||
date: r.date as string,
|
||||
country: (r.country as string).trim(),
|
||||
theme: r.theme as string,
|
||||
vol: Number(r.vol_pct)
|
||||
})),
|
||||
ches: ches.map((r) => ({
|
||||
id: r.party_id as string,
|
||||
label: r.label as string,
|
||||
name: r.name as string | null,
|
||||
family: r.family as string | null,
|
||||
year: r.year as number,
|
||||
dim: r.dim as string,
|
||||
value: Number(r.value),
|
||||
share: r.vote_share == null ? null : Number(r.vote_share)
|
||||
})),
|
||||
vparty: vparty.map((r) => ({
|
||||
id: r.party_id as string,
|
||||
label: r.label as string,
|
||||
year: r.year as number,
|
||||
value: Number(r.value),
|
||||
share: r.vote_share == null ? null : Number(r.vote_share)
|
||||
}))
|
||||
};
|
||||
});
|
||||
@@ -1,612 +0,0 @@
|
||||
<script lang="ts">
|
||||
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 } = $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>
|
||||
|
||||
<svelte:head>
|
||||
<title>Opinions · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Polls in the Netherlands, the US and South Korea, what Europeans worry about and whom they trust, world happiness, search trends, news themes and where Dutch parties stand."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<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/opinions/world')}>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>
|
||||
@@ -1,4 +0,0 @@
|
||||
import { loadWorldOpinions } from '#lib/server/world-opinions.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => ({ wo: await loadWorldOpinions() });
|
||||
@@ -1,146 +0,0 @@
|
||||
<script lang="ts">
|
||||
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 } = $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>
|
||||
|
||||
<svelte:head>
|
||||
<title>world opinions · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="How political words, parties, values and national histories differ across 57 countries: what liberal means, what left and right are about, a shared party compass and a values map."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<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/opinions') + '#parties'}
|
||||
>where Dutch parties stand</a
|
||||
>; for polls, happiness and Eurobarometer see <a href={resolve('/stats/opinions')}>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/opinions') + '#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>
|
||||
@@ -1,302 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
Reference in new issue
Block a user