From d93374bca2f38f20e314e2df06081d71120685ac Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sun, 25 Oct 2026 22:44:09 +0000 Subject: [PATCH] =?UTF-8?q?stats=20step=203=20pages:=20/stats/elections=20?= =?UTF-8?q?(NL=201918=E2=86=92,=20turnout,=20seats,=20Gouda=20per=20pollin?= =?UTF-8?q?g=20station,=20US=20by=20state)=20and=20/stats/opinions=20(NL/U?= =?UTF-8?q?S/KR=20polls,=20Eurobarometer=20issues=20and=20trust,=20WHR,=20?= =?UTF-8?q?Google=20Trends,=20GDELT=20themes,=20CHES=20and=20V-Party);=20n?= =?UTF-8?q?av,=20cards,=20sitemap;=20scatter=20in=20the=20chart=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/energy/EnergyChart.svelte | 3 +- src/lib/projects.ts | 12 + src/lib/stats/opinions.ts | 202 +++++++ src/lib/stats/parties.ts | 98 ++++ src/routes/sitemap.xml/+server.ts | 2 + src/routes/stats/+layout.svelte | 12 +- src/routes/stats/+page.svelte | 16 + src/routes/stats/elections/+page.server.ts | 83 +++ src/routes/stats/elections/+page.svelte | 430 +++++++++++++++ src/routes/stats/opinions/+page.server.ts | 101 ++++ src/routes/stats/opinions/+page.svelte | 611 +++++++++++++++++++++ 11 files changed, 1568 insertions(+), 2 deletions(-) create mode 100644 src/lib/stats/opinions.ts create mode 100644 src/lib/stats/parties.ts create mode 100644 src/routes/stats/elections/+page.server.ts create mode 100644 src/routes/stats/elections/+page.svelte create mode 100644 src/routes/stats/opinions/+page.server.ts create mode 100644 src/routes/stats/opinions/+page.svelte diff --git a/src/lib/energy/EnergyChart.svelte b/src/lib/energy/EnergyChart.svelte index 35b4639..84624d2 100644 --- a/src/lib/energy/EnergyChart.svelte +++ b/src/lib/energy/EnergyChart.svelte @@ -49,7 +49,7 @@ let theme: MutationObserver | undefined; import('echarts/core').then(async (echarts) => { const [ - { LineChart, BarChart, PieChart }, + { LineChart, BarChart, PieChart, ScatterChart }, { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkAreaComponent }, { CanvasRenderer } ] = await Promise.all([ @@ -62,6 +62,7 @@ LineChart, BarChart, PieChart, + ScatterChart, GridComponent, TooltipComponent, LegendComponent, diff --git a/src/lib/projects.ts b/src/lib/projects.ts index cdb98f2..53bd989 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -57,6 +57,18 @@ export const statsPages = [ subtitle: 'the Netherlands, Curaçao, the US, Korea, China and more side by side', href: '/stats/countries', external: false + }, + { + name: 'elections', + subtitle: 'Dutch elections since 1918, Gouda per polling station, US presidential', + href: '/stats/elections', + external: false + }, + { + name: 'opinions', + subtitle: 'polls, Eurobarometer, happiness, search trends and party positions', + href: '/stats/opinions', + external: false } ] as const; diff --git a/src/lib/stats/opinions.ts b/src/lib/stats/opinions.ts new file mode 100644 index 0000000..b94a7d2 --- /dev/null +++ b/src/lib/stats/opinions.ts @@ -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(); + 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(); + 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 } + >(); + 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 = { + 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(); + 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(); + 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(); + 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 } + >(); + 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(); + 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); +} diff --git a/src/lib/stats/parties.ts b/src/lib/stats/parties.ts new file mode 100644 index 0000000..dded362 --- /dev/null +++ b/src/lib/stats/parties.ts @@ -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 = { + 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(); + 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) }; +} diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index 9c3eb05..6f04bea 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -20,6 +20,8 @@ const staticPaths = [ '/stats/gouda', '/stats/countries', '/stats/countries/CUW', + '/stats/elections', + '/stats/opinions', '/trackers/books', '/trackers/spotting', '/trackers/cars', diff --git a/src/routes/stats/+layout.svelte b/src/routes/stats/+layout.svelte index 44e431a..6733552 100644 --- a/src/routes/stats/+layout.svelte +++ b/src/routes/stats/+layout.svelte @@ -7,13 +7,17 @@ import House from 'lucide-svelte/icons/house'; import Landmark from 'lucide-svelte/icons/landmark'; import Globe from 'lucide-svelte/icons/globe'; + import Vote from 'lucide-svelte/icons/vote'; + import MessageCircle from 'lucide-svelte/icons/message-circle'; let { children } = $props(); const icons = { overview: ChartLine, economy: Landmark, energy: Flame, gouda: House, - countries: Globe + countries: Globe, + elections: Vote, + opinions: MessageCircle } as const; const tabs = [ { name: 'overview', href: '/stats' }, @@ -96,6 +100,12 @@ .tab-countries { background: linear-gradient(135deg, #a855f7, #ec4899); } + .tab-elections { + background: linear-gradient(135deg, #06b6d4, #3b82f6); + } + .tab-opinions { + background: linear-gradient(135deg, #ec4899, #f97316); + } /* Shared page furniture for every /stats page. */ .stats-page :global(.stats-intro) { display: flex; diff --git a/src/routes/stats/+page.svelte b/src/routes/stats/+page.svelte index ac7b807..1054bab 100644 --- a/src/routes/stats/+page.svelte +++ b/src/routes/stats/+page.svelte @@ -9,6 +9,8 @@ import House from 'lucide-svelte/icons/house'; import Globe from 'lucide-svelte/icons/globe'; import Palmtree from 'lucide-svelte/icons/tree-palm'; + import Vote from 'lucide-svelte/icons/vote'; + import MessageCircle from 'lucide-svelte/icons/message-circle'; import ArrowRight from 'lucide-svelte/icons/arrow-right'; let { data } = $props(); @@ -78,6 +80,20 @@ icon: Palmtree, grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)', text: 'Population, income, inflation, tourism, trade and government finances of Curaçao.' + }, + { + href: '/stats/elections', + title: 'Elections', + icon: Vote, + grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)', + text: 'Every Dutch general election since 1918, Gouda down to the polling station, and the US presidential vote by state.' + }, + { + href: '/stats/opinions', + title: 'Opinions', + icon: MessageCircle, + grad: 'linear-gradient(135deg,#ec4899,#f97316)', + text: 'Seat and approval polls, Eurobarometer worries and trust, world happiness, search trends, news themes and party positions.' } ] as const; diff --git a/src/routes/stats/elections/+page.server.ts b/src/routes/stats/elections/+page.server.ts new file mode 100644 index 0000000..6065879 --- /dev/null +++ b/src/routes/stats/elections/+page.server.ts @@ -0,0 +1,83 @@ +import { cached } from '#lib/server/stats.js'; +import { client } from '#lib/server/db/index.js'; +import type { PageServerLoad } from './$types'; + +export type ElecRow = { + election: string; + date: string; + party: string; + votes: number | null; + seats: number | null; +}; + +const rows = (r: readonly Record[]): ElecRow[] => + r.map((x) => ({ + election: x.election as string, + date: x.date as string, + party: x.party as string, + votes: x.votes as number | null, + seats: (x.seats as number | null) ?? null + })); + +export const load: PageServerLoad = async () => + cached('stats:elections', async () => { + const [elections, nl, turnout, gouda, stations, us, usStates] = await Promise.all([ + client`select election_id, country, date::text as date, name, total_seats from stats.elec_election order by date`, + client`select r.election_id as election, e.date::text as date, r.party, r.votes::float8 as votes, r.seats + from stats.elec_result r join stats.elec_election e using (election_id) + where e.country = 'NLD' and r.level = 'national'`, + client`select t.election_id as election, e.date::text as date, t.electorate::float8 as electorate, + t.ballots::float8 as ballots, t.valid::float8 as valid + from stats.elec_turnout t join stats.elec_election e using (election_id) + where t.level = 'national' order by e.date`, + client`select r.election_id as election, e.date::text as date, r.party, r.votes::float8 as votes + from stats.elec_result r join stats.elec_election e using (election_id) + where r.level = 'muni' and r.area_code = 'GM0513'`, + client`select r.area_code, r.area_name, r.party, r.votes::float8 as votes + from stats.elec_result r + where r.level = 'station' and r.election_id = ( + select max(election_id) from stats.elec_result where level = 'station')`, + client`select r.election_id as election, e.date::text as date, r.party, r.votes::float8 as votes + from stats.elec_result r join stats.elec_election e using (election_id) + where e.country = 'USA' and r.level = 'national'`, + client`select r.area_code, r.area_name, r.party, r.votes::float8 as votes + from stats.elec_result r + where r.level = 'state' and r.election_id = ( + select max(election_id) from stats.elec_election where country = 'USA')` + ]); + const stationElection = ( + await client`select max(election_id) as id from stats.elec_result where level = 'station'` + )[0]?.id as string | null; + return { + elections: elections.map((e) => ({ + id: e.election_id as string, + country: e.country as string, + date: e.date as string, + name: e.name as string, + seats: e.total_seats as number | null + })), + nl: rows(nl), + turnout: turnout.map((t) => ({ + election: t.election as string, + date: t.date as string, + electorate: t.electorate as number | null, + ballots: t.ballots as number | null, + valid: t.valid as number | null + })), + gouda: rows(gouda), + stationElection, + stations: stations.map((s) => ({ + code: s.area_code as string, + name: s.area_name as string, + party: s.party as string, + votes: s.votes as number | null + })), + us: rows(us), + usStates: usStates.map((s) => ({ + code: s.area_code as string, + name: s.area_name as string, + party: s.party as string, + votes: s.votes as number | null + })) + }; + }); diff --git a/src/routes/stats/elections/+page.svelte b/src/routes/stats/elections/+page.svelte new file mode 100644 index 0000000..69548cb --- /dev/null +++ b/src/routes/stats/elections/+page.svelte @@ -0,0 +1,430 @@ + + + + Elections · stats · Stan Runge + + + +
+ stats / elections +

Elections

+

+ Every Tweede Kamer election since universal suffrage in 1918, how Gouda votes down to the + polling station, and the US presidential vote by state. +

+
+ +
+ + + + 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" + /> +
+ +

The Netherlands

+
+ lineOption(t, familyLines, { category: true, unit: '%', decimals: 1, min: 0 })} + /> + + lineOption(t, [{ name: 'Turnout', points: turnout, color: '#3b82f6', kind: 'area' }], { + category: true, + unit: '%', + decimals: 1, + legend: false, + min: 50 + })} + /> + + barRankOption( + t, + (latestNl?.parties ?? []) + .filter((p) => p.seats) + .map((p) => ({ label: p.party, value: p.seats ?? 0 })), + { decimals: 0, color: '#06b6d4' } + )} + /> +
+ +
+ + + + + {#each recent as e (e.id)}{/each} + + + + {#each seatParties as p (p)} + + + {#each recent as e (e.id)}{/each} + + {/each} + +
Seats{year(e.date)}
+ {p} + {seatsOf(e, p) ?? ''}
+
+ +

Gouda

+{#if goudaLatest} +
+ lineOption(t, goudaVsNl, { category: true, unit: '%', decimals: 1 })} + /> + lineOption(t, goudaTrend, { category: true, unit: '%', decimals: 1 })} + /> + {#each stationCols.slice(0, 2) as p (p)} + + barRankOption(t, stationBars(p), { + decimals: 1, + unit: '%', + color: PARTY_COLORS[p] ?? '#3b82f6' + })} + /> + {/each} +
+
+ + + + + + + {#each stationCols as p (p)}{/each} + + + + {#each stationRows as s (s.code)} + + + + + {#each stationCols as p (p)}{/each} + + {/each} + +
Polling station, TK {year(goudaLatest.date)}VotesLargest{p} %
{s.name}{num(s.total, 0)}{s.top}{num(share(s.shares.get(p) ?? 0, s.total), 1)}
+
+

+ {a.kiesraad ?? ''} · Postal and mobile stations have few votes; votes are counted where they are cast, + not where voters live. +

+{:else} +

Gouda's results are not loaded yet.

+{/if} + +

United States

+
+ + 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 } + )} + /> + + barRankOption( + t, + states.map((s) => ({ label: s.name, value: s.margin, highlight: Math.abs(s.margin) < 3 })), + { decimals: 1, unit: 'pp', color: '#64748b' } + )} + /> +
+ + diff --git a/src/routes/stats/opinions/+page.server.ts b/src/routes/stats/opinions/+page.server.ts new file mode 100644 index 0000000..a9c8d67 --- /dev/null +++ b/src/routes/stats/opinions/+page.server.ts @@ -0,0 +1,101 @@ +import { cached } from '#lib/server/stats.js'; +import { client } from '#lib/server/db/index.js'; +import { ebLabel, latestPolls, pollAverages, type Poll } from '#lib/stats/opinions.js'; + +// Averages are computed here so the page gets a few hundred points per series, not every poll row. +function pollData(polls: Poll[]) { + const noRaw = (s: ReturnType) => s.map((o) => ({ ...o, raw: [] })); + return { + nl: pollAverages(polls, 'nl-tk-seats', 5), + nlLatest: latestPolls(polls, 'nl-tk-seats', 8), + us: pollAverages(polls, 'us-trump-approval', 10), + usAgg: latestPolls(polls, 'us-trump-approval-aggregate', 20), + kr: noRaw(pollAverages(polls, 'kr-lee-approval', 10)), + krParty: noRaw(pollAverages(polls, 'kr-party', 10)) + }; +} +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => + cached('stats:opinions', async () => { + const [polls, eb, whr, trends, gdelt, ches, vparty] = await Promise.all([ + client`select series, poll_key, fieldwork_end::text as date, pollster, sample, option, value + from stats.op_poll where value is not null order by fieldwork_end`, + client`select wave, fieldwork_end::text as date, topic, item, country, share + from stats.eb_item where country in ('NL', 'EU27') and share is not null`, + client`select year, country, iso3, rank, ladder, gdp, social, health, freedom, generosity, corruption + from stats.whr_score order by year`, + client`select geo, date::text as date, rank, query, approx_traffic + from stats.gt_trending t where date = (select max(date) from stats.gt_trending where geo = t.geo) + order by geo, rank`, + client`select date::text as date, country, theme, vol_pct from stats.gdelt_theme_vol + where date > now() - interval '45 days' order by date`, + client`select p.party_id, coalesce(p.abbrev, p.name) as label, p.name, p.family, pp.year, pp.dim, + pp.value, pp.vote_share + from stats.wo_party_position pp join stats.wo_party p using (party_id) + where p.iso3 = 'NLD' and pp.source = 'ches' + and pp.year = (select max(year) from stats.wo_party_position where source = 'ches')`, + client`select p.party_id, coalesce(p.abbrev, p.name) as label, pp.year, pp.value, pp.vote_share + from stats.wo_party_position pp join stats.wo_party p using (party_id) + where p.iso3 = 'NLD' and pp.source = 'vparty' and pp.dim = 'v2xpa_popul' and pp.year >= 1946 + order by pp.year` + ]); + return { + ...pollData( + polls.map((p): Poll => ({ + series: p.series as string, + key: p.poll_key as string, + date: p.date as string, + pollster: p.pollster as string, + sample: p.sample as number | null, + option: p.option as string, + value: Number(p.value) + })) + ), + eb: eb.map((r) => ({ + wave: r.wave as string, + date: r.date as string, + topic: r.topic as string, + item: ebLabel(r.topic as string, r.item as string), + country: r.country as string, + share: Math.round(Number(r.share) * 1000) / 10 + })), + whr: whr.map((r) => ({ + year: r.year as number, + country: r.country as string, + iso3: r.iso3 as string | null, + rank: r.rank as number | null, + ladder: r.ladder == null ? null : Number(r.ladder) + })), + trends: trends.map((r) => ({ + geo: r.geo as string, + date: r.date as string, + rank: r.rank as number, + query: r.query as string, + traffic: r.approx_traffic as number | null + })), + gdelt: gdelt.map((r) => ({ + date: r.date as string, + country: (r.country as string).trim(), + theme: r.theme as string, + vol: Number(r.vol_pct) + })), + ches: ches.map((r) => ({ + id: r.party_id as string, + label: r.label as string, + name: r.name as string | null, + family: r.family as string | null, + year: r.year as number, + dim: r.dim as string, + value: Number(r.value), + share: r.vote_share == null ? null : Number(r.vote_share) + })), + vparty: vparty.map((r) => ({ + id: r.party_id as string, + label: r.label as string, + year: r.year as number, + value: Number(r.value), + share: r.vote_share == null ? null : Number(r.vote_share) + })) + }; + }); diff --git a/src/routes/stats/opinions/+page.svelte b/src/routes/stats/opinions/+page.svelte new file mode 100644 index 0000000..2996d4e --- /dev/null +++ b/src/routes/stats/opinions/+page.svelte @@ -0,0 +1,611 @@ + + + + Opinions · stats · Stan Runge + + + +
+ stats / opinions +

