feat(web): VitalSignsChart 集成趋势线 + 移动平均 + 异常标注
增强 VitalSignsChart 组件: - 线性回归趋势线(虚线显示,斜率/R² 统计) - 移动平均线(自适应窗口,平滑实线) - 异常点检测(2倍标准差,红色标记) - 概览卡片显示趋势方向箭头和异常警告图标 - 详情图下方图例说明各系列含义
This commit is contained in:
@@ -1,12 +1,77 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Line } from '@ant-design/charts';
|
import { Line } from '@ant-design/charts';
|
||||||
import { Spin, Typography } from 'antd';
|
import { Spin, Typography, Tag } from 'antd';
|
||||||
import { LineChartOutlined, CloseOutlined } from '@ant-design/icons';
|
import { LineChartOutlined, CloseOutlined, WarningOutlined } from '@ant-design/icons';
|
||||||
import { healthDataApi } from '../../../api/health/healthData';
|
import { healthDataApi } from '../../../api/health/healthData';
|
||||||
import { useThemeMode } from '../../../hooks/useThemeMode';
|
import { useThemeMode } from '../../../hooks/useThemeMode';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 统计计算工具函数(与后端 trend_stats.rs 对齐)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 最小二乘法线性回归 */
|
||||||
|
function linearRegression(points: { date: string; value: number }[]) {
|
||||||
|
const n = points.length;
|
||||||
|
if (n < 2) return null;
|
||||||
|
|
||||||
|
const xs = points.map((_, i) => i);
|
||||||
|
const ys = points.map((p) => p.value);
|
||||||
|
|
||||||
|
const sumX = xs.reduce((a, b) => a + b, 0);
|
||||||
|
const sumY = ys.reduce((a, b) => a + b, 0);
|
||||||
|
const sumXY = xs.reduce((acc, x, i) => acc + x * ys[i], 0);
|
||||||
|
const sumX2 = xs.reduce((acc, x) => acc + x * x, 0);
|
||||||
|
|
||||||
|
const denom = n * sumX2 - sumX * sumX;
|
||||||
|
if (Math.abs(denom) < 1e-10) return null;
|
||||||
|
|
||||||
|
const slope = (n * sumXY - sumX * sumY) / denom;
|
||||||
|
const intercept = (sumY - slope * sumX) / n;
|
||||||
|
|
||||||
|
const meanY = sumY / n;
|
||||||
|
const ssTot = ys.reduce((acc, y) => acc + (y - meanY) ** 2, 0);
|
||||||
|
const rSquared = ssTot > 0 ? 1 - ys.reduce((acc, y, i) => acc + (y - (intercept + slope * i)) ** 2, 0) / ssTot : 1;
|
||||||
|
|
||||||
|
return { slope, intercept, rSquared };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移动平均 */
|
||||||
|
function movingAverage(values: number[], window: number): (number | null)[] {
|
||||||
|
if (values.length === 0 || window === 0) return [];
|
||||||
|
return values.map((_, i) => {
|
||||||
|
if (i + 1 < window) return null;
|
||||||
|
const slice = values.slice(i + 1 - window, i + 1);
|
||||||
|
return slice.reduce((a, b) => a + b, 0) / window;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常检测 */
|
||||||
|
function detectAnomalies(points: { date: string; value: number }[], threshold: number) {
|
||||||
|
if (points.length < 3) return new Set<number>();
|
||||||
|
|
||||||
|
const values = points.map((p) => p.value);
|
||||||
|
const n = values.length;
|
||||||
|
const mean = values.reduce((a, b) => a + b, 0) / n;
|
||||||
|
const variance = values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / n;
|
||||||
|
const stdDev = Math.sqrt(variance);
|
||||||
|
|
||||||
|
if (stdDev < 1e-10) return new Set<number>();
|
||||||
|
|
||||||
|
const anomalies = new Set<number>();
|
||||||
|
values.forEach((v, i) => {
|
||||||
|
if (Math.abs((v - mean) / stdDev) > threshold) {
|
||||||
|
anomalies.add(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return anomalies;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 组件
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
patientId: string;
|
patientId: string;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
@@ -41,17 +106,21 @@ function extractData(res: unknown): { date: string; value: number }[] {
|
|||||||
|
|
||||||
const emptyData: MetricData = { points: [], latest: null };
|
const emptyData: MetricData = { points: [], latest: null };
|
||||||
|
|
||||||
/** 概览卡片 — 指标名 + 最新值 + 微型趋势线 */
|
/** 概览卡片 — 指标名 + 最新值 + 趋势方向 + 微型趋势线 */
|
||||||
function MetricCard({
|
function MetricCard({
|
||||||
metric,
|
metric,
|
||||||
metricData,
|
metricData,
|
||||||
selected,
|
selected,
|
||||||
onClick,
|
onClick,
|
||||||
|
trendDirection,
|
||||||
|
anomalyCount,
|
||||||
}: {
|
}: {
|
||||||
metric: MetricConfig;
|
metric: MetricConfig;
|
||||||
metricData: MetricData;
|
metricData: MetricData;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
trendDirection: 'rising' | 'falling' | 'stable' | null;
|
||||||
|
anomalyCount: number;
|
||||||
}) {
|
}) {
|
||||||
const hasData = metricData.points.length > 0;
|
const hasData = metricData.points.length > 0;
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
@@ -68,16 +137,27 @@ function MetricCard({
|
|||||||
...(selected && hasData ? { boxShadow: `0 0 0 1px ${metric.color}22` } : {}),
|
...(selected && hasData ? { boxShadow: `0 0 0 1px ${metric.color}22` } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const directionIcon = trendDirection === 'rising' ? '↑' : trendDirection === 'falling' ? '↓' : '→';
|
||||||
|
const directionColor = trendDirection === 'rising' ? '#ef4444' : trendDirection === 'falling' ? '#3b82f6' : '#94a3b8';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle} onClick={hasData ? onClick : undefined}>
|
<div style={cardStyle} onClick={hasData ? onClick : undefined}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
|
||||||
<Text style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)' }}>{metric.label}</Text>
|
<Text style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)' }}>{metric.label}</Text>
|
||||||
{metricData.latest != null && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<Text style={{ fontSize: 12, fontWeight: 600, color: metric.color }}>
|
{anomalyCount > 0 && (
|
||||||
{metricData.latest}
|
<WarningOutlined style={{ fontSize: 10, color: '#ef4444' }} />
|
||||||
<span style={{ fontWeight: 400, fontSize: 10, marginLeft: 2 }}>{metric.unit}</span>
|
)}
|
||||||
</Text>
|
{trendDirection && (
|
||||||
)}
|
<span style={{ fontSize: 11, color: directionColor, fontWeight: 600 }}>{directionIcon}</span>
|
||||||
|
)}
|
||||||
|
{metricData.latest != null && (
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: 600, color: metric.color }}>
|
||||||
|
{metricData.latest}
|
||||||
|
<span style={{ fontWeight: 400, fontSize: 10, marginLeft: 2 }}>{metric.unit}</span>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasData ? (
|
{hasData ? (
|
||||||
<Line
|
<Line
|
||||||
@@ -134,6 +214,32 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
});
|
});
|
||||||
}, [patientId, refreshKey]);
|
}, [patientId, refreshKey]);
|
||||||
|
|
||||||
|
// 为每个指标预计算趋势统计
|
||||||
|
const trendStats = useMemo(() => {
|
||||||
|
const stats: Record<string, {
|
||||||
|
regression: ReturnType<typeof linearRegression>;
|
||||||
|
ma: (number | null)[];
|
||||||
|
anomalyIndices: Set<number>;
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
for (const m of METRICS) {
|
||||||
|
const data = metricsData[m.key];
|
||||||
|
if (!data || data.points.length < 2) {
|
||||||
|
stats[m.key] = { regression: null, ma: [], anomalyIndices: new Set() };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regression = linearRegression(data.points);
|
||||||
|
const values = data.points.map((p) => p.value);
|
||||||
|
const maWindow = Math.min(7, Math.max(3, Math.floor(values.length / 3)));
|
||||||
|
const ma = movingAverage(values, maWindow);
|
||||||
|
const anomalyIndices = detectAnomalies(data.points, 2.0);
|
||||||
|
|
||||||
|
stats[m.key] = { regression, ma, anomalyIndices };
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}, [metricsData]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -164,6 +270,83 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
const selectedData = selectedKey ? metricsData[selectedKey] : null;
|
const selectedData = selectedKey ? metricsData[selectedKey] : null;
|
||||||
const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
|
const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
|
||||||
|
|
||||||
|
// 为详情图构建多系列数据
|
||||||
|
const chartSeries = useMemo(() => {
|
||||||
|
if (!selectedKey || !selectedData) return [];
|
||||||
|
const stat = trendStats[selectedKey];
|
||||||
|
if (!stat) return [];
|
||||||
|
|
||||||
|
const points = selectedData.points;
|
||||||
|
const series: { date: string; value: number; series: string; isAnomaly?: boolean }[] = [];
|
||||||
|
|
||||||
|
// 原始数据系列
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
series.push({
|
||||||
|
date: p.date,
|
||||||
|
value: p.value,
|
||||||
|
series: '实际值',
|
||||||
|
isAnomaly: stat.anomalyIndices.has(i),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 移动平均系列
|
||||||
|
stat.ma.forEach((val, i) => {
|
||||||
|
if (val != null) {
|
||||||
|
series.push({
|
||||||
|
date: points[i].date,
|
||||||
|
value: val,
|
||||||
|
series: '移动平均',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 趋势线系列(线性回归)
|
||||||
|
if (stat.regression) {
|
||||||
|
const { intercept, slope } = stat.regression;
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
series.push({
|
||||||
|
date: p.date,
|
||||||
|
value: intercept + slope * i,
|
||||||
|
series: '趋势线',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return series;
|
||||||
|
}, [selectedKey, selectedData, trendStats]);
|
||||||
|
|
||||||
|
// 趋势方向摘要
|
||||||
|
const getTrendDirection = (key: string): 'rising' | 'falling' | 'stable' | null => {
|
||||||
|
const stat = trendStats[key];
|
||||||
|
if (!stat?.regression) return null;
|
||||||
|
const { slope } = stat.regression;
|
||||||
|
if (Math.abs(slope) < 0.01) return 'stable';
|
||||||
|
return slope > 0 ? 'rising' : 'falling';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 趋势描述文本
|
||||||
|
const trendDescription = useMemo(() => {
|
||||||
|
if (!selectedKey || !selectedData || !trendStats[selectedKey]) return null;
|
||||||
|
const stat = trendStats[selectedKey];
|
||||||
|
if (!stat?.regression) return null;
|
||||||
|
|
||||||
|
const { slope, rSquared } = stat.regression;
|
||||||
|
const direction = getTrendDirection(selectedKey);
|
||||||
|
const anomalyCount = stat.anomalyIndices.size;
|
||||||
|
const points = selectedData.points;
|
||||||
|
const periodDays = points.length > 1 ? points.length : 0;
|
||||||
|
const totalChange = slope * (points.length - 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
direction: direction === 'rising' ? '上升' : direction === 'falling' ? '下降' : '平稳',
|
||||||
|
slope,
|
||||||
|
rSquared,
|
||||||
|
totalChange,
|
||||||
|
periodDays,
|
||||||
|
anomalyCount,
|
||||||
|
};
|
||||||
|
}, [selectedKey, selectedData, trendStats]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* 概览卡片条 */}
|
{/* 概览卡片条 */}
|
||||||
@@ -175,6 +358,8 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
metricData={metricsData[m.key] ?? emptyData}
|
metricData={metricsData[m.key] ?? emptyData}
|
||||||
selected={selectedKey === m.key}
|
selected={selectedKey === m.key}
|
||||||
onClick={() => setSelectedKey((prev) => (prev === m.key ? null : m.key))}
|
onClick={() => setSelectedKey((prev) => (prev === m.key ? null : m.key))}
|
||||||
|
trendDirection={getTrendDirection(m.key)}
|
||||||
|
anomalyCount={trendStats[m.key]?.anomalyIndices.size ?? 0}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -195,6 +380,14 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
{selectedData.latest}
|
{selectedData.latest}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>{selectedMetric.unit}</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>{selectedMetric.unit}</Text>
|
||||||
|
{trendDescription && (
|
||||||
|
<Tag color={
|
||||||
|
trendDescription.direction === '上升' ? 'red' :
|
||||||
|
trendDescription.direction === '下降' ? 'blue' : 'default'
|
||||||
|
} style={{ marginLeft: 4, fontSize: 11 }}>
|
||||||
|
{trendDescription.direction} ({trendDescription.totalChange > 0 ? '+' : ''}{trendDescription.totalChange.toFixed(1)})
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedKey(null)}
|
onClick={() => setSelectedKey(null)}
|
||||||
@@ -204,23 +397,88 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 趋势统计摘要 */}
|
||||||
|
{trendDescription && (
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
趋势斜率: {trendDescription.slope > 0 ? '+' : ''}{trendDescription.slope.toFixed(2)}/{selectedMetric.unit}/天
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
R²: {trendDescription.rSquared.toFixed(3)}
|
||||||
|
</Text>
|
||||||
|
{trendDescription.anomalyCount > 0 && (
|
||||||
|
<Text style={{ fontSize: 11, color: '#ef4444' }}>
|
||||||
|
<WarningOutlined style={{ marginRight: 2 }} />
|
||||||
|
{trendDescription.anomalyCount} 个异常点
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Line
|
<Line
|
||||||
data={selectedData.points}
|
data={chartSeries}
|
||||||
xField="date"
|
xField="date"
|
||||||
yField="value"
|
yField="value"
|
||||||
smooth
|
colorField="series"
|
||||||
height={200}
|
height={240}
|
||||||
point={{ shapeField: 'circle', sizeField: 3 }}
|
|
||||||
axis={{
|
axis={{
|
||||||
x: { labelAutoRotate: true, label: { style: axisLabelStyle } },
|
x: { labelAutoRotate: true, label: { style: axisLabelStyle } },
|
||||||
y: { title: selectedMetric.unit, label: { style: axisLabelStyle } },
|
y: { title: selectedMetric.unit, label: { style: axisLabelStyle } },
|
||||||
}}
|
}}
|
||||||
style={{ lineWidth: 2, stroke: selectedMetric.color }}
|
style={{
|
||||||
|
lineWidth: 2,
|
||||||
|
stroke: (datum: Record<string, unknown>) => {
|
||||||
|
const series = datum.series as string;
|
||||||
|
if (series === '趋势线') return '#94a3b8';
|
||||||
|
if (series === '移动平均') return selectedMetric.color + '88';
|
||||||
|
return selectedMetric.color;
|
||||||
|
},
|
||||||
|
lineDash: (datum: Record<string, unknown>) => {
|
||||||
|
const series = datum.series as string;
|
||||||
|
if (series === '趋势线') return [6, 3];
|
||||||
|
if (series === '移动平均') return [];
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
point={{
|
||||||
|
shapeField: 'circle',
|
||||||
|
sizeField: 4,
|
||||||
|
style: {
|
||||||
|
fill: (datum: Record<string, unknown>) => {
|
||||||
|
if (datum.isAnomaly) return '#ef4444';
|
||||||
|
return selectedMetric.color;
|
||||||
|
},
|
||||||
|
r: (datum: Record<string, unknown>) => {
|
||||||
|
if (datum.isAnomaly) return 5;
|
||||||
|
return 3;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
tooltip={{
|
tooltip={{
|
||||||
title: (d) => d.date,
|
title: (d: Record<string, unknown>) => d.date as string,
|
||||||
items: [{ channel: 'y', valueFormatter: (v: number) => `${v} ${selectedMetric.unit}` }],
|
items: [{ channel: 'y', valueFormatter: (v: number) => `${v} ${selectedMetric.unit}` }],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 图例说明 */}
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginTop: 4, justifyContent: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<div style={{ width: 16, height: 2, background: selectedMetric.color }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 10 }}>实际值</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<div style={{ width: 16, borderTop: '2px dashed #94a3b8' }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 10 }}>趋势线</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<div style={{ width: 16, height: 2, background: selectedMetric.color + '88' }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 10 }}>移动平均</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#ef4444' }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 10 }}>异常点</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user