367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
|
|
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>
|
|||
|
|
);
|
|||
|
|
}
|