when-am-i / commit
Show calendar-era bands on the day-of-year scrollbar with clickable changeover ticks
README.md | 8 +- screenshots/app/01-landing.png | Bin 297586 -> 297492 bytes screenshots/app/02-boston-1650.png | Bin 1196180 -> 1196456 bytes screenshots/app/03-boston-1752.png | Bin 0 -> 1199007 bytes screenshots/app/03-boston-1800.png | Bin 1190074 -> 0 bytes screenshots/app/04-boston-1800.png | Bin 0 -> 1190448 bytes .../calendars/aztec/01-aztec-xiuhp-hualli.png | Bin 868014 -> 868143 bytes .../aztec/02-aztec-mexica-year-bearer.png | Bin 868014 -> 868143 bytes screenshots/calendars/aztec/03-t-nalp-hualli.png | Bin 868934 -> 869081 bytes .../china/01-chinese-sexagenary-cycle.png | Bin 584041 -> 584043 bytes .../calendars/colonial-america/01-julian.png | Bin 416532 -> 416457 bytes .../calendars/colonial-america/02-gregorian.png | Bin 400139 -> 400240 bytes screenshots/calendars/ethiopia/01-ethiopian.png | Bin 487790 -> 487786 bytes screenshots/calendars/india/01-indian.png | Bin 465215 -> 465219 bytes screenshots/calendars/iran/01-islamic.png | Bin 514650 -> 514649 bytes screenshots/calendars/iran/02-persian.png | Bin 621930 -> 621927 bytes .../japan/01-japanese-lunisolar-calendar.png | Bin 450820 -> 450819 bytes screenshots/calendars/japan/02-japanese.png | Bin 429817 -> 429820 bytes screenshots/calendars/judea/01-hebrew.png | Bin 890624 -> 890620 bytes screenshots/calendars/judea/02-ab-urbe-condita.png | Bin 1128153 -> 1128186 bytes screenshots/calendars/judea/03-seleucid-era.png | Bin 1128473 -> 1128522 bytes .../korea/01-korean-lunisolar-calendar.png | Bin 755465 -> 755467 bytes screenshots/calendars/korea/02-gregorian.png | Bin 682457 -> 682404 bytes screenshots/calendars/korea/03-gregorian.png | Bin 680413 -> 680356 bytes screenshots/calendars/korea/04-gregorian.png | Bin 655458 -> 655400 bytes screenshots/calendars/korea/05-gregorian.png | Bin 661989 -> 661948 bytes .../maori/01-maramataka-m-ori-lunar-calendar.png | Bin 444079 -> 444075 bytes .../massachusett/01-algonquian-lunar-calendar.png | Bin 1082019 -> 1081968 bytes screenshots/calendars/maya/01-maya-long-count.png | Bin 907023 -> 907170 bytes .../maya/02-tzolk-in-and-haab-calendar-round.png | Bin 694426 -> 694533 bytes screenshots/calendars/north-korea/01-dangi.png | Bin 752334 -> 752302 bytes screenshots/calendars/north-korea/02-juche.png | Bin 793504 -> 793472 bytes screenshots/calendars/north-korea/03-gregorian.png | Bin 745821 -> 745772 bytes .../north-sentinel-island/01-none-documented.png | Bin 508468 -> 508467 bytes .../calendars/oregon-country/01-gregorian.png | Bin 495385 -> 495541 bytes screenshots/calendars/ottoman/01-islamic.png | Bin 500085 -> 500085 bytes screenshots/calendars/ottoman/02-rumi.png | Bin 716632 -> 716646 bytes screenshots/calendars/ottoman/03-gregorian.png | Bin 434544 -> 434499 bytes .../revolutionary-france/01-gregorian.png | Bin 460458 -> 460360 bytes .../02-french-republican-calendar.png | Bin 534635 -> 534629 bytes .../03-french-republican-calendar.png | Bin 452995 -> 455318 bytes .../revolutionary-france/04-gregorian.png | Bin 455844 -> 455799 bytes .../calendars/russian-america/01-julian.png | Bin 490970 -> 490929 bytes .../calendars/russian-america/02-gregorian.png | Bin 474531 -> 474491 bytes screenshots/calendars/thailand/01-buddhist.png | Bin 486772 -> 486755 bytes .../vietnam/01-vietnamese-lunisolar-calendar.png | Bin 718942 -> 718941 bytes screenshots/calendars/vietnam/02-gregorian.png | Bin 707550 -> 707654 bytes .../vietnam/03-vietnamese-lunisolar-calendar.png | Bin 707550 -> 707654 bytes screenshots/readme/01-landing.png | Bin 229447 -> 229441 bytes screenshots/readme/02-example.png | Bin 942830 -> 942783 bytes scripts/generate-screenshots.mts | 7 +- scripts/lib/server.mts | 11 +- src/App.tsx | 68 ++++------ src/components/DatePicker.tsx | 145 ++++++++++++++++++--- src/utils/timelineBands.test.ts | 99 ++++++++++++++ src/utils/timelineBands.ts | 99 ++++++++++++++ 56 files changed, 373 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index f3ce1ab..bff3d09 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,10 @@ when more than one calendar applies at once: they're evenly spaced and labelled (e.g. 1954 for Vietnam) instead of crammed into recent centuries; dashed lines mark where the calendar actually changes, and each year label is **clickable to jump** straight to that date +- A **day-of-year track** color-coded the same way for the *selected* year, so a + changeover that falls mid-year shows up as a color boundary within the year — + e.g. for Boston in 1752 it switches from Julian to Gregorian in mid-September + (the British calendar reform), with a dashed line on the exact day - **Shuffle** button that drops you at a random place and date on a non-default (non-Gregorian) calendar — e.g. Chinese, Japanese, Saka, Buddhist, Ethiopian, Islamic, Persian, Julian, or a lunar reckoning @@ -137,7 +141,9 @@ npm run gen:badges -- --check # fail if the block is stale (no write) — for CI Screenshots are generated with Playwright, which forces deterministic app state via URL params (`?lat=&lng=&zoom=&year=&month=&day=&era=`). The scripts reuse a -running dev server or start one automatically. +running dev server on port 5173 or start one automatically. Set +`SCREENSHOT_PORT` to use a different port — handy when another checkout (e.g. a +parallel workspace) already holds 5173, so you capture *this* code, not theirs. ```bash npm run screenshot # app states → screenshots/app/ diff --git a/screenshots/app/01-landing.png b/screenshots/app/01-landing.png index 5abf82d..b502a86 100644 Binary files a/screenshots/app/01-landing.png and b/screenshots/app/01-landing.png differ diff --git a/screenshots/app/02-boston-1650.png b/screenshots/app/02-boston-1650.png index 1ede8ff..f2064f0 100644 Binary files a/screenshots/app/02-boston-1650.png and b/screenshots/app/02-boston-1650.png differ diff --git a/screenshots/app/03-boston-1752.png b/screenshots/app/03-boston-1752.png new file mode 100644 index 0000000..042d8b6 Binary files /dev/null and b/screenshots/app/03-boston-1752.png differ diff --git a/screenshots/app/03-boston-1800.png b/screenshots/app/03-boston-1800.png deleted file mode 100644 index b4b827a..0000000 Binary files a/screenshots/app/03-boston-1800.png and /dev/null differ diff --git a/screenshots/app/04-boston-1800.png b/screenshots/app/04-boston-1800.png new file mode 100644 index 0000000..9070cc6 Binary files /dev/null and b/screenshots/app/04-boston-1800.png differ diff --git a/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png b/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png index c40d8db..aa5a787 100644 Binary files a/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png and b/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png differ diff --git a/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png b/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png index c40d8db..aa5a787 100644 Binary files a/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png and b/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png differ diff --git a/screenshots/calendars/aztec/03-t-nalp-hualli.png b/screenshots/calendars/aztec/03-t-nalp-hualli.png index 5d9d605..9793e99 100644 Binary files a/screenshots/calendars/aztec/03-t-nalp-hualli.png and b/screenshots/calendars/aztec/03-t-nalp-hualli.png differ diff --git a/screenshots/calendars/china/01-chinese-sexagenary-cycle.png b/screenshots/calendars/china/01-chinese-sexagenary-cycle.png index 1d90cf7..baa9102 100644 Binary files a/screenshots/calendars/china/01-chinese-sexagenary-cycle.png and b/screenshots/calendars/china/01-chinese-sexagenary-cycle.png differ diff --git a/screenshots/calendars/colonial-america/01-julian.png b/screenshots/calendars/colonial-america/01-julian.png index 0b9cc45..9a98eb7 100644 Binary files a/screenshots/calendars/colonial-america/01-julian.png and b/screenshots/calendars/colonial-america/01-julian.png differ diff --git a/screenshots/calendars/colonial-america/02-gregorian.png b/screenshots/calendars/colonial-america/02-gregorian.png index 85ca4f2..18679c1 100644 Binary files a/screenshots/calendars/colonial-america/02-gregorian.png and b/screenshots/calendars/colonial-america/02-gregorian.png differ diff --git a/screenshots/calendars/ethiopia/01-ethiopian.png b/screenshots/calendars/ethiopia/01-ethiopian.png index 22fcb95..07915ef 100644 Binary files a/screenshots/calendars/ethiopia/01-ethiopian.png and b/screenshots/calendars/ethiopia/01-ethiopian.png differ diff --git a/screenshots/calendars/india/01-indian.png b/screenshots/calendars/india/01-indian.png index 1479c1a..a377c54 100644 Binary files a/screenshots/calendars/india/01-indian.png and b/screenshots/calendars/india/01-indian.png differ diff --git a/screenshots/calendars/iran/01-islamic.png b/screenshots/calendars/iran/01-islamic.png index 8c945bc..4ae9752 100644 Binary files a/screenshots/calendars/iran/01-islamic.png and b/screenshots/calendars/iran/01-islamic.png differ diff --git a/screenshots/calendars/iran/02-persian.png b/screenshots/calendars/iran/02-persian.png index 07d6bf7..3e0f262 100644 Binary files a/screenshots/calendars/iran/02-persian.png and b/screenshots/calendars/iran/02-persian.png differ diff --git a/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png b/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png index 3cb193c..5ec1895 100644 Binary files a/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png and b/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png differ diff --git a/screenshots/calendars/japan/02-japanese.png b/screenshots/calendars/japan/02-japanese.png index 7b903c2..0c15c97 100644 Binary files a/screenshots/calendars/japan/02-japanese.png and b/screenshots/calendars/japan/02-japanese.png differ diff --git a/screenshots/calendars/judea/01-hebrew.png b/screenshots/calendars/judea/01-hebrew.png index af0e5b6..03a6ba9 100644 Binary files a/screenshots/calendars/judea/01-hebrew.png and b/screenshots/calendars/judea/01-hebrew.png differ diff --git a/screenshots/calendars/judea/02-ab-urbe-condita.png b/screenshots/calendars/judea/02-ab-urbe-condita.png index ee660d9..71cae1e 100644 Binary files a/screenshots/calendars/judea/02-ab-urbe-condita.png and b/screenshots/calendars/judea/02-ab-urbe-condita.png differ diff --git a/screenshots/calendars/judea/03-seleucid-era.png b/screenshots/calendars/judea/03-seleucid-era.png index ab272ec..38650ee 100644 Binary files a/screenshots/calendars/judea/03-seleucid-era.png and b/screenshots/calendars/judea/03-seleucid-era.png differ diff --git a/screenshots/calendars/korea/01-korean-lunisolar-calendar.png b/screenshots/calendars/korea/01-korean-lunisolar-calendar.png index 4bb6aad..0caba2b 100644 Binary files a/screenshots/calendars/korea/01-korean-lunisolar-calendar.png and b/screenshots/calendars/korea/01-korean-lunisolar-calendar.png differ diff --git a/screenshots/calendars/korea/02-gregorian.png b/screenshots/calendars/korea/02-gregorian.png index 5860a2e..22e3352 100644 Binary files a/screenshots/calendars/korea/02-gregorian.png and b/screenshots/calendars/korea/02-gregorian.png differ diff --git a/screenshots/calendars/korea/03-gregorian.png b/screenshots/calendars/korea/03-gregorian.png index 2848c8c..b61ac5c 100644 Binary files a/screenshots/calendars/korea/03-gregorian.png and b/screenshots/calendars/korea/03-gregorian.png differ diff --git a/screenshots/calendars/korea/04-gregorian.png b/screenshots/calendars/korea/04-gregorian.png index eff1920..f7f940b 100644 Binary files a/screenshots/calendars/korea/04-gregorian.png and b/screenshots/calendars/korea/04-gregorian.png differ diff --git a/screenshots/calendars/korea/05-gregorian.png b/screenshots/calendars/korea/05-gregorian.png index 291c873..7611559 100644 Binary files a/screenshots/calendars/korea/05-gregorian.png and b/screenshots/calendars/korea/05-gregorian.png differ diff --git a/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png b/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png index 9004b0a..ec96573 100644 Binary files a/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png and b/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png differ diff --git a/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png b/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png index 4799156..1d540b8 100644 Binary files a/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png and b/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png differ diff --git a/screenshots/calendars/maya/01-maya-long-count.png b/screenshots/calendars/maya/01-maya-long-count.png index 658c7af..73a7bde 100644 Binary files a/screenshots/calendars/maya/01-maya-long-count.png and b/screenshots/calendars/maya/01-maya-long-count.png differ diff --git a/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png b/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png index 1bc437d..d50812d 100644 Binary files a/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png and b/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png differ diff --git a/screenshots/calendars/north-korea/01-dangi.png b/screenshots/calendars/north-korea/01-dangi.png index 1b96b1c..c21d197 100644 Binary files a/screenshots/calendars/north-korea/01-dangi.png and b/screenshots/calendars/north-korea/01-dangi.png differ diff --git a/screenshots/calendars/north-korea/02-juche.png b/screenshots/calendars/north-korea/02-juche.png index 3486f70..1c08e96 100644 Binary files a/screenshots/calendars/north-korea/02-juche.png and b/screenshots/calendars/north-korea/02-juche.png differ diff --git a/screenshots/calendars/north-korea/03-gregorian.png b/screenshots/calendars/north-korea/03-gregorian.png index 7adb052..59d6472 100644 Binary files a/screenshots/calendars/north-korea/03-gregorian.png and b/screenshots/calendars/north-korea/03-gregorian.png differ diff --git a/screenshots/calendars/north-sentinel-island/01-none-documented.png b/screenshots/calendars/north-sentinel-island/01-none-documented.png index bbaa35a..7d7bbf1 100644 Binary files a/screenshots/calendars/north-sentinel-island/01-none-documented.png and b/screenshots/calendars/north-sentinel-island/01-none-documented.png differ diff --git a/screenshots/calendars/oregon-country/01-gregorian.png b/screenshots/calendars/oregon-country/01-gregorian.png index 626df2a..c022eef 100644 Binary files a/screenshots/calendars/oregon-country/01-gregorian.png and b/screenshots/calendars/oregon-country/01-gregorian.png differ diff --git a/screenshots/calendars/ottoman/01-islamic.png b/screenshots/calendars/ottoman/01-islamic.png index c5edd86..b36018b 100644 Binary files a/screenshots/calendars/ottoman/01-islamic.png and b/screenshots/calendars/ottoman/01-islamic.png differ diff --git a/screenshots/calendars/ottoman/02-rumi.png b/screenshots/calendars/ottoman/02-rumi.png index 3c23660..5891d30 100644 Binary files a/screenshots/calendars/ottoman/02-rumi.png and b/screenshots/calendars/ottoman/02-rumi.png differ diff --git a/screenshots/calendars/ottoman/03-gregorian.png b/screenshots/calendars/ottoman/03-gregorian.png index 354b5c8..da1cc51 100644 Binary files a/screenshots/calendars/ottoman/03-gregorian.png and b/screenshots/calendars/ottoman/03-gregorian.png differ diff --git a/screenshots/calendars/revolutionary-france/01-gregorian.png b/screenshots/calendars/revolutionary-france/01-gregorian.png index dc5b97e..e64a21d 100644 Binary files a/screenshots/calendars/revolutionary-france/01-gregorian.png and b/screenshots/calendars/revolutionary-france/01-gregorian.png differ diff --git a/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png b/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png index 555d34d..7232ce6 100644 Binary files a/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png and b/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png differ diff --git a/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png b/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png index b60f80c..a5cdd3a 100644 Binary files a/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png and b/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png differ diff --git a/screenshots/calendars/revolutionary-france/04-gregorian.png b/screenshots/calendars/revolutionary-france/04-gregorian.png index c717ac7..44412bb 100644 Binary files a/screenshots/calendars/revolutionary-france/04-gregorian.png and b/screenshots/calendars/revolutionary-france/04-gregorian.png differ diff --git a/screenshots/calendars/russian-america/01-julian.png b/screenshots/calendars/russian-america/01-julian.png index ef0543c..88262b6 100644 Binary files a/screenshots/calendars/russian-america/01-julian.png and b/screenshots/calendars/russian-america/01-julian.png differ diff --git a/screenshots/calendars/russian-america/02-gregorian.png b/screenshots/calendars/russian-america/02-gregorian.png index bca5488..a415acb 100644 Binary files a/screenshots/calendars/russian-america/02-gregorian.png and b/screenshots/calendars/russian-america/02-gregorian.png differ diff --git a/screenshots/calendars/thailand/01-buddhist.png b/screenshots/calendars/thailand/01-buddhist.png index baf8e3e..38974a5 100644 Binary files a/screenshots/calendars/thailand/01-buddhist.png and b/screenshots/calendars/thailand/01-buddhist.png differ diff --git a/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png b/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png index f17ff0d..588f798 100644 Binary files a/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png and b/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png differ diff --git a/screenshots/calendars/vietnam/02-gregorian.png b/screenshots/calendars/vietnam/02-gregorian.png index 8ac4d1b..228cfc2 100644 Binary files a/screenshots/calendars/vietnam/02-gregorian.png and b/screenshots/calendars/vietnam/02-gregorian.png differ diff --git a/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png b/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png index 8ac4d1b..228cfc2 100644 Binary files a/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png and b/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png differ diff --git a/screenshots/readme/01-landing.png b/screenshots/readme/01-landing.png index 5e4cfa0..e225717 100644 Binary files a/screenshots/readme/01-landing.png and b/screenshots/readme/01-landing.png differ diff --git a/screenshots/readme/02-example.png b/screenshots/readme/02-example.png index b1d327b..33a2465 100644 Binary files a/screenshots/readme/02-example.png and b/screenshots/readme/02-example.png differ diff --git a/scripts/generate-screenshots.mts b/scripts/generate-screenshots.mts index 40e3698..9b1d598 100644 --- a/scripts/generate-screenshots.mts +++ b/scripts/generate-screenshots.mts @@ -19,7 +19,12 @@ const STATES = [ desc: 'Boston, 1650 — coexistence (Massachusett + colonial Julian)', }, { - name: '03-boston-1800', + name: '03-boston-1752', + query: '?lat=42.32&lng=-71.06&zoom=10&year=1752&month=9&day=20', + desc: 'Boston, 1752 — the British Julian→Gregorian switch mid-September (day-of-year track splits)', + }, + { + name: '04-boston-1800', query: '?lat=42.32&lng=-71.06&zoom=10&year=1800&month=6&day=15', desc: 'Boston, 1800 — Gregorian', }, diff --git a/scripts/lib/server.mts b/scripts/lib/server.mts index 0c5ddef..a925557 100644 --- a/scripts/lib/server.mts +++ b/scripts/lib/server.mts @@ -8,7 +8,12 @@ import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const REPO_ROOT = path.resolve(__dirname, '../..'); -export const BASE_URL = 'http://127.0.0.1:5173/when-am-i/'; + +// Port is overridable via SCREENSHOT_PORT so screenshots can be generated +// against *this* checkout even when another Vite server (e.g. a parallel +// Conductor workspace) already holds the default 5173. Defaults to 5173. +const PORT = process.env.SCREENSHOT_PORT ?? '5173'; +export const BASE_URL = `http://127.0.0.1:${PORT}/when-am-i/`; function isUp(url: string): Promise<boolean> { return new Promise((resolve) => { @@ -36,8 +41,8 @@ export async function startDevServer(): Promise<DevServer> { return { stop: () => {} }; } - console.log(' 🚀 Starting Vite dev server…'); - const proc: ChildProcess = spawn('npx', ['vite', '--port', '5173', '--strictPort'], { + console.log(` 🚀 Starting Vite dev server on :${PORT}…`); + const proc: ChildProcess = spawn('npx', ['vite', '--port', PORT, '--strictPort'], { cwd: REPO_ROOT, stdio: 'ignore', }); diff --git a/src/App.tsx b/src/App.tsx index bb08ec1..74da4f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,13 @@ import calendar from './assets/calendar.png'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { MapPicker, type MapOverlay, type Recenter } from './components/MapPicker'; -import { DatePicker, type YearBand } from './components/DatePicker'; +import { DatePicker, type YearBand, type DayBand } from './components/DatePicker'; import { ResultPanel } from './components/ResultPanel'; import { Attribution } from './components/Attribution'; import { regions } from './data'; -import { resolve, MODERN_DEFAULT_START_YEAR } from './utils/resolve'; +import { resolve } from './utils/resolve'; import { findEras } from './utils/eras'; -import { findRegions } from './utils/geo'; -import { parseGregorianDate } from './utils/julianDay'; +import { yearBandsForPoint, dayBandsForPoint } from './utils/timelineBands'; import { deriveMinYear } from './utils/dateRange'; import { calendarColorScale } from './utils/calendarColor'; import { nearestCity } from './utils/nearestCity'; @@ -117,41 +116,30 @@ function App() { }, [date]); // Era bands for the year scrollbar: the picked region's calendars over time. - const yearBands = useMemo<YearBand[]>(() => { - if (!picked) return []; - const bands: YearBand[] = []; - let suppressesModernDefault = false; - for (const cr of findRegions(picked.lat, picked.lng, regions)) { - for (const era of cr.eras) { - const end = era.end ? parseGregorianDate(era.end).year : Infinity; - bands.push({ - start: era.start ? parseGregorianDate(era.start).year : -Infinity, - end, - color: colorFor(era.calendar), - calendar: era.calendar, - }); - // Mirror resolve()'s suppressModernDefault: an era opting out - // (e.g. North Sentinel Island) blocks the global Gregorian band - // too, not just the text answer — only if it reaches modern times. - if (era.suppressModernDefault && end >= MODERN_DEFAULT_START_YEAR) suppressesModernDefault = true; - } - } - // Mirror resolve()'s modern global Gregorian default on the timeline, - // unless a region already supplies a Gregorian band reaching modern - // times, or an era explicitly suppresses the default here. - const hasModernGregorian = bands.some( - (b) => b.calendar === 'Gregorian' && b.end >= MODERN_DEFAULT_START_YEAR, - ); - if (!hasModernGregorian && !suppressesModernDefault) { - bands.push({ - start: MODERN_DEFAULT_START_YEAR, - end: Infinity, - color: colorFor('Gregorian'), - calendar: 'Gregorian', - }); - } - return bands; - }, [picked]); + const yearBands = useMemo<YearBand[]>( + () => + picked + ? yearBandsForPoint(picked.lat, picked.lng, regions).map((b) => ({ + ...b, + color: colorFor(b.calendar), + })) + : [], + [picked], + ); + + // Era bands for the day-of-year scrollbar: the same calendars clipped to the + // selected year, so a mid-year changeover (e.g. a Gregorian adoption date) + // shows up as a color boundary within the year, not just between years. + const dayBands = useMemo<DayBand[]>( + () => + picked + ? dayBandsForPoint(picked.lat, picked.lng, regions, date.year).map((b) => ({ + ...b, + color: colorFor(b.calendar), + })) + : [], + [picked, date.year], + ); const reportContext = useMemo<ReportContext>(() => { const dateText = date.year > 0 ? `${date.year} CE` : `${1 - date.year} BCE`; @@ -234,7 +222,7 @@ function App() { <div className="app-cols"> <div> <p style={sectionLabel}>When?</p> - <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} minYear={minYear} /> + <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} dayBands={dayBands} minYear={minYear} /> <p style={sectionLabel}>Where?</p> {picked && ( diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index 3d649c2..7a7c239 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -19,11 +19,21 @@ export interface YearBand { calendar: string; } +/** A stretch of the day-of-year axis using one calendar (1-based, inclusive). */ +export interface DayBand { + startDoy: number; + endDoy: 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[]; + /** Era bands clipped to the selected year, shown along the day-of-year track. */ + dayBands: DayBand[]; /** Earliest year the slider reaches; defaults to FALLBACK_MIN_YEAR. */ minYear?: number; } @@ -67,11 +77,47 @@ const trackStyle: React.CSSProperties = { overflow: 'hidden', }; -const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, minYear = FALLBACK_MIN_YEAR }) => { +const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, dayBands, minYear = FALLBACK_MIN_YEAR }) => { const astroYear = toHistoricalDate(value).year; const doy = dayOfYear(astroYear, value.month, value.day); const yearDays = daysInYear(astroYear); + // Day-of-year track: a day d (1-based) occupies the slice + // [(d-1)/yearDays, d/yearDays), matching the month-initial placement below, + // so a band covering days [startDoy, endDoy] spans those slices exactly. + const dayPct = (doy0: number) => (doy0 / yearDays) * 100; + // Pack any concurrent calendars (parallel reckonings in the same year) into + // lanes, as the year bar does. endDoy is inclusive ⇒ exclusive edge is +1. + const { lanes: dayLanes, count: dayLaneCount } = assignLanes(dayBands, (b) => b.startDoy, (b) => b.endDoy + 1); + // Days where the set of calendar colors changes — drawn as dashed verticals + // so a mid-year changeover reads as a transition, like the year axis. + const dayColorsAt = (d: number) => + [...new Set(dayBands.filter((b) => b.startDoy <= d && d <= b.endDoy).map((b) => b.color))].sort().join('|'); + const dayBoundaries = Array.from({ length: yearDays - 1 }, (_, i) => i + 2).filter( + (d) => dayColorsAt(d - 1) !== dayColorsAt(d), + ); + // Each changeover day is a clickable tick (like the year axis's key dates): + // its label is the date the new calendar took effect, and clicking jumps the + // selection straight to that day. + const dayTickLabel = (d: number) => { + const { month, day } = fromDayOfYear(astroYear, d); + return `${MONTHS[month - 1].slice(0, 3)} ${day}`; + }; + // Group ticks that are too close to label separately — render as a + // space-separated row at their midpoint so close changeovers (e.g. the + // 18-day 1871 Paris Commune) appear side-by-side on one line. + const DAY_TICK_CLUSTER_GAP_PCT = 8; + const dayTickClusters: { ticks: { d: number; pct: number }[]; midPct: number }[] = []; + for (const d of dayBoundaries) { + const pct = dayPct(d - 1); + const last = dayTickClusters[dayTickClusters.length - 1]; + if (last && pct - last.ticks[0].pct < DAY_TICK_CLUSTER_GAP_PCT) { + last.ticks.push({ d, pct }); + last.midPct = (last.ticks[0].pct + pct) / 2; + } else { + dayTickClusters.push({ ticks: [{ d, pct }], midPct: pct }); + } + } const emitYear = (nextAstroYear: number) => { const { month, day } = fromDayOfYear(nextAstroYear, doy); onChange(fromHistoricalDate({ year: nextAstroYear, month, day })); @@ -118,20 +164,64 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, min {MONTHS[value.month - 1]} {value.day}, {yearLabel(astroYear)} </div> - {/* Day-of-year bar: grey track with a fill up to the current day */} + {/* Day-of-year bar: calendar-colored bands for the selected year + (or a grey progress fill when no region is pinned), with clickable + ticks at any mid-year changeover. */} <p style={barLabel}>Day of year</p> <div style={{ position: 'relative', height: 28 }}> <div style={trackStyle}> - <div - style={{ - position: 'absolute', - left: 0, - width: `${(doy / yearDays) * 100}%`, - top: 0, - bottom: 0, - background: '#adb5bd', - }} - /> + {dayBands.length > 0 ? ( + <> + {/* Calendar-colored bands for the selected year: a + changeover mid-year splits the track, so you can + see *when in the year* the calendar changed. */} + {dayLanes.map(({ item: band, lane }, i) => { + const left = dayPct(band.startDoy - 1); + const width = dayPct(band.endDoy) - left; + return ( + <div + key={i} + title={band.calendar} + style={{ + position: 'absolute', + left: `${left}%`, + width: `${width}%`, + top: `${(lane / dayLaneCount) * 100}%`, + height: `${(1 / dayLaneCount) * 100}%`, + background: band.color, + opacity: 0.85, + }} + /> + ); + })} + {dayBoundaries.map((d) => ( + <div + key={d} + aria-hidden + style={{ + position: 'absolute', + left: `${dayPct(d - 1)}%`, + top: 0, + bottom: 0, + borderLeft: '1px dashed rgba(0, 0, 0, 0.4)', + pointerEvents: 'none', + }} + /> + ))} + </> + ) : ( + // No region pinned: fall back to a plain progress fill. + <div + style={{ + position: 'absolute', + left: 0, + width: `${dayPct(doy)}%`, + top: 0, + bottom: 0, + background: '#adb5bd', + }} + /> + )} </div> <input className="timeline-slider" @@ -145,18 +235,35 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, min aria-valuetext={`${MONTHS[value.month - 1]} ${value.day}`} /> </div> - <div style={{ position: 'relative', height: 16, marginTop: 4, marginBottom: 16 }}> - {MONTH_INITIALS.map((initial, i) => { - const left = ((dayOfYear(astroYear, i + 1, 1) - 1) / yearDays) * 100; + <div style={{ position: 'relative', height: 16, marginTop: 4, marginBottom: 16, fontSize: '0.7em', color: '#999' }}> + {dayTickClusters.length === 0 && MONTH_INITIALS.map((initial, i) => { + const left = dayPct(dayOfYear(astroYear, i + 1, 1) - 1); return ( - <span - key={i} - style={{ position: 'absolute', left: `${left}%`, fontSize: '0.7em', color: '#999' }} - > + <span key={i} style={{ position: 'absolute', top: 0, left: `${left}%` }}> {initial} </span> ); })} + {/* Changeover ticks: close ticks are grouped into a cluster and + rendered side-by-side at their midpoint so they never stack. */} + {dayTickClusters.map(({ ticks, midPct }) => ( + <span + key={ticks[0].d} + style={{ position: 'absolute', top: 0, left: `${midPct}%`, transform: 'translateX(-50%)', whiteSpace: 'nowrap', display: 'inline-flex', gap: '0.6em' }} + > + {ticks.map(({ d }) => ( + <button + key={d} + type="button" + className="year-tick" + onClick={() => emitDoy(d)} + aria-label={`Jump to ${dayTickLabel(d)}`} + > + {dayTickLabel(d)} + </button> + ))} + </span> + ))} </div> {/* Year bar with era-color bands behind the track */} diff --git a/src/utils/timelineBands.test.ts b/src/utils/timelineBands.test.ts new file mode 100644 index 0000000..b0608af --- /dev/null +++ b/src/utils/timelineBands.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { effectiveErasForPoint, yearBandsForPoint, dayBandsForPoint } from './timelineBands'; +import { daysInYear } from './historicalDate'; +import type { CalendarRegion, Geometry } from '../types'; + +const square: Geometry = { + type: 'Polygon', + coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], +}; + +// A region that switches calendars mid-year, on 22 September 1792 (the French +// Republican start) — a real intra-year changeover for the day-band tests. +const switchover: CalendarRegion[] = [ + { + region: { name: 'France', geo: square }, + sourceFile: 'src/data/calendars/france.json', + eras: [ + { name: 'Ancien régime', start: null, end: '1792-09-22', calendar: 'Gregorian' }, + { name: 'Republic', start: '1792-09-22', end: '1806-01-01', calendar: 'French Republican calendar' }, + ], + }, +]; + +describe('effectiveErasForPoint', () => { + it('returns just the modern Gregorian default outside every region', () => { + // The default is appended unconditionally (mirroring the old year-band + // behavior); the day/year mappers clip it out of pre-modern years. + const eras = effectiveErasForPoint(50, 50, switchover); + expect(eras).toHaveLength(1); + expect(eras[0].name).toBe('Modern (global civil calendar)'); + expect(eras[0].calendar).toBe('Gregorian'); + }); + + it('appends the modern Gregorian default for a region without modern Gregorian', () => { + const eras = effectiveErasForPoint(5, 5, switchover); + expect(eras.map((e) => e.calendar)).toEqual([ + 'Gregorian', // ancien régime, ends 1792 (does not reach modern) + 'French Republican calendar', + 'Gregorian', // appended global default + ]); + expect(eras[eras.length - 1].name).toBe('Modern (global civil calendar)'); + }); + + it('does not append the default when a region already has modern Gregorian', () => { + const modern: CalendarRegion[] = [ + { region: { name: 'X', geo: square }, eras: [{ name: 'Now', start: '1900-01-01', end: null, calendar: 'Gregorian' }] }, + ]; + expect(effectiveErasForPoint(5, 5, modern)).toHaveLength(1); + }); + + it('suppresses the default when an era opts out and reaches modern times', () => { + const isolated: CalendarRegion[] = [ + { region: { name: 'I', geo: square }, eras: [{ name: 'None', start: null, end: null, calendar: 'None', suppressModernDefault: true }] }, + ]; + const eras = effectiveErasForPoint(5, 5, isolated); + expect(eras).toHaveLength(1); + expect(eras[0].calendar).toBe('None'); + }); +}); + +describe('yearBandsForPoint', () => { + it('maps eras to year spans with ±Infinity for open ends', () => { + const bands = yearBandsForPoint(5, 5, switchover); + expect(bands[0]).toEqual({ start: -Infinity, end: 1792, calendar: 'Gregorian' }); + expect(bands[1]).toEqual({ start: 1792, end: 1806, calendar: 'French Republican calendar' }); + // appended modern default + expect(bands[2]).toEqual({ start: 1927, end: Infinity, calendar: 'Gregorian' }); + }); +}); + +describe('dayBandsForPoint', () => { + it('splits the changeover year at the exact day', () => { + // 1792 is a leap year; 22 Sept is day 266. The half-open boundary means + // the old calendar covers days 1–265 and the new one 266–366. + const bands = dayBandsForPoint(5, 5, switchover, 1792); + expect(daysInYear(1792)).toBe(366); + expect(bands).toEqual([ + { startDoy: 1, endDoy: 265, calendar: 'Gregorian' }, + { startDoy: 266, endDoy: 366, calendar: 'French Republican calendar' }, + ]); + }); + + it('yields a single full-year band for a year with no changeover', () => { + const bands = dayBandsForPoint(5, 5, switchover, 1800); + expect(bands).toEqual([{ startDoy: 1, endDoy: 365, calendar: 'French Republican calendar' }]); + }); + + it('drops eras that do not reach into the selected year', () => { + // 1850: the region's eras have lapsed and the global default starts 1927, + // so no band covers this year here. + expect(dayBandsForPoint(5, 5, switchover, 1850)).toEqual([]); + }); + + it('covers the whole year with the modern default once it applies', () => { + // 2000 is a leap year (divisible by 400), so 366 days. + const bands = dayBandsForPoint(5, 5, switchover, 2000); + expect(bands).toEqual([{ startDoy: 1, endDoy: 366, calendar: 'Gregorian' }]); + }); +}); diff --git a/src/utils/timelineBands.ts b/src/utils/timelineBands.ts new file mode 100644 index 0000000..d58ecf0 --- /dev/null +++ b/src/utils/timelineBands.ts @@ -0,0 +1,99 @@ +// Colored era bands for the date-picker timelines. +// +// Both the year axis and the day-of-year axis shade the picked region's +// calendars over time. They share one source of truth here so the modern global +// Gregorian default (see resolve.ts) is mirrored identically on both — the year +// bar shows *which years* each calendar covered; the day bar zooms into one year +// to show *which days*, so a mid-year changeover (e.g. France's 22 Sept 1792 +// Republican start) is visible as a color boundary within the year. + +import type { CalendarRegion, Era } from '../types'; +import { findRegions } from './geo'; +import { gregorianToJulianDay, parseGregorianDate } from './julianDay'; +import { daysInYear } from './historicalDate'; +import { DEFAULT_MODERN_ERA, MODERN_DEFAULT_START_YEAR } from './resolve'; + +/** A stretch of the year axis using one calendar (years; ±Infinity open-ended). */ +export interface YearBandSpan { + start: number; + end: number; + calendar: string; +} + +/** A stretch of the day-of-year axis using one calendar (1-based, inclusive). */ +export interface DayBandSpan { + startDoy: number; + endDoy: number; + calendar: string; +} + +/** + * Every era in effect at a point, in render order: the matched regions' eras + * (in file/era order), plus the synthetic modern global Gregorian default when + * it applies — i.e. unless a matched region already supplies a Gregorian era + * reaching modern times, or an era opts the point out (suppressModernDefault). + * Mirrors resolve()'s default logic so the timelines and the answer cards agree. + */ +export function effectiveErasForPoint( + lat: number, + lng: number, + regions: CalendarRegion[], +): Era[] { + const eras: Era[] = []; + let suppressesModernDefault = false; + let hasModernGregorian = false; + for (const cr of findRegions(lat, lng, regions)) { + for (const era of cr.eras) { + eras.push(era); + const endYear = era.end ? parseGregorianDate(era.end).year : Infinity; + // Only an era reaching modern times can block (or satisfy) the + // modern default — matching resolve() and the year-band logic. + if (era.suppressModernDefault && endYear >= MODERN_DEFAULT_START_YEAR) suppressesModernDefault = true; + if (era.calendar === 'Gregorian' && endYear >= MODERN_DEFAULT_START_YEAR) hasModernGregorian = true; + } + } + if (!hasModernGregorian && !suppressesModernDefault) eras.push(DEFAULT_MODERN_ERA); + return eras; +} + +/** Year-axis bands for the picked point: each era's [startYear, endYear) span. */ +export function yearBandsForPoint( + lat: number, + lng: number, + regions: CalendarRegion[], +): YearBandSpan[] { + return effectiveErasForPoint(lat, lng, regions).map((era) => ({ + start: era.start ? parseGregorianDate(era.start).year : -Infinity, + end: era.end ? parseGregorianDate(era.end).year : Infinity, + calendar: era.calendar, + })); +} + +/** + * Day-of-year bands for the picked point within a single (astronomical) year: + * each era clipped to that year's days, as a 1-based inclusive [startDoy, + * endDoy] range. Eras not touching the year are dropped, so most years yield a + * single full-width band (the calendar in use all year) and a changeover year + * splits into two — exactly where the user should "see" the change happen. + */ +export function dayBandsForPoint( + lat: number, + lng: number, + regions: CalendarRegion[], + astroYear: number, +): DayBandSpan[] { + const yearStartJd = gregorianToJulianDay({ year: astroYear, month: 1, day: 1 }); + const total = daysInYear(astroYear); + const bands: DayBandSpan[] = []; + for (const era of effectiveErasForPoint(lat, lng, regions)) { + // Half-open [start, end): day d (1-based) is covered when + // startJd <= yearStartJd + (d-1) < endJd. + const startJd = era.start ? gregorianToJulianDay(parseGregorianDate(era.start)) : -Infinity; + const endJd = era.end ? gregorianToJulianDay(parseGregorianDate(era.end)) : Infinity; + const startDoy = Math.max(1, startJd - yearStartJd + 1); + const endDoy = Math.min(total, endJd - yearStartJd); // end exclusive ⇒ last covered day + if (endDoy < startDoy) continue; // era doesn't reach into this year + bands.push({ startDoy, endDoy, calendar: era.calendar }); + } + return bands; +}