2026-06-13 11:02:11 +02:00
|
|
|
/**
|
|
|
|
|
* 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'
|
|
|
|
|
|
2026-06-13 15:20:35 +02:00
|
|
|
import { peakGridCount } from './mapUtils'
|
2026-06-13 11:02:11 +02:00
|
|
|
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
|
2026-06-13 15:20:35 +02:00
|
|
|
|
|
|
|
|
/** 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:
|
|
|
|
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
|
|
|
}
|
|
|
|
|
const DARK_TILES = {
|
|
|
|
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
|
|
|
|
attribution:
|
|
|
|
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
2026-06-13 11:02:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Inner child: Heat layers (uses useMap hook — must be inside MapContainer)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface HeatLayerChildProps {
|
|
|
|
|
locationHeatPoints: HeatPoint[]
|
|
|
|
|
pooHeatPoints: HeatPoint[]
|
|
|
|
|
showLocationHeat: boolean
|
|
|
|
|
showPooHeat: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 15:20:35 +02:00
|
|
|
// 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({
|
2026-06-13 11:02:11 +02:00
|
|
|
locationHeatPoints,
|
|
|
|
|
pooHeatPoints,
|
|
|
|
|
showLocationHeat,
|
|
|
|
|
showPooHeat,
|
|
|
|
|
}: HeatLayerChildProps) {
|
|
|
|
|
const map = useMap()
|
|
|
|
|
const locationLayerRef = useRef<HeatLayer | null>(null)
|
|
|
|
|
const pooLayerRef = useRef<HeatLayer | null>(null)
|
|
|
|
|
|
2026-06-13 15:20:35 +02:00
|
|
|
// 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
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-13 11:02:11 +02:00
|
|
|
// Location heat layer
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!locationLayerRef.current) {
|
|
|
|
|
locationLayerRef.current = leafletHeatLayer([], {
|
2026-06-13 15:20:35 +02:00
|
|
|
...LOC_HEAT,
|
|
|
|
|
maxZoom: 0,
|
2026-06-13 11:02:11 +02:00
|
|
|
gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const layer = locationLayerRef.current
|
|
|
|
|
if (showLocationHeat) {
|
2026-06-13 15:20:35 +02:00
|
|
|
// 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.
|
2026-06-13 11:02:11 +02:00
|
|
|
if (!map.hasLayer(layer)) map.addLayer(layer)
|
2026-06-13 15:20:35 +02:00
|
|
|
layer.setLatLngs(locationHeatPoints)
|
|
|
|
|
layer.setOptions({ max: viewportHeatMax(map, locationHeatPoints, LOC_HEAT.radius, LOC_HEAT.blur) })
|
2026-06-13 11:02:11 +02:00
|
|
|
} 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([], {
|
2026-06-13 15:20:35 +02:00
|
|
|
...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' },
|
2026-06-13 11:02:11 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const layer = pooLayerRef.current
|
|
|
|
|
if (showPooHeat) {
|
2026-06-13 15:20:35 +02:00
|
|
|
// Add to the map before setLatLngs (see the location heat layer above).
|
2026-06-13 11:02:11 +02:00
|
|
|
if (!map.hasLayer(layer)) map.addLayer(layer)
|
2026-06-13 15:20:35 +02:00
|
|
|
layer.setLatLngs(pooHeatPoints)
|
|
|
|
|
layer.setOptions({ max: viewportHeatMax(map, pooHeatPoints, POO_HEAT.radius, POO_HEAT.blur) })
|
2026-06-13 11:02:11 +02:00
|
|
|
} else {
|
|
|
|
|
if (map.hasLayer(layer)) map.removeLayer(layer)
|
|
|
|
|
}
|
|
|
|
|
return () => {
|
|
|
|
|
if (map.hasLayer(layer)) map.removeLayer(layer)
|
|
|
|
|
}
|
|
|
|
|
}, [map, pooHeatPoints, showPooHeat])
|
|
|
|
|
|
2026-06-13 15:20:35 +02:00
|
|
|
// 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])
|
|
|
|
|
|
2026-06-13 11:02:11 +02:00
|
|
|
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%',
|
2026-06-13 15:20:35 +02:00
|
|
|
dark = false,
|
2026-06-13 11:02:11 +02:00
|
|
|
}: RecordsMapProps) {
|
2026-06-13 15:20:35 +02:00
|
|
|
const tiles = dark ? DARK_TILES : LIGHT_TILES
|
2026-06-13 11:02:11 +02:00
|
|
|
return (
|
|
|
|
|
<MapContainer
|
|
|
|
|
center={DEFAULT_CENTER}
|
|
|
|
|
zoom={DEFAULT_ZOOM}
|
2026-06-13 15:20:35 +02:00
|
|
|
style={{ height, width: '100%', background: dark ? '#1a1b1e' : undefined }}
|
2026-06-13 11:02:11 +02:00
|
|
|
data-testid="records-map"
|
|
|
|
|
>
|
2026-06-13 15:20:35 +02:00
|
|
|
{/* key forces a clean tile-layer swap when the color scheme changes */}
|
|
|
|
|
<TileLayer key={tiles.url} attribution={tiles.attribution} url={tiles.url} />
|
2026-06-13 11:02:11 +02:00
|
|
|
|
|
|
|
|
<HeatLayers
|
|
|
|
|
locationHeatPoints={locationHeatPoints}
|
|
|
|
|
pooHeatPoints={pooHeatPoints}
|
|
|
|
|
showLocationHeat={showLocationHeat}
|
|
|
|
|
showPooHeat={showPooHeat}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<ScatterLayer
|
|
|
|
|
locationScatterPoints={locationScatterPoints}
|
|
|
|
|
pooScatterPoints={pooScatterPoints}
|
|
|
|
|
showScatter={showScatter}
|
|
|
|
|
onSelectLocation={onSelectLocation}
|
|
|
|
|
onSelectPoo={onSelectPoo}
|
|
|
|
|
/>
|
|
|
|
|
</MapContainer>
|
|
|
|
|
)
|
|
|
|
|
}
|