stats step 3 pages: /stats/elections (NL 1918→, turnout, seats, Gouda per polling station, US by state) and /stats/opinions (NL/US/KR polls, Eurobarometer issues and trust, WHR, Google Trends, GDELT themes, CHES and V-Party); nav, cards, sitemap; scatter in the chart wrapper
All checks were successful
Deploy production / deploy (push) Successful in 1m15s

This commit is contained in:
2026-10-25 22:44:09 +00:00
parent e1fcd4f321
commit d93374bca2
11 changed files with 1568 additions and 2 deletions

View File

@@ -49,7 +49,7 @@
let theme: MutationObserver | undefined; let theme: MutationObserver | undefined;
import('echarts/core').then(async (echarts) => { import('echarts/core').then(async (echarts) => {
const [ const [
{ LineChart, BarChart, PieChart }, { LineChart, BarChart, PieChart, ScatterChart },
{ GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkAreaComponent }, { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkAreaComponent },
{ CanvasRenderer } { CanvasRenderer }
] = await Promise.all([ ] = await Promise.all([
@@ -62,6 +62,7 @@
LineChart, LineChart,
BarChart, BarChart,
PieChart, PieChart,
ScatterChart,
GridComponent, GridComponent,
TooltipComponent, TooltipComponent,
LegendComponent, LegendComponent,

View File

@@ -57,6 +57,18 @@ export const statsPages = [
subtitle: 'the Netherlands, Curaçao, the US, Korea, China and more side by side', subtitle: 'the Netherlands, Curaçao, the US, Korea, China and more side by side',
href: '/stats/countries', href: '/stats/countries',
external: false external: false
},
{
name: 'elections',
subtitle: 'Dutch elections since 1918, Gouda per polling station, US presidential',
href: '/stats/elections',
external: false
},
{
name: 'opinions',
subtitle: 'polls, Eurobarometer, happiness, search trends and party positions',
href: '/stats/opinions',
external: false
} }
] as const; ] as const;

202
src/lib/stats/opinions.ts Normal file
View File

@@ -0,0 +1,202 @@
// Helpers for /stats/opinions: poll averages, Eurobarometer label clean-up, GDELT theme names.
import type { StatPoint } from './chart.js';
export type Poll = {
series: string;
key: string;
date: string;
pollster: string;
sample: number | null;
option: string;
value: number;
};
/** Per option: every poll as a point plus a trailing average over the last `window` polls. */
export function pollAverages(polls: Poll[], series: string, window = 7) {
const rows = polls
.filter((p) => p.series === series)
.sort((a, b) => a.date.localeCompare(b.date));
const options = new Map<string, Poll[]>();
for (const p of rows) options.set(p.option, [...(options.get(p.option) ?? []), p]);
return [...options.entries()].map(([option, ps]) => {
const raw: StatPoint[] = ps.map((p) => [p.date, p.value]);
const avg: StatPoint[] = [];
const byDate = new Map<string, number>();
ps.forEach((p, i) => {
const slice = ps.slice(Math.max(0, i - window + 1), i + 1);
byDate.set(
p.date,
Math.round((slice.reduce((s, x) => s + x.value, 0) / slice.length) * 10) / 10
);
});
for (const [d, v] of byDate) avg.push([d, v]);
return { option, raw, avg, last: avg.at(-1) ?? null, n: ps.length };
});
}
/** Latest poll per option, newest first (for "latest polls" tables). */
export function latestPolls(polls: Poll[], series: string, limit = 10) {
const keys = new Map<
string,
{ date: string; pollster: string; sample: number | null; values: Record<string, number> }
>();
for (const p of polls.filter((p) => p.series === series)) {
const k = keys.get(p.key) ?? {
date: p.date,
pollster: p.pollster,
sample: p.sample,
values: {}
};
k.values[p.option] = p.value;
keys.set(p.key, k);
}
return [...keys.values()].sort((a, b) => b.date.localeCompare(a.date)).slice(0, limit);
}
const EB_TRUST: [RegExp, string][] = [
[/Government/i, 'National government'],
[/European Union/i, 'European Union'],
[/European Parliament/i, 'European Parliament'],
[/European Commission/i, 'European Commission'],
[/European Central Bank/i, 'European Central Bank'],
[/European Council/i, 'European Council'],
[/PARLIAMENT|Parliament/, 'National parliament'],
[/Political parties/i, 'Political parties'],
[/media/i, 'The media'],
[/legal system/i, 'Justice system'],
[/police/i, 'Police'],
[/army/i, 'Army'],
[/Regional or local/i, 'Regional & local authorities'],
[/United Nations/i, 'United Nations'],
[/NATO/i, 'NATO'],
[/Health and medical/i, 'Health staff'],
[/Teachers/i, 'Teachers'],
[/Scientists/i, 'Scientists']
];
/** One label per Eurobarometer item across waves (wording and punctuation drift). */
export function ebLabel(topic: string, item: string): string {
if (topic.startsWith('trust')) {
for (const [re, l] of EB_TRUST) if (re.test(item)) return l;
return item;
}
const k = item.toLowerCase().replace(/[^a-z]/g, '');
if (k.startsWith('risingprices')) return 'Rising prices, cost of living';
if (k.includes('economicsituation')) return 'Economic situation';
if (k.includes('environment')) return 'Environment and climate';
if (k.startsWith('none')) return 'None';
if (k.startsWith('other')) return 'Other';
if (k.startsWith('threatstodemocracy')) return 'Threats to democracy';
if (k.startsWith('russia')) return "Russia's war in Ukraine";
if (k.includes('middleeast')) return 'Middle East conflict';
return item.replace(/^The /, '').replace(/\s*\(.*\)$/, '');
}
export const GDELT_THEMES: Record<string, string> = {
ECON_INFLATION: 'Inflation',
ECON_HOUSING_PRICES: 'Housing prices',
UNEMPLOYMENT: 'Unemployment',
IMMIGRATION: 'Immigration',
REFUGEES: 'Refugees',
ENV_CLIMATECHANGE: 'Climate change',
HEALTH_PANDEMIC: 'Pandemics',
TERROR: 'Terrorism',
PROTEST: 'Protests',
MILITARY: 'Military',
ELECTION: 'Elections',
CORRUPTION: 'Corruption'
};
type EbRow = {
wave: string;
date: string;
topic: string;
item: string;
country: string;
share: number;
};
/** Latest wave of a topic: NL and EU27 share per item, sorted by the NL share. */
export function ebLatest(rows: EbRow[], topic: string) {
const t = rows.filter((r) => r.topic === topic);
const date = t.reduce((m, r) => (r.date > m ? r.date : m), '');
const items = new Map<string, { item: string; nl: number | null; eu: number | null }>();
for (const r of t.filter((r) => r.date === date)) {
const it = items.get(r.item) ?? { item: r.item, nl: null, eu: null };
if (r.country === 'NL') it.nl = Math.max(it.nl ?? 0, r.share);
else it.eu = Math.max(it.eu ?? 0, r.share);
items.set(r.item, it);
}
return { date, items: [...items.values()].sort((a, b) => (b.nl ?? 0) - (a.nl ?? 0)) };
}
/** One series per item for a country over the waves. */
export function ebTrend(rows: EbRow[], topic: string, country: string, items: string[]) {
return items.map((item) => {
const by = new Map<string, number>();
for (const r of rows)
if (r.topic === topic && r.country === country && r.item === item)
by.set(r.date, Math.max(by.get(r.date) ?? 0, r.share));
return {
item,
points: [...by.entries()].sort((a, b) => a[0].localeCompare(b[0])) as StatPoint[]
};
});
}
type GdeltRow = { date: string; country: string; theme: string; vol: number };
/** Average share of coverage per theme and country over the whole window. */
export function gdeltAverages(rows: GdeltRow[]) {
const by = new Map<string, { sum: number; n: number }>();
for (const r of rows) {
const k = `${r.country}|${r.theme}`;
const v = by.get(k) ?? { sum: 0, n: 0 };
v.sum += r.vol;
v.n++;
by.set(k, v);
}
return (country: string, theme: string) => {
const v = by.get(`${country}|${theme}`);
return v ? Math.round((v.sum / v.n) * 10000) / 10000 : 0;
};
}
type ChesRow = {
id: string;
label: string;
name: string | null;
year: number;
dim: string;
value: number;
share: number | null;
};
/** CHES parties with every dimension as a field. */
export function chesParties(rows: ChesRow[]) {
const by = new Map<
string,
{ id: string; label: string; year: number; share: number | null; dims: Record<string, number> }
>();
for (const r of rows) {
const p = by.get(r.id) ?? { id: r.id, label: r.label, year: r.year, share: r.share, dims: {} };
p.dims[r.dim] = r.value;
by.set(r.id, p);
}
return [...by.values()].sort((a, b) => (b.share ?? 0) - (a.share ?? 0));
}
/** V-Party index per party over time, for the parties that ever reached `minShare`% of the vote. */
export function vpartyLines(
rows: { id: string; label: string; year: number; value: number; share: number | null }[],
minShare = 8
) {
const by = new Map<string, { label: string; max: number; points: StatPoint[] }>();
for (const r of rows) {
const p = by.get(r.id) ?? { label: r.label, max: 0, points: [] };
p.max = Math.max(p.max, r.share ?? 0);
p.points.push([`${r.year}-01-01`, Math.round(r.value * 1000) / 1000]);
by.set(r.id, p);
}
return [...by.values()].filter((p) => p.max >= minShare).sort((a, b) => b.max - a.max);
}

98
src/lib/stats/parties.ts Normal file
View File

@@ -0,0 +1,98 @@
// Dutch party names as they appear in CBS 37278 and the Kiesraad files, mapped to one short
// label and a party family for the long-run chart.
const SHORT: [RegExp, string][] = [
[/GROENLINKS\s*\/\s*Partij van de Arbeid|GL.?PvdA/i, 'GL-PvdA'],
[/Groep Wilders|Partij voor de Vrijheid|^PVV/i, 'PVV'],
[/^GROENLINKS|^GroenLinks/i, 'GL'],
[/Partij van de Arbeid|^PvdA/i, 'PvdA'],
[/^VVD|Volkspartij voor Vrijheid/i, 'VVD'],
[/^CDA|Christen Democratisch App/i, 'CDA'],
[/^D66|Democraten 66/i, 'D66'],
[/^SP\b|Socialistische Partij/i, 'SP'],
[/ChristenUnie/i, 'CU'],
[/^SGP|Staatkundig Gereformeerde/i, 'SGP'],
[/Partij voor de Dieren|^PvdD/i, 'PvdD'],
[/Forum voor Democratie|^FvD/i, 'FvD'],
[/^JA21/i, 'JA21'],
[/^BBB|BoerBurgerBeweging/i, 'BBB'],
[/^DENK/i, 'DENK'],
[/^Volt/i, 'Volt'],
[/^50PLUS/i, '50PLUS'],
[/Nieuw Sociaal Contract|^NSC/i, 'NSC'],
[/^BIJ1/i, 'BIJ1'],
[/^LPF|Lijst Pim Fortuyn/i, 'LPF'],
[/^CD \(|Centrumdemocraten/i, 'CD'],
[/^AOV|Unie 55/i, 'AOV/Unie 55+'],
[/^Overige/i, 'Other']
];
export function shortParty(name: string): string {
for (const [re, short] of SHORT) if (re.test(name.trim())) return short;
return name.replace(/\s*\(.*\)$/, '').trim();
}
export const FAMILIES: { name: string; color: string; parties: string[] }[] = [
{ name: 'Christian democrats', color: '#10b981', parties: ['ARP', 'CHU', 'RKSP', 'KVP', 'CDA'] },
{
name: 'Labour & green left',
color: '#e11d48',
parties: ['SDAP', 'PvdA', 'GL', 'GL-PvdA', 'CPN', 'PSP', 'PPR', 'EVP']
},
{ name: 'Socialists (SP)', color: '#f43f5e', parties: ['SP'] },
{ name: 'Liberals (VVD)', color: '#3b82f6', parties: ['VVD', 'PvdV', 'LN'] },
{
name: 'Social liberals (D66, Volt)',
color: '#06b6d4',
parties: ['D66', 'Vrijzinnig Democratische Bond', 'Volt']
},
{
name: 'Orthodox Protestant',
color: '#64748b',
parties: ['SGP', 'GPV', 'RPF', 'CU']
},
{
name: 'Populist & radical right',
color: '#a855f7',
parties: ['CD', 'LPF', 'PVV', 'FvD', 'JA21', 'BBB', 'BP / PLM']
}
];
export const PARTY_COLORS: Record<string, string> = {
PVV: '#1e3a8a',
'GL-PvdA': '#dc2626',
GL: '#16a34a',
PvdA: '#e11d48',
VVD: '#f97316',
CDA: '#10b981',
D66: '#06b6d4',
SP: '#ef4444',
CU: '#0ea5e9',
SGP: '#64748b',
PvdD: '#84cc16',
FvD: '#7c3aed',
JA21: '#1d4ed8',
BBB: '#65a30d',
DENK: '#14b8a6',
Volt: '#6d28d9',
'50PLUS': '#a21caf',
NSC: '#eab308',
LPF: '#a855f7'
};
export const familyOf = (short: string) =>
FAMILIES.find((f) => f.parties.includes(short))?.name ?? 'Other';
/** Votes (and seats) per short party name; total is the sum of the votes. */
export function tally(rows: { party: string; votes: number | null; seats?: number | null }[]) {
const by = new Map<string, { party: string; votes: number | null; seats: number | null }>();
for (const r of rows) {
const party = shortParty(r.party);
const prev = by.get(party) ?? { party, votes: null, seats: null };
if (r.votes != null) prev.votes = (prev.votes ?? 0) + r.votes;
if (r.seats != null) prev.seats = (prev.seats ?? 0) + r.seats;
by.set(party, prev);
}
const parties = [...by.values()];
const votes = new Map(parties.map((p) => [p.party, p.votes ?? 0]));
return { parties, votes, total: parties.reduce((s, p) => s + (p.votes ?? 0), 0) };
}

View File

@@ -20,6 +20,8 @@ const staticPaths = [
'/stats/gouda', '/stats/gouda',
'/stats/countries', '/stats/countries',
'/stats/countries/CUW', '/stats/countries/CUW',
'/stats/elections',
'/stats/opinions',
'/trackers/books', '/trackers/books',
'/trackers/spotting', '/trackers/spotting',
'/trackers/cars', '/trackers/cars',

View File

@@ -7,13 +7,17 @@
import House from 'lucide-svelte/icons/house'; import House from 'lucide-svelte/icons/house';
import Landmark from 'lucide-svelte/icons/landmark'; import Landmark from 'lucide-svelte/icons/landmark';
import Globe from 'lucide-svelte/icons/globe'; import Globe from 'lucide-svelte/icons/globe';
import Vote from 'lucide-svelte/icons/vote';
import MessageCircle from 'lucide-svelte/icons/message-circle';
let { children } = $props(); let { children } = $props();
const icons = { const icons = {
overview: ChartLine, overview: ChartLine,
economy: Landmark, economy: Landmark,
energy: Flame, energy: Flame,
gouda: House, gouda: House,
countries: Globe countries: Globe,
elections: Vote,
opinions: MessageCircle
} as const; } as const;
const tabs = [ const tabs = [
{ name: 'overview', href: '/stats' }, { name: 'overview', href: '/stats' },
@@ -96,6 +100,12 @@
.tab-countries { .tab-countries {
background: linear-gradient(135deg, #a855f7, #ec4899); background: linear-gradient(135deg, #a855f7, #ec4899);
} }
.tab-elections {
background: linear-gradient(135deg, #06b6d4, #3b82f6);
}
.tab-opinions {
background: linear-gradient(135deg, #ec4899, #f97316);
}
/* Shared page furniture for every /stats page. */ /* Shared page furniture for every /stats page. */
.stats-page :global(.stats-intro) { .stats-page :global(.stats-intro) {
display: flex; display: flex;

View File

@@ -9,6 +9,8 @@
import House from 'lucide-svelte/icons/house'; import House from 'lucide-svelte/icons/house';
import Globe from 'lucide-svelte/icons/globe'; import Globe from 'lucide-svelte/icons/globe';
import Palmtree from 'lucide-svelte/icons/tree-palm'; import Palmtree from 'lucide-svelte/icons/tree-palm';
import Vote from 'lucide-svelte/icons/vote';
import MessageCircle from 'lucide-svelte/icons/message-circle';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
let { data } = $props(); let { data } = $props();
@@ -78,6 +80,20 @@
icon: Palmtree, icon: Palmtree,
grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)', grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)',
text: 'Population, income, inflation, tourism, trade and government finances of Curaçao.' text: 'Population, income, inflation, tourism, trade and government finances of Curaçao.'
},
{
href: '/stats/elections',
title: 'Elections',
icon: Vote,
grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)',
text: 'Every Dutch general election since 1918, Gouda down to the polling station, and the US presidential vote by state.'
},
{
href: '/stats/opinions',
title: 'Opinions',
icon: MessageCircle,
grad: 'linear-gradient(135deg,#ec4899,#f97316)',
text: 'Seat and approval polls, Eurobarometer worries and trust, world happiness, search trends, news themes and party positions.'
} }
] as const; ] as const;
</script> </script>

View File

@@ -0,0 +1,83 @@
import { cached } from '#lib/server/stats.js';
import { client } from '#lib/server/db/index.js';
import type { PageServerLoad } from './$types';
export type ElecRow = {
election: string;
date: string;
party: string;
votes: number | null;
seats: number | null;
};
const rows = (r: readonly Record<string, unknown>[]): ElecRow[] =>
r.map((x) => ({
election: x.election as string,
date: x.date as string,
party: x.party as string,
votes: x.votes as number | null,
seats: (x.seats as number | null) ?? null
}));
export const load: PageServerLoad = async () =>
cached('stats:elections', async () => {
const [elections, nl, turnout, gouda, stations, us, usStates] = await Promise.all([
client`select election_id, country, date::text as date, name, total_seats from stats.elec_election order by date`,
client`select r.election_id as election, e.date::text as date, r.party, r.votes::float8 as votes, r.seats
from stats.elec_result r join stats.elec_election e using (election_id)
where e.country = 'NLD' and r.level = 'national'`,
client`select t.election_id as election, e.date::text as date, t.electorate::float8 as electorate,
t.ballots::float8 as ballots, t.valid::float8 as valid
from stats.elec_turnout t join stats.elec_election e using (election_id)
where t.level = 'national' order by e.date`,
client`select r.election_id as election, e.date::text as date, r.party, r.votes::float8 as votes
from stats.elec_result r join stats.elec_election e using (election_id)
where r.level = 'muni' and r.area_code = 'GM0513'`,
client`select r.area_code, r.area_name, r.party, r.votes::float8 as votes
from stats.elec_result r
where r.level = 'station' and r.election_id = (
select max(election_id) from stats.elec_result where level = 'station')`,
client`select r.election_id as election, e.date::text as date, r.party, r.votes::float8 as votes
from stats.elec_result r join stats.elec_election e using (election_id)
where e.country = 'USA' and r.level = 'national'`,
client`select r.area_code, r.area_name, r.party, r.votes::float8 as votes
from stats.elec_result r
where r.level = 'state' and r.election_id = (
select max(election_id) from stats.elec_election where country = 'USA')`
]);
const stationElection = (
await client`select max(election_id) as id from stats.elec_result where level = 'station'`
)[0]?.id as string | null;
return {
elections: elections.map((e) => ({
id: e.election_id as string,
country: e.country as string,
date: e.date as string,
name: e.name as string,
seats: e.total_seats as number | null
})),
nl: rows(nl),
turnout: turnout.map((t) => ({
election: t.election as string,
date: t.date as string,
electorate: t.electorate as number | null,
ballots: t.ballots as number | null,
valid: t.valid as number | null
})),
gouda: rows(gouda),
stationElection,
stations: stations.map((s) => ({
code: s.area_code as string,
name: s.area_name as string,
party: s.party as string,
votes: s.votes as number | null
})),
us: rows(us),
usStates: usStates.map((s) => ({
code: s.area_code as string,
name: s.area_name as string,
party: s.party as string,
votes: s.votes as number | null
}))
};
});

View File

@@ -0,0 +1,430 @@
<script lang="ts">
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import { barRankOption, lineOption, type StatPoint } from '#lib/stats/chart.js';
import { compact, num } from '#lib/stats/format.js';
import { FAMILIES, familyOf, PARTY_COLORS, tally } from '#lib/stats/parties.js';
let { data } = $props();
const a = $derived(data.attributions);
const year = (d: string) => d.slice(0, 4);
const share = (v: number | null, total: number) =>
v == null || !total ? 0 : Math.round((v / total) * 1000) / 10;
// ---- Netherlands, national ------------------------------------------------------------
const nlElections = $derived(
[...new Set(data.nl.map((r) => r.election))]
.map((id) => {
const rows = data.nl.filter((r) => r.election === id);
const { parties, total } = tally(rows);
return { id, date: rows[0].date, total, parties };
})
.sort((x, y) => x.date.localeCompare(y.date))
);
const familyLines = $derived([
...FAMILIES.map((f) => ({
name: f.name,
color: f.color,
kind: 'bar' as const,
stack: 'f',
points: nlElections.map(
(e) =>
[
year(e.date),
share(
e.parties
.filter((p) => familyOf(p.party) === f.name)
.reduce((s, p) => s + (p.votes ?? 0), 0),
e.total
)
] as StatPoint
)
})),
{
name: 'Other',
color: '#cbd5e1',
kind: 'bar' as const,
stack: 'f',
points: nlElections.map(
(e) =>
[
year(e.date),
share(
e.parties
.filter((p) => familyOf(p.party) === 'Other')
.reduce((s, p) => s + (p.votes ?? 0), 0),
e.total
)
] as StatPoint
)
}
]);
const turnout = $derived(
data.turnout
.filter((t) => t.election.startsWith('NL-') && t.ballots && t.electorate)
.map((t) => [year(t.date), share(t.ballots, t.electorate!)] as StatPoint)
);
const recent = $derived(nlElections.filter((e) => e.date >= '2010'));
const latestNl = $derived(recent.at(-1));
const seatParties = $derived(
[...new Set(recent.flatMap((e) => e.parties.filter((p) => p.seats).map((p) => p.party)))].sort(
(x, y) =>
(latestNl?.parties.find((p) => p.party === y)?.seats ?? 0) -
(latestNl?.parties.find((p) => p.party === x)?.seats ?? 0) || x.localeCompare(y)
)
);
const seatsOf = (e: (typeof recent)[number], p: string) =>
e.parties.find((x) => x.party === p)?.seats ?? null;
const biggest = $derived(
latestNl ? [...latestNl.parties].sort((x, y) => (y.seats ?? 0) - (x.seats ?? 0))[0] : null
);
const latestTurnout = $derived(data.turnout.filter((t) => t.election.startsWith('NL-')).at(-1));
// ---- Gouda ------------------------------------------------------------------------------
const goudaBy = $derived(
[...new Set(data.gouda.map((r) => r.election))].sort().map((id) => {
const rows = data.gouda.filter((r) => r.election === id);
const { votes, total } = tally(rows);
return { id, date: rows[0].date, total, votes };
})
);
const goudaLatest = $derived(goudaBy.at(-1));
const nlForGouda = $derived(nlElections.find((e) => e.id === goudaLatest?.id));
const goudaTop = $derived(
goudaLatest
? [...goudaLatest.votes.entries()]
.sort((x, y) => y[1] - x[1])
.slice(0, 12)
.map(([p]) => p)
: []
);
const goudaVsNl = $derived([
{
name: 'Gouda',
color: '#f97316',
kind: 'bar' as const,
points: goudaTop.map(
(p) => [p, share(goudaLatest?.votes.get(p) ?? 0, goudaLatest?.total ?? 0)] as StatPoint
)
},
{
name: 'Netherlands',
color: '#0f172a',
kind: 'bar' as const,
points: goudaTop.map(
(p) =>
[
p,
share(
nlForGouda?.parties.find((x) => x.party === p)?.votes ?? 0,
nlForGouda?.total ?? 0
)
] as StatPoint
)
}
]);
const goudaTrend = $derived(
goudaTop.slice(0, 8).map((p) => ({
name: p,
color: PARTY_COLORS[p],
points: goudaBy
.filter((e) => e.votes.get(p))
.map((e) => [year(e.date), share(e.votes.get(p) ?? 0, e.total)] as StatPoint)
}))
);
// Polling stations, latest election.
const stations = $derived(
[...new Set(data.stations.map((s) => s.code))].map((code) => {
const rows = data.stations.filter((s) => s.code === code);
const { votes: m, total } = tally(rows);
const top = [...m.entries()].sort((x, y) => y[1] - x[1])[0];
return {
code,
name: rows[0].name
.replace(/^(Stembureau\s+)+/i, '')
.replace(/\s*\(postcode:\s*([^)]*)\)/i, ' · $1'),
total,
top: top?.[0] ?? '',
shares: m
};
})
);
const stationCols = $derived(goudaTop.slice(0, 6));
const stationRows = $derived(
[...stations].sort((x, y) => x.code.localeCompare(y.code, 'en', { numeric: true }))
);
const stationBars = (p: string) =>
stations
.filter((s) => s.total >= 100)
.map((s) => ({
label: s.name.split(' · ')[0].slice(0, 34),
value: share(s.shares.get(p) ?? 0, s.total)
}));
const goudaWinner = $derived(goudaTop[0]);
// ---- United States ----------------------------------------------------------------------
const usBy = $derived(
[...new Set(data.us.map((r) => r.election))].sort().map((id) => {
const rows = data.us.filter((r) => r.election === id);
const total = rows.reduce((s, r) => s + (r.votes ?? 0), 0);
const v = (p: string) => rows.find((r) => r.party === p)?.votes ?? 0;
return {
id,
date: rows[0].date,
total,
dem: share(v('DEMOCRAT'), total),
rep: share(v('REPUBLICAN'), total)
};
})
);
const usLatest = $derived(usBy.at(-1));
const states = $derived(
[...new Set(data.usStates.map((s) => s.code))]
.map((code) => {
const rows = data.usStates.filter((s) => s.code === code);
const total = rows.reduce((s, r) => s + (r.votes ?? 0), 0);
const v = (p: string) =>
rows.filter((r) => r.party === p).reduce((s, r) => s + (r.votes ?? 0), 0);
const dem = share(v('DEMOCRAT'), total);
const rep = share(v('REPUBLICAN'), total);
const name = rows[0].name.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
return { code, name, total, dem, rep, margin: Math.round((rep - dem) * 10) / 10 };
})
.sort((x, y) => x.margin - y.margin)
);
</script>
<svelte:head>
<title>Elections · stats · Stan Runge</title>
<meta
name="description"
content="Dutch general elections since 1918, Gouda per polling station, and US presidential results by state since 1976."
/>
</svelte:head>
<section class="stats-intro">
<span class="stats-eyebrow">stats / elections</span>
<h1>Elections</h1>
<p>
Every Tweede Kamer election since universal suffrage in 1918, how Gouda votes down to the
polling station, and the US presidential vote by state.
</p>
</section>
<div class="kpis">
<Kpi
label={`Largest party, TK ${latestNl ? year(latestNl.date) : ''}`}
value={biggest?.party ?? '–'}
sub={`${biggest?.seats ?? '–'} of 150 seats · ${num(share(biggest?.votes ?? 0, latestNl?.total ?? 0), 1)}% of the vote`}
color="#06b6d4"
/>
<Kpi
label="Turnout"
value={num(
latestTurnout?.ballots && latestTurnout.electorate
? (latestTurnout.ballots / latestTurnout.electorate) * 100
: null,
1
)}
unit="%"
sub={`${compact(latestTurnout?.ballots)} ballots · ${latestTurnout ? year(latestTurnout.date) : ''}`}
color="#3b82f6"
/>
<Kpi
label="Gouda's largest party"
value={goudaWinner ?? '–'}
sub={`${num(share(goudaLatest?.votes.get(goudaWinner ?? '') ?? 0, goudaLatest?.total ?? 0), 1)}% · ${stations.length} polling stations`}
color="#f97316"
/>
<Kpi
label={`US popular vote ${usLatest ? year(usLatest.date) : ''}`}
value={usLatest
? usLatest.rep > usLatest.dem
? `R +${num(usLatest.rep - usLatest.dem, 1)}`
: `D +${num(usLatest.dem - usLatest.rep, 1)}`
: '–'}
sub={`R ${num(usLatest?.rep, 1)}% · D ${num(usLatest?.dem, 1)}%`}
color="#e11d48"
/>
</div>
<h2 class="stats-section-title">The Netherlands</h2>
<div class="stats-grid">
<StatChart
title="A century of Dutch elections by party family"
subtitle="% of valid votes, Tweede Kamer"
sources={['cbs', 'kiesraad']}
attributions={a}
wide
height="420px"
note="Christian democrats: ARP, CHU, RKSP/KVP and from 1977 the CDA. Labour & green left includes the joint GL-PvdA list (2023, 2025). Populist & radical right: CD, LPF, PVV, FvD, JA21 and BBB. CBS publishes shares to one decimal for 1918–2006; the Kiesraad counts are used from 2010."
option={(t) => lineOption(t, familyLines, { category: true, unit: '%', decimals: 1, min: 0 })}
/>
<StatChart
title="Turnout"
subtitle="% of the electorate that cast a ballot"
sources={['cbs', 'kiesraad']}
attributions={a}
note="Voting was compulsory until 1970; turnout fell from 95% in 1967 to 79% in 1971."
option={(t) =>
lineOption(t, [{ name: 'Turnout', points: turnout, color: '#3b82f6', kind: 'area' }], {
category: true,
unit: '%',
decimals: 1,
legend: false,
min: 50
})}
/>
<StatChart
title={`Seats, TK ${latestNl ? year(latestNl.date) : ''}`}
subtitle="of 150"
sources={['kiesraad']}
attributions={a}
option={(t) =>
barRankOption(
t,
(latestNl?.parties ?? [])
.filter((p) => p.seats)
.map((p) => ({ label: p.party, value: p.seats ?? 0 })),
{ decimals: 0, color: '#06b6d4' }
)}
/>
</div>
<div class="table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Seats</th>
{#each recent as e (e.id)}<th class="num">{year(e.date)}</th>{/each}
</tr>
</thead>
<tbody>
{#each seatParties as p (p)}
<tr>
<td>
<span class="dot" style:background={PARTY_COLORS[p] ?? '#94a3b8'}></span>{p}
</td>
{#each recent as e (e.id)}<td class="num">{seatsOf(e, p) ?? ''}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<h2 class="stats-section-title">Gouda</h2>
{#if goudaLatest}
<div class="stats-grid">
<StatChart
title={`Gouda vs the Netherlands, TK ${year(goudaLatest.date)}`}
subtitle="% of valid votes, largest parties in Gouda"
sources={['kiesraad']}
attributions={a}
option={(t) => lineOption(t, goudaVsNl, { category: true, unit: '%', decimals: 1 })}
/>
<StatChart
title="Gouda's vote since 2010"
subtitle="% of valid votes"
sources={['kiesraad']}
attributions={a}
option={(t) => lineOption(t, goudaTrend, { category: true, unit: '%', decimals: 1 })}
/>
{#each stationCols.slice(0, 2) as p (p)}
<StatChart
title={`${p} by polling station`}
subtitle={`% of valid votes · TK ${year(goudaLatest.date)} · stations with 100+ votes`}
sources={['kiesraad']}
attributions={a}
height="760px"
option={(t) =>
barRankOption(t, stationBars(p), {
decimals: 1,
unit: '%',
color: PARTY_COLORS[p] ?? '#3b82f6'
})}
/>
{/each}
</div>
<div class="table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Polling station, TK {year(goudaLatest.date)}</th>
<th class="num">Votes</th>
<th>Largest</th>
{#each stationCols as p (p)}<th class="num">{p} %</th>{/each}
</tr>
</thead>
<tbody>
{#each stationRows as s (s.code)}
<tr>
<td>{s.name}</td>
<td class="num">{num(s.total, 0)}</td>
<td>{s.top}</td>
{#each stationCols as p (p)}<td class="num"
>{num(share(s.shares.get(p) ?? 0, s.total), 1)}</td
>{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="stats-note">
{a.kiesraad ?? ''} · Postal and mobile stations have few votes; votes are counted where they are cast,
not where voters live.
</p>
{:else}
<p class="empty">Gouda's results are not loaded yet.</p>
{/if}
<h2 class="stats-section-title">United States</h2>
<div class="stats-grid">
<StatChart
title="US presidential popular vote"
subtitle="% of all votes cast"
sources={['mitelection']}
attributions={a}
option={(t) =>
lineOption(
t,
[
{
name: 'Democrat',
color: '#2563eb',
points: usBy.map((e) => [year(e.date), e.dem] as StatPoint)
},
{
name: 'Republican',
color: '#dc2626',
points: usBy.map((e) => [year(e.date), e.rep] as StatPoint)
}
],
{ category: true, unit: '%', decimals: 1, min: 30 }
)}
/>
<StatChart
title={`Margin by state, ${usLatest ? year(usLatest.date) : ''}`}
subtitle="Republican minus Democratic share, percentage points"
sources={['mitelection']}
attributions={a}
height="900px"
note="Positive: Republican lead; negative: Democratic lead."
option={(t) =>
barRankOption(
t,
states.map((s) => ({ label: s.name, value: s.margin, highlight: Math.abs(s.margin) < 3 })),
{ decimals: 1, unit: 'pp', color: '#64748b' }
)}
/>
</div>
<style>
.dot {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 999px;
margin-right: 0.4rem;
}
</style>

View File

@@ -0,0 +1,101 @@
import { cached } from '#lib/server/stats.js';
import { client } from '#lib/server/db/index.js';
import { ebLabel, latestPolls, pollAverages, type Poll } from '#lib/stats/opinions.js';
// Averages are computed here so the page gets a few hundred points per series, not every poll row.
function pollData(polls: Poll[]) {
const noRaw = (s: ReturnType<typeof pollAverages>) => s.map((o) => ({ ...o, raw: [] }));
return {
nl: pollAverages(polls, 'nl-tk-seats', 5),
nlLatest: latestPolls(polls, 'nl-tk-seats', 8),
us: pollAverages(polls, 'us-trump-approval', 10),
usAgg: latestPolls(polls, 'us-trump-approval-aggregate', 20),
kr: noRaw(pollAverages(polls, 'kr-lee-approval', 10)),
krParty: noRaw(pollAverages(polls, 'kr-party', 10))
};
}
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () =>
cached('stats:opinions', async () => {
const [polls, eb, whr, trends, gdelt, ches, vparty] = await Promise.all([
client`select series, poll_key, fieldwork_end::text as date, pollster, sample, option, value
from stats.op_poll where value is not null order by fieldwork_end`,
client`select wave, fieldwork_end::text as date, topic, item, country, share
from stats.eb_item where country in ('NL', 'EU27') and share is not null`,
client`select year, country, iso3, rank, ladder, gdp, social, health, freedom, generosity, corruption
from stats.whr_score order by year`,
client`select geo, date::text as date, rank, query, approx_traffic
from stats.gt_trending t where date = (select max(date) from stats.gt_trending where geo = t.geo)
order by geo, rank`,
client`select date::text as date, country, theme, vol_pct from stats.gdelt_theme_vol
where date > now() - interval '45 days' order by date`,
client`select p.party_id, coalesce(p.abbrev, p.name) as label, p.name, p.family, pp.year, pp.dim,
pp.value, pp.vote_share
from stats.wo_party_position pp join stats.wo_party p using (party_id)
where p.iso3 = 'NLD' and pp.source = 'ches'
and pp.year = (select max(year) from stats.wo_party_position where source = 'ches')`,
client`select p.party_id, coalesce(p.abbrev, p.name) as label, pp.year, pp.value, pp.vote_share
from stats.wo_party_position pp join stats.wo_party p using (party_id)
where p.iso3 = 'NLD' and pp.source = 'vparty' and pp.dim = 'v2xpa_popul' and pp.year >= 1946
order by pp.year`
]);
return {
...pollData(
polls.map((p): Poll => ({
series: p.series as string,
key: p.poll_key as string,
date: p.date as string,
pollster: p.pollster as string,
sample: p.sample as number | null,
option: p.option as string,
value: Number(p.value)
}))
),
eb: eb.map((r) => ({
wave: r.wave as string,
date: r.date as string,
topic: r.topic as string,
item: ebLabel(r.topic as string, r.item as string),
country: r.country as string,
share: Math.round(Number(r.share) * 1000) / 10
})),
whr: whr.map((r) => ({
year: r.year as number,
country: r.country as string,
iso3: r.iso3 as string | null,
rank: r.rank as number | null,
ladder: r.ladder == null ? null : Number(r.ladder)
})),
trends: trends.map((r) => ({
geo: r.geo as string,
date: r.date as string,
rank: r.rank as number,
query: r.query as string,
traffic: r.approx_traffic as number | null
})),
gdelt: gdelt.map((r) => ({
date: r.date as string,
country: (r.country as string).trim(),
theme: r.theme as string,
vol: Number(r.vol_pct)
})),
ches: ches.map((r) => ({
id: r.party_id as string,
label: r.label as string,
name: r.name as string | null,
family: r.family as string | null,
year: r.year as number,
dim: r.dim as string,
value: Number(r.value),
share: r.vote_share == null ? null : Number(r.vote_share)
})),
vparty: vparty.map((r) => ({
id: r.party_id as string,
label: r.label as string,
year: r.year as number,
value: Number(r.value),
share: r.vote_share == null ? null : Number(r.vote_share)
}))
};
});

View File

@@ -0,0 +1,611 @@
<script lang="ts">
import { resolve } from '$app/paths';
import StatChart from '#lib/stats/StatChart.svelte';
import Kpi from '#lib/stats/Kpi.svelte';
import { barRankOption, lineOption, type ChartTheme, type StatPoint } from '#lib/stats/chart.js';
import { num, period } from '#lib/stats/format.js';
import { PARTY_COLORS } from '#lib/stats/parties.js';
import {
chesParties,
ebLatest,
ebTrend,
gdeltAverages,
GDELT_THEMES,
vpartyLines
} from '#lib/stats/opinions.js';
import type { EChartsCoreOption } from 'echarts/core';
let { data } = $props();
const a = $derived(data.attributions);
const day = (d: string | undefined) =>
d
? new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
: '';
// ---- Polls ------------------------------------------------------------------------------
const nl = $derived(
data.nl
.filter((o) => o.option !== 'Others')
.sort((x, y) => (y.last?.[1] ?? 0) - (x.last?.[1] ?? 0))
);
const nlLatest = $derived(data.nlLatest);
const nlCols = $derived(nl.map((o) => o.option));
const us = $derived(data.us);
const usApprove = $derived(us.find((o) => o.option === 'Approve'));
const usDisapprove = $derived(us.find((o) => o.option === 'Disapprove'));
const usAgg = $derived(data.usAgg);
const kr = $derived(data.kr);
const krApprove = $derived(kr.find((o) => o.option === 'Approve'));
const krParty = $derived(
data.krParty.filter((o) => o.n > 50).sort((x, y) => (y.last?.[1] ?? 0) - (x.last?.[1] ?? 0))
);
const KR_COLORS: Record<string, string> = {
DPK: '#2563eb',
PPP: '#e11d48',
RKP: '#f97316',
RP: '#eab308',
PP: '#0ea5e9',
'Und./no ans.': '#94a3b8',
Others: '#cbd5e1'
};
// ---- Eurobarometer ----------------------------------------------------------------------
const miiCountry = $derived(ebLatest(data.eb, 'mii_country'));
const miiPersonal = $derived(ebLatest(data.eb, 'mii_personal'));
const trust = $derived(ebLatest(data.eb, 'trust'));
const trustEu = $derived(ebLatest(data.eb, 'trust_eu'));
const topIssues = $derived(
miiCountry.items.filter((i) => !/^(None|Other|Don)/.test(i.item)).slice(0, 6)
);
const issueTrend = $derived(
ebTrend(
data.eb,
'mii_country',
'NL',
topIssues.map((i) => i.item)
)
);
const trustTrend = $derived([
...ebTrend(data.eb, 'trust', 'NL', ['National government', 'European Union']).map((t) => ({
...t,
item: `${t.item}, NL`
})),
...ebTrend(data.eb, 'trust', 'EU27', ['National government', 'European Union']).map((t) => ({
...t,
item: `${t.item}, EU27`,
dashed: true
}))
]);
// Category charts with long labels: show every label, wrapped.
const wrapLabels = (o: EChartsCoreOption, width = 90): EChartsCoreOption => {
const x = o.xAxis as { axisLabel?: object };
x.axisLabel = { ...x.axisLabel, interval: 0, width, overflow: 'break', fontSize: 10 };
(o.grid as { bottom: number }).bottom = 40;
return o;
};
const issueBars = (items: { item: string; nl: number | null; eu: number | null }[]) => [
{
name: 'Netherlands',
color: '#f97316',
kind: 'bar' as const,
points: items.map((i) => [i.item, i.nl ?? 0] as StatPoint)
},
{
name: 'EU27',
color: '#3b82f6',
kind: 'bar' as const,
points: items.map((i) => [i.item, i.eu ?? 0] as StatPoint)
}
];
// ---- World Happiness Report ---------------------------------------------------------------
const whrYear = $derived(Math.max(...data.whr.map((w) => w.year)));
const FOCUS = ['NLD', 'USA', 'KOR', 'DNK', 'FIN', 'DEU', 'CHN'];
const whrLatest = $derived(
data.whr
.filter((w) => w.year === whrYear && w.ladder != null)
.sort((x, y) => (x.rank ?? 999) - (y.rank ?? 999))
);
const whrRows = $derived(
whrLatest
.filter((w, i) => i < 20 || FOCUS.includes(w.iso3 ?? ''))
.map((w) => ({
label: `${w.rank}. ${w.country}`,
value: w.ladder ?? 0,
highlight: FOCUS.includes(w.iso3 ?? '')
}))
);
const nlWhr = $derived(whrLatest.find((w) => w.iso3 === 'NLD'));
const whrTrend = $derived(
FOCUS.map((iso, i) => ({
name: data.whr.find((w) => w.iso3 === iso)?.country ?? iso,
color: ['#f97316', '#e11d48', '#3b82f6', '#10b981', '#06b6d4', '#64748b', '#eab308'][i],
points: data.whr
.filter((w) => w.iso3 === iso && w.ladder != null)
.map((w) => [`${w.year}-01-01`, w.ladder!] as StatPoint)
})).filter((l) => l.points.length)
);
// ---- Google Trends and GDELT -------------------------------------------------------------
const GEOS: Record<string, string> = {
NL: 'Netherlands',
US: 'United States',
KR: 'South Korea'
};
const trendGeos = $derived([...new Set(data.trends.map((t) => t.geo))]);
const gAvg = $derived(gdeltAverages(data.gdelt));
const gCountries = $derived([...new Set(data.gdelt.map((g) => g.country))].sort());
const gThemes = $derived([...new Set(data.gdelt.map((g) => g.theme))]);
const gDays = $derived([...new Set(data.gdelt.map((g) => g.date))].sort());
// ---- Party positions -----------------------------------------------------------------------
const ches = $derived(chesParties(data.ches));
const chesYear = $derived(data.ches[0]?.year);
const scatter = (t: ChartTheme): EChartsCoreOption => ({
animation: false,
grid: { left: 40, right: 24, top: 24, bottom: 40 },
tooltip: {
backgroundColor: t.surface,
borderColor: t.border,
textStyle: { color: t.text, fontSize: 12 },
formatter: (p: { data: { value: [number, number, number, string] } }) =>
`${p.data.value[3]}<br>economic ${num(p.data.value[0], 1)} · GAL–TAN ${num(p.data.value[1], 1)}<br>${num(p.data.value[2], 1)}% vote`
},
xAxis: {
type: 'value',
min: 0,
max: 10,
name: 'economic left ← → right',
nameLocation: 'middle',
nameGap: 26,
nameTextStyle: { color: t.muted, fontSize: 11 },
axisLabel: { color: t.muted, fontSize: 11 },
splitLine: { lineStyle: { color: t.border, opacity: 0.6 } }
},
yAxis: {
type: 'value',
min: 0,
max: 10,
name: 'GAL ↓ TAN ↑',
nameTextStyle: { color: t.muted, fontSize: 11 },
axisLabel: { color: t.muted, fontSize: 11 },
splitLine: { lineStyle: { color: t.border, opacity: 0.6 } }
},
series: [
{
type: 'scatter',
data: ches
.filter((p) => p.dims.lrecon != null && p.dims.galtan != null)
.map((p) => ({
value: [p.dims.lrecon, p.dims.galtan, p.share ?? 0, p.label],
itemStyle: { color: PARTY_COLORS[p.label] ?? '#64748b', opacity: 0.85 }
})),
symbolSize: (v: [number, number, number]) => 8 + Math.sqrt(v[2] ?? 0) * 5,
label: {
show: true,
position: 'right',
color: t.text,
fontSize: 11,
formatter: (p: { data: { value: [number, number, number, string] } }) => p.data.value[3]
}
}
]
});
const vparty = $derived(vpartyLines(data.vparty, 10));
</script>
<svelte:head>
<title>Opinions · stats · Stan Runge</title>
<meta
name="description"
content="Polls in the Netherlands, the US and South Korea, what Europeans worry about and whom they trust, world happiness, search trends, news themes and where Dutch parties stand."
/>
</svelte:head>
<section class="stats-intro">
<span class="stats-eyebrow">stats / opinions</span>
<h1>Opinions</h1>
<p>
What people think, from polls and surveys: Dutch seat polls, approval of Trump and Lee
Jae-myung, Eurobarometer worries and trust, the World Happiness Report, and where parties stand.
For values by country see <a href={resolve('/projects/world-opinions')}>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">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>