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,