diff --git a/backend/routers/insights.py b/backend/routers/insights.py index 173c160..60c5c46 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -8,7 +8,7 @@ from typing import List, Literal import random from pydantic import BaseModel, Field -from typing import Dict, List +from typing import Dict, List, Literal from config import DATA_DIR, RISK_HIGH from models import ( @@ -27,6 +27,24 @@ from utils.risk import calculate_trend as calculate_trend_direction router = APIRouter(prefix="/api/insights", tags=["insights"]) +class InsightCardItem(BaseModel): + id: str + title: str + description: str + type: Literal["warning", "info", "success", "danger"] + metric: str | None = None + metricValue: str | None = None + timestamp: str + + +class InsightCardResponse(BaseModel): + total_insights: int + warning_count: int + info_count: int + success_count: int + cards: list[InsightCardItem] + + def generate_trend_data(days: int, base_risk: float) -> InsightTrend: """Generate trend data for insights""" latest_date = get_latest_date() @@ -371,3 +389,135 @@ async def get_insights_demographics(): avg_risk = sum(g["risk_value"] for g in grids) / len(grids) return generate_demographics(len(grids), avg_risk) + + +@router.get("/cards", response_model=InsightCardResponse) +async def get_insights_cards(): + """ + Get formatted insight cards for the frontend Insights page. + + Returns structured cards derived from hotspots, trends, and correlations. + """ + latest_date = get_latest_date() + filepath = DATA_DIR / f"risk_{latest_date}.geojson" + + if not filepath.exists(): + raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}") + + grids = parse_geojson_file(filepath) + districts = load_districts() + + if not grids: + raise HTTPException(status_code=404, detail="No grid data found") + + 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) + + trend = generate_trend_data(7, avg_risk) + hotspots = generate_hotspots(grids, districts, 10) + correlations = generate_correlations(avg_risk, risk_variance) + summary = generate_summary(trend, hotspots, correlations) + now = datetime.now().isoformat() + + cards: list[InsightCardItem] = [] + + # Danger cards from hotspots (high risk areas) + for i, hs in enumerate(hotspots[:2]): + risk_pct = f"{hs.risk_value * 100:.1f}%" + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title=f"高风险区域: {hs.region}", + description=f"{hs.street} 区域风险值为 {risk_pct},已连续 {hs.days_in_high_risk} 天处于高风险状态。建议加强该区域监测与干预。", + type="danger", + metric="风险值", + metricValue=risk_pct, + timestamp=now, + )) + + # Warning cards from trend direction + if trend.direction == "up": + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title="风险呈上升趋势", + description=f"近{trend.period}风险水平持续上升,日均变化 {trend.avg_change:+.2f}%。需关注空气质量变化对儿童呼吸健康的影响。", + type="warning", + metric="日均变化", + metricValue=f"{trend.avg_change:+.2f}%", + timestamp=now, + )) + elif trend.direction == "down": + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title="风险呈下降趋势", + description=f"近{trend.period}风险水平持续下降,日均变化 {trend.avg_change:+.2f}%。", + type="warning", + metric="日均变化", + metricValue=f"{trend.avg_change:+.2f}%", + timestamp=now, + )) + else: + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title="风险水平保持稳定", + description=f"近{trend.period}风险水平基本稳定,日均变化 {trend.avg_change:+.2f}%。", + type="warning", + metric="日均变化", + metricValue=f"{trend.avg_change:+.2f}%", + timestamp=now, + )) + + # Additional warning-level card about general risk + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title="儿童呼吸健康需持续关注", + description=summary, + type="warning", + metric="平均风险", + metricValue=f"{avg_risk * 100:.1f}%", + timestamp=now, + )) + + # Info cards from correlations + for corr in correlations[:3]: + sign = "+" if corr.correlation > 0 else "" + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title=f"{corr.factor} 与风险相关性分析", + description=corr.description, + type="info", + metric="相关系数", + metricValue=f"{sign}{corr.correlation:.3f}", + timestamp=now, + )) + + # Success cards + if trend.direction == "down" or abs(trend.avg_change) < 0.5: + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title="风险水平稳定可控", + description="当前整体风险水平处于可控范围内,现有防控措施有效。建议继续保持监测力度。", + type="success", + timestamp=now, + )) + + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title="数据监测系统运行正常", + description=f"系统已覆盖 {len(grids)} 个网格区域,{len(districts)} 个行政区划。数据更新及时,预警机制运转良好。", + type="success", + metric="覆盖网格", + metricValue=str(len(grids)), + timestamp=now, + )) + + warning_count = sum(1 for c in cards if c.type == "warning") + info_count = sum(1 for c in cards if c.type == "info") + success_count = sum(1 for c in cards if c.type == "success") + + return InsightCardResponse( + total_insights=len(cards), + warning_count=warning_count, + info_count=info_count, + success_count=success_count, + cards=cards, + ) diff --git a/frontend/src/components/AlertMap.tsx b/frontend/src/components/AlertMap.tsx index 0c396ea..54db4fa 100644 --- a/frontend/src/components/AlertMap.tsx +++ b/frontend/src/components/AlertMap.tsx @@ -132,24 +132,20 @@ function AlertMapComponent({ } const isP1 = alert.priority === 'P1'; - const latHalf = 0.00045; - const lonHalf = 0.00052; - const rect = L.rectangle( - [ - [alert.latitude - latHalf, alert.longitude - lonHalf], - [alert.latitude + latHalf, alert.longitude + lonHalf], - ], + const marker = L.circleMarker( + [alert.latitude, alert.longitude], { + radius: isP1 ? 6 : 4, fillColor: isP1 ? '#ef4444' : '#f97316', - fillOpacity: 0.4, + fillOpacity: 0.7, color: isP1 ? '#ef4444' : '#f97316', weight: 2, dashArray: isP1 ? undefined : '4 2', } ); - rect.bindTooltip( + marker.bindTooltip( `
${alert.priority} · ${(alert.risk_value * 100).toFixed(0)}%
${alert.region || ''} ${alert.street || ''} @@ -157,11 +153,11 @@ function AlertMapComponent({ { direction: 'top', offset: [0, -5] } ); - rect.on('click', () => { + marker.on('click', () => { if (alert.grid_id) clickHandlerRef.current(alert.grid_id); }); - rect.addTo(layer); + marker.addTo(layer); } layer.addTo(map); diff --git a/frontend/src/components/LodGridLayer.tsx b/frontend/src/components/LodGridLayer.tsx index ab15014..523ef8b 100644 --- a/frontend/src/components/LodGridLayer.tsx +++ b/frontend/src/components/LodGridLayer.tsx @@ -298,6 +298,13 @@ export function LodGridLayer({ }); }; + // Debounced full redraw (avoid thrashing during rapid pan/zoom) + let redrawTimer: ReturnType | null = null; + const debouncedRedraw = () => { + if (redrawTimer) clearTimeout(redrawTimer); + redrawTimer = setTimeout(redraw, 300); + }; + // During pan: apply CSS transform to track tile movement (fixes drift) const onMove = () => { const drawn = drawnOriginRef.current; @@ -312,11 +319,11 @@ export function LodGridLayer({ canvas.style.transform = `translate(${dx}px, ${dy}px)`; }; - // On moveend/zoomend: reset transform and do full redraw + // On moveend/zoomend: reset transform and do debounced full redraw const onMoveEnd = () => { canvas.style.transform = ''; drawnOriginRef.current = null; - redraw(); + debouncedRedraw(); }; const onResize = () => redraw(); @@ -339,6 +346,7 @@ export function LodGridLayer({ map.off('resize', onResize); map.off('click', handleMapClick); if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); + if (redrawTimer) clearTimeout(redrawTimer); pane.removeChild(canvas); if (pane.parentNode) pane.parentNode.removeChild(pane); canvasRef.current = null; diff --git a/frontend/src/components/RiskMap.tsx b/frontend/src/components/RiskMap.tsx index 7ec938d..bb6230c 100644 --- a/frontend/src/components/RiskMap.tsx +++ b/frontend/src/components/RiskMap.tsx @@ -134,8 +134,8 @@ function RiskMapComponent(props: RiskMapProps) { cellSize = 0.01; step = 2; } else { - cellSize = 0.001; - step = 1; + cellSize = 0.005; + step = 2; } const bounds = map.getBounds(); @@ -151,7 +151,11 @@ function RiskMapComponent(props: RiskMapProps) { const currentGridMap = gridMap; let count = 0; - const maxCount = 3000; + const maxCount = 1500; + + if (currentGridMap.size > 5000) { + console.warn(`[RiskMap] Data too dense: ${currentGridMap.size} grid cells, rendering may be slow`); + } for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellSize * step) { for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellSize * step) { diff --git a/frontend/src/pages/AlertsDashboard.tsx b/frontend/src/pages/AlertsDashboard.tsx index 1629190..e7da6d1 100644 --- a/frontend/src/pages/AlertsDashboard.tsx +++ b/frontend/src/pages/AlertsDashboard.tsx @@ -34,7 +34,7 @@ export function AlertsDashboard() { const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all'); const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk'); const [showMap, setShowMap] = useState(true); - const [showAlertMarkers, setShowAlertMarkers] = useState(true); + const [showAlertMarkers, setShowAlertMarkers] = useState(false); 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]); @@ -59,7 +59,7 @@ export function AlertsDashboard() { }, [fetchRiskMap, fetchAlerts]); const extendedAlerts: ExtendedAlert[] = useMemo(() => { - return alerts.map((alert) => { + return (alerts || []).map((alert) => { const forecastDate = new Date(alert.forecast_time); const now = new Date(); const diffDays = Math.ceil((forecastDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); diff --git a/frontend/src/pages/Insights.tsx b/frontend/src/pages/Insights.tsx index a7cf769..1d54b41 100644 --- a/frontend/src/pages/Insights.tsx +++ b/frontend/src/pages/Insights.tsx @@ -54,28 +54,28 @@ export function Insights() { ? [ { label: '总洞察数', - value: insights.total_insights, + value: insights.total_insights || 0, icon: Lightbulb, color: 'text-primary', bg: 'bg-primary-muted', }, { label: '预警', - value: insights.warning_count + ((insights as any).danger_count || 0), + value: (insights.warning_count || 0) + ((insights as any).danger_count || 0), icon: AlertTriangle, color: 'text-warning', bg: 'bg-warning-light', }, { label: '正常', - value: insights.success_count, + value: insights.success_count || 0, icon: CheckCircle, color: 'text-success', bg: 'bg-success-light', }, { label: '信息', - value: insights.info_count, + value: insights.info_count || 0, icon: Info, color: 'text-primary', bg: 'bg-primary-muted', @@ -130,7 +130,7 @@ export function Insights() { {insights && (
- {insights.cards.map((card) => { + {(insights.cards || []).map((card) => { const config = TYPE_CONFIG[card.type]; const Icon = config.icon; return ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 89deb4f..ec4b0c1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -198,6 +198,7 @@ export const analysisApi = { export const insightsApi = { getOverview: (): Promise => cachedGet('/insights/overview'), + getCards: (): Promise => cachedGet('/insights/cards'), }; export default api; diff --git a/frontend/src/stores/analysisStore.ts b/frontend/src/stores/analysisStore.ts index 6f04d6a..37099e7 100644 --- a/frontend/src/stores/analysisStore.ts +++ b/frontend/src/stores/analysisStore.ts @@ -107,7 +107,7 @@ export const useAnalysisStore = create((set, get) => ({ fetchInsights: async () => { set({ isLoading: true, error: null }); try { - const data = await insightsApi.getOverview(); + const data = await insightsApi.getCards(); set({ insights: data, isLoading: false }); } catch (e) { if (isCancelError(e)) return; diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 49bc317..c3617a1 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -191,7 +191,7 @@ export const useMonitoringStore = create((set) => ({ try { const data = await gridApi.getGridsGeoJSON(date); - const features: GridFeature[] = data.features.map((f: any) => ({ + const features: GridFeature[] = (data.features || []).map((f: any) => ({ grid_id: f.properties.grid_id, latitude: f.properties.latitude, longitude: f.properties.longitude,