Files
CA/frontend/src/pages/DemographicAnalysis.tsx
Akiba So e95e2f1338 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
2026-06-21 17:35:03 +08:00

367 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}