Ship a new app version with broader analytics, restructured dashboards, and a server-rendered risk map. Frontend: - Add Overview, Demographic, Disease, and Environmental Health analysis pages - Add AnomalyMarkers, CalendarHeatmap, and MetricHeatmapTable components - Rebuild Alerts map onto server-rendered raster risk tiles; expand Monitoring, Trend, and District Comparison views - Extend API client, stores, and TypeScript types Backend: - Add environment router (pollutants, lag correlations) - Add risk_raster util serving XYZ 100m risk tiles - Expand cases endpoints (demographics, seasonality, diagnoses) and insights; harden auth and file-based loaders Data & tooling: - Add processed outpatient/inpatient/combined case parquet (LFS) - Add nested CLAUDE.md guides, pyrightconfig, and test updates
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>
|
||
);
|
||
}
|