when-am-i / files / src / App.tsx
App.tsx
faf0b564 2026-03-20 Pavel Safronov | 1 | import calendar from './assets/calendar.png'; |
f9fc6714 2026-06-21 Pavel Safronov | 2 | import React, { useEffect, useMemo, useRef, useState } from 'react'; |
| 3 | import { MapPicker, type MapOverlay, type Recenter } from './components/MapPicker'; | |
68dcf3ed 2026-06-25 Pavel Safronov | 4 | import { DatePicker, type YearBand, type DayBand } from './components/DatePicker'; |
34c4dd59 2026-06-18 Pavel Safronov | 5 | import { ResultPanel } from './components/ResultPanel'; |
faf0b564 2026-03-20 Pavel Safronov | 6 | import { Attribution } from './components/Attribution'; |
34c4dd59 2026-06-18 Pavel Safronov | 7 | import { regions } from './data'; |
68dcf3ed 2026-06-25 Pavel Safronov | 8 | import { resolve } from './utils/resolve'; |
34c4dd59 2026-06-18 Pavel Safronov | 9 | import { findEras } from './utils/eras'; |
68dcf3ed 2026-06-25 Pavel Safronov | 10 | import { yearBandsForPoint, dayBandsForPoint } from './utils/timelineBands'; |
8fa8f3cb 2026-06-21 Pavel Safronov | 11 | import { deriveMinYear } from './utils/dateRange'; |
862df8d3 2026-06-21 Pavel Safronov | 12 | import { calendarColorScale } from './utils/calendarColor'; |
f9fc6714 2026-06-21 Pavel Safronov | 13 | import { nearestCity } from './utils/nearestCity'; |
34c4dd59 2026-06-18 Pavel Safronov | 14 | import { |
| 15 | fromHistoricalDate, | |
| 16 | toHistoricalDate, | |
| 17 | type PickerDate, | |
| 18 | } from './utils/historicalDate'; | |
f9fc6714 2026-06-21 Pavel Safronov | 19 | import { parseInitialState, toSearch } from './utils/urlState'; |
3adbd147 2026-06-20 Pavel Safronov | 20 | import { pickRandomScene } from './utils/shuffle'; |
34c4dd59 2026-06-18 Pavel Safronov | 21 | import { |
| 22 | reportMailtoHref, | |
| 23 | setReportContext, | |
| 24 | subscribeReportErrors, | |
| 25 | type ReportContext, | |
| 26 | } from './utils/report'; | |
| 27 | ||
| 28 | const initial = parseInitialState(typeof window !== 'undefined' ? window.location.search : ''); | |
| 29 | ||
862df8d3 2026-06-21 Pavel Safronov | 30 | // One color assignment for every calendar in the dataset (plus the modern global |
| 31 | // Gregorian default), so colors are distinct *and* stable app-wide — a calendar | |
| 32 | // keeps the same color across the map, the timeline, and as you scrub dates. | |
3cbfe9ad 2026-06-22 Pavel Safronov | 33 | // Colors only need to differ between calendars that can actually appear |
| 34 | // together (see calendarConflicts.ts) — calendars that never co-occur (e.g. | |
| 35 | // Japan and Iran) may freely share a color. | |
| 36 | const colorFor = calendarColorScale(regions, ['Gregorian']); | |
862df8d3 2026-06-21 Pavel Safronov | 37 | |
8fa8f3cb 2026-06-21 Pavel Safronov | 38 | // Year slider floor: the oldest dated era in the dataset, plus a margin, so the |
| 39 | // timeline doesn't waste most of its width on millennia with no calendar data. | |
| 40 | const minYear = deriveMinYear(regions, new Date().getFullYear()); | |
| 41 | ||
34c4dd59 2026-06-18 Pavel Safronov | 42 | const now = new Date(); |
| 43 | const defaultDate: PickerDate = initial.date | |
| 44 | ? fromHistoricalDate(initial.date) | |
429842aa 2026-06-21 Pavel Safronov | 45 | : { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), era: 'CE' }; |
34c4dd59 2026-06-18 Pavel Safronov | 46 | |
f9fc6714 2026-06-21 Pavel Safronov | 47 | // Mirror MapPicker's own default zoom so the shareable URL has the right zoom |
| 48 | // from the first render (before the user touches the map). | |
| 49 | const initialZoom = initial.zoom ?? (initial.picked ? 6 : 2); | |
| 50 | ||
| 51 | // How many recently-shuffled calendars to remember and avoid repeating. Sized | |
| 52 | // below the number of distinct non-Gregorian calendars so a fresh choice always | |
| 53 | // exists (about half of them, so "recent" is meaningful but not the whole set). | |
| 54 | const distinctShuffleCalendars = new Set( | |
| 55 | regions.flatMap((cr) => cr.eras.map((era) => era.calendar)).filter((c) => c !== 'Gregorian'), | |
| 56 | ).size; | |
| 57 | const SHUFFLE_RECENT_WINDOW = Math.max(0, Math.min(distinctShuffleCalendars - 1, Math.ceil(distinctShuffleCalendars / 2))); | |
| 58 | ||
34c4dd59 2026-06-18 Pavel Safronov | 59 | const sectionLabel: React.CSSProperties = { margin: '0 0 4px', fontSize: '0.85em', fontWeight: 600, color: '#555' }; |
edbab6ce 2026-03-20 Pavel Safronov | 60 | |
84d39e5c 2026-06-21 Pavel Safronov | 61 | const toDMS = (D: number, lng: boolean) => { |
| 62 | const dir = D < 0 ? (lng ? "W" : "S") : lng ? "E" : "N"; | |
| 63 | D = Math.abs(D); | |
| 64 | const deg = Math.floor(D); | |
| 65 | const minFloat = (D - deg) * 60; | |
| 66 | const min = Math.floor(minFloat); | |
| 67 | const sec = Math.round((minFloat - min) * 60 * 100) / 100; // Round to 2 decimal places | |
| 68 | return { dir, deg, min, sec }; | |
| 69 | } | |
| 70 | const pickedToText = (picked: { lat: number; lng: number }) => { | |
| 71 | const latDMS = toDMS(picked.lat, false); | |
| 72 | const lngDMS = toDMS(picked.lng, true); | |
| 73 | return `${latDMS.deg}°${latDMS.min}'${latDMS.sec}"${latDMS.dir} ${lngDMS.deg}°${lngDMS.min}'${lngDMS.sec}"${lngDMS.dir}`; | |
| 74 | } | |
| 75 | ||
edbab6ce 2026-03-20 Pavel Safronov | 76 | function App() { |
34c4dd59 2026-06-18 Pavel Safronov | 77 | const [picked, setPicked] = useState<{ lat: number; lng: number } | null>(initial.picked ?? null); |
| 78 | const [pickerDate, setPickerDate] = useState<PickerDate>(defaultDate); | |
f9fc6714 2026-06-21 Pavel Safronov | 79 | const [zoom, setZoom] = useState<number>(initialZoom); |
| 80 | const [recenter, setRecenter] = useState<Recenter | null>(null); | |
e6fe2bc7 2026-06-17 Pavel Safronov | 81 | const [runtimeError, setRuntimeError] = useState<unknown>(null); |
| 82 | ||
f9fc6714 2026-06-21 Pavel Safronov | 83 | // Calendars shown by recent shuffles, newest first, so the next press avoids |
| 84 | // repeating them (see SHUFFLE_RECENT_WINDOW). | |
| 85 | const recentCalendars = useRef<string[]>([]); | |
| 86 | ||
34c4dd59 2026-06-18 Pavel Safronov | 87 | const date = useMemo(() => toHistoricalDate(pickerDate), [pickerDate]); |
| 88 | const resolution = useMemo( | |
| 89 | () => (picked ? resolve(picked.lat, picked.lng, date, regions) : null), | |
| 90 | [picked, date], | |
| 91 | ); | |
| 92 | ||
f9fc6714 2026-06-21 Pavel Safronov | 93 | // Nearest large city + country for the pinned point (offline lookup). |
| 94 | const geography = useMemo(() => (picked ? nearestCity(picked.lat, picked.lng) : null), [picked]); | |
| 95 | ||
| 96 | // Keep the URL in sync with the view so it's a shareable link to the same | |
| 97 | // place, date, and zoom. replaceState (not push) so it doesn't spam history. | |
| 98 | useEffect(() => { | |
| 99 | window.history.replaceState(null, '', toSearch(picked, pickerDate, zoom)); | |
| 100 | }, [picked, pickerDate, zoom]); | |
| 101 | ||
34c4dd59 2026-06-18 Pavel Safronov | 102 | // Shade every region in use at this date, colored by its calendar. |
| 103 | const overlays = useMemo<(MapOverlay & { calendar: string })[]>(() => { | |
| 104 | const items: (MapOverlay & { calendar: string })[] = []; | |
| 105 | for (const cr of regions) { | |
| 106 | for (const era of findEras(date, cr.eras)) { | |
| 107 | items.push({ | |
| 108 | id: `${cr.sourceFile ?? cr.region.name}::${era.name}`, | |
| 109 | geometry: cr.region.geo, | |
862df8d3 2026-06-21 Pavel Safronov | 110 | color: colorFor(era.calendar), |
34c4dd59 2026-06-18 Pavel Safronov | 111 | calendar: era.calendar, |
| 112 | }); | |
| 113 | } | |
| 114 | } | |
| 115 | return items; | |
| 116 | }, [date]); | |
| 117 | ||
4014e5e0 2026-06-18 Pavel Safronov | 118 | // Era bands for the year scrollbar: the picked region's calendars over time. |
68dcf3ed 2026-06-25 Pavel Safronov | 119 | const yearBands = useMemo<YearBand[]>( |
| 120 | () => | |
| 121 | picked | |
| 122 | ? yearBandsForPoint(picked.lat, picked.lng, regions).map((b) => ({ | |
| 123 | ...b, | |
| 124 | color: colorFor(b.calendar), | |
| 125 | })) | |
| 126 | : [], | |
| 127 | [picked], | |
| 128 | ); | |
| 129 | ||
| 130 | // Era bands for the day-of-year scrollbar: the same calendars clipped to the | |
| 131 | // selected year, so a mid-year changeover (e.g. a Gregorian adoption date) | |
| 132 | // shows up as a color boundary within the year, not just between years. | |
| 133 | const dayBands = useMemo<DayBand[]>( | |
| 134 | () => | |
| 135 | picked | |
| 136 | ? dayBandsForPoint(picked.lat, picked.lng, regions, date.year).map((b) => ({ | |
| 137 | ...b, | |
| 138 | color: colorFor(b.calendar), | |
| 139 | })) | |
| 140 | : [], | |
| 141 | [picked, date.year], | |
| 142 | ); | |
4014e5e0 2026-06-18 Pavel Safronov | 143 | |
34c4dd59 2026-06-18 Pavel Safronov | 144 | const reportContext = useMemo<ReportContext>(() => { |
| 145 | const dateText = date.year > 0 ? `${date.year} CE` : `${1 - date.year} BCE`; | |
| 146 | return { picked, date: `${dateText} (${date.year}-${date.month}-${date.day})` }; | |
| 147 | }, [picked, date]); | |
| 148 | ||
| 149 | // Keep the async error handlers' context in sync (render-time links below | |
| 150 | // pass reportContext explicitly so they're never stale). | |
| 151 | useEffect(() => setReportContext(reportContext), [reportContext]); | |
e6fe2bc7 2026-06-17 Pavel Safronov | 152 | |
| 153 | // Surface async errors caught by the global handlers as a dismissible banner. | |
| 154 | useEffect(() => subscribeReportErrors(setRuntimeError), []); | |
30b4cdad 2026-03-20 Pavel Safronov | 155 | |
f9fc6714 2026-06-21 Pavel Safronov | 156 | // Shuffle: jump to a random place-and-time on a non-default calendar, and |
| 157 | // recenter the map so the new pin lands in the middle of the view. | |
3adbd147 2026-06-20 Pavel Safronov | 158 | const shuffle = () => { |
f9fc6714 2026-06-21 Pavel Safronov | 159 | const scene = pickRandomScene(regions, Math.random, recentCalendars.current); |
3adbd147 2026-06-20 Pavel Safronov | 160 | if (!scene) return; |
| 161 | setPicked({ lat: scene.lat, lng: scene.lng }); | |
| 162 | setPickerDate(scene.date); | |
f9fc6714 2026-06-21 Pavel Safronov | 163 | setRecenter({ lat: scene.lat, lng: scene.lng, key: Date.now() }); |
| 164 | recentCalendars.current = [scene.calendar, ...recentCalendars.current].slice(0, SHUFFLE_RECENT_WINDOW); | |
3adbd147 2026-06-20 Pavel Safronov | 165 | }; |
| 166 | ||
0f7dce25 2026-06-21 Pavel Safronov | 167 | const rootUrl = typeof window !== 'undefined' ? `${window.location.origin}${window.location.pathname}` : '/'; |
| 168 | ||
edbab6ce 2026-03-20 Pavel Safronov | 169 | return ( |
f9fc6714 2026-06-21 Pavel Safronov | 170 | <div className="app"> |
0f7dce25 2026-06-21 Pavel Safronov | 171 | <a href={rootUrl} style={{ textDecoration: 'none', color: 'inherit' }}> |
| 172 | <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> | |
| 173 | <img src={calendar} alt="" style={{ width: 40, height: 40 }} /> | |
| 174 | <h1 style={{ margin: 0 }}>When am I?</h1> | |
| 175 | </div> | |
| 176 | </a> | |
| 177 | ||
d1eb8ad5 2026-06-19 Pavel Safronov | 178 | <p style={{ marginTop: 12 }}> |
bd3d5038 2026-06-17 Pavel Safronov | 179 | You’re a time traveler who lands somewhere in history. You stop a local |
26a02b71 2026-06-19 Pavel Safronov | 180 | and ask: <em>“When am I?”</em> Their answer depends on where |
3adbd147 2026-06-20 Pavel Safronov | 181 | you are — and who you ask. Pick a date and a spot on the map to begin |
| 182 | — or hit <em>Shuffle</em> to be dropped somewhere on a calendar | |
| 183 | that isn’t today’s. | |
bd3d5038 2026-06-17 Pavel Safronov | 184 | </p> |
34c4dd59 2026-06-18 Pavel Safronov | 185 | |
3adbd147 2026-06-20 Pavel Safronov | 186 | <button |
| 187 | type="button" | |
| 188 | onClick={shuffle} | |
| 189 | style={{ | |
| 190 | margin: '4px 0 8px', | |
| 191 | padding: '8px 16px', | |
| 192 | fontSize: '0.95em', | |
| 193 | fontWeight: 600, | |
| 194 | cursor: 'pointer', | |
| 195 | border: '1px solid #bbb', | |
| 196 | borderRadius: 6, | |
| 197 | background: '#f6f6f6', | |
| 198 | }} | |
| 199 | > | |
| 200 | 🎲 Shuffle | |
| 201 | </button> | |
| 202 | ||
e6fe2bc7 2026-06-17 Pavel Safronov | 203 | {runtimeError != null && ( |
| 204 | <div | |
| 205 | role="alert" | |
| 206 | style={{ | |
| 207 | margin: '12px 0', | |
| 208 | padding: 12, | |
| 209 | border: '1px solid #d33', | |
| 210 | borderRadius: 6, | |
| 211 | background: '#fff5f5', | |
| 212 | }} | |
| 213 | > | |
| 214 | An unexpected error occurred.{' '} | |
34c4dd59 2026-06-18 Pavel Safronov | 215 | <a href={reportMailtoHref(runtimeError, reportContext)}>Send an error report</a>{' '} |
e6fe2bc7 2026-06-17 Pavel Safronov | 216 | <button type="button" onClick={() => setRuntimeError(null)}> |
| 217 | Dismiss | |
| 218 | </button> | |
| 219 | </div> | |
| 220 | )} | |
34c4dd59 2026-06-18 Pavel Safronov | 221 | |
f9fc6714 2026-06-21 Pavel Safronov | 222 | <div className="app-cols"> |
| 223 | <div> | |
| 224 | <p style={sectionLabel}>When?</p> | |
68dcf3ed 2026-06-25 Pavel Safronov | 225 | <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} dayBands={dayBands} minYear={minYear} /> |
f9fc6714 2026-06-21 Pavel Safronov | 226 | |
| 227 | <p style={sectionLabel}>Where?</p> | |
| 228 | {picked && ( | |
| 229 | <div style={{ marginTop: 8, fontSize: '0.85em', color: '#888' }}> | |
84d39e5c 2026-06-21 Pavel Safronov | 230 | <strong>{pickedToText(picked)}</strong> |
f9fc6714 2026-06-21 Pavel Safronov | 231 | {geography && ( |
| 232 | <> | |
| 233 | {' '}·{' '} | |
| 234 | {geography.distanceKm < 25 ? 'in' : 'near'}{' '} | |
| 235 | <strong> | |
| 236 | {geography.name}, {geography.country} | |
| 237 | </strong> | |
| 238 | {geography.distanceKm >= 25 && ` (~${Math.round(geography.distanceKm)} km)`} | |
| 239 | </> | |
| 240 | )} | |
| 241 | </div> | |
| 242 | )} | |
84d39e5c 2026-06-21 Pavel Safronov | 243 | <div className="app-map"> |
| 244 | <MapPicker | |
| 245 | marker={picked ? [picked.lat, picked.lng] : null} | |
| 246 | overlays={overlays} | |
| 247 | initialZoom={initial.zoom} | |
| 248 | recenter={recenter} | |
| 249 | onPick={(lat, lng) => setPicked({ lat, lng })} | |
| 250 | onZoomChange={setZoom} | |
| 251 | /> | |
| 252 | </div> | |
30b4cdad 2026-03-20 Pavel Safronov | 253 | </div> |
34c4dd59 2026-06-18 Pavel Safronov | 254 | |
f9fc6714 2026-06-21 Pavel Safronov | 255 | <div className="app-answers"> |
3cbfe9ad 2026-06-22 Pavel Safronov | 256 | <ResultPanel |
| 257 | picked={picked} | |
| 258 | date={date} | |
| 259 | resolution={resolution} | |
| 260 | colorFor={colorFor} | |
| 261 | /> | |
f9fc6714 2026-06-21 Pavel Safronov | 262 | </div> |
| 263 | </div> | |
34c4dd59 2026-06-18 Pavel Safronov | 264 | |
| 265 | <Attribution reportContext={reportContext} /> | |
edbab6ce 2026-03-20 Pavel Safronov | 266 | </div> |
| 267 | ); | |
| 268 | } | |
| 269 | ||
| 270 | export default App; |