stats: place × theme matrix (/stats/<place>/<theme>), sticky place/theme selector, generic metric engine with comparisons, 301s from the old section URLs
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
This commit is contained in:
170
src/lib/stats/views/CountriesView.svelte
Normal file
170
src/lib/stats/views/CountriesView.svelte
Normal file
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import type { CountriesViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import { barRankOption, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||
import { compact, num } from '#lib/stats/format.js';
|
||||
import { COUNTRY } from '#lib/stats/regions.js';
|
||||
import { COMPARE_COLUMNS, COUNTRY_COLOR, FLAG } from '#lib/stats/countries.js';
|
||||
|
||||
let { data }: { data: CountriesViewData & StatsCommon } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
const name = (c: string) => COUNTRY[c] ?? c;
|
||||
const HIGHLIGHT = ['NLD', 'CUW', 'USA', 'KOR', 'CHN', 'WLD'];
|
||||
|
||||
function show(v: { value: number } | null, fmt: string) {
|
||||
if (!v) return '–';
|
||||
if (fmt === 'compact') return compact(v.value);
|
||||
if (fmt === 'usd') return `$${compact(v.value)}`;
|
||||
if (fmt === 'years') return num(v.value, 1);
|
||||
return `${num(v.value, 1)}%`;
|
||||
}
|
||||
const rank = (key: string) =>
|
||||
data.rows
|
||||
.filter((r) => r.values[key])
|
||||
.map((r) => ({
|
||||
label: `${FLAG[r.code] ?? ''} ${name(r.code)}`,
|
||||
value: r.values[key]!.value,
|
||||
highlight: r.code === 'NLD' || r.code === 'CUW'
|
||||
}));
|
||||
const gdpLines = $derived(
|
||||
HIGHLIGHT.map((c) => ({
|
||||
name: name(c),
|
||||
points: pts(`wb:NY.GDP.PCAP.PP.KD:${c}`),
|
||||
color: COUNTRY_COLOR[c]
|
||||
})).filter((l) => l.points.length)
|
||||
);
|
||||
const lifeLines = $derived(
|
||||
HIGHLIGHT.map((c) => ({
|
||||
name: name(c),
|
||||
points: pts(`wb:SP.DYN.LE00.IN:${c}`),
|
||||
color: COUNTRY_COLOR[c]
|
||||
})).filter((l) => l.points.length)
|
||||
);
|
||||
const newestYear = (key: string) =>
|
||||
data.rows.reduce((m, r) => {
|
||||
const p = r.values[key]?.period;
|
||||
return p && p > m ? p : m;
|
||||
}, '');
|
||||
</script>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / countries</span>
|
||||
<h1>Countries</h1>
|
||||
<p>
|
||||
The places I compare most, side by side. Latest actual values (no forecasts) from the World Bank
|
||||
and the IMF World Economic Outlook; the IMF is used where it covers a country. Click a country
|
||||
for its full profile.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Country</th>
|
||||
{#each COMPARE_COLUMNS as col (col.key)}<th class="num">{col.label}</th>{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.rows as r (r.code)}
|
||||
<tr class:hl={r.code === 'NLD' || r.code === 'CUW'}>
|
||||
<td>
|
||||
<a
|
||||
class="country-link"
|
||||
href={resolve(`/stats/${r.code.toLowerCase()}/overview` as '/stats')}
|
||||
><span class="flag">{FLAG[r.code]}</span>{name(r.code)}</a
|
||||
>
|
||||
</td>
|
||||
{#each COMPARE_COLUMNS as col (col.key)}
|
||||
{@const v = r.values[col.key]}
|
||||
<td class="num" title={v ? `${v.period.slice(0, 4)} · ${v.source}` : ''}
|
||||
>{show(v, col.fmt)}<small>{v ? ` '${v.period.slice(2, 4)}` : ''}</small></td
|
||||
>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="stats-note">
|
||||
Small numbers after each value are the year it refers to. GDP per capita in constant 2021
|
||||
international dollars (PPP). Growth, inflation, unemployment and debt from the IMF where
|
||||
available, otherwise the World Bank.
|
||||
</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="GDP per capita, PPP"
|
||||
subtitle={`constant 2021 international $ · ${newestYear('gdppc').slice(0, 4)}`}
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
height="420px"
|
||||
option={(t) => barRankOption(t, rank('gdppc'), { decimals: 0, unit: '$', color: '#10b981' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Life expectancy at birth"
|
||||
subtitle={`years · ${newestYear('life').slice(0, 4)}`}
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
height="420px"
|
||||
option={(t) => barRankOption(t, rank('life'), { decimals: 1, unit: 'yr', color: '#a855f7' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="GDP per capita over time"
|
||||
subtitle="PPP, constant 2021 international $"
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) => lineOption(t, gdpLines, { unit: '$', decimals: 0 })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Life expectancy since 1960"
|
||||
subtitle="years at birth"
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) => lineOption(t, lifeLines, { unit: 'years', decimals: 1, min: 'dataMin' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Government debt"
|
||||
subtitle="% of GDP · IMF where available, else World Bank (central government)"
|
||||
sources={['imf', 'worldbank']}
|
||||
attributions={a}
|
||||
height="420px"
|
||||
option={(t) => barRankOption(t, rank('debt'), { decimals: 0, unit: '%', color: '#e11d48' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Inflation"
|
||||
subtitle="% consumer prices, latest actual year"
|
||||
sources={['imf', 'worldbank']}
|
||||
attributions={a}
|
||||
height="420px"
|
||||
option={(t) => barRankOption(t, rank('infl'), { decimals: 1, unit: '%', color: '#f97316' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.country-link {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
color: var(--site-text);
|
||||
text-decoration: none;
|
||||
font-weight: 550;
|
||||
}
|
||||
.country-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.flag {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
tr.hl td {
|
||||
background: color-mix(in srgb, var(--site-text) 4%, transparent);
|
||||
}
|
||||
td small {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
</style>
|
||||
731
src/lib/stats/views/CountryView.svelte
Normal file
731
src/lib/stats/views/CountryView.svelte
Normal file
@@ -0,0 +1,731 @@
|
||||
<script lang="ts">
|
||||
import type { CountryViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
import {
|
||||
barRankOption,
|
||||
change,
|
||||
latest,
|
||||
lineOption,
|
||||
type Line,
|
||||
type StatPoint
|
||||
} from '#lib/stats/chart.js';
|
||||
import { CW_INDUSTRIES, CW_NIGHT_MARKETS, EU_TRADE } from '#lib/stats/curacao.js';
|
||||
import { compact, num, period } from '#lib/stats/format.js';
|
||||
import { COUNTRY } from '#lib/stats/regions.js';
|
||||
import { COUNTRY_COLOR, FLAG } from '#lib/stats/countries.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import PlaceHeritage from '#lib/stats/PlaceHeritage.svelte';
|
||||
|
||||
let { data }: { data: CountryViewData & StatsCommon } = $props();
|
||||
const c = $derived(data.code);
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const name = $derived(COUNTRY[c] ?? c);
|
||||
const color = $derived(COUNTRY_COLOR[c] ?? '#3b82f6');
|
||||
const has = (id: string) => (s[`${id}:${c}`]?.points.length ?? 0) > 0;
|
||||
const pts = (id: string, code = c): StatPoint[] => s[`${id}:${code}`]?.points ?? [];
|
||||
const forecastFrom = (id: string) => s[`${id}:${c}`]?.forecastFrom;
|
||||
|
||||
/** IMF series split into outcome (solid) and WEO projection (dashed). */
|
||||
function imfLines(id: string, label: string, col: string): Line[] {
|
||||
const all = pts(id);
|
||||
const from = forecastFrom(id);
|
||||
if (!from) return [{ name: label, points: all, color: col }];
|
||||
const cut = `${from}-01-01`;
|
||||
const past = all.filter(([d]) => d < cut);
|
||||
const future = all.filter(([d]) => d >= cut);
|
||||
return [
|
||||
{ name: label, points: past, color: col },
|
||||
{
|
||||
name: `${label} (IMF forecast)`,
|
||||
points: past.length ? [past[past.length - 1], ...future] : future,
|
||||
color: col,
|
||||
dashed: true
|
||||
}
|
||||
];
|
||||
}
|
||||
/** IMF if it covers the country, else the World Bank fallback. */
|
||||
const pick = (imf: string, wb: string) => (has(imf) ? imf : wb);
|
||||
const lastOf = (id: string) => {
|
||||
const from = forecastFrom(id);
|
||||
const p = pts(id).filter(([d]) => !from || d < `${from}-01-01`);
|
||||
return latest(p);
|
||||
};
|
||||
|
||||
const kPop = $derived(lastOf('wb:SP.POP.TOTL'));
|
||||
const kGdp = $derived(lastOf('wb:NY.GDP.PCAP.PP.KD'));
|
||||
const kGrowth = $derived(lastOf(pick('imf:NGDP_RPCH', 'wb:NY.GDP.MKTP.KD.ZG')));
|
||||
const kInfl = $derived(lastOf(pick('imf:PCPIPCH', 'wb:FP.CPI.TOTL.ZG')));
|
||||
const kUnemp = $derived(lastOf(pick('imf:LUR', 'wb:SL.UEM.TOTL.ZS')));
|
||||
const kLife = $derived(lastOf('wb:SP.DYN.LE00.IN'));
|
||||
const kTour = $derived(lastOf('wb:ST.INT.ARVL'));
|
||||
const kDebt = $derived(lastOf(pick('imf:GGXWDG_NGDP', 'wb:GC.DOD.TOTL.GD.ZS')));
|
||||
const popDelta = $derived(change(pts('wb:SP.POP.TOTL'), 10)?.pct ?? null);
|
||||
const imfCovered = $derived(has('imf:NGDP_RPCH'));
|
||||
|
||||
// §8 Curaçao detail series: stored without the `:CUW` suffix.
|
||||
const cw = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
const isCw = $derived(c === 'CUW' && cw('cw:cpi:index').length > 0);
|
||||
const cwGdpYear = $derived(latest(cw('cw:gdp:gdp'))?.[0]);
|
||||
const cwIndustries = $derived(
|
||||
Object.entries(CW_INDUSTRIES)
|
||||
.map(([k, label]) => ({
|
||||
label,
|
||||
value: cw(`cw:gdp:${k}`).find(([d]) => d === cwGdpYear)?.[1] ?? NaN
|
||||
}))
|
||||
.filter((r) => Number.isFinite(r.value))
|
||||
);
|
||||
const cwCpi = $derived(latest(cw('cw:cpi:yoy')));
|
||||
const cwPop = $derived(latest(cw('cw:pop:jan1')));
|
||||
const cwUnemp = $derived(latest(cw('cw:lfs:unemployment_rate_pct:total')));
|
||||
const cwGrowth = $derived(latest(cw('cw:gdp:real_growth_pct')));
|
||||
</script>
|
||||
|
||||
<section class="stats-intro">
|
||||
<a class="back" href={resolve('/stats/core/overview' as '/stats')}
|
||||
><ArrowLeft size={14} /> countries</a
|
||||
>
|
||||
<span class="stats-eyebrow">stats / countries / {c}</span>
|
||||
<h1><span class="flag">{FLAG[c]}</span> {name}</h1>
|
||||
<p>
|
||||
{#if c === 'CUW'}
|
||||
Curaçao has been a country within the Kingdom of the Netherlands since 2010, with about
|
||||
158,000 inhabitants. The IMF's World Economic Outlook doesn't cover it, so the headline
|
||||
numbers come from the World Bank; the detail further down comes from CBS Curaçao (the island's
|
||||
statistics office), Eurostat, ILOSTAT, Our World in Data and the UN. Amounts in guilders (ANG,
|
||||
pegged at 1.79 to the US dollar). The central bank's balance-of-payments tables can't be
|
||||
fetched automatically, so they aren't here yet.
|
||||
{:else}
|
||||
{name} at a glance, from the World Bank's development indicators and the IMF World Economic Outlook{imfCovered
|
||||
? ' (dashed lines are IMF forecasts)'
|
||||
: ''}.
|
||||
{/if}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="kpis">
|
||||
<Kpi
|
||||
label="Population"
|
||||
value={compact(kPop?.[1])}
|
||||
sub={` 10 yr · ${period(kPop?.[0], 'A')}`}
|
||||
delta={popDelta}
|
||||
deltaUnit="%"
|
||||
{color}
|
||||
/>
|
||||
<Kpi
|
||||
label="GDP per capita, PPP"
|
||||
value={`$${compact(kGdp?.[1])}`}
|
||||
sub={`constant 2021 int. $ · ${period(kGdp?.[0], 'A')}`}
|
||||
color="#10b981"
|
||||
/>
|
||||
<Kpi
|
||||
label="Real growth"
|
||||
value={num(kGrowth?.[1], 1)}
|
||||
unit="%"
|
||||
sub={period(kGrowth?.[0], 'A')}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<Kpi
|
||||
label="Inflation"
|
||||
value={num(kInfl?.[1], 1)}
|
||||
unit="%"
|
||||
sub={period(kInfl?.[0], 'A')}
|
||||
color="#e11d48"
|
||||
/>
|
||||
{#if kUnemp}
|
||||
<Kpi
|
||||
label="Unemployment"
|
||||
value={num(kUnemp[1], 1)}
|
||||
unit="%"
|
||||
sub={period(kUnemp[0], 'A')}
|
||||
color="#a855f7"
|
||||
/>
|
||||
{/if}
|
||||
{#if kDebt}
|
||||
<Kpi
|
||||
label="Government debt"
|
||||
value={num(kDebt[1], 0)}
|
||||
unit="% GDP"
|
||||
sub={period(kDebt[0], 'A')}
|
||||
color="#f97316"
|
||||
/>
|
||||
{/if}
|
||||
<Kpi
|
||||
label="Life expectancy"
|
||||
value={num(kLife?.[1], 1)}
|
||||
unit="yr"
|
||||
sub={period(kLife?.[0], 'A')}
|
||||
color="#06b6d4"
|
||||
/>
|
||||
{#if kTour}
|
||||
<Kpi
|
||||
label="Tourist arrivals"
|
||||
value={compact(kTour[1])}
|
||||
sub={period(kTour[0], 'A')}
|
||||
color="#eab308"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">People</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Population"
|
||||
subtitle="inhabitants · net migration on the right"
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Population', points: pts('wb:SP.POP.TOTL'), color, kind: 'area' },
|
||||
{
|
||||
name: 'Net migration',
|
||||
points: pts('wb:SM.POP.NETM'),
|
||||
color: '#64748b',
|
||||
kind: 'bar',
|
||||
right: true
|
||||
}
|
||||
],
|
||||
{ decimals: 0, min: 'dataMin', rightUnit: 'people' }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Life expectancy"
|
||||
subtitle="years at birth, next to the Netherlands and the world"
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name, points: pts('wb:SP.DYN.LE00.IN'), color },
|
||||
...(c !== 'NLD'
|
||||
? [
|
||||
{
|
||||
name: 'Netherlands',
|
||||
points: pts('wb:SP.DYN.LE00.IN', 'NLD'),
|
||||
color: '#f97316',
|
||||
dashed: true
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(c !== 'WLD'
|
||||
? [
|
||||
{
|
||||
name: 'World',
|
||||
points: pts('wb:SP.DYN.LE00.IN', 'WLD'),
|
||||
color: '#94a3b8',
|
||||
dashed: true
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
{ unit: 'years', decimals: 1, min: 'dataMin' }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Fertility and ageing"
|
||||
subtitle="births per woman · share aged 65+ on the right"
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Fertility rate', points: pts('wb:SP.DYN.TFRT.IN'), color: '#ec4899' },
|
||||
{
|
||||
name: 'Aged 65+ (%)',
|
||||
points: pts('wb:SP.POP.65UP.TO.ZS'),
|
||||
color: '#64748b',
|
||||
right: true
|
||||
}
|
||||
],
|
||||
{ unit: 'births', rightUnit: '%', decimals: 2 }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Economy</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="GDP per capita"
|
||||
subtitle="PPP, constant 2021 international $ · vs the Netherlands and the world"
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name, points: pts('wb:NY.GDP.PCAP.PP.KD'), color },
|
||||
...(c !== 'NLD'
|
||||
? [
|
||||
{
|
||||
name: 'Netherlands',
|
||||
points: pts('wb:NY.GDP.PCAP.PP.KD', 'NLD'),
|
||||
color: '#f97316',
|
||||
dashed: true
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(c !== 'WLD'
|
||||
? [
|
||||
{
|
||||
name: 'World',
|
||||
points: pts('wb:NY.GDP.PCAP.PP.KD', 'WLD'),
|
||||
color: '#94a3b8',
|
||||
dashed: true
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
{ unit: '$', decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Real GDP growth"
|
||||
subtitle={has('imf:NGDP_RPCH') ? '% a year · IMF WEO incl. forecasts' : '% a year · World Bank'}
|
||||
sources={has('imf:NGDP_RPCH') ? ['imf'] : ['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
has('imf:NGDP_RPCH')
|
||||
? imfLines('imf:NGDP_RPCH', 'Growth', '#3b82f6')
|
||||
: [
|
||||
{ name: 'Growth', points: pts('wb:NY.GDP.MKTP.KD.ZG'), color: '#3b82f6', kind: 'bar' }
|
||||
],
|
||||
{ unit: '%', decimals: 1, legend: has('imf:NGDP_RPCH') }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Inflation"
|
||||
subtitle="% consumer prices, a year"
|
||||
sources={has('imf:PCPIPCH') ? ['imf', 'worldbank'] : ['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
...(has('imf:PCPIPCH')
|
||||
? imfLines('imf:PCPIPCH', name, '#e11d48')
|
||||
: [{ name, points: pts('wb:FP.CPI.TOTL.ZG'), color: '#e11d48' }]),
|
||||
...(c !== 'NLD'
|
||||
? [
|
||||
{
|
||||
name: 'Netherlands',
|
||||
points: pts('wb:FP.CPI.TOTL.ZG', 'NLD'),
|
||||
color: '#f97316',
|
||||
dashed: true
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
{ unit: '%', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
{#if has('imf:LUR') || has('wb:SL.UEM.TOTL.ZS')}
|
||||
<StatChart
|
||||
title="Unemployment"
|
||||
subtitle="% of the labour force"
|
||||
sources={has('imf:LUR') ? ['imf'] : ['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
has('imf:LUR')
|
||||
? imfLines('imf:LUR', 'Unemployment', '#a855f7')
|
||||
: [{ name: 'Unemployment', points: pts('wb:SL.UEM.TOTL.ZS'), color: '#a855f7' }],
|
||||
{ unit: '%', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
{#if has('wb:NE.EXP.GNFS.ZS') || has('wb:NE.TRD.GNFS.ZS')}
|
||||
<StatChart
|
||||
title="Trade"
|
||||
subtitle="exports and imports of goods and services, % of GDP"
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Exports', points: pts('wb:NE.EXP.GNFS.ZS'), color: '#10b981' },
|
||||
{ name: 'Imports', points: pts('wb:NE.IMP.GNFS.ZS'), color: '#e11d48' },
|
||||
{
|
||||
name: 'Services value added',
|
||||
points: pts('wb:NV.SRV.TOTL.ZS'),
|
||||
color: '#64748b',
|
||||
dashed: true
|
||||
}
|
||||
],
|
||||
{ unit: '% GDP', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
{#if has('wb:ST.INT.ARVL') || has('wb:ST.INT.RCPT.CD')}
|
||||
<StatChart
|
||||
title="Tourism"
|
||||
subtitle="international arrivals · receipts (US$) on the right"
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Arrivals', points: pts('wb:ST.INT.ARVL'), color: '#eab308', kind: 'bar' },
|
||||
{ name: 'Receipts', points: pts('wb:ST.INT.RCPT.CD'), color: '#06b6d4', right: true }
|
||||
],
|
||||
{ decimals: 0, rightUnit: 'US$' }
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Government and energy</h2>
|
||||
<div class="stats-grid">
|
||||
{#if !isCw || has('imf:GGXWDG_NGDP') || has('wb:GC.DOD.TOTL.GD.ZS')}
|
||||
<StatChart
|
||||
title="Government finances"
|
||||
subtitle={has('imf:GGXWDG_NGDP')
|
||||
? '% of GDP · IMF (general government)'
|
||||
: '% of GDP · World Bank (central government)'}
|
||||
sources={has('imf:GGXWDG_NGDP') ? ['imf'] : ['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
has('imf:GGXWDG_NGDP')
|
||||
? [
|
||||
...imfLines('imf:GGXWDG_NGDP', 'Debt', '#e11d48'),
|
||||
...imfLines('imf:GGR_G01_GDP_PT', 'Revenue', '#10b981'),
|
||||
...imfLines('imf:G_X_G01_GDP_PT', 'Spending', '#3b82f6')
|
||||
]
|
||||
: [
|
||||
{ name: 'Debt', points: pts('wb:GC.DOD.TOTL.GD.ZS'), color: '#e11d48' },
|
||||
{ name: 'Revenue', points: pts('wb:GC.REV.XGRT.GD.ZS'), color: '#10b981' },
|
||||
{ name: 'Expense', points: pts('wb:GC.XPN.TOTL.GD.ZS'), color: '#3b82f6' }
|
||||
],
|
||||
{ unit: '% GDP', decimals: 1 }
|
||||
)}
|
||||
note={!has('imf:GGXWDG_NGDP') && !has('wb:GC.DOD.TOTL.GD.ZS')
|
||||
? 'No government finance data for this country yet.'
|
||||
: ''}
|
||||
/>
|
||||
{/if}
|
||||
<StatChart
|
||||
title="Energy"
|
||||
subtitle="net energy imports (% of use) · renewable share of electricity"
|
||||
sources={['worldbank']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Net energy imports', points: pts('wb:EG.IMP.CONS.ZS'), color: '#64748b' },
|
||||
{ name: 'Renewable electricity', points: pts('wb:EG.ELC.RNEW.ZS'), color: '#10b981' }
|
||||
],
|
||||
{ unit: '%', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isCw}
|
||||
<h2 class="stats-section-title">Curaçao in detail</h2>
|
||||
<div class="kpis">
|
||||
<Kpi
|
||||
label="Population, 1 Jan"
|
||||
value={compact(cwPop?.[1])}
|
||||
sub={`CBS Curaçao · ${period(cwPop?.[0], 'A')}`}
|
||||
{color}
|
||||
/>
|
||||
<Kpi
|
||||
label="Inflation"
|
||||
value={num(cwCpi?.[1], 1)}
|
||||
unit="%"
|
||||
sub={`year on year · ${period(cwCpi?.[0], 'M')}`}
|
||||
color="#e11d48"
|
||||
/>
|
||||
<Kpi
|
||||
label="Real growth"
|
||||
value={num(cwGrowth?.[1], 1)}
|
||||
unit="%"
|
||||
sub={period(cwGrowth?.[0], 'A')}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<Kpi
|
||||
label="Unemployment"
|
||||
value={num(cwUnemp?.[1], 1)}
|
||||
unit="%"
|
||||
sub={`labour force survey · ${period(cwUnemp?.[0], 'A')}`}
|
||||
color="#a855f7"
|
||||
/>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Consumer prices since 1970"
|
||||
subtitle="CPI (2023 = 100) · inflation year on year (%) on the right"
|
||||
sources={['cbscw']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'CPI', points: cw('cw:cpi:index'), color: '#64748b', kind: 'area' },
|
||||
{ name: 'Inflation', points: cw('cw:cpi:yoy'), color: '#e11d48', right: true }
|
||||
],
|
||||
{ unit: 'index', rightUnit: '%', decimals: 1, zoom: true }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="GDP"
|
||||
subtitle="nominal, million guilders · real growth (%) on the right"
|
||||
sources={['cbscw']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'GDP', points: cw('cw:gdp:gdp'), color, kind: 'bar' },
|
||||
{
|
||||
name: 'Real growth',
|
||||
points: cw('cw:gdp:real_growth_pct'),
|
||||
color: '#3b82f6',
|
||||
right: true
|
||||
}
|
||||
],
|
||||
{ unit: 'mln ANG', rightUnit: '%', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="GDP by industry"
|
||||
subtitle={`gross value added, million guilders · ${period(cwGdpYear, 'A')}`}
|
||||
sources={['cbscw']}
|
||||
attributions={a}
|
||||
height="420px"
|
||||
option={(t) => barRankOption(t, cwIndustries, { decimals: 0, unit: 'mln ANG', color })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Population since 1900"
|
||||
subtitle="1 January (31 December before 1992) and census counts · UN estimate dashed"
|
||||
sources={['cbscw', 'owid']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Population', points: cw('cw:pop:jan1'), color, kind: 'area' },
|
||||
{ name: 'Census', points: cw('cw:pop:census'), color: '#0f172a', kind: 'bar' },
|
||||
{
|
||||
name: 'UN WPP (via OWID)',
|
||||
points: cw('owid:cw:population'),
|
||||
color: '#94a3b8',
|
||||
dashed: true
|
||||
}
|
||||
],
|
||||
{ decimals: 0, unit: 'people' }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Unemployment"
|
||||
subtitle="% of the labour force · CBS labour force survey (no survey in 2021 and 2023), ILO dashed"
|
||||
sources={['cbscw', 'ilostat']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Total', points: cw('cw:lfs:unemployment_rate_pct:total'), color: '#a855f7' },
|
||||
{ name: 'Men', points: cw('cw:lfs:unemployment_rate_pct:m'), color: '#3b82f6' },
|
||||
{ name: 'Women', points: cw('cw:lfs:unemployment_rate_pct:f'), color: '#ec4899' },
|
||||
{
|
||||
name: 'Aged 15–24',
|
||||
points: cw('cw:lfs:unemployment_rate_pct:15-24'),
|
||||
color: '#f97316'
|
||||
},
|
||||
{
|
||||
name: 'ILO estimate (15+)',
|
||||
points: cw('ilostat:une_deap_sex_age_rt:total:15plus'),
|
||||
color: '#94a3b8',
|
||||
dashed: true
|
||||
}
|
||||
],
|
||||
{ unit: '%', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Tourism"
|
||||
subtitle="cruise passengers and daytrippers · stay-over nights on the right"
|
||||
sources={['cbscw']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Cruise passengers',
|
||||
points: cw('cw:tour:cruise_pax'),
|
||||
color: '#06b6d4',
|
||||
kind: 'bar'
|
||||
},
|
||||
{
|
||||
name: 'Daytrippers',
|
||||
points: cw('cw:tour:daytrippers'),
|
||||
color: '#eab308',
|
||||
kind: 'bar'
|
||||
},
|
||||
{
|
||||
name: 'Stay-over nights',
|
||||
points: cw('cw:tour:nights'),
|
||||
color: '#e11d48',
|
||||
right: true
|
||||
}
|
||||
],
|
||||
{ decimals: 0, rightUnit: 'nights' }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Stay-over nights by market"
|
||||
subtitle="tourist nights by region of residence"
|
||||
sources={['cbscw']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
Object.entries(CW_NIGHT_MARKETS).map(([k, label], i) => ({
|
||||
name: label,
|
||||
points: cw(`cw:tour:nights:${k}`),
|
||||
color: ['#3b82f6', '#e11d48', '#10b981', '#eab308', '#94a3b8'][i],
|
||||
kind: 'bar' as const,
|
||||
stack: 'nights'
|
||||
})),
|
||||
{ decimals: 0, unit: 'nights' }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Government revenue and expenditure"
|
||||
subtitle="million guilders · balance as bars (recent years provisional)"
|
||||
sources={['cbscw']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Revenue', points: cw('cw:gov:r.total'), color: '#10b981' },
|
||||
{ name: 'Expenditure', points: cw('cw:gov:e.total'), color: '#3b82f6' },
|
||||
{ name: 'Balance', points: cw('cw:gov:balance'), color: '#e11d48', kind: 'bar' }
|
||||
],
|
||||
{ unit: 'mln ANG', decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Trade with the EU"
|
||||
subtitle="EU27 goods exports to and imports from Curaçao, million €"
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'EU exports to Curaçao',
|
||||
points: cw(EU_TRADE('MIO_EXP_VAL')),
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
name: 'EU imports from Curaçao',
|
||||
points: cw(EU_TRADE('MIO_IMP_VAL')),
|
||||
color: '#f97316'
|
||||
},
|
||||
{
|
||||
name: 'EU balance',
|
||||
points: cw(EU_TRADE('MIO_BAL_VAL')),
|
||||
color: '#94a3b8',
|
||||
kind: 'bar'
|
||||
}
|
||||
],
|
||||
{ unit: 'mln €', decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Goods trade (as reported by Curaçao)"
|
||||
subtitle="US$ · only 2017–2020 are reported to the UN"
|
||||
sources={['comtrade']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Imports',
|
||||
points: cw('comtrade:cw:imports:world'),
|
||||
color: '#e11d48',
|
||||
kind: 'bar'
|
||||
},
|
||||
{
|
||||
name: 'Exports',
|
||||
points: cw('comtrade:cw:exports:world'),
|
||||
color: '#10b981',
|
||||
kind: 'bar'
|
||||
},
|
||||
{
|
||||
name: 'Imports from the US',
|
||||
points: cw('comtrade:cw:imports:usa'),
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{ name: 'Imports from NL', points: cw('comtrade:cw:imports:nld'), color: '#f97316' }
|
||||
],
|
||||
{ decimals: 0, unit: 'US$' }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Climate and energy"
|
||||
subtitle="CO₂ per person (t) · renewable share of final energy (%) on the right"
|
||||
sources={['owid', 'unsdg']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'CO₂ per person',
|
||||
points: cw('owid:cw:co-emissions-per-capita'),
|
||||
color: '#64748b'
|
||||
},
|
||||
{
|
||||
name: 'Renewable share',
|
||||
points: cw('unsdg:eg_fec_rnew'),
|
||||
color: '#10b981',
|
||||
right: true
|
||||
}
|
||||
],
|
||||
{ unit: 't', rightUnit: '%', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.heritage}
|
||||
<PlaceHeritage h={data.heritage} attributions={a} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: var(--site-muted);
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
width: fit-content;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.flag {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
592
src/lib/stats/views/EconomyView.svelte
Normal file
592
src/lib/stats/views/EconomyView.svelte
Normal file
@@ -0,0 +1,592 @@
|
||||
<script lang="ts">
|
||||
import type { EconomyViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
import CountryPicker from '#lib/stats/CountryPicker.svelte';
|
||||
import * as Tabs from '#lib/components/ui/tabs/index.js';
|
||||
import { barRankOption, latest, lineOption, spread, type Line } from '#lib/stats/chart.js';
|
||||
import { num, period } from '#lib/stats/format.js';
|
||||
import { COUNTRY } from '#lib/stats/regions.js';
|
||||
import { COUNTRY_COLOR } from '#lib/stats/countries.js';
|
||||
import {
|
||||
ANNUAL,
|
||||
CORE7,
|
||||
CPI_BY_CATEGORY,
|
||||
CPI_CATS,
|
||||
CPI_CAT_LABEL,
|
||||
CPI_TOTAL,
|
||||
POLICY_RATE,
|
||||
rebase,
|
||||
yoyByDate,
|
||||
type CpiCat
|
||||
} from '#lib/stats/econ.js';
|
||||
|
||||
let { data }: { data: EconomyViewData & StatsCommon } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string) => s[id]?.points ?? [];
|
||||
|
||||
// Country selection shared by every multi-country chart.
|
||||
let selected = $state<string[]>([...CORE7]);
|
||||
let eu = $state(true);
|
||||
const regions = $derived([...selected, ...(eu ? ['EU27'] : [])]);
|
||||
const nameOf = (c: string) =>
|
||||
c === 'EU27' ? 'EU 27' : c === 'EA' ? 'Euro area' : (COUNTRY[c] ?? c);
|
||||
const colorOf = (c: string) => COUNTRY_COLOR[c] ?? '#64748b';
|
||||
const missing = (list: string[]) =>
|
||||
list.length ? `No data for ${list.map(nameOf).join(', ')}.` : '';
|
||||
|
||||
// CPI: index level rebased to a common year, or year-on-year %.
|
||||
let cpiMode = $state<'level' | 'yoy'>('level');
|
||||
let baseYear = $state(2015);
|
||||
const BASE_YEARS = [2000, 2010, 2015, 2020, 2025];
|
||||
const cpiLine = (id: string): [string, number][] | null => {
|
||||
const p = pts(id);
|
||||
if (!p.length) return null;
|
||||
return cpiMode === 'yoy' ? yoyByDate(p) : rebase(p, baseYear);
|
||||
};
|
||||
const cpiLines = $derived.by(() => {
|
||||
const lines: Line[] = [];
|
||||
const gaps: string[] = [];
|
||||
for (const c of regions) {
|
||||
const src = CPI_TOTAL[c];
|
||||
const points = src ? cpiLine(src.id) : null;
|
||||
if (!points?.length) gaps.push(c);
|
||||
else lines.push({ name: nameOf(c), points, color: colorOf(c), dashed: c === 'EU27' });
|
||||
}
|
||||
return { lines, gaps };
|
||||
});
|
||||
const cpiSources = $derived([
|
||||
...new Set(regions.map((c) => CPI_TOTAL[c]?.source).filter(Boolean))
|
||||
] as string[]);
|
||||
|
||||
let catCountry = $state('NLD');
|
||||
const CAT_COLORS: Record<CpiCat, string> = {
|
||||
total: '#64748b',
|
||||
food: '#10b981',
|
||||
housing: '#a855f7',
|
||||
energy: '#f97316',
|
||||
core: '#3b82f6'
|
||||
};
|
||||
const catLines = $derived.by(() => {
|
||||
const def = CPI_BY_CATEGORY[catCountry];
|
||||
return CPI_CATS.flatMap((k) => {
|
||||
const id = def?.ids[k];
|
||||
const points = id ? cpiLine(id) : null;
|
||||
return points?.length
|
||||
? [{ name: CPI_CAT_LABEL[k], points, color: CAT_COLORS[k], dashed: k === 'total' }]
|
||||
: [];
|
||||
});
|
||||
});
|
||||
const catMissing = $derived(
|
||||
CPI_CATS.filter((k) => !CPI_BY_CATEGORY[catCountry]?.ids[k]).map((k) =>
|
||||
CPI_CAT_LABEL[k].toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
// Policy rates (BIS), euro members follow the ECB.
|
||||
const rateLines = $derived.by(() => {
|
||||
const seen: string[] = [];
|
||||
const lines: Line[] = [];
|
||||
const gaps: string[] = [];
|
||||
for (const c of [...selected, ...(eu ? ['EA'] : [])]) {
|
||||
const area = POLICY_RATE[c] ?? (['DEU', 'FRA', 'BEL'].includes(c) ? 'XM' : undefined);
|
||||
const p = area ? pts(`bis:M:${area}`) : [];
|
||||
if (!p.length) gaps.push(c);
|
||||
else if (!seen.includes(area!)) {
|
||||
seen.push(area!);
|
||||
lines.push({
|
||||
name: area === 'XM' ? 'ECB (euro area)' : nameOf(c),
|
||||
points: p,
|
||||
color: area === 'XM' ? '#1d4ed8' : colorOf(c),
|
||||
kind: 'step'
|
||||
});
|
||||
}
|
||||
}
|
||||
return { lines, gaps };
|
||||
});
|
||||
|
||||
const yieldLines = $derived.by(() => {
|
||||
const lines: Line[] = [];
|
||||
const gaps: string[] = [];
|
||||
for (const c of [...selected, ...(eu ? ['EA'] : [])]) {
|
||||
const p = pts(`oecd:irlt:${c}`);
|
||||
if (!p.length) gaps.push(c);
|
||||
else lines.push({ name: nameOf(c), points: p, color: colorOf(c), dashed: c === 'EA' });
|
||||
}
|
||||
return { lines, gaps };
|
||||
});
|
||||
|
||||
// Annual indicators: IMF WEO (outcomes only, forecasts cut), World Bank where the IMF has none.
|
||||
const annual = (key: keyof typeof ANNUAL) => {
|
||||
const ind = ANNUAL[key];
|
||||
const lines: Line[] = [];
|
||||
const gaps: string[] = [];
|
||||
const used: string[] = [];
|
||||
for (const c of regions) {
|
||||
const imf = ind.imf ? s[`imf:${ind.imf}:${c}`] : undefined;
|
||||
const wb = s[`wb:${ind.wb}:${c}`];
|
||||
const cw = c === 'CUW' && ind.cw ? s[ind.cw] : undefined;
|
||||
const pick = imf?.points.length
|
||||
? imf
|
||||
: cw?.points.length
|
||||
? cw
|
||||
: wb?.points.length
|
||||
? wb
|
||||
: undefined;
|
||||
if (!pick) {
|
||||
gaps.push(c);
|
||||
continue;
|
||||
}
|
||||
if (!used.includes(pick.sourceId)) used.push(pick.sourceId);
|
||||
const cut = pick.forecastFrom ? `${pick.forecastFrom}-01-01` : '9999';
|
||||
lines.push({
|
||||
name: nameOf(c),
|
||||
points: pick.points.filter(([d]) => d < cut),
|
||||
color: colorOf(c),
|
||||
dashed: c === 'EU27'
|
||||
});
|
||||
}
|
||||
return { lines, gaps, sources: used };
|
||||
};
|
||||
const growth = $derived(annual('growth'));
|
||||
const unemployment = $derived(annual('unemployment'));
|
||||
const govDebt = $derived(annual('debt'));
|
||||
const trade = $derived(annual('trade'));
|
||||
|
||||
// Europe-only charts (Eurostat).
|
||||
const y = (g: string) => pts(`eurostat:irt_lt_mcby_m:MCBY.${g}`);
|
||||
const frde = $derived(spread(y('FR'), y('DE')));
|
||||
const itde = $derived(spread(y('IT'), y('DE')));
|
||||
const nlde = $derived(spread(y('NL'), y('DE')));
|
||||
const us10 = $derived(pts('fred:DGS10'));
|
||||
const curve = $derived(spread(us10, pts('fred:DGS2')));
|
||||
const EU_LIST = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020', 'EA20'];
|
||||
const lastOf = (id: string) => latest(s[id]?.points);
|
||||
const lastYear = (prefix: string) =>
|
||||
EU_LIST.map((g) => ({ g, p: lastOf(`${prefix}.${g}`) }))
|
||||
.filter((r) => r.p)
|
||||
.map((r) => ({
|
||||
label: COUNTRY[r.g] ?? r.g,
|
||||
value: r.p![1],
|
||||
highlight: r.g === 'NL',
|
||||
year: r.p![0].slice(0, 4)
|
||||
}));
|
||||
const debt = $derived(lastYear('eurostat:gov_10dd_edpt1:PC_GDP.S13.GD'));
|
||||
const deficit = $derived(lastYear('eurostat:gov_10dd_edpt1:PC_GDP.S13.B9'));
|
||||
const migrationPerK = $derived(
|
||||
EU_LIST.filter((g) => !g.startsWith('E'))
|
||||
.map((g) => {
|
||||
const m = lastOf(`eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.${g}`);
|
||||
const pop = pts(`eurostat:demo_pjan:NR.TOTAL.T.${g}`).find(([d]) => m && d === m[0]);
|
||||
return m && pop
|
||||
? { label: COUNTRY[g] ?? g, value: (m[1] / pop[1]) * 1000, highlight: g === 'NL' }
|
||||
: null;
|
||||
})
|
||||
.filter((r) => r != null)
|
||||
);
|
||||
const k = (id: string) => lastOf(id);
|
||||
const fed = $derived(k('bis:M:US'));
|
||||
const ecb = $derived(k('bis:M:XM'));
|
||||
const boc = $derived(k('bis:M:CA'));
|
||||
const boj = $derived(k('bis:M:JP'));
|
||||
const nlInfl = $derived(k('eurostat:prc_hicp_minr:RCH_A.TOTAL.NL'));
|
||||
const eaInfl = $derived(k('eurostat:prc_hicp_minr:RCH_A.TOTAL.EA20'));
|
||||
const usInfl = $derived(latest(yoyByDate(pts('fred:CPIAUCSL'))));
|
||||
const caInfl = $derived(latest(yoyByDate(pts('statcan:cpi:total'))));
|
||||
</script>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / economy</span>
|
||||
<h1>Economy</h1>
|
||||
<p>
|
||||
Consumer prices, central bank rates, bond yields, growth, jobs, debt and trade for Canada, South
|
||||
Korea, China, Japan, the US, the Netherlands and Curaçao, with the EU as a comparison line. Add
|
||||
other countries with the picker. Refreshed from open sources (CBS, BLS, Statistics Canada, CBS
|
||||
Curaçao, BIS, OECD, IMF, World Bank, Eurostat).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="kpis">
|
||||
<Kpi
|
||||
label="Fed policy rate"
|
||||
value={num(fed?.[1], 2)}
|
||||
unit="%"
|
||||
sub={period(fed?.[0])}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<Kpi
|
||||
label="ECB policy rate"
|
||||
value={num(ecb?.[1], 2)}
|
||||
unit="%"
|
||||
sub={period(ecb?.[0])}
|
||||
color="#eab308"
|
||||
/>
|
||||
<Kpi
|
||||
label="Bank of Canada"
|
||||
value={num(boc?.[1], 2)}
|
||||
unit="%"
|
||||
sub={period(boc?.[0])}
|
||||
color="#dc2626"
|
||||
/>
|
||||
<Kpi
|
||||
label="Bank of Japan"
|
||||
value={num(boj?.[1], 2)}
|
||||
unit="%"
|
||||
sub={period(boj?.[0])}
|
||||
color="#ec4899"
|
||||
/>
|
||||
<Kpi
|
||||
label="NL inflation (HICP)"
|
||||
value={num(nlInfl?.[1])}
|
||||
unit="%"
|
||||
sub={period(nlInfl?.[0])}
|
||||
color="#f97316"
|
||||
/>
|
||||
<Kpi
|
||||
label="Euro area inflation"
|
||||
value={num(eaInfl?.[1])}
|
||||
unit="%"
|
||||
sub={period(eaInfl?.[0])}
|
||||
color="#10b981"
|
||||
/>
|
||||
<Kpi
|
||||
label="US inflation (CPI)"
|
||||
value={num(usInfl?.[1])}
|
||||
unit="%"
|
||||
sub={period(usInfl?.[0])}
|
||||
color="#06b6d4"
|
||||
/>
|
||||
<Kpi
|
||||
label="Canada inflation (CPI)"
|
||||
value={num(caInfl?.[1])}
|
||||
unit="%"
|
||||
sub={period(caInfl?.[0])}
|
||||
color="#dc2626"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CountryPicker bind:selected bind:eu />
|
||||
|
||||
<h2 class="stats-section-title">Prices and rates</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Consumer prices"
|
||||
subtitle={cpiMode === 'level'
|
||||
? `index, ${baseYear} average = 100 · monthly`
|
||||
: '% change on the same month a year earlier'}
|
||||
sources={cpiSources}
|
||||
attributions={a}
|
||||
height="400px"
|
||||
wide
|
||||
note={`National CPI for the US (BLS CPI-U), Canada (Statistics Canada) and Curaçao (CBS Curaçao); BIS long series (national CPIs) for the Netherlands, Japan, South Korea, China and the added countries; EU 27 is the HICP. Each source keeps its own base year; the chart divides by the ${baseYear} average.${cpiMode === 'level' ? ' Series that do not cover the base year are left out.' : ''} ${missing(cpiLines.gaps)}`}
|
||||
option={(t) =>
|
||||
lineOption(t, cpiLines.lines, { unit: cpiMode === 'level' ? 'index' : '%', zoom: true })}
|
||||
>
|
||||
<div class="controls">
|
||||
<Tabs.Root bind:value={cpiMode}>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="level">index level</Tabs.Trigger>
|
||||
<Tabs.Trigger value="yoy">year on year %</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
{#if cpiMode === 'level'}
|
||||
<label class="select">
|
||||
base year
|
||||
<select bind:value={baseYear}>
|
||||
{#each BASE_YEARS as yr (yr)}<option value={yr}>{yr}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</StatChart>
|
||||
<StatChart
|
||||
title={`Consumer prices by category: ${nameOf(catCountry)}`}
|
||||
subtitle={cpiMode === 'level'
|
||||
? `index, ${baseYear} average = 100 · monthly`
|
||||
: '% change on the same month a year earlier'}
|
||||
sources={[CPI_BY_CATEGORY[catCountry]?.source ?? 'eurostat']}
|
||||
attributions={a}
|
||||
height="380px"
|
||||
wide
|
||||
note={`${CPI_BY_CATEGORY[catCountry]?.note ?? ''}.${catMissing.length ? ` Not published here: ${catMissing.join(', ')}.` : ''} Category definitions differ a little per source (Canada's housing is shelter; the HICP and OECD use COICOP 04, housing including utilities).`}
|
||||
option={(t) =>
|
||||
lineOption(t, catLines, { unit: cpiMode === 'level' ? 'index' : '%', zoom: true })}
|
||||
>
|
||||
<div class="controls">
|
||||
<label class="select">
|
||||
country
|
||||
<select bind:value={catCountry}>
|
||||
{#each Object.keys(CPI_BY_CATEGORY) as c (c)}<option value={c}>{nameOf(c)}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<span class="hint">uses the index / year-on-year switch and base year above</span>
|
||||
</div>
|
||||
</StatChart>
|
||||
<StatChart
|
||||
title="Central bank policy rates"
|
||||
subtitle="% · monthly · BIS"
|
||||
sources={['bis']}
|
||||
attributions={a}
|
||||
note={`Euro members follow the ECB. ${missing(rateLines.gaps)}${rateLines.gaps.includes('CUW') ? ' Curaçao: the CBCS pegs the guilder to the US dollar and is not in the BIS policy rate set.' : ''}`}
|
||||
option={(t) => lineOption(t, rateLines.lines, { unit: '%', zoom: true })}
|
||||
/>
|
||||
<StatChart
|
||||
title="10-year government bond yields"
|
||||
subtitle="% · monthly · OECD long-term interest rates"
|
||||
sources={['oecd']}
|
||||
attributions={a}
|
||||
note={missing(yieldLines.gaps)}
|
||||
option={(t) => lineOption(t, yieldLines.lines, { unit: '%', zoom: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Growth, jobs and debt</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Real GDP growth"
|
||||
subtitle="% change on the previous year · outcomes only, no forecasts"
|
||||
sources={growth.sources}
|
||||
attributions={a}
|
||||
note={`IMF World Economic Outlook; CBS Curaçao (real growth, 2011→) for Curaçao. ${missing(growth.gaps)}`}
|
||||
option={(t) => lineOption(t, growth.lines, { unit: '%', zoom: true })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Unemployment rate"
|
||||
subtitle="% of the labour force · annual"
|
||||
sources={unemployment.sources}
|
||||
attributions={a}
|
||||
note={`IMF WEO; CBS Curaçao labour force survey (2014→) for Curaçao. ${missing(unemployment.gaps)}`}
|
||||
option={(t) => lineOption(t, unemployment.lines, { unit: '%', zoom: true })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Government debt"
|
||||
subtitle="general government gross debt, % of GDP · annual"
|
||||
sources={govDebt.sources}
|
||||
attributions={a}
|
||||
note={`IMF WEO; World Bank central government debt where the IMF has none. ${missing(govDebt.gaps)}`}
|
||||
option={(t) => lineOption(t, govDebt.lines, { unit: '% of GDP', zoom: true })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Trade openness"
|
||||
subtitle="exports + imports of goods and services, % of GDP · annual"
|
||||
sources={trade.sources}
|
||||
attributions={a}
|
||||
note={missing(trade.gaps)}
|
||||
option={(t) => lineOption(t, trade.lines, { unit: '% of GDP', zoom: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Bond and stock markets</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Spreads over Germany"
|
||||
subtitle="percentage points · 10-year yield minus the German Bund"
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'France − Germany', points: frde, color: '#3b82f6', kind: 'area' },
|
||||
{ name: 'Italy − Germany', points: itde, color: '#10b981' },
|
||||
{ name: 'Netherlands − Germany', points: nlde, color: '#f97316' }
|
||||
],
|
||||
{ unit: 'pp', zoom: true }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="US yield curve: 10-year minus 2-year"
|
||||
subtitle="percentage points · monthly average · below zero = inverted"
|
||||
sources={['fred']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(t, [{ name: '10y − 2y', points: curve, color: '#a855f7', kind: 'area' }], {
|
||||
unit: 'pp',
|
||||
zoom: true,
|
||||
legend: false
|
||||
})}
|
||||
/>
|
||||
<StatChart
|
||||
title="Share prices"
|
||||
subtitle="index 2015 = 100 · OECD main economic indicators"
|
||||
sources={['fred']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Netherlands', points: pts('fred:SPASTT01NLM661N'), color: '#f97316' },
|
||||
{ name: 'United States', points: pts('fred:SPASTT01USM661N'), color: '#3b82f6' },
|
||||
{ name: 'South Korea', points: pts('fred:SPASTT01KRM661N'), color: '#e11d48' },
|
||||
{ name: 'China', points: pts('fred:SPASTT01CNM661N'), color: '#eab308' }
|
||||
],
|
||||
{ zoom: true }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="S&P 500 and NASDAQ Composite"
|
||||
subtitle="index points · weekly average"
|
||||
sources={['fred']}
|
||||
attributions={a}
|
||||
wide
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'S&P 500', points: pts('fred:SP500'), color: '#3b82f6' },
|
||||
{ name: 'NASDAQ Composite', points: pts('fred:NASDAQCOM'), color: '#10b981', right: true }
|
||||
],
|
||||
{ zoom: true, unit: 'S&P', rightUnit: 'NASDAQ' }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">United States</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="US real GDP and unemployment"
|
||||
subtitle="billions of chained 2017 USD (SAAR) · unemployment %"
|
||||
sources={['fred']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Real GDP', points: pts('fred:GDPC1'), color: '#3b82f6', kind: 'area' },
|
||||
{ name: 'Unemployment rate', points: pts('fred:UNRATE'), color: '#e11d48', right: true }
|
||||
],
|
||||
{ unit: 'USD bn', rightUnit: '%', zoom: true }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Europe: public finances and migration</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Government debt"
|
||||
subtitle={`% of GDP · ${debt[0]?.year ?? ''}`}
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
height="360px"
|
||||
option={(t) => barRankOption(t, debt, { unit: '% of GDP', color: '#a855f7' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Government balance"
|
||||
subtitle={`surplus (+) or deficit (−), % of GDP · ${deficit[0]?.year ?? ''}`}
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
height="360px"
|
||||
option={(t) => barRankOption(t, deficit, { unit: '% of GDP', color: '#10b981' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Dutch government: spending vs revenue"
|
||||
subtitle="% of GDP · general government"
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Spending',
|
||||
points: pts('eurostat:gov_10a_main:PC_GDP.S13.TE.NL'),
|
||||
color: '#e11d48'
|
||||
},
|
||||
{
|
||||
name: 'Revenue',
|
||||
points: pts('eurostat:gov_10a_main:PC_GDP.S13.TR.NL'),
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
name: 'EU 27 spending',
|
||||
points: pts('eurostat:gov_10a_main:PC_GDP.S13.TE.EU27_2020'),
|
||||
color: '#e11d48',
|
||||
dashed: true
|
||||
},
|
||||
{
|
||||
name: 'EU 27 revenue',
|
||||
points: pts('eurostat:gov_10a_main:PC_GDP.S13.TR.EU27_2020'),
|
||||
color: '#10b981',
|
||||
dashed: true
|
||||
}
|
||||
],
|
||||
{ unit: '% of GDP', min: 'dataMin' }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Immigration"
|
||||
subtitle="people arriving per year"
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Netherlands',
|
||||
points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.NL'),
|
||||
color: '#f97316',
|
||||
kind: 'area'
|
||||
},
|
||||
{
|
||||
name: 'Belgium',
|
||||
points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.BE'),
|
||||
color: '#eab308'
|
||||
},
|
||||
{
|
||||
name: 'Sweden',
|
||||
points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.SE'),
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
name: 'Austria',
|
||||
points: pts('eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.AT'),
|
||||
color: '#e11d48'
|
||||
}
|
||||
],
|
||||
{ decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Immigration per 1,000 inhabitants"
|
||||
subtitle="latest year"
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
height="340px"
|
||||
option={(t) => barRankOption(t, migrationPerK, { unit: 'per 1,000', color: '#06b6d4' })}
|
||||
/>
|
||||
</div>
|
||||
<p class="stats-note">
|
||||
Policy rates: BIS central bank policy rates (month end). Spreads use Eurostat's EMU
|
||||
convergence-criterion yields. The country picker drives the charts in the first two sections; the
|
||||
European and US sections keep their own fixed sets.
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.select select {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
</style>
|
||||
446
src/lib/stats/views/ElectionsView.svelte
Normal file
446
src/lib/stats/views/ElectionsView.svelte
Normal file
@@ -0,0 +1,446 @@
|
||||
<script lang="ts">
|
||||
import type { ElectionsViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
import { barRankOption, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||
import { compact, num } from '#lib/stats/format.js';
|
||||
import { FAMILIES, familyOf, PARTY_COLORS, tally } from '#lib/stats/parties.js';
|
||||
|
||||
let {
|
||||
data,
|
||||
focus = 'all'
|
||||
}: { data: ElectionsViewData & StatsCommon; focus?: 'all' | 'nld' | 'gouda' | 'usa' } = $props();
|
||||
const show = (f: 'nld' | 'gouda' | 'usa') => focus === 'all' || focus === f;
|
||||
const a = $derived(data.attributions);
|
||||
const year = (d: string) => d.slice(0, 4);
|
||||
const share = (v: number | null, total: number) =>
|
||||
v == null || !total ? 0 : Math.round((v / total) * 1000) / 10;
|
||||
|
||||
// ---- Netherlands, national ------------------------------------------------------------
|
||||
const nlElections = $derived(
|
||||
[...new Set(data.nl.map((r) => r.election))]
|
||||
.map((id) => {
|
||||
const rows = data.nl.filter((r) => r.election === id);
|
||||
const { parties, total } = tally(rows);
|
||||
return { id, date: rows[0].date, total, parties };
|
||||
})
|
||||
.sort((x, y) => x.date.localeCompare(y.date))
|
||||
);
|
||||
const familyLines = $derived([
|
||||
...FAMILIES.map((f) => ({
|
||||
name: f.name,
|
||||
color: f.color,
|
||||
kind: 'bar' as const,
|
||||
stack: 'f',
|
||||
points: nlElections.map(
|
||||
(e) =>
|
||||
[
|
||||
year(e.date),
|
||||
share(
|
||||
e.parties
|
||||
.filter((p) => familyOf(p.party) === f.name)
|
||||
.reduce((s, p) => s + (p.votes ?? 0), 0),
|
||||
e.total
|
||||
)
|
||||
] as StatPoint
|
||||
)
|
||||
})),
|
||||
{
|
||||
name: 'Other',
|
||||
color: '#cbd5e1',
|
||||
kind: 'bar' as const,
|
||||
stack: 'f',
|
||||
points: nlElections.map(
|
||||
(e) =>
|
||||
[
|
||||
year(e.date),
|
||||
share(
|
||||
e.parties
|
||||
.filter((p) => familyOf(p.party) === 'Other')
|
||||
.reduce((s, p) => s + (p.votes ?? 0), 0),
|
||||
e.total
|
||||
)
|
||||
] as StatPoint
|
||||
)
|
||||
}
|
||||
]);
|
||||
const turnout = $derived(
|
||||
data.turnout
|
||||
.filter((t) => t.election.startsWith('NL-') && t.ballots && t.electorate)
|
||||
.map((t) => [year(t.date), share(t.ballots, t.electorate!)] as StatPoint)
|
||||
);
|
||||
const recent = $derived(nlElections.filter((e) => e.date >= '2010'));
|
||||
const latestNl = $derived(recent.at(-1));
|
||||
const seatParties = $derived(
|
||||
[...new Set(recent.flatMap((e) => e.parties.filter((p) => p.seats).map((p) => p.party)))].sort(
|
||||
(x, y) =>
|
||||
(latestNl?.parties.find((p) => p.party === y)?.seats ?? 0) -
|
||||
(latestNl?.parties.find((p) => p.party === x)?.seats ?? 0) || x.localeCompare(y)
|
||||
)
|
||||
);
|
||||
const seatsOf = (e: (typeof recent)[number], p: string) =>
|
||||
e.parties.find((x) => x.party === p)?.seats ?? null;
|
||||
const biggest = $derived(
|
||||
latestNl ? [...latestNl.parties].sort((x, y) => (y.seats ?? 0) - (x.seats ?? 0))[0] : null
|
||||
);
|
||||
const latestTurnout = $derived(data.turnout.filter((t) => t.election.startsWith('NL-')).at(-1));
|
||||
|
||||
// ---- Gouda ------------------------------------------------------------------------------
|
||||
const goudaBy = $derived(
|
||||
[...new Set(data.gouda.map((r) => r.election))].sort().map((id) => {
|
||||
const rows = data.gouda.filter((r) => r.election === id);
|
||||
const { votes, total } = tally(rows);
|
||||
return { id, date: rows[0].date, total, votes };
|
||||
})
|
||||
);
|
||||
const goudaLatest = $derived(goudaBy.at(-1));
|
||||
const nlForGouda = $derived(nlElections.find((e) => e.id === goudaLatest?.id));
|
||||
const goudaTop = $derived(
|
||||
goudaLatest
|
||||
? [...goudaLatest.votes.entries()]
|
||||
.sort((x, y) => y[1] - x[1])
|
||||
.slice(0, 12)
|
||||
.map(([p]) => p)
|
||||
: []
|
||||
);
|
||||
const goudaVsNl = $derived([
|
||||
{
|
||||
name: 'Gouda',
|
||||
color: '#f97316',
|
||||
kind: 'bar' as const,
|
||||
points: goudaTop.map(
|
||||
(p) => [p, share(goudaLatest?.votes.get(p) ?? 0, goudaLatest?.total ?? 0)] as StatPoint
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'Netherlands',
|
||||
color: '#0f172a',
|
||||
kind: 'bar' as const,
|
||||
points: goudaTop.map(
|
||||
(p) =>
|
||||
[
|
||||
p,
|
||||
share(
|
||||
nlForGouda?.parties.find((x) => x.party === p)?.votes ?? 0,
|
||||
nlForGouda?.total ?? 0
|
||||
)
|
||||
] as StatPoint
|
||||
)
|
||||
}
|
||||
]);
|
||||
const goudaTrend = $derived(
|
||||
goudaTop.slice(0, 8).map((p) => ({
|
||||
name: p,
|
||||
color: PARTY_COLORS[p],
|
||||
points: goudaBy
|
||||
.filter((e) => e.votes.get(p))
|
||||
.map((e) => [year(e.date), share(e.votes.get(p) ?? 0, e.total)] as StatPoint)
|
||||
}))
|
||||
);
|
||||
|
||||
// Polling stations, latest election.
|
||||
const stations = $derived(
|
||||
[...new Set(data.stations.map((s) => s.code))].map((code) => {
|
||||
const rows = data.stations.filter((s) => s.code === code);
|
||||
const { votes: m, total } = tally(rows);
|
||||
const top = [...m.entries()].sort((x, y) => y[1] - x[1])[0];
|
||||
return {
|
||||
code,
|
||||
name: rows[0].name
|
||||
.replace(/^(Stembureau\s+)+/i, '')
|
||||
.replace(/\s*\(postcode:\s*([^)]*)\)/i, ' · $1'),
|
||||
total,
|
||||
top: top?.[0] ?? '',
|
||||
shares: m
|
||||
};
|
||||
})
|
||||
);
|
||||
const stationCols = $derived(goudaTop.slice(0, 6));
|
||||
const stationRows = $derived(
|
||||
[...stations].sort((x, y) => x.code.localeCompare(y.code, 'en', { numeric: true }))
|
||||
);
|
||||
const stationBars = (p: string) =>
|
||||
stations
|
||||
.filter((s) => s.total >= 100)
|
||||
.map((s) => ({
|
||||
label: s.name.split(' · ')[0].slice(0, 34),
|
||||
value: share(s.shares.get(p) ?? 0, s.total)
|
||||
}));
|
||||
const goudaWinner = $derived(goudaTop[0]);
|
||||
|
||||
// ---- United States ----------------------------------------------------------------------
|
||||
const usBy = $derived(
|
||||
[...new Set(data.us.map((r) => r.election))].sort().map((id) => {
|
||||
const rows = data.us.filter((r) => r.election === id);
|
||||
const total = rows.reduce((s, r) => s + (r.votes ?? 0), 0);
|
||||
const v = (p: string) => rows.find((r) => r.party === p)?.votes ?? 0;
|
||||
return {
|
||||
id,
|
||||
date: rows[0].date,
|
||||
total,
|
||||
dem: share(v('DEMOCRAT'), total),
|
||||
rep: share(v('REPUBLICAN'), total)
|
||||
};
|
||||
})
|
||||
);
|
||||
const usLatest = $derived(usBy.at(-1));
|
||||
const states = $derived(
|
||||
[...new Set(data.usStates.map((s) => s.code))]
|
||||
.map((code) => {
|
||||
const rows = data.usStates.filter((s) => s.code === code);
|
||||
const total = rows.reduce((s, r) => s + (r.votes ?? 0), 0);
|
||||
const v = (p: string) =>
|
||||
rows.filter((r) => r.party === p).reduce((s, r) => s + (r.votes ?? 0), 0);
|
||||
const dem = share(v('DEMOCRAT'), total);
|
||||
const rep = share(v('REPUBLICAN'), total);
|
||||
const name = rows[0].name.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return { code, name, total, dem, rep, margin: Math.round((rep - dem) * 10) / 10 };
|
||||
})
|
||||
.sort((x, y) => x.margin - y.margin)
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / elections</span>
|
||||
<h1>Elections</h1>
|
||||
<p>
|
||||
Every Tweede Kamer election since universal suffrage in 1918, how Gouda votes down to the
|
||||
polling station, and the US presidential vote by state.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="kpis">
|
||||
{#if show('nld')}
|
||||
<Kpi
|
||||
label={`Largest party, TK ${latestNl ? year(latestNl.date) : ''}`}
|
||||
value={biggest?.party ?? '–'}
|
||||
sub={`${biggest?.seats ?? '–'} of 150 seats · ${num(share(biggest?.votes ?? 0, latestNl?.total ?? 0), 1)}% of the vote`}
|
||||
color="#06b6d4"
|
||||
/>
|
||||
{/if}
|
||||
{#if show('nld')}
|
||||
<Kpi
|
||||
label="Turnout"
|
||||
value={num(
|
||||
latestTurnout?.ballots && latestTurnout.electorate
|
||||
? (latestTurnout.ballots / latestTurnout.electorate) * 100
|
||||
: null,
|
||||
1
|
||||
)}
|
||||
unit="%"
|
||||
sub={`${compact(latestTurnout?.ballots)} ballots · ${latestTurnout ? year(latestTurnout.date) : ''}`}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
{/if}
|
||||
{#if show('gouda')}
|
||||
<Kpi
|
||||
label="Gouda's largest party"
|
||||
value={goudaWinner ?? '–'}
|
||||
sub={`${num(share(goudaLatest?.votes.get(goudaWinner ?? '') ?? 0, goudaLatest?.total ?? 0), 1)}% · ${stations.length} polling stations`}
|
||||
color="#f97316"
|
||||
/>
|
||||
{/if}
|
||||
{#if show('usa')}
|
||||
<Kpi
|
||||
label={`US popular vote ${usLatest ? year(usLatest.date) : ''}`}
|
||||
value={usLatest
|
||||
? usLatest.rep > usLatest.dem
|
||||
? `R +${num(usLatest.rep - usLatest.dem, 1)}`
|
||||
: `D +${num(usLatest.dem - usLatest.rep, 1)}`
|
||||
: '–'}
|
||||
sub={`R ${num(usLatest?.rep, 1)}% · D ${num(usLatest?.dem, 1)}%`}
|
||||
color="#e11d48"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if show('nld')}
|
||||
<h2 class="stats-section-title">The Netherlands</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="A century of Dutch elections by party family"
|
||||
subtitle="% of valid votes, Tweede Kamer"
|
||||
sources={['cbs', 'kiesraad']}
|
||||
attributions={a}
|
||||
wide
|
||||
height="420px"
|
||||
note="Christian democrats: ARP, CHU, RKSP/KVP and from 1977 the CDA. Labour & green left includes the joint GL-PvdA list (2023, 2025). Populist & radical right: CD, LPF, PVV, FvD, JA21 and BBB. CBS publishes shares to one decimal for 1918–2006; the Kiesraad counts are used from 2010."
|
||||
option={(t) => lineOption(t, familyLines, { category: true, unit: '%', decimals: 1, min: 0 })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Turnout"
|
||||
subtitle="% of the electorate that cast a ballot"
|
||||
sources={['cbs', 'kiesraad']}
|
||||
attributions={a}
|
||||
note="Voting was compulsory until 1970; turnout fell from 95% in 1967 to 79% in 1971."
|
||||
option={(t) =>
|
||||
lineOption(t, [{ name: 'Turnout', points: turnout, color: '#3b82f6', kind: 'area' }], {
|
||||
category: true,
|
||||
unit: '%',
|
||||
decimals: 1,
|
||||
legend: false,
|
||||
min: 50
|
||||
})}
|
||||
/>
|
||||
<StatChart
|
||||
title={`Seats, TK ${latestNl ? year(latestNl.date) : ''}`}
|
||||
subtitle="of 150"
|
||||
sources={['kiesraad']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
barRankOption(
|
||||
t,
|
||||
(latestNl?.parties ?? [])
|
||||
.filter((p) => p.seats)
|
||||
.map((p) => ({ label: p.party, value: p.seats ?? 0 })),
|
||||
{ decimals: 0, color: '#06b6d4' }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seats</th>
|
||||
{#each recent as e (e.id)}<th class="num">{year(e.date)}</th>{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each seatParties as p (p)}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="dot" style:background={PARTY_COLORS[p] ?? '#94a3b8'}></span>{p}
|
||||
</td>
|
||||
{#each recent as e (e.id)}<td class="num">{seatsOf(e, p) ?? ''}</td>{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if show('gouda')}
|
||||
<h2 class="stats-section-title">Gouda</h2>
|
||||
{#if goudaLatest}
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title={`Gouda vs the Netherlands, TK ${year(goudaLatest.date)}`}
|
||||
subtitle="% of valid votes, largest parties in Gouda"
|
||||
sources={['kiesraad']}
|
||||
attributions={a}
|
||||
option={(t) => lineOption(t, goudaVsNl, { category: true, unit: '%', decimals: 1 })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Gouda's vote since 2010"
|
||||
subtitle="% of valid votes"
|
||||
sources={['kiesraad']}
|
||||
attributions={a}
|
||||
option={(t) => lineOption(t, goudaTrend, { category: true, unit: '%', decimals: 1 })}
|
||||
/>
|
||||
{#each stationCols.slice(0, 2) as p (p)}
|
||||
<StatChart
|
||||
title={`${p} by polling station`}
|
||||
subtitle={`% of valid votes · TK ${year(goudaLatest.date)} · stations with 100+ votes`}
|
||||
sources={['kiesraad']}
|
||||
attributions={a}
|
||||
height="760px"
|
||||
option={(t) =>
|
||||
barRankOption(t, stationBars(p), {
|
||||
decimals: 1,
|
||||
unit: '%',
|
||||
color: PARTY_COLORS[p] ?? '#3b82f6'
|
||||
})}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Polling station, TK {year(goudaLatest.date)}</th>
|
||||
<th class="num">Votes</th>
|
||||
<th>Largest</th>
|
||||
{#each stationCols as p (p)}<th class="num">{p} %</th>{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each stationRows as s (s.code)}
|
||||
<tr>
|
||||
<td>{s.name}</td>
|
||||
<td class="num">{num(s.total, 0)}</td>
|
||||
<td>{s.top}</td>
|
||||
{#each stationCols as p (p)}<td class="num"
|
||||
>{num(share(s.shares.get(p) ?? 0, s.total), 1)}</td
|
||||
>{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="stats-note">
|
||||
{a.kiesraad ?? ''} · Postal and mobile stations have few votes; votes are counted where they are
|
||||
cast, not where voters live.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="empty">Gouda's results are not loaded yet.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if show('usa')}
|
||||
<h2 class="stats-section-title">United States</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="US presidential popular vote"
|
||||
subtitle="% of all votes cast"
|
||||
sources={['mitelection']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Democrat',
|
||||
color: '#2563eb',
|
||||
points: usBy.map((e) => [year(e.date), e.dem] as StatPoint)
|
||||
},
|
||||
{
|
||||
name: 'Republican',
|
||||
color: '#dc2626',
|
||||
points: usBy.map((e) => [year(e.date), e.rep] as StatPoint)
|
||||
}
|
||||
],
|
||||
{ category: true, unit: '%', decimals: 1, min: 30 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title={`Margin by state, ${usLatest ? year(usLatest.date) : ''}`}
|
||||
subtitle="Republican minus Democratic share, percentage points"
|
||||
sources={['mitelection']}
|
||||
attributions={a}
|
||||
height="900px"
|
||||
note="Positive: Republican lead; negative: Democratic lead."
|
||||
option={(t) =>
|
||||
barRankOption(
|
||||
t,
|
||||
states.map((s) => ({
|
||||
label: s.name,
|
||||
value: s.margin,
|
||||
highlight: Math.abs(s.margin) < 3
|
||||
})),
|
||||
{ decimals: 1, unit: 'pp', color: '#64748b' }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
627
src/lib/stats/views/EnergyView.svelte
Normal file
627
src/lib/stats/views/EnergyView.svelte
Normal file
@@ -0,0 +1,627 @@
|
||||
<script lang="ts">
|
||||
import type { EnergyViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
import { barRankOption, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||
import { compact, num, period } from '#lib/stats/format.js';
|
||||
import { COUNTRY } from '#lib/stats/regions.js';
|
||||
import {
|
||||
aOrBetter,
|
||||
epHomesId,
|
||||
epShareId,
|
||||
EP_LABELS,
|
||||
labelStackOption
|
||||
} from '#lib/stats/energylabels.js';
|
||||
import EnergyLive from '#lib/stats/EnergyLive.svelte';
|
||||
|
||||
let { data }: { data: EnergyViewData & StatsCommon } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
const last = (id: string) => latest(s[id]?.points);
|
||||
// EP-Online: share of homes per energy label, latest monthly snapshot.
|
||||
const epShares = (area: string) => EP_LABELS.map((l) => last(epShareId(l, area))?.[1] ?? 0);
|
||||
const epHomes = $derived(last(epHomesId('NL')));
|
||||
const epAorBetter = $derived(aOrBetter(pts, 'NL'));
|
||||
const epGoudaAorBetter = $derived(aOrBetter(pts, 'GOUDA'));
|
||||
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020'];
|
||||
|
||||
/** a ÷ b × k on shared dates. */
|
||||
function ratio(x: StatPoint[], y: StatPoint[], k = 100): StatPoint[] {
|
||||
const m = new Map(y);
|
||||
return x
|
||||
.filter(([d]) => m.get(d))
|
||||
.map(([d, v]) => [d, Math.round((v / m.get(d)!) * k * 100) / 100]);
|
||||
}
|
||||
function sum(...series: StatPoint[][]): StatPoint[] {
|
||||
const maps = series.map((x) => new Map(x));
|
||||
return series[0]
|
||||
.filter(([d]) => maps.every((m) => m.has(d)))
|
||||
.map(([d]) => [d, Math.round(maps.reduce((t, m) => t + m.get(d)!, 0) * 10000) / 10000]);
|
||||
}
|
||||
const elecAllIn = $derived(sum(pts('cbs:tariff:elec:variable'), pts('cbs:tariff:elec:tax')));
|
||||
const gasAllIn = $derived(sum(pts('cbs:tariff:gas:variable'), pts('cbs:tariff:gas:tax')));
|
||||
const elec1823 = $derived(
|
||||
sum(pts('cbs:tariff1823:elec:variable'), pts('cbs:tariff1823:elec:tax'))
|
||||
);
|
||||
const gas1823 = $derived(sum(pts('cbs:tariff1823:gas:variable'), pts('cbs:tariff1823:gas:tax')));
|
||||
const importDep = $derived(ratio(pts('cbs:energy:netimport'), pts('cbs:energy:tpes')));
|
||||
const evShare = $derived(ratio(pts('cbs:cars:electric'), pts('cbs:cars:total')));
|
||||
const evNewShare = $derived(
|
||||
ratio(pts('eurostat:road_eqr_carpda:NR.ELC.NL'), pts('eurostat:road_eqr_carpda:NR.TOTAL.NL'))
|
||||
);
|
||||
/** Current band, with the pre-2007 band (old methodology) stitched in before it starts. */
|
||||
function stitch(now: StatPoint[], old: StatPoint[]): StatPoint[] {
|
||||
const start = now[0]?.[0] ?? 9999;
|
||||
return [...old.filter(([d]) => d < start), ...now];
|
||||
}
|
||||
const elecPrice = (g: string) =>
|
||||
stitch(
|
||||
pts(`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.I_TAX.EUR.${g}`),
|
||||
pts(`eurostat:nrg_pc_204:E7000.KWH3500.KWH.I_TAX.EUR.${g}`)
|
||||
);
|
||||
const gasPrice = (g: string) =>
|
||||
stitch(
|
||||
pts(`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.I_TAX.EUR.${g}`),
|
||||
pts(`eurostat:nrg_pc_202:G3000.GJ83P70.GJ_GCV.I_TAX.EUR.${g}`).map(
|
||||
([d, v]) => [d, Math.round((v / 277.778) * 10000) / 10000] as StatPoint
|
||||
)
|
||||
);
|
||||
const ranking = (f: (g: string) => StatPoint[]) => {
|
||||
const rows = EU.map((g) => ({ g, p: latest(f(g)) })).filter((r) => r.p);
|
||||
const newest = rows.reduce((m, r) => (r.p![0] > m ? r.p![0] : m), '');
|
||||
return {
|
||||
when: newest,
|
||||
rows: rows
|
||||
.filter((r) => r.p![0] === newest)
|
||||
.map((r) => ({ label: COUNTRY[r.g] ?? r.g, value: r.p![1], highlight: r.g === 'NL' }))
|
||||
};
|
||||
};
|
||||
const elecRank = $derived(ranking(elecPrice));
|
||||
const gasRank = $derived(ranking(gasPrice));
|
||||
const depRank = $derived(ranking((g) => pts(`eurostat:nrg_ind_id:TOTAL.PC.${g}`)));
|
||||
const usElec = $derived(pts('fred:APU000072610'));
|
||||
const giga = (p: StatPoint[], k: number) =>
|
||||
p.map(([d, v]) => [d, Math.round((v / k) * 100) / 100] as StatPoint);
|
||||
const goudaSolarPerK = $derived(
|
||||
ratio(pts('cbs:solar:homes:n:GM0513'), pts('cbs:pop:jan1:GM0513'), 1000)
|
||||
);
|
||||
|
||||
const kE = $derived(latest(elecAllIn));
|
||||
const kG = $derived(latest(gasAllIn));
|
||||
const kBrent = $derived(last('fred:DCOILBRENTEU'));
|
||||
const kTtf = $derived(last('fred:PNGASEUUSDM'));
|
||||
const kRenew = $derived(last('cbs:renew:total:share'));
|
||||
const kHp = $derived(last('cbs:heatpumps:homes'));
|
||||
const kEv = $derived(last('cbs:cars:electric'));
|
||||
const kDep = $derived(latest(importDep));
|
||||
</script>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / energy</span>
|
||||
<h1>Energy</h1>
|
||||
<p>
|
||||
What Dutch households pay for electricity and gas, how that compares with Europe and the US, and
|
||||
how fast the Netherlands is switching: renewables, heat pumps, solar panels and electric cars.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="kpis">
|
||||
<Kpi
|
||||
label="Electricity, variable + tax"
|
||||
value={num(kE?.[1], 3)}
|
||||
unit="€/kWh"
|
||||
sub={period(kE?.[0])}
|
||||
color="#eab308"
|
||||
/>
|
||||
<Kpi
|
||||
label="Gas, variable + tax"
|
||||
value={num(kG?.[1], 2)}
|
||||
unit="€/m³"
|
||||
sub={period(kG?.[0])}
|
||||
color="#f97316"
|
||||
/>
|
||||
<Kpi
|
||||
label="Brent crude"
|
||||
value={num(kBrent?.[1], 1)}
|
||||
unit="$/bbl"
|
||||
sub={`week of ${period(kBrent?.[0], 'D')}`}
|
||||
color="#64748b"
|
||||
/>
|
||||
<Kpi
|
||||
label="EU gas (TTF)"
|
||||
value={num(kTtf?.[1], 1)}
|
||||
unit="$/MMBtu"
|
||||
sub={period(kTtf?.[0])}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<Kpi
|
||||
label="Renewable electricity"
|
||||
value={num(kRenew?.[1], 0)}
|
||||
unit="%"
|
||||
sub={`of consumption, ${period(kRenew?.[0], 'A')}`}
|
||||
color="#10b981"
|
||||
/>
|
||||
<Kpi
|
||||
label="Import dependence"
|
||||
value={num(kDep?.[1], 0)}
|
||||
unit="%"
|
||||
sub={`net imports / supply, ${period(kDep?.[0], 'A')}`}
|
||||
color="#e11d48"
|
||||
/>
|
||||
<Kpi
|
||||
label="Heat pumps in homes"
|
||||
value={compact(kHp?.[1])}
|
||||
sub={`end of ${period(kHp?.[0], 'A')}`}
|
||||
color="#06b6d4"
|
||||
/>
|
||||
<Kpi
|
||||
label="Electric cars"
|
||||
value={compact(kEv?.[1])}
|
||||
sub={`1 Jan ${period(kEv?.[0], 'A')}`}
|
||||
color="#a855f7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EnergyLive energy={data.energy} attributions={a} />
|
||||
|
||||
<h2 class="stats-section-title">Household tariffs in the Netherlands</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Electricity price per kWh"
|
||||
subtitle="€/kWh incl. VAT · variable supply + energy tax (CBS) and all-in average 2007–2022"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
note="Since 2023 the energy tax reduction (≈ €600 a year) is a fixed credit, so the marginal kWh is pricier than the average bill. The 2023 price cap is not included."
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Average price 2007–2022 (2.5–5 MWh)',
|
||||
points: pts('cbs:price0723:elec'),
|
||||
color: '#64748b'
|
||||
},
|
||||
{ name: 'Variable + tax 2018–2022', points: elec1823, color: '#eab308', dashed: true },
|
||||
{ name: 'Variable + tax (new contracts)', points: elecAllIn, color: '#eab308' },
|
||||
{
|
||||
name: 'Dynamic contract (supply only)',
|
||||
points: pts('cbs:tariff:elec:dynamic'),
|
||||
color: '#10b981'
|
||||
}
|
||||
],
|
||||
{ unit: '€/kWh', decimals: 3, zoom: true }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Gas price per m³"
|
||||
subtitle="€/m³ incl. VAT · variable supply + energy tax"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Variable + tax 2018–2022', points: gas1823, color: '#f97316', dashed: true },
|
||||
{ name: 'Variable + tax (new contracts)', points: gasAllIn, color: '#f97316' },
|
||||
{
|
||||
name: 'of which energy tax',
|
||||
points: pts('cbs:tariff:gas:tax'),
|
||||
color: '#e11d48',
|
||||
kind: 'area'
|
||||
}
|
||||
],
|
||||
{ unit: '€/m³', decimals: 3, zoom: true }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="What a kWh is made of"
|
||||
subtitle="€/kWh incl. VAT · supply vs taxes"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Supply (variable)',
|
||||
points: [
|
||||
...pts('cbs:tariff1823:elec:variable').filter(([d]) => d < '2021-01-01'),
|
||||
...pts('cbs:tariff:elec:variable')
|
||||
],
|
||||
color: '#eab308',
|
||||
kind: 'area',
|
||||
stack: 'kwh'
|
||||
},
|
||||
{
|
||||
name: 'Energy tax',
|
||||
points: [
|
||||
...pts('cbs:tariff1823:elec:tax').filter(([d]) => d < '2021-01-01'),
|
||||
...pts('cbs:tariff:elec:tax')
|
||||
],
|
||||
color: '#e11d48',
|
||||
kind: 'area',
|
||||
stack: 'kwh'
|
||||
},
|
||||
{
|
||||
name: 'ODE surcharge',
|
||||
points: pts('cbs:tariff1823:elec:ode'),
|
||||
color: '#a855f7',
|
||||
kind: 'area',
|
||||
stack: 'kwh'
|
||||
}
|
||||
],
|
||||
{ unit: '€/kWh', decimals: 3 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Fixed costs per year"
|
||||
subtitle="€ per year incl. VAT · electricity connection"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Network (transport) tariff',
|
||||
points: pts('cbs:tariff:elec:transport'),
|
||||
color: '#3b82f6',
|
||||
kind: 'step'
|
||||
},
|
||||
{
|
||||
name: 'Fixed supply tariff',
|
||||
points: pts('cbs:tariff:elec:fixed'),
|
||||
color: '#eab308',
|
||||
kind: 'step'
|
||||
},
|
||||
{
|
||||
name: 'Energy tax reduction',
|
||||
points: pts('cbs:tariff:elec:taxcredit'),
|
||||
color: '#10b981',
|
||||
kind: 'step'
|
||||
},
|
||||
{
|
||||
name: 'Gas network tariff',
|
||||
points: pts('cbs:tariff:gas:transport'),
|
||||
color: '#f97316',
|
||||
kind: 'step'
|
||||
}
|
||||
],
|
||||
{ unit: '€/year', decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Compared with Europe and the US</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Household electricity price, Europe"
|
||||
subtitle={`€/kWh incl. all taxes · 2,500–5,000 kWh a year · ${period(elecRank.when, 'S')}`}
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
height="360px"
|
||||
option={(t) =>
|
||||
barRankOption(t, elecRank.rows, { decimals: 3, unit: '€/kWh', color: '#eab308' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Electricity: Netherlands, Germany, EU and the US"
|
||||
subtitle="per kWh incl. taxes · Europe in € (2,500–5,000 kWh; 3,500 kWh band before 2007), US city average in $ (BLS)"
|
||||
sources={['eurostat', 'fred']}
|
||||
attributions={a}
|
||||
height="360px"
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Netherlands (€)', points: elecPrice('NL'), color: '#f97316', kind: 'step' },
|
||||
{ name: 'Germany (€)', points: elecPrice('DE'), color: '#64748b', kind: 'step' },
|
||||
{ name: 'EU 27 (€)', points: elecPrice('EU27_2020'), color: '#3b82f6', kind: 'step' },
|
||||
{ name: 'United States ($)', points: usElec, color: '#e11d48' }
|
||||
],
|
||||
{ unit: 'per kWh', decimals: 3 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Household gas price, Europe"
|
||||
subtitle={`€/kWh incl. all taxes · 20–200 GJ a year · ${period(gasRank.when, 'S')}`}
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
height="360px"
|
||||
option={(t) => barRankOption(t, gasRank.rows, { decimals: 3, unit: '€/kWh', color: '#f97316' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Dutch household prices: taxes vs the rest"
|
||||
subtitle="€/kWh · Eurostat semesters"
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
height="360px"
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Electricity excl. taxes',
|
||||
points: pts('eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.X_TAX.EUR.NL'),
|
||||
color: '#eab308',
|
||||
kind: 'step',
|
||||
dashed: true
|
||||
},
|
||||
{
|
||||
name: 'Electricity incl. taxes',
|
||||
points: elecPrice('NL'),
|
||||
color: '#eab308',
|
||||
kind: 'step'
|
||||
},
|
||||
{
|
||||
name: 'Gas excl. taxes',
|
||||
points: pts('eurostat:nrg_pc_202:G3000.GJ20-199.KWH.X_TAX.EUR.NL'),
|
||||
color: '#f97316',
|
||||
kind: 'step',
|
||||
dashed: true
|
||||
},
|
||||
{ name: 'Gas incl. taxes', points: gasPrice('NL'), color: '#f97316', kind: 'step' }
|
||||
],
|
||||
{ unit: '€/kWh', decimals: 3 }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Oil, gas and imports</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Brent crude and EU natural gas"
|
||||
subtitle="Brent $/barrel (weekly) · TTF $/MMBtu (monthly, IMF)"
|
||||
sources={['fred']}
|
||||
attributions={a}
|
||||
wide
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Brent', points: pts('fred:DCOILBRENTEU'), color: '#64748b' },
|
||||
{ name: 'EU gas (TTF)', points: pts('fred:PNGASEUUSDM'), color: '#3b82f6', right: true }
|
||||
],
|
||||
{ unit: '$/bbl', rightUnit: '$/MMBtu', zoom: true }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Import dependence since 1960"
|
||||
subtitle="net energy imports as % of total primary supply"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(t, [{ name: 'Netherlands', points: importDep, color: '#e11d48', kind: 'area' }], {
|
||||
unit: '%',
|
||||
legend: false
|
||||
})}
|
||||
/>
|
||||
<StatChart
|
||||
title="Natural gas: production vs imports"
|
||||
subtitle="PJ per year · the Groningen field from boom to shutdown"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Production',
|
||||
points: pts('cbs:energy:gas:production'),
|
||||
color: '#10b981',
|
||||
kind: 'area'
|
||||
},
|
||||
{ name: 'Net imports', points: pts('cbs:energy:gas:netimport'), color: '#e11d48' },
|
||||
{ name: 'Supply', points: pts('cbs:energy:gas:tpes'), color: '#64748b', dashed: true }
|
||||
],
|
||||
{ unit: 'PJ', decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Energy import dependency, Europe"
|
||||
subtitle={`% of gross available energy · ${period(depRank.when, 'A')}`}
|
||||
sources={['eurostat']}
|
||||
attributions={a}
|
||||
height="340px"
|
||||
option={(t) => barRankOption(t, depRank.rows, { unit: '%', color: '#e11d48' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">The switch</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Renewable electricity"
|
||||
subtitle="% of electricity consumption · normalised for wind and water"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Onshore wind',
|
||||
points: pts('cbs:renew:wind-onshore:share'),
|
||||
color: '#06b6d4',
|
||||
kind: 'area',
|
||||
stack: 'r'
|
||||
},
|
||||
{
|
||||
name: 'Offshore wind',
|
||||
points: pts('cbs:renew:wind-offshore:share'),
|
||||
color: '#3b82f6',
|
||||
kind: 'area',
|
||||
stack: 'r'
|
||||
},
|
||||
{
|
||||
name: 'Solar',
|
||||
points: pts('cbs:renew:solar:share'),
|
||||
color: '#eab308',
|
||||
kind: 'area',
|
||||
stack: 'r'
|
||||
},
|
||||
{
|
||||
name: 'Biomass',
|
||||
points: pts('cbs:renew:biomass:share'),
|
||||
color: '#84cc16',
|
||||
kind: 'area',
|
||||
stack: 'r'
|
||||
},
|
||||
{
|
||||
name: 'Hydro',
|
||||
points: pts('cbs:renew:hydro:share'),
|
||||
color: '#a855f7',
|
||||
kind: 'area',
|
||||
stack: 'r'
|
||||
}
|
||||
],
|
||||
{ unit: '%' }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Installed solar and wind capacity"
|
||||
subtitle="GW, end of year"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Solar',
|
||||
points: giga(pts('cbs:renew:solar:mw'), 1000),
|
||||
color: '#eab308',
|
||||
kind: 'bar'
|
||||
},
|
||||
{
|
||||
name: 'Onshore wind',
|
||||
points: giga(pts('cbs:renew:wind-onshore:mw'), 1000),
|
||||
color: '#06b6d4',
|
||||
kind: 'bar'
|
||||
},
|
||||
{
|
||||
name: 'Offshore wind',
|
||||
points: giga(pts('cbs:renew:wind-offshore:mw'), 1000),
|
||||
color: '#3b82f6',
|
||||
kind: 'bar'
|
||||
}
|
||||
],
|
||||
{ unit: 'GW' }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Heat pumps in Dutch homes"
|
||||
subtitle="installed at the end of each year"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Air source',
|
||||
points: pts('cbs:heatpumps:homes:air'),
|
||||
color: '#06b6d4',
|
||||
kind: 'area',
|
||||
stack: 'hp'
|
||||
},
|
||||
{
|
||||
name: 'Ground / water source',
|
||||
points: pts('cbs:heatpumps:homes:ground'),
|
||||
color: '#10b981',
|
||||
kind: 'area',
|
||||
stack: 'hp'
|
||||
},
|
||||
{
|
||||
name: 'New per year (all buildings)',
|
||||
points: pts('cbs:heatpumps:new'),
|
||||
color: '#e11d48',
|
||||
kind: 'bar',
|
||||
right: true
|
||||
}
|
||||
],
|
||||
{ decimals: 0, rightUnit: 'new' }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Solar panels on homes: Netherlands and Gouda"
|
||||
subtitle="installed panel capacity, MWp · Gouda on the right axis"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
note={goudaSolarPerK.length
|
||||
? `Gouda: ${num(latest(goudaSolarPerK)?.[1], 0)} home installations per 1,000 inhabitants in ${period(latest(goudaSolarPerK)?.[0], 'A')}.`
|
||||
: ''}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Netherlands',
|
||||
points: giga(pts('cbs:solar:homes:kwp:NL01'), 1000),
|
||||
color: '#eab308',
|
||||
kind: 'bar'
|
||||
},
|
||||
{
|
||||
name: 'Gouda',
|
||||
points: giga(pts('cbs:solar:homes:kwp:GM0513'), 1000),
|
||||
color: '#f97316',
|
||||
right: true
|
||||
}
|
||||
],
|
||||
{ unit: 'NL MWp', rightUnit: 'Gouda MWp', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Electric cars"
|
||||
subtitle="share of the passenger car fleet (1 January) and of new registrations"
|
||||
sources={['cbs', 'eurostat']}
|
||||
attributions={a}
|
||||
wide
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Share of all cars on the road', points: evShare, color: '#a855f7', kind: 'bar' },
|
||||
{ name: 'Share of new cars registered', points: evNewShare, color: '#10b981' }
|
||||
],
|
||||
{ unit: '%' }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if epHomes}
|
||||
<h2 class="stats-section-title">Energy labels of homes</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Homes by energy label"
|
||||
subtitle={`% of homes with a registered label · ${num(epHomes[1], 0)} homes in NL · ${period(epHomes[0], 'M')}`}
|
||||
sources={['eponline']}
|
||||
attributions={a}
|
||||
wide
|
||||
height="220px"
|
||||
note="Latest registered label per home (BAG dwelling) in EP-Online's monthly total file; labels on building-permit designs are left out. Gouda = postcodes 2800–2809."
|
||||
option={(t) =>
|
||||
labelStackOption(t, [
|
||||
{ name: 'Netherlands', shares: epShares('NL') },
|
||||
{ name: 'Gouda', shares: epShares('GOUDA') }
|
||||
])}
|
||||
/>
|
||||
{#if epAorBetter.length > 1}
|
||||
<StatChart
|
||||
title="Homes labelled A or better"
|
||||
subtitle="% of homes with a registered label, monthly"
|
||||
sources={['eponline']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Netherlands', points: epAorBetter, color: '#0f172a', dashed: true },
|
||||
{ name: 'Gouda', points: epGoudaAorBetter, color: '#f97316' }
|
||||
],
|
||||
{ unit: '%', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
420
src/lib/stats/views/GoudaView.svelte
Normal file
420
src/lib/stats/views/GoudaView.svelte
Normal file
@@ -0,0 +1,420 @@
|
||||
<script lang="ts">
|
||||
import type { GoudaViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import PlaceHeritage from '#lib/stats/PlaceHeritage.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
import { barRankOption, change, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||
import { compact, num, period } from '#lib/stats/format.js';
|
||||
import { epShareId, EP_LABELS, GOUDA_PC4, labelStackOption } from '#lib/stats/energylabels.js';
|
||||
|
||||
let { data }: { data: GoudaViewData & StatsCommon } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
const last = (id: string) => latest(s[id]?.points);
|
||||
// EP-Online energy labels per Gouda postcode (PC4), latest monthly snapshot.
|
||||
const epShares = (area: string) => EP_LABELS.map((l) => last(epShareId(l, area))?.[1] ?? 0);
|
||||
const epPeriod = $derived(last(epShareId('A', 'GOUDA'))?.[0]);
|
||||
const epRows = $derived([
|
||||
{ name: 'Netherlands', shares: epShares('NL') },
|
||||
{ name: 'Gouda (2800–2809)', shares: epShares('GOUDA') },
|
||||
...GOUDA_PC4.filter((p) => s[epShareId('A', p)]?.points.length).map((p) => ({
|
||||
name: p,
|
||||
shares: epShares(p)
|
||||
}))
|
||||
]);
|
||||
|
||||
const NAMES: Record<string, string> = {
|
||||
NL01: 'Netherlands',
|
||||
GM0513: 'Gouda',
|
||||
GM0627: 'Waddinxveen',
|
||||
GM1901: 'Bodegraven-Reeuwijk',
|
||||
GM1892: 'Zuidplas',
|
||||
GM1931: 'Krimpenerwaard',
|
||||
GM0484: 'Alphen aan den Rijn',
|
||||
GM0632: 'Woerden'
|
||||
};
|
||||
const RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];
|
||||
const COLORS: Record<string, string> = {
|
||||
GM0513: '#f97316',
|
||||
NL01: '#0f172a',
|
||||
GM0627: '#3b82f6',
|
||||
GM1901: '#10b981',
|
||||
GM1892: '#a855f7',
|
||||
GM1931: '#06b6d4',
|
||||
GM0484: '#eab308',
|
||||
GM0632: '#e11d48'
|
||||
};
|
||||
|
||||
const pop = $derived(last('cbs:pop:monthly:GM0513'));
|
||||
const popYoy = $derived(change(pts('cbs:pop:monthly:GM0513'), 12)?.abs ?? null);
|
||||
const price = $derived(last('cbs:house:avgprice:GM0513'));
|
||||
const priceNl = $derived(last('cbs:house:avgprice:NL01'));
|
||||
const hpi = $derived(pts('cbs:house:hpi:cr28'));
|
||||
const hpiYoy = $derived(hpi.length > 4 ? (hpi.at(-1)![1] / hpi.at(-5)![1] - 1) * 100 : null);
|
||||
const stock = $derived(last('cbs:stock:total:GM0513'));
|
||||
const social = $derived(last('cbs:stock:social:GM0513'));
|
||||
const levy = $derived(last('cbs:levy:total:GM0513'));
|
||||
|
||||
// Index every ring municipality's monthly population to 100 in Jan 2016 so sizes compare.
|
||||
// CBS reports historical borders, so start after the last merger (Krimpenerwaard, 2015).
|
||||
const indexed = (all: StatPoint[]): StatPoint[] => {
|
||||
const p = all.filter(([d]) => d >= '2016-01-01');
|
||||
return p.map(([d, v]) => [d, Math.round((v / p[0][1]) * 1000) / 10] as StatPoint);
|
||||
};
|
||||
const levyRank = $derived(
|
||||
RING.map((r) => ({ r, p: last(`cbs:levy:total:${r}`) }))
|
||||
.filter((x) => x.p)
|
||||
.map((x) => ({ label: NAMES[x.r], value: x.p![1], highlight: x.r === 'GM0513' }))
|
||||
);
|
||||
const priceRank = $derived(
|
||||
['NL01', ...RING]
|
||||
.map((r) => ({ r, p: last(`cbs:house:avgprice:${r}`) }))
|
||||
.filter((x) => x.p)
|
||||
.map((x) => ({
|
||||
label: NAMES[x.r],
|
||||
value: Math.round(x.p![1] / 1000),
|
||||
highlight: x.r === 'GM0513'
|
||||
}))
|
||||
);
|
||||
const socialShare = $derived(
|
||||
['NL01', ...RING]
|
||||
.map((r) => {
|
||||
const t = last(`cbs:stock:total:${r}`);
|
||||
const so = last(`cbs:stock:social:${r}`);
|
||||
return t && so
|
||||
? {
|
||||
label: NAMES[r],
|
||||
value: Math.round((so[1] / t[1]) * 1000) / 10,
|
||||
highlight: r === 'GM0513'
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter((x) => x != null)
|
||||
);
|
||||
|
||||
const wijken = $derived(data.wijken.filter((w) => w.code.startsWith('WK')));
|
||||
const totals = $derived(data.wijken.filter((w) => !w.code.startsWith('WK')));
|
||||
const COLS: { key: string; label: string; decimals: number; unit?: string }[] = [
|
||||
{ key: 'T001036', label: 'Inhabitants', decimals: 0 },
|
||||
{ key: 'M001642', label: 'Avg WOZ', decimals: 0, unit: 'k€' },
|
||||
{ key: '1014850_2', label: 'Rented', decimals: 0, unit: '%' },
|
||||
{ key: 'A047047', label: 'Social housing', decimals: 0, unit: '%' },
|
||||
{ key: 'M000224', label: 'Income / inhabitant', decimals: 1, unit: 'k€' },
|
||||
{ key: 'M008349', label: 'In poverty', decimals: 1, unit: '%' },
|
||||
{ key: 'M000219_2', label: 'Gas use', decimals: 0, unit: 'm³' },
|
||||
{ key: 'M008297', label: 'Solar homes', decimals: 0, unit: '%' }
|
||||
];
|
||||
const wijkBars = (key: string) =>
|
||||
wijken
|
||||
.filter((w) => w.values[key] != null)
|
||||
.map((w) => ({ label: w.title, value: w.values[key] as number }));
|
||||
</script>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / gouda</span>
|
||||
<h1>Gouda</h1>
|
||||
<p>
|
||||
My home town in numbers, next to its neighbours: Waddinxveen, Bodegraven-Reeuwijk, Zuidplas,
|
||||
Krimpenerwaard, Alphen aan den Rijn and Woerden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="kpis">
|
||||
<Kpi
|
||||
label="Inhabitants"
|
||||
value={num(pop?.[1], 0)}
|
||||
sub={` in a year · ${period(pop?.[0])}`}
|
||||
delta={popYoy}
|
||||
color="#f97316"
|
||||
/>
|
||||
<Kpi
|
||||
label="Average sale price"
|
||||
value={`€${compact(price?.[1])}`}
|
||||
sub={`${period(price?.[0], 'A')} · NL €${compact(priceNl?.[1])}`}
|
||||
color="#e11d48"
|
||||
/>
|
||||
<Kpi
|
||||
label="House prices, region"
|
||||
value={num(hpi.at(-1)?.[1], 0)}
|
||||
unit="index"
|
||||
sub={` in a year · ${period(hpi.at(-1)?.[0], 'Q')}`}
|
||||
delta={hpiYoy}
|
||||
deltaUnit="%"
|
||||
invert
|
||||
color="#a855f7"
|
||||
/>
|
||||
<Kpi
|
||||
label="Homes"
|
||||
value={num(stock?.[1], 0)}
|
||||
sub={`${num(social && stock ? (social[1] / stock[1]) * 100 : null, 0)}% social rent · ${period(stock?.[0], 'A')}`}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<Kpi
|
||||
label="Municipal levies"
|
||||
value={`€${num(levy?.[1], 0)}`}
|
||||
sub={`multi-person household, ${period(levy?.[0], 'A')}`}
|
||||
color="#10b981"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">People</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Population of Gouda since 1942"
|
||||
subtitle="inhabitants on 1 January"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[{ name: 'Gouda', points: pts('cbs:pop:jan1:GM0513'), color: '#f97316', kind: 'area' }],
|
||||
{ decimals: 0, legend: false }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Growth since 2016, Gouda and its neighbours"
|
||||
subtitle="monthly population, January 2016 = 100"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
['NL01', ...RING].map((r) => ({
|
||||
name: NAMES[r],
|
||||
points: indexed(pts(`cbs:pop:monthly:${r}`)),
|
||||
color: COLORS[r],
|
||||
dashed: r === 'NL01'
|
||||
})),
|
||||
{ decimals: 1, zoom: true }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Births, deaths and migration in Gouda"
|
||||
subtitle="people per year"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
wide
|
||||
note="Moving in/out counts moves to and from other Dutch municipalities; immigration and emigration are moves across the border."
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Births', points: pts('cbs:pop:births:GM0513'), color: '#10b981' },
|
||||
{ name: 'Deaths', points: pts('cbs:pop:deaths:GM0513'), color: '#64748b' },
|
||||
{ name: 'Immigration', points: pts('cbs:pop:immigration:GM0513'), color: '#3b82f6' },
|
||||
{ name: 'Emigration', points: pts('cbs:pop:emigration:GM0513'), color: '#a855f7' },
|
||||
{ name: 'Moved in', points: pts('cbs:pop:in:GM0513'), color: '#eab308', dashed: true },
|
||||
{ name: 'Moved out', points: pts('cbs:pop:out:GM0513'), color: '#e11d48', dashed: true },
|
||||
{
|
||||
name: 'Net growth',
|
||||
points: pts('cbs:pop:growth:GM0513'),
|
||||
color: '#f97316',
|
||||
kind: 'bar'
|
||||
}
|
||||
],
|
||||
{ decimals: 0, zoom: true }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Housing</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Average sale price of existing homes"
|
||||
subtitle="€ per year · Gouda vs neighbours and the Netherlands"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
['NL01', ...RING].map((r) => ({
|
||||
name: NAMES[r],
|
||||
points: pts(`cbs:house:avgprice:${r}`),
|
||||
color: COLORS[r],
|
||||
dashed: r === 'NL01'
|
||||
})),
|
||||
{ unit: '€', decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="House price index"
|
||||
subtitle="2020 = 100 · Oost-Zuid-Holland (Gouda's COROP region, quarterly) vs NL (monthly)"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Oost-Zuid-Holland', points: pts('cbs:house:hpi:cr28'), color: '#f97316' },
|
||||
{ name: 'Netherlands', points: pts('cbs:house:hpi:nl'), color: '#0f172a', dashed: true }
|
||||
],
|
||||
{ decimals: 1, zoom: true }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Average sale price, latest year"
|
||||
subtitle={`thousand € · ${period(price?.[0], 'A')}`}
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) => barRankOption(t, priceRank, { decimals: 0, unit: 'k€', color: '#64748b' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Gouda's homes by ownership"
|
||||
subtitle="dwellings on 1 January"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Owner-occupied',
|
||||
points: pts('cbs:stock:owner:GM0513'),
|
||||
color: '#3b82f6',
|
||||
kind: 'bar',
|
||||
stack: 's'
|
||||
},
|
||||
{
|
||||
name: 'Social rent (housing corporation)',
|
||||
points: pts('cbs:stock:social:GM0513'),
|
||||
color: '#f97316',
|
||||
kind: 'bar',
|
||||
stack: 's'
|
||||
},
|
||||
{
|
||||
name: 'Private rent',
|
||||
points: pts('cbs:stock:private:GM0513'),
|
||||
color: '#a855f7',
|
||||
kind: 'bar',
|
||||
stack: 's'
|
||||
}
|
||||
],
|
||||
{ decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Share of social rented homes"
|
||||
subtitle={`% of dwellings · ${period(stock?.[0], 'A')}`}
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) => barRankOption(t, socialShare, { unit: '%', color: '#64748b' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Municipal levies"
|
||||
subtitle={`total municipal housing charges, € per multi-person household · ${period(levy?.[0], 'A')}`}
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) => barRankOption(t, levyRank, { decimals: 0, unit: '€', color: '#64748b' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Gouda's levies over time"
|
||||
subtitle="€ per multi-person household"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
wide
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{
|
||||
name: 'Property tax (OZB)',
|
||||
points: pts('cbs:levy:ozb:GM0513'),
|
||||
color: '#3b82f6',
|
||||
kind: 'bar',
|
||||
stack: 'l'
|
||||
},
|
||||
{
|
||||
name: 'Sewer charge',
|
||||
points: pts('cbs:levy:sewer:GM0513'),
|
||||
color: '#06b6d4',
|
||||
kind: 'bar',
|
||||
stack: 'l'
|
||||
},
|
||||
{
|
||||
name: 'Waste charge',
|
||||
points: pts('cbs:levy:waste:GM0513'),
|
||||
color: '#10b981',
|
||||
kind: 'bar',
|
||||
stack: 'l'
|
||||
}
|
||||
],
|
||||
{ unit: '€', decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
{#if epPeriod}
|
||||
<StatChart
|
||||
title="Energy labels by postcode"
|
||||
subtitle={`% of homes with a registered label · ${period(epPeriod, 'M')}`}
|
||||
sources={['eponline']}
|
||||
attributions={a}
|
||||
wide
|
||||
height="440px"
|
||||
note="Latest registered label per home in EP-Online's monthly total file (permit-stage labels left out). 2809 is mostly new-build (Westergouwe)."
|
||||
option={(t) => labelStackOption(t, epRows)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Neighbourhoods ({data.kwbYear})</h2>
|
||||
{#if wijken.length}
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Average WOZ value by neighbourhood"
|
||||
subtitle={`thousand € · ${data.kwbYear}`}
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
barRankOption(t, wijkBars('M001642'), { decimals: 0, unit: 'k€', color: '#e11d48' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Income per inhabitant by neighbourhood"
|
||||
subtitle={`thousand € a year · ${data.kwbYear}`}
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
barRankOption(t, wijkBars('M000224'), { decimals: 1, unit: 'k€', color: '#10b981' })}
|
||||
/>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Neighbourhood</th>
|
||||
{#each COLS as c (c.key)}<th class="num">{c.label}{c.unit ? ` (${c.unit})` : ''}</th
|
||||
>{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each wijken as w (w.code)}
|
||||
<tr>
|
||||
<td>{w.title}</td>
|
||||
{#each COLS as c (c.key)}<td class="num">{num(w.values[c.key], c.decimals)}</td>{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{#each totals as w (w.code)}
|
||||
<tr class="total">
|
||||
<td>{w.title}</td>
|
||||
{#each COLS as c (c.key)}<td class="num">{num(w.values[c.key], c.decimals)}</td>{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="stats-note">
|
||||
{data.attributions.cbs ?? ''} · Kerncijfers wijken en buurten {data.kwbYear}. Blank cells are
|
||||
suppressed by CBS for privacy.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="empty">Neighbourhood figures are not loaded yet.</p>
|
||||
{/if}
|
||||
|
||||
<PlaceHeritage h={data.heritage} attributions={a} />
|
||||
|
||||
<style>
|
||||
tr.total td {
|
||||
font-weight: 600;
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
</style>
|
||||
197
src/lib/stats/views/HistoryView.svelte
Normal file
197
src/lib/stats/views/HistoryView.svelte
Normal file
@@ -0,0 +1,197 @@
|
||||
<script lang="ts">
|
||||
import type { HistoryViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import EventTimeline from '#lib/stats/EventTimeline.svelte';
|
||||
import { barRankOption, lineOption, type Line, type StatPoint } from '#lib/stats/chart.js';
|
||||
import { HERITAGE, indexTo } from '#lib/stats/heritage.js';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
|
||||
let { data }: { data: HistoryViewData & StatsCommon } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
const COUNTRIES = ['NLD', 'USA', 'KOR'] as const;
|
||||
const WORLD = { name: 'World', color: '#94a3b8', dashed: true };
|
||||
const lines = (prefix: string, withCw = false, cwId = ''): Line[] => [
|
||||
...COUNTRIES.map((c) => ({
|
||||
name: HERITAGE[c].name,
|
||||
points: pts(`${prefix}:${c}`),
|
||||
color: HERITAGE[c].color
|
||||
})),
|
||||
...(withCw
|
||||
? [{ name: 'Curaçao', points: pts(cwId || `${prefix}:CUW`), color: HERITAGE.CUW.color }]
|
||||
: []),
|
||||
{ ...WORLD, points: pts(`${prefix}:WLD`) }
|
||||
];
|
||||
const popIndex = $derived([
|
||||
...COUNTRIES.map((c) => ({
|
||||
name: HERITAGE[c].name,
|
||||
points: indexTo(pts(`owid:hist:population:${c}`), '1950'),
|
||||
color: HERITAGE[c].color
|
||||
})),
|
||||
{
|
||||
name: 'Curaçao',
|
||||
points: indexTo(pts('owid:cw:population'), '1950'),
|
||||
color: HERITAGE.CUW.color
|
||||
},
|
||||
{
|
||||
name: 'Gouda',
|
||||
points: indexTo(pts('cbs:pop:jan1:GM0513'), '1950'),
|
||||
color: HERITAGE.GM0513.color
|
||||
},
|
||||
{ ...WORLD, points: indexTo(pts('owid:hist:population:WLD'), '1950') }
|
||||
]);
|
||||
const unaffiliated = $derived(
|
||||
(['NLD', 'USA', 'KOR', 'CUW', 'WLD'] as const)
|
||||
.map((c) => ({
|
||||
label: c === 'WLD' ? 'World' : HERITAGE[c].name,
|
||||
value:
|
||||
pts(`pew:religion:religiously_unaffiliated:${c}`).find(([d]) =>
|
||||
d.startsWith('2020')
|
||||
)?.[1] ?? NaN,
|
||||
highlight: c === 'NLD'
|
||||
}))
|
||||
.filter((r) => Number.isFinite(r.value))
|
||||
);
|
||||
const places = Object.values(HERITAGE);
|
||||
</script>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / history</span>
|
||||
<h1>History and culture</h1>
|
||||
<p>
|
||||
Five places over the long run: the Netherlands, Gouda, Curaçao, the United States and South
|
||||
Korea. Long series come from the Maddison Project, HYDE and the UN via Our World in Data, CBS
|
||||
and Pew; the timeline is curated, and every event links to its source. Each place's own page has
|
||||
its office holders, holidays, UNESCO sites, well-known people and food.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="places">
|
||||
{#each places as p (p.code)}
|
||||
<a class="place" href={`${resolve(p.href as '/stats')}#history`} style:--c={p.color}>
|
||||
<strong>{p.name}</strong>
|
||||
<span>history, politics and culture <ArrowRight size={13} /></span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">The long run</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="GDP per person, long run"
|
||||
subtitle="Maddison Project Database 2023 · international $ at 2011 prices"
|
||||
sources={['owid']}
|
||||
attributions={a}
|
||||
note="Curaçao is not in the Maddison database."
|
||||
option={(t) => lineOption(t, lines('owid:maddison'), { unit: '$', decimals: 0, zoom: true })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Life expectancy, long run"
|
||||
subtitle="years at birth · Riley, Zijdeman et al., HMD and UN WPP via Our World in Data"
|
||||
sources={['owid']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(t, lines('owid:hist:life-expectancy', true, 'owid:cw:life-expectancy'), {
|
||||
unit: 'years',
|
||||
decimals: 1
|
||||
})}
|
||||
/>
|
||||
<StatChart
|
||||
title="Urbanisation since 1500"
|
||||
subtitle="% living in urban areas · HYDE 2023 via Our World in Data"
|
||||
sources={['owid']}
|
||||
attributions={a}
|
||||
option={(t) => lineOption(t, lines('owid:hist:urban-long'), { unit: '%', decimals: 1 })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Population growth since 1950"
|
||||
subtitle="index, 1950 = 100 · OWID (UN WPP), CBS for Gouda"
|
||||
sources={['owid', 'cbs']}
|
||||
attributions={a}
|
||||
option={(t) => lineOption(t, popIndex, { decimals: 0 })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Population since 1500"
|
||||
subtitle="inhabitants · HYDE, Gapminder and UN via Our World in Data"
|
||||
sources={['owid']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
COUNTRIES.map((c) => ({
|
||||
name: HERITAGE[c].name,
|
||||
points: pts(`owid:hist:population:${c}`),
|
||||
color: HERITAGE[c].color
|
||||
})),
|
||||
{ decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="No religion, 2020"
|
||||
subtitle="% of the population religiously unaffiliated · Pew Research Center"
|
||||
sources={['pew']}
|
||||
attributions={a}
|
||||
height="240px"
|
||||
option={(t) => barRankOption(t, unaffiliated, { unit: '%', decimals: 1, color: '#64748b' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="The Netherlands loses its religion"
|
||||
subtitle="% of adults without religious affiliation, and Catholics · CBS since 1849"
|
||||
sources={['cbs']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'No religion', points: pts('cbs:religion:37944:none:NL01'), color: '#64748b' },
|
||||
{
|
||||
name: 'Roman Catholic',
|
||||
points: pts('cbs:religion:37944:catholic:NL01'),
|
||||
color: '#e11d48'
|
||||
},
|
||||
{
|
||||
name: 'Dutch Reformed',
|
||||
points: pts('cbs:religion:37944:hervormd:NL01'),
|
||||
color: '#f97316'
|
||||
}
|
||||
],
|
||||
{ unit: '%', decimals: 1 }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Timeline</h2>
|
||||
<EventTimeline events={data.events} showPlaces />
|
||||
<p class="stats-note">
|
||||
{data.events.length} curated events, each summarised from and linked to its source (mostly Wikipedia,
|
||||
CC BY-SA). Click an event for the summary and source; filter by theme or place.
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.places {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 190px), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.place {
|
||||
border: 1px solid var(--site-border);
|
||||
border-left: 4px solid var(--c);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.place span {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.78rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
</style>
|
||||
606
src/lib/stats/views/OpinionsView.svelte
Normal file
606
src/lib/stats/views/OpinionsView.svelte
Normal file
@@ -0,0 +1,606 @@
|
||||
<script lang="ts">
|
||||
import type { OpinionsViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
import { barRankOption, lineOption, type ChartTheme, type StatPoint } from '#lib/stats/chart.js';
|
||||
import { num, period } from '#lib/stats/format.js';
|
||||
import { PARTY_COLORS } from '#lib/stats/parties.js';
|
||||
import {
|
||||
chesParties,
|
||||
ebLatest,
|
||||
ebTrend,
|
||||
gdeltAverages,
|
||||
GDELT_THEMES,
|
||||
vpartyLines
|
||||
} from '#lib/stats/opinions.js';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
|
||||
let { data }: { data: OpinionsViewData & StatsCommon } = $props();
|
||||
const a = $derived(data.attributions);
|
||||
const day = (d: string | undefined) =>
|
||||
d
|
||||
? new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
: '';
|
||||
|
||||
// ---- Polls ------------------------------------------------------------------------------
|
||||
const nl = $derived(
|
||||
data.nl
|
||||
.filter((o) => o.option !== 'Others')
|
||||
.sort((x, y) => (y.last?.[1] ?? 0) - (x.last?.[1] ?? 0))
|
||||
);
|
||||
const nlLatest = $derived(data.nlLatest);
|
||||
const nlCols = $derived(nl.map((o) => o.option));
|
||||
const us = $derived(data.us);
|
||||
const usApprove = $derived(us.find((o) => o.option === 'Approve'));
|
||||
const usDisapprove = $derived(us.find((o) => o.option === 'Disapprove'));
|
||||
const usAgg = $derived(data.usAgg);
|
||||
const kr = $derived(data.kr);
|
||||
const krApprove = $derived(kr.find((o) => o.option === 'Approve'));
|
||||
const krParty = $derived(
|
||||
data.krParty.filter((o) => o.n > 50).sort((x, y) => (y.last?.[1] ?? 0) - (x.last?.[1] ?? 0))
|
||||
);
|
||||
const KR_COLORS: Record<string, string> = {
|
||||
DPK: '#2563eb',
|
||||
PPP: '#e11d48',
|
||||
RKP: '#f97316',
|
||||
RP: '#eab308',
|
||||
PP: '#0ea5e9',
|
||||
'Und./no ans.': '#94a3b8',
|
||||
Others: '#cbd5e1'
|
||||
};
|
||||
|
||||
// ---- Eurobarometer ----------------------------------------------------------------------
|
||||
const miiCountry = $derived(ebLatest(data.eb, 'mii_country'));
|
||||
const miiPersonal = $derived(ebLatest(data.eb, 'mii_personal'));
|
||||
const trust = $derived(ebLatest(data.eb, 'trust'));
|
||||
const trustEu = $derived(ebLatest(data.eb, 'trust_eu'));
|
||||
const topIssues = $derived(
|
||||
miiCountry.items.filter((i) => !/^(None|Other|Don)/.test(i.item)).slice(0, 6)
|
||||
);
|
||||
const issueTrend = $derived(
|
||||
ebTrend(
|
||||
data.eb,
|
||||
'mii_country',
|
||||
'NL',
|
||||
topIssues.map((i) => i.item)
|
||||
)
|
||||
);
|
||||
const trustTrend = $derived([
|
||||
...ebTrend(data.eb, 'trust', 'NL', ['National government', 'European Union']).map((t) => ({
|
||||
...t,
|
||||
item: `${t.item}, NL`
|
||||
})),
|
||||
...ebTrend(data.eb, 'trust', 'EU27', ['National government', 'European Union']).map((t) => ({
|
||||
...t,
|
||||
item: `${t.item}, EU27`,
|
||||
dashed: true
|
||||
}))
|
||||
]);
|
||||
// Category charts with long labels: show every label, wrapped.
|
||||
const wrapLabels = (o: EChartsCoreOption, width = 90): EChartsCoreOption => {
|
||||
const x = o.xAxis as { axisLabel?: object };
|
||||
x.axisLabel = { ...x.axisLabel, interval: 0, width, overflow: 'break', fontSize: 10 };
|
||||
(o.grid as { bottom: number }).bottom = 40;
|
||||
return o;
|
||||
};
|
||||
const issueBars = (items: { item: string; nl: number | null; eu: number | null }[]) => [
|
||||
{
|
||||
name: 'Netherlands',
|
||||
color: '#f97316',
|
||||
kind: 'bar' as const,
|
||||
points: items.map((i) => [i.item, i.nl ?? 0] as StatPoint)
|
||||
},
|
||||
{
|
||||
name: 'EU27',
|
||||
color: '#3b82f6',
|
||||
kind: 'bar' as const,
|
||||
points: items.map((i) => [i.item, i.eu ?? 0] as StatPoint)
|
||||
}
|
||||
];
|
||||
|
||||
// ---- World Happiness Report ---------------------------------------------------------------
|
||||
const whrYear = $derived(Math.max(...data.whr.map((w) => w.year)));
|
||||
const FOCUS = ['NLD', 'USA', 'KOR', 'DNK', 'FIN', 'DEU', 'CHN'];
|
||||
const whrLatest = $derived(
|
||||
data.whr
|
||||
.filter((w) => w.year === whrYear && w.ladder != null)
|
||||
.sort((x, y) => (x.rank ?? 999) - (y.rank ?? 999))
|
||||
);
|
||||
const whrRows = $derived(
|
||||
whrLatest
|
||||
.filter((w, i) => i < 20 || FOCUS.includes(w.iso3 ?? ''))
|
||||
.map((w) => ({
|
||||
label: `${w.rank}. ${w.country}`,
|
||||
value: w.ladder ?? 0,
|
||||
highlight: FOCUS.includes(w.iso3 ?? '')
|
||||
}))
|
||||
);
|
||||
const nlWhr = $derived(whrLatest.find((w) => w.iso3 === 'NLD'));
|
||||
const whrTrend = $derived(
|
||||
FOCUS.map((iso, i) => ({
|
||||
name: data.whr.find((w) => w.iso3 === iso)?.country ?? iso,
|
||||
color: ['#f97316', '#e11d48', '#3b82f6', '#10b981', '#06b6d4', '#64748b', '#eab308'][i],
|
||||
points: data.whr
|
||||
.filter((w) => w.iso3 === iso && w.ladder != null)
|
||||
.map((w) => [`${w.year}-01-01`, w.ladder!] as StatPoint)
|
||||
})).filter((l) => l.points.length)
|
||||
);
|
||||
|
||||
// ---- Google Trends and GDELT -------------------------------------------------------------
|
||||
const GEOS: Record<string, string> = {
|
||||
NL: 'Netherlands',
|
||||
US: 'United States',
|
||||
KR: 'South Korea'
|
||||
};
|
||||
const trendGeos = $derived([...new Set(data.trends.map((t) => t.geo))]);
|
||||
const gAvg = $derived(gdeltAverages(data.gdelt));
|
||||
const gCountries = $derived([...new Set(data.gdelt.map((g) => g.country))].sort());
|
||||
const gThemes = $derived([...new Set(data.gdelt.map((g) => g.theme))]);
|
||||
const gDays = $derived([...new Set(data.gdelt.map((g) => g.date))].sort());
|
||||
|
||||
// ---- Party positions -----------------------------------------------------------------------
|
||||
const ches = $derived(chesParties(data.ches));
|
||||
const chesYear = $derived(data.ches[0]?.year);
|
||||
const scatter = (t: ChartTheme): EChartsCoreOption => ({
|
||||
animation: false,
|
||||
grid: { left: 40, right: 24, top: 24, bottom: 40 },
|
||||
tooltip: {
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.border,
|
||||
textStyle: { color: t.text, fontSize: 12 },
|
||||
formatter: (p: { data: { value: [number, number, number, string] } }) =>
|
||||
`${p.data.value[3]}<br>economic ${num(p.data.value[0], 1)} · GAL–TAN ${num(p.data.value[1], 1)}<br>${num(p.data.value[2], 1)}% vote`
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 10,
|
||||
name: 'economic left ← → right',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 26,
|
||||
nameTextStyle: { color: t.muted, fontSize: 11 },
|
||||
axisLabel: { color: t.muted, fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: t.border, opacity: 0.6 } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 10,
|
||||
name: 'GAL ↓ TAN ↑',
|
||||
nameTextStyle: { color: t.muted, fontSize: 11 },
|
||||
axisLabel: { color: t.muted, fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: t.border, opacity: 0.6 } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'scatter',
|
||||
data: ches
|
||||
.filter((p) => p.dims.lrecon != null && p.dims.galtan != null)
|
||||
.map((p) => ({
|
||||
value: [p.dims.lrecon, p.dims.galtan, p.share ?? 0, p.label],
|
||||
itemStyle: { color: PARTY_COLORS[p.label] ?? '#64748b', opacity: 0.85 }
|
||||
})),
|
||||
symbolSize: (v: [number, number, number]) => 8 + Math.sqrt(v[2] ?? 0) * 5,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: t.text,
|
||||
fontSize: 11,
|
||||
formatter: (p: { data: { value: [number, number, number, string] } }) => p.data.value[3]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
const vparty = $derived(vpartyLines(data.vparty, 10));
|
||||
</script>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / opinions</span>
|
||||
<h1>Opinions</h1>
|
||||
<p>
|
||||
What people think, from polls and surveys: Dutch seat polls, approval of Trump and Lee
|
||||
Jae-myung, Eurobarometer worries and trust, the World Happiness Report, and where parties stand.
|
||||
For values, what words mean and a party compass across 57 countries see
|
||||
<a href={resolve('/stats/world/politics')}>world opinions</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="kpis">
|
||||
<Kpi
|
||||
label="Leading Dutch party in the polls"
|
||||
value={nl[0]?.option ?? '–'}
|
||||
sub={`${num(nl[0]?.last?.[1], 0)} seats, 5-poll average · ${day(nl[0]?.last?.[0])}`}
|
||||
color="#06b6d4"
|
||||
/>
|
||||
<Kpi
|
||||
label="Trump approval"
|
||||
value={num(usApprove?.last?.[1], 1)}
|
||||
unit="%"
|
||||
sub={`net ${num((usApprove?.last?.[1] ?? 0) - (usDisapprove?.last?.[1] ?? 0), 1)} · 10-poll average`}
|
||||
color="#e11d48"
|
||||
/>
|
||||
<Kpi
|
||||
label="Lee Jae-myung approval"
|
||||
value={num(krApprove?.last?.[1], 1)}
|
||||
unit="%"
|
||||
sub={`10-poll average · ${day(krApprove?.last?.[0])}`}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<Kpi
|
||||
label="Dutch life satisfaction"
|
||||
value={num(nlWhr?.ladder, 2)}
|
||||
unit="/10"
|
||||
sub={`#${nlWhr?.rank ?? '–'} of ${whrLatest.length} · WHR ${whrYear}`}
|
||||
color="#10b981"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Polls</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Dutch seat polls since the 2025 election"
|
||||
subtitle="seats out of 150, 5-poll average · dots are single polls"
|
||||
sources={['wikipedia']}
|
||||
attributions={a}
|
||||
wide
|
||||
height="420px"
|
||||
option={(t) => {
|
||||
const o = lineOption(
|
||||
t,
|
||||
nl.map((p) => ({ name: p.option, color: PARTY_COLORS[p.option], points: p.avg })),
|
||||
{ decimals: 1, zoom: true }
|
||||
) as { series: object[] };
|
||||
o.series.push(
|
||||
...nl.map((p) => ({
|
||||
type: 'scatter',
|
||||
name: p.option,
|
||||
data: p.raw,
|
||||
symbolSize: 4,
|
||||
itemStyle: { color: PARTY_COLORS[p.option] ?? '#94a3b8', opacity: 0.35 },
|
||||
tooltip: { show: false }
|
||||
}))
|
||||
);
|
||||
return o;
|
||||
}}
|
||||
/>
|
||||
<StatChart
|
||||
title="Trump approval"
|
||||
subtitle="% of adults, 10-poll average · dots are single polls"
|
||||
sources={['wikipedia']}
|
||||
attributions={a}
|
||||
option={(t) => {
|
||||
const o = lineOption(
|
||||
t,
|
||||
[
|
||||
{ name: 'Approve', color: '#10b981', points: usApprove?.avg ?? [] },
|
||||
{ name: 'Disapprove', color: '#e11d48', points: usDisapprove?.avg ?? [] }
|
||||
],
|
||||
{ unit: '%', decimals: 1, min: 'dataMin' }
|
||||
) as { series: object[] };
|
||||
o.series.push(
|
||||
...[usApprove, usDisapprove].map((p, i) => ({
|
||||
type: 'scatter',
|
||||
name: p?.option,
|
||||
data: p?.raw ?? [],
|
||||
symbolSize: 3,
|
||||
itemStyle: { color: i ? '#e11d48' : '#10b981', opacity: 0.3 },
|
||||
tooltip: { show: false }
|
||||
}))
|
||||
);
|
||||
return o;
|
||||
}}
|
||||
/>
|
||||
<StatChart
|
||||
title="Lee Jae-myung approval"
|
||||
subtitle="% approve / disapprove, 10-poll average"
|
||||
sources={['wikipedia']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
kr.map((p) => ({
|
||||
name: p.option,
|
||||
color:
|
||||
p.option === 'Approve' ? '#10b981' : p.option === 'Disapprove' ? '#e11d48' : '#94a3b8',
|
||||
points: p.avg
|
||||
})),
|
||||
{ unit: '%', decimals: 1, zoom: true }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="South Korean party support"
|
||||
subtitle="%, 10-poll average"
|
||||
sources={['wikipedia']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
krParty.map((p) => ({ name: p.option, color: KR_COLORS[p.option], points: p.avg })),
|
||||
{ unit: '%', decimals: 1, zoom: true }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Trump approval by aggregator"
|
||||
subtitle={`% approve · ${day(usAgg[0]?.date)}`}
|
||||
sources={['wikipedia']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
barRankOption(
|
||||
t,
|
||||
usAgg.map((r) => ({ label: r.pollster, value: r.values.Approve ?? 0 })),
|
||||
{ decimals: 1, unit: '%', color: '#e11d48' }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if nlLatest.length}
|
||||
<div class="table-wrap">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Latest Dutch polls</th>
|
||||
{#each nlCols as p (p)}<th class="num">{p}</th>{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each nlLatest as r (r.date + r.pollster)}
|
||||
<tr>
|
||||
<td>{r.pollster} · {day(r.date)}</td>
|
||||
{#each nlCols as p (p)}<td class="num">{num(r.values[p], 0)}</td>{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h2 class="stats-section-title">Eurobarometer</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="Most important issues facing the country"
|
||||
subtitle={`% naming the issue (up to two) · ${period(miiCountry.date, 'M')}`}
|
||||
sources={['eurobarometer']}
|
||||
attributions={a}
|
||||
height="420px"
|
||||
option={(t) =>
|
||||
wrapLabels(
|
||||
lineOption(t, issueBars(miiCountry.items.slice(0, 10)), {
|
||||
category: true,
|
||||
unit: '%',
|
||||
decimals: 0
|
||||
})
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Most important issues for you personally"
|
||||
subtitle={`% naming the issue (up to two) · ${period(miiPersonal.date, 'M')}`}
|
||||
sources={['eurobarometer']}
|
||||
attributions={a}
|
||||
height="420px"
|
||||
option={(t) =>
|
||||
wrapLabels(
|
||||
lineOption(t, issueBars(miiPersonal.items.slice(0, 10)), {
|
||||
category: true,
|
||||
unit: '%',
|
||||
decimals: 0
|
||||
})
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="What the Dutch see as the country's main issues"
|
||||
subtitle="% naming the issue, by survey wave"
|
||||
sources={['eurobarometer']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
issueTrend.map((i) => ({ name: i.item, points: i.points })),
|
||||
{ unit: '%', decimals: 0 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Trust in government and the EU"
|
||||
subtitle="% who tend to trust"
|
||||
sources={['eurobarometer']}
|
||||
attributions={a}
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
trustTrend.map((i) => ({
|
||||
name: i.item,
|
||||
points: i.points,
|
||||
color: i.item.startsWith('National') ? '#f97316' : '#3b82f6',
|
||||
dashed: 'dashed' in i
|
||||
})),
|
||||
{ unit: '%', decimals: 0, min: 0 }
|
||||
)}
|
||||
/>
|
||||
<StatChart
|
||||
title="Whom people tend to trust"
|
||||
subtitle={`% who tend to trust · ${period(trust.date, 'M')}`}
|
||||
sources={['eurobarometer']}
|
||||
attributions={a}
|
||||
wide
|
||||
height="380px"
|
||||
option={(t) =>
|
||||
wrapLabels(
|
||||
lineOption(t, issueBars([...trust.items, ...trustEu.items]), {
|
||||
category: true,
|
||||
unit: '%',
|
||||
decimals: 0
|
||||
}),
|
||||
70
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">Happiness</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title={`World Happiness Report ${whrYear + 1}`}
|
||||
subtitle={`life evaluation (Cantril ladder, 0–10), 3-year average to ${whrYear} · top 20 and highlighted countries`}
|
||||
sources={['whr']}
|
||||
attributions={a}
|
||||
height="720px"
|
||||
option={(t) => barRankOption(t, whrRows, { decimals: 2, color: '#94a3b8' })}
|
||||
/>
|
||||
<StatChart
|
||||
title="Life evaluation over time"
|
||||
subtitle="0–10 ladder, report year's 3-year average"
|
||||
sources={['whr']}
|
||||
attributions={a}
|
||||
option={(t) => lineOption(t, whrTrend, { decimals: 2, min: 'dataMin' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="stats-section-title">What people search and read</h2>
|
||||
<div class="trends">
|
||||
{#each trendGeos as g (g)}
|
||||
{@const rows = data.trends.filter((t) => t.geo === g)}
|
||||
<div class="trend-col">
|
||||
<h3>{GEOS[g] ?? g}</h3>
|
||||
<ol>
|
||||
{#each rows.slice(0, 10) as r (r.query)}
|
||||
<li>
|
||||
<span>{r.query}</span>{#if r.traffic}<span class="muted">{num(r.traffic, 0)}+</span
|
||||
>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<p class="muted">Google Trends daily searches · {day(rows[0]?.date)}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if gThemes.length}
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title="News themes"
|
||||
subtitle={`% of online news coverage mentioning the theme, by country of the outlet · ${day(gDays[0])} – ${day(gDays.at(-1))} average`}
|
||||
sources={['gdelt']}
|
||||
attributions={a}
|
||||
wide
|
||||
height="380px"
|
||||
note="GDELT's theme tagging is automatic and coarse; shares compare countries, not absolute attention."
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
gCountries.map((c, i) => ({
|
||||
name: c,
|
||||
kind: 'bar' as const,
|
||||
color: [
|
||||
'#f97316',
|
||||
'#e11d48',
|
||||
'#3b82f6',
|
||||
'#10b981',
|
||||
'#a855f7',
|
||||
'#eab308',
|
||||
'#06b6d4',
|
||||
'#64748b',
|
||||
'#84cc16',
|
||||
'#ec4899'
|
||||
][i % 10],
|
||||
points: gThemes.map((th) => [GDELT_THEMES[th] ?? th, gAvg(c, th)] as StatPoint)
|
||||
})),
|
||||
{ category: true, unit: '%', decimals: 3 }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h2 class="stats-section-title" id="parties">Where Dutch parties stand</h2>
|
||||
<div class="stats-grid">
|
||||
{#if ches.length}
|
||||
<StatChart
|
||||
title={`Dutch parties, CHES ${chesYear}`}
|
||||
subtitle="expert placements, 0–10 · dot size is vote share"
|
||||
sources={['ches']}
|
||||
attributions={a}
|
||||
height="460px"
|
||||
note="GAL: green, alternative, libertarian; TAN: traditional, authoritarian, nationalist."
|
||||
option={scatter}
|
||||
/>
|
||||
{/if}
|
||||
{#if vparty.length}
|
||||
<StatChart
|
||||
title="Populism of Dutch parties since 1946"
|
||||
subtitle="V-Party populism index, 0–1 · parties that ever won 10% or more"
|
||||
sources={['ches']}
|
||||
attributions={a}
|
||||
height="460px"
|
||||
note="V-Party scores each party at each election; the index combines anti-elitism and people-centrism."
|
||||
option={(t) =>
|
||||
lineOption(
|
||||
t,
|
||||
vparty.map((p) => ({ name: p.label, color: PARTY_COLORS[p.label], points: p.points })),
|
||||
{ decimals: 2, zoom: true }
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ches.length}
|
||||
<div class="table-wrap">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Party, CHES {chesYear}</th>
|
||||
<th class="num">Vote %</th>
|
||||
<th class="num">Left–right</th>
|
||||
<th class="num">Economic</th>
|
||||
<th class="num">GAL–TAN</th>
|
||||
<th class="num">EU (1–7)</th>
|
||||
<th class="num">Immigration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ches as p (p.id)}
|
||||
<tr>
|
||||
<td>{p.label}</td>
|
||||
<td class="num">{num(p.share, 1)}</td>
|
||||
<td class="num">{num(p.dims.lrgen, 1)}</td>
|
||||
<td class="num">{num(p.dims.lrecon, 1)}</td>
|
||||
<td class="num">{num(p.dims.galtan, 1)}</td>
|
||||
<td class="num">{num(p.dims.eu_position, 1)}</td>
|
||||
<td class="num">{num(p.dims.immigrate_policy, 1)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="stats-note">
|
||||
{a.ches ?? ''} · Immigration: 0 strongly for a liberal policy, 10 strongly for a restrictive one.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.trends {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.trend-col {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.trend-col h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.trend-col ol {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.trend-col li span:first-child {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.muted {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
141
src/lib/stats/views/WorldOpinionsView.svelte
Normal file
141
src/lib/stats/views/WorldOpinionsView.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import type { WorldOpinionsViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
import './world-opinions.css';
|
||||
import CountryPanel from '#lib/world-opinions/CountryPanel.svelte';
|
||||
import CultureMap from '#lib/world-opinions/CultureMap.svelte';
|
||||
import PartyCompass from '#lib/world-opinions/PartyCompass.svelte';
|
||||
import WorldMap, { type MapMode } from '#lib/world-opinions/WorldMap.svelte';
|
||||
import { buildWorldOpinions, provideWorldOpinions } from '#lib/world-opinions/data.js';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { data }: { data: WorldOpinionsViewData & StatsCommon } = $props();
|
||||
// Static per load (stats.wo_*), so it is read once and shared with the components.
|
||||
const wo = provideWorldOpinions(buildWorldOpinions(data.wo));
|
||||
const { countries, countryById, narratives, sources } = wo;
|
||||
|
||||
const modes: { id: MapMode; label: string }[] = [
|
||||
{ id: 'liberal', label: 'what "liberal" means' },
|
||||
{ id: 'divide', label: 'what left vs right is about' },
|
||||
{ id: 'tradition', label: 'traditional ↔ secular' },
|
||||
{ id: 'expression', label: 'survival ↔ self-expression' }
|
||||
];
|
||||
const sorted = [...countries].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
let mode = $state<MapMode>('liberal');
|
||||
let countryId = $state('us');
|
||||
let eventIndex = $state(0);
|
||||
let narrative = $derived(narratives[eventIndex]);
|
||||
|
||||
function select(id: string) {
|
||||
countryId = id;
|
||||
document.getElementById('country')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / opinions / world</span>
|
||||
<h1>World opinions</h1>
|
||||
<p>
|
||||
Same words, different politics. Hover the map, click a country. For measured party positions
|
||||
(Chapel Hill Expert Survey, V-Party) see <a href={resolve('/stats/core/politics') + '#parties'}
|
||||
>where Dutch parties stand</a
|
||||
>; for polls, happiness and Eurobarometer see
|
||||
<a href={resolve('/stats/core/politics')}>opinions</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="wo">
|
||||
<section aria-label="World map">
|
||||
<div class="mb-4 flex flex-wrap gap-2" role="tablist" aria-label="Map layer">
|
||||
{#each modes as item (item.id)}
|
||||
<Button
|
||||
variant={mode === item.id ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
role="tab"
|
||||
aria-selected={mode === item.id}
|
||||
onclick={() => (mode = item.id)}>{item.label}</Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<WorldMap {mode} selected={countryId} onselect={select} />
|
||||
</section>
|
||||
|
||||
<section id="country" class="scroll-mt-20 pt-10" aria-label="Country">
|
||||
<label class="mb-3 block max-w-xs text-sm">
|
||||
<span class="sr-only">Country</span>
|
||||
<NativeSelect
|
||||
bind:value={countryId}
|
||||
class="w-full rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-3 py-2"
|
||||
>
|
||||
{#each sorted as country (country.id)}
|
||||
<option value={country.id}>{country.name}</option>
|
||||
{/each}
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<CountryPanel id={countryId} />
|
||||
</section>
|
||||
|
||||
<section class="mt-12 border-t border-[var(--site-border)] py-10">
|
||||
<h2 class="stats-section-title mb-1">Party compass</h2>
|
||||
<p class="mb-5 text-[var(--site-muted)]">
|
||||
Every party on one shared scale, hand-placed for comparison. Measured CHES and V-Party
|
||||
positions for Dutch parties are on <a
|
||||
class="underline underline-offset-2"
|
||||
href={resolve('/stats/core/politics') + '#parties'}>opinions</a
|
||||
>.
|
||||
</p>
|
||||
<PartyCompass focus={countryId} />
|
||||
</section>
|
||||
|
||||
<section class="border-t border-[var(--site-border)] py-10">
|
||||
<h2 class="stats-section-title mb-1">Values map</h2>
|
||||
<p class="mb-5 text-[var(--site-muted)]">Both value axes at once.</p>
|
||||
<CultureMap selected={countryId} onselect={select} />
|
||||
</section>
|
||||
|
||||
<section class="border-t border-[var(--site-border)] py-10">
|
||||
<h2 class="stats-section-title mb-4">One event, many stories</h2>
|
||||
<div class="mb-5 flex flex-wrap gap-2" role="tablist" aria-label="Event">
|
||||
{#each narratives as item, index (item.event)}
|
||||
<Button
|
||||
variant={eventIndex === index ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
role="tab"
|
||||
aria-selected={eventIndex === index}
|
||||
onclick={() => (eventIndex = index)}
|
||||
>{item.event.toLowerCase()} <span class="opacity-60">{item.years}</span></Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div role="tabpanel">
|
||||
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each narrative.views as view (view.country)}
|
||||
<li>
|
||||
<button class="wo-card wo-quote w-full text-left" onclick={() => select(view.country)}>
|
||||
<span class="text-sm text-[var(--site-muted)]"
|
||||
>{countryById.get(view.country)?.name}</span
|
||||
>
|
||||
<span class="block text-lg">“{view.story}”</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="border-t border-[var(--site-border)] pt-6 text-sm text-[var(--site-muted)]">
|
||||
<p class="mb-2">Approximate, hand-placed positions for comparison. Based on:</p>
|
||||
<p class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{#each sources as source (source.href)}
|
||||
<a
|
||||
class="underline underline-offset-2 hover:text-[var(--site-text)]"
|
||||
href={source.href}
|
||||
rel="external">{source.name}</a
|
||||
>
|
||||
{/each}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
302
src/lib/stats/views/world-opinions.css
Normal file
302
src/lib/stats/views/world-opinions.css
Normal file
@@ -0,0 +1,302 @@
|
||||
.wo {
|
||||
--wo-s1: #3987e5;
|
||||
--wo-s2: #d95926;
|
||||
--wo-s3: #199e70;
|
||||
--wo-dot: #6b6b68;
|
||||
--wo-dot-dim: #3a3a38;
|
||||
--wo-grid: #1c1c1b;
|
||||
--wo-axis: #3a3a38;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .wo {
|
||||
--wo-s1: #2a78d6;
|
||||
--wo-s2: #eb6834;
|
||||
--wo-s3: #1baf7a;
|
||||
--wo-dot: #9a9994;
|
||||
--wo-dot-dim: #dcdbd6;
|
||||
--wo-grid: #f0efec;
|
||||
--wo-axis: #cfcec9;
|
||||
}
|
||||
|
||||
.wo-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.wo-chip:hover:not(:disabled) {
|
||||
background: var(--site-hover);
|
||||
color: var(--site-text);
|
||||
}
|
||||
.wo-chip[aria-pressed='true'],
|
||||
.wo-chip[aria-selected='true'] {
|
||||
border-color: var(--site-text);
|
||||
color: var(--site-text);
|
||||
}
|
||||
.wo-chip:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wo-swatch {
|
||||
display: inline-block;
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.wo-link {
|
||||
font-size: 0.875rem;
|
||||
color: var(--site-muted);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.wo-link:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
.wo-chart svg text {
|
||||
font-family: inherit;
|
||||
}
|
||||
.wo-grid {
|
||||
stroke: var(--wo-grid);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.wo-axis {
|
||||
stroke: var(--wo-axis);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.wo-axis-label {
|
||||
fill: var(--site-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wo-label {
|
||||
fill: var(--site-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.wo-label-strong {
|
||||
fill: var(--site-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wo-dot {
|
||||
fill: var(--wo-dot);
|
||||
stroke: var(--site-bg);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.wo-dot-dim {
|
||||
fill: var(--wo-dot-dim);
|
||||
stroke: var(--site-bg);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.wo-dot-active {
|
||||
fill: var(--wo-s1);
|
||||
stroke: var(--site-bg);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.wo-ring {
|
||||
stroke: var(--site-bg);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.wo-chart g:focus-visible circle:last-of-type {
|
||||
stroke: var(--site-text);
|
||||
}
|
||||
|
||||
.wo-tooltip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, calc(-100% - 14px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
pointer-events: none;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-surface);
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
}
|
||||
.wo-tooltip strong {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.wo-tooltip .max-w-64 {
|
||||
white-space: normal;
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
.wo-table {
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.wo-table th,
|
||||
.wo-table td {
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding: 0.4rem 0.5rem 0.4rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
.wo-table th {
|
||||
color: var(--site-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wo-card {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.wo {
|
||||
--wo-pos: #3987e5;
|
||||
--wo-neg: #d95926;
|
||||
--wo-mid: #4a4a47;
|
||||
--wo-none: #7a7a76;
|
||||
--wo-nodata: #161615;
|
||||
}
|
||||
:root[data-theme='light'] .wo {
|
||||
--wo-pos: #2a78d6;
|
||||
--wo-neg: #eb6834;
|
||||
--wo-mid: #d9d8d3;
|
||||
--wo-none: #a3a29c;
|
||||
--wo-nodata: #f1f1ef;
|
||||
}
|
||||
|
||||
.wo-country {
|
||||
stroke: var(--site-bg);
|
||||
stroke-width: 0.5;
|
||||
transition: opacity 120ms;
|
||||
}
|
||||
.wo-country-hover {
|
||||
stroke: var(--site-text);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.wo-country-selected {
|
||||
fill: none;
|
||||
stroke: var(--site-text);
|
||||
stroke-width: 1.75;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wo-tooltip-note {
|
||||
margin-top: 0.2rem;
|
||||
width: 15rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.wo-gradient {
|
||||
display: inline-block;
|
||||
width: 9rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(to right, var(--wo-neg), var(--wo-mid), var(--wo-pos));
|
||||
}
|
||||
.wo-swatch-nodata {
|
||||
background: var(--wo-nodata);
|
||||
outline: 1px solid var(--site-border);
|
||||
}
|
||||
|
||||
.wo-gauge {
|
||||
position: relative;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(to right, var(--wo-neg), var(--wo-mid), var(--wo-pos));
|
||||
}
|
||||
.wo-gauge-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 999px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--site-text);
|
||||
border: 3px solid var(--site-surface);
|
||||
transition: left 300ms ease;
|
||||
}
|
||||
|
||||
.wo-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--site-hover);
|
||||
padding: 0.2rem 0.7rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wo-tag-dashed {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--site-border);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
|
||||
.wo-timeline {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
position: relative;
|
||||
}
|
||||
.wo-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 1.85rem;
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
.wo-timeline li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.wo-timeline-year {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wo-timeline-dot {
|
||||
position: relative;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: var(--site-text);
|
||||
}
|
||||
.wo-timeline-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wo-timeline {
|
||||
grid-auto-flow: row;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.wo-timeline::before {
|
||||
display: none;
|
||||
}
|
||||
.wo-timeline li {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
}
|
||||
.wo-timeline-year {
|
||||
width: 3.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wo-quote:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
|
||||
.wo-tag-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-right: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
Reference in New Issue
Block a user