A curated event with same-year linked letters renders as one EventCluster card (no separate pill); a cluster whose event lives in another band renders as a cross-year text-header card. Letterless/derived/world events stay plain pills/world-bands. Loose letters keep the alternating left/right layout and fold into one density strip past 12 — and the layout + strip count ONLY the loose letters, so a clustered letter never re-appears loose. Refs #850
237 lines
7.5 KiB
Svelte
237 lines
7.5 KiB
Svelte
<script lang="ts">
|
|
import EventPill from './EventPill.svelte';
|
|
import WorldBand from './WorldBand.svelte';
|
|
import LetterCard from './LetterCard.svelte';
|
|
import YearLetterStrip from './YearLetterStrip.svelte';
|
|
import EventCluster from './EventCluster.svelte';
|
|
import { isDense } from './timelineDensity';
|
|
import { entryKey } from './entryKey';
|
|
import { splitYearLetters, type EventCluster as EventClusterModel } from './eventClustering';
|
|
import type { components } from '$lib/generated/api';
|
|
|
|
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
|
|
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
|
|
|
/**
|
|
* One year of the timeline: a <section> with a sticky <h2> (REQ-006). Events
|
|
* render in DTO order as pills/world-bands; entries are never re-sorted (REQ-003).
|
|
*
|
|
* A curated event with letters linked to it (#850) becomes a contained event card:
|
|
* the event IS the card header and its linked letters sit inside (no separate pill —
|
|
* REQ-002). A curated event with letters in another year band renders here as a
|
|
* cross-year text-header card (REQ-004). An event with no linked letters stays a
|
|
* plain pill/world-band (REQ-005).
|
|
*
|
|
* Every other letter (no linkedEventId, or linking to an event the #780 layer filter
|
|
* removed) stays loose: alternating left/right while the band holds ≤ 12 such loose
|
|
* letters (REQ-006), folding into a single month-density strip above that (REQ-007).
|
|
* The loose-letter layout and the strip count ONLY these loose letters — clustered
|
|
* letters never re-appear loose (REQ-007).
|
|
*/
|
|
let {
|
|
year,
|
|
canWrite = false,
|
|
eventLookup
|
|
}: {
|
|
year: TimelineYearDTO;
|
|
canWrite?: boolean;
|
|
eventLookup?: Map<string, string>;
|
|
} = $props();
|
|
|
|
type Row =
|
|
| { t: 'event'; entry: TimelineEntryDTO }
|
|
| { t: 'eventcard'; event?: TimelineEntryDTO; cluster: EventClusterModel }
|
|
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
|
| { t: 'strip' };
|
|
|
|
// Split this band's letters into event clusters and the loose remainder once; the loose
|
|
// list alone drives the alternating layout and the density strip (REQ-007).
|
|
const split = $derived(
|
|
splitYearLetters(
|
|
year.entries.filter((e) => e.kind === 'LETTER'),
|
|
eventLookup
|
|
)
|
|
);
|
|
const loose = $derived(split.loose);
|
|
const dense = $derived(isDense(loose.length));
|
|
|
|
const rows = $derived.by<Row[]>(() => {
|
|
const out: Row[] = [];
|
|
const { clusters } = split;
|
|
const consumed: string[] = [];
|
|
let stripInserted = false;
|
|
let letterIndex = 0;
|
|
|
|
for (const entry of year.entries) {
|
|
if (entry.kind === 'EVENT') {
|
|
// A curated event whose letters live in THIS band becomes the contained card's
|
|
// header — its title reads once, no separate pill (REQ-002). Otherwise it stays a
|
|
// plain pill/world-band (REQ-005).
|
|
const cluster = entry.eventId ? clusters.find((c) => c.eventId === entry.eventId) : undefined;
|
|
if (cluster) {
|
|
out.push({ t: 'eventcard', event: entry, cluster });
|
|
consumed.push(cluster.eventId);
|
|
} else {
|
|
out.push({ t: 'event', entry });
|
|
}
|
|
} else if (loose.includes(entry)) {
|
|
// A loose letter: alternate while sparse, or fold the whole loose set into one
|
|
// density strip (inserted once, at the first loose letter) when dense.
|
|
if (!dense) {
|
|
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
|
|
letterIndex += 1;
|
|
} else if (!stripInserted) {
|
|
out.push({ t: 'strip' });
|
|
stripInserted = true;
|
|
}
|
|
}
|
|
// a clustered letter is rendered by its event card (or the cross-year pass) — skip here.
|
|
}
|
|
|
|
// Cross-year clusters: a cluster whose event is NOT a same-year EVENT entry renders as a
|
|
// text-header card (no pill, no edit link) holding this year's linked letters (REQ-004).
|
|
for (const cluster of clusters) {
|
|
if (!consumed.includes(cluster.eventId)) {
|
|
out.push({ t: 'eventcard', cluster });
|
|
}
|
|
}
|
|
return out;
|
|
});
|
|
|
|
function rowKey(row: Row): string {
|
|
if (row.t === 'strip') return `strip-${year.year}`;
|
|
if (row.t === 'eventcard') return `evcard:${row.cluster.eventId}`;
|
|
return entryKey(row.entry);
|
|
}
|
|
</script>
|
|
|
|
<section class="py-2">
|
|
<h2 class="year-heading">
|
|
<span data-testid="year-node" class="year-node bg-brand-navy" aria-hidden="true"></span>
|
|
<span
|
|
data-testid="year-label"
|
|
class="year-label rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
|
|
>{year.year}</span
|
|
>
|
|
</h2>
|
|
|
|
<div class="mt-3 space-y-3">
|
|
{#each rows as row (rowKey(row))}
|
|
{#if row.t === 'event'}
|
|
{#if row.entry.type === 'HISTORICAL'}
|
|
<WorldBand entry={row.entry} canWrite={canWrite} />
|
|
{:else}
|
|
<EventPill entry={row.entry} canWrite={canWrite} />
|
|
{/if}
|
|
{:else if row.t === 'eventcard'}
|
|
<EventCluster
|
|
letters={row.cluster.letters}
|
|
event={row.event}
|
|
title={row.cluster.title}
|
|
canWrite={canWrite}
|
|
/>
|
|
{:else if row.t === 'letter'}
|
|
<div class="letter-row" data-side={row.side}>
|
|
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
|
<LetterCard entry={row.entry} />
|
|
</div>
|
|
{:else}
|
|
<YearLetterStrip letters={loose} year={year.year} />
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
/* Sticky offset in scoped CSS so it holds in unit tests too (the global
|
|
header is a 64px sticky nav). #779 REQ-006 / #833 REQ-003. The sticky
|
|
element is also the positioning context for the year-node marker. */
|
|
.year-heading {
|
|
position: sticky;
|
|
top: 4rem;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Markers ride on the spine's single source of truth: --spine-x, declared
|
|
once on TimelineView's .timeline-axis and inherited here (0.5rem phone /
|
|
50% desktop). The 0.5rem fallback only applies when a YearBand is rendered
|
|
outside the axis (e.g. component tests). #833 REQ-003/004/005. */
|
|
|
|
/* Phone (< 1024px): badge sits at the left spine, clearing the node marker.
|
|
The badge sits above the node (z-index) so on desktop, where the centered
|
|
pill covers the centered node, the white year text is never occluded. */
|
|
.year-label {
|
|
display: inline-block;
|
|
margin-left: 1.75rem;
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
/* Navy node marker on the spine. On phone it shows to the left of the badge;
|
|
on desktop it sits behind the centered pill, which is itself the
|
|
axis interruption. */
|
|
.year-node {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: var(--spine-x, 0.5rem);
|
|
width: 11px;
|
|
height: 11px;
|
|
border-radius: 9999px;
|
|
transform: translate(-50%, -50%);
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Per-letter connector dot (white fill via bg-surface, mint ring) on the spine. */
|
|
.letter-row {
|
|
position: relative;
|
|
padding-left: 1.75rem;
|
|
}
|
|
.letter-dot {
|
|
position: absolute;
|
|
top: 0.9rem;
|
|
left: var(--spine-x, 0.5rem);
|
|
width: 13px;
|
|
height: 13px;
|
|
border-radius: 9999px;
|
|
border: 2.5px solid var(--palette-mint);
|
|
transform: translate(-50%, -50%);
|
|
z-index: 2;
|
|
}
|
|
|
|
/* Desktop (≥ 1024px): centered axis. The badge centers on the spine, the node
|
|
sits at the spine centre, and letters alternate left/right with the
|
|
connector dot on the centred spine between card and axis. #833 REQ-003/004/005. */
|
|
@media (min-width: 1024px) {
|
|
.year-label {
|
|
display: block;
|
|
width: fit-content;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
}
|
|
/* .year-node needs no desktop override — it inherits --spine-x: 50% from
|
|
the axis. */
|
|
.letter-row {
|
|
width: 50%;
|
|
padding-left: 0;
|
|
}
|
|
.letter-row[data-side='left'] {
|
|
margin-right: auto;
|
|
padding-right: 1.75rem;
|
|
}
|
|
.letter-row[data-side='right'] {
|
|
margin-left: auto;
|
|
padding-left: 1.75rem;
|
|
}
|
|
.letter-row[data-side='left'] .letter-dot {
|
|
left: auto;
|
|
right: 0;
|
|
transform: translate(50%, -50%);
|
|
}
|
|
.letter-row[data-side='right'] .letter-dot {
|
|
left: 0;
|
|
right: auto;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
}
|
|
</style>
|