Files
website/src/lib/stats/EventTimeline.svelte
Stan Runge 9b21220280
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
stats: /stats/history and History, Politics and Culture sections on NL, Gouda, Curaçao, US and KR pages (step 4)
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.
2026-10-25 23:39:34 +00:00

249 lines
5.6 KiB
Svelte

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