431 lines
13 KiB
Svelte
431 lines
13 KiB
Svelte
<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>
|