From 9b2122028032708dc36ba715d8685c0380deafa1 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sun, 25 Oct 2026 23:39:34 +0000 Subject: [PATCH] =?UTF-8?q?stats:=20/stats/history=20and=20History,=20Poli?= =?UTF-8?q?tics=20and=20Culture=20sections=20on=20NL,=20Gouda,=20Cura?= =?UTF-8?q?=C3=A7ao,=20US=20and=20KR=20pages=20(step=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-run charts (Maddison, HYDE, UN via OWID; CBS and Pew religion), a filterable sourced timeline, Wikidata office holders and turnout, Nager.Date holidays, UNESCO sites, Wikidata people by sitelinks, curated food and cultural exports. Wikidata labels now fall back to mul. --- scripts/stats/history.ts | 4 +- src/lib/projects.ts | 6 + src/lib/server/stats-history.ts | 100 ++++ src/lib/stats/EventTimeline.svelte | 248 ++++++++ src/lib/stats/PlaceHeritage.svelte | 532 ++++++++++++++++++ src/lib/stats/heritage.test.ts | 64 +++ src/lib/stats/heritage.ts | 288 ++++++++++ src/routes/sitemap.xml/+server.ts | 4 + src/routes/stats/+layout.svelte | 7 +- src/routes/stats/+page.svelte | 8 + .../stats/countries/[iso3]/+page.server.ts | 5 +- .../stats/countries/[iso3]/+page.svelte | 5 + src/routes/stats/gouda/+page.server.ts | 8 +- src/routes/stats/gouda/+page.svelte | 3 + src/routes/stats/history/+page.server.ts | 4 + src/routes/stats/history/+page.svelte | 203 +++++++ 16 files changed, 1482 insertions(+), 7 deletions(-) create mode 100644 src/lib/server/stats-history.ts create mode 100644 src/lib/stats/EventTimeline.svelte create mode 100644 src/lib/stats/PlaceHeritage.svelte create mode 100644 src/lib/stats/heritage.test.ts create mode 100644 src/lib/stats/heritage.ts create mode 100644 src/routes/stats/history/+page.server.ts create mode 100644 src/routes/stats/history/+page.svelte diff --git a/scripts/stats/history.ts b/scripts/stats/history.ts index f3c9385..972bde9 100644 --- a/scripts/stats/history.ts +++ b/scripts/stats/history.ts @@ -305,7 +305,7 @@ async function officesJob(note: (w: string) => void) { FILTER(?rank != wikibase:DeprecatedRank) OPTIONAL { ?st pq:P582 ?end } OPTIONAL { ?p wdt:P102 ?party. ?party rdfs:label ?partyName. FILTER(lang(?partyName) = "en") } - SERVICE wikibase:label { bd:serviceParam wikibase:language "en,nl,ko,pap". } + SERVICE wikibase:label { bd:serviceParam wikibase:language "en,mul,nl,ko,pap". } } GROUP BY ?p ?pLabel ?officeLabel ?start ?end`); const seen = new Set(); for (const r of b) { @@ -422,7 +422,7 @@ async function peopleJob(note: (w: string) => void) { OPTIONAL { ?p wdt:P569 ?b } OPTIONAL { ?p wdt:P570 ?d } OPTIONAL { ?en schema:about ?p; schema:isPartOf } OPTIONAL { ?nl schema:about ?p; schema:isPartOf } - SERVICE wikibase:label { bd:serviceParam wikibase:language "en,nl,ko". } + SERVICE wikibase:label { bd:serviceParam wikibase:language "en,mul,nl,ko". } } GROUP BY ?p ?pLabel ?pDescription`); const info = new Map(detail.map((r) => [qid(r.p), r])); top.forEach(([q, n], i) => { diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 53bd989..b08426e 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -64,6 +64,12 @@ export const statsPages = [ href: '/stats/elections', external: false }, + { + name: 'history', + subtitle: 'the long run, a sourced timeline, politics and culture of five places', + href: '/stats/history', + external: false + }, { name: 'opinions', subtitle: 'polls, Eurobarometer, happiness, search trends and party positions', diff --git a/src/lib/server/stats-history.ts b/src/lib/server/stats-history.ts new file mode 100644 index 0000000..2561de4 --- /dev/null +++ b/src/lib/server/stats-history.ts @@ -0,0 +1,100 @@ +// Read side for §9 (history and culture per place): curated events and facts, Wikidata office +// holders and people, Nager.Date holidays, UNESCO sites and the long-run/religion/language series. +import { client } from '#lib/server/db/index.js'; +import { cached, loadSeries } from '#lib/server/stats.js'; +import { + HERITAGE, + type Heritage, + type HeritageCode, + type HeritagePlace, + type HistEvent, + type HistFact, + type HistHoliday, + type HistOffice, + type HistPerson, + type Turnout, + type UnescoSite +} from '#lib/stats/heritage.js'; + +export async function loadEvents(places: string[]): Promise { + return client` + select place, slug, year, year_end as "yearEnd", date_label as "dateLabel", title, summary, theme, + source_url as "sourceUrl" + from stats.hist_event where place = any(${places}) order by year, slug`; +} + +/** Series ids by prefix (the history collector writes one id per category). */ +async function seriesIds(patterns: string[]): Promise { + if (!patterns.length) return []; + const rows = await client<{ series_id: string }[]>` + select series_id from stats.series where series_id like any(${patterns})`; + return rows.map((r) => r.series_id); +} + +export function loadHeritage(place: HeritageCode): Promise { + const p: HeritagePlace = HERITAGE[place]; + return cached(`stats:heritage:${place}`, async () => { + const ids = [...p.series, ...(await seriesIds(p.seriesLike))]; + const [events, facts, offices, people, holidays, unesco, turnout, series] = await Promise.all([ + loadEvents([place]), + client` + select slug, topic, title, body, source_url as "sourceUrl" from stats.hist_fact + where place = ${place} order by sort`, + client` + select kind, office_qid as "officeQid", office_label as "officeLabel", person_qid as "personQid", + person_label as "personLabel", party, to_char(start_date, 'YYYY-MM-DD') as start, + to_char(end_date, 'YYYY-MM-DD') as "end" + from stats.hist_office where place = ${place} order by start_date desc`, + client` + select person_qid as qid, label, description, birth_year as born, death_year as died, sitelinks, url + from stats.hist_person where place = ${place} order by rank limit 20`, + client` + select to_char(date, 'YYYY-MM-DD') as date, name, local_name as "localName", global + from stats.hist_holiday where place = ${place} and date >= current_date - 1 + order by date limit 16`, + client` + select id_no as id, name, category, year_inscribed as year, danger, description + from stats.unesco_site where places ? ${place} order by year_inscribed, name`, + p.turnoutArea + ? client` + select e.name as election, to_char(e.date, 'YYYY-MM-DD') as date, + round(100.0 * t.ballots / t.electorate, 1)::float8 as pct, e.source_id as "sourceId" + from stats.elec_turnout t join stats.elec_election e using (election_id) + where t.area_code = ${p.turnoutArea} and t.level = ${p.turnoutLevel ?? 'national'} + and e.kind = 'tk' and t.electorate > 0 and t.ballots > 0 + order by e.date` + : Promise.resolve([] as Turnout[]), + loadSeries(ids) + ]); + return { place: p, events, facts, offices, people, holidays, unesco, turnout, series }; + }); +} + +/** Long-run comparison series for /stats/history. */ +export function loadHistoryOverview() { + return cached('stats:history:overview', async () => { + const codes = ['NLD', 'USA', 'KOR', 'CUW', 'WLD']; + const ids = [ + ...codes.flatMap((c) => [ + `owid:hist:population:${c}`, + `owid:hist:life-expectancy:${c}`, + `owid:hist:urban-long:${c}`, + `owid:hist:urban:${c}`, + `owid:maddison:${c}`, + `pew:religion:religiously_unaffiliated:${c}`, + `pew:religion:christians:${c}` + ]), + 'owid:cw:population', + 'owid:cw:life-expectancy', + 'cw:pop:jan1', + 'hist:pop:GM0513', + 'cbs:pop:jan1:GM0513', + ...(await seriesIds(['cbs:religion:37944:%'])) + ]; + const [series, events] = await Promise.all([ + loadSeries(ids), + loadEvents(['NLD', 'GM0513', 'CUW', 'USA', 'KOR']) + ]); + return { series, events }; + }); +} diff --git a/src/lib/stats/EventTimeline.svelte b/src/lib/stats/EventTimeline.svelte new file mode 100644 index 0000000..c144846 --- /dev/null +++ b/src/lib/stats/EventTimeline.svelte @@ -0,0 +1,248 @@ + + +
+ + {#each groups as g (g.label)} +

{g.label}

+
    + {#each g.items as e (`${e.place}/${e.slug}`)} + {@const key = `${e.place}/${e.slug}`} +
  1. + + {#if open === key || !showPlaces} +

    + {#if e.dateLabel}{e.dateLabel}.{/if} + {e.summary} + {sourceLabel(e.sourceUrl)} +

    + {/if} +
  2. + {/each} +
+ {:else} +

No events match these filters.

+ {/each} +
+ + diff --git a/src/lib/stats/PlaceHeritage.svelte b/src/lib/stats/PlaceHeritage.svelte new file mode 100644 index 0000000..8a0079a --- /dev/null +++ b/src/lib/stats/PlaceHeritage.svelte @@ -0,0 +1,532 @@ + + +

History

+
+ {#if popLines.length} + lineOption(t, popLines, { decimals: 0 })} + /> + {/if} + {#if gdpLines.length} + lineOption(t, gdpLines, { unit: '$', decimals: 0 })} + /> + {/if} + {#if lifeLines.length} + lineOption(t, lifeLines, { unit: 'years', decimals: 1 })} + /> + {/if} + {#if urbanLines.length} + lineOption(t, urbanLines, { unit: '%', decimals: 1 })} + /> + {/if} +
+{#if h.events.length} +

Timeline

+ +

+ {h.events.length} key events, curated and summarised from the linked articles (mostly Wikipedia, CC + BY-SA). Tap a theme to filter. +

+{/if} + +

Politics

+
+ {#each groups as g (g.qid)} + {@const list = showAll[g.qid] ? g.holders : g.holders.slice(0, 10)} +
+
+

{kindLabel(g.kind)}: {g.label}

+ {g.holders.length} holders on Wikidata, newest first +
+
+ + + {#each list as o (`${o.personQid}${o.start}`)} + + + + + + {/each} + +
{o.personLabel}{#if o.terms > 1} ({o.terms} terms){/if}{o.party ?? ''}{yr(o.start)}–{yr(o.end)}
+
+ {#if g.holders.length > 10} + + {/if} +
+ Source: Wikidata, position held (P39) of {g.qid} · CC0 +
+
+ {/each} + {#if turnoutPoints.length} + + lineOption(t, [{ name: 'Turnout', points: turnoutPoints, color, kind: 'bar' }], { + unit: '%', + decimals: 1, + min: 'dataMin' + })} + > + all election results on /stats/elections → + + {/if} + {#if turnoutSeries.length} + + lineOption( + t, + turnoutSeries.map((x, i) => ({ + name: x.label, + points: pts(x.id), + color: i ? '#64748b' : color + })), + { unit: '%', decimals: 1, min: 'dataMin' } + )} + /> + {/if} +
+ +

Culture

+
+ {#if languages.length} + barRankOption(t, languages, { unit: '%', decimals: 1, color })} + > + CBS Curaçao: talen gesproken in Curaçao + + {/if} + {#if nlReligion.length} + lineOption(t, nlReligion, { unit: '%', decimals: 1 })} + /> + {/if} + {#if gouda1930.length} + barRankOption(t, gouda1930, { unit: '%', decimals: 1, color })} + /> + {/if} + {#if pew.length} +
+
+

Religious composition, 2010 and 2020

+ % of the whole population +
+
+ + + + {#each pew as r (r.label)} + + {/each} + +
Group20102020
{r.label}{num(r.v2010, 1)}{num(r.v2020, 1)}
+
+
+ Source: Pew Research Center (2025) , via + Our World in Data +
+
+ {/if} + {#if h.holidays.length} +
+
+

Public holidays

+ next {h.holidays.length}{isGouda ? ' (national)' : ''} +
+
    + {#each h.holidays as d (`${d.date}${d.name}`)} +
  • + {fmtDate(d.date)} + {d.name}{#if d.localName && d.localName !== d.name} + · {d.localName}{/if}{#if !d.global} (regional){/if} +
  • + {/each} +
+
+ Source: Nager.Date public holiday API +
+
+ {/if} + {#if h.unesco.length} +
+
+

UNESCO World Heritage

+ {h.unesco.length} site{h.unesco.length === 1 ? '' : 's'} · year inscribed +
+
    + {#each h.unesco as u (u.id)} +
  • + {u.year ?? ''} + {u.name} + · {u.category.toLowerCase()}{u.danger ? ' · in danger' : ''} +
  • + {/each} +
+
+ Source: UNESCO World Heritage Centre open data · CC BY-SA 3.0 IGO +
+
+ {/if} + {#if h.people.length} +
+
+

{isGouda || isCw ? 'Well-known people born here' : 'Well-known people'}

+ ranked by the number of Wikipedia language editions +
+
    + {#each h.people as p (p.qid)} +
  1. + {p.sitelinks} + {p.label} + {p.born ?? '?'}{p.died ? `–${p.died}` : ''}{p.description + ? ` · ${p.description}` + : ''} +
  2. + {/each} +
+
+ Source: Wikidata ({isGouda || isCw ? 'place of birth' : 'citizenship'}), sitelink counts · CC0 +
+
+ {/if} + {#each [{ title: 'Food', rows: cuisine }, { title: 'Cultural exports', rows: exports_ }] as block (block.title)} + {#if block.rows.length} +
+
+

{block.title}

+ curated, with sources +
+ +
+ {/if} + {/each} +
+ + diff --git a/src/lib/stats/heritage.test.ts b/src/lib/stats/heritage.test.ts new file mode 100644 index 0000000..d6dd6a4 --- /dev/null +++ b/src/lib/stats/heritage.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { byCentury, filterEvents, indexTo, officeGroups, sourceLabel, yearLabel } from './heritage'; + +describe('heritage helpers', () => { + it('labels years and ranges, BCE included', () => { + expect(yearLabel(1568, 1648)).toBe('1568–1648'); + expect(yearLabel(1795, null)).toBe('1795'); + expect(yearLabel(-3700, null)).toBe('3700 BCE'); + }); + it('groups events by century with ordinal suffixes', () => { + const g = byCentury([{ year: -3700 }, { year: 1272 }, { year: 1295 }, { year: 2001 }]); + expect(g.map((x) => x.label)).toEqual([ + 'Before the common era', + '13th century', + '21st century' + ]); + expect(g[1].items).toHaveLength(2); + }); + it('filters by place and theme', () => { + const ev = [ + { place: 'NLD', theme: 'war' }, + { place: 'USA', theme: 'war' }, + { place: 'NLD', theme: 'culture' } + ]; + expect(filterEvents(ev, ['NLD'], ['war'])).toHaveLength(1); + expect(filterEvents(ev, [], [])).toHaveLength(3); + }); + it('merges consecutive terms of the same person', () => { + const row = (p: string, start: string) => ({ + officeQid: 'Q1', + officeLabel: 'PM', + kind: 'head_of_government', + personQid: p, + start, + end: null + }); + const [g] = officeGroups([ + row('A', '2020-01-01'), + row('A', '2010-01-01'), + row('B', '2000-01-01') + ]); + expect(g.holders.map((h) => [h.personQid, h.terms, h.start])).toEqual([ + ['A', 2, '2010-01-01'], + ['B', 1, '2000-01-01'] + ]); + }); + it('indexes a series and names sources', () => { + expect( + indexTo( + [ + ['1940-01-01', 5], + ['1950-01-01', 10], + ['1960-01-01', 15] + ], + '1950' + ) + ).toEqual([ + ['1950-01-01', 100], + ['1960-01-01', 150] + ]); + expect(sourceLabel('https://en.wikipedia.org/wiki/Gouda')).toBe('Wikipedia'); + expect(sourceLabel('https://nl.wikipedia.org/wiki/Gouda')).toBe('Wikipedia (nl)'); + }); +}); diff --git a/src/lib/stats/heritage.ts b/src/lib/stats/heritage.ts new file mode 100644 index 0000000..9bc6b5b --- /dev/null +++ b/src/lib/stats/heritage.ts @@ -0,0 +1,288 @@ +// §9 history and culture: the five places, the series each one shows, and small pure helpers +// (kept out of .svelte files so the components stay free of Map/Set state). +import type { StatPoint } from './chart.js'; + +export type HeritagePlace = { + code: 'NLD' | 'GM0513' | 'CUW' | 'USA' | 'KOR'; + name: string; + href: string; + color: string; + /** Exact series ids to load. */ + series: string[]; + /** SQL LIKE patterns for categories with many ids (religion, language). */ + seriesLike: string[]; + /** Area code in stats.elec_turnout (NL and Gouda only). */ + turnoutArea?: string; + turnoutLevel?: 'national' | 'muni'; + /** Wikidata turnout series ids (USA, KOR). */ + turnoutSeries?: { id: string; label: string }[]; + wikidata: string; +}; + +const longRun = (c: string) => [ + `owid:hist:population:${c}`, + `owid:hist:life-expectancy:${c}`, + `owid:hist:urban-long:${c}`, + `owid:hist:urban:${c}`, + `owid:maddison:${c}`, + `wb:NY.GDP.PCAP.PP.KD:${c}` +]; + +export const HERITAGE = { + NLD: { + code: 'NLD', + name: 'Netherlands', + href: '/stats/countries/NLD', + color: '#f97316', + series: [...longRun('NLD'), 'owid:hist:population:WLD', 'owid:hist:life-expectancy:WLD'], + seriesLike: ['cbs:religion:37944:%', 'cbs:religion:82904:%', 'pew:religion:%:NLD'], + turnoutArea: 'NL', + turnoutLevel: 'national', + wikidata: 'Q55' + }, + GM0513: { + code: 'GM0513', + name: 'Gouda', + href: '/stats/gouda', + color: '#eab308', + series: ['hist:pop:GM0513', 'cbs:pop:jan1:GM0513', 'owid:hist:population:NLD'], + seriesLike: ['cbs:religion:1930:%'], + turnoutArea: 'GM0513', + turnoutLevel: 'muni', + wikidata: 'Q84125' + }, + CUW: { + code: 'CUW', + name: 'Curaçao', + href: '/stats/countries/CUW', + color: '#0ea5e9', + series: [ + 'owid:cw:population', + 'owid:cw:life-expectancy', + 'cw:pop:jan1', + 'owid:hist:urban:CUW', + 'wb:NY.GDP.PCAP.PP.KD:CUW', + 'wb:NY.GDP.PCAP.KD:CUW' + ], + seriesLike: ['pew:religion:%:CUW', 'cbscw:census2023:language:%'], + wikidata: 'Q25279' + }, + USA: { + code: 'USA', + name: 'United States', + href: '/stats/countries/USA', + color: '#3b82f6', + series: longRun('USA'), + seriesLike: ['pew:religion:%:USA'], + turnoutSeries: [{ id: 'wikidata:turnout:uspres:USA', label: 'Presidential elections' }], + wikidata: 'Q30' + }, + KOR: { + code: 'KOR', + name: 'South Korea', + href: '/stats/countries/KOR', + color: '#e11d48', + series: longRun('KOR'), + seriesLike: ['pew:religion:%:KOR'], + turnoutSeries: [ + { id: 'wikidata:turnout:krpres:KOR', label: 'Presidential elections' }, + { id: 'wikidata:turnout:krleg:KOR', label: 'National Assembly elections' } + ], + wikidata: 'Q884' + } +} satisfies Record; +export type HeritageCode = keyof typeof HERITAGE; +export const isHeritage = (code: string): code is HeritageCode => code in HERITAGE; + +export const THEMES = [ + { id: 'politics', label: 'Politics', color: '#3b82f6' }, + { id: 'war', label: 'War', color: '#e11d48' }, + { id: 'economy', label: 'Economy', color: '#10b981' }, + { id: 'society', label: 'Society', color: '#a855f7' }, + { id: 'culture', label: 'Culture', color: '#eab308' }, + { id: 'science', label: 'Science', color: '#06b6d4' } +] as const; +export const themeColor = (id: string) => THEMES.find((t) => t.id === id)?.color ?? '#64748b'; + +/** "1568", "1568–1648", "c. 3700 BCE". */ +export function yearLabel(year: number, end: number | null) { + const y = (v: number) => (v < 0 ? `${-v} BCE` : String(v)); + return end && end !== year ? `${y(year)}–${y(end)}` : y(year); +} + +/** Group events by century for the timeline rail. */ +export function byCentury( + events: T[] +): { label: string; items: T[] }[] { + const out: { label: string; items: T[] }[] = []; + for (const e of events) { + const c = e.year < 0 ? 'Before the common era' : `${Math.floor(e.year / 100) + 1}th century`; + const label = c + .replace(/^1th/, '1st') + .replace(/^2th/, '2nd') + .replace(/^3th/, '3rd') + .replace(/^21th/, '21st'); + const last = out[out.length - 1]; + if (last?.label === label) last.items.push(e); + else out.push({ label, items: [e] }); + } + return out; +} + +const KIND_LABEL: Record = { + head_of_government: 'Head of government', + head_of_state: 'Head of state', + governor: 'Governor', + mayor: 'Mayor' +}; +export const kindLabel = (k: string) => KIND_LABEL[k] ?? k; + +/** Office holders grouped per office, newest first, with consecutive terms of one person merged. */ +export function officeGroups< + T extends { + officeQid: string; + officeLabel: string; + kind: string; + personQid: string; + start: string; + end: string | null; + } +>(rows: T[]) { + const groups: { qid: string; label: string; kind: string; holders: (T & { terms: number })[] }[] = + []; + for (const r of rows) { + let g = groups.find((x) => x.qid === r.officeQid); + if (!g) + groups.push((g = { qid: r.officeQid, label: r.officeLabel, kind: r.kind, holders: [] })); + const prev = g.holders[g.holders.length - 1]; + if (prev && prev.personQid === r.personQid) { + prev.start = r.start; + prev.terms++; + } else g.holders.push({ ...r, terms: 1 }); + } + return groups; +} + +/** Latest value of each series whose id starts with prefix, as a bar list. */ +export function sharesByPrefix( + series: Record, + prefix: string, + titleCut = /,.*$/ +): { label: string; value: number; year: string }[] { + return Object.entries(series) + .filter(([id, s]) => id.startsWith(prefix) && s.points.length) + .map(([, s]) => { + const [d, v] = s.points[s.points.length - 1]; + return { label: s.title.replace(titleCut, ''), value: v, year: d.slice(0, 4) }; + }) + .sort((a, b) => b.value - a.value); +} + +/** Short label for a source link ("Wikipedia", "nl.wikipedia", "Wikidata", host otherwise). */ +export function sourceLabel(url: string) { + try { + const host = new URL(url).hostname.replace(/^www\./, ''); + if (host === 'en.wikipedia.org') return 'Wikipedia'; + if (host.endsWith('.wikipedia.org')) return `Wikipedia (${host.split('.')[0]})`; + if (host === 'wikidata.org') return 'Wikidata'; + return host; + } catch { + return 'source'; + } +} + +/** Pew 2010/2020 rows as a table: label, 2010, 2020. */ +export function pewTable( + series: Record, + code: string +) { + return Object.entries(series) + .filter(([id]) => id.startsWith('pew:religion:') && id.endsWith(`:${code}`)) + .map(([, s]) => ({ + label: s.title.replace(/, share of population$/, ''), + v2010: s.points.find(([d]) => d.startsWith('2010'))?.[1] ?? null, + v2020: s.points.find(([d]) => d.startsWith('2020'))?.[1] ?? null + })) + .sort((a, b) => (b.v2020 ?? 0) - (a.v2020 ?? 0)); +} + +export function filterEvents( + events: T[], + places: string[], + themes: string[] +) { + return events.filter( + (e) => + (!places.length || places.includes(e.place)) && (!themes.length || themes.includes(e.theme)) + ); +} + +export type HistEvent = { + place: string; + slug: string; + year: number; + yearEnd: number | null; + dateLabel: string | null; + title: string; + summary: string; + theme: string; + sourceUrl: string; +}; +export type HistFact = { + slug: string; + topic: string; + title: string; + body: string; + sourceUrl: string; +}; +export type HistOffice = { + kind: string; + officeQid: string; + officeLabel: string; + personQid: string; + personLabel: string; + party: string | null; + start: string; + end: string | null; +}; +export type HistPerson = { + qid: string; + label: string; + description: string | null; + born: number | null; + died: number | null; + sitelinks: number; + url: string; +}; +export type HistHoliday = { date: string; name: string; localName: string | null; global: boolean }; +export type UnescoSite = { + id: number; + name: string; + category: string; + year: number | null; + danger: boolean; + description: string | null; +}; +export type Turnout = { election: string; date: string; pct: number; sourceId: string }; + +export type Heritage = { + place: HeritagePlace; + events: HistEvent[]; + facts: HistFact[]; + offices: HistOffice[]; + people: HistPerson[]; + holidays: HistHoliday[]; + unesco: UnescoSite[]; + turnout: Turnout[]; + series: Record< + string, + { id: string; title: string; unit: string; sourceId: string; points: StatPoint[] } + >; +}; + +/** Rebase a series to 100 at the first point in or after the given year. */ +export function indexTo(points: StatPoint[], year: string): StatPoint[] { + const base = points.find(([d]) => d >= year)?.[1]; + if (!base) return []; + return points.filter(([d]) => d >= year).map(([d, v]) => [d, Math.round((v / base) * 1000) / 10]); +} diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index 6f04bea..96b5e07 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -22,6 +22,10 @@ const staticPaths = [ '/stats/countries/CUW', '/stats/elections', '/stats/opinions', + '/stats/history', + '/stats/countries/NLD', + '/stats/countries/USA', + '/stats/countries/KOR', '/trackers/books', '/trackers/spotting', '/trackers/cars', diff --git a/src/routes/stats/+layout.svelte b/src/routes/stats/+layout.svelte index 6733552..a7a3bc9 100644 --- a/src/routes/stats/+layout.svelte +++ b/src/routes/stats/+layout.svelte @@ -9,6 +9,7 @@ import Globe from 'lucide-svelte/icons/globe'; import Vote from 'lucide-svelte/icons/vote'; import MessageCircle from 'lucide-svelte/icons/message-circle'; + import ScrollText from 'lucide-svelte/icons/scroll-text'; let { children } = $props(); const icons = { overview: ChartLine, @@ -17,7 +18,8 @@ gouda: House, countries: Globe, elections: Vote, - opinions: MessageCircle + opinions: MessageCircle, + history: ScrollText } as const; const tabs = [ { name: 'overview', href: '/stats' }, @@ -103,6 +105,9 @@ .tab-elections { background: linear-gradient(135deg, #06b6d4, #3b82f6); } + .tab-history { + background: linear-gradient(135deg, #b45309, #eab308); + } .tab-opinions { background: linear-gradient(135deg, #ec4899, #f97316); } diff --git a/src/routes/stats/+page.svelte b/src/routes/stats/+page.svelte index 1054bab..2aae6cc 100644 --- a/src/routes/stats/+page.svelte +++ b/src/routes/stats/+page.svelte @@ -11,6 +11,7 @@ import Palmtree from 'lucide-svelte/icons/tree-palm'; import Vote from 'lucide-svelte/icons/vote'; import MessageCircle from 'lucide-svelte/icons/message-circle'; + import ScrollText from 'lucide-svelte/icons/scroll-text'; import ArrowRight from 'lucide-svelte/icons/arrow-right'; let { data } = $props(); @@ -88,6 +89,13 @@ grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)', text: 'Every Dutch general election since 1918, Gouda down to the polling station, and the US presidential vote by state.' }, + { + href: '/stats/history', + title: 'History and culture', + icon: ScrollText, + grad: 'linear-gradient(135deg,#b45309,#eab308)', + 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', title: 'Opinions', diff --git a/src/routes/stats/countries/[iso3]/+page.server.ts b/src/routes/stats/countries/[iso3]/+page.server.ts index 8a19e29..ce7bd16 100644 --- a/src/routes/stats/countries/[iso3]/+page.server.ts +++ b/src/routes/stats/countries/[iso3]/+page.server.ts @@ -2,6 +2,8 @@ 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 }) => { @@ -9,6 +11,7 @@ export const load: PageServerLoad = async ({ params, url }) => { 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}`), @@ -22,5 +25,5 @@ export const load: PageServerLoad = async ({ params, url }) => { ...(code === 'CUW' ? CW_SERIES : []) ]) ); - return { code, series }; + return { code, series, heritage: heritage ? await heritage : null }; }; diff --git a/src/routes/stats/countries/[iso3]/+page.svelte b/src/routes/stats/countries/[iso3]/+page.svelte index d265520..d74c37f 100644 --- a/src/routes/stats/countries/[iso3]/+page.svelte +++ b/src/routes/stats/countries/[iso3]/+page.svelte @@ -15,6 +15,7 @@ import { COUNTRY } from '#lib/stats/regions.js'; import { COUNTRY_COLOR, FLAG } from '#lib/stats/countries.js'; import ArrowLeft from 'lucide-svelte/icons/arrow-left'; + import PlaceHeritage from '#lib/stats/PlaceHeritage.svelte'; let { data } = $props(); const c = $derived(data.code); @@ -713,6 +714,10 @@ {/if} +{#if data.heritage} + +{/if} +