when-am-i / files / src / App.tsx
App.tsx
| 1 | import calendar from './assets/calendar.png'; |
| 2 | import React, { useEffect, useMemo, useRef, useState } from 'react'; |
| 3 | import { MapPicker, type MapOverlay, type Recenter } from './components/MapPicker'; |
| 4 | import { DatePicker, type YearBand, type DayBand } from './components/DatePicker'; |
| 5 | import { ResultPanel } from './components/ResultPanel'; |
| 6 | import { Attribution } from './components/Attribution'; |
| 7 | import { regions } from './data'; |
| 8 | import { resolve } from './utils/resolve'; |
| 9 | import { findEras } from './utils/eras'; |
| 10 | import { yearBandsForPoint, dayBandsForPoint } from './utils/timelineBands'; |
| 11 | import { deriveMinYear } from './utils/dateRange'; |
| 12 | import { calendarColorScale } from './utils/calendarColor'; |
| 13 | import { nearestCity } from './utils/nearestCity'; |
| 14 | import { |
| 15 | fromHistoricalDate, |
| 16 | toHistoricalDate, |
| 17 | type PickerDate, |
| 18 | } from './utils/historicalDate'; |
| 19 | import { parseInitialState, toSearch } from './utils/urlState'; |
| 20 | import { pickRandomScene } from './utils/shuffle'; |
| 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 | |
| 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. |
| 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']); |
| 37 | |
| 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 | |
| 42 | const now = new Date(); |
| 43 | const defaultDate: PickerDate = initial.date |
| 44 | ? fromHistoricalDate(initial.date) |
| 45 | : { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), era: 'CE' }; |
| 46 | |
| 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 | |
| 59 | const sectionLabel: React.CSSProperties = { margin: '0 0 4px', fontSize: '0.85em', fontWeight: 600, color: '#555' }; |
| 60 | |
| 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 | |
| 76 | function App() { |
| 77 | const [picked, setPicked] = useState<{ lat: number; lng: number } | null>(initial.picked ?? null); |
| 78 | const [pickerDate, setPickerDate] = useState<PickerDate>(defaultDate); |
| 79 | const [zoom, setZoom] = useState<number>(initialZoom); |
| 80 | const [recenter, setRecenter] = useState<Recenter | null>(null); |
| 81 | const [runtimeError, setRuntimeError] = useState<unknown>(null); |
| 82 | |
| 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 | |
| 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 | |
| 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 | |
| 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, |
| 110 | color: colorFor(era.calendar), |
| 111 | calendar: era.calendar, |
| 112 | }); |
| 113 | } |
| 114 | } |
| 115 | return items; |
| 116 | }, [date]); |
| 117 | |
| 118 | // Era bands for the year scrollbar: the picked region's calendars over time. |
| 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 | ); |
| 143 | |
| 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]); |
| 152 | |
| 153 | // Surface async errors caught by the global handlers as a dismissible banner. |
| 154 | useEffect(() => subscribeReportErrors(setRuntimeError), []); |
| 155 | |
| 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. |
| 158 | const shuffle = () => { |
| 159 | const scene = pickRandomScene(regions, Math.random, recentCalendars.current); |
| 160 | if (!scene) return; |
| 161 | setPicked({ lat: scene.lat, lng: scene.lng }); |
| 162 | setPickerDate(scene.date); |
| 163 | setRecenter({ lat: scene.lat, lng: scene.lng, key: Date.now() }); |
| 164 | recentCalendars.current = [scene.calendar, ...recentCalendars.current].slice(0, SHUFFLE_RECENT_WINDOW); |
| 165 | }; |
| 166 | |
| 167 | const rootUrl = typeof window !== 'undefined' ? `${window.location.origin}${window.location.pathname}` : '/'; |
| 168 | |
| 169 | return ( |
| 170 | <div className="app"> |
| 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 | |
| 178 | <p style={{ marginTop: 12 }}> |
| 179 | You’re a time traveler who lands somewhere in history. You stop a local |
| 180 | and ask: <em>“When am I?”</em> Their answer depends on where |
| 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. |
| 184 | </p> |
| 185 | |
| 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 | |
| 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.{' '} |
| 215 | <a href={reportMailtoHref(runtimeError, reportContext)}>Send an error report</a>{' '} |
| 216 | <button type="button" onClick={() => setRuntimeError(null)}> |
| 217 | Dismiss |
| 218 | </button> |
| 219 | </div> |
| 220 | )} |
| 221 | |
| 222 | <div className="app-cols"> |
| 223 | <div> |
| 224 | <p style={sectionLabel}>When?</p> |
| 225 | <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} dayBands={dayBands} minYear={minYear} /> |
| 226 | |
| 227 | <p style={sectionLabel}>Where?</p> |
| 228 | {picked && ( |
| 229 | <div style={{ marginTop: 8, fontSize: '0.85em', color: '#888' }}> |
| 230 | <strong>{pickedToText(picked)}</strong> |
| 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 | )} |
| 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> |
| 253 | </div> |
| 254 | |
| 255 | <div className="app-answers"> |
| 256 | <ResultPanel |
| 257 | picked={picked} |
| 258 | date={date} |
| 259 | resolution={resolution} |
| 260 | colorFor={colorFor} |
| 261 | /> |
| 262 | </div> |
| 263 | </div> |
| 264 | |
| 265 | <Attribution reportContext={reportContext} /> |
| 266 | </div> |
| 267 | ); |
| 268 | } |
| 269 | |
| 270 | export default App; |