stats: /stats/history and History, Politics and Culture sections on NL, Gouda, Curaçao, US and KR pages (step 4)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s

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.
This commit is contained in:
2026-10-25 23:39:34 +00:00
parent e0bcd63ce3
commit 9b21220280
16 changed files with 1482 additions and 7 deletions

View File

@@ -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',

View File

@@ -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<HistEvent[]> {
return client<HistEvent[]>`
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<string[]> {
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<Heritage> {
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<HistFact[]>`
select slug, topic, title, body, source_url as "sourceUrl" from stats.hist_fact
where place = ${place} order by sort`,
client<HistOffice[]>`
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<HistPerson[]>`
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<HistHoliday[]>`
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<UnescoSite[]>`
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<Turnout[]>`
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 };
});
}

View File

@@ -0,0 +1,248 @@
<script lang="ts">
// Interactive timeline of curated events (stats.hist_event): theme filters, optional place
// filters, grouped by century. Every event links to its source.
import {
byCentury,
filterEvents,
HERITAGE,
isHeritage,
sourceLabel,
THEMES,
themeColor,
yearLabel
} from '#lib/stats/heritage.js';
import ExternalLink from 'lucide-svelte/icons/external-link';
type Ev = {
place: string;
slug: string;
year: number;
yearEnd: number | null;
dateLabel: string | null;
title: string;
summary: string;
theme: string;
sourceUrl: string;
};
let { events, showPlaces = false }: { events: Ev[]; showPlaces?: boolean } = $props();
let themes = $state<string[]>([]);
let places = $state<string[]>([]);
let open = $state<string | null>(null);
const placeCodes = $derived(
showPlaces ? Object.keys(HERITAGE).filter((c) => events.some((e) => e.place === c)) : []
);
const shown = $derived(filterEvents(events, places, themes));
const groups = $derived(byCentury(shown));
const toggle = (list: string[], id: string) =>
list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
const placeName = (c: string) => (isHeritage(c) ? HERITAGE[c].name : c);
const placeColor = (c: string) => (isHeritage(c) ? HERITAGE[c].color : '#64748b');
</script>
<div class="timeline">
<div class="filters" role="toolbar" aria-label="Filter events">
{#each THEMES as t (t.id)}
<button
type="button"
class:active={themes.includes(t.id)}
style:--c={t.color}
aria-pressed={themes.includes(t.id)}
onclick={() => (themes = toggle(themes, t.id))}><span class="dot"></span>{t.label}</button
>
{/each}
{#if placeCodes.length > 1}
<span class="sep"></span>
{#each placeCodes as c (c)}
<button
type="button"
class:active={places.includes(c)}
style:--c={placeColor(c)}
aria-pressed={places.includes(c)}
onclick={() => (places = toggle(places, c))}
><span class="dot"></span>{placeName(c)}</button
>
{/each}
{/if}
{#if themes.length || places.length}
<button type="button" class="clear" onclick={() => ((themes = []), (places = []))}
>clear</button
>
{/if}
<span class="count">{shown.length} events</span>
</div>
{#each groups as g (g.label)}
<h4 class="century">{g.label}</h4>
<ol>
{#each g.items as e (`${e.place}/${e.slug}`)}
{@const key = `${e.place}/${e.slug}`}
<li style:--c={themeColor(e.theme)}>
<button
type="button"
class="head"
aria-expanded={open === key}
onclick={() => (open = open === key ? null : key)}
>
<span class="year">{yearLabel(e.year, e.yearEnd)}</span>
<span class="title">{e.title}</span>
{#if showPlaces}<span class="place" style:--p={placeColor(e.place)}
>{placeName(e.place)}</span
>{/if}
</button>
{#if open === key || !showPlaces}
<p>
{#if e.dateLabel}<span class="date">{e.dateLabel}.</span>{/if}
{e.summary}
<a href={e.sourceUrl} target="_blank" rel="noopener noreferrer"
>{sourceLabel(e.sourceUrl)} <ExternalLink size={11} /></a
>
</p>
{/if}
</li>
{/each}
</ol>
{:else}
<p class="empty">No events match these filters.</p>
{/each}
</div>
<style>
.timeline {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
position: sticky;
top: 0;
background: var(--site-bg);
padding-bottom: 0.4rem;
z-index: 1;
}
.filters button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--site-border);
background: none;
color: inherit;
border-radius: 999px;
padding: 0.2rem 0.65rem;
font-size: 0.78rem;
cursor: pointer;
}
.filters button.active {
border-color: var(--c);
background: color-mix(in srgb, var(--c) 14%, transparent);
}
.filters .dot {
background: var(--c);
}
.filters .clear {
color: var(--site-muted);
}
.sep {
width: 1px;
height: 1.2rem;
background: var(--site-border);
}
.count {
margin-left: auto;
color: var(--site-muted);
font-size: 0.75rem;
}
.century {
margin: 0.6rem 0 0;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
ol {
list-style: none;
margin: 0;
padding: 0 0 0 0.9rem;
border-left: 2px solid var(--site-border);
display: flex;
flex-direction: column;
gap: 0.55rem;
}
li {
position: relative;
}
li::before {
content: '';
position: absolute;
left: calc(-0.9rem - 6px);
top: 0.4rem;
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--c);
}
.head {
display: flex;
gap: 0.6rem;
align-items: baseline;
flex-wrap: wrap;
background: none;
border: 0;
padding: 0;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.year {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 0.82rem;
min-width: 5.5rem;
color: var(--c);
}
.title {
font-weight: 550;
font-size: 0.92rem;
}
.place {
font-size: 0.7rem;
border-radius: 999px;
padding: 0 0.45rem;
background: color-mix(in srgb, var(--p) 16%, transparent);
}
p {
margin: 0.2rem 0 0 6.1rem;
color: var(--site-muted);
font-size: 0.84rem;
line-height: 1.45;
}
@media (max-width: 640px) {
p {
margin-left: 0;
}
}
.date {
color: var(--site-text, inherit);
margin-right: 0.25rem;
}
p a {
color: inherit;
font-size: 0.75rem;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
.empty {
color: var(--site-muted);
font-size: 0.85rem;
}
</style>

View File

@@ -0,0 +1,532 @@
<script lang="ts">
// §9: History, Politics and Culture sections for one place (country pages and /stats/gouda).
// 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';
import StatChart from '#lib/stats/StatChart.svelte';
import EventTimeline from '#lib/stats/EventTimeline.svelte';
import { barRankOption, lineOption, type Line, type StatPoint } from '#lib/stats/chart.js';
import { num } from '#lib/stats/format.js';
import {
kindLabel,
officeGroups,
pewTable,
sharesByPrefix,
sourceLabel
} from '#lib/stats/heritage.js';
import type { Heritage } from '#lib/stats/heritage.js';
import ExternalLink from 'lucide-svelte/icons/external-link';
let { h, attributions: a }: { h: Heritage; attributions: Record<string, string> } = $props();
const c = $derived(h.place.code);
const color = $derived(h.place.color);
const s = $derived(h.series);
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
const has = (id: string) => pts(id).length > 0;
const line = (name: string, id: string, col: string, extra: Partial<Line> = {}): Line[] =>
has(id) ? [{ name, points: pts(id), color: col, ...extra }] : [];
const isGouda = $derived(c === 'GM0513');
const isCw = $derived(c === 'CUW');
const popLines = $derived(
isGouda
? [
...line('Estimates (Lourens & Lucassen)', 'hist:pop:GM0513', color, { kind: 'step' }),
...line('CBS, 1 January', 'cbs:pop:jan1:GM0513', '#64748b')
]
: isCw
? [
...line('CBS Curaçao, 1 January', 'cw:pop:jan1', color),
...line('UN WPP (OWID)', 'owid:cw:population', '#64748b', { dashed: true })
]
: line(h.place.name, `owid:hist:population:${c}`, color, { kind: 'area' })
);
const gdpLines = $derived([
...line('Maddison Project (2011 int. $)', `owid:maddison:${c}`, color),
...line('World Bank, PPP (2021 int. $)', `wb:NY.GDP.PCAP.PP.KD:${c}`, '#10b981', {
dashed: true
})
]);
const lifeLines = $derived(
isCw
? line('Curaçao', 'owid:cw:life-expectancy', color)
: [
...line(h.place.name, `owid:hist:life-expectancy:${c}`, color),
...line('World', 'owid:hist:life-expectancy:WLD', '#94a3b8', { dashed: true })
]
);
const urbanLines = $derived([
...line('Long run (HYDE)', `owid:hist:urban-long:${c}`, color),
...line('UN WUP', `owid:hist:urban:${c}`, '#64748b', { dashed: true })
]);
const groups = $derived(officeGroups(h.offices));
let showAll = $state<Record<string, boolean>>({});
const turnoutPoints = $derived(h.turnout.map((t): StatPoint => [t.date, t.pct]));
const turnoutSeries = $derived((h.place.turnoutSeries ?? []).filter((t) => has(t.id)));
const nlReligion = $derived(
c === 'NLD'
? [
...line('None', 'cbs:religion:37944:none:NL01', '#64748b'),
...line('Roman Catholic', 'cbs:religion:37944:catholic:NL01', '#e11d48'),
...line('Dutch Reformed', 'cbs:religion:37944:hervormd:NL01', '#f97316'),
...line('Gereformeerd', 'cbs:religion:37944:gereformeerd:NL01', '#3b82f6'),
...line('PKN (from 2004)', 'cbs:religion:37944:pkn:NL01', '#a855f7'),
...line('Other', 'cbs:religion:37944:other:NL01', '#10b981'),
...line('None (15+, from 2010)', 'cbs:religion:82904:none:NL01', '#64748b', {
dashed: true
}),
...line('Islam (15+, from 2010)', 'cbs:religion:82904:islam:NL01', '#06b6d4', {
dashed: true
})
]
: []
);
const gouda1930 = $derived(
isGouda ? sharesByPrefix(s, 'cbs:religion:1930:', /, share of residents.*$/) : []
);
const pew = $derived(pewTable(s, c));
const languages = $derived(isCw ? sharesByPrefix(s, 'cbscw:census2023:language:', /^.*: /) : []);
const fmtDate = (d: string) =>
new Date(`${d}T12:00:00Z`).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
const yr = (d: string | null) => (d ? d.slice(0, 4) : 'now');
const cuisine = $derived(h.facts.filter((f) => f.topic === 'cuisine'));
const exports_ = $derived(h.facts.filter((f) => f.topic === 'export'));
</script>
<h2 class="stats-section-title" id="history">History</h2>
<div class="stats-grid">
{#if popLines.length}
<StatChart
title={isGouda
? 'Population since 1382'
: isCw
? 'Population since 1901'
: 'Population since 1500'}
subtitle={isGouda
? 'inhabitants · historical estimates, then CBS from 1942'
: isCw
? 'inhabitants · CBS Curaçao and UN estimates'
: 'inhabitants · HYDE, Gapminder and UN, via Our World in Data'}
sources={isGouda ? ['history', 'cbs'] : isCw ? ['cbscw', 'owid'] : ['owid']}
attributions={a}
note={isGouda
? 'Before 1800: Lourens & Lucassen (1997), Inwonertallen van Nederlandse steden ca. 1300–1800, as tabulated on Wikipedia.'
: ''}
option={(t) => lineOption(t, popLines, { decimals: 0 })}
/>
{/if}
{#if gdpLines.length}
<StatChart
title="GDP per person, long run"
subtitle="Maddison Project Database 2023 · World Bank from 1990 (different price base)"
sources={['owid', 'worldbank']}
attributions={a}
option={(t) => lineOption(t, gdpLines, { unit: '$', decimals: 0 })}
/>
{/if}
{#if lifeLines.length}
<StatChart
title="Life expectancy, long run"
subtitle="years at birth · Riley, Zijdeman et al., HMD and UN WPP via Our World in Data"
sources={['owid']}
attributions={a}
option={(t) => lineOption(t, lifeLines, { unit: 'years', decimals: 1 })}
/>
{/if}
{#if urbanLines.length}
<StatChart
title="Urbanisation"
subtitle="% of people living in urban areas · HYDE long run, UN since 1950"
sources={['owid']}
attributions={a}
option={(t) => lineOption(t, urbanLines, { unit: '%', decimals: 1 })}
/>
{/if}
</div>
{#if h.events.length}
<h3 class="sub">Timeline</h3>
<EventTimeline events={h.events} />
<p class="stats-note">
{h.events.length} key events, curated and summarised from the linked articles (mostly Wikipedia, CC
BY-SA). Tap a theme to filter.
</p>
{/if}
<h2 class="stats-section-title" id="politics">Politics</h2>
<div class="stats-grid">
{#each groups as g (g.qid)}
{@const list = showAll[g.qid] ? g.holders : g.holders.slice(0, 10)}
<figure class="h-panel">
<header>
<h3>{kindLabel(g.kind)}: {g.label}</h3>
<span>{g.holders.length} holders on Wikidata, newest first</span>
</header>
<div class="table-wrap">
<table class="stats-table">
<tbody>
{#each list as o (`${o.personQid}${o.start}`)}
<tr>
<td
><a
href={`https://www.wikidata.org/wiki/${o.personQid}`}
target="_blank"
rel="noopener noreferrer">{o.personLabel}</a
>{#if o.terms > 1}<span class="muted"> ({o.terms} terms)</span>{/if}</td
>
<td class="muted party">{o.party ?? ''}</td>
<td class="num">{yr(o.start)}–{yr(o.end)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if g.holders.length > 10}
<button
type="button"
class="more"
onclick={() => (showAll = { ...showAll, [g.qid]: !showAll[g.qid] })}
>{showAll[g.qid] ? 'show fewer' : `show all ${g.holders.length}`}</button
>
{/if}
<figcaption>
<a href={`https://www.wikidata.org/wiki/${g.qid}`} target="_blank" rel="noopener noreferrer"
>Source: Wikidata, position held (P39) of {g.qid} <ExternalLink size={11} /></a
> · CC0
</figcaption>
</figure>
{/each}
{#if turnoutPoints.length}
<StatChart
title="Turnout, House of Representatives elections"
subtitle={isGouda ? '% of eligible voters in Gouda' : '% of eligible voters'}
sources={['kiesraad', 'cbs']}
attributions={a}
option={(t) =>
lineOption(t, [{ name: 'Turnout', points: turnoutPoints, color, kind: 'bar' }], {
unit: '%',
decimals: 1,
min: 'dataMin'
})}
>
<a class="inline-link" href={resolve('/stats/elections')}
>all election results on /stats/elections →</a
>
</StatChart>
{/if}
{#if turnoutSeries.length}
<StatChart
title="Turnout, national elections"
subtitle="% ballots cast of eligible voters, as recorded on Wikidata"
sources={['wikidata']}
attributions={a}
note="Only elections where Wikidata has both eligible voters (P1867) and ballots cast (P1868); gaps are missing data, not missing elections. US figures use Wikidata's eligible counts, which mix voting-age and voting-eligible population."
option={(t) =>
lineOption(
t,
turnoutSeries.map((x, i) => ({
name: x.label,
points: pts(x.id),
color: i ? '#64748b' : color
})),
{ unit: '%', decimals: 1, min: 'dataMin' }
)}
/>
{/if}
</div>
<h2 class="stats-section-title" id="culture">Culture</h2>
<div class="stats-grid">
{#if languages.length}
<StatChart
title="Languages spoken at home"
subtitle="% of people, main language at home · Census 2023"
sources={['cbscw']}
attributions={a}
height="220px"
note="CBS Curaçao, first results of Census 2023 (147,498 respondents); about 90% speak Papiamentu at all. Not comparable with earlier censuses, which asked per household."
option={(t) => barRankOption(t, languages, { unit: '%', decimals: 1, color })}
>
<a
class="inline-link"
href="https://senso.cbs.cw/talen-gesproken-in-curacao-resultaten-census-2023"
target="_blank"
rel="noopener noreferrer"
>CBS Curaçao: talen gesproken in Curaçao <ExternalLink size={11} /></a
>
</StatChart>
{/if}
{#if nlReligion.length}
<StatChart
title="Religion since 1849"
subtitle="% of adults · censuses to 1971, surveys after · dashed: 15+, from 2010"
sources={['cbs']}
attributions={a}
option={(t) => lineOption(t, nlReligion, { unit: '%', decimals: 1 })}
/>
{/if}
{#if gouda1930.length}
<StatChart
title="Religion in Gouda, 1930 census"
subtitle="% of residents by denomination"
sources={['cbs']}
attributions={a}
height="300px"
note="CBS table 71124NED (Volkstelling 1930). CBS publishes no recent religion figures per municipality."
option={(t) => barRankOption(t, gouda1930, { unit: '%', decimals: 1, color })}
/>
{/if}
{#if pew.length}
<figure class="h-panel">
<header>
<h3>Religious composition, 2010 and 2020</h3>
<span>% of the whole population</span>
</header>
<div class="table-wrap">
<table class="stats-table">
<thead><tr><th>Group</th><th class="num">2010</th><th class="num">2020</th></tr></thead>
<tbody>
{#each pew as r (r.label)}
<tr
><td>{r.label}</td><td class="num">{num(r.v2010, 1)}</td><td class="num"
>{num(r.v2020, 1)}</td
></tr
>
{/each}
</tbody>
</table>
</div>
<figcaption>
<a
href="https://www.pewresearch.org/religion/feature/religious-composition-by-country-2010-2020/"
target="_blank"
rel="noopener noreferrer">Source: Pew Research Center (2025) <ExternalLink size={11} /></a
>, via
<a
href="https://ourworldindata.org/grapher/share-of-population-by-religious-affiliation"
target="_blank"
rel="noopener noreferrer">Our World in Data</a
>
</figcaption>
</figure>
{/if}
{#if h.holidays.length}
<figure class="h-panel">
<header>
<h3>Public holidays</h3>
<span>next {h.holidays.length}{isGouda ? ' (national)' : ''}</span>
</header>
<ul class="list">
{#each h.holidays as d (`${d.date}${d.name}`)}
<li>
<span class="when">{fmtDate(d.date)}</span>
<span
>{d.name}{#if d.localName && d.localName !== d.name}<span class="muted">
· {d.localName}</span
>{/if}{#if !d.global}<span class="muted"> (regional)</span>{/if}</span
>
</li>
{/each}
</ul>
<figcaption>
<a href="https://date.nager.at/" target="_blank" rel="noopener noreferrer"
>Source: Nager.Date public holiday API <ExternalLink size={11} /></a
>
</figcaption>
</figure>
{/if}
{#if h.unesco.length}
<figure class="h-panel">
<header>
<h3>UNESCO World Heritage</h3>
<span>{h.unesco.length} site{h.unesco.length === 1 ? '' : 's'} · year inscribed</span>
</header>
<ul class="list">
{#each h.unesco as u (u.id)}
<li>
<span class="when">{u.year ?? ''}</span>
<span
><a
href={`https://whc.unesco.org/en/list/${u.id}`}
target="_blank"
rel="noopener noreferrer">{u.name}</a
>
<span class="muted">· {u.category.toLowerCase()}{u.danger ? ' · in danger' : ''}</span
></span
>
</li>
{/each}
</ul>
<figcaption>
<a
href="https://data.unesco.org/explore/dataset/whc001/"
target="_blank"
rel="noopener noreferrer"
>Source: UNESCO World Heritage Centre open data <ExternalLink size={11} /></a
> · CC BY-SA 3.0 IGO
</figcaption>
</figure>
{/if}
{#if h.people.length}
<figure class="h-panel">
<header>
<h3>{isGouda || isCw ? 'Well-known people born here' : 'Well-known people'}</h3>
<span>ranked by the number of Wikipedia language editions</span>
</header>
<ol class="list people">
{#each h.people as p (p.qid)}
<li>
<span class="when">{p.sitelinks}</span>
<span
><a href={p.url} target="_blank" rel="noopener noreferrer">{p.label}</a>
<span class="muted"
>{p.born ?? '?'}{p.died ? `–${p.died}` : ''}{p.description
? ` · ${p.description}`
: ''}</span
></span
>
</li>
{/each}
</ol>
<figcaption>
<a
href={`https://www.wikidata.org/wiki/${h.place.wikidata}`}
target="_blank"
rel="noopener noreferrer"
>Source: Wikidata ({isGouda || isCw ? 'place of birth' : 'citizenship'}), sitelink counts <ExternalLink
size={11}
/></a
> · CC0
</figcaption>
</figure>
{/if}
{#each [{ title: 'Food', rows: cuisine }, { title: 'Cultural exports', rows: exports_ }] as block (block.title)}
{#if block.rows.length}
<figure class="h-panel">
<header>
<h3>{block.title}</h3>
<span>curated, with sources</span>
</header>
<ul class="list facts">
{#each block.rows as f (f.slug)}
<li>
<strong>{f.title}</strong>
<span class="muted"
>{f.body}
<a href={f.sourceUrl} target="_blank" rel="noopener noreferrer"
>{sourceLabel(f.sourceUrl)} <ExternalLink size={11} /></a
></span
>
</li>
{/each}
</ul>
</figure>
{/if}
{/each}
</div>
<style>
.sub {
font-size: 1rem;
font-weight: 600;
margin: 0.5rem 0 0;
}
.h-panel {
margin: 0;
min-width: 0;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-bg);
padding: 1rem 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.h-panel header {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.h-panel h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
.h-panel header span,
.muted {
color: var(--site-muted);
font-size: 0.78rem;
}
.h-panel figcaption {
color: var(--site-muted);
font-size: 0.72rem;
margin-top: auto;
}
.h-panel a,
.inline-link {
color: inherit;
}
figcaption a,
.list a,
.inline-link {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
.inline-link {
font-size: 0.78rem;
color: var(--site-muted);
}
.table-wrap {
max-height: 420px;
overflow-y: auto;
}
.party {
white-space: normal !important;
max-width: 16rem;
}
.more {
align-self: flex-start;
border: 1px solid var(--site-border);
background: none;
color: inherit;
border-radius: 999px;
padding: 0.15rem 0.7rem;
font-size: 0.75rem;
cursor: pointer;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.85rem;
}
.list li {
display: flex;
gap: 0.6rem;
align-items: baseline;
}
.list .when {
min-width: 5.8rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
font-size: 0.78rem;
flex: none;
}
.people .when {
min-width: 2rem;
text-align: right;
}
.facts li {
flex-direction: column;
gap: 0.1rem;
}
</style>

View File

@@ -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)');
});
});

288
src/lib/stats/heritage.ts Normal file
View File

@@ -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<string, HeritagePlace>;
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<T extends { year: number }>(
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<string, string> = {
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<string, { title: string; points: [string, number][] }>,
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<string, { title: string; points: [string, number][] }>,
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<T extends { place: string; theme: string }>(
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]);
}