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
This commit is contained in:
@@ -25,6 +25,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import 'leaflet.heat'
|
||||
import 'leaflet.markercluster'
|
||||
|
||||
import { peakGridCount } from './mapUtils'
|
||||
import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils'
|
||||
import type { LocationRecord, PooRecord } from '../records'
|
||||
|
||||
@@ -60,6 +61,21 @@ export interface RecordsMapProps {
|
||||
|
||||
/** 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:
|
||||
'© <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>',
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,7 +89,34 @@ interface HeatLayerChildProps {
|
||||
showPooHeat: boolean
|
||||
}
|
||||
|
||||
function HeatLayers({
|
||||
// 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,
|
||||
@@ -83,20 +126,36 @@ function HeatLayers({
|
||||
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([], {
|
||||
radius: 20,
|
||||
blur: 15,
|
||||
maxZoom: 17,
|
||||
...LOC_HEAT,
|
||||
maxZoom: 0,
|
||||
gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' },
|
||||
})
|
||||
}
|
||||
const layer = locationLayerRef.current
|
||||
layer.setLatLngs(locationHeatPoints)
|
||||
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)
|
||||
}
|
||||
@@ -109,16 +168,19 @@ function HeatLayers({
|
||||
useEffect(() => {
|
||||
if (!pooLayerRef.current) {
|
||||
pooLayerRef.current = leafletHeatLayer([], {
|
||||
radius: 25,
|
||||
blur: 18,
|
||||
maxZoom: 17,
|
||||
gradient: { 0.4: 'yellow', 0.65: 'orange', 1: '#8B4513' },
|
||||
...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
|
||||
layer.setLatLngs(pooHeatPoints)
|
||||
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)
|
||||
}
|
||||
@@ -127,6 +189,26 @@ function HeatLayers({
|
||||
}
|
||||
}, [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
|
||||
}
|
||||
|
||||
@@ -231,18 +313,18 @@ export function RecordsMap({
|
||||
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%' }}
|
||||
style={{ height, width: '100%', background: dark ? '#1a1b1e' : undefined }}
|
||||
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"
|
||||
/>
|
||||
{/* 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}
|
||||
|
||||
Reference in New Issue
Block a user