Opinions

+

+ What people think, from polls and surveys: Dutch seat polls, approval of Trump and Lee + Jae-myung, Eurobarometer worries and trust, the World Happiness Report, and where parties stand. + For values by country see world opinions. +

+
+ +
+ + + + +
+ +

Polls

+
+ { + const o = lineOption( + t, + nl.map((p) => ({ name: p.option, color: PARTY_COLORS[p.option], points: p.avg })), + { decimals: 1, zoom: true } + ) as { series: object[] }; + o.series.push( + ...nl.map((p) => ({ + type: 'scatter', + name: p.option, + data: p.raw, + symbolSize: 4, + itemStyle: { color: PARTY_COLORS[p.option] ?? '#94a3b8', opacity: 0.35 }, + tooltip: { show: false } + })) + ); + return o; + }} + /> + { + const o = lineOption( + t, + [ + { name: 'Approve', color: '#10b981', points: usApprove?.avg ?? [] }, + { name: 'Disapprove', color: '#e11d48', points: usDisapprove?.avg ?? [] } + ], + { unit: '%', decimals: 1, min: 'dataMin' } + ) as { series: object[] }; + o.series.push( + ...[usApprove, usDisapprove].map((p, i) => ({ + type: 'scatter', + name: p?.option, + data: p?.raw ?? [], + symbolSize: 3, + itemStyle: { color: i ? '#e11d48' : '#10b981', opacity: 0.3 }, + tooltip: { show: false } + })) + ); + return o; + }} + /> + + lineOption( + t, + kr.map((p) => ({ + name: p.option, + color: + p.option === 'Approve' ? '#10b981' : p.option === 'Disapprove' ? '#e11d48' : '#94a3b8', + points: p.avg + })), + { unit: '%', decimals: 1, zoom: true } + )} + /> + + lineOption( + t, + krParty.map((p) => ({ name: p.option, color: KR_COLORS[p.option], points: p.avg })), + { unit: '%', decimals: 1, zoom: true } + )} + /> + + barRankOption( + t, + usAgg.map((r) => ({ label: r.pollster, value: r.values.Approve ?? 0 })), + { decimals: 1, unit: '%', color: '#e11d48' } + )} + /> +
+ +{#if nlLatest.length} +
+ + + + + {#each nlCols as p (p)}{/each} + + + + {#each nlLatest as r (r.date + r.pollster)} + + + {#each nlCols as p (p)}{/each} + + {/each} + +
Latest Dutch polls{p}
{r.pollster} · {day(r.date)}{num(r.values[p], 0)}
+
+{/if} + +

Eurobarometer

+
+ + wrapLabels( + lineOption(t, issueBars(miiCountry.items.slice(0, 10)), { + category: true, + unit: '%', + decimals: 0 + }) + )} + /> + + wrapLabels( + lineOption(t, issueBars(miiPersonal.items.slice(0, 10)), { + category: true, + unit: '%', + decimals: 0 + }) + )} + /> + + lineOption( + t, + issueTrend.map((i) => ({ name: i.item, points: i.points })), + { unit: '%', decimals: 0 } + )} + /> + + lineOption( + t, + trustTrend.map((i) => ({ + name: i.item, + points: i.points, + color: i.item.startsWith('National') ? '#f97316' : '#3b82f6', + dashed: 'dashed' in i + })), + { unit: '%', decimals: 0, min: 0 } + )} + /> + + wrapLabels( + lineOption(t, issueBars([...trust.items, ...trustEu.items]), { + category: true, + unit: '%', + decimals: 0 + }), + 70 + )} + /> +
+ +

