fuzzyworld

when-am-i / commit

feat: map recenter-on-shuffle and single-world bounds

9f3f1e6fedd136ab27d2cede08121f73b2a6236f · Pavel Safronov · 2026-06-20 19:15:40 -0700 · parents 631012abe1e4

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: map recenter-on-shuffle and single-world bounds

- RecenterOnKey recenters the map on a point (keeping zoom) when its key
  changes, so Shuffle drops the new pin in the middle of the view.
- ZoomReporter reports the zoom up after the user zooms, for the shareable URL.
- Constrain to one world copy: noWrap tiles + maxBounds (viscosity 1.0) +
  minZoom, so you can't pan/zoom out into empty space with no shaded regions.
- Map fills its container (height 100%) for the new layout.

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>
 src/components/MapPicker.tsx | 65 ++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 60 insertions(+), 5 deletions(-)

diff --git a/src/components/MapPicker.tsx b/src/components/MapPicker.tsx
index defef1e..9fd122e 100644
--- a/src/components/MapPicker.tsx
+++ b/src/components/MapPicker.tsx
@@ -1,6 +1,6 @@
 import React from 'react';
-import { MapContainer, TileLayer, Marker, Polygon, useMapEvents } from 'react-leaflet';
-import type { LeafletMouseEvent } from 'leaflet';
+import { MapContainer, TileLayer, Marker, Polygon, useMap, useMapEvents } from 'react-leaflet';
+import type { LeafletMouseEvent, LatLngBoundsExpression } from 'leaflet';
 import type { Geometry, LinearRing } from '../types';
 import 'leaflet/dist/leaflet.css';
 
@@ -11,15 +11,35 @@ export interface MapOverlay {
     color: string;
 }
 
+/** A request to recenter the map on a point; bump `key` to re-trigger. */
+export interface Recenter {
+    lat: number;
+    lng: number;
+    key: number;
+}
+
 interface MapPickerProps {
     /** The currently pinned location (owned by the parent), or null. */
     marker: [number, number] | null;
     overlays: MapOverlay[];
     /** Initial zoom override (e.g. from a shared/demo URL). */
     initialZoom?: number;
+    /** Recenter the map here (keeping zoom) whenever `key` changes — e.g. Shuffle. */
+    recenter?: Recenter | null;
     onPick: (lat: number, lng: number) => void;
+    /** Reports the map's zoom after the user pans/zooms, for the shareable URL. */
+    onZoomChange?: (zoom: number) => void;
 }
 
+// Single-world view: don't repeat tiles, and pen the user inside one copy of the
+// world so they can't pan/zoom out into empty space where no regions are shaded
+// (see the UI notes). Latitude is clamped to the Web-Mercator limit (~85°).
+const WORLD_BOUNDS: LatLngBoundsExpression = [
+    [-85, -180],
+    [85, 180],
+];
+const MIN_ZOOM = 2;
+
 // Hoisted out of MapPicker so it isn't recreated on every render (which would
 // remount the Leaflet click handler). Renders the marker at the pinned spot.
 function LocationMarker({
@@ -37,6 +57,30 @@ function LocationMarker({
     return marker ? <Marker position={marker} /> : null;
 }
 
+// Recenters the map on `recenter` (keeping the current zoom) whenever its key
+// changes. Used by Shuffle so the new pin lands in the middle of the map.
+function RecenterOnKey({ recenter }: { recenter?: Recenter | null }) {
+    const map = useMap();
+    const lastKey = React.useRef<number | null>(null);
+    React.useEffect(() => {
+        if (!recenter || recenter.key === lastKey.current) return;
+        lastKey.current = recenter.key;
+        map.setView([recenter.lat, recenter.lng], map.getZoom(), { animate: true });
+    }, [recenter, map]);
+    return null;
+}
+
+// Reports the map's zoom up to the parent after any user zoom, so it can be
+// written to the shareable URL.
+function ZoomReporter({ onZoomChange }: { onZoomChange?: (zoom: number) => void }) {
+    const map = useMapEvents({
+        zoomend() {
+            onZoomChange?.(map.getZoom());
+        },
+    });
+    return null;
+}
+
 // GeoJSON rings are [lng, lat]; Leaflet wants [lat, lng].
 function ringsToLatLng(rings: LinearRing[]): [number, number][][] {
     return rings.map((ring) => ring.map(([lng, lat]) => [lat, lng] as [number, number]));
@@ -59,22 +103,33 @@ function OverlayShape({ overlay }: { overlay: MapOverlay }) {
     );
 }
 
-const MapPicker: React.FC<MapPickerProps> = ({ marker, overlays, initialZoom, onPick }) => {
+const MapPicker: React.FC<MapPickerProps> = ({ marker, overlays, initialZoom, recenter, onPick, onZoomChange }) => {
     // Initial view only (Leaflet ignores later center/zoom prop changes): if a
     // location is pinned at mount — e.g. from a shared/demo URL — frame it.
     const [center] = React.useState<[number, number]>(marker ?? [20, 0]);
-    const [zoom] = React.useState(initialZoom ?? (marker ? 6 : 2));
+    const [zoom] = React.useState(Math.max(MIN_ZOOM, initialZoom ?? (marker ? 6 : MIN_ZOOM)));
 
     return (
-        <MapContainer center={center} zoom={zoom} style={{ height: '400px', width: '100%' }}>
+        <MapContainer
+            center={center}
+            zoom={zoom}
+            minZoom={MIN_ZOOM}
+            maxBounds={WORLD_BOUNDS}
+            maxBoundsViscosity={1.0}
+            worldCopyJump={false}
+            style={{ height: '100%', width: '100%' }}
+        >
             <TileLayer
                 attribution='&copy; OpenStreetMap contributors'
                 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+                noWrap
             />
             {overlays.map((overlay) => (
                 <OverlayShape key={`${overlay.id}-${overlay.color}`} overlay={overlay} />
             ))}
             <LocationMarker marker={marker} onSelect={onPick} />
+            <RecenterOnKey recenter={recenter} />
+            <ZoomReporter onZoomChange={onZoomChange} />
         </MapContainer>
     );
 };