feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
LineChart,
|
|
|
|
|
Line,
|
|
|
|
|
XAxis,
|
|
|
|
|
YAxis,
|
|
|
|
|
CartesianGrid,
|
|
|
|
|
Tooltip,
|
|
|
|
|
Legend,
|
|
|
|
|
ResponsiveContainer,
|
|
|
|
|
AreaChart,
|
|
|
|
|
Area,
|
|
|
|
|
} from 'recharts';
|
|
|
|
|
import { useAnalysisStore } from '@/stores/analysisStore';
|
|
|
|
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
|
|
|
|
import { TrendingUp, Calendar, Activity } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
const POLLUTANT_OPTIONS = [
|
|
|
|
|
{ key: 'aqi', label: 'AQI', color: '#2563EB', unit: '' },
|
|
|
|
|
{ key: 'pm25', label: 'PM2.5', color: '#DC2626', unit: 'μg/m³' },
|
|
|
|
|
{ key: 'pm10', label: 'PM10', color: '#D97706', unit: 'μg/m³' },
|
|
|
|
|
{ key: 'so2', label: 'SO₂', color: '#7C3AED', unit: 'μg/m³' },
|
|
|
|
|
{ key: 'no2', label: 'NO₂', color: '#059669', unit: 'μg/m³' },
|
|
|
|
|
{ key: 'co', label: 'CO', color: '#0891B2', unit: 'mg/m³' },
|
|
|
|
|
{ key: 'o3', label: 'O₃', color: '#EA580C', unit: 'μg/m³' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const DAY_OPTIONS = [
|
|
|
|
|
{ label: '7天', value: 7 },
|
|
|
|
|
{ label: '14天', value: 14 },
|
|
|
|
|
{ label: '30天', value: 30 },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
export function TrendAnalysis() {
|
feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization
Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages
Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module
Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter
Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
2026-06-08 18:40:08 +08:00
|
|
|
const trendData = useAnalysisStore((s) => s.trendData);
|
|
|
|
|
const isLoading = useAnalysisStore((s) => s.isLoading);
|
|
|
|
|
const error = useAnalysisStore((s) => s.error);
|
|
|
|
|
const clearError = useAnalysisStore((s) => s.clearError);
|
|
|
|
|
const selectedDays = useAnalysisStore((s) => s.selectedDays);
|
|
|
|
|
const setSelectedDays = useAnalysisStore((s) => s.setSelectedDays);
|
|
|
|
|
const fetchTrend = useAnalysisStore((s) => s.fetchTrend);
|
feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchTrend(selectedDays);
|
|
|
|
|
}, [selectedDays, fetchTrend]);
|
|
|
|
|
|
|
|
|
|
const togglePollutant = (key: string) => {
|
|
|
|
|
setSelectedPollutants((prev) =>
|
|
|
|
|
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
|
|
|
const d = new Date(dateStr);
|
|
|
|
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const latestData = trendData[trendData.length - 1];
|
|
|
|
|
const firstData = trendData[0];
|
|
|
|
|
|
|
|
|
|
const getChange = (key: string) => {
|
|
|
|
|
if (!latestData || !firstData) return 0;
|
|
|
|
|
const latest = latestData[key as keyof typeof latestData] as number;
|
|
|
|
|
const first = firstData[key as keyof typeof firstData] as number;
|
|
|
|
|
if (!first) return 0;
|
|
|
|
|
return ((latest - first) / first) * 100;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
{error && (
|
|
|
|
|
<ErrorBanner
|
|
|
|
|
error={error}
|
|
|
|
|
onRetry={() => { clearError(); fetchTrend(selectedDays); }}
|
|
|
|
|
onDismiss={clearError}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<div className="mb-5">
|
|
|
|
|
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
|
|
|
|
<TrendingUp className="w-5 h-5 text-primary" />
|
|
|
|
|
趋势分析
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-[12px] text-text-muted">
|
|
|
|
|
空气质量与污染物浓度时间序列分析
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-4 mb-4">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Calendar className="w-4 h-4 text-text-muted" />
|
|
|
|
|
<span className="text-[13px] text-text-secondary">时间范围:</span>
|
|
|
|
|
<div className="flex gap-1 bg-bg-page p-0.5 rounded">
|
|
|
|
|
{DAY_OPTIONS.map((opt) => (
|
|
|
|
|
<button
|
|
|
|
|
key={opt.value}
|
|
|
|
|
onClick={() => setSelectedDays(opt.value)}
|
|
|
|
|
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
|
|
|
|
selectedDays === opt.value
|
|
|
|
|
? 'bg-bg-card text-primary shadow-sm'
|
|
|
|
|
: 'text-text-secondary hover:text-text-primary'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
|
|
|
<Activity className="w-4 h-4 text-text-muted" />
|
|
|
|
|
<span className="text-[13px] text-text-secondary">指标选择:</span>
|
|
|
|
|
{POLLUTANT_OPTIONS.map((p) => (
|
|
|
|
|
<button
|
|
|
|
|
key={p.key}
|
|
|
|
|
onClick={() => togglePollutant(p.key)}
|
|
|
|
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[12px] font-medium transition-all ${
|
|
|
|
|
selectedPollutants.includes(p.key)
|
|
|
|
|
? 'bg-bg-active text-text-primary'
|
|
|
|
|
: 'bg-bg-page text-text-muted hover:text-text-secondary'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className="w-2.5 h-2.5 rounded-full"
|
|
|
|
|
style={{ backgroundColor: p.color }}
|
|
|
|
|
/>
|
|
|
|
|
{p.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isLoading && (
|
|
|
|
|
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
|
|
|
|
|
<span className="text-text-secondary">数据加载中...</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="card p-4 mb-4">
|
|
|
|
|
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
|
|
|
|
污染物浓度趋势
|
|
|
|
|
</div>
|
|
|
|
|
<ResponsiveContainer width="100%" height={360}>
|
|
|
|
|
<LineChart data={trendData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
|
|
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
|
|
|
|
<XAxis
|
|
|
|
|
dataKey="date"
|
|
|
|
|
tickFormatter={formatDate}
|
|
|
|
|
tick={{ fontSize: 12, fill: '#64748B' }}
|
|
|
|
|
axisLine={{ stroke: '#E2E8F0' }}
|
|
|
|
|
/>
|
|
|
|
|
<YAxis
|
|
|
|
|
tick={{ fontSize: 12, fill: '#64748B' }}
|
|
|
|
|
axisLine={{ stroke: '#E2E8F0' }}
|
|
|
|
|
/>
|
|
|
|
|
<Tooltip
|
|
|
|
|
contentStyle={{
|
|
|
|
|
backgroundColor: '#FFFFFF',
|
|
|
|
|
border: '1px solid #E2E8F0',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
}}
|
|
|
|
|
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
|
|
|
|
/>
|
|
|
|
|
<Legend
|
|
|
|
|
wrapperStyle={{ fontSize: '12px', paddingTop: '12px' }}
|
|
|
|
|
/>
|
|
|
|
|
{POLLUTANT_OPTIONS.filter((p) => selectedPollutants.includes(p.key)).map(
|
|
|
|
|
(p) => (
|
|
|
|
|
<Line
|
|
|
|
|
key={p.key}
|
|
|
|
|
type="monotone"
|
|
|
|
|
dataKey={p.key}
|
|
|
|
|
name={p.label}
|
|
|
|
|
stroke={p.color}
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
dot={{ r: 3, fill: p.color }}
|
|
|
|
|
activeDot={{ r: 5 }}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
</LineChart>
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{selectedPollutants.includes('aqi') && (
|
|
|
|
|
<div className="card p-4 mb-4">
|
|
|
|
|
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
|
|
|
|
AQI 变化趋势
|
|
|
|
|
</div>
|
|
|
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
|
|
|
<AreaChart data={trendData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
|
|
|
|
<defs>
|
|
|
|
|
<linearGradient id="aqiGradient" x1="0" y1="0" x2="0" y2="1">
|
|
|
|
|
<stop offset="5%" stopColor="#2563EB" stopOpacity={0.3} />
|
|
|
|
|
<stop offset="95%" stopColor="#2563EB" stopOpacity={0.05} />
|
|
|
|
|
</linearGradient>
|
|
|
|
|
</defs>
|
|
|
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
|
|
|
|
<XAxis
|
|
|
|
|
dataKey="date"
|
|
|
|
|
tickFormatter={formatDate}
|
|
|
|
|
tick={{ fontSize: 12, fill: '#64748B' }}
|
|
|
|
|
axisLine={{ stroke: '#E2E8F0' }}
|
|
|
|
|
/>
|
|
|
|
|
<YAxis
|
|
|
|
|
tick={{ fontSize: 12, fill: '#64748B' }}
|
|
|
|
|
axisLine={{ stroke: '#E2E8F0' }}
|
|
|
|
|
/>
|
|
|
|
|
<Tooltip
|
|
|
|
|
contentStyle={{
|
|
|
|
|
backgroundColor: '#FFFFFF',
|
|
|
|
|
border: '1px solid #E2E8F0',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<Area
|
|
|
|
|
type="monotone"
|
|
|
|
|
dataKey="aqi"
|
|
|
|
|
name="AQI"
|
|
|
|
|
stroke="#2563EB"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
fill="url(#aqiGradient)"
|
|
|
|
|
dot={{ r: 3, fill: '#2563EB' }}
|
|
|
|
|
/>
|
|
|
|
|
</AreaChart>
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{latestData && (
|
|
|
|
|
<div className="grid grid-cols-4 gap-4">
|
|
|
|
|
{POLLUTANT_OPTIONS.filter((p) => selectedPollutants.includes(p.key)).slice(0, 4).map((p) => {
|
|
|
|
|
const value = latestData[p.key as keyof typeof latestData] as number;
|
|
|
|
|
const change = getChange(p.key);
|
|
|
|
|
return (
|
|
|
|
|
<div key={p.key} className="card p-4">
|
|
|
|
|
<div className="flex items-center gap-2 mb-2">
|
|
|
|
|
<span
|
|
|
|
|
className="w-2.5 h-2.5 rounded-full"
|
|
|
|
|
style={{ backgroundColor: p.color }}
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
|
|
|
|
|
{p.label}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="font-display text-[24px] font-bold text-text-primary mb-1">
|
|
|
|
|
{typeof value === 'number' ? value.toFixed(p.key === 'co' ? 1 : 0) : value}
|
|
|
|
|
<span className="text-[12px] font-normal text-text-muted ml-1">
|
|
|
|
|
{p.unit}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className={`text-[11px] font-medium ${
|
|
|
|
|
change > 0 ? 'text-danger' : change < 0 ? 'text-success' : 'text-text-muted'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{change > 0 ? '↑' : change < 0 ? '↓' : '→'} {Math.abs(change).toFixed(1)}%
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|