feat: add analysis pages and raster risk map
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
This commit is contained in:
366
frontend/src/pages/DemographicAnalysis.tsx
Normal file
366
frontend/src/pages/DemographicAnalysis.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user