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:
@@ -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 = [
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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."""
|
||||||
with open(filepath, "r", encoding="utf-8") as f:
|
try:
|
||||||
geojson = json.load(f)
|
with open(filepath, "r", encoding="utf-8") as 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():
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,67 +488,51 @@ 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
|
<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]">
|
||||||
// grids are [lat, lon, risk_1d, risk_3d, risk_7d]
|
<div className="flex items-center justify-between mb-3">
|
||||||
let nearest: { lat: number; lon: number; risk_1d: number; risk_3d: number; risk_7d: number } | null = null;
|
<span className="text-[14px] font-semibold text-text-primary">网格详情</span>
|
||||||
let minDist = Infinity;
|
<button onClick={clearCellInfo} className="text-text-muted hover:text-text-primary text-[18px] leading-none">×</button>
|
||||||
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>
|
|
||||||
</div>
|
</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 */}
|
{/* 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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user