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:
@@ -305,7 +305,7 @@ async function officesJob(note: (w: string) => void) {
|
|||||||
FILTER(?rank != wikibase:DeprecatedRank)
|
FILTER(?rank != wikibase:DeprecatedRank)
|
||||||
OPTIONAL { ?st pq:P582 ?end }
|
OPTIONAL { ?st pq:P582 ?end }
|
||||||
OPTIONAL { ?p wdt:P102 ?party. ?party rdfs:label ?partyName. FILTER(lang(?partyName) = "en") }
|
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`);
|
} GROUP BY ?p ?pLabel ?officeLabel ?start ?end`);
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const r of b) {
|
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 { ?p wdt:P569 ?b } OPTIONAL { ?p wdt:P570 ?d }
|
||||||
OPTIONAL { ?en schema:about ?p; schema:isPartOf <https://en.wikipedia.org/> }
|
OPTIONAL { ?en schema:about ?p; schema:isPartOf <https://en.wikipedia.org/> }
|
||||||
OPTIONAL { ?nl schema:about ?p; schema:isPartOf <https://nl.wikipedia.org/> }
|
OPTIONAL { ?nl schema:about ?p; schema:isPartOf <https://nl.wikipedia.org/> }
|
||||||
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`);
|
} GROUP BY ?p ?pLabel ?pDescription`);
|
||||||
const info = new Map(detail.map((r) => [qid(r.p), r]));
|
const info = new Map(detail.map((r) => [qid(r.p), r]));
|
||||||
top.forEach(([q, n], i) => {
|
top.forEach(([q, n], i) => {
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ export const statsPages = [
|
|||||||
href: '/stats/elections',
|
href: '/stats/elections',
|
||||||
external: false
|
external: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'history',
|
||||||
|
subtitle: 'the long run, a sourced timeline, politics and culture of five places',
|
||||||
|
href: '/stats/history',
|
||||||
|
external: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'opinions',
|
name: 'opinions',
|
||||||
subtitle: 'polls, Eurobarometer, happiness, search trends and party positions',
|
subtitle: 'polls, Eurobarometer, happiness, search trends and party positions',
|
||||||
|
|||||||
100
src/lib/server/stats-history.ts
Normal file
100
src/lib/server/stats-history.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
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]);
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ const staticPaths = [
|
|||||||
'/stats/countries/CUW',
|
'/stats/countries/CUW',
|
||||||
'/stats/elections',
|
'/stats/elections',
|
||||||
'/stats/opinions',
|
'/stats/opinions',
|
||||||
|
'/stats/history',
|
||||||
|
'/stats/countries/NLD',
|
||||||
|
'/stats/countries/USA',
|
||||||
|
'/stats/countries/KOR',
|
||||||
'/trackers/books',
|
'/trackers/books',
|
||||||
'/trackers/spotting',
|
'/trackers/spotting',
|
||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import Globe from 'lucide-svelte/icons/globe';
|
import Globe from 'lucide-svelte/icons/globe';
|
||||||
import Vote from 'lucide-svelte/icons/vote';
|
import Vote from 'lucide-svelte/icons/vote';
|
||||||
import MessageCircle from 'lucide-svelte/icons/message-circle';
|
import MessageCircle from 'lucide-svelte/icons/message-circle';
|
||||||
|
import ScrollText from 'lucide-svelte/icons/scroll-text';
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
const icons = {
|
const icons = {
|
||||||
overview: ChartLine,
|
overview: ChartLine,
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
gouda: House,
|
gouda: House,
|
||||||
countries: Globe,
|
countries: Globe,
|
||||||
elections: Vote,
|
elections: Vote,
|
||||||
opinions: MessageCircle
|
opinions: MessageCircle,
|
||||||
|
history: ScrollText
|
||||||
} as const;
|
} as const;
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ name: 'overview', href: '/stats' },
|
{ name: 'overview', href: '/stats' },
|
||||||
@@ -103,6 +105,9 @@
|
|||||||
.tab-elections {
|
.tab-elections {
|
||||||
background: linear-gradient(135deg, #06b6d4, #3b82f6);
|
background: linear-gradient(135deg, #06b6d4, #3b82f6);
|
||||||
}
|
}
|
||||||
|
.tab-history {
|
||||||
|
background: linear-gradient(135deg, #b45309, #eab308);
|
||||||
|
}
|
||||||
.tab-opinions {
|
.tab-opinions {
|
||||||
background: linear-gradient(135deg, #ec4899, #f97316);
|
background: linear-gradient(135deg, #ec4899, #f97316);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import Palmtree from 'lucide-svelte/icons/tree-palm';
|
import Palmtree from 'lucide-svelte/icons/tree-palm';
|
||||||
import Vote from 'lucide-svelte/icons/vote';
|
import Vote from 'lucide-svelte/icons/vote';
|
||||||
import MessageCircle from 'lucide-svelte/icons/message-circle';
|
import MessageCircle from 'lucide-svelte/icons/message-circle';
|
||||||
|
import ScrollText from 'lucide-svelte/icons/scroll-text';
|
||||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -88,6 +89,13 @@
|
|||||||
grad: 'linear-gradient(135deg,#06b6d4,#3b82f6)',
|
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.'
|
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',
|
href: '/stats/opinions',
|
||||||
title: 'Opinions',
|
title: 'Opinions',
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { error, redirect } from '@sveltejs/kit';
|
|||||||
import { cached, loadSeries } from '#lib/server/stats.js';
|
import { cached, loadSeries } from '#lib/server/stats.js';
|
||||||
import { isCountryPage, PROFILE_SERIES } from '#lib/stats/countries.js';
|
import { isCountryPage, PROFILE_SERIES } from '#lib/stats/countries.js';
|
||||||
import { CW_SERIES } from '#lib/stats/curacao.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';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, url }) => {
|
export const load: PageServerLoad = async ({ params, url }) => {
|
||||||
@@ -9,6 +11,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
|||||||
if (code !== params.iso3 && isCountryPage(code))
|
if (code !== params.iso3 && isCountryPage(code))
|
||||||
redirect(308, `/stats/countries/${code}${url.search}`);
|
redirect(308, `/stats/countries/${code}${url.search}`);
|
||||||
if (!isCountryPage(code)) error(404, 'Not Found');
|
if (!isCountryPage(code)) error(404, 'Not Found');
|
||||||
|
const heritage = isHeritage(code) ? loadHeritage(code) : null;
|
||||||
const series = await cached(`stats:country:${code}`, () =>
|
const series = await cached(`stats:country:${code}`, () =>
|
||||||
loadSeries([
|
loadSeries([
|
||||||
...PROFILE_SERIES.map((id) => `${id}:${code}`),
|
...PROFILE_SERIES.map((id) => `${id}:${code}`),
|
||||||
@@ -22,5 +25,5 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
|||||||
...(code === 'CUW' ? CW_SERIES : [])
|
...(code === 'CUW' ? CW_SERIES : [])
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
return { code, series };
|
return { code, series, heritage: heritage ? await heritage : null };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { COUNTRY } from '#lib/stats/regions.js';
|
import { COUNTRY } from '#lib/stats/regions.js';
|
||||||
import { COUNTRY_COLOR, FLAG } from '#lib/stats/countries.js';
|
import { COUNTRY_COLOR, FLAG } from '#lib/stats/countries.js';
|
||||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import PlaceHeritage from '#lib/stats/PlaceHeritage.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const c = $derived(data.code);
|
const c = $derived(data.code);
|
||||||
@@ -713,6 +714,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if data.heritage}
|
||||||
|
<PlaceHeritage h={data.heritage} attributions={a} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.back {
|
.back {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cached, loadCbsCross, loadSeries } from '#lib/server/stats.js';
|
import { cached, loadCbsCross, loadSeries } from '#lib/server/stats.js';
|
||||||
import { epSeriesIds, GOUDA_PC4 } from '#lib/stats/energylabels.js';
|
import { epSeriesIds, GOUDA_PC4 } from '#lib/stats/energylabels.js';
|
||||||
|
import { loadHeritage } from '#lib/server/stats-history.js';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
// Gouda (GM0513) and its neighbours; NL01 is the national total.
|
// Gouda (GM0513) and its neighbours; NL01 is the national total.
|
||||||
@@ -20,7 +21,7 @@ const KWB_MEASURES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const [series, wijken] = await Promise.all([
|
const [series, wijken, heritage] = await Promise.all([
|
||||||
cached('stats:gouda', () =>
|
cached('stats:gouda', () =>
|
||||||
loadSeries([
|
loadSeries([
|
||||||
...['NL01', ...RING].flatMap((r) => [
|
...['NL01', ...RING].flatMap((r) => [
|
||||||
@@ -40,7 +41,8 @@ export const load: PageServerLoad = async () => {
|
|||||||
),
|
),
|
||||||
cached('stats:gouda:kwb', () =>
|
cached('stats:gouda:kwb', () =>
|
||||||
loadCbsCross(KWB_TABLE, KWB_MEASURES, 'WijkenEnBuurten', ['WK0513', 'GM0513', 'NL00'])
|
loadCbsCross(KWB_TABLE, KWB_MEASURES, 'WijkenEnBuurten', ['WK0513', 'GM0513', 'NL00'])
|
||||||
)
|
),
|
||||||
|
loadHeritage('GM0513')
|
||||||
]);
|
]);
|
||||||
return { series, wijken, kwbYear: 2024 };
|
return { series, wijken, kwbYear: 2024, heritage };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import StatChart from '#lib/stats/StatChart.svelte';
|
import StatChart from '#lib/stats/StatChart.svelte';
|
||||||
|
import PlaceHeritage from '#lib/stats/PlaceHeritage.svelte';
|
||||||
import Kpi from '#lib/stats/Kpi.svelte';
|
import Kpi from '#lib/stats/Kpi.svelte';
|
||||||
import { barRankOption, change, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
import { barRankOption, change, latest, lineOption, type StatPoint } from '#lib/stats/chart.js';
|
||||||
import { compact, num, period } from '#lib/stats/format.js';
|
import { compact, num, period } from '#lib/stats/format.js';
|
||||||
@@ -415,6 +416,8 @@
|
|||||||
<p class="empty">Neighbourhood figures are not loaded yet.</p>
|
<p class="empty">Neighbourhood figures are not loaded yet.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<PlaceHeritage h={data.heritage} attributions={a} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
tr.total td {
|
tr.total td {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
4
src/routes/stats/history/+page.server.ts
Normal file
4
src/routes/stats/history/+page.server.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { loadHistoryOverview } from '#lib/server/stats-history.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => loadHistoryOverview();
|
||||||
203
src/routes/stats/history/+page.svelte
Normal file
203
src/routes/stats/history/+page.svelte
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 { HERITAGE, indexTo } from '#lib/stats/heritage.js';
|
||||||
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const s = $derived(data.series);
|
||||||
|
const a = $derived(data.attributions);
|
||||||
|
const pts = (id: string): StatPoint[] => s[id]?.points ?? [];
|
||||||
|
const COUNTRIES = ['NLD', 'USA', 'KOR'] as const;
|
||||||
|
const WORLD = { name: 'World', color: '#94a3b8', dashed: true };
|
||||||
|
const lines = (prefix: string, withCw = false, cwId = ''): Line[] => [
|
||||||
|
...COUNTRIES.map((c) => ({
|
||||||
|
name: HERITAGE[c].name,
|
||||||
|
points: pts(`${prefix}:${c}`),
|
||||||
|
color: HERITAGE[c].color
|
||||||
|
})),
|
||||||
|
...(withCw
|
||||||
|
? [{ name: 'Curaçao', points: pts(cwId || `${prefix}:CUW`), color: HERITAGE.CUW.color }]
|
||||||
|
: []),
|
||||||
|
{ ...WORLD, points: pts(`${prefix}:WLD`) }
|
||||||
|
];
|
||||||
|
const popIndex = $derived([
|
||||||
|
...COUNTRIES.map((c) => ({
|
||||||
|
name: HERITAGE[c].name,
|
||||||
|
points: indexTo(pts(`owid:hist:population:${c}`), '1950'),
|
||||||
|
color: HERITAGE[c].color
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: 'Curaçao',
|
||||||
|
points: indexTo(pts('owid:cw:population'), '1950'),
|
||||||
|
color: HERITAGE.CUW.color
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gouda',
|
||||||
|
points: indexTo(pts('cbs:pop:jan1:GM0513'), '1950'),
|
||||||
|
color: HERITAGE.GM0513.color
|
||||||
|
},
|
||||||
|
{ ...WORLD, points: indexTo(pts('owid:hist:population:WLD'), '1950') }
|
||||||
|
]);
|
||||||
|
const unaffiliated = $derived(
|
||||||
|
(['NLD', 'USA', 'KOR', 'CUW', 'WLD'] as const)
|
||||||
|
.map((c) => ({
|
||||||
|
label: c === 'WLD' ? 'World' : HERITAGE[c].name,
|
||||||
|
value:
|
||||||
|
pts(`pew:religion:religiously_unaffiliated:${c}`).find(([d]) =>
|
||||||
|
d.startsWith('2020')
|
||||||
|
)?.[1] ?? NaN,
|
||||||
|
highlight: c === 'NLD'
|
||||||
|
}))
|
||||||
|
.filter((r) => Number.isFinite(r.value))
|
||||||
|
);
|
||||||
|
const places = Object.values(HERITAGE);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>History · stats · Stan Runge</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="The Netherlands, Gouda, Curaçao, the US and South Korea over the long run: population, income, life expectancy, urbanisation and religion, plus a sourced timeline of key events."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="stats-intro">
|
||||||
|
<span class="stats-eyebrow">stats / history</span>
|
||||||
|
<h1>History and culture</h1>
|
||||||
|
<p>
|
||||||
|
Five places over the long run: the Netherlands, Gouda, Curaçao, the United States and South
|
||||||
|
Korea. Long series come from the Maddison Project, HYDE and the UN via Our World in Data, CBS
|
||||||
|
and Pew; the timeline is curated, and every event links to its source. Each place's own page has
|
||||||
|
its office holders, holidays, UNESCO sites, well-known people and food.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="places">
|
||||||
|
{#each places as p (p.code)}
|
||||||
|
<a class="place" href={`${resolve(p.href as '/stats')}#history`} style:--c={p.color}>
|
||||||
|
<strong>{p.name}</strong>
|
||||||
|
<span>history, politics and culture <ArrowRight size={13} /></span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">The long run</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<StatChart
|
||||||
|
title="GDP per person, long run"
|
||||||
|
subtitle="Maddison Project Database 2023 · international $ at 2011 prices"
|
||||||
|
sources={['owid']}
|
||||||
|
attributions={a}
|
||||||
|
note="Curaçao is not in the Maddison database."
|
||||||
|
option={(t) => lineOption(t, lines('owid:maddison'), { unit: '$', decimals: 0, zoom: true })}
|
||||||
|
/>
|
||||||
|
<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, lines('owid:hist:life-expectancy', true, 'owid:cw:life-expectancy'), {
|
||||||
|
unit: 'years',
|
||||||
|
decimals: 1
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Urbanisation since 1500"
|
||||||
|
subtitle="% living in urban areas · HYDE 2023 via Our World in Data"
|
||||||
|
sources={['owid']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) => lineOption(t, lines('owid:hist:urban-long'), { unit: '%', decimals: 1 })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Population growth since 1950"
|
||||||
|
subtitle="index, 1950 = 100 · OWID (UN WPP), CBS for Gouda"
|
||||||
|
sources={['owid', 'cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) => lineOption(t, popIndex, { decimals: 0 })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="Population since 1500"
|
||||||
|
subtitle="inhabitants · HYDE, Gapminder and UN via Our World in Data"
|
||||||
|
sources={['owid']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
lineOption(
|
||||||
|
t,
|
||||||
|
COUNTRIES.map((c) => ({
|
||||||
|
name: HERITAGE[c].name,
|
||||||
|
points: pts(`owid:hist:population:${c}`),
|
||||||
|
color: HERITAGE[c].color
|
||||||
|
})),
|
||||||
|
{ decimals: 0 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="No religion, 2020"
|
||||||
|
subtitle="% of the population religiously unaffiliated · Pew Research Center"
|
||||||
|
sources={['pew']}
|
||||||
|
attributions={a}
|
||||||
|
height="240px"
|
||||||
|
option={(t) => barRankOption(t, unaffiliated, { unit: '%', decimals: 1, color: '#64748b' })}
|
||||||
|
/>
|
||||||
|
<StatChart
|
||||||
|
title="The Netherlands loses its religion"
|
||||||
|
subtitle="% of adults without religious affiliation, and Catholics · CBS since 1849"
|
||||||
|
sources={['cbs']}
|
||||||
|
attributions={a}
|
||||||
|
option={(t) =>
|
||||||
|
lineOption(
|
||||||
|
t,
|
||||||
|
[
|
||||||
|
{ name: 'No religion', points: pts('cbs:religion:37944:none:NL01'), color: '#64748b' },
|
||||||
|
{
|
||||||
|
name: 'Roman Catholic',
|
||||||
|
points: pts('cbs:religion:37944:catholic:NL01'),
|
||||||
|
color: '#e11d48'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Dutch Reformed',
|
||||||
|
points: pts('cbs:religion:37944:hervormd:NL01'),
|
||||||
|
color: '#f97316'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{ unit: '%', decimals: 1 }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="stats-section-title">Timeline</h2>
|
||||||
|
<EventTimeline events={data.events} showPlaces />
|
||||||
|
<p class="stats-note">
|
||||||
|
{data.events.length} curated events, each summarised from and linked to its source (mostly Wikipedia,
|
||||||
|
CC BY-SA). Click an event for the summary and source; filter by theme or place.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.places {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 190px), 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.place {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-left: 4px solid var(--c);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.place span {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user