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
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
This commit is contained in:
202
src/lib/stats/opinions.ts
Normal file
202
src/lib/stats/opinions.ts
Normal 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
98
src/lib/stats/parties.ts
Normal 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user