fuzzyworld

when-am-i / commit

Derive the year slider's lower bound from the oldest dated calendar era

8fa8f3cbd975eddf4585eb0ba8a06b62ce6f8c7f · Pavel Safronov · 2026-06-21 10:56:09 -0700 · parents f24ee458fee8

Derive the year slider's lower bound from the oldest dated calendar era

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 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);
+}