when-am-i / commit
Derive the year slider's lower bound from the oldest dated calendar era
src/App.tsx | 7 ++++++- src/components/DatePicker.tsx | 24 ++++++++++++++---------- src/utils/dateRange.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/utils/dateRange.ts | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0b79fca..8de9cdd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { resolve, MODERN_DEFAULT_START_YEAR } from './utils/resolve'; import { findEras } from './utils/eras'; import { findRegions } from './utils/geo'; import { parseGregorianDate } from './utils/julianDay'; +import { deriveMinYear } from './utils/dateRange'; import { calendarColorScale } from './utils/calendarColor'; import { nearestCity } from './utils/nearestCity'; import { @@ -35,6 +36,10 @@ const colorFor = calendarColorScale([ ...regions.flatMap((cr) => cr.eras.map((era) => era.calendar)), ]); +// Year slider floor: the oldest dated era in the dataset, plus a margin, so the +// timeline doesn't waste most of its width on millennia with no calendar data. +const minYear = deriveMinYear(regions, new Date().getFullYear()); + const now = new Date(); const defaultDate: PickerDate = initial.date ? fromHistoricalDate(initial.date) @@ -223,7 +228,7 @@ function App() { <div className="app-cols"> <div> <p style={sectionLabel}>When?</p> - <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} /> + <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} minYear={minYear} /> <p style={sectionLabel}>Where?</p> {picked && ( diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index 425dcd9..574a182 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -23,6 +23,8 @@ interface DatePickerProps { onChange: (next: PickerDate) => void; /** Era bands for the picked region, shown along the year track. */ yearBands: YearBand[]; + /** Earliest year the slider reaches; defaults to FALLBACK_MIN_YEAR. */ + minYear?: number; } const MONTHS = [ @@ -31,17 +33,19 @@ const MONTHS = [ ]; const MONTH_INITIALS = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; -// Year axis: 2000 BCE (astronomical -1999) to the present. -const MIN_YEAR = -1999; +// Year axis: by default reaches back to 2000 BCE (astronomical -1999); App.tsx +// passes a tighter, data-driven minYear so the slider doesn't waste most of its +// width on millennia with no calendar data (see deriveMinYear in utils/dateRange.ts). +const FALLBACK_MIN_YEAR = -1999; const MAX_YEAR = new Date().getFullYear(); function yearLabel(astronomicalYear: number): string { return astronomicalYear > 0 ? `${astronomicalYear} CE` : `${1 - astronomicalYear} BCE`; } -function pct(year: number): number { - const clamped = Math.max(MIN_YEAR, Math.min(MAX_YEAR, year)); - return ((clamped - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100; +function pct(year: number, minYear: number): number { + const clamped = Math.max(minYear, Math.min(MAX_YEAR, year)); + return ((clamped - minYear) / (MAX_YEAR - minYear)) * 100; } const barLabel: React.CSSProperties = { margin: 0, fontSize: '0.8em', color: '#666' }; @@ -53,7 +57,7 @@ const trackStyle: React.CSSProperties = { overflow: 'hidden', }; -const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands }) => { +const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, minYear = FALLBACK_MIN_YEAR }) => { const astroYear = toHistoricalDate(value).year; const doy = dayOfYear(astroYear, value.month, value.day); const yearDays = daysInYear(astroYear); @@ -127,8 +131,8 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands }) = <div style={{ position: 'relative', height: 28 }}> <div style={trackStyle}> {lanes.map(({ item: band, lane }, i) => { - const left = pct(band.start); - const width = pct(band.end) - left; + const left = pct(band.start, minYear); + const width = pct(band.end, minYear) - left; return ( <div key={i} @@ -149,7 +153,7 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands }) = <input className="timeline-slider" type="range" - min={MIN_YEAR} + min={minYear} max={MAX_YEAR} step={1} value={astroYear} @@ -159,7 +163,7 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands }) = /> </div> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7em', color: '#999' }}> - <span>{yearLabel(MIN_YEAR)}</span> + <span>{yearLabel(minYear)}</span> <span>{yearLabel(MAX_YEAR)}</span> </div> diff --git a/src/utils/dateRange.test.ts b/src/utils/dateRange.test.ts new file mode 100644 index 0000000..5cfa83a --- /dev/null +++ b/src/utils/dateRange.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { deriveMinYear } from './dateRange'; +import type { CalendarRegion, Era } from '../types'; + +const WORLD_GEO = { type: 'Polygon' as const, coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]] as [number, number][]] }; + +function regionWith(eras: Era[]): CalendarRegion { + return { region: { name: 'Test', geo: WORLD_GEO }, eras }; +} + +describe('deriveMinYear', () => { + it('falls back to 2000 BCE when no era has a dated start', () => { + const regions = [regionWith([{ name: 'Open', start: null, end: null, calendar: 'Gregorian' }])]; + expect(deriveMinYear(regions, 2026)).toBe(-1999); + }); + + it('uses the oldest dated start across all regions, minus a 10% margin', () => { + const regions = [ + regionWith([{ name: 'A', start: '1700-01-01', end: null, calendar: 'Gregorian' }]), + regionWith([{ name: 'B', start: '-0036-01-01', end: null, calendar: 'Maya Long Count' }]), + ]; + // oldest = -36; span to 2026 = 2062; margin = 206.2 → floor(-36 - 206.2) = -243 + expect(deriveMinYear(regions, 2026)).toBe(-243); + }); + + it('ignores null-start eras even when other eras in the same region are dated', () => { + const regions = [ + regionWith([ + { name: 'Open', start: null, end: '1000-01-01', calendar: 'Hebrew' }, + { name: 'Dated', start: '500-01-01', end: null, calendar: 'Gregorian' }, + ]), + ]; + // oldest dated = 500; span to 2026 = 1526; margin = 152.6 → floor(500 - 152.6) = 347 + expect(deriveMinYear(regions, 2026)).toBe(347); + }); +}); diff --git a/src/utils/dateRange.ts b/src/utils/dateRange.ts new file mode 100644 index 0000000..c35709c --- /dev/null +++ b/src/utils/dateRange.ts @@ -0,0 +1,34 @@ +// Derives the year-slider's lower bound from the loaded calendar data, so the +// timeline doesn't waste most of its width on millennia with no eras plotted. +// +// Only eras with an explicit numeric `start` count — `start: null` means "since +// the earliest time" in the schema (e.g. the Hebrew or Ethiopian calendars' +// traditional epochs run far earlier, but we never invent a date for them), so +// those eras stay open-ended rather than pushing the floor back further. + +import type { CalendarRegion } from '../types'; +import { parseGregorianDate } from './julianDay'; + +const FALLBACK_MIN_YEAR = -1999; // 2000 BCE, used if no era has a dated start. +const MARGIN_FRACTION = 0.1; // headroom before the oldest era, as a fraction of its span to "now". + +/** + * The earliest astronomical year any era explicitly starts in `regions`, minus + * a margin (MARGIN_FRACTION of the span from that year to `nowYear`), so the + * oldest era isn't pinned at the very edge of the slider. Falls back to + * FALLBACK_MIN_YEAR if no era has a dated (non-null) start. + */ +export function deriveMinYear(regions: CalendarRegion[], nowYear: number): number { + let oldest: number | undefined; + for (const { eras } of regions) { + for (const era of eras) { + if (!era.start) continue; + const year = parseGregorianDate(era.start).year; + if (oldest === undefined || year < oldest) oldest = year; + } + } + if (oldest === undefined) return FALLBACK_MIN_YEAR; + + const margin = (nowYear - oldest) * MARGIN_FRACTION; + return Math.floor(oldest - margin); +}