fuzzyworld

when-am-i / files / src / App.tsx

App.tsx

11.7 KB · history · blame

1import calendar from './assets/calendar.png';
2import React, { useEffect, useMemo, useRef, useState } from 'react';
3import { MapPicker, type MapOverlay, type Recenter } from './components/MapPicker';
4import { DatePicker, type YearBand, type DayBand } from './components/DatePicker';
5import { ResultPanel } from './components/ResultPanel';
6import { Attribution } from './components/Attribution';
7import { regions } from './data';
8import { resolve } from './utils/resolve';
9import { findEras } from './utils/eras';
10import { yearBandsForPoint, dayBandsForPoint } from './utils/timelineBands';
11import { deriveMinYear } from './utils/dateRange';
12import { calendarColorScale } from './utils/calendarColor';
13import { nearestCity } from './utils/nearestCity';
14import {
15 fromHistoricalDate,
16 toHistoricalDate,
17 type PickerDate,
18} from './utils/historicalDate';
19import { parseInitialState, toSearch } from './utils/urlState';
20import { pickRandomScene } from './utils/shuffle';
21import {
22 reportMailtoHref,
23 setReportContext,
24 subscribeReportErrors,
25 type ReportContext,
26} from './utils/report';
27
28const 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.
36const 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.
40const minYear = deriveMinYear(regions, new Date().getFullYear());
41
42const now = new Date();
43const 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).
49const 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).
54const distinctShuffleCalendars = new Set(
55 regions.flatMap((cr) => cr.eras.map((era) => era.calendar)).filter((c) => c !== 'Gregorian'),
56).size;
57const SHUFFLE_RECENT_WINDOW = Math.max(0, Math.min(distinctShuffleCalendars - 1, Math.ceil(distinctShuffleCalendars / 2)));
58
59const sectionLabel: React.CSSProperties = { margin: '0 0 4px', fontSize: '0.85em', fontWeight: 600, color: '#555' };
60
61const 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}
70const 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
76function 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&rsquo;re a time traveler who lands somewhere in history. You stop a local
180 and ask: <em>&ldquo;When am I?&rdquo;</em> Their answer depends on where
181 you are &mdash; and who you ask. Pick a date and a spot on the map to begin
182 &mdash; or hit <em>Shuffle</em> to be dropped somewhere on a calendar
183 that isn&rsquo;t today&rsquo;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 {' '}&middot;{' '}
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
270export default App;