when-am-i / commit
feat: timeline date picker with era-color year bands
src/App.tsx | 25 ++++- src/components/DatePicker.tsx | 199 +++++++++++++++++++++++++++------------ src/index.css | 37 ++++++++ src/utils/historicalDate.test.ts | 36 ++++++- src/utils/historicalDate.ts | 33 ++++++- 5 files changed, 262 insertions(+), 68 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 58130c1..a881d9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,14 @@ import calendar from './assets/calendar.png'; import React, { useEffect, useMemo, useState } from 'react'; import { MapPicker, type MapOverlay } from './components/MapPicker'; -import { DatePicker } from './components/DatePicker'; +import { DatePicker, type YearBand } from './components/DatePicker'; import { ResultPanel } from './components/ResultPanel'; import { Attribution } from './components/Attribution'; import { regions } from './data'; import { resolve } from './utils/resolve'; import { findEras } from './utils/eras'; +import { findRegions } from './utils/geo'; +import { parseGregorianDate } from './utils/julianDay'; import { calendarColor } from './utils/calendarColor'; import { fromHistoricalDate, @@ -26,7 +28,7 @@ const initial = parseInitialState(typeof window !== 'undefined' ? window.locatio const now = new Date(); const defaultDate: PickerDate = initial.date ? fromHistoricalDate(initial.date) - : { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), era: 'CE' }; + : { year: 1700, month: now.getMonth() + 1, day: now.getDate(), era: 'CE' }; const sectionLabel: React.CSSProperties = { margin: '0 0 4px', fontSize: '0.85em', fontWeight: 600, color: '#555' }; @@ -63,6 +65,23 @@ function App() { return [...byCalendar.entries()].map(([cal, color]) => ({ calendar: cal, color })); }, [overlays]); + // Era bands for the year scrollbar: the picked region's calendars over time. + const yearBands = useMemo<YearBand[]>(() => { + if (!picked) return []; + const bands: YearBand[] = []; + for (const cr of findRegions(picked.lat, picked.lng, regions)) { + for (const era of cr.eras) { + bands.push({ + start: era.start ? parseGregorianDate(era.start).year : -Infinity, + end: era.end ? parseGregorianDate(era.end).year : Infinity, + color: calendarColor(era.calendar), + calendar: era.calendar, + }); + } + } + return bands; + }, [picked]); + const reportContext = useMemo<ReportContext>(() => { const dateText = date.year > 0 ? `${date.year} CE` : `${1 - date.year} BCE`; return { picked, date: `${dateText} (${date.year}-${date.month}-${date.day})` }; @@ -107,7 +126,7 @@ function App() { )} <p style={sectionLabel}>When?</p> - <DatePicker value={pickerDate} onChange={setPickerDate} /> + <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} /> <p style={sectionLabel}>Where?</p> <MapPicker diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index 135935c..9c11f6e 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -1,83 +1,162 @@ import React from 'react'; -import { clampDay, daysInMonth, type Era, type PickerDate } from '../utils/historicalDate'; +import { + daysInYear, + dayOfYear, + fromDayOfYear, + fromHistoricalDate, + toHistoricalDate, + type PickerDate, +} from '../utils/historicalDate'; + +/** A stretch of the year axis using one calendar, for the colored track bands. */ +export interface YearBand { + /** Astronomical years; ±Infinity for open-ended eras. */ + start: number; + end: number; + color: string; + calendar: string; +} interface DatePickerProps { value: PickerDate; onChange: (next: PickerDate) => void; + /** Era bands for the picked region, shown along the year track. */ + yearBands: YearBand[]; } const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; +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; +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; +} + +const barLabel: React.CSSProperties = { margin: 0, fontSize: '0.8em', color: '#666' }; +const rangeStyle: React.CSSProperties = { width: '100%', margin: 0 }; + +const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands }) => { + const astroYear = toHistoricalDate(value).year; + const doy = dayOfYear(astroYear, value.month, value.day); + const yearDays = daysInYear(astroYear); -const fieldStyle: React.CSSProperties = { padding: '6px 8px', fontSize: '1em' }; + const emitYear = (nextAstroYear: number) => { + const { month, day } = fromDayOfYear(nextAstroYear, doy); + onChange(fromHistoricalDate({ year: nextAstroYear, month, day })); + }; -const DatePicker: React.FC<DatePickerProps> = ({ value, onChange }) => { - const astronomicalYear = value.era === 'BCE' ? 1 - value.year : value.year; - const maxDay = daysInMonth(astronomicalYear, value.month); + const emitDoy = (nextDoy: number) => { + const { month, day } = fromDayOfYear(astroYear, nextDoy); + onChange(fromHistoricalDate({ year: astroYear, month, day })); + }; - // Apply a single-field change, then clamp the day so e.g. switching to - // February can't leave day 31 selected. - const update = (patch: Partial<PickerDate>) => onChange(clampDay({ ...value, ...patch })); + // Unique calendars across the bands, for a small legend. + const bandLegend = [...new Map(yearBands.map((b) => [b.calendar, b.color])).entries()]; return ( - <div - style={{ - display: 'flex', - gap: 8, - alignItems: 'flex-end', - flexWrap: 'wrap', - margin: '16px 0', - }} - > - <label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: '0.85em', color: '#555' }}> - Month - <select - style={fieldStyle} - value={value.month} - onChange={(e) => update({ month: Number(e.target.value) })} - > - {MONTHS.map((name, i) => ( - <option key={name} value={i + 1}>{name}</option> - ))} - </select> - </label> + <div style={{ margin: '12px 0', maxWidth: 560 }}> + <div style={{ fontSize: '1.3em', fontWeight: 700, marginBottom: 12 }}> + {MONTHS[value.month - 1]} {value.day}, {yearLabel(astroYear)} + </div> - <label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: '0.85em', color: '#555' }}> - Day - <input - style={{ ...fieldStyle, width: 64 }} - type="number" - min={1} - max={maxDay} - value={value.day} - onChange={(e) => update({ day: Number(e.target.value) })} - /> - </label> + {/* Day-of-year bar */} + <p style={barLabel}>Day of year</p> + <input + type="range" + style={rangeStyle} + min={1} + max={yearDays} + step={1} + value={doy} + onChange={(e) => emitDoy(Number(e.target.value))} + aria-label="Day of year" + aria-valuetext={`${MONTHS[value.month - 1]} ${value.day}`} + /> + <div style={{ position: 'relative', height: 16, marginBottom: 16 }}> + {MONTH_INITIALS.map((initial, i) => { + const left = ((dayOfYear(astroYear, i + 1, 1) - 1) / yearDays) * 100; + return ( + <span + key={i} + style={{ position: 'absolute', left: `${left}%`, fontSize: '0.7em', color: '#999' }} + > + {initial} + </span> + ); + })} + </div> - <label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: '0.85em', color: '#555' }}> - Year + {/* Year bar with era-color bands behind the track */} + <p style={barLabel}>Year</p> + <div style={{ position: 'relative', height: 28 }}> + <div + style={{ + position: 'absolute', + inset: 0, + background: '#e9ecef', + borderRadius: 3, + overflow: 'hidden', + }} + > + {yearBands.map((band, i) => { + const left = pct(band.start); + const width = pct(band.end) - left; + return ( + <div + key={i} + title={band.calendar} + style={{ + position: 'absolute', + left: `${left}%`, + width: `${width}%`, + top: 0, + bottom: 0, + background: band.color, + opacity: 0.75, + }} + /> + ); + })} + </div> <input - style={{ ...fieldStyle, width: 96 }} - type="number" - min={1} - value={value.year} - onChange={(e) => update({ year: Math.max(1, Number(e.target.value)) })} + className="year-range" + style={{ position: 'relative', zIndex: 1 }} + type="range" + min={MIN_YEAR} + max={MAX_YEAR} + step={1} + value={astroYear} + onChange={(e) => emitYear(Number(e.target.value))} + aria-label="Year" + aria-valuetext={yearLabel(astroYear)} /> - </label> - - <label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: '0.85em', color: '#555' }}> - Era - <select - style={fieldStyle} - value={value.era} - onChange={(e) => update({ era: e.target.value as Era })} - > - <option value="CE">CE</option> - <option value="BCE">BCE</option> - </select> - </label> + </div> + <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7em', color: '#999' }}> + <span>{yearLabel(MIN_YEAR)}</span> + <span>{yearLabel(MAX_YEAR)}</span> + </div> + + {bandLegend.length > 0 && ( + <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 6, fontSize: '0.75em', color: '#555' }}> + {bandLegend.map(([cal, color]) => ( + <span key={cal} style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}> + <span aria-hidden style={{ width: 11, height: 11, background: color, border: '1px solid #ccc', borderRadius: 2 }} /> + {cal} + </span> + ))} + </div> + )} </div> ); }; diff --git a/src/index.css b/src/index.css index 3a87d0b..5672d0e 100644 --- a/src/index.css +++ b/src/index.css @@ -5,4 +5,41 @@ body { margin: 0; font-family: system-ui, sans-serif; background: #f8f9fa; +} + +/* Year slider: transparent track so the era-color bands behind it show + through, with a tall "playhead" thumb. */ +.year-range { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 28px; + margin: 0; + background: transparent; + cursor: pointer; +} +.year-range::-webkit-slider-runnable-track { + height: 28px; + background: transparent; +} +.year-range::-moz-range-track { + height: 28px; + background: transparent; +} +.year-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 4px; + height: 36px; + margin-top: -4px; + background: #1a1a1a; + border: 1px solid #fff; + border-radius: 2px; +} +.year-range::-moz-range-thumb { + width: 4px; + height: 36px; + background: #1a1a1a; + border: 1px solid #fff; + border-radius: 2px; } \ No newline at end of file diff --git a/src/utils/historicalDate.test.ts b/src/utils/historicalDate.test.ts index fd1b57e..f9a62bf 100644 --- a/src/utils/historicalDate.test.ts +++ b/src/utils/historicalDate.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { daysInMonth, toHistoricalDate, fromHistoricalDate, clampDay } from './historicalDate'; +import { + daysInMonth, + daysInYear, + dayOfYear, + fromDayOfYear, + toHistoricalDate, + fromHistoricalDate, + clampDay, +} from './historicalDate'; describe('daysInMonth', () => { it('handles February leap years (Gregorian rule)', () => { @@ -15,6 +23,32 @@ describe('daysInMonth', () => { }); }); +describe('day-of-year helpers', () => { + it('daysInYear handles leap years', () => { + expect(daysInYear(2000)).toBe(366); + expect(daysInYear(1900)).toBe(365); + expect(daysInYear(2001)).toBe(365); + }); + + it('dayOfYear counts from Jan 1', () => { + expect(dayOfYear(2001, 1, 1)).toBe(1); + expect(dayOfYear(2001, 3, 1)).toBe(60); // 31 + 28 + 1 + expect(dayOfYear(2000, 3, 1)).toBe(61); // leap: 31 + 29 + 1 + expect(dayOfYear(2001, 12, 31)).toBe(365); + }); + + it('fromDayOfYear inverts dayOfYear', () => { + for (const [year, m, d] of [[2001, 3, 1], [2000, 3, 1], [2026, 6, 18], [1999, 12, 31]] as const) { + expect(fromDayOfYear(year, dayOfYear(year, m, d))).toEqual({ month: m, day: d }); + } + }); + + it('fromDayOfYear clamps out-of-range input', () => { + expect(fromDayOfYear(2001, 999)).toEqual({ month: 12, day: 31 }); + expect(fromDayOfYear(2001, 0)).toEqual({ month: 1, day: 1 }); + }); +}); + describe('toHistoricalDate / fromHistoricalDate', () => { it('passes CE years through unchanged', () => { expect(toHistoricalDate({ year: 1700, month: 3, day: 22, era: 'CE' })).toEqual({ year: 1700, month: 3, day: 22 }); diff --git a/src/utils/historicalDate.ts b/src/utils/historicalDate.ts index cf7dceb..050ccb5 100644 --- a/src/utils/historicalDate.ts +++ b/src/utils/historicalDate.ts @@ -16,15 +16,40 @@ export interface PickerDate { era: Era; } +/** Whether a proleptic-Gregorian year is a leap year. */ +export function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + /** Days in a proleptic-Gregorian month (astronomical year numbering). */ export function daysInMonth(year: number, month: number): number { - if (month === 2) { - const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; - return leap ? 29 : 28; - } + if (month === 2) return isLeapYear(year) ? 29 : 28; return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 31; } +/** Days in a proleptic-Gregorian year (365 or 366). */ +export function daysInYear(year: number): number { + return isLeapYear(year) ? 366 : 365; +} + +/** 1-based ordinal day within the year for a (month, day). */ +export function dayOfYear(year: number, month: number, day: number): number { + let doy = day; + for (let m = 1; m < month; m++) doy += daysInMonth(year, m); + return doy; +} + +/** Convert a 1-based ordinal day-of-year back to a (month, day), clamped. */ +export function fromDayOfYear(year: number, doy: number): { month: number; day: number } { + let remaining = Math.max(1, Math.min(Math.round(doy), daysInYear(year))); + let month = 1; + while (month < 12 && remaining > daysInMonth(year, month)) { + remaining -= daysInMonth(year, month); + month++; + } + return { month, day: remaining }; +} + /** Convert a picker date to the engine's astronomical HistoricalDate. */ export function toHistoricalDate({ year, month, day, era }: PickerDate): HistoricalDate { // BCE uses astronomical numbering: 1 BCE = 0, 2 BCE = -1, …