when-am-i / files / src / utils / lanes.ts
lanes.ts
ba77fd91 2026-06-19 Pavel Safronov | 1 | // Pack time intervals into non-overlapping horizontal lanes — the trick the |
| 2 | // Wikipedia band-member timelines use to show concurrent roles as parallel | |
| 3 | // stripes instead of overpainting. Greedy: sweep by start time, reuse the first | |
| 4 | // lane whose last interval has already ended, else open a new lane. | |
| 5 | ||
| 6 | export interface Laned<T> { | |
| 7 | item: T; | |
| 8 | lane: number; | |
| 9 | } | |
| 10 | ||
| 11 | export function assignLanes<T>( | |
| 12 | items: T[], | |
| 13 | startOf: (item: T) => number, | |
| 14 | endOf: (item: T) => number, | |
| 15 | ): { lanes: Laned<T>[]; count: number } { | |
| 16 | const ordered = items | |
| 17 | .map((item, i) => ({ item, i })) | |
| 18 | .sort((a, b) => { | |
| 19 | const sa = startOf(a.item); | |
| 20 | const sb = startOf(b.item); | |
| 21 | if (sa < sb) return -1; | |
| 22 | if (sa > sb) return 1; | |
| 23 | return a.i - b.i; // stable for equal starts | |
| 24 | }); | |
| 25 | ||
| 26 | const laneEnds: number[] = []; | |
| 27 | const lanes: Laned<T>[] = []; | |
| 28 | for (const { item } of ordered) { | |
| 29 | const start = startOf(item); | |
| 30 | // Half-open intervals: a lane is free if its last interval ended at or | |
| 31 | // before this one starts. | |
| 32 | let lane = laneEnds.findIndex((end) => end <= start); | |
| 33 | if (lane === -1) { | |
| 34 | lane = laneEnds.length; | |
| 35 | laneEnds.push(endOf(item)); | |
| 36 | } else { | |
| 37 | laneEnds[lane] = endOf(item); | |
| 38 | } | |
| 39 | lanes.push({ item, lane }); | |
| 40 | } | |
| 41 | ||
| 42 | return { lanes, count: laneEnds.length }; | |
| 43 | } |