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.
301 lines
8.7 KiB
TypeScript
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);
|