fuzzyworld

when-am-i / commit

feat: stack overlapping eras into lanes on the year timeline

ba77fd9161cf0a618815a7801450f768c85fd5be · Pavel Safronov · 2026-06-18 17:56:15 -0700 · parents 48a625b794f2

feat: stack overlapping eras into lanes on the year timeline

Coexisting calendars previously overpainted each other on the year scrollbar.
Pack the era bands into non-overlapping horizontal lanes (greedy interval
scheduling, like Wikipedia band-member timelines) and render each as a stripe in
its lane, so concurrent calendars appear as parallel bands. assignLanes is a
small pure, tested helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 src/components/DatePicker.tsx | 13 +++++++++----
 src/utils/lanes.test.ts       | 39 +++++++++++++++++++++++++++++++++++++++
 src/utils/lanes.ts            | 43 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 91 insertions(+), 4 deletions(-)

diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx
index 9c11f6e..a4acc28 100644
--- a/src/components/DatePicker.tsx
+++ b/src/components/DatePicker.tsx
@@ -7,6 +7,7 @@ import {
     toHistoricalDate,
     type PickerDate,
 } from '../utils/historicalDate';
+import { assignLanes } from '../utils/lanes';
 
 /** A stretch of the year axis using one calendar, for the colored track bands. */
 export interface YearBand {
@@ -64,6 +65,10 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands }) =
     // Unique calendars across the bands, for a small legend.
     const bandLegend = [...new Map(yearBands.map((b) => [b.calendar, b.color])).entries()];
 
+    // Pack concurrent eras into parallel lanes so coexisting calendars stack as
+    // horizontal stripes instead of overpainting (cf. Wikipedia band timelines).
+    const { lanes, count: laneCount } = assignLanes(yearBands, (b) => b.start, (b) => b.end);
+
     return (
         <div style={{ margin: '12px 0', maxWidth: 560 }}>
             <div style={{ fontSize: '1.3em', fontWeight: 700, marginBottom: 12 }}>
@@ -109,7 +114,7 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands }) =
                         overflow: 'hidden',
                     }}
                 >
-                    {yearBands.map((band, i) => {
+                    {lanes.map(({ item: band, lane }, i) => {
                         const left = pct(band.start);
                         const width = pct(band.end) - left;
                         return (
@@ -120,10 +125,10 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands }) =
                                     position: 'absolute',
                                     left: `${left}%`,
                                     width: `${width}%`,
-                                    top: 0,
-                                    bottom: 0,
+                                    top: `${(lane / laneCount) * 100}%`,
+                                    height: `${(1 / laneCount) * 100}%`,
                                     background: band.color,
-                                    opacity: 0.75,
+                                    opacity: 0.85,
                                 }}
                             />
                         );
diff --git a/src/utils/lanes.test.ts b/src/utils/lanes.test.ts
new file mode 100644
index 0000000..90d66e6
--- /dev/null
+++ b/src/utils/lanes.test.ts
@@ -0,0 +1,39 @@
+import { describe, it, expect } from 'vitest';
+import { assignLanes } from './lanes';
+
+interface Span { start: number; end: number; }
+const lane = (spans: Span[]) =>
+    assignLanes(spans, (s) => s.start, (s) => s.end);
+
+describe('assignLanes', () => {
+    it('puts non-overlapping, adjacent intervals in the same lane', () => {
+        const { lanes, count } = lane([
+            { start: 1630, end: 1752 },
+            { start: 1752, end: Infinity }, // half-open: shares the lane
+        ]);
+        expect(count).toBe(1);
+        expect(lanes.map((l) => l.lane)).toEqual([0, 0]);
+    });
+
+    it('puts overlapping intervals in separate lanes', () => {
+        const { lanes, count } = lane([
+            { start: -Infinity, end: Infinity }, // spans everything
+            { start: 1630, end: 1752 },
+            { start: 1752, end: Infinity },
+        ]);
+        expect(count).toBe(2);
+        // The all-spanning interval gets its own lane; the two adjacent ones share the other.
+        const byOrder = lanes.sort((a, b) => a.item.start - b.item.start).map((l) => l.lane);
+        expect(byOrder[0]).not.toBe(byOrder[1]); // spanning vs 1630
+        expect(byOrder[1]).toBe(byOrder[2]); // 1630 and 1752 share
+    });
+
+    it('opens a third lane when three intervals overlap at once', () => {
+        const { count } = lane([
+            { start: 0, end: 10 },
+            { start: 5, end: 15 },
+            { start: 8, end: 20 },
+        ]);
+        expect(count).toBe(3);
+    });
+});
diff --git a/src/utils/lanes.ts b/src/utils/lanes.ts
new file mode 100644
index 0000000..c8cdec8
--- /dev/null
+++ b/src/utils/lanes.ts
@@ -0,0 +1,43 @@
+// Pack time intervals into non-overlapping horizontal lanes — the trick the
+// Wikipedia band-member timelines use to show concurrent roles as parallel
+// stripes instead of overpainting. Greedy: sweep by start time, reuse the first
+// lane whose last interval has already ended, else open a new lane.
+
+export interface Laned<T> {
+    item: T;
+    lane: number;
+}
+
+export function assignLanes<T>(
+    items: T[],
+    startOf: (item: T) => number,
+    endOf: (item: T) => number,
+): { lanes: Laned<T>[]; count: number } {
+    const ordered = items
+        .map((item, i) => ({ item, i }))
+        .sort((a, b) => {
+            const sa = startOf(a.item);
+            const sb = startOf(b.item);
+            if (sa < sb) return -1;
+            if (sa > sb) return 1;
+            return a.i - b.i; // stable for equal starts
+        });
+
+    const laneEnds: number[] = [];
+    const lanes: Laned<T>[] = [];
+    for (const { item } of ordered) {
+        const start = startOf(item);
+        // Half-open intervals: a lane is free if its last interval ended at or
+        // before this one starts.
+        let lane = laneEnds.findIndex((end) => end <= start);
+        if (lane === -1) {
+            lane = laneEnds.length;
+            laneEnds.push(endOf(item));
+        } else {
+            laneEnds[lane] = endOf(item);
+        }
+        lanes.push({ item, lane });
+    }
+
+    return { lanes, count: laneEnds.length };
+}