fix: analysis 500s, caching, alert page perf

P0: Fix KeyError in 3 analysis endpoints. geojson.py stores 1d risk
as "risk_value" but analysis.py accessed "risk_1d" — always crashed.

Backend: Add lru_cache to GeoJSON/CSV/Parquet loaders, date helpers,
and district loader. Add try/except and FileNotFoundError guards.

Frontend: Debounce riskRange, merge counts into useMemo, stabilize
handleGridClick with ref, memoize nearest-grid scan, wrap AlertMap
in React.memo, switch useLodGrid from fetch to cachedGet.
This commit is contained in:
2026-06-05 02:27:10 +08:00
parent fc468464b2
commit e64ca3b4f5
9 changed files with 154 additions and 114 deletions

View File

@@ -83,7 +83,7 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
if filepath.exists(): if filepath.exists():
grids = parse_geojson_file(filepath) grids = parse_geojson_file(filepath)
if grids: if grids:
avg_risk = sum(g["risk_1d"] for g in grids) / len(grids) avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
values.append(round(avg_risk, 4)) values.append(round(avg_risk, 4))
else: else:
values.append(0) values.append(0)
@@ -125,8 +125,8 @@ async def get_districts():
if not districts: if not districts:
# Fallback: return city-wide aggregation # Fallback: return city-wide aggregation
avg_risk = sum(g["risk_1d"] for g in grids) / len(grids) if grids else 0 avg_risk = sum(g["risk_value"] for g in grids) / len(grids) if grids else 0
high_risk_count = sum(1 for g in grids if g["risk_1d"] >= RISK_HIGH) high_risk_count = sum(1 for g in grids if g["risk_value"] >= RISK_HIGH)
return DistrictsResponse( return DistrictsResponse(
districts=[ districts=[
@@ -150,14 +150,14 @@ async def get_districts():
for district in districts: for district in districts:
if point_in_polygon(grid["latitude"], grid["longitude"], district["coordinates"]): if point_in_polygon(grid["latitude"], grid["longitude"], district["coordinates"]):
district_data[district["name"]]["grids"].append(grid) district_data[district["name"]]["grids"].append(grid)
if grid["risk_1d"] >= RISK_HIGH: if grid["risk_value"] >= RISK_HIGH:
district_data[district["name"]]["high_risk"] += 1 district_data[district["name"]]["high_risk"] += 1
assigned = True assigned = True
break break
if not assigned: if not assigned:
unassigned["grids"].append(grid) unassigned["grids"].append(grid)
if grid["risk_1d"] >= RISK_HIGH: if grid["risk_value"] >= RISK_HIGH:
unassigned["high_risk"] += 1 unassigned["high_risk"] += 1
# Build response # Build response
@@ -169,7 +169,7 @@ async def get_districts():
if not grids_in_district: if not grids_in_district:
continue continue
avg_risk = sum(g["risk_1d"] for g in grids_in_district) / len(grids_in_district) avg_risk = sum(g["risk_value"] for g in grids_in_district) / len(grids_in_district)
high_risk_count = district_data[name]["high_risk"] high_risk_count = district_data[name]["high_risk"]
# Mock total cases based on risk and grid count # Mock total cases based on risk and grid count
@@ -187,7 +187,7 @@ async def get_districts():
# Add unassigned as "其他" if significant # Add unassigned as "其他" if significant
if unassigned["grids"]: if unassigned["grids"]:
avg_risk = sum(g["risk_1d"] for g in unassigned["grids"]) / len(unassigned["grids"]) avg_risk = sum(g["risk_value"] for g in unassigned["grids"]) / len(unassigned["grids"])
result.append( result.append(
DistrictRisk( DistrictRisk(
name="其他", name="其他",
@@ -224,8 +224,8 @@ async def get_correlations():
# Calculate mock correlations based on risk patterns # Calculate mock correlations based on risk patterns
# In production, this would use actual weather and health data # In production, this would use actual weather and health data
avg_risk = sum(g["risk_1d"] for g in grids) / len(grids) avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
risk_variance = sum((g["risk_1d"] - avg_risk) ** 2 for g in grids) / len(grids) risk_variance = sum((g["risk_value"] - avg_risk) ** 2 for g in grids) / len(grids)
# Generate realistic correlation coefficients # Generate realistic correlation coefficients
correlations = [ correlations = [

View File

@@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
import logging import logging
from functools import lru_cache
import pandas as pd import pandas as pd
from pathlib import Path from pathlib import Path
@@ -15,6 +16,11 @@ router = APIRouter(prefix="/api/geocoded", tags=["geocoded"])
PROJECT_ROOT = Path(__file__).parent.parent.parent PROJECT_ROOT = Path(__file__).parent.parent.parent
DATA_DIR = PROJECT_ROOT / "outputs" DATA_DIR = PROJECT_ROOT / "outputs"
@lru_cache(maxsize=1)
def _load_csv(path: Path) -> pd.DataFrame:
return pd.read_csv(path)
class GridCaseData(BaseModel): class GridCaseData(BaseModel):
"""Grid case data for visualization""" """Grid case data for visualization"""
grid_id: int grid_id: int
@@ -60,7 +66,7 @@ async def get_grid_cases():
raise HTTPException(status_code=404, detail="Grid data not found") raise HTTPException(status_code=404, detail="Grid data not found")
try: try:
df = pd.read_csv(grid_file) df = _load_csv(grid_file)
grids = [] grids = []
for _, row in df.iterrows(): for _, row in df.iterrows():
@@ -105,7 +111,7 @@ async def get_geocoded_cases(
raise HTTPException(status_code=404, detail="Geocoded data not found") raise HTTPException(status_code=404, detail="Geocoded data not found")
try: try:
df = pd.read_csv(cases_file) df = _load_csv(cases_file)
# Drop rows with missing coordinates # Drop rows with missing coordinates
df = df.dropna(subset=['latitude', 'longitude']) df = df.dropna(subset=['latitude', 'longitude'])
@@ -122,7 +128,7 @@ async def get_geocoded_cases(
df = df.head(limit) df = df.head(limit)
cases = [] cases = []
for _, row in df.iterrows(): for row in df.to_dict('records'):
street_val = row.get('street') street_val = row.get('street')
if pd.isna(street_val): if pd.isna(street_val):
street_val = None street_val = None
@@ -157,7 +163,7 @@ async def get_geocoded_count():
raise HTTPException(status_code=404, detail="Geocoded data not found") raise HTTPException(status_code=404, detail="Geocoded data not found")
try: try:
df = pd.read_csv(cases_file) df = _load_csv(cases_file)
street_matched = len(df[df['geocode_method'] == 'street']) street_matched = len(df[df['geocode_method'] == 'street'])
district_fallback = len(df[df['geocode_method'] == 'district']) district_fallback = len(df[df['geocode_method'] == 'district'])

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import logging import logging
@@ -22,6 +23,12 @@ from models import (
router = APIRouter(prefix="/api", tags=["grid"]) router = APIRouter(prefix="/api", tags=["grid"])
@lru_cache(maxsize=1)
def _load_parquet(path: Path) -> "pd.DataFrame":
import pandas as pd
return pd.read_parquet(path)
@router.get("/history/aggregated", response_model=HistoricalAggregationResponse) @router.get("/history/aggregated", response_model=HistoricalAggregationResponse)
async def get_historical_aggregated( async def get_historical_aggregated(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
@@ -45,7 +52,13 @@ async def get_historical_aggregated(
import pandas as pd import pandas as pd
cases_df = pd.read_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet") try:
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
except FileNotFoundError:
return HistoricalAggregationResponse(
aggregations=[], total_records=0,
date_range=(start_date, end_date), timestamp=datetime.now().isoformat(),
)
cases_df['date'] = pd.to_datetime(cases_df['date']) cases_df['date'] = pd.to_datetime(cases_df['date'])
filtered_cases = cases_df[ filtered_cases = cases_df[
@@ -78,7 +91,10 @@ async def get_historical_aggregated(
grouped = filtered_cases.copy() grouped = filtered_cases.copy()
grouped['date'] = grouped['date'].dt.strftime('%Y-%m-%d') grouped['date'] = grouped['date'].dt.strftime('%Y-%m-%d')
weather_df = pd.read_parquet(PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet") try:
weather_df = _load_parquet(PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet")
except FileNotFoundError:
weather_df = pd.DataFrame(columns=['date', 'AQI', 'PM25', 'PM10'])
weather_df['date'] = pd.to_datetime(weather_df['date']).dt.strftime('%Y-%m-%d') weather_df['date'] = pd.to_datetime(weather_df['date']).dt.strftime('%Y-%m-%d')
# Weather data doesn't have district - aggregate by date only # Weather data doesn't have district - aggregate by date only
@@ -124,12 +140,12 @@ async def get_grids_geojson(
import pandas as pd import pandas as pd
try: try:
grid_df = pd.read_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet") grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
except FileNotFoundError: except FileNotFoundError:
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat()) return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
try: try:
district_map = pd.read_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet") district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
except FileNotFoundError: except FileNotFoundError:
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat()) return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
@@ -139,7 +155,7 @@ async def get_grids_geojson(
merged = merged[merged['district_name'].str.contains(district.replace('', ''), na=False, regex=False)] merged = merged[merged['district_name'].str.contains(district.replace('', ''), na=False, regex=False)]
try: try:
cases_df = pd.read_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet") cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
except FileNotFoundError: except FileNotFoundError:
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat()) return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d') cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
@@ -313,7 +329,7 @@ async def get_grid_history(
""" """
import pandas as pd import pandas as pd
district_map = pd.read_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet") district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
grid_info = district_map[district_map['grid_id'] == grid_id] grid_info = district_map[district_map['grid_id'] == grid_id]
if len(grid_info) == 0: if len(grid_info) == 0:
@@ -321,7 +337,7 @@ async def get_grid_history(
district = grid_info.iloc[0]['district_name'] district = grid_info.iloc[0]['district_name']
cases_df = pd.read_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet") cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
cases_df['date'] = pd.to_datetime(cases_df['date']) cases_df['date'] = pd.to_datetime(cases_df['date'])
end_date = datetime.now() end_date = datetime.now()

View File

@@ -3,6 +3,7 @@ Date utilities: finding latest dates from GeoJSON files, parsing date strings.
""" """
import glob import glob
import re import re
from functools import lru_cache
from pathlib import Path from pathlib import Path
from fastapi import HTTPException from fastapi import HTTPException
@@ -10,6 +11,7 @@ from fastapi import HTTPException
from config import DATA_DIR, DATE_FORMAT_GEOJSON from config import DATA_DIR, DATE_FORMAT_GEOJSON
@lru_cache(maxsize=1)
def get_latest_date() -> str: def get_latest_date() -> str:
"""Get latest available date from GeoJSON files in DATA_DIR.""" """Get latest available date from GeoJSON files in DATA_DIR."""
pattern = str(DATA_DIR / "risk_*.geojson") pattern = str(DATA_DIR / "risk_*.geojson")
@@ -29,6 +31,7 @@ def get_latest_date() -> str:
return max(dates) return max(dates)
@lru_cache(maxsize=1)
def get_available_dates(days: int = 30) -> list[str]: def get_available_dates(days: int = 30) -> list[str]:
"""Get list of available dates, most recent first.""" """Get list of available dates, most recent first."""
pattern = str(DATA_DIR / "risk_*.geojson") pattern = str(DATA_DIR / "risk_*.geojson")

View File

@@ -2,17 +2,26 @@
GeoJSON file parsing utilities. GeoJSON file parsing utilities.
""" """
import json import json
import logging
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from config import WUHAN_BOUNDARY_PATH from config import WUHAN_BOUNDARY_PATH
from utils.risk import risk_value_to_level from utils.risk import risk_value_to_level
logger = logging.getLogger(__name__)
@lru_cache(maxsize=8)
def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]: def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]:
"""Parse GeoJSON file and extract grid data with standard fields.""" """Parse GeoJSON file and extract grid data with standard fields."""
try:
with open(filepath, "r", encoding="utf-8") as f: with open(filepath, "r", encoding="utf-8") as f:
geojson = json.load(f) geojson = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to parse GeoJSON file %s: %s", filepath, e)
return []
grids: list[dict[str, Any]] = [] grids: list[dict[str, Any]] = []
for feature in geojson.get("features", []): for feature in geojson.get("features", []):
@@ -33,6 +42,7 @@ def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]:
return grids return grids
@lru_cache(maxsize=1)
def load_districts() -> list[dict[str, Any]]: def load_districts() -> list[dict[str, Any]]:
"""Load Wuhan district boundaries from GeoJSON.""" """Load Wuhan district boundaries from GeoJSON."""
if not WUHAN_BOUNDARY_PATH.exists(): if not WUHAN_BOUNDARY_PATH.exists():

View File

@@ -1,10 +1,10 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback, memo } from 'react';
import L from 'leaflet'; import L from 'leaflet';
import { useRiskStore } from '@/stores'; import { useRiskStore } from '@/stores';
import { LodGridLayer } from '@/components/LodGridLayer'; import { LodGridLayer } from '@/components/LodGridLayer';
import { GridStatsOverlay } from '@/components/GridStatsOverlay'; import { GridStatsOverlay } from '@/components/GridStatsOverlay';
import { useLodGrid } from '@/hooks/useLodGrid'; import { useLodGrid } from '@/hooks/useLodGrid';
import type { Alert } from '@/types'; import type { Alert, GridRisk } from '@/types';
export interface CellInfo { export interface CellInfo {
lat: number; lat: number;
@@ -44,6 +44,8 @@ function getRiskLabel(value: number): string {
return '低风险'; return '低风险';
} }
const EMPTY_GRIDS: GridRisk[] = [];
function AlertMapComponent({ function AlertMapComponent({
selectedGridId, selectedGridId,
onGridClick, onGridClick,
@@ -62,7 +64,7 @@ function AlertMapComponent({
const clickHandlerRef = useRef(onGridClick); const clickHandlerRef = useRef(onGridClick);
const [currentZoom, setCurrentZoom] = useState(10); const [currentZoom, setCurrentZoom] = useState(10);
const grids = useRiskStore((s) => s.grids ?? []); const grids = useRiskStore((s) => s.grids ?? EMPTY_GRIDS);
// LOD grid data for stats overlay // LOD grid data for stats overlay
const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay); const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay);
@@ -299,4 +301,4 @@ function AlertMapComponent({
); );
} }
export const AlertMap = AlertMapComponent; export const AlertMap = memo(AlertMapComponent);

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { cachedGet } from '../services/api';
export interface LodGridResult { export interface LodGridResult {
grids: number[][]; grids: number[][];
@@ -30,23 +31,17 @@ export function useLodGrid(zoom: number, forecastDay: 1 | 3 | 7, bounds?: MapBou
const abortRef = useRef<AbortController>(); const abortRef = useRef<AbortController>();
const fetchData = useCallback(async (z: number, day: 1 | 3 | 7, b?: MapBounds) => { const fetchData = useCallback(async (z: number, day: 1 | 3 | 7, b?: MapBounds) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setResult((prev) => ({ ...prev, loading: true })); setResult((prev) => ({ ...prev, loading: true }));
try { try {
let url = `/api/risk/lod-grid?zoom=${z}&forecast_day=${day}`; const params: Record<string, any> = { zoom: z, forecast_day: day };
if (b && z >= 10) { if (b && z >= 10) {
url += `&min_lat=${b.min_lat}&max_lat=${b.max_lat}&min_lon=${b.min_lon}&max_lon=${b.max_lon}`; params.min_lat = b.min_lat;
params.max_lat = b.max_lat;
params.min_lon = b.min_lon;
params.max_lon = b.max_lon;
} }
const resp = await fetch(url, { const data = await cachedGet<any>('/risk/lod-grid', params);
signal: controller.signal,
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const grids: number[][] = data.grids || []; const grids: number[][] = data.grids || [];
const count = data.total_count || grids.length; const count = data.total_count || grids.length;

View File

@@ -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 { useRiskStore } from '@/stores';
import { useLodGrid } from '@/hooks/useLodGrid'; import { useLodGrid } from '@/hooks/useLodGrid';
import { AlertMap } from '@/components/AlertMap'; import { AlertMap } from '@/components/AlertMap';
@@ -36,6 +37,7 @@ export function AlertsDashboard() {
const [showAlertMarkers, setShowAlertMarkers] = useState(true); const [showAlertMarkers, setShowAlertMarkers] = useState(true);
const [selectedAlert, setSelectedAlert] = useState<string | null>(null); const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
const [riskRange, setRiskRange] = useState<[number, number]>([0.6, 1.0]); 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 [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [showGrid, setShowGrid] = useState(true); const [showGrid, setShowGrid] = useState(true);
@@ -44,6 +46,12 @@ export function AlertsDashboard() {
// LOD grid data for cell info lookup (1d/3d/7d risk values) // LOD grid data for cell info lookup (1d/3d/7d risk values)
const { grids: lodGrids } = useLodGrid(10, forecastDay); 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 // Fetch grids (for map) and alerts (for side panel) on mount
useEffect(() => { useEffect(() => {
fetchRiskMap(); fetchRiskMap();
@@ -71,7 +79,7 @@ export function AlertsDashboard() {
.filter((alert) => { .filter((alert) => {
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon; const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority; 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; return horizonMatch && priorityMatch && riskMatch;
}) })
.sort((a, b) => { .sort((a, b) => {
@@ -80,13 +88,12 @@ export function AlertsDashboard() {
} }
return new Date(b.forecast_time).getTime() - new Date(a.forecast_time).getTime(); 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; // Risk distribution stats (includes p1/p2 counts)
const p2Count = extendedAlerts.filter((a) => a.priority === 'P2').length;
// Risk distribution stats
const riskStats = useMemo(() => { 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 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 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; 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]) .sort((a, b) => b[1] - a[1])
.slice(0, 5); .slice(0, 5);
return { high, mediumHigh, medium, avgRisk, topDistricts }; return { p1, p2, high, mediumHigh, medium, avgRisk, topDistricts };
}, [filteredAlerts]); }, [extendedAlerts, filteredAlerts]);
const selectedAlertData = useMemo(() => { const selectedAlertData = useMemo(() => {
return filteredAlerts.find(a => a.alert_id === selectedAlert); return filteredAlerts.find(a => a.alert_id === selectedAlert);
@@ -116,12 +123,15 @@ export function AlertsDashboard() {
return alert?.grid_id ?? null; return alert?.grid_id ?? null;
}, [filteredAlerts, selectedAlert]); }, [filteredAlerts, selectedAlert]);
const filteredAlertsRef = useRef(filteredAlerts);
useEffect(() => { filteredAlertsRef.current = filteredAlerts; }, [filteredAlerts]);
const handleGridClick = useCallback((gridId: string) => { 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) { if (alertForGrid) {
setSelectedAlert(alertForGrid.alert_id); setSelectedAlert(alertForGrid.alert_id);
} }
}, [filteredAlerts]); }, []);
const handleAlertCardClick = useCallback((id: string) => { const handleAlertCardClick = useCallback((id: string) => {
setSelectedAlert(id); setSelectedAlert(id);
@@ -169,6 +179,20 @@ export function AlertsDashboard() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [filteredAlerts]); }, [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 ( return (
<div className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}> <div className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
{error && ( {error && (
@@ -189,8 +213,8 @@ export function AlertsDashboard() {
</div> </div>
<div className="flex items-center gap-3 text-[11px]"> <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="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-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: {p2Count}</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>
</div> </div>
@@ -464,20 +488,7 @@ export function AlertsDashboard() {
)} )}
{/* Cell info panel - shown when clicking grid cell without alert */} {/* Cell info panel - shown when clicking grid cell without alert */}
{cellInfo && !selectedAlertData && (() => { {cellInfo && !selectedAlertData && nearestGrid && (
// 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="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"> <div className="flex items-center justify-between mb-3">
<span className="text-[14px] font-semibold text-text-primary"></span> <span className="text-[14px] font-semibold text-text-primary"></span>
@@ -494,22 +505,20 @@ export function AlertsDashboard() {
{(cellInfo.risk * 100).toFixed(1)}% {(cellInfo.risk * 100).toFixed(1)}%
</span> </span>
</div> </div>
{nearest && (
<div className="flex gap-3 pt-1"> <div className="flex gap-3 pt-1">
<div className="flex-1 text-center p-1.5 rounded bg-bg-page"> <div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">1</div> <div className="text-[10px] text-text-muted">1</div>
<div className="font-bold text-[13px]">{(nearest.risk_1d * 100).toFixed(0)}%</div> <div className="font-bold text-[13px]">{(nearestGrid.risk_1d * 100).toFixed(0)}%</div>
</div> </div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page"> <div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">3</div> <div className="text-[10px] text-text-muted">3</div>
<div className="font-bold text-[13px]">{(nearest.risk_3d * 100).toFixed(0)}%</div> <div className="font-bold text-[13px]">{(nearestGrid.risk_3d * 100).toFixed(0)}%</div>
</div> </div>
<div className="flex-1 text-center p-1.5 rounded bg-bg-page"> <div className="flex-1 text-center p-1.5 rounded bg-bg-page">
<div className="text-[10px] text-text-muted">7</div> <div className="text-[10px] text-text-muted">7</div>
<div className="font-bold text-[13px]">{(nearest.risk_7d * 100).toFixed(0)}%</div> <div className="font-bold text-[13px]">{(nearestGrid.risk_7d * 100).toFixed(0)}%</div>
</div> </div>
</div> </div>
)}
{cellInfo.nearestAlertId && ( {cellInfo.nearestAlertId && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-muted"></span> <span className="text-text-muted"></span>
@@ -523,8 +532,7 @@ export function AlertsDashboard() {
)} )}
</div> </div>
</div> </div>
); )}
})()}
{/* Alert detail modal */} {/* Alert detail modal */}
{selectedAlertData && ( {selectedAlertData && (
@@ -574,7 +582,7 @@ interface AlertCardProps {
onClick?: () => void; onClick?: () => void;
} }
function AlertCard({ alert, isSelected, onClick }: AlertCardProps) { const AlertCard = React.memo(function AlertCard({ alert, isSelected, onClick }: AlertCardProps) {
const isP1 = alert.priority === 'P1'; const isP1 = alert.priority === 'P1';
const riskPercent = Math.round(alert.risk_value * 100); const riskPercent = Math.round(alert.risk_value * 100);
@@ -625,4 +633,4 @@ function AlertCard({ alert, isSelected, onClick }: AlertCardProps) {
</div> </div>
</div> </div>
); );
} });

View File

@@ -87,7 +87,7 @@ api.interceptors.response.use(
} }
); );
async function cachedGet<T>(url: string, params?: Record<string, any>): Promise<T> { export async function cachedGet<T>(url: string, params?: Record<string, any>): Promise<T> {
const key = getCacheKey(url, params); const key = getCacheKey(url, params);
const cached = getCached<T>(key); const cached = getCached<T>(key);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;