when-am-i / commit
feat: map recenter-on-shuffle and single-world bounds
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='© 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> ); };