32d93bba2a
- self-contained RecordsMap (only module importing leaflet/react-leaflet/ leaflet.heat/leaflet.markercluster); OSM tiles, swappable behind clean props - heatmap layers for location + poo (primary); time-range selector fetches only the window (locations server-filtered; poo client-filtered) - toggleable scatter layer with marker clustering; point-select reuses T10's edit/delete modals + hooks; query-key prefixes refresh map on mutation - pure map logic isolated + unit-tested; leaflet mocked in component tests - responsive layout; typed client only
264 lines
7.7 KiB
TypeScript
264 lines
7.7 KiB
TypeScript
/**
|
|
* 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<HeatLayer | null>(null)
|
|
const pooLayerRef = useRef<HeatLayer | null>(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: '<div style="font-size:20px;line-height:1;">💩</div>',
|
|
className: '',
|
|
iconSize: [24, 24],
|
|
iconAnchor: [12, 12],
|
|
})
|
|
|
|
export function ScatterLayer({
|
|
locationScatterPoints,
|
|
pooScatterPoints,
|
|
showScatter,
|
|
onSelectLocation,
|
|
onSelectPoo,
|
|
}: ScatterLayerChildProps) {
|
|
const map = useMap()
|
|
const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(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}<br/>${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}<br/>${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 (
|
|
<MapContainer
|
|
center={DEFAULT_CENTER}
|
|
zoom={DEFAULT_ZOOM}
|
|
style={{ height, width: '100%' }}
|
|
data-testid="records-map"
|
|
>
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
|
|
<HeatLayers
|
|
locationHeatPoints={locationHeatPoints}
|
|
pooHeatPoints={pooHeatPoints}
|
|
showLocationHeat={showLocationHeat}
|
|
showPooHeat={showPooHeat}
|
|
/>
|
|
|
|
<ScatterLayer
|
|
locationScatterPoints={locationScatterPoints}
|
|
pooScatterPoints={pooScatterPoints}
|
|
showScatter={showScatter}
|
|
onSelectLocation={onSelectLocation}
|
|
onSelectPoo={onSelectPoo}
|
|
/>
|
|
</MapContainer>
|
|
)
|
|
}
|