From 70a76904e1d21852291ad3f29c2d6f9457f388b8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 22:27:17 +0200 Subject: [PATCH] refactor(timeline): O(1) lookups in YearBand row assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `loose.includes(entry)` ran once per LETTER inside the band loop — O(L²) on a dense band of hundreds of loose letters, recomputed on every layer re-render. splitYearLetters now also returns its `byEvent` map, so a letter's disposition is `byEvent.has(linkedEventId)` and an event's card is `byEvent.get(eventId)`, both O(1); `consumed` is a plain object. No behavior change. Fixes review finding #3. Refs #850 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/YearBand.svelte | 21 +++++++++++++------- frontend/src/lib/timeline/eventClustering.ts | 5 ++++- 2 files changed, 18 insertions(+), 8 deletions(-) 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 }; }