Happiness

+
+ barRankOption(t, whrRows, { decimals: 2, color: '#94a3b8' })} + /> + lineOption(t, whrTrend, { decimals: 2, min: 'dataMin' })} + /> +
+ +

What people search and read

+ +{#if gThemes.length} +
+ + lineOption( + t, + gCountries.map((c, i) => ({ + name: c, + kind: 'bar' as const, + color: [ + '#f97316', + '#e11d48', + '#3b82f6', + '#10b981', + '#a855f7', + '#eab308', + '#06b6d4', + '#64748b', + '#84cc16', + '#ec4899' + ][i % 10], + points: gThemes.map((th) => [GDELT_THEMES[th] ?? th, gAvg(c, th)] as StatPoint) + })), + { category: true, unit: '%', decimals: 3 } + )} + /> +
+{/if} + +

Where Dutch parties stand

+
+ {#if ches.length} + + {/if} + {#if vparty.length} + + lineOption( + t, + vparty.map((p) => ({ name: p.label, color: PARTY_COLORS[p.label], points: p.points })), + { decimals: 2, zoom: true } + )} + /> + {/if} +
+{#if ches.length} +
+ + + + + + + + + + + + + + {#each ches as p (p.id)} + + + + + + + + + + {/each} + +
Party, CHES {chesYear}Vote %Left–rightEconomicGAL–TANEU (1–7)Immigration
{p.label}{num(p.share, 1)}{num(p.dims.lrgen, 1)}{num(p.dims.lrecon, 1)}{num(p.dims.galtan, 1)}{num(p.dims.eu_position, 1)}{num(p.dims.immigrate_policy, 1)}
+
+

+ {a.ches ?? ''} · Immigration: 0 strongly for a liberal policy, 10 strongly for a restrictive one. +

+{/if} + +