fuzzyworld

when-am-i / files / src / utils / timelineBands.ts

timelineBands.ts

4.3 KB · history · blame

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
10import type { CalendarRegion, Era } from '../types';
11import { findRegions } from './geo';
12import { gregorianToJulianDay, parseGregorianDate } from './julianDay';
13import { daysInYear } from './historicalDate';
14import { DEFAULT_MODERN_ERA, MODERN_DEFAULT_START_YEAR } from './resolve';
15
16/** A stretch of the year axis using one calendar (years; ±Infinity open-ended). */
17export 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). */
24export 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 */
37export 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. */
60export 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 */
79export 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}