fuzzyworld

when-am-i / files / src / components / MapPicker.tsx

MapPicker.tsx

5.1 KB · history · blame

1import React from 'react';
2import { MapContainer, TileLayer, Marker, Polygon, useMap, useMapEvents } from 'react-leaflet';
3import type { LeafletMouseEvent, LatLngBoundsExpression } from 'leaflet';
4import type { Geometry, LinearRing } from '../types';
5import 'leaflet/dist/leaflet.css';
6
7/** A region polygon to shade on the map for the current date. */
8export 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. */
15export interface Recenter {
16 lat: number;
17 lng: number;
18 key: number;
19}
20
21interface 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°).
37const WORLD_BOUNDS: LatLngBoundsExpression = [
38 [-85, -180],
39 [85, 180],
40];
41const 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.
45function 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.
62function 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.
75function 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].
85function ringsToLatLng(rings: LinearRing[]): [number, number][][] {
86 return rings.map((ring) => ring.map(([lng, lat]) => [lat, lng] as [number, number]));
87}
88
89function 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
106const 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='&copy; 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
137export { MapPicker, type MapPickerProps };