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.
226 lines
7.3 KiB
TypeScript
226 lines
7.3 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { TrendingUp, Activity } from 'lucide-react';
|
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
|
|
|
interface StatisticalChartsProps {
|
|
data: Array<{
|
|
date: string;
|
|
cases: number;
|
|
risk?: number;
|
|
aqi?: number;
|
|
}>;
|
|
height?: number;
|
|
showCases?: boolean;
|
|
showRisk?: boolean;
|
|
showAQI?: boolean;
|
|
}
|
|
|
|
export function StatisticalCharts({
|
|
data,
|
|
height = 300,
|
|
showCases = true,
|
|
showRisk = false,
|
|
showAQI = false,
|
|
}: StatisticalChartsProps) {
|
|
const [activeChart, setActiveChart] = useState<'cases' | 'risk' | 'aqi'>('cases');
|
|
|
|
const chartData = useMemo(() => {
|
|
return data.map((item) => ({
|
|
...item,
|
|
date: new Date(item.date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }),
|
|
}));
|
|
}, [data]);
|
|
|
|
const calculateTrend = (values: number[]) => {
|
|
if (values.length < 2) return 'stable';
|
|
|
|
const firstHalf = values.slice(0, Math.floor(values.length / 2));
|
|
const secondHalf = values.slice(Math.floor(values.length / 2));
|
|
|
|
const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
|
|
const secondAvg = secondHalf.reduce((a, b) => a + b) / secondHalf.length;
|
|
|
|
const change = ((secondAvg - firstAvg) / firstAvg) * 100;
|
|
|
|
if (change > 10) return 'up';
|
|
if (change < -10) return 'down';
|
|
return 'stable';
|
|
};
|
|
|
|
const stats = useMemo(() => {
|
|
if (data.length === 0) return null;
|
|
|
|
const totalCases = data.reduce((sum, item) => sum + item.cases, 0);
|
|
const avgCases = totalCases / data.length;
|
|
const maxCases = Math.max(...data.map((item) => item.cases));
|
|
const trend = calculateTrend(data.map((item) => item.cases));
|
|
|
|
return {
|
|
totalCases,
|
|
avgCases: Math.round(avgCases),
|
|
maxCases,
|
|
trend,
|
|
};
|
|
}, [data]);
|
|
|
|
const getTrendIcon = () => {
|
|
if (!stats) return null;
|
|
|
|
switch (stats.trend) {
|
|
case 'up':
|
|
return <TrendingUp className="w-5 h-5 text-red-500" />;
|
|
case 'down':
|
|
return <TrendingUp className="w-5 h-5 text-green-500 rotate-180" />;
|
|
default:
|
|
return <Activity className="w-5 h-5 text-gray-500" />;
|
|
}
|
|
};
|
|
|
|
const getTrendLabel = () => {
|
|
if (!stats) return '';
|
|
|
|
switch (stats.trend) {
|
|
case 'up':
|
|
return '上升趋势';
|
|
case 'down':
|
|
return '下降趋势';
|
|
default:
|
|
return '平稳';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-lg font-semibold text-gray-900">统计图表</h3>
|
|
{getTrendIcon()}
|
|
<span className={`text-sm font-medium ${
|
|
stats?.trend === 'up' ? 'text-red-600' :
|
|
stats?.trend === 'down' ? 'text-green-600' :
|
|
'text-gray-600'
|
|
}`}>
|
|
{getTrendLabel()}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{showCases && (
|
|
<button
|
|
onClick={() => setActiveChart('cases')}
|
|
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
|
activeChart === 'cases'
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
病例数
|
|
</button>
|
|
)}
|
|
{showRisk && (
|
|
<button
|
|
onClick={() => setActiveChart('risk')}
|
|
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
|
activeChart === 'risk'
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
风险指数
|
|
</button>
|
|
)}
|
|
{showAQI && (
|
|
<button
|
|
onClick={() => setActiveChart('aqi')}
|
|
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
|
activeChart === 'aqi'
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
AQI
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats cards */}
|
|
{stats && activeChart === 'cases' && (
|
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
<div className="bg-blue-50 rounded-lg p-3">
|
|
<div className="text-sm text-gray-600">总病例数</div>
|
|
<div className="text-2xl font-bold text-blue-600">{stats.totalCases}</div>
|
|
</div>
|
|
<div className="bg-green-50 rounded-lg p-3">
|
|
<div className="text-sm text-gray-600">日均病例</div>
|
|
<div className="text-2xl font-bold text-green-600">{stats.avgCases}</div>
|
|
</div>
|
|
<div className="bg-purple-50 rounded-lg p-3">
|
|
<div className="text-sm text-gray-600">峰值病例</div>
|
|
<div className="text-2xl font-bold text-purple-600">{stats.maxCases}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Chart */}
|
|
<div style={{ height }}>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={chartData}>
|
|
<defs>
|
|
<linearGradient id="colorCases" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorRisk" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorAQI" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
|
<XAxis
|
|
dataKey="date"
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(value) => Math.round(value).toString()}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: 'white',
|
|
border: '1px solid #e5e7eb',
|
|
borderRadius: '8px',
|
|
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
|
}}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey={activeChart === 'cases' ? 'cases' : activeChart === 'risk' ? 'risk' : 'aqi'}
|
|
stroke={
|
|
activeChart === 'cases' ? '#3b82f6' :
|
|
activeChart === 'risk' ? '#ef4444' :
|
|
'#f59e0b'
|
|
}
|
|
fill={
|
|
activeChart === 'cases' ? 'url(#colorCases)' :
|
|
activeChart === 'risk' ? 'url(#colorRisk)' :
|
|
'url(#colorAQI)'
|
|
}
|
|
strokeWidth={2}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|