Files
CA/frontend/src/components/StatisticalCharts.tsx

226 lines
7.3 KiB
TypeScript
Raw Normal View History

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>
);
}