when-am-i / files / src / components / MapPicker.tsx
MapPicker.tsx
| 1 | import React from 'react'; |
| 2 | import { MapContainer, TileLayer, Marker, Polygon, useMap, useMapEvents } from 'react-leaflet'; |
| 3 | import type { LeafletMouseEvent, LatLngBoundsExpression } from 'leaflet'; |
| 4 | import type { Geometry, LinearRing } from '../types'; |
| 5 | import 'leaflet/dist/leaflet.css'; |
| 6 | |
| 7 | /** A region polygon to shade on the map for the current date. */ |
| 8 | export interface MapOverlay { |
| 9 | id: string; |
| 10 | geometry: Geometry; |
| 11 | color: string; |
| 12 | } |
| 13 | |
| 14 | /** A request to recenter the map on a point; bump `key` to re-trigger. */ |
| 15 | export interface Recenter { |
| 16 | lat: number; |
| 17 | lng: number; |
| 18 | key: number; |
| 19 | } |
| 20 | |
| 21 | interface MapPickerProps { |
| 22 | /** The currently pinned location (owned by the parent), or null. */ |
| 23 | marker: [number, number] | null; |
| 24 | overlays: MapOverlay[]; |
| 25 | /** Initial zoom override (e.g. from a shared/demo URL). */ |
| 26 | initialZoom?: number; |
| 27 | /** Recenter the map here (keeping zoom) whenever `key` changes — e.g. Shuffle. */ |
| 28 | recenter?: Recenter | null; |
| 29 | onPick: (lat: number, lng: number) => void; |
| 30 | /** Reports the map's zoom after the user pans/zooms, for the shareable URL. */ |
| 31 | onZoomChange?: (zoom: number) => void; |
| 32 | } |
| 33 | |
| 34 | // Single-world view: don't repeat tiles, and pen the user inside one copy of the |
| 35 | // world so they can't pan/zoom out into empty space where no regions are shaded |
| 36 | // (see the UI notes). Latitude is clamped to the Web-Mercator limit (~85°). |
| 37 | const WORLD_BOUNDS: LatLngBoundsExpression = [ |
| 38 | [-85, -180], |
| 39 | [85, 180], |
| 40 | ]; |
| 41 | const MIN_ZOOM = 2; |
| 42 | |
| 43 | // Hoisted out of MapPicker so it isn't recreated on every render (which would |
| 44 | // remount the Leaflet click handler). Renders the marker at the pinned spot. |
| 45 | function LocationMarker({ |
| 46 | marker, |
| 47 | onSelect, |
| 48 | }: { |
| 49 | marker: [number, number] | null; |
| 50 | onSelect: (lat: number, lng: number) => void; |
| 51 | }) { |
| 52 | useMapEvents({ |
| 53 | click(e: LeafletMouseEvent) { |
| 54 | onSelect(e.latlng.lat, e.latlng.lng); |
| 55 | }, |
| 56 | }); |
| 57 | return marker ? <Marker position={marker} /> : null; |
| 58 | } |
| 59 | |
| 60 | // Recenters the map on `recenter` (keeping the current zoom) whenever its key |
| 61 | // changes. Used by Shuffle so the new pin lands in the middle of the map. |
| 62 | function RecenterOnKey({ recenter }: { recenter?: Recenter | null }) { |
| 63 | const map = useMap(); |
| 64 | const lastKey = React.useRef<number | null>(null); |
| 65 | React.useEffect(() => { |
| 66 | if (!recenter || recenter.key === lastKey.current) return; |
| 67 | lastKey.current = recenter.key; |
| 68 | map.setView([recenter.lat, recenter.lng], map.getZoom(), { animate: true }); |
| 69 | }, [recenter, map]); |
| 70 | return null; |
| 71 | } |
| 72 | |
| 73 | // Reports the map's zoom up to the parent after any user zoom, so it can be |
| 74 | // written to the shareable URL. |
| 75 | function ZoomReporter({ onZoomChange }: { onZoomChange?: (zoom: number) => void }) { |
| 76 | const map = useMapEvents({ |
| 77 | zoomend() { |
| 78 | onZoomChange?.(map.getZoom()); |
| 79 | }, |
| 80 | }); |
| 81 | return null; |
| 82 | } |
| 83 | |
| 84 | // GeoJSON rings are [lng, lat]; Leaflet wants [lat, lng]. |
| 85 | function ringsToLatLng(rings: LinearRing[]): [number, number][][] { |
| 86 | return rings.map((ring) => ring.map(([lng, lat]) => [lat, lng] as [number, number])); |
| 87 | } |
| 88 | |
| 89 | function OverlayShape({ overlay }: { overlay: MapOverlay }) { |
| 90 | // Neutral dark border so the region reads even when its fill matches nearby |
| 91 | // terrain/water; the fill color carries the calendar identity (the legend). |
| 92 | const pathOptions = { color: '#333', weight: 2, opacity: 0.85, fillColor: overlay.color, fillOpacity: 0.55 }; |
| 93 | const { geometry } = overlay; |
| 94 | if (geometry.type === 'Polygon') { |
| 95 | return <Polygon positions={ringsToLatLng(geometry.coordinates)} pathOptions={pathOptions} />; |
| 96 | } |
| 97 | return ( |
| 98 | <> |
| 99 | {geometry.coordinates.map((polygon, i) => ( |
| 100 | <Polygon key={i} positions={ringsToLatLng(polygon)} pathOptions={pathOptions} /> |
| 101 | ))} |
| 102 | </> |
| 103 | ); |
| 104 | } |
| 105 | |
| 106 | const MapPicker: React.FC<MapPickerProps> = ({ marker, overlays, initialZoom, recenter, onPick, onZoomChange }) => { |
| 107 | // Initial view only (Leaflet ignores later center/zoom prop changes): if a |
| 108 | // location is pinned at mount — e.g. from a shared/demo URL — frame it. |
| 109 | const [center] = React.useState<[number, number]>(marker ?? [20, 0]); |
| 110 | const [zoom] = React.useState(Math.max(MIN_ZOOM, initialZoom ?? (marker ? 6 : MIN_ZOOM))); |
| 111 | |
| 112 | return ( |
| 113 | <MapContainer |
| 114 | center={center} |
| 115 | zoom={zoom} |
| 116 | minZoom={MIN_ZOOM} |
| 117 | maxBounds={WORLD_BOUNDS} |
| 118 | maxBoundsViscosity={1.0} |
| 119 | worldCopyJump={false} |
| 120 | style={{ height: '100%', width: '100%' }} |
| 121 | > |
| 122 | <TileLayer |
| 123 | attribution='© OpenStreetMap contributors' |
| 124 | url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" |
| 125 | noWrap |
| 126 | /> |
| 127 | {overlays.map((overlay) => ( |
| 128 | <OverlayShape key={`${overlay.id}-${overlay.color}`} overlay={overlay} /> |
| 129 | ))} |
| 130 | <LocationMarker marker={marker} onSelect={onPick} /> |
| 131 | <RecenterOnKey recenter={recenter} /> |
| 132 | <ZoomReporter onZoomChange={onZoomChange} /> |
| 133 | </MapContainer> |
| 134 | ); |
| 135 | }; |
| 136 | |
| 137 | export { MapPicker, type MapPickerProps }; |