/** * 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 { peakGridCount } from './mapUtils' 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 /** Use dark base tiles to match the app's dark color scheme. */ dark?: boolean } // OSM (light) and CARTO dark_all (dark) raster tiles — both zero-key. const LIGHT_TILES = { url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors', } const DARK_TILES = { url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors © CARTO', } // --------------------------------------------------------------------------- // Inner child: Heat layers (uses useMap hook — must be inside MapContainer) // --------------------------------------------------------------------------- interface HeatLayerChildProps { locationHeatPoints: HeatPoint[] pooHeatPoints: HeatPoint[] showLocationHeat: boolean showPooHeat: boolean } // Heat layer geometry. maxZoom:0 makes leaflet.heat's zoom intensity factor f=1 // at every zoom, so accumulated per-cell intensity equals the raw point count — // which lets us normalize with a pixel-grid count below. const LOC_HEAT = { radius: 20, blur: 15 } const POO_HEAT = { radius: 25, blur: 18 } /** * leaflet.heat `max` (normalization denominator) for the CURRENT viewport: * project the points that are visible (within the map size + a radius margin) to * container pixels, then count the densest pixel cell using leaflet.heat's own * grid (cell = (radius+blur)/2). The densest visible cluster maps to the hot * color; recomputing on every zoom/pan keeps it normalized to what's on screen. */ function viewportHeatMax(map: L.Map, points: HeatPoint[], radius: number, blur: number): number { if (points.length === 0) return 1 const cell = (radius + blur) / 2 const size = map.getSize() const margin = radius + blur const coords: Array<[number, number]> = [] for (let i = 0; i < points.length; i++) { const p = map.latLngToContainerPoint([points[i][0], points[i][1]]) if (p.x < -margin || p.y < -margin || p.x > size.x + margin || p.y > size.y + margin) continue coords.push([p.x, p.y]) } return peakGridCount(coords, cell) } export function HeatLayers({ locationHeatPoints, pooHeatPoints, showLocationHeat, showPooHeat, }: HeatLayerChildProps) { const map = useMap() const locationLayerRef = useRef(null) const pooLayerRef = useRef(null) // Latest data/visibility in refs so the once-registered map move/zoom handler // re-normalizes against the current points without re-subscribing. const locPointsRef = useRef(locationHeatPoints) const pooPointsRef = useRef(pooHeatPoints) const showLocRef = useRef(showLocationHeat) const showPooRef = useRef(showPooHeat) useEffect(() => { locPointsRef.current = locationHeatPoints pooPointsRef.current = pooHeatPoints showLocRef.current = showLocationHeat showPooRef.current = showPooHeat }) // Location heat layer useEffect(() => { if (!locationLayerRef.current) { locationLayerRef.current = leafletHeatLayer([], { ...LOC_HEAT, maxZoom: 0, gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' }, }) } const layer = locationLayerRef.current if (showLocationHeat) { // Add the layer to the map BEFORE setLatLngs. A heat layer that is not on // a map has a null `_map`, and `setLatLngs -> redraw` dereferences // `_map._animating`, which throws and white-screens the SPA. if (!map.hasLayer(layer)) map.addLayer(layer) layer.setLatLngs(locationHeatPoints) layer.setOptions({ max: viewportHeatMax(map, locationHeatPoints, LOC_HEAT.radius, LOC_HEAT.blur) }) } 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([], { ...POO_HEAT, maxZoom: 0, // High-frequency poo spots reach red (per request); mid tones stay // yellow/orange to distinguish from the location layer. gradient: { 0.4: 'yellow', 0.7: 'orange', 1: 'red' }, }) } const layer = pooLayerRef.current if (showPooHeat) { // Add to the map before setLatLngs (see the location heat layer above). if (!map.hasLayer(layer)) map.addLayer(layer) layer.setLatLngs(pooHeatPoints) layer.setOptions({ max: viewportHeatMax(map, pooHeatPoints, POO_HEAT.radius, POO_HEAT.blur) }) } else { if (map.hasLayer(layer)) map.removeLayer(layer) } return () => { if (map.hasLayer(layer)) map.removeLayer(layer) } }, [map, pooHeatPoints, showPooHeat]) // Re-normalize each visible layer to the viewport peak on pan/zoom. useEffect(() => { const recompute = () => { const loc = locationLayerRef.current if (loc && showLocRef.current && map.hasLayer(loc)) { loc.setOptions({ max: viewportHeatMax(map, locPointsRef.current, LOC_HEAT.radius, LOC_HEAT.blur) }) } const poo = pooLayerRef.current if (poo && showPooRef.current && map.hasLayer(poo)) { poo.setOptions({ max: viewportHeatMax(map, pooPointsRef.current, POO_HEAT.radius, POO_HEAT.blur) }) } } map.on('moveend', recompute) map.on('zoomend', recompute) return () => { map.off('moveend', recompute) map.off('zoomend', recompute) } }, [map]) 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%', dark = false, }: RecordsMapProps) { const tiles = dark ? DARK_TILES : LIGHT_TILES return ( {/* key forces a clean tile-layer swap when the color scheme changes */} ) }