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>
|
|||
|
|
);
|
|||
|
|
});
|