fuzzyworld

when-am-i / commit

feat: two-column layout, wiring URL/recenter/geography/shuffle into App

f9fc67143ef0a9caa5cb879a8e95466058e5f3cc · Pavel Safronov · 2026-06-20 19:15:51 -0700 · parents 38f135ad6407

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 3 tally 37.2K in / 500.1K out / 63.5M cached · usage by commit and session

feat: two-column layout, wiring URL/recenter/geography/shuffle into App

App is the integration point for the UI changes, so the wiring lands with the
layout it lives in:
- responsive two-column grid (index.css): map + controls beside the answers on
  wide screens, stacking on mobile, so you don't scroll past the map to read the
  answers.
- persist pin/zoom/date to the URL via replaceState (shareable view).
- Shuffle recenters the map on the new pin and avoids recently-shown calendars.
- show the pinned point's country + nearest large city.
- README: features + GeoNames attribution.

Usage: 37.2K in / 500.1K out / 63.5M cached (session tally)
rtk:   2.2M saved, 98.7% over 277 cmds (global/lifetime)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 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 && (
+                                <>
+                                    {' '}&middot;{' '}
+                                    {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. */