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

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) };
}