Files
home-automation/frontend/src/map/RecordsMap.tsx
T
tliu93 da236643f2
frontend / frontend (push) Successful in 2m0s
pytest / test (push) Successful in 1m32s
M2: frontend walkthrough fixes + explicit dev compose stack
Post-M2 self-walkthrough polish, batched into one commit.

Map / heat:
- fix heat-layer white-screen crash after login (add layer to map before
  setLatLngs; an off-map leaflet.heat layer has a null _map and throws)
- normalize each heat layer to the densest pixel cell visible in the CURRENT
  viewport (maxZoom:0 so intensity factor f=1) and recompute on moveend/zoomend,
  so sparse poo data reaches red and stays normalized at any zoom level
- dark CARTO basemap tiles when the color scheme is dark

UI:
- dark-mode toggle in the top-right, beside the settings gear
- switch top-right nav (records / theme / settings / logout) to Feather icons
  with hover tooltips
- home: Grafana-style quick time-range presets + back/forward shift buttons,
  placed between the From/To pickers and Apply; fix Select/tooltip z-index
  (Leaflet stacking) and the shift-button height alignment

API client:
- stop flooding GET /api/session with 401s: the session probe and the login
  endpoint own their 401s (no global redirect), which fixes the logout hang and
  the spinning login page

Compose:
- rename docker-compose.override.yml -> docker-compose.dev.yml as an explicit,
  non-auto-layered dev stack (8001, -dev container names, prod-copy ./data DB);
  update tests/test_deployment.py (read dev.yml, tolerate the !override tag) and
  the README "Docker Compose" section

Tests:
- pixel-grid peak counter, time-range presets, heat-layer ordering regression,
  and 401-redirect regression
2026-06-13 15:20:50 +02:00

346 lines
12 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 { 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:
'&copy; <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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
}
// ---------------------------------------------------------------------------
// 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<HeatLayer | null>(null)
const pooLayerRef = useRef<HeatLayer | null>(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: '<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%',
dark = false,
}: RecordsMapProps) {
const tiles = dark ? DARK_TILES : LIGHT_TILES
return (
<MapContainer
center={DEFAULT_CENTER}
zoom={DEFAULT_ZOOM}
style={{ height, width: '100%', background: dark ? '#1a1b1e' : undefined }}
data-testid="records-map"
>
{/* key forces a clean tile-layer swap when the color scheme changes */}
<TileLayer key={tiles.url} attribution={tiles.attribution} url={tiles.url} />
<HeatLayers
locationHeatPoints={locationHeatPoints}
pooHeatPoints={pooHeatPoints}
showLocationHeat={showLocationHeat}
showPooHeat={showPooHeat}
/>
<ScatterLayer
locationScatterPoints={locationScatterPoints}
pooScatterPoints={pooScatterPoints}
showScatter={showScatter}
onSelectLocation={onSelectLocation}
onSelectPoo={onSelectPoo}
/>
</MapContainer>
)
}