stats: place × theme matrix (/stats/<place>/<theme>), sticky place/theme selector, generic metric engine with comparisons, 301s from the old section URLs
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
This commit is contained in:
@@ -34,70 +34,66 @@ export const projects = [
|
||||
}
|
||||
] as const;
|
||||
|
||||
// The public /stats section: open data on the economy, energy and Gouda (src/routes/stats).
|
||||
// The public /stats section (src/routes/stats): a place × theme matrix; these are the shortcuts.
|
||||
export const statsPages = [
|
||||
{
|
||||
name: 'economy',
|
||||
icon: 'stats-economy',
|
||||
subtitle: 'rates, inflation, bond spreads, GDP and debt',
|
||||
href: '/stats/economy',
|
||||
href: '/stats/core/economy',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'jobs & cost of living',
|
||||
icon: 'stats-jobs',
|
||||
subtitle: 'unemployment, vacancies, graduate outcomes, wages, rents and price levels',
|
||||
href: '/stats/core/jobs',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'energy',
|
||||
icon: 'stats-energy',
|
||||
subtitle: 'live grid mix and gas storage, tariffs, oil and gas, renewables, heat pumps and EVs',
|
||||
href: '/stats/energy',
|
||||
href: '/stats/nld/energy',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'gouda',
|
||||
icon: 'stats-gouda',
|
||||
subtitle: 'population, housing and neighbourhoods in Gouda',
|
||||
href: '/stats/gouda',
|
||||
href: '/stats/gouda/overview',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'countries',
|
||||
icon: 'stats-countries',
|
||||
subtitle: 'the Netherlands, Curaçao, the US, Korea, China and more side by side',
|
||||
href: '/stats/countries',
|
||||
href: '/stats/core/overview',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'elections',
|
||||
icon: 'stats-elections',
|
||||
subtitle: 'Dutch elections since 1918, Gouda per polling station, US presidential',
|
||||
href: '/stats/elections',
|
||||
href: '/stats/nld/politics',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
icon: 'stats-history',
|
||||
subtitle: 'the long run, a sourced timeline, politics and culture of five places',
|
||||
href: '/stats/history',
|
||||
href: '/stats/world/history',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'opinions',
|
||||
icon: 'stats-opinions',
|
||||
subtitle: 'polls, Eurobarometer, happiness, party positions and world opinions',
|
||||
href: '/stats/opinions',
|
||||
href: '/stats/core/politics',
|
||||
external: false
|
||||
}
|
||||
] as const;
|
||||
|
||||
/** Sub-tabs of a /stats section (second row under the stats nav), keyed by the section path. */
|
||||
export const statsSubPages: Record<
|
||||
string,
|
||||
readonly { name: string; href: string; icon: string }[]
|
||||
> = {
|
||||
'/stats/opinions': [
|
||||
{ name: 'polls & surveys', href: '/stats/opinions', icon: 'stats-polls' },
|
||||
{ name: 'world', href: '/stats/opinions/world', icon: 'stats-world' }
|
||||
]
|
||||
};
|
||||
|
||||
type Tracker = {
|
||||
name: string;
|
||||
subtitle: string;
|
||||
|
||||
@@ -122,7 +122,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
||||
description:
|
||||
'Learn Dutch, Korean and English from beginner to advanced, with lessons and endless daily challenges.'
|
||||
},
|
||||
'/stats/opinions/world': {
|
||||
'/stats/world/politics': {
|
||||
title: 'World Opinions',
|
||||
description:
|
||||
'How political words, parties, values and national histories differ across the world.'
|
||||
|
||||
@@ -523,7 +523,7 @@ export const statsElecTurnout = statsSchema.table(
|
||||
);
|
||||
|
||||
// ── Step 3: world opinions (§4, A15) ─────────────────────────────────────────────────────
|
||||
// The /stats/opinions/world guide reads these. Curated rows (seeded from the former
|
||||
// The /stats/world/politics guide reads these. Curated rows (seeded from the former
|
||||
// src/lib/world-opinions/data.ts) keep Stan's text exactly; collectors add CHES / V-Party data.
|
||||
|
||||
export const statsWoCountry = statsSchema.table('wo_country', {
|
||||
|
||||
@@ -70,7 +70,7 @@ export function loadHeritage(place: HeritageCode): Promise<Heritage> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Long-run comparison series for /stats/history. */
|
||||
/** Long-run comparison series for /stats/world/history. */
|
||||
export function loadHistoryOverview() {
|
||||
return cached('stats:history:overview', async () => {
|
||||
const codes = ['NLD', 'USA', 'KOR', 'CUW', 'WLD'];
|
||||
|
||||
111
src/lib/server/stats-matrix.ts
Normal file
111
src/lib/server/stats-matrix.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Server side of the /stats matrix: resolves a theme's metrics for the selected places to stored
|
||||
// series (first candidate with data wins) and loads the rich views that fit the combination.
|
||||
import { cached, loadSeries, type StatPoint } from '#lib/server/stats.js';
|
||||
import {
|
||||
candidates,
|
||||
PLACE_BY_CODE,
|
||||
type Metric,
|
||||
type Selection,
|
||||
type Theme,
|
||||
type ViewSpec
|
||||
} from '#lib/stats/matrix.js';
|
||||
import * as views from '#lib/server/stats-views.js';
|
||||
import type { CountryCode } from '#lib/stats/countries.js';
|
||||
import type { HeritageCode } from '#lib/stats/heritage.js';
|
||||
|
||||
export type MetricLine = {
|
||||
code: string;
|
||||
seriesId: string;
|
||||
sourceId: string;
|
||||
freq: string;
|
||||
unit: string;
|
||||
points: StatPoint[];
|
||||
};
|
||||
export type MetricResult = {
|
||||
metric: Metric;
|
||||
lines: MetricLine[];
|
||||
/** Selected places without any series for this metric. */
|
||||
missing: string[];
|
||||
};
|
||||
|
||||
export async function loadMetrics(sel: Selection, theme: Theme): Promise<MetricResult[]> {
|
||||
const places = sel.codes.map((c) => PLACE_BY_CODE.get(c)!).filter(Boolean);
|
||||
const reqs = new Map<string, { id: string; from?: string; resample?: 'M' }>();
|
||||
for (const m of theme.metrics)
|
||||
for (const p of places)
|
||||
for (const id of candidates(m, p))
|
||||
reqs.set(id, {
|
||||
id,
|
||||
...(m.from ? { from: m.from } : {}),
|
||||
...(m.resample ? { resample: m.resample } : {})
|
||||
});
|
||||
const key = `stats:matrix:${theme.slug}:${sel.codes.join(',')}`;
|
||||
const series = await cached(key, () => loadSeries([...reqs.values()]));
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return theme.metrics.map((m) => {
|
||||
const lines: MetricLine[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const p of places) {
|
||||
const id = candidates(m, p).find((i) => series[i]?.points.length);
|
||||
if (!id) {
|
||||
missing.push(p.code);
|
||||
continue;
|
||||
}
|
||||
const s = series[id];
|
||||
// Drop forecasts (IMF WEO runs years ahead); the charts show what has happened.
|
||||
const points = s.points.filter(([period]) => period <= today);
|
||||
if (!points.length) {
|
||||
missing.push(p.code);
|
||||
continue;
|
||||
}
|
||||
lines.push({
|
||||
code: p.code,
|
||||
seriesId: id,
|
||||
sourceId: s.sourceId,
|
||||
freq: s.freq,
|
||||
unit: s.unit,
|
||||
points
|
||||
});
|
||||
}
|
||||
return { metric: m, lines, missing };
|
||||
});
|
||||
}
|
||||
|
||||
export type LoadedView =
|
||||
| { kind: 'economy'; data: views.EconomyViewData }
|
||||
| { kind: 'energy'; data: views.EnergyViewData }
|
||||
| { kind: 'gouda'; data: views.GoudaViewData }
|
||||
| { kind: 'countries'; data: views.CountriesViewData }
|
||||
| { kind: 'country'; data: views.CountryViewData }
|
||||
| { kind: 'elections'; focus: 'all' | 'nld' | 'gouda' | 'usa'; data: views.ElectionsViewData }
|
||||
| { kind: 'opinions'; data: views.OpinionsViewData }
|
||||
| { kind: 'worldOpinions'; data: views.WorldOpinionsViewData }
|
||||
| { kind: 'history'; data: views.HistoryViewData }
|
||||
| { kind: 'heritage'; data: Awaited<ReturnType<typeof views.loadHeritageView>> };
|
||||
|
||||
export function loadView(v: ViewSpec): Promise<LoadedView> {
|
||||
switch (v.kind) {
|
||||
case 'economy':
|
||||
return views.loadEconomyView().then((data) => ({ kind: v.kind, data }));
|
||||
case 'energy':
|
||||
return views.loadEnergyView().then((data) => ({ kind: v.kind, data }));
|
||||
case 'gouda':
|
||||
return views.loadGoudaView().then((data) => ({ kind: v.kind, data }));
|
||||
case 'countries':
|
||||
return views.loadCountriesView().then((data) => ({ kind: v.kind, data }));
|
||||
case 'country':
|
||||
return views.loadCountryView(v.code as CountryCode).then((data) => ({ kind: v.kind, data }));
|
||||
case 'elections':
|
||||
return views.loadElectionsView().then((data) => ({ kind: v.kind, focus: v.focus, data }));
|
||||
case 'opinions':
|
||||
return views.loadOpinionsView().then((data) => ({ kind: v.kind, data }));
|
||||
case 'worldOpinions':
|
||||
return views.loadWorldOpinionsView().then((data) => ({ kind: v.kind, data }));
|
||||
case 'history':
|
||||
return views.loadHistoryView().then((data) => ({ kind: v.kind, data }));
|
||||
case 'heritage':
|
||||
return views
|
||||
.loadHeritageView(v.code as HeritageCode)
|
||||
.then((data) => ({ kind: v.kind, data }));
|
||||
}
|
||||
}
|
||||
433
src/lib/server/stats-views.ts
Normal file
433
src/lib/server/stats-views.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
// Loaders for the rich per-place views of the /stats matrix (the former /stats/<section> pages).
|
||||
// Each returns plain data for one view component in #lib/stats/views; the matrix page decides
|
||||
// which views a place × theme combination shows (see viewsFor in #lib/stats/matrix.ts).
|
||||
import { cached, loadCbsCross, loadLatest, loadSeries } from '#lib/server/stats.js';
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import { loadEnergy } from '#lib/server/energy.js';
|
||||
import { loadHeritage, loadHistoryOverview } from '#lib/server/stats-history.js';
|
||||
import { loadWorldOpinions } from '#lib/server/world-opinions.js';
|
||||
import {
|
||||
ANNUAL,
|
||||
CPI_BY_CATEGORY,
|
||||
CPI_TOTAL,
|
||||
ECON_COUNTRIES,
|
||||
POLICY_RATE
|
||||
} from '#lib/stats/econ.js';
|
||||
import { epSeriesIds, GOUDA_PC4 } from '#lib/stats/energylabels.js';
|
||||
import {
|
||||
COMPARE_COLUMNS,
|
||||
COUNTRY_PAGES,
|
||||
PROFILE_SERIES,
|
||||
seriesIds,
|
||||
type CountryCode
|
||||
} from '#lib/stats/countries.js';
|
||||
import { CW_SERIES } from '#lib/stats/curacao.js';
|
||||
import { isHeritage, type HeritageCode } from '#lib/stats/heritage.js';
|
||||
import { ebLabel, latestPolls, pollAverages, type Poll } from '#lib/stats/opinions.js';
|
||||
|
||||
// economy (core countries)
|
||||
|
||||
const ECO_EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020', 'EA20'];
|
||||
const ECO_YIELDS = ['NL', 'DE', 'FR', 'IT', 'ES', 'BE', 'AT', 'EA'];
|
||||
const ECO_REGIONS = [...ECON_COUNTRIES, 'EU27', 'EA'];
|
||||
|
||||
export const loadEconomyView = async () => ({
|
||||
series: await cached('stats:economy', () =>
|
||||
loadSeries([
|
||||
...[...new Set(Object.values(POLICY_RATE))].map((a) => ({
|
||||
id: `bis:M:${a}`,
|
||||
from: '1990-01-01'
|
||||
})),
|
||||
...ECO_EU.map((g) => `eurostat:prc_hicp_minr:RCH_A.TOTAL.${g}`),
|
||||
// CPI index levels (all items, then the categories)
|
||||
...[
|
||||
...new Set([
|
||||
...Object.values(CPI_TOTAL).map((c) => c.id),
|
||||
...Object.values(CPI_BY_CATEGORY).flatMap((c) => Object.values(c.ids))
|
||||
])
|
||||
].map((id) => ({ id, from: '1960-01-01' })),
|
||||
...ECO_REGIONS.map((c) => ({ id: `oecd:irlt:${c}`, from: '1980-01-01' })),
|
||||
...ECO_YIELDS.map((g) => `eurostat:irt_lt_mcby_m:MCBY.${g}`),
|
||||
{ id: 'fred:DGS10', from: '1990-01-01', resample: 'M' },
|
||||
{ id: 'fred:DGS2', from: '1990-01-01', resample: 'M' },
|
||||
...['NL', 'KR', 'US', 'CN'].map((c) => ({
|
||||
id: `fred:SPASTT01${c}M661N`,
|
||||
from: '1990-01-01'
|
||||
})),
|
||||
{ id: 'fred:SP500', resample: 'W' },
|
||||
{ id: 'fred:NASDAQCOM', from: '1990-01-01', resample: 'W' },
|
||||
...Object.values(ANNUAL).flatMap((ind) =>
|
||||
ECO_REGIONS.flatMap((c) => [
|
||||
...(ind.imf ? [{ id: `imf:${ind.imf}:${c}`, from: '1980-01-01' }] : []),
|
||||
{ id: `wb:${ind.wb}:${c}`, from: '1980-01-01' }
|
||||
])
|
||||
),
|
||||
...Object.values(ANNUAL).flatMap((ind) => (ind.cw ? [ind.cw] : [])),
|
||||
'fred:GDPC1',
|
||||
'fred:UNRATE',
|
||||
...ECO_EU.flatMap((g) => [
|
||||
`eurostat:gov_10dd_edpt1:PC_GDP.S13.GD.${g}`,
|
||||
`eurostat:gov_10dd_edpt1:PC_GDP.S13.B9.${g}`,
|
||||
`eurostat:gov_10a_main:PC_GDP.S13.TE.${g}`,
|
||||
`eurostat:gov_10a_main:PC_GDP.S13.TR.${g}`,
|
||||
`eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.${g}`,
|
||||
`eurostat:demo_pjan:NR.TOTAL.T.${g}`
|
||||
])
|
||||
])
|
||||
)
|
||||
});
|
||||
|
||||
// energy (the Netherlands, with Europe and the US)
|
||||
|
||||
const ENE_EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020'];
|
||||
const ENE_RENEW = ['total', 'wind-onshore', 'wind-offshore', 'solar', 'biomass', 'hydro'];
|
||||
|
||||
export const loadEnergyView = async () => ({
|
||||
// Live NED grid + gas storage (read-only, ned-collector's `energy` schema).
|
||||
energy: await cached('stats:energy:live', loadEnergy),
|
||||
series: await cached('stats:energy', () =>
|
||||
loadSeries([
|
||||
...['variable', 'tax', 'transport', 'fixed', 'taxcredit', 'dynamic'].map(
|
||||
(k) => `cbs:tariff:elec:${k}`
|
||||
),
|
||||
...['variable', 'tax', 'transport', 'fixed'].map((k) => `cbs:tariff:gas:${k}`),
|
||||
...['variable', 'tax', 'ode'].flatMap((k) => [
|
||||
`cbs:tariff1823:elec:${k}`,
|
||||
`cbs:tariff1823:gas:${k}`
|
||||
]),
|
||||
'cbs:price0723:elec',
|
||||
'cbs:price0723:gas',
|
||||
'cbs:cpi:energy:yoy',
|
||||
...ENE_EU.flatMap((g) => [
|
||||
`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_pc_204:E7000.KWH3500.KWH.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_pc_202:G3000.GJ83P70.GJ_GCV.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_ind_id:TOTAL.PC.${g}`
|
||||
]),
|
||||
...['X_TAX', 'X_VAT'].flatMap((t) => [
|
||||
`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.${t}.EUR.NL`,
|
||||
`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.${t}.EUR.NL`
|
||||
]),
|
||||
{ id: 'fred:APU000072610', from: '2007-01-01' },
|
||||
{ id: 'fred:DCOILBRENTEU', resample: 'W' },
|
||||
'fred:PNGASEUUSDM',
|
||||
'cbs:energy:tpes',
|
||||
'cbs:energy:netimport',
|
||||
'cbs:energy:production',
|
||||
'cbs:energy:gas:production',
|
||||
'cbs:energy:gas:netimport',
|
||||
'cbs:energy:gas:tpes',
|
||||
...ENE_RENEW.flatMap((k) => [`cbs:renew:${k}:share`, `cbs:renew:${k}:mw`]),
|
||||
'cbs:heatpumps:homes',
|
||||
'cbs:heatpumps:homes:air',
|
||||
'cbs:heatpumps:homes:ground',
|
||||
'cbs:heatpumps:new',
|
||||
...['NL01', 'GM0513'].flatMap((r) => [
|
||||
`cbs:solar:homes:kwp:${r}`,
|
||||
`cbs:solar:homes:n:${r}`,
|
||||
`cbs:solar:kwp:${r}`
|
||||
]),
|
||||
'cbs:pop:jan1:GM0513',
|
||||
'cbs:cars:total',
|
||||
'cbs:cars:electric',
|
||||
'cbs:cars:petrol',
|
||||
'cbs:cars:diesel',
|
||||
'eurostat:road_eqr_carpda:NR.ELC.NL',
|
||||
'eurostat:road_eqr_carpda:NR.TOTAL.NL',
|
||||
...epSeriesIds(['NL', 'GOUDA'])
|
||||
])
|
||||
)
|
||||
});
|
||||
|
||||
// Gouda overview
|
||||
|
||||
// Gouda (GM0513) and its neighbours; NL01 is the national total.
|
||||
const GOU_RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];
|
||||
const GOU_KWB_TABLE = '85984NED'; // Kerncijfers wijken en buurten 2024
|
||||
const GOU_KWB_MEASURES = [
|
||||
'T001036',
|
||||
'M000100',
|
||||
'M001642',
|
||||
'1014850_2',
|
||||
'A047047',
|
||||
'M000224',
|
||||
'M008349',
|
||||
'M000221_2',
|
||||
'M000219_2',
|
||||
'M008295',
|
||||
'M008297'
|
||||
];
|
||||
|
||||
export const loadGoudaView = async () => {
|
||||
const [series, wijken, heritage] = await Promise.all([
|
||||
cached('stats:gouda', () =>
|
||||
loadSeries([
|
||||
...['NL01', ...GOU_RING].flatMap((r) => [
|
||||
`cbs:pop:monthly:${r}`,
|
||||
`cbs:pop:jan1:${r}`,
|
||||
`cbs:house:avgprice:${r}`,
|
||||
...['total', 'owner', 'social', 'private'].map((k) => `cbs:stock:${k}:${r}`)
|
||||
]),
|
||||
...GOU_RING.flatMap((r) =>
|
||||
['total', 'ozb', 'sewer', 'waste'].map((k) => `cbs:levy:${k}:${r}`)
|
||||
),
|
||||
...['births', 'deaths', 'immigration', 'emigration', 'in', 'out', 'growth'].map(
|
||||
(k) => `cbs:pop:${k}:GM0513`
|
||||
),
|
||||
'cbs:house:hpi:cr28',
|
||||
'cbs:house:hpi:nl',
|
||||
...epSeriesIds(['NL', 'GOUDA', ...GOUDA_PC4])
|
||||
])
|
||||
),
|
||||
cached('stats:gouda:kwb', () =>
|
||||
loadCbsCross(GOU_KWB_TABLE, GOU_KWB_MEASURES, 'WijkenEnBuurten', ['WK0513', 'GM0513', 'NL00'])
|
||||
),
|
||||
loadHeritage('GM0513')
|
||||
]);
|
||||
return { series, wijken, kwbYear: 2024, heritage };
|
||||
};
|
||||
|
||||
// countries side by side
|
||||
|
||||
export const loadCountriesView = async () =>
|
||||
cached('stats:countries', async () => {
|
||||
const ids = COUNTRY_PAGES.flatMap((c) =>
|
||||
COMPARE_COLUMNS.flatMap((col) => seriesIds(c, col.ids))
|
||||
);
|
||||
const [latest, series] = await Promise.all([
|
||||
loadLatest(ids),
|
||||
loadSeries(
|
||||
COUNTRY_PAGES.flatMap((c) => [
|
||||
{ id: `wb:NY.GDP.PCAP.PP.KD:${c}` },
|
||||
{ id: `wb:SP.DYN.LE00.IN:${c}`, from: '1960-01-01' }
|
||||
])
|
||||
)
|
||||
]);
|
||||
const rows = COUNTRY_PAGES.map((c) => ({
|
||||
code: c,
|
||||
values: Object.fromEntries(
|
||||
COMPARE_COLUMNS.map((col) => {
|
||||
const id = seriesIds(c, col.ids).find((i) => latest[i]);
|
||||
return [col.key, id ? { ...latest[id], source: id.split(':')[0] } : null];
|
||||
})
|
||||
)
|
||||
}));
|
||||
return { rows, series };
|
||||
});
|
||||
|
||||
// elections (NL, Gouda, US)
|
||||
|
||||
export type ElecRow = {
|
||||
election: string;
|
||||
date: string;
|
||||
party: string;
|
||||
votes: number | null;
|
||||
seats: number | null;
|
||||
};
|
||||
|
||||
const ELE_rows = (r: readonly Record<string, unknown>[]): 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 loadElectionsView = 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: ELE_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: ELE_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: ELE_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
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
// opinions
|
||||
|
||||
// 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<typeof pollAverages>) => 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))
|
||||
};
|
||||
}
|
||||
|
||||
export const loadOpinionsView = 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)
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
// one country or region profile
|
||||
export const loadCountryView = async (code: CountryCode) => {
|
||||
const heritage = isHeritage(code) ? loadHeritage(code) : null;
|
||||
const series = await cached(`stats:country:${code}`, () =>
|
||||
loadSeries([
|
||||
...PROFILE_SERIES.map((id) => `${id}:${code}`),
|
||||
// Benchmarks for the "next to the Netherlands and the world" lines.
|
||||
...['NLD', 'WLD'].flatMap((c) => [
|
||||
`wb:NY.GDP.PCAP.PP.KD:${c}`,
|
||||
`wb:SP.DYN.LE00.IN:${c}`,
|
||||
`wb:FP.CPI.TOTL.ZG:${c}`
|
||||
]),
|
||||
// §8: Curaçao detail from CBS Curaçao and the international APIs (ids carry no code suffix).
|
||||
...(code === 'CUW' ? CW_SERIES : [])
|
||||
])
|
||||
);
|
||||
return { code, series, heritage: heritage ? await heritage : null };
|
||||
};
|
||||
|
||||
export const loadWorldOpinionsView = async () => ({ wo: await loadWorldOpinions() });
|
||||
export const loadHistoryView = () => loadHistoryOverview();
|
||||
export const loadHeritageView = async (code: HeritageCode) => ({
|
||||
heritage: await loadHeritage(code)
|
||||
});
|
||||
|
||||
export type EconomyViewData = Awaited<ReturnType<typeof loadEconomyView>>;
|
||||
export type EnergyViewData = Awaited<ReturnType<typeof loadEnergyView>>;
|
||||
export type GoudaViewData = Awaited<ReturnType<typeof loadGoudaView>>;
|
||||
export type CountriesViewData = Awaited<ReturnType<typeof loadCountriesView>>;
|
||||
export type ElectionsViewData = Awaited<ReturnType<typeof loadElectionsView>>;
|
||||
export type OpinionsViewData = Awaited<ReturnType<typeof loadOpinionsView>>;
|
||||
export type CountryViewData = Awaited<ReturnType<typeof loadCountryView>>;
|
||||
export type WorldOpinionsViewData = Awaited<ReturnType<typeof loadWorldOpinionsView>>;
|
||||
export type HistoryViewData = Awaited<ReturnType<typeof loadHistoryView>>;
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
WorldOpinionsData
|
||||
} from '#lib/world-opinions/data.js';
|
||||
|
||||
// /stats/opinions/world content from stats.wo_* (seeded from the former data.ts). Rebuilds
|
||||
// /stats/world/politics content from stats.wo_* (seeded from the former data.ts). Rebuilds
|
||||
// the exact shapes the components used, leaving optional keys out when the column is null.
|
||||
export function loadWorldOpinions(): Promise<WorldOpinionsData> {
|
||||
return cached('world-opinions', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
// Country chips for the multi-country charts on /stats/economy: the core seven are on by
|
||||
// Country chips for the multi-country charts on /stats/core/economy: the core seven are on by
|
||||
// default, the extra countries can be added, and the EU 27 comparison line can be toggled.
|
||||
import { COUNTRY } from './regions.js';
|
||||
import { FLAG } from './countries.js';
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
import type { EnergyData } from '#lib/server/energy.js';
|
||||
|
||||
// The live NED.nl grid and AGSI+ gas storage section of /stats/energy (formerly the
|
||||
// The live NED.nl grid and AGSI+ gas storage section of /stats/nld/energy (formerly the
|
||||
// /projects/energy tracker). Reads the ned-collector's `energy` schema, never writes.
|
||||
let { energy, attributions }: { energy: EnergyData; attributions: Record<string, string> } =
|
||||
$props();
|
||||
|
||||
155
src/lib/stats/MatrixMetrics.svelte
Normal file
155
src/lib/stats/MatrixMetrics.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
// Generic part of a /stats place × theme page: an at-a-glance table (latest value per place),
|
||||
// then one chart per metric that has data, grouped by section. Places without a series for a
|
||||
// metric get a "no data for …" note; metrics with no data at all are listed once at the end.
|
||||
import StatChart from './StatChart.svelte';
|
||||
import { barRankOption, lineOption, type Line } from './chart.js';
|
||||
import { compact, num, period } from './format.js';
|
||||
import { PLACE_BY_CODE, placeName } from './matrix.js';
|
||||
import type { MetricResult } from '#lib/server/stats-matrix.js';
|
||||
|
||||
let {
|
||||
metrics,
|
||||
codes,
|
||||
attributions
|
||||
}: { metrics: MetricResult[]; codes: string[]; attributions: Record<string, string> } = $props();
|
||||
|
||||
const shown = $derived(metrics.filter((m) => m.lines.length));
|
||||
const empty = $derived(metrics.filter((m) => !m.lines.length));
|
||||
const groups = $derived.by(() => {
|
||||
const out: { name: string; items: MetricResult[] }[] = [];
|
||||
for (const m of shown) {
|
||||
const name = m.metric.group ?? '';
|
||||
const g = out.find((x) => x.name === name);
|
||||
if (g) g.items.push(m);
|
||||
else out.push({ name, items: [m] });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const color = (code: string) => PLACE_BY_CODE.get(code)?.color ?? '#64748b';
|
||||
const short = (code: string) => PLACE_BY_CODE.get(code)?.short ?? code;
|
||||
const multi = $derived(codes.length > 1);
|
||||
const fmt = (v: number | undefined, m: MetricResult['metric']) => {
|
||||
if (v == null) return '–';
|
||||
if (Math.abs(v) >= 1e6) return compact(v);
|
||||
return num(v, m.decimals ?? 1);
|
||||
};
|
||||
const lastOf = (m: MetricResult, code: string) => {
|
||||
const l = m.lines.find((x) => x.code === code);
|
||||
const p = l?.points.at(-1);
|
||||
return p ? { value: p[1], period: period(p[0], l!.freq) } : null;
|
||||
};
|
||||
/** Best value among the places (for the at-a-glance highlight). */
|
||||
const best = (m: MetricResult) => {
|
||||
if (!multi || m.metric.lowerIsBetter === undefined) return null;
|
||||
const vals = m.lines
|
||||
.map((l) => ({ code: l.code, v: l.points.at(-1)?.[1] }))
|
||||
.filter((x) => x.v != null);
|
||||
if (vals.length < 2) return null;
|
||||
vals.sort((a, b) => (m.metric.lowerIsBetter ? a.v! - b.v! : b.v! - a.v!));
|
||||
return vals[0].code;
|
||||
};
|
||||
const lines = (m: MetricResult): Line[] =>
|
||||
m.lines.map((l) => ({
|
||||
name: placeName(l.code),
|
||||
points: l.points,
|
||||
color: color(l.code),
|
||||
kind: m.lines.length === 1 && l.points.length > 1 ? 'area' : 'line'
|
||||
}));
|
||||
const missingNote = (m: MetricResult) =>
|
||||
m.missing.length ? `No data for ${m.missing.map(placeName).join(', ')}.` : '';
|
||||
const note = (m: MetricResult) => [m.metric.note, missingNote(m)].filter(Boolean).join(' ');
|
||||
</script>
|
||||
|
||||
{#if shown.length && (multi || shown.length > 2)}
|
||||
<section class="glance" aria-label="At a glance">
|
||||
<div class="table-wrap">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Latest</th>
|
||||
{#each codes as c (c)}
|
||||
<th class="num"><span class="dot" style:background={color(c)}></span> {short(c)}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each shown as m (m.metric.key)}
|
||||
{@const b = best(m)}
|
||||
<tr>
|
||||
<td>{m.metric.title} <span class="unit">{m.metric.unit}</span></td>
|
||||
{#each codes as c (c)}
|
||||
{@const v = lastOf(m, c)}
|
||||
<td class="num" class:best={b === c}>
|
||||
{#if v}{fmt(v.value, m.metric)}<span class="when">{v.period}</span>{:else}<span
|
||||
class="na">–</span
|
||||
>{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#each groups as g (g.name)}
|
||||
{#if g.name}<h2 class="stats-section-title">{g.name}</h2>{/if}
|
||||
<div class="stats-grid">
|
||||
{#each g.items as m (m.metric.key)}
|
||||
<StatChart
|
||||
title={m.metric.title}
|
||||
subtitle={[m.metric.subtitle, m.metric.unit].filter(Boolean).join(' · ')}
|
||||
sources={m.lines.map((l) => l.sourceId)}
|
||||
{attributions}
|
||||
note={note(m)}
|
||||
height={m.metric.bars ? `${Math.max(140, m.lines.length * 34 + 30)}px` : '300px'}
|
||||
option={(t) =>
|
||||
m.metric.bars
|
||||
? barRankOption(
|
||||
t,
|
||||
m.lines.map((l) => ({ label: placeName(l.code), value: l.points.at(-1)![1] })),
|
||||
{ decimals: m.metric.decimals ?? 1, unit: m.metric.unit }
|
||||
)
|
||||
: lineOption(t, lines(m), {
|
||||
unit: m.metric.unit,
|
||||
decimals: m.metric.decimals ?? 1,
|
||||
zoom: m.lines.some((l) => l.points.length > 60)
|
||||
})}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if !shown.length}
|
||||
<p class="empty">No series in this theme for {codes.map(placeName).join(', ')} yet.</p>
|
||||
{:else if empty.length}
|
||||
<p class="stats-note">
|
||||
Not available for {codes.map(placeName).join(', ')}: {empty
|
||||
.map((m) => m.metric.title)
|
||||
.join(', ')}.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.unit {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.when {
|
||||
display: block;
|
||||
color: var(--site-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.best {
|
||||
font-weight: 650;
|
||||
color: #16a34a;
|
||||
}
|
||||
.na {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.glance th .dot {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
160
src/lib/stats/MatrixSelector.svelte
Normal file
160
src/lib/stats/MatrixSelector.svelte
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
// The sticky two-axis selector on every /stats page: place chips (one place, a group, or a
|
||||
// comparison in compare mode) and theme chips. Every chip is a plain link, so it works
|
||||
// without JavaScript; compare mode only changes where the place chips point.
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import GitCompare from 'lucide-svelte/icons/git-compare';
|
||||
import { parsePlaces, PLACES, statsHref, THEMES, toggled } from './matrix.js';
|
||||
|
||||
const sel = $derived(page.params.place ? parsePlaces(page.params.place) : null);
|
||||
const theme = $derived(page.params.theme ?? 'overview');
|
||||
let compareToggle = $state<boolean | null>(null);
|
||||
const compare = $derived(compareToggle ?? (sel ? sel.picked.length > 1 : false));
|
||||
const selected = (slug: string) => !!sel?.picked.some((p) => p.slug === slug);
|
||||
const placeHref = (slug: string) =>
|
||||
statsHref(compare && slug !== 'core' ? toggled(sel, slug) : slug, theme);
|
||||
const themeHref = (t: string) => statsHref(sel?.slug ?? 'core', t);
|
||||
const main = PLACES.filter((p) => p.main);
|
||||
const more = PLACES.filter((p) => !p.main);
|
||||
let bar: HTMLElement | undefined = $state();
|
||||
// Keep the active chips in view on narrow screens (the rows scroll sideways).
|
||||
$effect(() => {
|
||||
void page.url.pathname;
|
||||
for (const el of bar?.querySelectorAll<HTMLElement>('.chip.active') ?? []) {
|
||||
const row = el.parentElement!;
|
||||
const left = el.offsetLeft - row.offsetLeft;
|
||||
if (left < row.scrollLeft || left + el.offsetWidth > row.scrollLeft + row.clientWidth)
|
||||
row.scrollLeft = left - 16;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="matrix-bar" aria-label="Choose places and a theme" bind:this={bar}>
|
||||
<div class="row places" role="group" aria-label="Places">
|
||||
<button
|
||||
type="button"
|
||||
class="chip toggle"
|
||||
aria-pressed={compare}
|
||||
title="Compare several places"
|
||||
onclick={() => (compareToggle = !compare)}><GitCompare size={14} />compare</button
|
||||
>
|
||||
{#each main as p (p.slug)}
|
||||
<a
|
||||
class="chip"
|
||||
class:active={selected(p.slug)}
|
||||
href={resolve(placeHref(p.slug) as '/stats')}
|
||||
aria-current={selected(p.slug) && sel?.picked.length === 1 ? 'page' : undefined}
|
||||
style:--c={p.color}
|
||||
><span class="flag" aria-hidden="true">{p.flag}</span
|
||||
>{p.short}{#if compare && p.slug !== 'core'}<span class="mark" aria-hidden="true"
|
||||
>{selected(p.slug) ? '−' : '+'}</span
|
||||
>{/if}</a
|
||||
>
|
||||
{/each}
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
{#each more as p (p.slug)}
|
||||
<a
|
||||
class="chip"
|
||||
class:active={selected(p.slug)}
|
||||
href={resolve(placeHref(p.slug) as '/stats')}
|
||||
aria-current={selected(p.slug) && sel?.picked.length === 1 ? 'page' : undefined}
|
||||
style:--c={p.color}
|
||||
><span class="flag" aria-hidden="true">{p.flag}</span>{p.short}{#if compare}<span
|
||||
class="mark"
|
||||
aria-hidden="true">{selected(p.slug) ? '−' : '+'}</span
|
||||
>{/if}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<nav class="row themes" aria-label="Themes">
|
||||
{#each THEMES as t (t.slug)}
|
||||
<a
|
||||
class="chip theme"
|
||||
class:active={sel && theme === t.slug}
|
||||
href={resolve(themeHref(t.slug) as '/stats')}
|
||||
aria-current={sel && theme === t.slug ? 'page' : undefined}
|
||||
><TrackerIcon name={t.icon} class="size-5" />{t.name}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.matrix-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
margin: -0.5rem -1rem 0;
|
||||
padding: 0.5rem 1rem 0.55rem;
|
||||
background: color-mix(in srgb, var(--site-bg) 92%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
align-items: center;
|
||||
}
|
||||
.row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.28rem 0.65rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
background: var(--site-bg);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.chip:hover {
|
||||
color: var(--site-text);
|
||||
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
|
||||
}
|
||||
.chip.active {
|
||||
background: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.chip.theme {
|
||||
padding: 0.2rem 0.7rem 0.2rem 0.25rem;
|
||||
}
|
||||
.flag {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.mark {
|
||||
font-weight: 600;
|
||||
margin-left: 0.1rem;
|
||||
color: var(--c);
|
||||
}
|
||||
.chip.active .mark {
|
||||
color: inherit;
|
||||
}
|
||||
.toggle[aria-pressed='true'] {
|
||||
border-color: var(--site-text);
|
||||
color: var(--site-text);
|
||||
border-style: dashed;
|
||||
}
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 1.2rem;
|
||||
background: var(--site-border);
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
// §9: History, Politics and Culture sections for one place (country pages and /stats/gouda).
|
||||
// §9: History, Politics and Culture sections for one place (country pages and /stats/gouda/overview).
|
||||
// Everything comes from stats.hist_* / stats.unesco_site and the long-run series; every block
|
||||
// names its source and links to it.
|
||||
import { resolve } from '$app/paths';
|
||||
@@ -214,8 +214,8 @@
|
||||
min: 'dataMin'
|
||||
})}
|
||||
>
|
||||
<a class="inline-link" href={resolve('/stats/elections')}
|
||||
>all election results on /stats/elections →</a
|
||||
<a class="inline-link" href={resolve('/stats/nld/politics')}
|
||||
>all election results on /stats/nld/politics →</a
|
||||
>
|
||||
</StatChart>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Country profiles for /stats/countries and /stats/countries/[iso3]: which regions get a page,
|
||||
// Country profiles for /stats/core/overview and /stats/<place>/overview: which regions get a page,
|
||||
// and which normalised series (World Bank, IMF WEO) feed each indicator. IMF first where it
|
||||
// exists (it has forecasts); the World Bank fills in for places the IMF skips, like Curaçao.
|
||||
|
||||
@@ -47,7 +47,7 @@ export const COUNTRY_COLOR: Record<string, string> = {
|
||||
NLD: '#f97316',
|
||||
CUW: '#06b6d4',
|
||||
USA: '#3b82f6',
|
||||
KOR: '#e11d48',
|
||||
KOR: '#8b5cf6',
|
||||
CHN: '#eab308',
|
||||
DEU: '#64748b',
|
||||
FRA: '#6366f1',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// §8 Curaçao detail series (CBS Curaçao, Eurostat, ILOSTAT, OWID, UN SDG, UN Comtrade), all with
|
||||
// region CUW in stats.series. Loaded by /stats/countries/CUW next to the World Bank profile.
|
||||
// region CUW in stats.series. Loaded by /stats/cuw/overview next to the World Bank profile.
|
||||
|
||||
/** CBS Curaçao GDP by industry (ISIC 4 sections as published, mln ANG) with short labels. */
|
||||
export const CW_INDUSTRIES: Record<string, string> = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Country sets and series maps for /stats/economy. The core set (CAN, KOR, CHN, JPN, USA, NLD,
|
||||
// Country sets and series maps for /stats/core/economy. The core set (CAN, KOR, CHN, JPN, USA, NLD,
|
||||
// CUW) is the default on every multi-country chart; the extra countries can be added, and the
|
||||
// EU aggregate is kept as a comparison line. Every id points at a stored series; nothing here is
|
||||
// estimated, gaps stay gaps.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// EP-Online energy labels on /stats/energy and /stats/gouda (collector: scripts/stats/eponline.ts).
|
||||
// EP-Online energy labels on /stats/nld/energy and /stats/gouda/overview (collector: scripts/stats/eponline.ts).
|
||||
import type { ChartTheme, StatPoint } from './chart';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const HERITAGE = {
|
||||
NLD: {
|
||||
code: 'NLD',
|
||||
name: 'Netherlands',
|
||||
href: '/stats/countries/NLD',
|
||||
href: '/stats/nld/overview',
|
||||
color: '#f97316',
|
||||
series: [...longRun('NLD'), 'owid:hist:population:WLD', 'owid:hist:life-expectancy:WLD'],
|
||||
seriesLike: ['cbs:religion:37944:%', 'cbs:religion:82904:%', 'pew:religion:%:NLD'],
|
||||
@@ -43,7 +43,7 @@ export const HERITAGE = {
|
||||
GM0513: {
|
||||
code: 'GM0513',
|
||||
name: 'Gouda',
|
||||
href: '/stats/gouda',
|
||||
href: '/stats/gouda/overview',
|
||||
color: '#eab308',
|
||||
series: ['hist:pop:GM0513', 'cbs:pop:jan1:GM0513', 'owid:hist:population:NLD'],
|
||||
seriesLike: ['cbs:religion:1930:%'],
|
||||
@@ -54,7 +54,7 @@ export const HERITAGE = {
|
||||
CUW: {
|
||||
code: 'CUW',
|
||||
name: 'Curaçao',
|
||||
href: '/stats/countries/CUW',
|
||||
href: '/stats/cuw/overview',
|
||||
color: '#0ea5e9',
|
||||
series: [
|
||||
'owid:cw:population',
|
||||
@@ -70,7 +70,7 @@ export const HERITAGE = {
|
||||
USA: {
|
||||
code: 'USA',
|
||||
name: 'United States',
|
||||
href: '/stats/countries/USA',
|
||||
href: '/stats/usa/overview',
|
||||
color: '#3b82f6',
|
||||
series: longRun('USA'),
|
||||
seriesLike: ['pew:religion:%:USA'],
|
||||
@@ -80,7 +80,7 @@ export const HERITAGE = {
|
||||
KOR: {
|
||||
code: 'KOR',
|
||||
name: 'South Korea',
|
||||
href: '/stats/countries/KOR',
|
||||
href: '/stats/kor/overview',
|
||||
color: '#e11d48',
|
||||
series: longRun('KOR'),
|
||||
seriesLike: ['pew:religion:%:KOR'],
|
||||
|
||||
869
src/lib/stats/matrix.ts
Normal file
869
src/lib/stats/matrix.ts
Normal file
@@ -0,0 +1,869 @@
|
||||
// The /stats matrix: every page is a PLACE × THEME combination, /stats/<place>/<theme>.
|
||||
// A place is one city, country or region (`nld`, `gouda`, `eu27`), the core group (`core`), or a
|
||||
// comma list compared side by side (`nld,kor`). A theme renders the generic comparison metrics
|
||||
// below (only the series that exist for the selected places), plus the rich per-place views from
|
||||
// #lib/stats/views where one fits (the former /stats/<section> pages). Shared by server and client.
|
||||
import { COUNTRY_COLOR, FLAG } from './countries.js';
|
||||
|
||||
export type StatsCommon = {
|
||||
attributions: Record<string, string>;
|
||||
sources?: Record<
|
||||
string,
|
||||
{ id: string; name: string; attribution: string; licence: string; baseUrl: string }
|
||||
>;
|
||||
};
|
||||
|
||||
export type PlaceKind = 'city' | 'country' | 'region' | 'group';
|
||||
export type Place = {
|
||||
slug: string;
|
||||
/** Region code in stats.series (ISO3, EU27, WLD, GM0513…). */
|
||||
code: string;
|
||||
name: string;
|
||||
short: string;
|
||||
kind: PlaceKind;
|
||||
flag: string;
|
||||
color: string;
|
||||
/** Shown in the first chip row (the rest sit behind "more"). */
|
||||
main?: boolean;
|
||||
/** Group members (only for kind 'group'). */
|
||||
members?: string[];
|
||||
/** Eurostat geo, BIS / ISO2 and CBS codes, when the place has them. */
|
||||
eurostat?: string;
|
||||
iso2?: string;
|
||||
cbs?: string;
|
||||
/** Euro-area member: ECB policy rate. */
|
||||
euro?: boolean;
|
||||
};
|
||||
|
||||
const country = (
|
||||
code: string,
|
||||
name: string,
|
||||
short: string,
|
||||
iso2: string,
|
||||
o: Partial<Place> = {}
|
||||
): Place => ({
|
||||
slug: code.toLowerCase(),
|
||||
code,
|
||||
name,
|
||||
short,
|
||||
kind: 'country',
|
||||
flag: FLAG[code] ?? '🏳️',
|
||||
color: COUNTRY_COLOR[code] ?? '#64748b',
|
||||
iso2,
|
||||
...o
|
||||
});
|
||||
|
||||
export const CORE = ['NLD', 'CUW', 'USA', 'KOR', 'CHN', 'JPN', 'CAN', 'EU27'];
|
||||
|
||||
export const PLACES: Place[] = [
|
||||
{
|
||||
slug: 'gouda',
|
||||
code: 'GM0513',
|
||||
name: 'Gouda',
|
||||
short: 'Gouda',
|
||||
kind: 'city',
|
||||
flag: '🧀',
|
||||
color: '#eab308',
|
||||
main: true,
|
||||
cbs: 'GM0513'
|
||||
},
|
||||
country('NLD', 'Netherlands', 'NL', 'NL', {
|
||||
main: true,
|
||||
eurostat: 'NL',
|
||||
cbs: 'NL01',
|
||||
euro: true
|
||||
}),
|
||||
country('CUW', 'Curaçao', 'CW', 'CW', { main: true }),
|
||||
country('USA', 'United States', 'US', 'US', { main: true }),
|
||||
country('KOR', 'South Korea', 'KR', 'KR', { main: true }),
|
||||
country('CHN', 'China', 'CN', 'CN', { main: true }),
|
||||
country('JPN', 'Japan', 'JP', 'JP', { main: true }),
|
||||
country('CAN', 'Canada', 'CA', 'CA', { main: true }),
|
||||
{
|
||||
slug: 'eu27',
|
||||
code: 'EU27',
|
||||
name: 'European Union',
|
||||
short: 'EU27',
|
||||
kind: 'region',
|
||||
flag: '🇪🇺',
|
||||
color: '#1d4ed8',
|
||||
main: true,
|
||||
eurostat: 'EU27_2020'
|
||||
},
|
||||
{
|
||||
slug: 'world',
|
||||
code: 'WLD',
|
||||
name: 'World',
|
||||
short: 'World',
|
||||
kind: 'region',
|
||||
flag: '🌍',
|
||||
color: '#94a3b8',
|
||||
main: true
|
||||
},
|
||||
{
|
||||
slug: 'core',
|
||||
code: 'CORE',
|
||||
name: 'Core 7 + EU',
|
||||
short: 'Core 7',
|
||||
kind: 'group',
|
||||
flag: '🧭',
|
||||
color: '#64748b',
|
||||
main: true,
|
||||
members: CORE
|
||||
},
|
||||
country('DEU', 'Germany', 'DE', 'DE', { eurostat: 'DE', euro: true }),
|
||||
country('FRA', 'France', 'FR', 'FR', { eurostat: 'FR', euro: true }),
|
||||
country('BEL', 'Belgium', 'BE', 'BE', { eurostat: 'BE', euro: true }),
|
||||
country('GBR', 'United Kingdom', 'UK', 'GB'),
|
||||
country('IND', 'India', 'IN', 'IN'),
|
||||
country('BRA', 'Brazil', 'BR', 'BR'),
|
||||
country('RUS', 'Russia', 'RU', 'RU'),
|
||||
{
|
||||
slug: 'ea',
|
||||
code: 'EA',
|
||||
name: 'Euro area',
|
||||
short: 'Euro area',
|
||||
kind: 'region',
|
||||
flag: '💶',
|
||||
color: '#0ea5e9',
|
||||
eurostat: 'EA20',
|
||||
iso2: 'XM'
|
||||
}
|
||||
];
|
||||
export const PLACE_BY_SLUG = new Map(PLACES.map((p) => [p.slug, p]));
|
||||
export const PLACE_BY_CODE = new Map(PLACES.map((p) => [p.code, p]));
|
||||
export const placeName = (code: string) => PLACE_BY_CODE.get(code)?.name ?? code;
|
||||
|
||||
export type ThemeSlug =
|
||||
| 'overview'
|
||||
| 'economy'
|
||||
| 'jobs'
|
||||
| 'energy'
|
||||
| 'politics'
|
||||
| 'budget'
|
||||
| 'culture'
|
||||
| 'history'
|
||||
| 'population';
|
||||
|
||||
export type Selection = {
|
||||
/** Canonical URL segment (`nld`, `core`, `nld,kor`). */
|
||||
slug: string;
|
||||
/** The chosen places as picked (a group stays one entry). */
|
||||
picked: Place[];
|
||||
/** Region codes to compare (groups expanded). */
|
||||
codes: string[];
|
||||
/** The one place when a single non-group place is picked. */
|
||||
single: Place | null;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const MAX_COMPARE = 10;
|
||||
|
||||
/** Parses the place segment; null when unknown. `canonical` differs when the URL should redirect. */
|
||||
export function parsePlaces(segment: string): Selection | null {
|
||||
const parts = segment
|
||||
.toLowerCase()
|
||||
.split(/[,+\s]+/)
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return null;
|
||||
const picked: Place[] = [];
|
||||
for (const s of parts) {
|
||||
const p = PLACE_BY_SLUG.get(s);
|
||||
if (!p) return null;
|
||||
if (!picked.includes(p)) picked.push(p);
|
||||
}
|
||||
if (picked.length > MAX_COMPARE) picked.length = MAX_COMPARE;
|
||||
// Canonical order: the chip order, so nld,kor and kor,nld are one page.
|
||||
picked.sort((a, b) => PLACES.indexOf(a) - PLACES.indexOf(b));
|
||||
const codes = [...new Set(picked.flatMap((p) => p.members ?? [p.code]))];
|
||||
const single = picked.length === 1 && picked[0].kind !== 'group' ? picked[0] : null;
|
||||
return {
|
||||
slug: picked.map((p) => p.slug).join(','),
|
||||
picked,
|
||||
codes,
|
||||
single,
|
||||
label: picked.map((p) => (picked.length > 1 ? p.short : p.name)).join(' · ')
|
||||
};
|
||||
}
|
||||
|
||||
/** Toggles a place in a comparison (for the chips in compare mode). */
|
||||
export function toggled(sel: Selection | null, slug: string): string {
|
||||
const cur = sel ? sel.picked.map((p) => p.slug) : [];
|
||||
const next = cur.includes(slug) ? cur.filter((s) => s !== slug) : [...cur, slug];
|
||||
return (next.length ? parsePlaces(next.join(',')) : parsePlaces(slug))!.slug;
|
||||
}
|
||||
|
||||
/** Template codes: {c} region code, {e} Eurostat geo, {b} BIS/ISO2, {g} CBS region. */
|
||||
function expand(tpl: string, p: Place): string | null {
|
||||
let out = tpl;
|
||||
const sub: Record<string, string | undefined> = {
|
||||
'{c}': p.code,
|
||||
'{e}': p.eurostat,
|
||||
'{b}': p.iso2,
|
||||
'{g}': p.cbs
|
||||
};
|
||||
for (const [k, v] of Object.entries(sub)) {
|
||||
if (!out.includes(k)) continue;
|
||||
if (!v) return null;
|
||||
out = out.replaceAll(k, v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type Metric = {
|
||||
key: string;
|
||||
title: string;
|
||||
/** Short unit for the axis and the at-a-glance table. */
|
||||
unit: string;
|
||||
subtitle?: string;
|
||||
decimals?: number;
|
||||
/** Candidate series id templates, best first; the first one with data wins per place. */
|
||||
ids: string[];
|
||||
/** Extra candidates for one region code, tried before `ids`. */
|
||||
byCode?: Record<string, string[]>;
|
||||
/** Only observations on or after this date. */
|
||||
from?: string;
|
||||
/** Average daily or weekly data per month. */
|
||||
resample?: 'M';
|
||||
/** Section heading inside the theme. */
|
||||
group?: string;
|
||||
/** Lower is better (for the at-a-glance colouring), e.g. unemployment. */
|
||||
lowerIsBetter?: boolean;
|
||||
/** Render as a latest-value bar ranking instead of lines (cross-section data). */
|
||||
bars?: boolean;
|
||||
note?: string;
|
||||
/** Sources the note or chart leans on beyond the series' own (for attribution). */
|
||||
wide?: boolean;
|
||||
};
|
||||
|
||||
export function candidates(m: Metric, p: Place): string[] {
|
||||
const ids = [...(m.byCode?.[p.code] ?? []), ...m.ids];
|
||||
return [...new Set(ids.map((t) => expand(t, p)).filter((x): x is string => !!x))];
|
||||
}
|
||||
|
||||
export type Theme = {
|
||||
slug: ThemeSlug;
|
||||
name: string;
|
||||
icon: string;
|
||||
blurb: string;
|
||||
metrics: Metric[];
|
||||
};
|
||||
|
||||
const UNEMP: Metric = {
|
||||
key: 'unemp',
|
||||
title: 'Unemployment rate',
|
||||
unit: '%',
|
||||
ids: ['imf:LUR:{c}', 'wb:SL.UEM.TOTL.ZS:{c}'],
|
||||
from: '1990-01-01',
|
||||
lowerIsBetter: true
|
||||
};
|
||||
|
||||
export const THEMES: Theme[] = [
|
||||
{
|
||||
slug: 'overview',
|
||||
name: 'Overview',
|
||||
icon: 'stats-overview',
|
||||
blurb: 'The headline numbers: people, income, growth, prices, jobs, debt and life expectancy.',
|
||||
metrics: [
|
||||
{
|
||||
key: 'pop',
|
||||
title: 'Population',
|
||||
unit: 'people',
|
||||
decimals: 0,
|
||||
ids: ['wb:SP.POP.TOTL:{c}', 'cbs:pop:jan1:{g}']
|
||||
},
|
||||
{
|
||||
key: 'gdppc',
|
||||
title: 'GDP per person, PPP',
|
||||
unit: 'int. $',
|
||||
decimals: 0,
|
||||
ids: ['wb:NY.GDP.PCAP.PP.KD:{c}', 'imf:PPPPC:{c}'],
|
||||
from: '1990-01-01'
|
||||
},
|
||||
{
|
||||
key: 'growth',
|
||||
title: 'Real GDP growth',
|
||||
unit: '%',
|
||||
ids: ['imf:NGDP_RPCH:{c}', 'wb:NY.GDP.MKTP.KD.ZG:{c}'],
|
||||
from: '1990-01-01'
|
||||
},
|
||||
{
|
||||
key: 'infl',
|
||||
title: 'Inflation',
|
||||
unit: '%',
|
||||
ids: ['imf:PCPIPCH:{c}', 'wb:FP.CPI.TOTL.ZG:{c}'],
|
||||
from: '1990-01-01',
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{ ...UNEMP },
|
||||
{
|
||||
key: 'debt',
|
||||
title: 'Government debt',
|
||||
unit: '% of GDP',
|
||||
ids: ['imf:GGXWDG_NGDP:{c}', 'wb:GC.DOD.TOTL.GD.ZS:{c}'],
|
||||
from: '1990-01-01',
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{
|
||||
key: 'life',
|
||||
title: 'Life expectancy',
|
||||
unit: 'years',
|
||||
ids: ['wb:SP.DYN.LE00.IN:{c}'],
|
||||
from: '1960-01-01'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'economy',
|
||||
name: 'Economy',
|
||||
icon: 'stats-economy',
|
||||
blurb: 'Growth, income, prices, interest rates, house prices and trade.',
|
||||
metrics: [
|
||||
{
|
||||
key: 'growth',
|
||||
group: 'Growth and income',
|
||||
title: 'Real GDP growth',
|
||||
unit: '%',
|
||||
ids: ['imf:NGDP_RPCH:{c}', 'wb:NY.GDP.MKTP.KD.ZG:{c}'],
|
||||
from: '1990-01-01'
|
||||
},
|
||||
{
|
||||
key: 'gdppc',
|
||||
group: 'Growth and income',
|
||||
title: 'GDP per person, PPP',
|
||||
unit: 'int. $',
|
||||
decimals: 0,
|
||||
ids: ['imf:PPPPC:{c}', 'wb:NY.GDP.PCAP.PP.KD:{c}'],
|
||||
from: '1990-01-01'
|
||||
},
|
||||
{
|
||||
key: 'gdpusd',
|
||||
group: 'Growth and income',
|
||||
title: 'GDP per person, US$',
|
||||
unit: 'US$',
|
||||
decimals: 0,
|
||||
ids: ['imf:NGDPDPC:{c}', 'wb:NY.GNP.PCAP.CD:{c}'],
|
||||
from: '1990-01-01'
|
||||
},
|
||||
{
|
||||
key: 'infl',
|
||||
group: 'Prices and rates',
|
||||
title: 'Inflation, yearly',
|
||||
unit: '%',
|
||||
ids: ['imf:PCPIPCH:{c}', 'wb:FP.CPI.TOTL.ZG:{c}'],
|
||||
from: '1990-01-01',
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{
|
||||
key: 'hicp',
|
||||
group: 'Prices and rates',
|
||||
title: 'Inflation, monthly (HICP)',
|
||||
subtitle: 'annual rate of change',
|
||||
unit: '%',
|
||||
ids: ['eurostat:prc_hicp_minr:RCH_A.TOTAL.{e}', 'cbs:cpi:yoy'],
|
||||
byCode: { NLD: ['eurostat:prc_hicp_minr:RCH_A.TOTAL.NL'] },
|
||||
from: '2000-01-01',
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{
|
||||
key: 'rate',
|
||||
group: 'Prices and rates',
|
||||
title: 'Central bank policy rate',
|
||||
unit: '%',
|
||||
ids: ['bis:M:{b}'],
|
||||
byCode: {
|
||||
NLD: ['bis:M:XM'],
|
||||
DEU: ['bis:M:XM'],
|
||||
FRA: ['bis:M:XM'],
|
||||
BEL: ['bis:M:XM'],
|
||||
EU27: ['bis:M:XM']
|
||||
},
|
||||
from: '1990-01-01'
|
||||
},
|
||||
{
|
||||
key: 'irlt',
|
||||
group: 'Prices and rates',
|
||||
title: '10-year government bond yield',
|
||||
unit: '%',
|
||||
ids: ['oecd:irlt:{c}'],
|
||||
from: '1990-01-01'
|
||||
},
|
||||
{
|
||||
key: 'hpi',
|
||||
group: 'Housing and trade',
|
||||
title: 'House prices, yearly change',
|
||||
unit: '%',
|
||||
ids: ['eurostat:prc_hpi_q:TOTAL.RCH_A.{e}'],
|
||||
from: '2005-01-01'
|
||||
},
|
||||
{
|
||||
key: 'house',
|
||||
group: 'Housing and trade',
|
||||
title: 'Average sale price of existing homes',
|
||||
unit: '€',
|
||||
decimals: 0,
|
||||
ids: ['cbs:house:avgprice:{g}']
|
||||
},
|
||||
{
|
||||
key: 'trade',
|
||||
group: 'Housing and trade',
|
||||
title: 'Trade (exports + imports)',
|
||||
unit: '% of GDP',
|
||||
ids: ['wb:NE.TRD.GNFS.ZS:{c}'],
|
||||
from: '1970-01-01'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'jobs',
|
||||
name: 'Jobs & cost of living',
|
||||
icon: 'stats-jobs',
|
||||
blurb:
|
||||
'For recent grads: how easy it is to find work, how good the jobs are, what they pay and what life costs.',
|
||||
metrics: [{ ...UNEMP, group: 'Unemployment', byCode: { USA: ['fred:UNRATE'] } }]
|
||||
},
|
||||
{
|
||||
slug: 'energy',
|
||||
name: 'Energy',
|
||||
icon: 'stats-energy',
|
||||
blurb:
|
||||
'The energy mix, electricity and gas prices, import dependence and the switch to low carbon.',
|
||||
metrics: [
|
||||
{
|
||||
key: 'renew',
|
||||
group: 'Mix',
|
||||
title: 'Renewables, share of primary energy',
|
||||
unit: '%',
|
||||
ids: ['owid:energy:renewables_share_energy:{c}'],
|
||||
from: '1965-01-01'
|
||||
},
|
||||
{
|
||||
key: 'lowc',
|
||||
group: 'Mix',
|
||||
title: 'Low-carbon share of electricity',
|
||||
unit: '%',
|
||||
ids: ['owid:energy:low_carbon_share_elec:{c}'],
|
||||
from: '1985-01-01'
|
||||
},
|
||||
{
|
||||
key: 'fossil',
|
||||
group: 'Mix',
|
||||
title: 'Fossil fuels, share of primary energy',
|
||||
unit: '%',
|
||||
ids: ['owid:energy:fossil_share_energy:{c}'],
|
||||
from: '1965-01-01',
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{
|
||||
key: 'carbon',
|
||||
group: 'Mix',
|
||||
title: 'Carbon intensity of electricity',
|
||||
unit: 'gCO₂/kWh',
|
||||
decimals: 0,
|
||||
ids: ['owid:energy:carbon_intensity_elec:{c}'],
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{
|
||||
key: 'percap',
|
||||
group: 'Use',
|
||||
title: 'Primary energy per person',
|
||||
unit: 'kWh',
|
||||
decimals: 0,
|
||||
ids: ['owid:energy:energy_per_capita:{c}'],
|
||||
from: '1965-01-01'
|
||||
},
|
||||
{
|
||||
key: 'imports',
|
||||
group: 'Use',
|
||||
title: 'Energy import dependency',
|
||||
unit: '%',
|
||||
ids: ['eurostat:nrg_ind_id:TOTAL.PC.{e}', 'wb:EG.IMP.CONS.ZS:{c}'],
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{
|
||||
key: 'elec',
|
||||
group: 'Household prices',
|
||||
title: 'Household electricity price, incl. taxes',
|
||||
subtitle: '2,500–4,999 kWh/year',
|
||||
unit: '€/kWh',
|
||||
decimals: 3,
|
||||
ids: ['eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.I_TAX.EUR.{e}'],
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{
|
||||
key: 'gas',
|
||||
group: 'Household prices',
|
||||
title: 'Household gas price, incl. taxes',
|
||||
subtitle: '20–199 GJ/year',
|
||||
unit: '€/kWh',
|
||||
decimals: 3,
|
||||
ids: ['eurostat:nrg_pc_202:G3000.GJ20-199.KWH.I_TAX.EUR.{e}'],
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{
|
||||
key: 'solar',
|
||||
group: 'Local',
|
||||
title: 'Solar panels on homes',
|
||||
unit: 'kWp',
|
||||
decimals: 0,
|
||||
ids: ['cbs:solar:homes:kwp:{g}']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'politics',
|
||||
name: 'Politics',
|
||||
icon: 'stats-elections',
|
||||
blurb: 'Elections, polls, approval, party positions and how the world sees politics.',
|
||||
metrics: [
|
||||
{
|
||||
key: 'turnout',
|
||||
title: 'Turnout',
|
||||
unit: '%',
|
||||
ids: ['wikidata:turnout:uspres:{c}', 'wikidata:turnout:krpres:{c}']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'budget',
|
||||
name: 'Public finance',
|
||||
icon: 'stats-budget',
|
||||
blurb: 'Government debt, deficits, revenue and what governments spend on.',
|
||||
metrics: [
|
||||
{
|
||||
key: 'debt',
|
||||
group: 'Debt and balance',
|
||||
title: 'Government gross debt',
|
||||
unit: '% of GDP',
|
||||
ids: [
|
||||
'imf:GGXWDG_NGDP:{c}',
|
||||
'eurostat:gov_10dd_edpt1:PC_GDP.S13.GD.{e}',
|
||||
'wb:GC.DOD.TOTL.GD.ZS:{c}'
|
||||
],
|
||||
from: '1990-01-01',
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{
|
||||
key: 'balance',
|
||||
group: 'Debt and balance',
|
||||
title: 'Government net lending / borrowing',
|
||||
unit: '% of GDP',
|
||||
ids: ['imf:GGXCNL_NGDP:{c}', 'eurostat:gov_10dd_edpt1:PC_GDP.S13.B9.{e}'],
|
||||
from: '1990-01-01'
|
||||
},
|
||||
{
|
||||
key: 'rev',
|
||||
group: 'Size of government',
|
||||
title: 'Government revenue',
|
||||
unit: '% of GDP',
|
||||
ids: [
|
||||
'imf:GGR_G01_GDP_PT:{c}',
|
||||
'eurostat:gov_10a_main:PC_GDP.S13.TR.{e}',
|
||||
'wb:GC.REV.XGRT.GD.ZS:{c}'
|
||||
],
|
||||
from: '1990-01-01'
|
||||
},
|
||||
{
|
||||
key: 'exp',
|
||||
group: 'Size of government',
|
||||
title: 'Government expenditure',
|
||||
unit: '% of GDP',
|
||||
ids: [
|
||||
'imf:G_X_G01_GDP_PT:{c}',
|
||||
'eurostat:gov_10a_main:PC_GDP.S13.TE.{e}',
|
||||
'wb:GC.XPN.TOTL.GD.ZS:{c}'
|
||||
],
|
||||
from: '1990-01-01'
|
||||
},
|
||||
{
|
||||
key: 'health',
|
||||
group: 'Spending by function (COFOG)',
|
||||
title: 'Health spending',
|
||||
unit: '% of GDP',
|
||||
ids: ['eurostat:gov_10a_exp:PC_GDP.S13.GF07.TE.{e}']
|
||||
},
|
||||
{
|
||||
key: 'edu',
|
||||
group: 'Spending by function (COFOG)',
|
||||
title: 'Education spending',
|
||||
unit: '% of GDP',
|
||||
ids: ['eurostat:gov_10a_exp:PC_GDP.S13.GF09.TE.{e}']
|
||||
},
|
||||
{
|
||||
key: 'social',
|
||||
group: 'Spending by function (COFOG)',
|
||||
title: 'Social protection spending',
|
||||
unit: '% of GDP',
|
||||
ids: ['eurostat:gov_10a_exp:PC_GDP.S13.GF10.TE.{e}']
|
||||
},
|
||||
{
|
||||
key: 'defence',
|
||||
group: 'Spending by function (COFOG)',
|
||||
title: 'Defence spending',
|
||||
unit: '% of GDP',
|
||||
ids: ['eurostat:gov_10a_exp:PC_GDP.S13.GF02.TE.{e}']
|
||||
},
|
||||
{
|
||||
key: 'levies',
|
||||
group: 'Local',
|
||||
title: 'Municipal levies per inhabitant',
|
||||
unit: '€',
|
||||
decimals: 0,
|
||||
ids: ['cbs:levy:total:{g}'],
|
||||
lowerIsBetter: true
|
||||
},
|
||||
{
|
||||
key: 'ozb',
|
||||
group: 'Local',
|
||||
title: 'Property tax (OZB) per inhabitant',
|
||||
unit: '€',
|
||||
decimals: 0,
|
||||
ids: ['cbs:levy:ozb:{g}'],
|
||||
lowerIsBetter: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'culture',
|
||||
name: 'Culture & society',
|
||||
icon: 'stats-culture',
|
||||
blurb: 'Religion, values, happiness and tourism.',
|
||||
metrics: [
|
||||
{
|
||||
key: 'unaff',
|
||||
group: 'Religion',
|
||||
title: 'Religiously unaffiliated',
|
||||
unit: '% of population',
|
||||
ids: ['pew:religion:religiously_unaffiliated:{c}'],
|
||||
bars: true
|
||||
},
|
||||
{
|
||||
key: 'christ',
|
||||
group: 'Religion',
|
||||
title: 'Christians',
|
||||
unit: '% of population',
|
||||
ids: ['pew:religion:christians:{c}'],
|
||||
bars: true
|
||||
},
|
||||
{
|
||||
key: 'muslim',
|
||||
group: 'Religion',
|
||||
title: 'Muslims',
|
||||
unit: '% of population',
|
||||
ids: ['pew:religion:muslims:{c}'],
|
||||
bars: true
|
||||
},
|
||||
{
|
||||
key: 'buddh',
|
||||
group: 'Religion',
|
||||
title: 'Buddhists',
|
||||
unit: '% of population',
|
||||
ids: ['pew:religion:buddhists:{c}'],
|
||||
bars: true
|
||||
},
|
||||
{
|
||||
key: 'arrivals',
|
||||
group: 'Tourism',
|
||||
title: 'International tourist arrivals',
|
||||
unit: 'arrivals',
|
||||
decimals: 0,
|
||||
ids: ['wb:ST.INT.ARVL:{c}']
|
||||
},
|
||||
{
|
||||
key: 'receipts',
|
||||
group: 'Tourism',
|
||||
title: 'International tourism receipts',
|
||||
unit: 'US$',
|
||||
decimals: 0,
|
||||
ids: ['wb:ST.INT.RCPT.CD:{c}']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'history',
|
||||
name: 'History',
|
||||
icon: 'stats-history',
|
||||
blurb: 'The long run: population, income, life expectancy and cities over centuries.',
|
||||
metrics: [
|
||||
{
|
||||
key: 'pop',
|
||||
title: 'Population, long run',
|
||||
unit: 'people',
|
||||
decimals: 0,
|
||||
ids: ['owid:hist:population:{c}', 'hist:pop:{g}']
|
||||
},
|
||||
{
|
||||
key: 'maddison',
|
||||
title: 'GDP per person (Maddison)',
|
||||
unit: '2011 int. $',
|
||||
decimals: 0,
|
||||
ids: ['owid:maddison:{c}']
|
||||
},
|
||||
{
|
||||
key: 'life',
|
||||
title: 'Life expectancy, long run',
|
||||
unit: 'years',
|
||||
ids: ['owid:hist:life-expectancy:{c}']
|
||||
},
|
||||
{
|
||||
key: 'urban',
|
||||
title: 'Share living in cities',
|
||||
unit: '%',
|
||||
ids: ['owid:hist:urban-long:{c}', 'owid:hist:urban:{c}']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'population',
|
||||
name: 'Population & migration',
|
||||
icon: 'stats-population',
|
||||
blurb: 'How many people, how old, how many children, and who moves in and out.',
|
||||
metrics: [
|
||||
{
|
||||
key: 'pop',
|
||||
group: 'People',
|
||||
title: 'Population',
|
||||
unit: 'people',
|
||||
decimals: 0,
|
||||
ids: ['wb:SP.POP.TOTL:{c}', 'cbs:pop:jan1:{g}', 'eurostat:demo_pjan:NR.TOTAL.T.{e}'],
|
||||
from: '1960-01-01'
|
||||
},
|
||||
{
|
||||
key: 'popm',
|
||||
group: 'People',
|
||||
title: 'Population, monthly',
|
||||
unit: 'people',
|
||||
decimals: 0,
|
||||
ids: ['cbs:pop:monthly:{g}']
|
||||
},
|
||||
{
|
||||
key: 'old',
|
||||
group: 'People',
|
||||
title: 'Population aged 65+',
|
||||
unit: '%',
|
||||
ids: ['wb:SP.POP.65UP.TO.ZS:{c}'],
|
||||
from: '1960-01-01'
|
||||
},
|
||||
{
|
||||
key: 'fert',
|
||||
group: 'People',
|
||||
title: 'Fertility rate',
|
||||
unit: 'births per woman',
|
||||
decimals: 2,
|
||||
ids: ['wb:SP.DYN.TFRT.IN:{c}'],
|
||||
from: '1960-01-01'
|
||||
},
|
||||
{
|
||||
key: 'life',
|
||||
group: 'People',
|
||||
title: 'Life expectancy at birth',
|
||||
unit: 'years',
|
||||
ids: ['wb:SP.DYN.LE00.IN:{c}'],
|
||||
from: '1960-01-01'
|
||||
},
|
||||
{
|
||||
key: 'netm',
|
||||
group: 'Migration',
|
||||
title: 'Net migration',
|
||||
unit: 'people',
|
||||
decimals: 0,
|
||||
ids: ['wb:SM.POP.NETM:{c}'],
|
||||
from: '1960-01-01'
|
||||
},
|
||||
{
|
||||
key: 'imm',
|
||||
group: 'Migration',
|
||||
title: 'Immigration',
|
||||
unit: 'people',
|
||||
decimals: 0,
|
||||
ids: ['eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.{e}', 'cbs:pop:immigration:{g}']
|
||||
},
|
||||
{
|
||||
key: 'emi',
|
||||
group: 'Migration',
|
||||
title: 'Emigration',
|
||||
unit: 'people',
|
||||
decimals: 0,
|
||||
ids: ['cbs:pop:emigration:{g}']
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
export const THEME_BY_SLUG = new Map(THEMES.map((t) => [t.slug, t]));
|
||||
|
||||
/** Rich views (the former section pages) a combination shows next to the generic metrics. */
|
||||
export type ViewSpec =
|
||||
| { kind: 'economy' }
|
||||
| { kind: 'energy' }
|
||||
| { kind: 'gouda' }
|
||||
| { kind: 'countries' }
|
||||
| { kind: 'country'; code: string }
|
||||
| { kind: 'elections'; focus: 'all' | 'nld' | 'gouda' | 'usa' }
|
||||
| { kind: 'opinions' }
|
||||
| { kind: 'worldOpinions' }
|
||||
| { kind: 'history' }
|
||||
| { kind: 'heritage'; code: string };
|
||||
|
||||
/** Places with a country / region profile page. */
|
||||
export const PROFILE_CODES = [
|
||||
'NLD',
|
||||
'CUW',
|
||||
'USA',
|
||||
'KOR',
|
||||
'CHN',
|
||||
'DEU',
|
||||
'FRA',
|
||||
'BEL',
|
||||
'GBR',
|
||||
'JPN',
|
||||
'CAN',
|
||||
'IND',
|
||||
'BRA',
|
||||
'RUS',
|
||||
'EU27',
|
||||
'WLD'
|
||||
];
|
||||
const HERITAGE_CODES = ['NLD', 'GM0513', 'CUW', 'USA', 'KOR'];
|
||||
|
||||
export function viewsFor(sel: Selection, theme: ThemeSlug): ViewSpec[] {
|
||||
const one = sel.single?.code;
|
||||
const group = sel.picked.length === 1 && sel.picked[0].kind === 'group';
|
||||
switch (theme) {
|
||||
case 'overview':
|
||||
if (one === 'GM0513') return [{ kind: 'gouda' }];
|
||||
if (one && PROFILE_CODES.includes(one)) return [{ kind: 'country', code: one }];
|
||||
if (group) return [{ kind: 'countries' }];
|
||||
return [];
|
||||
case 'economy':
|
||||
return group ? [{ kind: 'economy' }] : [];
|
||||
case 'energy':
|
||||
return one === 'NLD' ? [{ kind: 'energy' }] : [];
|
||||
case 'politics':
|
||||
if (one === 'GM0513') return [{ kind: 'elections', focus: 'gouda' }];
|
||||
if (one === 'NLD') return [{ kind: 'elections', focus: 'nld' }, { kind: 'opinions' }];
|
||||
if (one === 'USA') return [{ kind: 'elections', focus: 'usa' }, { kind: 'opinions' }];
|
||||
if (one === 'KOR' || one === 'EU27') return [{ kind: 'opinions' }];
|
||||
if (one === 'WLD') return [{ kind: 'worldOpinions' }];
|
||||
if (group) return [{ kind: 'elections', focus: 'all' }, { kind: 'opinions' }];
|
||||
return [];
|
||||
case 'history':
|
||||
if (one === 'WLD' || group) return [{ kind: 'history' }];
|
||||
if (one && HERITAGE_CODES.includes(one)) return [{ kind: 'heritage', code: one }];
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const statsHref = (place: string, theme: string) => `/stats/${place}/${theme}`;
|
||||
|
||||
/** Old section URLs → their place × theme (301s; the hash survives the redirect). */
|
||||
export const LEGACY: Record<string, string> = {
|
||||
economy: statsHref('core', 'economy'),
|
||||
energy: statsHref('nld', 'energy'),
|
||||
gouda: statsHref('gouda', 'overview'),
|
||||
countries: statsHref('core', 'overview'),
|
||||
elections: statsHref('nld', 'politics'),
|
||||
opinions: statsHref('core', 'politics'),
|
||||
history: statsHref('world', 'history')
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
// Helpers for /stats/opinions: poll averages, Eurobarometer label clean-up, GDELT theme names.
|
||||
// Helpers for /stats/core/politics: poll averages, Eurobarometer label clean-up, GDELT theme names.
|
||||
import type { StatPoint } from './chart.js';
|
||||
|
||||
export type Poll = {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { CountriesViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import { barRankOption, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||
@@ -6,7 +8,7 @@
|
||||
import { COUNTRY } from '#lib/stats/regions.js';
|
||||
import { COMPARE_COLUMNS, COUNTRY_COLOR, FLAG } from '#lib/stats/countries.js';
|
||||
|
||||
let { data } = $props();
|
||||
let { data }: { data: CountriesViewData & StatsCommon } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
@@ -49,14 +51,6 @@
|
||||
}, '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>countries · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Countries side by side: the Netherlands, Curaçao, the US, South Korea, China and more, on income, growth, inflation, debt, population and life expectancy."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / countries</span>
|
||||
<h1>Countries</h1>
|
||||
@@ -79,7 +73,9 @@
|
||||
{#each data.rows as r (r.code)}
|
||||
<tr class:hl={r.code === 'NLD' || r.code === 'CUW'}>
|
||||
<td>
|
||||
<a class="country-link" href={resolve(`/stats/countries/${r.code}` as '/stats')}
|
||||
<a
|
||||
class="country-link"
|
||||
href={resolve(`/stats/${r.code.toLowerCase()}/overview` as '/stats')}
|
||||
><span class="flag">{FLAG[r.code]}</span>{name(r.code)}</a
|
||||
>
|
||||
</td>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { CountryViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
@@ -17,7 +19,7 @@
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import PlaceHeritage from '#lib/stats/PlaceHeritage.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let { data }: { data: CountryViewData & StatsCommon } = $props();
|
||||
const c = $derived(data.code);
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
@@ -82,16 +84,8 @@
|
||||
const cwGrowth = $derived(latest(cw('cw:gdp:real_growth_pct')));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{name} · countries · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content={`${name} in numbers: population, income, growth, inflation, trade, tourism, government finances and life expectancy, from the World Bank and the IMF.`}
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="stats-intro">
|
||||
<a class="back" href={resolve('/stats/countries' as '/stats')}
|
||||
<a class="back" href={resolve('/stats/core/overview' as '/stats')}
|
||||
><ArrowLeft size={14} /> countries</a
|
||||
>
|
||||
<span class="stats-eyebrow">stats / countries / {c}</span>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { EconomyViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
import CountryPicker from '#lib/stats/CountryPicker.svelte';
|
||||
@@ -20,7 +22,7 @@
|
||||
type CpiCat
|
||||
} from '#lib/stats/econ.js';
|
||||
|
||||
let { data } = $props();
|
||||
let { data }: { data: EconomyViewData & StatsCommon } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string) => s[id]?.points ?? [];
|
||||
@@ -195,14 +197,6 @@
|
||||
const caInfl = $derived(latest(yoyByDate(pts('statcan:cpi:total'))));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>economy · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Consumer prices (index levels and inflation by category), interest rates, bond yields, growth, unemployment, debt and trade for Canada, South Korea, China, Japan, the US, the Netherlands and Curaçao, with the EU as a comparison."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / economy</span>
|
||||
<h1>Economy</h1>
|
||||
@@ -1,11 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { ElectionsViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
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();
|
||||
let {
|
||||
data,
|
||||
focus = 'all'
|
||||
}: { data: ElectionsViewData & StatsCommon; focus?: 'all' | 'nld' | 'gouda' | 'usa' } = $props();
|
||||
const show = (f: 'nld' | 'gouda' | 'usa') => focus === 'all' || focus === f;
|
||||
const a = $derived(data.attributions);
|
||||
const year = (d: string) => d.slice(0, 4);
|
||||
const share = (v: number | null, total: number) =>
|
||||
@@ -195,14 +201,6 @@
|
||||
);
|
||||
</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>
|
||||
@@ -213,211 +211,229 @@
|
||||
</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' }
|
||||
{#if show('nld')}
|
||||
<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"
|
||||
/>
|
||||
{/if}
|
||||
{#if show('nld')}
|
||||
<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"
|
||||
/>
|
||||
{/if}
|
||||
{#if show('gouda')}
|
||||
<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"
|
||||
/>
|
||||
{/if}
|
||||
{#if show('usa')}
|
||||
<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"
|
||||
/>
|
||||
{/if}
|
||||
</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}
|
||||
{#if show('nld')}
|
||||
<h2 class="stats-section-title">The Netherlands</h2>
|
||||
<div class="stats-grid">
|
||||
<StatChart
|
||||
title={`Gouda vs the Netherlands, TK ${year(goudaLatest.date)}`}
|
||||
subtitle="% of valid votes, largest parties in Gouda"
|
||||
sources={['kiesraad']}
|
||||
title="A century of Dutch elections by party family"
|
||||
subtitle="% of valid votes, Tweede Kamer"
|
||||
sources={['cbs', 'kiesraad']}
|
||||
attributions={a}
|
||||
option={(t) => lineOption(t, goudaVsNl, { category: true, unit: '%', decimals: 1 })}
|
||||
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="Gouda's vote since 2010"
|
||||
subtitle="% of valid votes"
|
||||
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) => lineOption(t, goudaTrend, { category: true, unit: '%', decimals: 1 })}
|
||||
option={(t) =>
|
||||
barRankOption(
|
||||
t,
|
||||
(latestNl?.parties ?? [])
|
||||
.filter((p) => p.seats)
|
||||
.map((p) => ({ label: p.party, value: p.seats ?? 0 })),
|
||||
{ decimals: 0, color: '#06b6d4' }
|
||||
)}
|
||||
/>
|
||||
{#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}
|
||||
<th>Seats</th>
|
||||
{#each recent as e (e.id)}<th class="num">{year(e.date)}</th>{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each stationRows as s (s.code)}
|
||||
{#each seatParties as p (p)}
|
||||
<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}
|
||||
<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>
|
||||
<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>
|
||||
{#if show('gouda')}
|
||||
<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}
|
||||
{/if}
|
||||
|
||||
{#if show('usa')}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dot {
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { EnergyViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
import { barRankOption, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||
@@ -13,7 +15,7 @@
|
||||
} from '#lib/stats/energylabels.js';
|
||||
import EnergyLive from '#lib/stats/EnergyLive.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let { data }: { data: EnergyViewData & StatsCommon } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
@@ -96,14 +98,6 @@
|
||||
const kDep = $derived(latest(importDep));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>energy · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="The live Dutch grid mix and gas storage, household energy tariffs, European and US prices, oil and gas, import dependence, renewables, heat pumps, solar and electric cars."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / energy</span>
|
||||
<h1>Energy</h1>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { GoudaViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import PlaceHeritage from '#lib/stats/PlaceHeritage.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
@@ -6,7 +8,7 @@
|
||||
import { compact, num, period } from '#lib/stats/format.js';
|
||||
import { epShareId, EP_LABELS, GOUDA_PC4, labelStackOption } from '#lib/stats/energylabels.js';
|
||||
|
||||
let { data } = $props();
|
||||
let { data }: { data: GoudaViewData & StatsCommon } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
@@ -110,14 +112,6 @@
|
||||
.map((w) => ({ label: w.title, value: w.values[key] as number }));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Gouda · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Gouda in numbers: population since 1942, births, deaths and migration, house prices, housing stock, municipal levies and neighbourhoods, compared with the surrounding municipalities."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / gouda</span>
|
||||
<h1>Gouda</h1>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HistoryViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import EventTimeline from '#lib/stats/EventTimeline.svelte';
|
||||
@@ -6,7 +8,7 @@
|
||||
import { HERITAGE, indexTo } from '#lib/stats/heritage.js';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
|
||||
let { data } = $props();
|
||||
let { data }: { data: HistoryViewData & StatsCommon } = $props();
|
||||
const s = $derived(data.series);
|
||||
const a = $derived(data.attributions);
|
||||
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||
@@ -56,14 +58,6 @@
|
||||
const places = Object.values(HERITAGE);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>History · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="The Netherlands, Gouda, Curaçao, the US and South Korea over the long run: population, income, life expectancy, urbanisation and religion, plus a sourced timeline of key events."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / history</span>
|
||||
<h1>History and culture</h1>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { OpinionsViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import StatChart from '#lib/stats/StatChart.svelte';
|
||||
import Kpi from '#lib/stats/Kpi.svelte';
|
||||
@@ -15,7 +17,7 @@
|
||||
} from '#lib/stats/opinions.js';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
|
||||
let { data } = $props();
|
||||
let { data }: { data: OpinionsViewData & StatsCommon } = $props();
|
||||
const a = $derived(data.attributions);
|
||||
const day = (d: string | undefined) =>
|
||||
d
|
||||
@@ -194,14 +196,6 @@
|
||||
const vparty = $derived(vpartyLines(data.vparty, 10));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Opinions · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Polls in the Netherlands, the US and South Korea, what Europeans worry about and whom they trust, world happiness, search trends, news themes and where Dutch parties stand."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / opinions</span>
|
||||
<h1>Opinions</h1>
|
||||
@@ -209,7 +203,7 @@
|
||||
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, what words mean and a party compass across 57 countries see
|
||||
<a href={resolve('/stats/opinions/world')}>world opinions</a>.
|
||||
<a href={resolve('/stats/world/politics')}>world opinions</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { WorldOpinionsViewData } from '#lib/server/stats-views.js';
|
||||
import type { StatsCommon } from '#lib/stats/matrix.js';
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
import './world-opinions.css';
|
||||
import CountryPanel from '#lib/world-opinions/CountryPanel.svelte';
|
||||
@@ -9,7 +11,7 @@
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { data } = $props();
|
||||
let { data }: { data: WorldOpinionsViewData & StatsCommon } = $props();
|
||||
// Static per load (stats.wo_*), so it is read once and shared with the components.
|
||||
const wo = provideWorldOpinions(buildWorldOpinions(data.wo));
|
||||
const { countries, countryById, narratives, sources } = wo;
|
||||
@@ -33,22 +35,15 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>world opinions · stats · Stan Runge</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="How political words, parties, values and national histories differ across 57 countries: what liberal means, what left and right are about, a shared party compass and a values map."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / opinions / world</span>
|
||||
<h1>World opinions</h1>
|
||||
<p>
|
||||
Same words, different politics. Hover the map, click a country. For measured party positions
|
||||
(Chapel Hill Expert Survey, V-Party) see <a href={resolve('/stats/opinions') + '#parties'}
|
||||
(Chapel Hill Expert Survey, V-Party) see <a href={resolve('/stats/core/politics') + '#parties'}
|
||||
>where Dutch parties stand</a
|
||||
>; for polls, happiness and Eurobarometer see <a href={resolve('/stats/opinions')}>opinions</a>.
|
||||
>; for polls, happiness and Eurobarometer see
|
||||
<a href={resolve('/stats/core/politics')}>opinions</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -89,7 +84,7 @@
|
||||
Every party on one shared scale, hand-placed for comparison. Measured CHES and V-Party
|
||||
positions for Dutch parties are on <a
|
||||
class="underline underline-offset-2"
|
||||
href={resolve('/stats/opinions') + '#parties'}>opinions</a
|
||||
href={resolve('/stats/core/politics') + '#parties'}>opinions</a
|
||||
>.
|
||||
</p>
|
||||
<PartyCompass focus={countryId} />
|
||||
@@ -41,7 +41,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
||||
'/projects/language-learner': 'Language Learner',
|
||||
'/projects/spotify-beatmap-finder': 'osu! map finder',
|
||||
'/projects/progress': 'Progress',
|
||||
'/stats/opinions/world': 'World Opinions',
|
||||
'/stats/world/politics': 'World Opinions',
|
||||
'/trackers/money': 'Money'
|
||||
};
|
||||
const cleaned = path.replace(/\/$/, '') || '/';
|
||||
|
||||
@@ -506,6 +506,44 @@ export const trackerIcons: Record<string, TrackerIcon> = {
|
||||
c(12, 12, 10),
|
||||
'm16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z'
|
||||
]
|
||||
},
|
||||
'stats-jobs': {
|
||||
from: '#8b5cf6',
|
||||
to: '#4f46e5',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16',
|
||||
'M4 6h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z'
|
||||
]
|
||||
},
|
||||
'stats-budget': {
|
||||
from: '#14b8a6',
|
||||
to: '#0f766e',
|
||||
ink: WHITE,
|
||||
paths: ['M3 22h18', 'M6 18v-7', 'M10 18v-7', 'M14 18v-7', 'M18 18v-7', 'M12 2 20 7H4z']
|
||||
},
|
||||
'stats-culture': {
|
||||
from: '#ec4899',
|
||||
to: '#a855f7',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z',
|
||||
c(13.5, 6.5, 1),
|
||||
c(17.5, 10.5, 1),
|
||||
c(6.5, 12.5, 1),
|
||||
c(8.5, 7.5, 1)
|
||||
]
|
||||
},
|
||||
'stats-population': {
|
||||
from: '#f43f5e',
|
||||
to: '#f97316',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2',
|
||||
c(9, 7, 4),
|
||||
'M22 21v-2a4 4 0 0 0-3-3.87',
|
||||
'M16 3.13a4 4 0 0 1 0 7.75'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Positions are approximate and meant for comparing countries/parties relative
|
||||
// to each other. The curated content (countries, party placements, profiles,
|
||||
// narratives, sources) lives in the `stats.wo_*` tables (seeded by the
|
||||
// stats_wo_seed migration) and is loaded by /stats/opinions/world; this file keeps
|
||||
// stats_wo_seed migration) and is loaded by /stats/world/politics; this file keeps
|
||||
// the types, the label dictionaries and the lookups the components share.
|
||||
import { getContext, setContext } from 'svelte';
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@ import type { RequestHandler } from './$types';
|
||||
|
||||
// Merged into /stats (2026-10-26): every old URL, subpaths included, moves permanently.
|
||||
export const GET: RequestHandler = ({ url }) => {
|
||||
redirect(301, `/stats/energy${url.search}#live`);
|
||||
redirect(301, `/stats/nld/energy${url.search}#live`);
|
||||
};
|
||||
|
||||
@@ -3,5 +3,5 @@ import type { RequestHandler } from './$types';
|
||||
|
||||
// Merged into /stats (2026-10-26): every old URL, subpaths included, moves permanently.
|
||||
export const GET: RequestHandler = ({ url }) => {
|
||||
redirect(301, `/stats/opinions/world${url.search}`);
|
||||
redirect(301, `/stats/world/politics${url.search}`);
|
||||
};
|
||||
|
||||
@@ -17,18 +17,18 @@ const staticPaths = [
|
||||
'/blog',
|
||||
'/lists',
|
||||
'/stats',
|
||||
'/stats/economy',
|
||||
'/stats/energy',
|
||||
'/stats/gouda',
|
||||
'/stats/countries',
|
||||
'/stats/countries/CUW',
|
||||
'/stats/elections',
|
||||
'/stats/opinions',
|
||||
'/stats/opinions/world',
|
||||
'/stats/history',
|
||||
'/stats/countries/NLD',
|
||||
'/stats/countries/USA',
|
||||
'/stats/countries/KOR',
|
||||
'/stats/core/economy',
|
||||
'/stats/nld/energy',
|
||||
'/stats/gouda/overview',
|
||||
'/stats/core/overview',
|
||||
'/stats/cuw/overview',
|
||||
'/stats/nld/politics',
|
||||
'/stats/core/politics',
|
||||
'/stats/world/politics',
|
||||
'/stats/world/history',
|
||||
'/stats/nld/overview',
|
||||
'/stats/usa/overview',
|
||||
'/stats/kor/overview',
|
||||
'/trackers/books',
|
||||
'/trackers/ai',
|
||||
'/trackers/spotting',
|
||||
|
||||
@@ -49,11 +49,11 @@
|
||||
];
|
||||
const statsHits = [
|
||||
{ name: 'Stats', href: '/stats' },
|
||||
{ name: 'Stats: economy', href: '/stats/economy' },
|
||||
{ name: 'Stats: energy', href: '/stats/energy' },
|
||||
{ name: 'Stats: Gouda', href: '/stats/gouda' },
|
||||
{ name: 'Stats: countries', href: '/stats/countries' },
|
||||
{ name: 'Stats: Curaçao', href: '/stats/countries/CUW' }
|
||||
{ name: 'Stats: economy', href: '/stats/core/economy' },
|
||||
{ name: 'Stats: energy', href: '/stats/nld/energy' },
|
||||
{ name: 'Stats: Gouda', href: '/stats/gouda/overview' },
|
||||
{ name: 'Stats: countries', href: '/stats/core/overview' },
|
||||
{ name: 'Stats: Curaçao', href: '/stats/cuw/overview' }
|
||||
];
|
||||
|
||||
const host = $derived(data.host);
|
||||
|
||||
@@ -1,43 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { statsPages, statsSubPages } from '#lib/projects.js';
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
// Every /stats page: the sticky place × theme selector above the page.
|
||||
import MatrixSelector from '#lib/stats/MatrixSelector.svelte';
|
||||
let { children } = $props();
|
||||
const tabs = [
|
||||
{ name: 'overview', href: '/stats', icon: 'stats-overview' },
|
||||
...statsPages.map((p) => ({ name: p.name, href: p.href, icon: p.icon }))
|
||||
];
|
||||
const path = $derived(page.url.pathname.replace(/\/$/, '') || '/');
|
||||
const active = (href: string) =>
|
||||
href === '/stats' ? path === href : path === href || path.startsWith(`${href}/`);
|
||||
const section = $derived(tabs.find((t) => t.href !== '/stats' && active(t.href))?.href);
|
||||
const subTabs = $derived(section ? statsSubPages[section] : undefined);
|
||||
</script>
|
||||
|
||||
<div class="stats-page">
|
||||
<nav class="stats-tabs" aria-label="Stats sections">
|
||||
{#each tabs as tab (tab.href)}
|
||||
<a
|
||||
href={resolve(tab.href as '/stats')}
|
||||
class:active={active(tab.href)}
|
||||
aria-current={path === tab.href ? 'page' : undefined}
|
||||
><TrackerIcon name={tab.icon} class="tab-icon size-6" />{tab.name}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if subTabs}
|
||||
<nav class="stats-tabs stats-subtabs" aria-label="Section pages">
|
||||
{#each subTabs as tab (tab.href)}
|
||||
<a
|
||||
href={resolve(tab.href as '/stats')}
|
||||
class:active={path === tab.href}
|
||||
aria-current={path === tab.href ? 'page' : undefined}
|
||||
><TrackerIcon name={tab.icon} class="tab-icon size-5" />{tab.name}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
<MatrixSelector />
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -50,42 +18,6 @@
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.stats-tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
.stats-tabs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
.stats-tabs a:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.stats-tabs a.active {
|
||||
background: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.stats-subtabs {
|
||||
margin-top: -0.9rem;
|
||||
border-bottom-style: dashed;
|
||||
}
|
||||
.stats-subtabs a {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.65rem 0.25rem 0.3rem;
|
||||
}
|
||||
/* Shared page furniture for every /stats page. */
|
||||
.stats-page :global(.stats-intro) {
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { cached, loadFreshness, loadSeries } from '#lib/server/stats.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { parsePlaces, statsHref, THEME_BY_SLUG, type ThemeSlug } from '#lib/stats/matrix.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
// /stats?places=NLD,KOR&theme=economy → /stats/nld,kor/economy
|
||||
const places = url.searchParams.get('places');
|
||||
if (places) {
|
||||
const sel = parsePlaces(places);
|
||||
const theme = THEME_BY_SLUG.get((url.searchParams.get('theme') ?? 'overview') as ThemeSlug);
|
||||
if (sel) redirect(307, statsHref(sel.slug, theme?.slug ?? 'overview'));
|
||||
}
|
||||
const [series, freshness] = await Promise.all([
|
||||
cached('stats:overview', () =>
|
||||
loadSeries([
|
||||
|
||||
@@ -41,49 +41,55 @@
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
href: '/stats/economy',
|
||||
href: '/stats/core/economy',
|
||||
title: 'Economy',
|
||||
icon: 'stats-economy',
|
||||
text: 'Central bank rates, inflation, bond yields and spreads, stock markets, growth, government debt and deficits, migration.'
|
||||
},
|
||||
{
|
||||
href: '/stats/energy',
|
||||
href: '/stats/core/jobs',
|
||||
title: 'Jobs & cost of living',
|
||||
icon: 'stats-jobs',
|
||||
text: 'For recent grads: unemployment, how hard it is to find a job, graduate outcomes, job quality, wages, rents and what life costs.'
|
||||
},
|
||||
{
|
||||
href: '/stats/nld/energy',
|
||||
title: 'Energy',
|
||||
icon: 'stats-energy',
|
||||
text: 'Dutch household tariffs, Europe vs the US, oil and gas, import dependence, renewables, heat pumps, solar and EVs.'
|
||||
},
|
||||
{
|
||||
href: '/stats/gouda',
|
||||
href: '/stats/gouda/overview',
|
||||
title: 'Gouda',
|
||||
icon: 'stats-gouda',
|
||||
text: 'Population since 1942, house prices, housing stock, municipal levies and neighbourhoods, next to the surrounding towns.'
|
||||
},
|
||||
{
|
||||
href: '/stats/countries',
|
||||
href: '/stats/core/overview',
|
||||
title: 'Countries',
|
||||
icon: 'stats-countries',
|
||||
text: 'The Netherlands, Curaçao, the US, South Korea, China and more side by side: income, growth, inflation, debt and life expectancy.'
|
||||
},
|
||||
{
|
||||
href: '/stats/countries/CUW',
|
||||
href: '/stats/cuw/overview',
|
||||
title: 'Curaçao',
|
||||
icon: 'stats-curacao',
|
||||
text: 'Population, income, inflation, tourism, trade and government finances of Curaçao.'
|
||||
},
|
||||
{
|
||||
href: '/stats/elections',
|
||||
href: '/stats/nld/politics',
|
||||
title: 'Elections',
|
||||
icon: 'stats-elections',
|
||||
text: 'Every Dutch general election since 1918, Gouda down to the polling station, and the US presidential vote by state.'
|
||||
},
|
||||
{
|
||||
href: '/stats/history',
|
||||
href: '/stats/world/history',
|
||||
title: 'History and culture',
|
||||
icon: 'stats-history',
|
||||
text: 'The Netherlands, Gouda, Curaçao, the US and Korea over the long run, a sourced timeline, office holders, religion, holidays, UNESCO sites and famous people.'
|
||||
},
|
||||
{
|
||||
href: '/stats/opinions',
|
||||
href: '/stats/core/politics',
|
||||
title: 'Opinions',
|
||||
icon: 'stats-opinions',
|
||||
text: 'Seat and approval polls, Eurobarometer worries and trust, world happiness, search trends, news themes and party positions.'
|
||||
@@ -104,13 +110,14 @@
|
||||
<h1>Statistics</h1>
|
||||
<p>
|
||||
Numbers I like to keep an eye on, pulled straight from official open data (CBS, Eurostat, FRED,
|
||||
the BIS) and refreshed on a schedule. Every chart names its source.
|
||||
the BIS) and refreshed on a schedule. Every chart names its source. Pick a place (or compare
|
||||
several) and a theme above; the shortcuts below jump to the richest pages.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="kpis">
|
||||
<Kpi
|
||||
href="/stats/energy"
|
||||
href="/stats/nld/energy"
|
||||
label="Brent crude"
|
||||
value={num(k.brent?.[1], 1)}
|
||||
unit="$/bbl"
|
||||
@@ -120,7 +127,7 @@
|
||||
color="#64748b"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/energy"
|
||||
href="/stats/nld/energy"
|
||||
label="EU gas (TTF)"
|
||||
value={num(k.ttf?.[1], 1)}
|
||||
unit="$/MMBtu"
|
||||
@@ -130,7 +137,7 @@
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/energy"
|
||||
href="/stats/nld/energy"
|
||||
label="NL electricity"
|
||||
value={num(k.elec?.[1], 3)}
|
||||
unit="€/kWh"
|
||||
@@ -138,7 +145,7 @@
|
||||
color="#eab308"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/energy"
|
||||
href="/stats/nld/energy"
|
||||
label="Renewable electricity"
|
||||
value={num(k.renew?.[1], 0)}
|
||||
unit="%"
|
||||
@@ -146,7 +153,7 @@
|
||||
color="#10b981"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/economy"
|
||||
href="/stats/core/economy"
|
||||
label="Fed / ECB / BoK"
|
||||
value={`${num(k.fed?.[1], 2)} / ${num(k.ecb?.[1], 2)} / ${num(k.bok?.[1], 2)}`}
|
||||
unit="%"
|
||||
@@ -154,7 +161,7 @@
|
||||
color="#a855f7"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/economy"
|
||||
href="/stats/core/economy"
|
||||
label="Dutch inflation"
|
||||
value={num(k.cpi?.[1], 1)}
|
||||
unit="%"
|
||||
@@ -162,7 +169,7 @@
|
||||
color="#e11d48"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/economy"
|
||||
href="/stats/core/economy"
|
||||
label="France – Germany 10y"
|
||||
value={num(k.frde ? k.frde[1] * 100 : null, 0)}
|
||||
unit="bp"
|
||||
@@ -170,7 +177,7 @@
|
||||
color="#06b6d4"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/gouda"
|
||||
href="/stats/gouda/overview"
|
||||
label="Gouda inhabitants"
|
||||
value={num(k.pop?.[1], 0)}
|
||||
sub={` in a year · ${period(k.pop?.[0])}`}
|
||||
@@ -178,7 +185,7 @@
|
||||
color="#f97316"
|
||||
/>
|
||||
<Kpi
|
||||
href="/stats/gouda"
|
||||
href="/stats/gouda/overview"
|
||||
label="Gouda avg house price"
|
||||
value={`€${compact(k.house?.[1])}`}
|
||||
sub={`${period(k.house?.[0], 'A')} · NL €${compact(k.houseNl?.[1])}`}
|
||||
|
||||
12
src/routes/stats/[place]/+server.ts
Normal file
12
src/routes/stats/[place]/+server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { LEGACY, parsePlaces, statsHref } from '#lib/stats/matrix.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// /stats/<place> → its overview; the old section URLs (/stats/economy, …) move permanently.
|
||||
export const GET: RequestHandler = ({ params, url }) => {
|
||||
const legacy = LEGACY[params.place.toLowerCase()];
|
||||
if (legacy) redirect(301, `${legacy}${url.search}`);
|
||||
const sel = parsePlaces(params.place);
|
||||
if (!sel) error(404, 'Not Found');
|
||||
redirect(307, `${statsHref(sel.slug, 'overview')}${url.search}`);
|
||||
};
|
||||
44
src/routes/stats/[place]/[theme]/+page.server.ts
Normal file
44
src/routes/stats/[place]/[theme]/+page.server.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { loadMetrics, loadView } from '#lib/server/stats-matrix.js';
|
||||
import {
|
||||
PLACE_BY_CODE,
|
||||
parsePlaces,
|
||||
PROFILE_CODES,
|
||||
statsHref,
|
||||
THEME_BY_SLUG,
|
||||
viewsFor,
|
||||
type ThemeSlug
|
||||
} from '#lib/stats/matrix.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const place = params.place.toLowerCase();
|
||||
const theme = params.theme.toLowerCase();
|
||||
// Old nested URLs: /stats/countries/NLD and /stats/opinions/world.
|
||||
if (place === 'countries') {
|
||||
const code = theme.toUpperCase();
|
||||
if (PROFILE_CODES.includes(code))
|
||||
redirect(301, `${statsHref(PLACE_BY_CODE.get(code)!.slug, 'overview')}${url.search}`);
|
||||
error(404, 'Not Found');
|
||||
}
|
||||
if (place === 'opinions' && theme === 'world')
|
||||
redirect(301, `${statsHref('world', 'politics')}${url.search}`);
|
||||
|
||||
const sel = parsePlaces(params.place);
|
||||
const t = THEME_BY_SLUG.get(theme as ThemeSlug);
|
||||
if (!sel || !t) error(404, 'Not Found');
|
||||
if (sel.slug !== params.place || t.slug !== params.theme)
|
||||
redirect(308, `${statsHref(sel.slug, t.slug)}${url.search}`);
|
||||
|
||||
const [metrics, views] = await Promise.all([
|
||||
loadMetrics(sel, t),
|
||||
Promise.all(viewsFor(sel, t.slug).map(loadView))
|
||||
]);
|
||||
return {
|
||||
place: sel.slug,
|
||||
theme: t.slug,
|
||||
metrics,
|
||||
views,
|
||||
seo: { title: `${t.name} · ${sel.label}`, description: `${sel.label}: ${t.blurb}` }
|
||||
};
|
||||
};
|
||||
82
src/routes/stats/[place]/[theme]/+page.svelte
Normal file
82
src/routes/stats/[place]/[theme]/+page.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
// One cell of the /stats matrix: a place (or comparison) × a theme. Rich hand-built views
|
||||
// render where one fits the combination; otherwise the generic metric charts do.
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import MatrixMetrics from '#lib/stats/MatrixMetrics.svelte';
|
||||
import EconomyView from '#lib/stats/views/EconomyView.svelte';
|
||||
import EnergyView from '#lib/stats/views/EnergyView.svelte';
|
||||
import GoudaView from '#lib/stats/views/GoudaView.svelte';
|
||||
import CountriesView from '#lib/stats/views/CountriesView.svelte';
|
||||
import CountryView from '#lib/stats/views/CountryView.svelte';
|
||||
import ElectionsView from '#lib/stats/views/ElectionsView.svelte';
|
||||
import OpinionsView from '#lib/stats/views/OpinionsView.svelte';
|
||||
import WorldOpinionsView from '#lib/stats/views/WorldOpinionsView.svelte';
|
||||
import HistoryView from '#lib/stats/views/HistoryView.svelte';
|
||||
import PlaceHeritage from '#lib/stats/PlaceHeritage.svelte';
|
||||
import { parsePlaces, THEME_BY_SLUG } from '#lib/stats/matrix.js';
|
||||
|
||||
let { data } = $props();
|
||||
const sel = $derived(parsePlaces(data.place)!);
|
||||
const theme = $derived(THEME_BY_SLUG.get(data.theme)!);
|
||||
const common = $derived({ sources: data.sources, attributions: data.attributions });
|
||||
</script>
|
||||
|
||||
{#if !data.views.length}
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / {sel.label.toLowerCase()} / {theme.name.toLowerCase()}</span
|
||||
>
|
||||
<h1 class="matrix-title">
|
||||
<TrackerIcon name={theme.icon} class="size-9" />
|
||||
<span>{theme.name} <span class="where">· {sel.label}</span></span>
|
||||
</h1>
|
||||
<p>{theme.blurb}</p>
|
||||
</section>
|
||||
<MatrixMetrics metrics={data.metrics} codes={sel.codes} attributions={data.attributions} />
|
||||
{:else}
|
||||
{#each data.views as v, i (i)}
|
||||
{#if v.kind === 'economy'}
|
||||
<EconomyView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'energy'}
|
||||
<EnergyView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'gouda'}
|
||||
<GoudaView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'countries'}
|
||||
<CountriesView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'country'}
|
||||
<CountryView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'elections'}
|
||||
<ElectionsView data={{ ...v.data, ...common }} focus={v.focus} />
|
||||
{:else if v.kind === 'opinions'}
|
||||
<OpinionsView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'worldOpinions'}
|
||||
<WorldOpinionsView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'history'}
|
||||
<HistoryView data={{ ...v.data, ...common }} />
|
||||
{:else if v.kind === 'heritage' && v.data.heritage}
|
||||
<section class="stats-intro">
|
||||
<span class="stats-eyebrow">stats / {sel.label.toLowerCase()} / history</span>
|
||||
<h1 class="matrix-title">
|
||||
<TrackerIcon name={theme.icon} class="size-9" />
|
||||
<span>History <span class="where">· {sel.label}</span></span>
|
||||
</h1>
|
||||
</section>
|
||||
<PlaceHeritage h={v.data.heritage} attributions={data.attributions} />
|
||||
{/if}
|
||||
{/each}
|
||||
{#if data.metrics.some((m) => m.lines.length) && !['overview', 'economy', 'energy'].includes(data.theme)}
|
||||
<h2 class="stats-section-title">{theme.name} at a glance · {sel.label}</h2>
|
||||
<MatrixMetrics metrics={data.metrics} codes={sel.codes} attributions={data.attributions} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.matrix-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.where {
|
||||
color: var(--site-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -1,29 +0,0 @@
|
||||
import { cached, loadLatest, loadSeries } from '#lib/server/stats.js';
|
||||
import { COMPARE_COLUMNS, COUNTRY_PAGES, seriesIds } from '#lib/stats/countries.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () =>
|
||||
cached('stats:countries', async () => {
|
||||
const ids = COUNTRY_PAGES.flatMap((c) =>
|
||||
COMPARE_COLUMNS.flatMap((col) => seriesIds(c, col.ids))
|
||||
);
|
||||
const [latest, series] = await Promise.all([
|
||||
loadLatest(ids),
|
||||
loadSeries(
|
||||
COUNTRY_PAGES.flatMap((c) => [
|
||||
{ id: `wb:NY.GDP.PCAP.PP.KD:${c}` },
|
||||
{ id: `wb:SP.DYN.LE00.IN:${c}`, from: '1960-01-01' }
|
||||
])
|
||||
)
|
||||
]);
|
||||
const rows = COUNTRY_PAGES.map((c) => ({
|
||||
code: c,
|
||||
values: Object.fromEntries(
|
||||
COMPARE_COLUMNS.map((col) => {
|
||||
const id = seriesIds(c, col.ids).find((i) => latest[i]);
|
||||
return [col.key, id ? { ...latest[id], source: id.split(':')[0] } : null];
|
||||
})
|
||||
)
|
||||
}));
|
||||
return { rows, series };
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { cached, loadSeries } from '#lib/server/stats.js';
|
||||
import { isCountryPage, PROFILE_SERIES } from '#lib/stats/countries.js';
|
||||
import { CW_SERIES } from '#lib/stats/curacao.js';
|
||||
import { isHeritage } from '#lib/stats/heritage.js';
|
||||
import { loadHeritage } from '#lib/server/stats-history.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const code = params.iso3.toUpperCase();
|
||||
if (code !== params.iso3 && isCountryPage(code))
|
||||
redirect(308, `/stats/countries/${code}${url.search}`);
|
||||
if (!isCountryPage(code)) error(404, 'Not Found');
|
||||
const heritage = isHeritage(code) ? loadHeritage(code) : null;
|
||||
const series = await cached(`stats:country:${code}`, () =>
|
||||
loadSeries([
|
||||
...PROFILE_SERIES.map((id) => `${id}:${code}`),
|
||||
// Benchmarks for the "next to the Netherlands and the world" lines.
|
||||
...['NLD', 'WLD'].flatMap((c) => [
|
||||
`wb:NY.GDP.PCAP.PP.KD:${c}`,
|
||||
`wb:SP.DYN.LE00.IN:${c}`,
|
||||
`wb:FP.CPI.TOTL.ZG:${c}`
|
||||
]),
|
||||
// §8: Curaçao detail from CBS Curaçao and the international APIs (ids carry no code suffix).
|
||||
...(code === 'CUW' ? CW_SERIES : [])
|
||||
])
|
||||
);
|
||||
return { code, series, heritage: heritage ? await heritage : null };
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import { cached, loadSeries } from '#lib/server/stats.js';
|
||||
import {
|
||||
ANNUAL,
|
||||
CPI_BY_CATEGORY,
|
||||
CPI_TOTAL,
|
||||
ECON_COUNTRIES,
|
||||
POLICY_RATE
|
||||
} from '#lib/stats/econ.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020', 'EA20'];
|
||||
const YIELDS = ['NL', 'DE', 'FR', 'IT', 'ES', 'BE', 'AT', 'EA'];
|
||||
const REGIONS = [...ECON_COUNTRIES, 'EU27', 'EA'];
|
||||
|
||||
export const load: PageServerLoad = async () => ({
|
||||
series: await cached('stats:economy', () =>
|
||||
loadSeries([
|
||||
...[...new Set(Object.values(POLICY_RATE))].map((a) => ({
|
||||
id: `bis:M:${a}`,
|
||||
from: '1990-01-01'
|
||||
})),
|
||||
...EU.map((g) => `eurostat:prc_hicp_minr:RCH_A.TOTAL.${g}`),
|
||||
// CPI index levels (all items, then the categories)
|
||||
...[
|
||||
...new Set([
|
||||
...Object.values(CPI_TOTAL).map((c) => c.id),
|
||||
...Object.values(CPI_BY_CATEGORY).flatMap((c) => Object.values(c.ids))
|
||||
])
|
||||
].map((id) => ({ id, from: '1960-01-01' })),
|
||||
...REGIONS.map((c) => ({ id: `oecd:irlt:${c}`, from: '1980-01-01' })),
|
||||
...YIELDS.map((g) => `eurostat:irt_lt_mcby_m:MCBY.${g}`),
|
||||
{ id: 'fred:DGS10', from: '1990-01-01', resample: 'M' },
|
||||
{ id: 'fred:DGS2', from: '1990-01-01', resample: 'M' },
|
||||
...['NL', 'KR', 'US', 'CN'].map((c) => ({
|
||||
id: `fred:SPASTT01${c}M661N`,
|
||||
from: '1990-01-01'
|
||||
})),
|
||||
{ id: 'fred:SP500', resample: 'W' },
|
||||
{ id: 'fred:NASDAQCOM', from: '1990-01-01', resample: 'W' },
|
||||
...Object.values(ANNUAL).flatMap((ind) =>
|
||||
REGIONS.flatMap((c) => [
|
||||
...(ind.imf ? [{ id: `imf:${ind.imf}:${c}`, from: '1980-01-01' }] : []),
|
||||
{ id: `wb:${ind.wb}:${c}`, from: '1980-01-01' }
|
||||
])
|
||||
),
|
||||
...Object.values(ANNUAL).flatMap((ind) => (ind.cw ? [ind.cw] : [])),
|
||||
'fred:GDPC1',
|
||||
'fred:UNRATE',
|
||||
...EU.flatMap((g) => [
|
||||
`eurostat:gov_10dd_edpt1:PC_GDP.S13.GD.${g}`,
|
||||
`eurostat:gov_10dd_edpt1:PC_GDP.S13.B9.${g}`,
|
||||
`eurostat:gov_10a_main:PC_GDP.S13.TE.${g}`,
|
||||
`eurostat:gov_10a_main:PC_GDP.S13.TR.${g}`,
|
||||
`eurostat:migr_imm1ctz:TOTAL.COMPLET.TOTAL.NR.T.${g}`,
|
||||
`eurostat:demo_pjan:NR.TOTAL.T.${g}`
|
||||
])
|
||||
])
|
||||
)
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
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<string, unknown>[]): 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
|
||||
}))
|
||||
};
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { cached, loadSeries } from '#lib/server/stats.js';
|
||||
import { loadEnergy } from '#lib/server/energy.js';
|
||||
import { epSeriesIds } from '#lib/stats/energylabels.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const EU = ['NL', 'DE', 'FR', 'BE', 'IT', 'ES', 'PL', 'SE', 'DK', 'AT', 'EU27_2020'];
|
||||
const RENEW = ['total', 'wind-onshore', 'wind-offshore', 'solar', 'biomass', 'hydro'];
|
||||
|
||||
export const load: PageServerLoad = async () => ({
|
||||
// Live NED grid + gas storage (read-only, ned-collector's `energy` schema).
|
||||
energy: await cached('stats:energy:live', loadEnergy),
|
||||
series: await cached('stats:energy', () =>
|
||||
loadSeries([
|
||||
...['variable', 'tax', 'transport', 'fixed', 'taxcredit', 'dynamic'].map(
|
||||
(k) => `cbs:tariff:elec:${k}`
|
||||
),
|
||||
...['variable', 'tax', 'transport', 'fixed'].map((k) => `cbs:tariff:gas:${k}`),
|
||||
...['variable', 'tax', 'ode'].flatMap((k) => [
|
||||
`cbs:tariff1823:elec:${k}`,
|
||||
`cbs:tariff1823:gas:${k}`
|
||||
]),
|
||||
'cbs:price0723:elec',
|
||||
'cbs:price0723:gas',
|
||||
'cbs:cpi:energy:yoy',
|
||||
...EU.flatMap((g) => [
|
||||
`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_pc_204:E7000.KWH3500.KWH.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_pc_202:G3000.GJ83P70.GJ_GCV.I_TAX.EUR.${g}`,
|
||||
`eurostat:nrg_ind_id:TOTAL.PC.${g}`
|
||||
]),
|
||||
...['X_TAX', 'X_VAT'].flatMap((t) => [
|
||||
`eurostat:nrg_pc_204:E7000.KWH2500-4999.KWH.${t}.EUR.NL`,
|
||||
`eurostat:nrg_pc_202:G3000.GJ20-199.KWH.${t}.EUR.NL`
|
||||
]),
|
||||
{ id: 'fred:APU000072610', from: '2007-01-01' },
|
||||
{ id: 'fred:DCOILBRENTEU', resample: 'W' },
|
||||
'fred:PNGASEUUSDM',
|
||||
'cbs:energy:tpes',
|
||||
'cbs:energy:netimport',
|
||||
'cbs:energy:production',
|
||||
'cbs:energy:gas:production',
|
||||
'cbs:energy:gas:netimport',
|
||||
'cbs:energy:gas:tpes',
|
||||
...RENEW.flatMap((k) => [`cbs:renew:${k}:share`, `cbs:renew:${k}:mw`]),
|
||||
'cbs:heatpumps:homes',
|
||||
'cbs:heatpumps:homes:air',
|
||||
'cbs:heatpumps:homes:ground',
|
||||
'cbs:heatpumps:new',
|
||||
...['NL01', 'GM0513'].flatMap((r) => [
|
||||
`cbs:solar:homes:kwp:${r}`,
|
||||
`cbs:solar:homes:n:${r}`,
|
||||
`cbs:solar:kwp:${r}`
|
||||
]),
|
||||
'cbs:pop:jan1:GM0513',
|
||||
'cbs:cars:total',
|
||||
'cbs:cars:electric',
|
||||
'cbs:cars:petrol',
|
||||
'cbs:cars:diesel',
|
||||
'eurostat:road_eqr_carpda:NR.ELC.NL',
|
||||
'eurostat:road_eqr_carpda:NR.TOTAL.NL',
|
||||
...epSeriesIds(['NL', 'GOUDA'])
|
||||
])
|
||||
)
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { cached, loadCbsCross, loadSeries } from '#lib/server/stats.js';
|
||||
import { epSeriesIds, GOUDA_PC4 } from '#lib/stats/energylabels.js';
|
||||
import { loadHeritage } from '#lib/server/stats-history.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Gouda (GM0513) and its neighbours; NL01 is the national total.
|
||||
const RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];
|
||||
const KWB_TABLE = '85984NED'; // Kerncijfers wijken en buurten 2024
|
||||
const KWB_MEASURES = [
|
||||
'T001036',
|
||||
'M000100',
|
||||
'M001642',
|
||||
'1014850_2',
|
||||
'A047047',
|
||||
'M000224',
|
||||
'M008349',
|
||||
'M000221_2',
|
||||
'M000219_2',
|
||||
'M008295',
|
||||
'M008297'
|
||||
];
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const [series, wijken, heritage] = await Promise.all([
|
||||
cached('stats:gouda', () =>
|
||||
loadSeries([
|
||||
...['NL01', ...RING].flatMap((r) => [
|
||||
`cbs:pop:monthly:${r}`,
|
||||
`cbs:pop:jan1:${r}`,
|
||||
`cbs:house:avgprice:${r}`,
|
||||
...['total', 'owner', 'social', 'private'].map((k) => `cbs:stock:${k}:${r}`)
|
||||
]),
|
||||
...RING.flatMap((r) => ['total', 'ozb', 'sewer', 'waste'].map((k) => `cbs:levy:${k}:${r}`)),
|
||||
...['births', 'deaths', 'immigration', 'emigration', 'in', 'out', 'growth'].map(
|
||||
(k) => `cbs:pop:${k}:GM0513`
|
||||
),
|
||||
'cbs:house:hpi:cr28',
|
||||
'cbs:house:hpi:nl',
|
||||
...epSeriesIds(['NL', 'GOUDA', ...GOUDA_PC4])
|
||||
])
|
||||
),
|
||||
cached('stats:gouda:kwb', () =>
|
||||
loadCbsCross(KWB_TABLE, KWB_MEASURES, 'WijkenEnBuurten', ['WK0513', 'GM0513', 'NL00'])
|
||||
),
|
||||
loadHeritage('GM0513')
|
||||
]);
|
||||
return { series, wijken, kwbYear: 2024, heritage };
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import { loadHistoryOverview } from '#lib/server/stats-history.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => loadHistoryOverview();
|
||||
@@ -1,101 +0,0 @@
|
||||
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<typeof pollAverages>) => 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)
|
||||
}))
|
||||
};
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import { loadWorldOpinions } from '#lib/server/world-opinions.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => ({ wo: await loadWorldOpinions() });
|
||||
Reference in New Issue
Block a user