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
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:
248
src/lib/stats/EventTimeline.svelte
Normal file
248
src/lib/stats/EventTimeline.svelte
Normal 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>
|
||||
532
src/lib/stats/PlaceHeritage.svelte
Normal file
532
src/lib/stats/PlaceHeritage.svelte
Normal 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>
|
||||
64
src/lib/stats/heritage.test.ts
Normal file
64
src/lib/stats/heritage.test.ts
Normal 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
288
src/lib/stats/heritage.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user