diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index ef4579d2..2783d141 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -54,11 +54,15 @@ const split = $derived( ); const loose = $derived(split.loose); const dense = $derived(isDense(loose.length)); +// Clusters keyed by eventId (built once in splitYearLetters): row assembly looks a letter's +// disposition up in O(1) — `byEvent.has(linkedEventId)` — instead of scanning the loose array +// per letter (was O(L²) on a dense band), and resolves an event's card with `byEvent.get`. +const byEvent = $derived(split.byEvent); const rows = $derived.by(() => { const out: Row[] = []; const { clusters } = split; - const consumed: string[] = []; + const consumed: Record = {}; let stripInserted = false; let letterIndex = 0; @@ -67,16 +71,19 @@ const rows = $derived.by(() => { // 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; + const cluster = entry.eventId ? byEvent.get(entry.eventId) : undefined; if (cluster) { out.push({ t: 'eventcard', event: entry, cluster }); - consumed.push(cluster.eventId); + consumed[cluster.eventId] = true; } 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. + } else if ( + entry.kind === 'LETTER' && + !(entry.linkedEventId && byEvent.has(entry.linkedEventId)) + ) { + // A loose letter (not clustered): 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; @@ -91,7 +98,7 @@ const rows = $derived.by(() => { // 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)) { + if (!consumed[cluster.eventId]) { out.push({ t: 'eventcard', cluster }); } } diff --git a/frontend/src/lib/timeline/eventClustering.ts b/frontend/src/lib/timeline/eventClustering.ts index e80a43fd..212c7338 100644 --- a/frontend/src/lib/timeline/eventClustering.ts +++ b/frontend/src/lib/timeline/eventClustering.ts @@ -19,6 +19,9 @@ export interface EventCluster { export interface SplitLetters { clusters: EventCluster[]; loose: TimelineEntryDTO[]; + /** Clusters keyed by `eventId` for O(1) lookup during row assembly (a letter's disposition is + * `byEvent.has(linkedEventId)`; an event's card is `byEvent.get(eventId)`). */ + byEvent: Map; } /** @@ -81,5 +84,5 @@ export function splitYearLetters( } } - return { clusters, loose }; + return { clusters, loose, byEvent }; }