when-am-i / files / src / utils / timelineBands.ts
timelineBands.ts
| 1 | // Colored era bands for the date-picker timelines. |
| 2 | // |
| 3 | // Both the year axis and the day-of-year axis shade the picked region's |
| 4 | // calendars over time. They share one source of truth here so the modern global |
| 5 | // Gregorian default (see resolve.ts) is mirrored identically on both — the year |
| 6 | // bar shows *which years* each calendar covered; the day bar zooms into one year |
| 7 | // to show *which days*, so a mid-year changeover (e.g. France's 22 Sept 1792 |
| 8 | // Republican start) is visible as a color boundary within the year. |
| 9 | |
| 10 | import type { CalendarRegion, Era } from '../types'; |
| 11 | import { findRegions } from './geo'; |
| 12 | import { gregorianToJulianDay, parseGregorianDate } from './julianDay'; |
| 13 | import { daysInYear } from './historicalDate'; |
| 14 | import { DEFAULT_MODERN_ERA, MODERN_DEFAULT_START_YEAR } from './resolve'; |
| 15 | |
| 16 | /** A stretch of the year axis using one calendar (years; ±Infinity open-ended). */ |
| 17 | export interface YearBandSpan { |
| 18 | start: number; |
| 19 | end: number; |
| 20 | calendar: string; |
| 21 | } |
| 22 | |
| 23 | /** A stretch of the day-of-year axis using one calendar (1-based, inclusive). */ |
| 24 | export interface DayBandSpan { |
| 25 | startDoy: number; |
| 26 | endDoy: number; |
| 27 | calendar: string; |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * Every era in effect at a point, in render order: the matched regions' eras |
| 32 | * (in file/era order), plus the synthetic modern global Gregorian default when |
| 33 | * it applies — i.e. unless a matched region already supplies a Gregorian era |
| 34 | * reaching modern times, or an era opts the point out (suppressModernDefault). |
| 35 | * Mirrors resolve()'s default logic so the timelines and the answer cards agree. |
| 36 | */ |
| 37 | export function effectiveErasForPoint( |
| 38 | lat: number, |
| 39 | lng: number, |
| 40 | regions: CalendarRegion[], |
| 41 | ): Era[] { |
| 42 | const eras: Era[] = []; |
| 43 | let suppressesModernDefault = false; |
| 44 | let hasModernGregorian = false; |
| 45 | for (const cr of findRegions(lat, lng, regions)) { |
| 46 | for (const era of cr.eras) { |
| 47 | eras.push(era); |
| 48 | const endYear = era.end ? parseGregorianDate(era.end).year : Infinity; |
| 49 | // Only an era reaching modern times can block (or satisfy) the |
| 50 | // modern default — matching resolve() and the year-band logic. |
| 51 | if (era.suppressModernDefault && endYear >= MODERN_DEFAULT_START_YEAR) suppressesModernDefault = true; |
| 52 | if (era.calendar === 'Gregorian' && endYear >= MODERN_DEFAULT_START_YEAR) hasModernGregorian = true; |
| 53 | } |
| 54 | } |
| 55 | if (!hasModernGregorian && !suppressesModernDefault) eras.push(DEFAULT_MODERN_ERA); |
| 56 | return eras; |
| 57 | } |
| 58 | |
| 59 | /** Year-axis bands for the picked point: each era's [startYear, endYear) span. */ |
| 60 | export function yearBandsForPoint( |
| 61 | lat: number, |
| 62 | lng: number, |
| 63 | regions: CalendarRegion[], |
| 64 | ): YearBandSpan[] { |
| 65 | return effectiveErasForPoint(lat, lng, regions).map((era) => ({ |
| 66 | start: era.start ? parseGregorianDate(era.start).year : -Infinity, |
| 67 | end: era.end ? parseGregorianDate(era.end).year : Infinity, |
| 68 | calendar: era.calendar, |
| 69 | })); |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Day-of-year bands for the picked point within a single (astronomical) year: |
| 74 | * each era clipped to that year's days, as a 1-based inclusive [startDoy, |
| 75 | * endDoy] range. Eras not touching the year are dropped, so most years yield a |
| 76 | * single full-width band (the calendar in use all year) and a changeover year |
| 77 | * splits into two — exactly where the user should "see" the change happen. |
| 78 | */ |
| 79 | export function dayBandsForPoint( |
| 80 | lat: number, |
| 81 | lng: number, |
| 82 | regions: CalendarRegion[], |
| 83 | astroYear: number, |
| 84 | ): DayBandSpan[] { |
| 85 | const yearStartJd = gregorianToJulianDay({ year: astroYear, month: 1, day: 1 }); |
| 86 | const total = daysInYear(astroYear); |
| 87 | const bands: DayBandSpan[] = []; |
| 88 | for (const era of effectiveErasForPoint(lat, lng, regions)) { |
| 89 | // Half-open [start, end): day d (1-based) is covered when |
| 90 | // startJd <= yearStartJd + (d-1) < endJd. |
| 91 | const startJd = era.start ? gregorianToJulianDay(parseGregorianDate(era.start)) : -Infinity; |
| 92 | const endJd = era.end ? gregorianToJulianDay(parseGregorianDate(era.end)) : Infinity; |
| 93 | const startDoy = Math.max(1, startJd - yearStartJd + 1); |
| 94 | const endDoy = Math.min(total, endJd - yearStartJd); // end exclusive ⇒ last covered day |
| 95 | if (endDoy < startDoy) continue; // era doesn't reach into this year |
| 96 | bands.push({ startDoy, endDoy, calendar: era.calendar }); |
| 97 | } |
| 98 | return bands; |
| 99 | } |