fix: insights crash, dup grids, .map() guards
Add /api/insights/cards endpoint with proper card format matching frontend expectations. Fixes "Cannot read properties of undefined (reading 'map')" crash. Switch frontend to use new endpoint. Replace alert marker rectangles with circleMarkers so they don't look like a second grid. Default showAlertMarkers to false. Add || [] guards on data.features.map() and alerts.map(). Reduce RiskMap grid count 3000→1500, debounce 150ms→300ms.
This commit is contained in:
@@ -8,7 +8,7 @@ from typing import List, Literal
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Literal
|
||||||
|
|
||||||
from config import DATA_DIR, RISK_HIGH
|
from config import DATA_DIR, RISK_HIGH
|
||||||
from models import (
|
from models import (
|
||||||
@@ -27,6 +27,24 @@ from utils.risk import calculate_trend as calculate_trend_direction
|
|||||||
router = APIRouter(prefix="/api/insights", tags=["insights"])
|
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:
|
def generate_trend_data(days: int, base_risk: float) -> InsightTrend:
|
||||||
"""Generate trend data for insights"""
|
"""Generate trend data for insights"""
|
||||||
latest_date = get_latest_date()
|
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)
|
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
|
||||||
|
|
||||||
return generate_demographics(len(grids), avg_risk)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -132,24 +132,20 @@ function AlertMapComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isP1 = alert.priority === 'P1';
|
const isP1 = alert.priority === 'P1';
|
||||||
const latHalf = 0.00045;
|
|
||||||
const lonHalf = 0.00052;
|
|
||||||
|
|
||||||
const rect = L.rectangle(
|
const marker = L.circleMarker(
|
||||||
[
|
[alert.latitude, alert.longitude],
|
||||||
[alert.latitude - latHalf, alert.longitude - lonHalf],
|
|
||||||
[alert.latitude + latHalf, alert.longitude + lonHalf],
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
|
radius: isP1 ? 6 : 4,
|
||||||
fillColor: isP1 ? '#ef4444' : '#f97316',
|
fillColor: isP1 ? '#ef4444' : '#f97316',
|
||||||
fillOpacity: 0.4,
|
fillOpacity: 0.7,
|
||||||
color: isP1 ? '#ef4444' : '#f97316',
|
color: isP1 ? '#ef4444' : '#f97316',
|
||||||
weight: 2,
|
weight: 2,
|
||||||
dashArray: isP1 ? undefined : '4 2',
|
dashArray: isP1 ? undefined : '4 2',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
rect.bindTooltip(
|
marker.bindTooltip(
|
||||||
`<div style="font-size:12px;">
|
`<div style="font-size:12px;">
|
||||||
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
|
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
|
||||||
${alert.region || ''} ${alert.street || ''}
|
${alert.region || ''} ${alert.street || ''}
|
||||||
@@ -157,11 +153,11 @@ function AlertMapComponent({
|
|||||||
{ direction: 'top', offset: [0, -5] }
|
{ direction: 'top', offset: [0, -5] }
|
||||||
);
|
);
|
||||||
|
|
||||||
rect.on('click', () => {
|
marker.on('click', () => {
|
||||||
if (alert.grid_id) clickHandlerRef.current(alert.grid_id);
|
if (alert.grid_id) clickHandlerRef.current(alert.grid_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
rect.addTo(layer);
|
marker.addTo(layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
layer.addTo(map);
|
layer.addTo(map);
|
||||||
|
|||||||
@@ -298,6 +298,13 @@ export function LodGridLayer({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Debounced full redraw (avoid thrashing during rapid pan/zoom)
|
||||||
|
let redrawTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const debouncedRedraw = () => {
|
||||||
|
if (redrawTimer) clearTimeout(redrawTimer);
|
||||||
|
redrawTimer = setTimeout(redraw, 300);
|
||||||
|
};
|
||||||
|
|
||||||
// During pan: apply CSS transform to track tile movement (fixes drift)
|
// During pan: apply CSS transform to track tile movement (fixes drift)
|
||||||
const onMove = () => {
|
const onMove = () => {
|
||||||
const drawn = drawnOriginRef.current;
|
const drawn = drawnOriginRef.current;
|
||||||
@@ -312,11 +319,11 @@ export function LodGridLayer({
|
|||||||
canvas.style.transform = `translate(${dx}px, ${dy}px)`;
|
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 = () => {
|
const onMoveEnd = () => {
|
||||||
canvas.style.transform = '';
|
canvas.style.transform = '';
|
||||||
drawnOriginRef.current = null;
|
drawnOriginRef.current = null;
|
||||||
redraw();
|
debouncedRedraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onResize = () => redraw();
|
const onResize = () => redraw();
|
||||||
@@ -339,6 +346,7 @@ export function LodGridLayer({
|
|||||||
map.off('resize', onResize);
|
map.off('resize', onResize);
|
||||||
map.off('click', handleMapClick);
|
map.off('click', handleMapClick);
|
||||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||||
|
if (redrawTimer) clearTimeout(redrawTimer);
|
||||||
pane.removeChild(canvas);
|
pane.removeChild(canvas);
|
||||||
if (pane.parentNode) pane.parentNode.removeChild(pane);
|
if (pane.parentNode) pane.parentNode.removeChild(pane);
|
||||||
canvasRef.current = null;
|
canvasRef.current = null;
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ function RiskMapComponent(props: RiskMapProps) {
|
|||||||
cellSize = 0.01;
|
cellSize = 0.01;
|
||||||
step = 2;
|
step = 2;
|
||||||
} else {
|
} else {
|
||||||
cellSize = 0.001;
|
cellSize = 0.005;
|
||||||
step = 1;
|
step = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bounds = map.getBounds();
|
const bounds = map.getBounds();
|
||||||
@@ -151,7 +151,11 @@ function RiskMapComponent(props: RiskMapProps) {
|
|||||||
const currentGridMap = gridMap;
|
const currentGridMap = gridMap;
|
||||||
|
|
||||||
let count = 0;
|
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 lat = latStart; lat < maxLat && count < maxCount; lat += cellSize * step) {
|
||||||
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellSize * step) {
|
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellSize * step) {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function AlertsDashboard() {
|
|||||||
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
|
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
|
||||||
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
|
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
|
||||||
const [showMap, setShowMap] = useState(true);
|
const [showMap, setShowMap] = useState(true);
|
||||||
const [showAlertMarkers, setShowAlertMarkers] = useState(true);
|
const [showAlertMarkers, setShowAlertMarkers] = useState(false);
|
||||||
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 [debouncedRiskRange, setDebouncedRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
||||||
@@ -59,7 +59,7 @@ export function AlertsDashboard() {
|
|||||||
}, [fetchRiskMap, fetchAlerts]);
|
}, [fetchRiskMap, fetchAlerts]);
|
||||||
|
|
||||||
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
|
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
|
||||||
return alerts.map((alert) => {
|
return (alerts || []).map((alert) => {
|
||||||
const forecastDate = new Date(alert.forecast_time);
|
const forecastDate = new Date(alert.forecast_time);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffDays = Math.ceil((forecastDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil((forecastDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|||||||
@@ -54,28 +54,28 @@ export function Insights() {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: '总洞察数',
|
label: '总洞察数',
|
||||||
value: insights.total_insights,
|
value: insights.total_insights || 0,
|
||||||
icon: Lightbulb,
|
icon: Lightbulb,
|
||||||
color: 'text-primary',
|
color: 'text-primary',
|
||||||
bg: 'bg-primary-muted',
|
bg: 'bg-primary-muted',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '预警',
|
label: '预警',
|
||||||
value: insights.warning_count + ((insights as any).danger_count || 0),
|
value: (insights.warning_count || 0) + ((insights as any).danger_count || 0),
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
color: 'text-warning',
|
color: 'text-warning',
|
||||||
bg: 'bg-warning-light',
|
bg: 'bg-warning-light',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '正常',
|
label: '正常',
|
||||||
value: insights.success_count,
|
value: insights.success_count || 0,
|
||||||
icon: CheckCircle,
|
icon: CheckCircle,
|
||||||
color: 'text-success',
|
color: 'text-success',
|
||||||
bg: 'bg-success-light',
|
bg: 'bg-success-light',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '信息',
|
label: '信息',
|
||||||
value: insights.info_count,
|
value: insights.info_count || 0,
|
||||||
icon: Info,
|
icon: Info,
|
||||||
color: 'text-primary',
|
color: 'text-primary',
|
||||||
bg: 'bg-primary-muted',
|
bg: 'bg-primary-muted',
|
||||||
@@ -130,7 +130,7 @@ export function Insights() {
|
|||||||
|
|
||||||
{insights && (
|
{insights && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{insights.cards.map((card) => {
|
{(insights.cards || []).map((card) => {
|
||||||
const config = TYPE_CONFIG[card.type];
|
const config = TYPE_CONFIG[card.type];
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export const analysisApi = {
|
|||||||
|
|
||||||
export const insightsApi = {
|
export const insightsApi = {
|
||||||
getOverview: (): Promise<any> => cachedGet('/insights/overview'),
|
getOverview: (): Promise<any> => cachedGet('/insights/overview'),
|
||||||
|
getCards: (): Promise<any> => cachedGet('/insights/cards'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const useAnalysisStore = create<AnalysisState>((set, get) => ({
|
|||||||
fetchInsights: async () => {
|
fetchInsights: async () => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const data = await insightsApi.getOverview();
|
const data = await insightsApi.getCards();
|
||||||
set({ insights: data, isLoading: false });
|
set({ insights: data, isLoading: false });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isCancelError(e)) return;
|
if (isCancelError(e)) return;
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export const useMonitoringStore = create<MonitoringState>((set) => ({
|
|||||||
try {
|
try {
|
||||||
const data = await gridApi.getGridsGeoJSON(date);
|
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,
|
grid_id: f.properties.grid_id,
|
||||||
latitude: f.properties.latitude,
|
latitude: f.properties.latitude,
|
||||||
longitude: f.properties.longitude,
|
longitude: f.properties.longitude,
|
||||||
|
|||||||
Reference in New Issue
Block a user