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(null); const mapInstanceRef = useRef(null); const alertLayerRef = useRef(null); const selectedMarkerRef = useRef(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( `
${alert.priority} · ${(alert.risk_value * 100).toFixed(0)}%
${alert.region || ''} ${alert.street || ''}
`, { 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 (
{/* LOD Grid Layer */} {/* Stats overlay */} {/* Legend */}
风险等级
{RISK_COLORS.slice().reverse().map(([min, max, color]) => (
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
))}
); } export const AlertMap = memo(AlertMapComponent);