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 { LoadingState } from '@/components/ui'; import { TESTIDS } from '@/utils/testids'; 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> = {}; 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 = {}; 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(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(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 --- // 防御性归一化:后端返回意外形状(缺字段/类型不符)时降级为空,避免 .map 抛错 // 冒泡到根 ErrorBoundary 把整页白屏(与 DiseaseAnalysis/EnvironmentalHealth 的处理一致)。 const ageData: AgeBin[] = useMemo(() => { const bins = Array.isArray(data?.age_distribution) ? data!.age_distribution : []; return bins.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 ?? 0; const female = data.gender_split?.female?.inpatient ?? 0; 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(() => { const matrix = Array.isArray(data?.age_diagnosis_matrix) ? data!.age_diagnosis_matrix : []; return buildHeatmapMatrix(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 ; } const isEmpty = !data || (ageData.length === 0 && genderData.length === 0 && heatmapData.matrix.length === 0); return (
{error && (
setError(null)} />
)}
{/* Page header */}

人群分析

住院患者年龄、性别与诊断分布分析

{isEmpty ? (
暂无人口统计数据
) : ( <> {/* Chart 1: Age Distribution Histogram */}
年龄分布

仅住院数据(门诊数据无人口统计信息)

{ageData.length > 0 ? ( [value.toLocaleString(), '住院病例']} labelFormatter={(label: number) => `${label} 岁`} /> ) : (
暂无数据
)}
{/* Chart 2: Gender Distribution Donut */}
性别分布
{genderData.length > 0 && genderTotal > 0 ? (
{genderData.map((entry, idx) => ( ))} [ `${value.toLocaleString()} (${((value / genderTotal) * 100).toFixed(1)}%)`, name, ]} /> {/* Center label */}
{genderTotal.toLocaleString()}
总计
{/* Legend below */}
{genderData.map((d) => (
{d.name}: {d.value.toLocaleString()} ({((d.value / genderTotal) * 100).toFixed(1)}%)
))}
) : (
暂无数据
)}
{/* Placeholder for future chart or spacing */}
{/* Chart 3: Age x Diagnosis Heatmap */}
年龄 × 诊断热力图

仅住院数据 — 显示各年龄段最常见的8种诊断

{heatmapData.matrix.length > 0 && heatmapData.diagnoses.length > 0 ? (
{/* Header row */}
年龄
{heatmapData.diagnoses.map((diag) => (
{diag.length > 6 ? `${diag.slice(0, 6)}…` : diag}
))} {/* Data rows */} {AGE_GROUPS.map((ag, rowIdx) => (
{ag}
{heatmapData.matrix[rowIdx].map((value, colIdx) => (
{value > 0 ? value.toLocaleString() : '-'}
))}
))}
) : (
暂无数据
)}
)}
); }