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
Deploy production / deploy (push) Successful in 1m15s

This commit is contained in:
stan committed 2026-10-25 22:44:09 +00:00
1 parent e1fcd4f321
commit d93374bca2
11 files changed
+1568 -2

No files matched your search

+430
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>