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>
88 lines
2.8 KiB
TypeScript
88 lines
2.8 KiB
TypeScript
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>
|
||
);
|
||
});
|