refactor(timeline): O(1) lookups in YearBand row assembly

`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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-15 22:27:17 +02:00
committed by marcel
parent 646b3c125e
commit 9a8b4ff6d0
2 changed files with 18 additions and 8 deletions

View File

@@ -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<Row[]>(() => {
const out: Row[] = [];
const { clusters } = split;
const consumed: string[] = [];
const consumed: Record<string, true> = {};
let stripInserted = false;
let letterIndex = 0;
@@ -67,16 +71,19 @@ const rows = $derived.by<Row[]>(() => {
// 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<Row[]>(() => {
// 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 });
}
}