Files
CA/frontend/src/components/AlertMap.tsx
Akiba So 58a6df0e06 fix: insights crash, dup grids, .map() guards
Add /api/insights/cards endpoint with proper card format matching
frontend expectations. Fixes "Cannot read properties of undefined
(reading 'map')" crash. Switch frontend to use new endpoint.

Replace alert marker rectangles with circleMarkers so they don't
look like a second grid. Default showAlertMarkers to false.

Add || [] guards on data.features.map() and alerts.map().
Reduce RiskMap grid count 3000→1500, debounce 150ms→300ms.
2026-06-05 02:37:03 +08:00

301 lines
8.7 KiB
TypeScript

import { useEffect, useRef, useState, useCallback, memo } from 'react';
import L from 'leaflet';
import { useRiskStore } from '@/stores';
import { LodGridLayer } from '@/components/LodGridLayer';
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
import { useLodGrid } from '@/hooks/useLodGrid';
import type { Alert, GridRisk } from '@/types';
export interface CellInfo {
lat: number;
lon: number;
risk: number;
nearestAlertId: string | null;
nearestAlertDist: number;
}
interface AlertMapProps {
selectedGridId: string | null;
onGridClick: (id: string) => void;
onCellInfo?: (info: CellInfo) => void;
forecastDay?: 1 | 3 | 7;
showAlertMarkers?: boolean;
showGrid?: boolean;
filteredAlerts?: Alert[];
riskRange?: [number, number];
isFullscreen?: boolean;
}
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
const RISK_COLORS: [number, number, string][] = [
[0.0, 0.2, '#22c55e'],
[0.2, 0.4, '#3b82f6'],
[0.4, 0.6, '#eab308'],
[0.6, 0.8, '#f97316'],
[0.8, 1.0, '#ef4444'],
];
function getRiskLabel(value: number): string {
if (value >= 0.8) return '高风险';
if (value >= 0.6) return '中高';
if (value >= 0.4) return '中风险';
if (value >= 0.2) return '中低';
return '低风险';
}
const EMPTY_GRIDS: GridRisk[] = [];
function AlertMapComponent({
selectedGridId,
onGridClick,
onCellInfo,
forecastDay = 1,
showAlertMarkers = true,
showGrid = true,
filteredAlerts = [],
riskRange,
isFullscreen = false,
}: AlertMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const alertLayerRef = useRef<L.LayerGroup | null>(null);
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
const clickHandlerRef = useRef(onGridClick);
const [currentZoom, setCurrentZoom] = useState(10);
const grids = useRiskStore((s) => s.grids ?? EMPTY_GRIDS);
// LOD grid data for stats overlay
const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay);
useEffect(() => {
clickHandlerRef.current = onGridClick;
}, [onGridClick]);
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
const map = L.map(mapRef.current, {
center: WUHAN_CENTER,
zoom: 9,
zoomControl: true,
preferCanvas: true,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
map.on('zoomend', () => {
setCurrentZoom(map.getZoom());
});
mapInstanceRef.current = map;
return () => {
map.remove();
mapInstanceRef.current = null;
};
}, []);
// Render alert markers overlay
const renderAlertMarkers = useCallback(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (alertLayerRef.current) {
try { map.removeLayer(alertLayerRef.current); } catch { /* ok */ }
alertLayerRef.current = null;
}
if (!showAlertMarkers || !filteredAlerts || filteredAlerts.length === 0) return;
const layer = L.layerGroup();
const mapBounds = map.getBounds();
const maxMarkers = 500;
const step = Math.max(1, Math.floor(filteredAlerts.length / maxMarkers));
for (let i = 0; i < filteredAlerts.length; i += step) {
const alert = filteredAlerts[i];
if (!alert.latitude || !alert.longitude) continue;
// Skip if outside viewport
if (
alert.latitude < mapBounds.getSouth() ||
alert.latitude > mapBounds.getNorth() ||
alert.longitude < mapBounds.getWest() ||
alert.longitude > mapBounds.getEast()
) {
continue;
}
const isP1 = alert.priority === 'P1';
const marker = L.circleMarker(
[alert.latitude, alert.longitude],
{
radius: isP1 ? 6 : 4,
fillColor: isP1 ? '#ef4444' : '#f97316',
fillOpacity: 0.7,
color: isP1 ? '#ef4444' : '#f97316',
weight: 2,
dashArray: isP1 ? undefined : '4 2',
}
);
marker.bindTooltip(
`<div style="font-size:12px;">
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
${alert.region || ''} ${alert.street || ''}
</div>`,
{ direction: 'top', offset: [0, -5] }
);
marker.on('click', () => {
if (alert.grid_id) clickHandlerRef.current(alert.grid_id);
});
marker.addTo(layer);
}
layer.addTo(map);
alertLayerRef.current = layer;
}, [filteredAlerts, showAlertMarkers]);
// Re-render alert markers when data changes
useEffect(() => {
renderAlertMarkers();
}, [renderAlertMarkers]);
// Also re-render on map zoom/pan
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
const handleMove = () => renderAlertMarkers();
map.on('moveend', handleMove);
return () => { map.off('moveend', handleMove); };
}, [renderAlertMarkers]);
// Selected grid highlight
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (selectedMarkerRef.current) {
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
selectedMarkerRef.current = null;
}
if (selectedGridId) {
let grid = grids.find((g) => g.grid_id === selectedGridId);
if (!grid) {
const selectedAlertObj = filteredAlerts.find((a) => a.grid_id === selectedGridId);
if (selectedAlertObj) {
grid = grids.find((g) =>
Math.abs(g.latitude - selectedAlertObj.latitude) < 0.001 &&
Math.abs(g.longitude - selectedAlertObj.longitude) < 0.001
);
}
}
if (grid) {
const latHalf = 0.00045;
const lonHalf = 0.00052;
const marker = L.rectangle(
[
[grid.latitude - latHalf, grid.longitude - lonHalf],
[grid.latitude + latHalf, grid.longitude + lonHalf],
],
{
fillColor: '#3b82f6',
fillOpacity: 0.3,
color: '#3b82f6',
weight: 3,
}
).addTo(map);
selectedMarkerRef.current = marker;
map.flyTo([grid.latitude, grid.longitude], Math.max(map.getZoom(), 12), { duration: 0.5 });
}
}
}, [selectedGridId, grids]);
// Handle LOD grid cell click → find nearest alert
const handleCellClick = useCallback(
(lat: number, lon: number, risk: number) => {
let nearestId: string | null = null;
let minDist = Infinity;
if (filteredAlerts) {
for (const a of filteredAlerts) {
const d = Math.sqrt((a.latitude - lat) ** 2 + (a.longitude - lon) ** 2);
if (d < minDist) {
minDist = d;
nearestId = a.grid_id;
}
}
}
if (nearestId && minDist < 0.01) {
clickHandlerRef.current(nearestId);
} else if (onCellInfo) {
onCellInfo({ lat, lon, risk, nearestAlertId: nearestId, nearestAlertDist: minDist });
}
},
[filteredAlerts, onCellInfo]
);
// Invalidate Leaflet size after fullscreen toggle
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 100);
return () => clearTimeout(timer);
}, [isFullscreen]);
const containerHeight = isFullscreen ? 'calc(100vh - 120px)' : 'calc(100vh - 280px)';
return (
<div className="relative">
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
{/* LOD Grid Layer */}
<LodGridLayer
map={mapInstanceRef.current}
forecastDay={forecastDay}
visible={showGrid}
riskRange={riskRange}
onCellClick={handleCellClick}
/>
{/* Stats overlay */}
<GridStatsOverlay
count={count}
avgRisk={avgRisk}
maxRisk={maxRisk}
loading={loading}
forecastDay={forecastDay}
/>
{/* Legend */}
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="space-y-1.5">
{RISK_COLORS.slice().reverse().map(([min, max, color]) => (
<div key={color} className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
<span className="text-[11px] text-text-secondary">
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
</span>
</div>
))}
</div>
</div>
</div>
);
}
export const AlertMap = memo(AlertMapComponent);