when-am-i / commit
feat: two-column layout, wiring URL/recenter/geography/shuffle into App
README.md | 8 ++++- src/App.tsx | 93 +++++++++++++++++++++++++++++++++++++++++++++-------------- src/index.css | 33 +++++++++++++++++++++ 3 files changed, 112 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 79e28de..017d447 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,12 @@ Imagine you're a time traveler who ends up in a random place and time in history - **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 -- Displays the local calendar year and system for the chosen place and time +- Displays the local calendar year and system for the chosen place and time, + plus the modern geography of the spot — the country and nearest large city + (offline lookup, no network) +- A **two-column layout** (map + controls beside the answers) on wide screens + that stacks on mobile, and a **shareable URL** that captures the pinned spot, + date, and zoom so you can link someone to the exact view - Shows **all** valid answers at once — different people at the same place and time reckoned dates differently (e.g. colonial Julian alongside an Indigenous lunar calendar) - For calendars with no numbered year, gives the kind of answer a local would — @@ -228,4 +233,5 @@ See `src/data/calendars/SCHEMA.md` (human-readable field docs) and - Map tiles and data: [OpenStreetMap](https://www.openstreetmap.org/) - Map library: [Leaflet](https://leafletjs.com/) +- City data (for the country / nearest-city readout): [GeoNames](https://www.geonames.org/), licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Regenerate the bundled `src/data/cities.json` with `npm run gen:cities`. - Calendar icon: [Calendar icons created by Icongeek26 - Flaticon](https://www.flaticon.com/free-icons/calendar) diff --git a/src/App.tsx b/src/App.tsx index 70eb508..c027562 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import calendar from './assets/calendar.png'; -import React, { useEffect, useMemo, useState } from 'react'; -import { MapPicker, type MapOverlay } from './components/MapPicker'; +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 { ResultPanel } from './components/ResultPanel'; import { Attribution } from './components/Attribution'; @@ -10,12 +10,13 @@ import { findEras } from './utils/eras'; import { findRegions } from './utils/geo'; import { parseGregorianDate } from './utils/julianDay'; import { calendarColorScale } from './utils/calendarColor'; +import { nearestCity } from './utils/nearestCity'; import { fromHistoricalDate, toHistoricalDate, type PickerDate, } from './utils/historicalDate'; -import { parseInitialState } from './utils/urlState'; +import { parseInitialState, toSearch } from './utils/urlState'; import { pickRandomScene } from './utils/shuffle'; import { reportMailtoHref, @@ -39,19 +40,46 @@ const defaultDate: PickerDate = initial.date ? fromHistoricalDate(initial.date) : { year: 1700, month: now.getMonth() + 1, day: now.getDate(), era: 'CE' }; +// Mirror MapPicker's own default zoom so the shareable URL has the right zoom +// from the first render (before the user touches the map). +const initialZoom = initial.zoom ?? (initial.picked ? 6 : 2); + +// How many recently-shuffled calendars to remember and avoid repeating. Sized +// below the number of distinct non-Gregorian calendars so a fresh choice always +// exists (about half of them, so "recent" is meaningful but not the whole set). +const distinctShuffleCalendars = new Set( + regions.flatMap((cr) => cr.eras.map((era) => era.calendar)).filter((c) => c !== 'Gregorian'), +).size; +const SHUFFLE_RECENT_WINDOW = Math.max(0, Math.min(distinctShuffleCalendars - 1, Math.ceil(distinctShuffleCalendars / 2))); + const sectionLabel: React.CSSProperties = { margin: '0 0 4px', fontSize: '0.85em', fontWeight: 600, color: '#555' }; function App() { const [picked, setPicked] = useState<{ lat: number; lng: number } | null>(initial.picked ?? null); const [pickerDate, setPickerDate] = useState<PickerDate>(defaultDate); + const [zoom, setZoom] = useState<number>(initialZoom); + const [recenter, setRecenter] = useState<Recenter | null>(null); const [runtimeError, setRuntimeError] = useState<unknown>(null); + // Calendars shown by recent shuffles, newest first, so the next press avoids + // repeating them (see SHUFFLE_RECENT_WINDOW). + const recentCalendars = useRef<string[]>([]); + const date = useMemo(() => toHistoricalDate(pickerDate), [pickerDate]); const resolution = useMemo( () => (picked ? resolve(picked.lat, picked.lng, date, regions) : null), [picked, date], ); + // Nearest large city + country for the pinned point (offline lookup). + const geography = useMemo(() => (picked ? nearestCity(picked.lat, picked.lng) : null), [picked]); + + // Keep the URL in sync with the view so it's a shareable link to the same + // place, date, and zoom. replaceState (not push) so it doesn't spam history. + useEffect(() => { + window.history.replaceState(null, '', toSearch(picked, pickerDate, zoom)); + }, [picked, pickerDate, zoom]); + // Shade every region in use at this date, colored by its calendar. const overlays = useMemo<(MapOverlay & { calendar: string })[]>(() => { const items: (MapOverlay & { calendar: string })[] = []; @@ -110,16 +138,19 @@ function App() { // Surface async errors caught by the global handlers as a dismissible banner. useEffect(() => subscribeReportErrors(setRuntimeError), []); - // Shuffle: jump to a random place-and-time on a non-default calendar. + // Shuffle: jump to a random place-and-time on a non-default calendar, and + // recenter the map so the new pin lands in the middle of the view. const shuffle = () => { - const scene = pickRandomScene(regions); + const scene = pickRandomScene(regions, Math.random, recentCalendars.current); if (!scene) return; setPicked({ lat: scene.lat, lng: scene.lng }); setPickerDate(scene.date); + setRecenter({ lat: scene.lat, lng: scene.lng, key: Date.now() }); + recentCalendars.current = [scene.calendar, ...recentCalendars.current].slice(0, SHUFFLE_RECENT_WINDOW); }; return ( - <div style={{ maxWidth: 880, margin: '0 auto', padding: '24px 20px 48px' }}> + <div className="app"> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <h1 style={{ margin: 0 }}>When am I?</h1> <img src={calendar} alt="" style={{ width: 40, height: 40 }} /> @@ -168,23 +199,43 @@ function App() { </div> )} - <p style={sectionLabel}>When?</p> - <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} /> - - <p style={sectionLabel}>Where?</p> - <MapPicker - marker={picked ? [picked.lat, picked.lng] : null} - overlays={overlays} - initialZoom={initial.zoom} - onPick={(lat, lng) => setPicked({ lat, lng })} - /> - {picked && ( - <div style={{ marginTop: 8, fontSize: '0.85em', color: '#888' }}> - Pinned: {picked.lat.toFixed(4)}, {picked.lng.toFixed(4)} + <div className="app-cols"> + <div> + <p style={sectionLabel}>When?</p> + <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} /> + + <p style={sectionLabel}>Where?</p> + <div className="app-map"> + <MapPicker + marker={picked ? [picked.lat, picked.lng] : null} + overlays={overlays} + initialZoom={initial.zoom} + recenter={recenter} + onPick={(lat, lng) => setPicked({ lat, lng })} + onZoomChange={setZoom} + /> + </div> + {picked && ( + <div style={{ marginTop: 8, fontSize: '0.85em', color: '#888' }}> + Pinned: {picked.lat.toFixed(4)}, {picked.lng.toFixed(4)} + {geography && ( + <> + {' '}·{' '} + {geography.distanceKm < 25 ? 'in' : 'near'}{' '} + <strong> + {geography.name}, {geography.country} + </strong> + {geography.distanceKm >= 25 && ` (~${Math.round(geography.distanceKm)} km)`} + </> + )} + </div> + )} </div> - )} - <ResultPanel picked={picked} date={date} resolution={resolution} /> + <div className="app-answers"> + <ResultPanel picked={picked} date={date} resolution={resolution} /> + </div> + </div> <Attribution reportContext={reportContext} /> </div> diff --git a/src/index.css b/src/index.css index fee77ef..a54da02 100644 --- a/src/index.css +++ b/src/index.css @@ -7,6 +7,39 @@ body { background: #f8f9fa; } +/* App shell + responsive two-column layout. On wide screens the map/controls and + the answers sit side by side so you don't have to scroll past the map to read + the answers; on narrow screens they stack. */ +.app { + max-width: 1180px; + margin: 0 auto; + padding: 24px 20px 48px; +} +.app-cols { + display: grid; + grid-template-columns: 1fr; + gap: 24px; + align-items: start; +} +@media (min-width: 900px) { + .app-cols { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + /* Keep the answers visible while the (taller) answer list scrolls. */ + .app-answers { + position: sticky; + top: 16px; + max-height: calc(100vh - 32px); + overflow-y: auto; + } +} +.app-map { + height: 380px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e3e5e8; +} + /* Timeline sliders (day-of-year and year): a transparent-track range input with a thin "playhead" thumb, laid over a track drawn behind it. Shared so both bars look identical and the thin thumb lines up with the ticks/bands. */