Files
CA/frontend/src/pages/DemographicAnalysis.tsx

367 lines
13 KiB
TypeScript
Raw Normal View History

import { Fragment, useEffect, useState, useMemo, useCallback } from 'react';
import {
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Users, Activity } from 'lucide-react';
import { caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types';
// --- Chart 3 helpers ---
const AGE_GROUPS = ['0-1', '1-3', '3-6', '6-12', '12-18'];
function buildHeatmapMatrix(data: AgeDiagnosisMatrixItem[]): {
diagnoses: string[];
matrix: number[][];
totals: number[];
} {
// Pivot: count per age_group x diagnosis
const map: Record<string, Record<string, number>> = {};
for (const ag of AGE_GROUPS) {
map[ag] = {};
}
for (const item of data) {
if (map[item.age_group] !== undefined) {
map[item.age_group][item.diagnosis] = item.inpatient;
}
}
// Collect all diagnoses and their total counts
const diagTotals: Record<string, number> = {};
for (const ag of AGE_GROUPS) {
for (const [diag, count] of Object.entries(map[ag])) {
diagTotals[diag] = (diagTotals[diag] || 0) + count;
}
}
// Top 8 diagnoses by total count
const top8 = Object.entries(diagTotals)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([diag]) => diag);
const matrix = AGE_GROUPS.map((ag) => top8.map((diag) => map[ag][diag] || 0));
const totals = top8.map((diag) => diagTotals[diag]);
return { diagnoses: top8, matrix, totals };
}
function getColorClass(value: number, maxValue: number): string {
if (maxValue === 0) return 'bg-blue-50 text-gray-800';
const ratio = value / maxValue;
if (ratio === 0) return 'bg-blue-50 text-gray-800';
if (ratio <= 0.125) return 'bg-blue-100 text-gray-800';
if (ratio <= 0.25) return 'bg-blue-200 text-gray-800';
if (ratio <= 0.375) return 'bg-blue-300 text-gray-800';
if (ratio <= 0.5) return 'bg-blue-400 text-white';
if (ratio <= 0.625) return 'bg-blue-500 text-white';
if (ratio <= 0.75) return 'bg-blue-600 text-white';
if (ratio <= 0.875) return 'bg-blue-700 text-white';
return 'bg-blue-800 text-white';
}
export function DemographicAnalysis() {
const [data, setData] = useState<DemographicsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await caseApi.getDemographics();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
let cancelled = false;
const run = async () => {
setIsLoading(true);
setError(null);
try {
const result = await caseApi.getDemographics();
if (!cancelled) setData(result);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
if (!cancelled) setIsLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, []);
// --- Derived data ---
const ageData: AgeBin[] = useMemo(() => {
if (!data) return [];
return data.age_distribution.map((d) => ({
age_bin: d.age_bin,
outpatient: d.outpatient,
inpatient: d.inpatient,
}));
}, [data]);
const genderData = useMemo(() => {
if (!data) return [];
const male = data.gender_split.male.inpatient;
const female = data.gender_split.female.inpatient;
return [
{ name: '男性', value: male, color: '#3B82F6' },
{ name: '女性', value: female, color: '#EC4899' },
];
}, [data]);
const genderTotal = useMemo(() => {
return genderData.reduce((s, d) => s + d.value, 0);
}, [genderData]);
const heatmapData = useMemo(() => {
if (!data) return { diagnoses: [], matrix: [], totals: [] };
return buildHeatmapMatrix(data.age_diagnosis_matrix);
}, [data]);
const heatmapMax = useMemo(() => {
let max = 0;
for (const row of heatmapData.matrix) {
for (const v of row) {
if (v > max) max = v;
}
}
return max;
}, [heatmapData]);
// --- Loading state ---
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
const isEmpty =
!data ||
(ageData.length === 0 &&
genderData.length === 0 &&
heatmapData.matrix.length === 0);
return (
<div className="flex flex-col h-full overflow-auto">
{error && (
<div className="px-6 pt-4">
<ErrorBanner
error={error}
onRetry={fetchData}
onDismiss={() => setError(null)}
/>
</div>
)}
<div className="p-6 space-y-6">
{/* Page header */}
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-gray-500"></p>
</div>
{isEmpty ? (
<div className="card p-8 text-center text-gray-400 text-sm">
<Activity className="w-8 h-8 mx-auto mb-3 opacity-30" />
</div>
) : (
<>
{/* Chart 1: Age Distribution Histogram */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1">
</div>
<p className="text-[11px] text-gray-400 mb-4">
</p>
{ageData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={ageData}
margin={{ top: 5, right: 10, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="age_bin"
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{ value: '年龄(岁)', position: 'insideBottom', offset: -5, fontSize: 11, fill: '#64748B' }}
/>
<YAxis
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '住院病例']}
labelFormatter={(label: number) => `${label}`}
/>
<Bar dataKey="inpatient" fill="#3B82F6" name="住院" barSize={28} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Chart 2: Gender Distribution Donut */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
</div>
{genderData.length > 0 && genderTotal > 0 ? (
<div className="relative">
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={genderData}
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={90}
paddingAngle={3}
dataKey="value"
nameKey="name"
>
{genderData.map((entry, idx) => (
<Cell key={idx} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [
`${value.toLocaleString()} (${((value / genderTotal) * 100).toFixed(1)}%)`,
name,
]}
/>
</PieChart>
</ResponsiveContainer>
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<div className="text-[24px] font-semibold text-gray-800">
{genderTotal.toLocaleString()}
</div>
<div className="text-[11px] text-gray-500"></div>
</div>
</div>
{/* Legend below */}
<div className="flex justify-center gap-6 mt-2">
{genderData.map((d) => (
<div key={d.name} className="flex items-center gap-2 text-[13px] text-gray-700">
<span
className="w-3 h-3 rounded-full inline-block"
style={{ backgroundColor: d.color }}
/>
{d.name}: {d.value.toLocaleString()} ({((d.value / genderTotal) * 100).toFixed(1)}%)
</div>
))}
</div>
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Placeholder for future chart or spacing */}
<div className="hidden lg:block" />
</div>
{/* Chart 3: Age x Diagnosis Heatmap */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1">
×
</div>
<p className="text-[11px] text-gray-400 mb-4">
8
</p>
{heatmapData.matrix.length > 0 && heatmapData.diagnoses.length > 0 ? (
<div className="overflow-x-auto">
<div
className="grid gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden"
style={{
gridTemplateColumns: `80px repeat(${heatmapData.diagnoses.length}, minmax(60px, 1fr))`,
}}
>
{/* Header row */}
<div className="bg-gray-100 px-2 py-2 text-[11px] font-medium text-gray-600">
</div>
{heatmapData.diagnoses.map((diag) => (
<div
key={diag}
className="bg-gray-100 px-2 py-2 text-[11px] font-medium text-gray-600 text-center"
title={diag}
>
{diag.length > 6 ? `${diag.slice(0, 6)}` : diag}
</div>
))}
{/* Data rows */}
{AGE_GROUPS.map((ag, rowIdx) => (
<Fragment key={ag}>
<div
key={`label-${ag}`}
className="bg-white px-2 py-2 text-[12px] text-gray-700 font-medium flex items-center"
>
{ag}
</div>
{heatmapData.matrix[rowIdx].map((value, colIdx) => (
<div
key={`${ag}-${colIdx}`}
className={`${getColorClass(value, heatmapMax)} px-2 py-2 text-center text-[12px] font-medium transition-colors cursor-default`}
title={`${ag}岁 | ${heatmapData.diagnoses[colIdx]}: ${value}`}
>
{value > 0 ? value.toLocaleString() : '-'}
</div>
))}
</Fragment>
))}
</div>
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</>
)}
</div>
</div>
);
}