Files
website/src/routes/stats/elections/+page.svelte
T

431 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>