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
Deploy production / deploy (push) Successful in 1m15s
This commit is contained in:
1 parent
e1fcd4f321
commit
d93374bca2
11 files changed
+1568
-2
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user