|
|
|
|
@@ -1,4 +1,5 @@
|
|
|
|
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
|
|
|
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
|
|
|
|
import React from 'react';
|
|
|
|
|
import { useRiskStore } from '@/stores';
|
|
|
|
|
import { useLodGrid } from '@/hooks/useLodGrid';
|
|
|
|
|
import { AlertMap } from '@/components/AlertMap';
|
|
|
|
|
@@ -36,6 +37,7 @@ export function AlertsDashboard() {
|
|
|
|
|
const [showAlertMarkers, setShowAlertMarkers] = useState(true);
|
|
|
|
|
const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
|
|
|
|
|
const [riskRange, setRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
|
|
|
|
const [debouncedRiskRange, setDebouncedRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
|
|
|
|
const [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
|
|
|
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
|
const [showGrid, setShowGrid] = useState(true);
|
|
|
|
|
@@ -44,6 +46,12 @@ export function AlertsDashboard() {
|
|
|
|
|
// LOD grid data for cell info lookup (1d/3d/7d risk values)
|
|
|
|
|
const { grids: lodGrids } = useLodGrid(10, forecastDay);
|
|
|
|
|
|
|
|
|
|
// Debounce riskRange for filteredAlerts computation
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const timer = setTimeout(() => setDebouncedRiskRange(riskRange), 300);
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}, [riskRange]);
|
|
|
|
|
|
|
|
|
|
// Fetch grids (for map) and alerts (for side panel) on mount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchRiskMap();
|
|
|
|
|
@@ -71,7 +79,7 @@ export function AlertsDashboard() {
|
|
|
|
|
.filter((alert) => {
|
|
|
|
|
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
|
|
|
|
|
const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority;
|
|
|
|
|
const riskMatch = alert.risk_value >= riskRange[0] && alert.risk_value <= riskRange[1];
|
|
|
|
|
const riskMatch = alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
|
|
|
|
|
return horizonMatch && priorityMatch && riskMatch;
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
@@ -80,13 +88,12 @@ export function AlertsDashboard() {
|
|
|
|
|
}
|
|
|
|
|
return new Date(b.forecast_time).getTime() - new Date(a.forecast_time).getTime();
|
|
|
|
|
});
|
|
|
|
|
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, riskRange]);
|
|
|
|
|
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
|
|
|
|
|
|
|
|
|
|
const p1Count = extendedAlerts.filter((a) => a.priority === 'P1').length;
|
|
|
|
|
const p2Count = extendedAlerts.filter((a) => a.priority === 'P2').length;
|
|
|
|
|
|
|
|
|
|
// Risk distribution stats
|
|
|
|
|
// Risk distribution stats (includes p1/p2 counts)
|
|
|
|
|
const riskStats = useMemo(() => {
|
|
|
|
|
const p1 = extendedAlerts.filter(a => a.priority === 'P1').length;
|
|
|
|
|
const p2 = extendedAlerts.filter(a => a.priority === 'P2').length;
|
|
|
|
|
const high = filteredAlerts.filter(a => a.risk_value >= 0.8).length;
|
|
|
|
|
const mediumHigh = filteredAlerts.filter(a => a.risk_value >= 0.6 && a.risk_value < 0.8).length;
|
|
|
|
|
const medium = filteredAlerts.filter(a => a.risk_value >= 0.4 && a.risk_value < 0.6).length;
|
|
|
|
|
@@ -103,8 +110,8 @@ export function AlertsDashboard() {
|
|
|
|
|
.sort((a, b) => b[1] - a[1])
|
|
|
|
|
.slice(0, 5);
|
|
|
|
|
|
|
|
|
|
return { high, mediumHigh, medium, avgRisk, topDistricts };
|
|
|
|
|
}, [filteredAlerts]);
|
|
|
|
|
return { p1, p2, high, mediumHigh, medium, avgRisk, topDistricts };
|
|
|
|
|
}, [extendedAlerts, filteredAlerts]);
|
|
|
|
|
|
|
|
|
|
const selectedAlertData = useMemo(() => {
|
|
|
|
|
return filteredAlerts.find(a => a.alert_id === selectedAlert);
|
|
|
|
|
@@ -116,12 +123,15 @@ export function AlertsDashboard() {
|
|
|
|
|
return alert?.grid_id ?? null;
|
|
|
|
|
}, [filteredAlerts, selectedAlert]);
|
|
|
|
|
|
|
|
|
|
const filteredAlertsRef = useRef(filteredAlerts);
|
|
|
|
|
useEffect(() => { filteredAlertsRef.current = filteredAlerts; }, [filteredAlerts]);
|
|
|
|
|
|
|
|
|
|
const handleGridClick = useCallback((gridId: string) => {
|
|
|
|
|
const alertForGrid = filteredAlerts.find(a => a.grid_id === gridId);
|
|
|
|
|
const alertForGrid = filteredAlertsRef.current.find(a => a.grid_id === gridId);
|
|
|
|
|
if (alertForGrid) {
|
|
|
|
|
setSelectedAlert(alertForGrid.alert_id);
|
|
|
|
|
}
|
|
|
|
|
}, [filteredAlerts]);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleAlertCardClick = useCallback((id: string) => {
|
|
|
|
|
setSelectedAlert(id);
|
|
|
|
|
@@ -169,6 +179,20 @@ export function AlertsDashboard() {
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}, [filteredAlerts]);
|
|
|
|
|
|
|
|
|
|
const nearestGrid = useMemo(() => {
|
|
|
|
|
if (!cellInfo || !lodGrids.length) return null;
|
|
|
|
|
let best: { lat: number; lon: number; risk_1d: number; risk_3d: number; risk_7d: number } | null = null;
|
|
|
|
|
let bestDist = Infinity;
|
|
|
|
|
for (const g of lodGrids) {
|
|
|
|
|
const d = Math.sqrt((g[0] - cellInfo.lat) ** 2 + (g[1] - cellInfo.lon) ** 2);
|
|
|
|
|
if (d < bestDist) {
|
|
|
|
|
bestDist = d;
|
|
|
|
|
best = { lat: g[0], lon: g[1], risk_1d: g[2] ?? 0, risk_3d: g[3] ?? 0, risk_7d: g[4] ?? 0 };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
}, [cellInfo, lodGrids]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
|
|
|
|
|
{error && (
|
|
|
|
|
@@ -189,8 +213,8 @@ export function AlertsDashboard() {
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-3 text-[11px]">
|
|
|
|
|
<span className="text-text-muted">共 <span className="font-semibold text-text-primary">{filteredAlerts.length}</span> 条预警</span>
|
|
|
|
|
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {p1Count}</span>
|
|
|
|
|
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {p2Count}</span>
|
|
|
|
|
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {riskStats.p1}</span>
|
|
|
|
|
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {riskStats.p2}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@@ -464,67 +488,51 @@ export function AlertsDashboard() {
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Cell info panel - shown when clicking grid cell without alert */}
|
|
|
|
|
{cellInfo && !selectedAlertData && (() => {
|
|
|
|
|
// Find nearest LOD grid cell for multi-day risk display
|
|
|
|
|
// grids are [lat, lon, risk_1d, risk_3d, risk_7d]
|
|
|
|
|
let nearest: { lat: number; lon: number; risk_1d: number; risk_3d: number; risk_7d: number } | null = null;
|
|
|
|
|
let minDist = Infinity;
|
|
|
|
|
for (const g of lodGrids) {
|
|
|
|
|
const d = Math.sqrt((g[0] - cellInfo.lat) ** 2 + (g[1] - cellInfo.lon) ** 2);
|
|
|
|
|
if (d < minDist) {
|
|
|
|
|
minDist = d;
|
|
|
|
|
nearest = { lat: g[0], lon: g[1], risk_1d: g[2] ?? 0, risk_3d: g[3] ?? 0, risk_7d: g[4] ?? 0 };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
<span className="text-[14px] font-semibold text-text-primary">网格详情</span>
|
|
|
|
|
<button onClick={clearCellInfo} className="text-text-muted hover:text-text-primary text-[18px] leading-none">×</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2 text-[12px]">
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span className="text-text-muted">坐标</span>
|
|
|
|
|
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span className="text-text-muted">当前风险</span>
|
|
|
|
|
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
|
|
|
|
|
{(cellInfo.risk * 100).toFixed(1)}%
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{nearest && (
|
|
|
|
|
<div className="flex gap-3 pt-1">
|
|
|
|
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
|
|
|
<div className="text-[10px] text-text-muted">1天</div>
|
|
|
|
|
<div className="font-bold text-[13px]">{(nearest.risk_1d * 100).toFixed(0)}%</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
|
|
|
<div className="text-[10px] text-text-muted">3天</div>
|
|
|
|
|
<div className="font-bold text-[13px]">{(nearest.risk_3d * 100).toFixed(0)}%</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
|
|
|
<div className="text-[10px] text-text-muted">7天</div>
|
|
|
|
|
<div className="font-bold text-[13px]">{(nearest.risk_7d * 100).toFixed(0)}%</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{cellInfo.nearestAlertId && (
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span className="text-text-muted">最近预警距离</span>
|
|
|
|
|
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{!cellInfo.nearestAlertId && (
|
|
|
|
|
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
|
|
|
|
|
该区域无预警
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{cellInfo && !selectedAlertData && nearestGrid && (
|
|
|
|
|
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
<span className="text-[14px] font-semibold text-text-primary">网格详情</span>
|
|
|
|
|
<button onClick={clearCellInfo} className="text-text-muted hover:text-text-primary text-[18px] leading-none">×</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
<div className="space-y-2 text-[12px]">
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span className="text-text-muted">坐标</span>
|
|
|
|
|
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span className="text-text-muted">当前风险</span>
|
|
|
|
|
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
|
|
|
|
|
{(cellInfo.risk * 100).toFixed(1)}%
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-3 pt-1">
|
|
|
|
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
|
|
|
<div className="text-[10px] text-text-muted">1天</div>
|
|
|
|
|
<div className="font-bold text-[13px]">{(nearestGrid.risk_1d * 100).toFixed(0)}%</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
|
|
|
<div className="text-[10px] text-text-muted">3天</div>
|
|
|
|
|
<div className="font-bold text-[13px]">{(nearestGrid.risk_3d * 100).toFixed(0)}%</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
|
|
|
<div className="text-[10px] text-text-muted">7天</div>
|
|
|
|
|
<div className="font-bold text-[13px]">{(nearestGrid.risk_7d * 100).toFixed(0)}%</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{cellInfo.nearestAlertId && (
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span className="text-text-muted">最近预警距离</span>
|
|
|
|
|
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{!cellInfo.nearestAlertId && (
|
|
|
|
|
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
|
|
|
|
|
该区域无预警
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Alert detail modal */}
|
|
|
|
|
{selectedAlertData && (
|
|
|
|
|
@@ -574,7 +582,7 @@ interface AlertCardProps {
|
|
|
|
|
onClick?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AlertCard({ alert, isSelected, onClick }: AlertCardProps) {
|
|
|
|
|
const AlertCard = React.memo(function AlertCard({ alert, isSelected, onClick }: AlertCardProps) {
|
|
|
|
|
const isP1 = alert.priority === 'P1';
|
|
|
|
|
const riskPercent = Math.round(alert.risk_value * 100);
|
|
|
|
|
|
|
|
|
|
@@ -625,4 +633,4 @@ function AlertCard({ alert, isSelected, onClick }: AlertCardProps) {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|