From 1cf5f59d8c40d4ecbef4cdc2adbf24bfdff2de17 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 28 Apr 2026 20:05:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20VitalSignsChart=20=E9=9B=86?= =?UTF-8?q?=E6=88=90=E8=B6=8B=E5=8A=BF=E7=BA=BF=20+=20=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E5=B9=B3=E5=9D=87=20+=20=E5=BC=82=E5=B8=B8=E6=A0=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增强 VitalSignsChart 组件: - 线性回归趋势线(虚线显示,斜率/R² 统计) - 移动平均线(自适应窗口,平滑实线) - 异常点检测(2倍标准差,红色标记) - 概览卡片显示趋势方向箭头和异常警告图标 - 详情图下方图例说明各系列含义 --- .../health/components/VitalSignsChart.tsx | 290 +++++++++++++++++- 1 file changed, 274 insertions(+), 16 deletions(-) diff --git a/apps/web/src/pages/health/components/VitalSignsChart.tsx b/apps/web/src/pages/health/components/VitalSignsChart.tsx index b92ff6b..617c319 100644 --- a/apps/web/src/pages/health/components/VitalSignsChart.tsx +++ b/apps/web/src/pages/health/components/VitalSignsChart.tsx @@ -1,12 +1,77 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Line } from '@ant-design/charts'; -import { Spin, Typography } from 'antd'; -import { LineChartOutlined, CloseOutlined } from '@ant-design/icons'; +import { Spin, Typography, Tag } from 'antd'; +import { LineChartOutlined, CloseOutlined, WarningOutlined } from '@ant-design/icons'; import { healthDataApi } from '../../../api/health/healthData'; import { useThemeMode } from '../../../hooks/useThemeMode'; 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(); + + 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(); + + const anomalies = new Set(); + values.forEach((v, i) => { + if (Math.abs((v - mean) / stdDev) > threshold) { + anomalies.add(i); + } + }); + return anomalies; +} + +// --------------------------------------------------------------------------- +// 组件 +// --------------------------------------------------------------------------- + interface Props { patientId: string; refreshKey?: number; @@ -41,17 +106,21 @@ function extractData(res: unknown): { date: string; value: number }[] { const emptyData: MetricData = { points: [], latest: null }; -/** 概览卡片 — 指标名 + 最新值 + 微型趋势线 */ +/** 概览卡片 — 指标名 + 最新值 + 趋势方向 + 微型趋势线 */ function MetricCard({ metric, metricData, selected, onClick, + trendDirection, + anomalyCount, }: { metric: MetricConfig; metricData: MetricData; selected: boolean; onClick: () => void; + trendDirection: 'rising' | 'falling' | 'stable' | null; + anomalyCount: number; }) { const hasData = metricData.points.length > 0; const cardStyle: React.CSSProperties = { @@ -68,16 +137,27 @@ function MetricCard({ ...(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 (
{metric.label} - {metricData.latest != null && ( - - {metricData.latest} - {metric.unit} - - )} +
+ {anomalyCount > 0 && ( + + )} + {trendDirection && ( + {directionIcon} + )} + {metricData.latest != null && ( + + {metricData.latest} + {metric.unit} + + )} +
{hasData ? ( { + const stats: Record; + ma: (number | null)[]; + anomalyIndices: Set; + }> = {}; + + 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) { return (
@@ -164,6 +270,83 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) { const selectedData = selectedKey ? metricsData[selectedKey] : null; 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 (
{/* 概览卡片条 */} @@ -175,6 +358,8 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) { metricData={metricsData[m.key] ?? emptyData} selected={selectedKey === m.key} onClick={() => setSelectedKey((prev) => (prev === m.key ? null : m.key))} + trendDirection={getTrendDirection(m.key)} + anomalyCount={trendStats[m.key]?.anomalyIndices.size ?? 0} /> ))}
@@ -195,6 +380,14 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) { {selectedData.latest} {selectedMetric.unit} + {trendDescription && ( + + {trendDescription.direction} ({trendDescription.totalChange > 0 ? '+' : ''}{trendDescription.totalChange.toFixed(1)}) + + )}
setSelectedKey(null)} @@ -204,23 +397,88 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
+ {/* 趋势统计摘要 */} + {trendDescription && ( +
+ + 趋势斜率: {trendDescription.slope > 0 ? '+' : ''}{trendDescription.slope.toFixed(2)}/{selectedMetric.unit}/天 + + + R²: {trendDescription.rSquared.toFixed(3)} + + {trendDescription.anomalyCount > 0 && ( + + + {trendDescription.anomalyCount} 个异常点 + + )} +
+ )} + ) => { + const series = datum.series as string; + if (series === '趋势线') return '#94a3b8'; + if (series === '移动平均') return selectedMetric.color + '88'; + return selectedMetric.color; + }, + lineDash: (datum: Record) => { + const series = datum.series as string; + if (series === '趋势线') return [6, 3]; + if (series === '移动平均') return []; + return []; + }, + }} + point={{ + shapeField: 'circle', + sizeField: 4, + style: { + fill: (datum: Record) => { + if (datum.isAnomaly) return '#ef4444'; + return selectedMetric.color; + }, + r: (datum: Record) => { + if (datum.isAnomaly) return 5; + return 3; + }, + }, + }} tooltip={{ - title: (d) => d.date, + title: (d: Record) => d.date as string, items: [{ channel: 'y', valueFormatter: (v: number) => `${v} ${selectedMetric.unit}` }], }} /> + + {/* 图例说明 */} +
+
+
+ 实际值 +
+
+
+ 趋势线 +
+
+
+ 移动平均 +
+
+
+ 异常点 +
+
)}