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>
|
||||
Reference in New Issue
Block a user