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.
This commit is contained in:
262
frontend/src/pages/TrendAnalysis.tsx
Normal file
262
frontend/src/pages/TrendAnalysis.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
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() {
|
||||
const { trendData, isLoading, error, clearError, selectedDays, setSelectedDays, fetchTrend } = useAnalysisStore();
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user