when-am-i / commit
feat: stack overlapping eras into lanes on the year timeline
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 }; +}