M2-T09: build data visualization UI (heatmap map as home page)
- 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
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user