/** * RecordsMap — self-contained Leaflet map component (M2-T09). * * THIS IS THE ONLY MODULE IN THE APP THAT IMPORTS LEAFLET / REACT-LEAFLET. * All data fetching and state lives outside; this component receives typed props. */ import { useEffect, useRef, useCallback } from 'react' import { MapContainer, TileLayer, useMap } from 'react-leaflet' import * as L from 'leaflet' import { Icon, DivIcon, marker as leafletMarker, heatLayer as leafletHeatLayer, type HeatLayer, } from 'leaflet' // Leaflet CSS — must be imported once; this component is the single place. import 'leaflet/dist/leaflet.css' import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.Default.css' // Side-effect imports (augment L with heatLayer and markerClusterGroup) import 'leaflet.heat' import 'leaflet.markercluster' import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils' import type { LocationRecord, PooRecord } from '../records' // Fix default Leaflet marker icon paths broken by Vite asset handling. import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png' import markerIcon from 'leaflet/dist/images/marker-icon.png' import markerShadow from 'leaflet/dist/images/marker-shadow.png' // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (Icon.Default.prototype as any)._getIconUrl Icon.Default.mergeOptions({ iconRetinaUrl: markerIcon2x, iconUrl: markerIcon, shadowUrl: markerShadow, }) // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- export interface RecordsMapProps { locationHeatPoints: HeatPoint[] pooHeatPoints: HeatPoint[] locationScatterPoints: LocationMapPoint[] pooScatterPoints: PooMapPoint[] showLocationHeat: boolean showPooHeat: boolean showScatter: boolean onSelectLocation?: (record: LocationRecord) => void onSelectPoo?: (record: PooRecord) => void /** Map container height (CSS value). Default: '100%'. */ height?: string } // --------------------------------------------------------------------------- // Inner child: Heat layers (uses useMap hook — must be inside MapContainer) // --------------------------------------------------------------------------- interface HeatLayerChildProps { locationHeatPoints: HeatPoint[] pooHeatPoints: HeatPoint[] showLocationHeat: boolean showPooHeat: boolean } function HeatLayers({ locationHeatPoints, pooHeatPoints, showLocationHeat, showPooHeat, }: HeatLayerChildProps) { const map = useMap() const locationLayerRef = useRef(null) const pooLayerRef = useRef(null) // Location heat layer useEffect(() => { if (!locationLayerRef.current) { locationLayerRef.current = leafletHeatLayer([], { radius: 20, blur: 15, maxZoom: 17, gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' }, }) } const layer = locationLayerRef.current layer.setLatLngs(locationHeatPoints) if (showLocationHeat) { if (!map.hasLayer(layer)) map.addLayer(layer) } else { if (map.hasLayer(layer)) map.removeLayer(layer) } return () => { if (map.hasLayer(layer)) map.removeLayer(layer) } }, [map, locationHeatPoints, showLocationHeat]) // Poo heat layer useEffect(() => { if (!pooLayerRef.current) { pooLayerRef.current = leafletHeatLayer([], { radius: 25, blur: 18, maxZoom: 17, gradient: { 0.4: 'yellow', 0.65: 'orange', 1: '#8B4513' }, }) } const layer = pooLayerRef.current layer.setLatLngs(pooHeatPoints) if (showPooHeat) { if (!map.hasLayer(layer)) map.addLayer(layer) } else { if (map.hasLayer(layer)) map.removeLayer(layer) } return () => { if (map.hasLayer(layer)) map.removeLayer(layer) } }, [map, pooHeatPoints, showPooHeat]) return null } // --------------------------------------------------------------------------- // Inner child: Scatter / cluster layer // --------------------------------------------------------------------------- interface ScatterLayerChildProps { locationScatterPoints: LocationMapPoint[] pooScatterPoints: PooMapPoint[] showScatter: boolean onSelectLocation?: (record: LocationRecord) => void onSelectPoo?: (record: PooRecord) => void } const locationIcon = new Icon({ iconUrl: markerIcon, iconRetinaUrl: markerIcon2x, shadowUrl: markerShadow, iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41], }) const pooIcon = new DivIcon({ html: '
💩
', className: '', iconSize: [24, 24], iconAnchor: [12, 12], }) export function ScatterLayer({ locationScatterPoints, pooScatterPoints, showScatter, onSelectLocation, onSelectPoo, }: ScatterLayerChildProps) { const map = useMap() const clusterGroupRef = useRef(null) const rebuild = useCallback(() => { if (clusterGroupRef.current) { map.removeLayer(clusterGroupRef.current) clusterGroupRef.current = null } if (!showScatter) return // markerClusterGroup is augmented onto the imported L namespace by the // leaflet.markercluster side-effect import above. Using the imported // namespace (not window.L) is what works in Vite ESM bundles. const group = L.markerClusterGroup({ maxClusterRadius: 50, showCoverageOnHover: false }) for (const pt of locationScatterPoints) { const m = leafletMarker([pt.lat, pt.lng], { icon: locationIcon }) m.bindTooltip(`${pt.record.person}
${pt.record.datetime}`, { sticky: true }) if (onSelectLocation) m.on('click', () => onSelectLocation(pt.record)) group.addLayer(m) } for (const pt of pooScatterPoints) { const m = leafletMarker([pt.lat, pt.lng], { icon: pooIcon }) m.bindTooltip(`${pt.record.timestamp}
${pt.record.status}`, { sticky: true }) if (onSelectPoo) m.on('click', () => onSelectPoo(pt.record)) group.addLayer(m) } map.addLayer(group) clusterGroupRef.current = group }, [map, locationScatterPoints, pooScatterPoints, showScatter, onSelectLocation, onSelectPoo]) useEffect(() => { rebuild() return () => { if (clusterGroupRef.current) { map.removeLayer(clusterGroupRef.current) clusterGroupRef.current = null } } }, [rebuild, map]) return null } // --------------------------------------------------------------------------- // Public component // --------------------------------------------------------------------------- /** Default map center: Beijing area. */ const DEFAULT_CENTER: [number, number] = [39.9, 116.4] const DEFAULT_ZOOM = 11 export function RecordsMap({ locationHeatPoints, pooHeatPoints, locationScatterPoints, pooScatterPoints, showLocationHeat, showPooHeat, showScatter, onSelectLocation, onSelectPoo, height = '100%', }: RecordsMapProps) { return ( ) }