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

Long-run charts (Maddison, HYDE, UN via OWID; CBS and Pew religion), a filterable
sourced timeline, Wikidata office holders and turnout, Nager.Date holidays, UNESCO
sites, Wikidata people by sitelinks, curated food and cultural exports. Wikidata
labels now fall back to mul.
This commit is contained in:
2026-10-25 23:39:34 +00:00
parent e0bcd63ce3
commit 9b21220280
16 changed files with 1482 additions and 7 deletions

View File

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