diff --git a/backend/routers/analysis.py b/backend/routers/analysis.py index cf690d2..2fc6f83 100644 --- a/backend/routers/analysis.py +++ b/backend/routers/analysis.py @@ -83,7 +83,7 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)): if filepath.exists(): grids = parse_geojson_file(filepath) 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)) else: values.append(0) @@ -125,8 +125,8 @@ async def get_districts(): if not districts: # Fallback: return city-wide aggregation - avg_risk = sum(g["risk_1d"] 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) + 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_value"] >= RISK_HIGH) return DistrictsResponse( districts=[ @@ -150,14 +150,14 @@ async def get_districts(): for district in districts: if point_in_polygon(grid["latitude"], grid["longitude"], district["coordinates"]): 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 assigned = True break if not assigned: unassigned["grids"].append(grid) - if grid["risk_1d"] >= RISK_HIGH: + if grid["risk_value"] >= RISK_HIGH: unassigned["high_risk"] += 1 # Build response @@ -169,7 +169,7 @@ async def get_districts(): if not grids_in_district: 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"] # Mock total cases based on risk and grid count @@ -187,7 +187,7 @@ async def get_districts(): # Add unassigned as "其他" if significant 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( DistrictRisk( name="其他", @@ -224,8 +224,8 @@ async def get_correlations(): # Calculate mock correlations based on risk patterns # In production, this would use actual weather and health data - avg_risk = sum(g["risk_1d"] for g in grids) / len(grids) - risk_variance = sum((g["risk_1d"] - avg_risk) ** 2 for g in grids) / len(grids) + avg_risk = sum(g["risk_value"] 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 correlations = [ diff --git a/backend/routers/geocoded.py b/backend/routers/geocoded.py index cb1017f..3ff92d1 100644 --- a/backend/routers/geocoded.py +++ b/backend/routers/geocoded.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import List, Optional import logging +from functools import lru_cache import pandas as pd from pathlib import Path @@ -15,6 +16,11 @@ router = APIRouter(prefix="/api/geocoded", tags=["geocoded"]) PROJECT_ROOT = Path(__file__).parent.parent.parent DATA_DIR = PROJECT_ROOT / "outputs" + +@lru_cache(maxsize=1) +def _load_csv(path: Path) -> pd.DataFrame: + return pd.read_csv(path) + class GridCaseData(BaseModel): """Grid case data for visualization""" grid_id: int @@ -60,7 +66,7 @@ async def get_grid_cases(): raise HTTPException(status_code=404, detail="Grid data not found") try: - df = pd.read_csv(grid_file) + df = _load_csv(grid_file) grids = [] for _, row in df.iterrows(): @@ -105,7 +111,7 @@ async def get_geocoded_cases( raise HTTPException(status_code=404, detail="Geocoded data not found") try: - df = pd.read_csv(cases_file) + df = _load_csv(cases_file) # Drop rows with missing coordinates df = df.dropna(subset=['latitude', 'longitude']) @@ -122,7 +128,7 @@ async def get_geocoded_cases( df = df.head(limit) cases = [] - for _, row in df.iterrows(): + for row in df.to_dict('records'): street_val = row.get('street') if pd.isna(street_val): street_val = None @@ -157,7 +163,7 @@ async def get_geocoded_count(): raise HTTPException(status_code=404, detail="Geocoded data not found") try: - df = pd.read_csv(cases_file) + df = _load_csv(cases_file) street_matched = len(df[df['geocode_method'] == 'street']) district_fallback = len(df[df['geocode_method'] == 'district']) diff --git a/backend/routers/grid.py b/backend/routers/grid.py index a003d96..8252c34 100644 --- a/backend/routers/grid.py +++ b/backend/routers/grid.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, HTTPException, Query from datetime import datetime, timedelta +from functools import lru_cache from pathlib import Path from typing import Optional import logging @@ -22,6 +23,12 @@ from models import ( 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) async def get_historical_aggregated( start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), @@ -45,7 +52,13 @@ async def get_historical_aggregated( 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']) filtered_cases = cases_df[ @@ -78,7 +91,10 @@ async def get_historical_aggregated( grouped = filtered_cases.copy() 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 data doesn't have district - aggregate by date only @@ -124,12 +140,12 @@ async def get_grids_geojson( import pandas as pd 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: return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat()) 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: 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)] 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: return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat()) 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 - 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] if len(grid_info) == 0: @@ -321,7 +337,7 @@ async def get_grid_history( 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']) end_date = datetime.now() diff --git a/backend/utils/date_helpers.py b/backend/utils/date_helpers.py index fdb01a2..bbd7506 100644 --- a/backend/utils/date_helpers.py +++ b/backend/utils/date_helpers.py @@ -3,6 +3,7 @@ Date utilities: finding latest dates from GeoJSON files, parsing date strings. """ import glob import re +from functools import lru_cache from pathlib import Path from fastapi import HTTPException @@ -10,6 +11,7 @@ from fastapi import HTTPException from config import DATA_DIR, DATE_FORMAT_GEOJSON +@lru_cache(maxsize=1) def get_latest_date() -> str: """Get latest available date from GeoJSON files in DATA_DIR.""" pattern = str(DATA_DIR / "risk_*.geojson") @@ -29,6 +31,7 @@ def get_latest_date() -> str: return max(dates) +@lru_cache(maxsize=1) def get_available_dates(days: int = 30) -> list[str]: """Get list of available dates, most recent first.""" pattern = str(DATA_DIR / "risk_*.geojson") diff --git a/backend/utils/geojson.py b/backend/utils/geojson.py index 3cd6c84..15ecdc0 100644 --- a/backend/utils/geojson.py +++ b/backend/utils/geojson.py @@ -2,17 +2,26 @@ GeoJSON file parsing utilities. """ import json +import logging +from functools import lru_cache from pathlib import Path from typing import Any from config import WUHAN_BOUNDARY_PATH 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]]: """Parse GeoJSON file and extract grid data with standard fields.""" - with open(filepath, "r", encoding="utf-8") as f: - geojson = json.load(f) + try: + 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]] = [] for feature in geojson.get("features", []): @@ -33,6 +42,7 @@ def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]: return grids +@lru_cache(maxsize=1) def load_districts() -> list[dict[str, Any]]: """Load Wuhan district boundaries from GeoJSON.""" if not WUHAN_BOUNDARY_PATH.exists(): diff --git a/frontend/src/components/AlertMap.tsx b/frontend/src/components/AlertMap.tsx index c365e53..0c396ea 100644 --- a/frontend/src/components/AlertMap.tsx +++ b/frontend/src/components/AlertMap.tsx @@ -1,10 +1,10 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; +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 } from '@/types'; +import type { Alert, GridRisk } from '@/types'; export interface CellInfo { lat: number; @@ -44,6 +44,8 @@ function getRiskLabel(value: number): string { return '低风险'; } +const EMPTY_GRIDS: GridRisk[] = []; + function AlertMapComponent({ selectedGridId, onGridClick, @@ -62,7 +64,7 @@ function AlertMapComponent({ const clickHandlerRef = useRef(onGridClick); 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 const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay); @@ -299,4 +301,4 @@ function AlertMapComponent({ ); } -export const AlertMap = AlertMapComponent; +export const AlertMap = memo(AlertMapComponent); diff --git a/frontend/src/hooks/useLodGrid.ts b/frontend/src/hooks/useLodGrid.ts index 7b79b72..ca12bb1 100644 --- a/frontend/src/hooks/useLodGrid.ts +++ b/frontend/src/hooks/useLodGrid.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; +import { cachedGet } from '../services/api'; export interface LodGridResult { grids: number[][]; @@ -30,23 +31,17 @@ export function useLodGrid(zoom: number, forecastDay: 1 | 3 | 7, bounds?: MapBou const abortRef = useRef(); 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 })); try { - let url = `/api/risk/lod-grid?zoom=${z}&forecast_day=${day}`; + const params: Record = { zoom: z, forecast_day: day }; 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, { - signal: controller.signal, - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - - const data = await resp.json(); + const data = await cachedGet('/risk/lod-grid', params); const grids: number[][] = data.grids || []; const count = data.total_count || grids.length; diff --git a/frontend/src/pages/AlertsDashboard.tsx b/frontend/src/pages/AlertsDashboard.tsx index d839ff7..1629190 100644 --- a/frontend/src/pages/AlertsDashboard.tsx +++ b/frontend/src/pages/AlertsDashboard.tsx @@ -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(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 (
{error && ( @@ -189,8 +213,8 @@ export function AlertsDashboard() {
{filteredAlerts.length} 条预警 - P1: {p1Count} - P2: {p2Count} + P1: {riskStats.p1} + P2: {riskStats.p2}
@@ -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 ( -
-
- 网格详情 - -
-
-
- 坐标 - {cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)} -
-
- 当前风险 - = 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}> - {(cellInfo.risk * 100).toFixed(1)}% - -
- {nearest && ( -
-
-
1天
-
{(nearest.risk_1d * 100).toFixed(0)}%
-
-
-
3天
-
{(nearest.risk_3d * 100).toFixed(0)}%
-
-
-
7天
-
{(nearest.risk_7d * 100).toFixed(0)}%
-
-
- )} - {cellInfo.nearestAlertId && ( -
- 最近预警距离 - {(cellInfo.nearestAlertDist * 111).toFixed(1)} km -
- )} - {!cellInfo.nearestAlertId && ( -
- 该区域无预警 -
- )} -
+ {cellInfo && !selectedAlertData && nearestGrid && ( +
+
+ 网格详情 +
- ); - })()} +
+
+ 坐标 + {cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)} +
+
+ 当前风险 + = 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}> + {(cellInfo.risk * 100).toFixed(1)}% + +
+
+
+
1天
+
{(nearestGrid.risk_1d * 100).toFixed(0)}%
+
+
+
3天
+
{(nearestGrid.risk_3d * 100).toFixed(0)}%
+
+
+
7天
+
{(nearestGrid.risk_7d * 100).toFixed(0)}%
+
+
+ {cellInfo.nearestAlertId && ( +
+ 最近预警距离 + {(cellInfo.nearestAlertDist * 111).toFixed(1)} km +
+ )} + {!cellInfo.nearestAlertId && ( +
+ 该区域无预警 +
+ )} +
+
+ )} {/* 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) {
); -} +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0719081..89deb4f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -87,7 +87,7 @@ api.interceptors.response.use( } ); -async function cachedGet(url: string, params?: Record): Promise { +export async function cachedGet(url: string, params?: Record): Promise { const key = getCacheKey(url, params); const cached = getCached(key); if (cached !== undefined) return cached;