when-am-i / files / src / components / MapPicker.tsx
MapPicker.tsx
34c4dd59 2026-06-18 Pavel Safronov | 1 | import React from 'react'; |
9f3f1e6f 2026-06-21 Pavel Safronov | 2 | import { MapContainer, TileLayer, Marker, Polygon, useMap, useMapEvents } from 'react-leaflet'; |
| 3 | import type { LeafletMouseEvent, LatLngBoundsExpression } from 'leaflet'; | |
34c4dd59 2026-06-18 Pavel Safronov | 4 | import type { Geometry, LinearRing } from '../types'; |
30b4cdad 2026-03-20 Pavel Safronov | 5 | import 'leaflet/dist/leaflet.css'; |
| 6 | ||
34c4dd59 2026-06-18 Pavel Safronov | 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 | ||
9f3f1e6f 2026-06-21 Pavel Safronov | 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 | ||
30b4cdad 2026-03-20 Pavel Safronov | 21 | interface MapPickerProps { |
34c4dd59 2026-06-18 Pavel Safronov | 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; | |
9f3f1e6f 2026-06-21 Pavel Safronov | 27 | /** Recenter the map here (keeping zoom) whenever `key` changes — e.g. Shuffle. */ |
| 28 | recenter?: Recenter | null; | |
30b4cdad 2026-03-20 Pavel Safronov | 29 | onPick: (lat: number, lng: number) => void; |
9f3f1e6f 2026-06-21 Pavel Safronov | 30 | /** Reports the map's zoom after the user pans/zooms, for the shareable URL. */ |
| 31 | onZoomChange?: (zoom: number) => void; | |
30b4cdad 2026-03-20 Pavel Safronov | 32 | } |
| 33 | ||
9f3f1e6f 2026-06-21 Pavel Safronov | 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 | ||
9a0109d5 2026-06-17 Pavel Safronov | 43 | // Hoisted out of MapPicker so it isn't recreated on every render (which would |
34c4dd59 2026-06-18 Pavel Safronov | 44 | // remount the Leaflet click handler). Renders the marker at the pinned spot. |
9a0109d5 2026-06-17 Pavel Safronov | 45 | function LocationMarker({ |
34c4dd59 2026-06-18 Pavel Safronov | 46 | marker, |
9a0109d5 2026-06-17 Pavel Safronov | 47 | onSelect, |
| 48 | }: { | |
34c4dd59 2026-06-18 Pavel Safronov | 49 | marker: [number, number] | null; |
9a0109d5 2026-06-17 Pavel Safronov | 50 | onSelect: (lat: number, lng: number) => void; |
| 51 | }) { | |
| 52 | useMapEvents({ | |
| 53 | click(e: LeafletMouseEvent) { | |
| 54 | onSelect(e.latlng.lat, e.latlng.lng); | |
| 55 | }, | |
| 56 | }); | |
34c4dd59 2026-06-18 Pavel Safronov | 57 | return marker ? <Marker position={marker} /> : null; |
| 58 | } | |
| 59 | ||
9f3f1e6f 2026-06-21 Pavel Safronov | 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 | ||
34c4dd59 2026-06-18 Pavel Safronov | 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])); | |
9a0109d5 2026-06-17 Pavel Safronov | 87 | } |
| 88 | ||
34c4dd59 2026-06-18 Pavel Safronov | 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 | } | |
30b4cdad 2026-03-20 Pavel Safronov | 105 | |
9f3f1e6f 2026-06-21 Pavel Safronov | 106 | const MapPicker: React.FC<MapPickerProps> = ({ marker, overlays, initialZoom, recenter, onPick, onZoomChange }) => { |
34c4dd59 2026-06-18 Pavel Safronov | 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]); | |
9f3f1e6f 2026-06-21 Pavel Safronov | 110 | const [zoom] = React.useState(Math.max(MIN_ZOOM, initialZoom ?? (marker ? 6 : MIN_ZOOM))); |
30b4cdad 2026-03-20 Pavel Safronov | 111 | |
| 112 | return ( | |
9f3f1e6f 2026-06-21 Pavel Safronov | 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 | > | |
30b4cdad 2026-03-20 Pavel Safronov | 122 | <TileLayer |
faf0b564 2026-03-20 Pavel Safronov | 123 | attribution='© OpenStreetMap contributors' |
30b4cdad 2026-03-20 Pavel Safronov | 124 | url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" |
9f3f1e6f 2026-06-21 Pavel Safronov | 125 | noWrap |
30b4cdad 2026-03-20 Pavel Safronov | 126 | /> |
34c4dd59 2026-06-18 Pavel Safronov | 127 | {overlays.map((overlay) => ( |
| 128 | <OverlayShape key={`${overlay.id}-${overlay.color}`} overlay={overlay} /> | |
| 129 | ))} | |
| 130 | <LocationMarker marker={marker} onSelect={onPick} /> | |
9f3f1e6f 2026-06-21 Pavel Safronov | 131 | <RecenterOnKey recenter={recenter} /> |
| 132 | <ZoomReporter onZoomChange={onZoomChange} /> | |
30b4cdad 2026-03-20 Pavel Safronov | 133 | </MapContainer> |
| 134 | ); | |
| 135 | }; | |
| 136 | ||
faf0b564 2026-03-20 Pavel Safronov | 137 | export { MapPicker, type MapPickerProps }; |