feat: deep statistical analytics — clinical, symptoms, incidence, env correlation, weekday
Adds a substantial layer of data-backed statistics (all grounded in verified, clean source data — no fabricated metrics). Backend (new routers/statistics.py, prefix /api/stats; +106 pytest still green): - /inpatient-clinical: LOS dist + by-disease quartiles, cost dist + by-disease + cost-vs-LOS, outcome counts, admission-route counts, BMI-by-age, KPIs (5822 admissions, median LOS 4d, mean ¥6294, cure 99.1%, emergency 47%) - /symptoms: 主诉 keyword frequencies (发热/咳嗽/肺炎…) + revisit ratio (36%) - /incidence-rate: per-10k-population standardized rate by district (cases ÷ pop) - /env-correlation: pollutant×cases Pearson + 7×7 pairwise matrix + PM2.5 scatter - /temporal: weekday distribution (+ month/yoy returned but UI omits them — data is December-only, so seasonality/YoY would be misleading) Frontend: - NEW 住院临床分析 page (/analysis/clinical, nav 临床分析): 9 charts + KPI row — LOS histogram + box-by-disease, cost histogram + scatter + by-disease, outcome donut (severity-colored), admission-route donut, age-band BMI box - DiseaseAnalysis: 主诉症状词频 horizontal bar + revisit ratio - DistrictComparison: 标化发病率(每万人)with 病例数↔发病率 toggle (rate is epidemiologically correct; raw counts mislead by population) - EnvironmentalHealth: pollutant-cases correlation bar + 7×7 correlation heatmap + PM2.5×cases scatter with least-squares regression line - TrendAnalysis: 星期就诊分布 + honest "data is December-only" note - statsApi client + types Gates: tsc 0 · build ok · functional e2e 43/43 (incl 2 new clinical) · verified live against real backend data via dev proxy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,7 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: NavIte
|
||||
{ to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
|
||||
{ to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
|
||||
{ to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
|
||||
{ to: '/analysis/clinical', label: '临床分析', testid: TESTIDS.navClinical },
|
||||
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
|
||||
],
|
||||
},
|
||||
|
||||
87
frontend/src/components/clinical/BoxPlotRows.tsx
Normal file
87
frontend/src/components/clinical/BoxPlotRows.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { memo } from 'react';
|
||||
import { CLINICAL_COLORS } from './chartColors';
|
||||
|
||||
export interface BoxRow {
|
||||
label: string;
|
||||
p25: number;
|
||||
median: number;
|
||||
p75: number;
|
||||
n: number;
|
||||
}
|
||||
|
||||
interface BoxPlotRowsProps {
|
||||
rows: BoxRow[];
|
||||
/** 数值单位后缀,如 "天" / ""。 */
|
||||
unit?: string;
|
||||
/** 标签列宽(px)。 */
|
||||
labelWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 横向箱线图(p25–中位–p75)。Recharts 无原生 box plot,
|
||||
* 故用纯 div 渲染:每行一条从 p25 到 p75 的横条,中位处一根竖向刻度。
|
||||
* 复用于「各病种住院天数」与「年龄别BMI」。
|
||||
*/
|
||||
export const BoxPlotRows = memo(function BoxPlotRows({
|
||||
rows,
|
||||
unit = '',
|
||||
labelWidth = 96,
|
||||
}: BoxPlotRowsProps) {
|
||||
if (!rows || rows.length === 0) {
|
||||
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
// 统一横轴域:覆盖所有行的 p25..p75,留一点边距。
|
||||
const domainMin = Math.min(...rows.map((r) => r.p25));
|
||||
const domainMax = Math.max(...rows.map((r) => r.p75));
|
||||
const span = domainMax - domainMin || 1;
|
||||
const pct = (v: number) => ((v - domainMin) / span) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{rows.map((r) => {
|
||||
const left = pct(r.p25);
|
||||
const right = pct(r.p75);
|
||||
const width = Math.max(right - left, 0.5);
|
||||
const medianLeft = pct(r.median);
|
||||
return (
|
||||
<div key={r.label} className="flex items-center gap-2 text-[11px]">
|
||||
<div
|
||||
className="shrink-0 truncate text-text-secondary text-right"
|
||||
style={{ width: labelWidth }}
|
||||
title={r.label}
|
||||
>
|
||||
{r.label}
|
||||
</div>
|
||||
<div className="relative flex-1 h-5 rounded bg-bg-hover">
|
||||
{/* p25–p75 箱体 */}
|
||||
<div
|
||||
className="absolute top-1 bottom-1 rounded-sm"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
backgroundColor: CLINICAL_COLORS.box,
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
{/* 中位刻度 */}
|
||||
<div
|
||||
className="absolute top-0.5 bottom-0.5 w-[2px] rounded"
|
||||
style={{
|
||||
left: `${medianLeft}%`,
|
||||
backgroundColor: CLINICAL_COLORS.boxMedian,
|
||||
}}
|
||||
title={`中位 ${r.median}${unit}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 w-28 text-text-muted tabular-nums">
|
||||
{r.p25}–<span className="font-semibold text-text-secondary">{r.median}</span>–{r.p75}
|
||||
{unit}
|
||||
<span className="ml-1 text-[10px] text-text-muted">n={r.n}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
47
frontend/src/components/clinical/ClinicalKpiRow.tsx
Normal file
47
frontend/src/components/clinical/ClinicalKpiRow.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { memo } from 'react';
|
||||
import { Users, CalendarDays, Wallet, HeartPulse, Siren } from 'lucide-react';
|
||||
import { StatCard } from '@/components/StatCard';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import type { InpatientClinicalResponse } from '@/services/api';
|
||||
|
||||
interface ClinicalKpiRowProps {
|
||||
kpis: InpatientClinicalResponse['kpis'];
|
||||
}
|
||||
|
||||
/** 住院临床 5 项核心指标。375px 下 2 列,sm 起 5 列。 */
|
||||
export const ClinicalKpiRow = memo(function ClinicalKpiRow({ kpis }: ClinicalKpiRowProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid={TESTIDS.clinicalKpis}
|
||||
className="grid grid-cols-2 sm:grid-cols-5 gap-3"
|
||||
>
|
||||
<StatCard
|
||||
icon={<Users className="w-4 h-4 text-primary" />}
|
||||
label="住院总人次"
|
||||
value={kpis.total_admissions.toLocaleString()}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CalendarDays className="w-4 h-4 text-primary" />}
|
||||
label="中位住院日"
|
||||
value={`${kpis.median_los_days} 天`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Wallet className="w-4 h-4 text-primary" />}
|
||||
label="人均费用"
|
||||
value={`¥${Math.round(kpis.mean_cost).toLocaleString()}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<HeartPulse className="w-4 h-4 text-success" />}
|
||||
label="治愈好转率"
|
||||
value={`${(kpis.cure_rate * 100).toFixed(1)}%`}
|
||||
color="#16A34A"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Siren className="w-4 h-4 text-warning" />}
|
||||
label="急诊入院占比"
|
||||
value={`${(kpis.emergency_admit_ratio * 100).toFixed(1)}%`}
|
||||
color="#D97706"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
62
frontend/src/components/clinical/CostByDiseaseChart.tsx
Normal file
62
frontend/src/components/clinical/CostByDiseaseChart.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
|
||||
|
||||
interface CostByDiseaseChartProps {
|
||||
data: { diagnosis: string; mean_cost: number; n: number }[];
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + '…' : s;
|
||||
}
|
||||
|
||||
/** 各病种平均费用横向柱状图。 */
|
||||
export const CostByDiseaseChart = memo(function CostByDiseaseChart({
|
||||
data,
|
||||
}: CostByDiseaseChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
const chartData = [...data]
|
||||
.sort((a, b) => a.mean_cost - b.mean_cost)
|
||||
.map((d) => ({ ...d, displayName: truncate(d.diagnosis, 8) }));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 34)}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 12, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
|
||||
tickFormatter={(v: number) => `¥${(v / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="displayName"
|
||||
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axisLabel }}
|
||||
width={72}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(v: number) => [`¥${Math.round(v).toLocaleString()}`, '人均费用']}
|
||||
/>
|
||||
<Bar dataKey="mean_cost" fill={CLINICAL_COLORS.cost} barSize={16} radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
});
|
||||
55
frontend/src/components/clinical/CostVsLosScatter.tsx
Normal file
55
frontend/src/components/clinical/CostVsLosScatter.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
|
||||
|
||||
interface CostVsLosScatterProps {
|
||||
data: { los: number; cost: number }[];
|
||||
}
|
||||
|
||||
/** 费用 vs 住院天数散点。 */
|
||||
export const CostVsLosScatter = memo(function CostVsLosScatter({ data }: CostVsLosScatterProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart margin={{ top: 10, right: 16, left: 6, bottom: 16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="los"
|
||||
name="住院天数"
|
||||
unit="天"
|
||||
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="cost"
|
||||
name="费用"
|
||||
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
|
||||
width={52}
|
||||
tickFormatter={(v: number) => `¥${(v / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
cursor={{ strokeDasharray: '3 3' }}
|
||||
formatter={(value: number, name: string) =>
|
||||
name === '费用'
|
||||
? [`¥${value.toLocaleString()}`, name]
|
||||
: [`${value} 天`, name]
|
||||
}
|
||||
/>
|
||||
<Scatter data={data} fill={CLINICAL_COLORS.scatter} fillOpacity={0.5} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
});
|
||||
56
frontend/src/components/clinical/DonutChart.tsx
Normal file
56
frontend/src/components/clinical/DonutChart.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { memo } from 'react';
|
||||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
|
||||
|
||||
export interface DonutSlice {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface DonutChartProps {
|
||||
data: DonutSlice[];
|
||||
/** name -> color。未命中时按 palette 顺序回退。 */
|
||||
colorMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 通用环形图。复用于「出院结局构成」与「入院途径构成」。 */
|
||||
export const DonutChart = memo(function DonutChart({ data, colorMap }: DonutChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
const colorFor = (name: string, idx: number) =>
|
||||
colorMap?.[name] ??
|
||||
CLINICAL_COLORS.routePalette[idx % CLINICAL_COLORS.routePalette.length] ??
|
||||
CLINICAL_COLORS.outcomeFallback;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={56}
|
||||
outerRadius={88}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{data.map((d, idx) => (
|
||||
<Cell key={d.name} fill={colorFor(d.name, idx)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(v: number, name: string) => [
|
||||
`${v.toLocaleString()}(${total > 0 ? ((v / total) * 100).toFixed(1) : '0'}%)`,
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
});
|
||||
51
frontend/src/components/clinical/HistogramChart.tsx
Normal file
51
frontend/src/components/clinical/HistogramChart.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
|
||||
|
||||
interface HistogramChartProps {
|
||||
data: { bin_label: string; count: number }[];
|
||||
color?: string;
|
||||
/** tooltip 中数量的标签,如 "住院天数" / "费用区间"。 */
|
||||
countLabel?: string;
|
||||
}
|
||||
|
||||
/** 通用直方图。复用于「住院天数分布」与「住院费用分布」。 */
|
||||
export const HistogramChart = memo(function HistogramChart({
|
||||
data,
|
||||
color = CLINICAL_COLORS.los,
|
||||
countLabel = '人次',
|
||||
}: HistogramChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-center py-8 text-text-muted text-sm">暂无数据</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data} margin={{ top: 5, right: 12, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="bin_label"
|
||||
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
|
||||
interval={0}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }} width={40} />
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(v: number) => [`${v.toLocaleString()}`, countLabel]}
|
||||
/>
|
||||
<Bar dataKey="count" fill={color} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
});
|
||||
36
frontend/src/components/clinical/chartColors.ts
Normal file
36
frontend/src/components/clinical/chartColors.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 住院临床分析页图表字面色值集中处。
|
||||
* Recharts 需要原始 hex,无法用 Tailwind class,故在此集中定义,避免散落 magic hex。
|
||||
*/
|
||||
export const CLINICAL_COLORS = {
|
||||
primary: '#2563EB', // primary
|
||||
los: '#2563EB',
|
||||
cost: '#0891B2', // cyan — 费用维度
|
||||
scatter: '#7C3AED', // violet — 散点
|
||||
box: '#3B82F6', // 箱体填充
|
||||
boxMedian: '#1D4ED8', // 中位刻度
|
||||
grid: '#E2E8F0',
|
||||
axis: '#64748B',
|
||||
axisLabel: '#374151',
|
||||
tooltipBorder: '#E2E8F0',
|
||||
tooltipText: '#1E293B',
|
||||
// 出院结局按严重程度配色:治愈/好转偏绿,未愈/死亡偏红,其他中性
|
||||
outcome: {
|
||||
治愈: '#16A34A',
|
||||
好转: '#4ADE80',
|
||||
其他: '#94A3B8',
|
||||
未愈: '#F97316',
|
||||
死亡: '#DC2626',
|
||||
} as Record<string, string>,
|
||||
outcomeFallback: '#94A3B8',
|
||||
// 入院途径 donut 顺序色板
|
||||
routePalette: ['#2563EB', '#0891B2', '#7C3AED', '#D97706', '#16A34A', '#DC2626'],
|
||||
} as const;
|
||||
|
||||
/** Recharts tooltip 通用样式。 */
|
||||
export const TOOLTIP_STYLE = {
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
} as const;
|
||||
Reference in New Issue
Block a user