数据可视化指南
📊 概述
本指南帮助您将 AIECG API 返回的 ECG/PPG 数据可视化,提供常用图表库的集成示例和详细参数说明。
内容导航
- 📈 ECG 信号绘制: 单导联和12导联波形图
- 💓 PPG 信号绘制: 多通道PPG波形显示
- 📊 图表库推荐: 不同场景的最佳选择
- ⚡ 性能优化: 大数据量实时绘制技巧
📈 ECG 信号可视化
数据格式说明
从 AIECG API 获取的 ECG 数据格式:
// 单导联数据示例
const singleLeadData = {
ecgData: [512, 515, 520, 518, 525, 530, ...], // 信号数据点数组
ecgSampleRate: 500, // 采样率 500Hz(每秒500个点)
adcGain: 1000.0, // ADC增益系数
adcZero: 0.0 // ADC零点电压
};
// 12导联数据示例
const twelveLeadData = {
ecgSampleRate: 500, // 采样率
lead_I: [512, 515, 520, ...], // I 导联数据
lead_II: [510, 518, 522, ...], // II 导联数据
lead_III: [508, 520, 525, ...], // III 导联数据
lead_aVR: [505, 512, 518, ...], // aVR 导联数据
lead_aVL: [515, 520, 528, ...], // aVL 导联数据
lead_aVF: [518, 525, 532, ...], // aVF 导联数据
lead_V1: [520, 530, 538, ...], // V1 导联数据
lead_V2: [522, 532, 540, ...], // V2 导联数据
lead_V3: [525, 535, 542, ...], // V3 导联数据
lead_V4: [528, 538, 545, ...], // V4 导联数据
lead_V5: [530, 540, 548, ...], // V5 导联数据
lead_V6: [532, 542, 550, ...] // V6 导联数据
};
方案一:使用 Chart.js 绘制
安装
npm install chart.js
单导联 ECG 绘制
import Chart from 'chart.js/auto';
/**
* 绘制单导联 ECG 波形图
* @param {Array} ecgData - ECG 信号数据数组
* @param {number} samplingRate - 采样率(Hz)
* @returns {Chart} Chart.js 实例
*/
function drawECGChart(ecgData, samplingRate) {
const ctx = document.getElementById('ecgChart').getContext('2d');
// 生成时间轴数据(单位:秒)
// 例如:采样率500Hz,15000个点 = 30秒数据
const timeData = ecgData.map((_, index) => (index / samplingRate).toFixed(3));
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: timeData,
datasets: [{
label: 'ECG 信号',
data: ecgData,
borderColor: '#00A86B', // 线条颜色:医疗绿
borderWidth: 1.5, // 线条宽度
pointRadius: 0, // 不显示数据点(提升性能)
tension: 0, // 线条平滑度(0=直线,0.4=平滑曲线)
fill: false // 不填充区域
}]
},
options: {
responsive: true, // 响应式布局
maintainAspectRatio: false, // 不保持宽高比,允许自定义高度
animation: false, // 禁用动画(大数据量时提升性能)
interaction: {
mode: 'nearest', // 鼠标交互模式
axis: 'x', // 只在X轴触发
intersect: false // 不需要精确悬停在点上
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true, // 使用圆点样式
padding: 15
}
},
title: {
display: true,
text: 'ECG 波形图',
font: {
size: 16,
weight: 'bold'
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0,0,0,0.8)',
callbacks: {
// 自定义提示框内容
label: function(context) {
return `时间: ${context.label}s, 幅值: ${context.parsed.y}`;
}
}
}
},
scales: {
x: {
type: 'linear', // 线性坐标轴
title: {
display: true,
text: '时间 (秒)',
font: { size: 14 }
},
ticks: {
maxTicksLimit: 20, // 最多显示20个刻度标签
callback: function(value) {
return value.toFixed(1); // 保留1位小数
}
},
grid: {
color: '#e8e8e8', // 网格线颜色
lineWidth: 1
}
},
y: {
title: {
display: true,
text: '幅值',
font: { size: 14 }
},
grid: {
color: '#e8e8e8',
lineWidth: 1
}
}
}
}
});
return chart;
}
// 使用示例
const ecgData = [512, 515, 520, 518, 525, ...]; // 您的 ECG 数据
const samplingRate = 500; // 采样率
const chart = drawECGChart(ecgData, samplingRate);
实现效果说明
Canvas 尺寸设置:
<!-- 建议设置固定高度,宽度自适应 -->
<canvas id="ecgChart" style="height: 400px;"></canvas>
显示效果:
- 📊 X轴:显示时间轴,范围从 0 到数据总时长(如 30 秒)
- 📈 Y轴:显示幅值,自动根据数据范围调整
- 🎨 波形:绿色连续线条,清晰展示心电 波形的 P、QRS、T 波特征
- 🖱️ 交互:鼠标悬停显示精确的时间和幅值
参数说明:
| 参数 | 说明 | 推荐值 | 效果 |
|---|---|---|---|
borderWidth | 线条粗细 | 1.5-2 | 过粗影响细节,过细不清晰 |
pointRadius | 数据点大小 | 0 | 设为0隐藏点,提升性能 |
tension | 曲线平滑度 | 0 | ECG需要尖锐波形,不平滑 |
animation | 动画效果 | false | 关闭动画加速渲染 |
maxTicksLimit | X轴标签数量 | 15-20 | 避免标签过密重叠 |
方案二:使用 ECharts 绘制
安装
npm install echarts
单导联 ECG 绘制(带缩放功能)
import * as echarts from 'echarts';
/**
* 使用 ECharts 绘制 ECG 波形图
* @param {Array} ecgData - ECG 信号数据数组
* @param {number} samplingRate - 采样率(Hz)
* @returns {echarts.ECharts} ECharts 实例
*/
function drawECGWithECharts(ecgData, samplingRate) {
const chartDom = document.getElementById('ecgChart');
const myChart = echarts.init(chartDom);
// 生成时间轴(毫秒)
// 例如:采样率500Hz,每个点间隔 1000/500 = 2ms
const timeData = ecgData.map((_, index) =>
((index / samplingRate) * 1000).toFixed(1)
);
const option = {
title: {
text: 'ECG 波形图',
left: 'center',
textStyle: {
fontSize: 18,
fontWeight: 'bold'
}
},
tooltip: {
trigger: 'axis', // 坐标轴触发
axisPointer: {
type: 'cross', // 十字准星指示器
label: {
backgroundColor: '#6a7985'
}
},
formatter: function(params) {
const time = parseFloat(params[0].axisValue);
const value = params[0].data;
return `时间: ${time.toFixed(1)}ms<br/>幅值: ${value}`;
}
},
grid: {
left: '5%',
right: '5%',
bottom: '15%', // 为 dataZoom 留空间
top: '10%',
containLabel: true,
backgroundColor: '#fafafa'
},
xAxis: {
type: 'category',
data: timeData,
name: '时间 (ms)',
nameLocation: 'middle',
nameGap: 35,
nameTextStyle: {
fontSize: 14,
fontWeight: 'bold'
},
axisLine: {
lineStyle: {
color: '#333'
}
},
axisTick: {
alignWithLabel: true
}
},
yAxis: {
type: 'value',
name: '幅值',
nameLocation: 'middle',
nameGap: 50,
nameTextStyle: {
fontSize: 14,
fontWeight: 'bold'
},
splitLine: {
lineStyle: {
color: '#e8e8e8',
type: 'solid'
}
}
},
series: [{
name: 'ECG',
type: 'line',
data: ecgData,
smooth: false, // 不平滑,保持原始波形
symbol: 'none', // 不显示数据点标记
sampling: 'lttb', // 采样策略:Largest-Triangle-Three-Buckets
lineStyle: {
color: '#00A86B',
width: 1.5
},
animation: false // 关闭动画
}],
// 缩放和滚动功能
dataZoom: [
{
type: 'inside', // 内置缩放(鼠标滚轮)
start: 0, // 初始显示数据的起始位置(百分比)
end: 100, // 初始显示数据的结束位置(百分比)
zoomOnMouseWheel: true, // 滚轮缩放
moveOnMouseMove: false, // 鼠标移动不触发平移
moveOnMouseWheel: true // 按住 Shift + 滚轮平移
},
{
type: 'slider', // 滑动条缩放
start: 0,
end: 100,
height: 30,
bottom: 10,
borderColor: '#ccc',
fillerColor: 'rgba(0, 168, 107, 0.2)',
handleStyle: {
color: '#00A86B'
},
textStyle: {
color: '#333'
}
}
],
// 工具箱
toolbox: {
feature: {
dataZoom: {
yAxisIndex: 'none' // Y轴不缩放
},
restore: {}, // 重置
saveAsImage: { // 保存为图片
pixelRatio: 2
}
}
}
};
myChart.setOption(option);
// 响应窗口大小变化
window.addEventListener('resize', () => {
myChart.resize();
});
return myChart;
}
// 使用示例
const ecgData = [512, 515, 520, 518, ...]; // 您的 ECG 数据
const samplingRate = 500;
const chart = drawECGWithECharts(ecgData, samplingRate);
实现效果说明
容器尺寸设置:
<div id="ecgChart" style="width: 100%; height: 500px;"></div>
显示效果:
-
🔍 缩放功能:
- 鼠标滚轮:上下滚动缩放时间轴
- Shift + 滚轮:左右平移查看不同时间段
- 底部滑动条:拖动查看任意时间段
-
📊 滚动显示效果:
初始状态:显示完整 30 秒数据
┌────────────────────────────────────┐
│ ECG 波形(0-30s) │
│ P QRS T P QRS T ... │
└────────────────────────────────────┘
放大后:底部滑动条显示当前查看位置
┌────────────────────────────────────┐
│ ECG 波形(5-10s 放大) │
│ P波 QRS波群 T波 细节清晰 │
└────────────────────────────────────┘
滑动条:[━━■━━━━━━━━━━━━━━━━━━━━━]
↑ 当前查看位置(5-10秒) -
🎯 交互特性:
- 十字准星:鼠标移动时显示精确坐标
- 工具栏:右上角提供重置、截图等功能
- 数据采样:自动优化显示(LTTB算法)
参数详解:
| 参数 | 说明 | 推荐值 | 效果 |
|---|---|---|---|
dataZoom[0].type | 缩放类型 | 'inside' | 内置缩放,无UI,鼠标操作 |
dataZoom[1].type | 缩放类型 | 'slider' | 滑动条,可视化当前位置 |
sampling | 数据采样策略 | 'lttb' | 大数据量时自动采样 |
grid.bottom | 底部边距 | '15%' | 为滑动条预留空间 |
symbol | 数据点标记 | 'none' | 隐藏标记点 |
12导联 ECG 同时显示
/**
* 绘制 12 导联 ECG(垂直排列)
* @param {Object} leadData - 包含12个导联数据的对象
* @param {number} samplingRate - 采样率
* @returns {echarts.ECharts} ECharts 实例
*/
function draw12LeadECG(leadData, samplingRate) {
const chartDom = document.getElementById('ecg12LeadChart');
const myChart = echarts.init(chartDom);
const leads = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'];
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A',
'#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2',
'#F8B88B', '#FAD7A0', '#AED6F1', '#A9DFBF'
];
// 计算每个导联占用的高度(百分比)
const gridHeight = 100 / 12 - 0.8; // 每个网格高度,留0.8%间隙
const grids = [];
const xAxes = [];
const yAxes = [];
const series = [];
leads.forEach((lead, index) => {
const leadKey = `lead_${lead}`;
const data = leadData[leadKey];
const timeData = data.map((_, i) => (i / samplingRate).toFixed(3));
// 配置网格
grids.push({
left: '8%',
right: '3%',
top: `${index * (gridHeight + 0.8) + 1}%`,
height: `${gridHeight}%`,
backgroundColor: index % 2 === 0 ? '#fafafa' : '#ffffff' // 斑马纹
});
// X轴配置(只显示最后一个)
xAxes.push({
type: 'category',
data: timeData,
gridIndex: index,
show: index === leads.length - 1, // 只在最底部显示X轴
axisLabel: {
show: index === leads.length - 1,
formatter: '{value}s'
},
axisTick: { show: false },
axisLine: { show: index === leads.length - 1 }
});
// Y轴配置
yAxes.push({
type: 'value',
gridIndex: index,
name: lead, // 导联名称
nameLocation: 'middle',
nameGap: 35,
nameTextStyle: {
fontSize: 12,
fontWeight: 'bold',
color: colors[index]
},
splitLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false } // 不显示Y轴刻度值
});
// 数据系列
series.push({
name: lead,
type: 'line',
xAxisIndex: index,
yAxisIndex: index,
data: data,
symbol: 'none',
lineStyle: {
color: colors[index],
width: 1.5
},
animation: false
});
});
const option = {
title: {
text: '12 导联 ECG',
left: 'center',
textStyle: {
fontSize: 18,
fontWeight: 'bold'
}
},
grid: grids,
xAxis: xAxes,
yAxis: yAxes,
series: series,
// 全局缩放控制
dataZoom: [{
type: 'inside',
xAxisIndex: Array.from({length: 12}, (_, i) => i), // 控制所有12个X轴
start: 0,
end: 100,
zoomOnMouseWheel: true
}],
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
lineStyle: {
color: '#999',
width: 1,
type: 'dashed'
}
}
}
};
myChart.setOption(option);
return myChart;
}
12导联实现效果
容器尺寸:
<div id="ecg12LeadChart" style="width: 100%; height: 1200px;"></div>
显示效果布局:
┌─────────────────────────────────────────┐
│ 12 导联 ECG │
├─────────────────────────────────────────┤
│ I │ ~~~~~~~~~~~~~ │ ← 第1导联(红色)
├─────────────────────────────────────────┤
│ II │ ~~~~~~~~~~~~~ │ ← 第2导联(青色)
├─────────────────────────────────────────┤
│ III │ ~~~~~~~~~~~~~ │ ← 第3导联(蓝色)
├─────────────────────────────────────────┤
│ aVR │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ aVL │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ aVF │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V1 │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V2 │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V3 │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V4 │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V5 │ ~~~~~~~~~~~~~ │
├ ─────────────────────────────────────────┤
│ V6 │ ~~~~~~~~~~~~~ │
└──────────────────────────────────0-10s──┘
↑ 时间轴
交互特性:
- 🔗 同步缩放:鼠标滚轮缩放,所有12个导联同步
- 🎨 颜色区分:每个导联使用不同颜色,便于识别
- 📏 对齐显示:所有导联时间轴对齐,便于对比分析
💓 PPG 信号可视化
PPG 数据格式
const ppgData = {
ppgData: {
red: [120.5, 118.3, 122.1, 125.6, ...], // 红光通道(660nm)
green: [125.6, 123.2, 127.8, 130.1, ...], // 绿光通道(525nm,最常用)
infrared: [115.2, 113.8, 117.5, 119.3, ...] // 红外通道(940nm)
},
ppgSampleRate: 100, // PPG采样率通常为 25-125Hz
duration: 30 // 时长(秒)
};
使用 Chart.js 绘制多通道 PPG
/**
* 绘制多通道 PPG 信号
* @param {Object} ppgData - 包含 red/green/infrared 的对象
* @param {number} samplingRate - 采样率
* @returns {Chart} Chart.js 实例
*/
function drawPPGChart(ppgData, samplingRate) {
const ctx = document.getElementById('ppgChart').getContext('2d');
// 生成时间轴
const timeData = ppgData.green.map((_, index) =>
(index / samplingRate).toFixed(2)
);
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: timeData,
datasets: [
{
label: '绿光通道 (Green)',
data: ppgData.green,
borderColor: '#2ECC71', // 绿色
backgroundColor: 'rgba(46, 204, 113, 0.1)',
borderWidth: 2,
pointRadius: 0,
tension: 0.2, // 稍微平滑(PPG波形较圆润)
fill: false,
yAxisID: 'y'
},
{
label: '红光通道 (Red)',
data: ppgData.red,
borderColor: '#E74C3C', // 红色
backgroundColor: 'rgba(231, 76, 60, 0.1)',
borderWidth: 2,
pointRadius: 0,
tension: 0.2,
fill: false,
yAxisID: 'y'
},
{
label: '红外通道 (IR)',
data: ppgData.infrared,
borderColor: '#9B59B6', // 紫色
backgroundColor: 'rgba(155, 89, 182, 0.1)',
borderWidth: 2,
pointRadius: 0,
tension: 0.2,
fill: false,
yAxisID: 'y'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index', // 显示同一时间点的所有通道数据
intersect: false
},
plugins: {
title: {
display: true,
text: 'PPG 多通道信号',
font: { size: 16, weight: 'bold' }
},
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true,
padding: 15
}
},
tooltip: {
callbacks: {
title: function(context) {
return `时间: ${context[0].label}s`;
},
label: function(context) {
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)}`;
}
}
}
},
scales: {
x: {
title: {
display: true,
text: '时间 (秒)',
font: { size: 14 }
},
grid: {
color: '#e8e8e8'
}
},
y: {
title: {
display: true,
text: '光强度值',
font: { size: 14 }
},
grid: {
color: '#e8e8e8'
}
}
}
}
});
return chart;
}
PPG 显示效果
Canvas 设置:
<canvas id="ppgChart" style="height: 450px;"></canvas>
显示效果:
光强度
↑
140 │ 绿光 ~~~~~~
130 │ / \ / \ / \ ← 绿光通道(最明显的脉搏波)
120 │ / \/ \/ \
110 │
100 │ 红光 ~~~~~~ ← 红光通道(用于血氧计算)
90 │ / \ / \ / \
80 │ / \/ \/ \
│
70 │ 红外 ~~~~~~ ← 红外通道(穿透深,基础信号)
60 │ / \ / \ / \
└────────────────────→ 时间(秒)
0 5 10 15 20 25 30
参数说明:
| 参数 | 说明 | PPG 特殊设置 |
|---|---|---|
tension | 曲线平滑度 | 0.2(PPG波形较平滑) |
interaction.mode | 交互模式 | 'index'(同时显示三通道) |
borderWidth | 线条粗细 | 2(稍粗,便于区分通道) |
⚡ 实时数据流可视化
应用场景
实时数据流可视化适用于以下场景:
- 🏥 穿戴设备监控:实时显示心 率、ECG 波形
- 🔴 实时采集:硬件设备持续发送数据到客户端
- 📡 WebSocket 推送:服务器实时推送分析结果
- 🎯 延迟显示:缓冲 2-3 秒后再展示(平滑过渡)
三种实时显示模式
模式1:滚动窗口(推荐)
效果说明:固定显示最近 N 秒数据,老数据从左侧移出,新数据从右侧进入。
时刻 T=0s:
┌────────────────────────┐
│ ~~~~~~~ │ ← 显示 0-10 秒
└────────────────────────┘
0 5 10
时刻 T=5s(新增5秒数据):
┌────────────────────────┐
│ ~~~~~~~~ │ ← 显示 5-15 秒(左侧数据移出)
└────────────────────────┘
5 10 15
时刻 T=10s:
┌────────────────────────┐
│ ~~~~~~~ │ ← 显示 10-20 秒
└────────────────────────┘
10 15 20
Chart.js 实现:
/**
* 实时滚动窗口图表类
*/
class RealtimeScrollChart {
constructor(canvasId, windowSize = 10, samplingRate = 500) {
this.windowSize = windowSize; // 显示窗口(秒)
this.samplingRate = samplingRate; // 采样率(Hz)
this.maxPoints = windowSize * samplingRate; // 最大点数
this.dataBuffer = []; // 数据缓冲区
this.timeBuffer = []; // 时间缓冲区
this.startTime = Date.now();
this.initChart(canvasId);
}
initChart(canvasId) {
const ctx = document.getElementById(canvasId).getContext('2d');
this.chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'ECG 实时信号',
data: [],
borderColor: '#00A86B',
borderWidth: 1.5,
pointRadius: 0,
tension: 0,
fill: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false, // 关键:必须关闭动画
plugins: {
title: {
display: true,
text: '实时 ECG 监控(滚动窗口)',
font: { size: 16, weight: 'bold' }
},
legend: { display: true }
},
scales: {
x: {
title: {
display: true,
text: '时间 (秒)'
},
ticks: {
maxTicksLimit: 10,
callback: function(value, index, values) {
// 显示相对时间
return value.toFixed(1);
}
}
},
y: {
title: {
display: true,
text: '幅值'
}
}
}
}
});
}
/**
* 添加新数据点(单个或批量)
* @param {Array|Number} newData - 新数据(数组或单个值)
*/
addData(newData) {
// 统一处理为数组
const dataArray = Array.isArray(newData) ? newData : [newData];
dataArray.forEach(value => {
// 添加到缓冲区
this.dataBuffer.push(value);
// 计算相对时间(秒)
const elapsedTime = (Date.now() - this.startTime) / 1000;
this.timeBuffer.push(elapsedTime.toFixed(2));
// 超过窗口大小时,移除最老的数据
if (this.dataBuffer.length > this.maxPoints) {
this.dataBuffer.shift();
this.timeBuffer.shift();
}
});
// 更新图表
this.updateChart();
}
/**
* 更新图表显示
*/
updateChart() {
this.chart.data.labels = this.timeBuffer;
this.chart.data.datasets[0].data = this.dataBuffer;
this.chart.update('none'); // 'none' 模式:无动画,立即更新
}
/**
* 清空数据
*/
clear() {
this.dataBuffer = [];
this.timeBuffer = [];
this.startTime = Date.now();
this.updateChart();
}
}
// 使用示例:模拟实时数据流
const realtimeChart = new RealtimeScrollChart('realtimeChart', 10, 500);
// 模拟 WebSocket 接收数据
function simulateWebSocket() {
setInterval(() => {
// 模拟接收 10 个新数据点(20ms 间隔)
const newData = Array.from({ length: 10 }, () =>
500 + Math.sin(Date.now() / 200) * 50 + Math.random() * 20
);
realtimeChart.addData(newData);
}, 20); // 每 20ms 接收一批数据
}
// 启动模拟
simulateWebSocket();
效果特点:
- ✅ 流畅滚动:老数据自动移出,新数据持续进入
- ✅ 固定窗口:始终显示最近 10 秒数据
- ✅ 性能优化:只保留窗口内数据,内存占用恒定
- ✅ 适合长期监控:可以持续运行数小时不卡顿
模式2:擦除模式(心电监护仪风格)
效果说明:模拟传统心电监护仪效果,显示一条擦除线,擦除后的区域显示新数据。
时刻 T=0-5s:
┌────────────────────────┐
│ ~~~~| │ ← 绿色波形 | 黑色擦除线
└────────────────────────┘
时刻 T=5-10s(擦除线移动):
┌────────────────────────┐
│ ~~~~~~~~| │ ← 擦除线向右移动
└────────────────────────┘
时刻 T=10s(循环到起点):
┌────────────────────────┐
│|~~~~~~~~~~ │ ← 擦除线回到起点,循环显示
└────────────────────────┘
Chart.js + 插件实现:
/**
* 擦除模式实时图表类
*/
class RealtimeEraseChart {
constructor(canvasId, totalDuration = 10, samplingRate = 500) {
this.totalDuration = totalDuration; // 总时长(秒)
this.samplingRate = samplingRate; // 采样率
this.totalPoints = totalDuration * samplingRate;
this.dataBuffer = new Array(this.totalPoints).fill(null); // 初始化为 null
this.currentIndex = 0; // 当前写入位置
this.eraseWidth = samplingRate * 0.5; // 擦除线宽度(0.5秒)
this.initChart(canvasId);
}
initChart(canvasId) {
const ctx = document.getElementById(canvasId).getContext('2d');
// 生成时间轴
const labels = Array.from({ length: this.totalPoints }, (_, i) =>
(i / this.samplingRate).toFixed(2)
);
this.chart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'ECG 信号',
data: this.dataBuffer,
borderColor: '#00A86B',
borderWidth: 2,
pointRadius: 0,
tension: 0,
fill: false,
spanGaps: true // 跨越 null 值
},
{
label: '擦除线',
data: [], // 动态更新
borderColor: 'rgba(255, 0, 0, 0.5)',
borderWidth: 3,
pointRadius: 0,
fill: false,
borderDash: [5, 5] // 虚线
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
title: {
display: true,
text: '实时 ECG 监控(擦除模式)',
font: { size: 16, weight: 'bold' }
},
legend: { display: true }
},
scales: {
x: {
title: { display: true, text: '时间 (秒)' },
ticks: { maxTicksLimit: 10 }
},
y: {
title: { display: true, text: '幅值' },
min: 400,
max: 600
}
}
}
});
}
/**
* 添加新数据点
*/
addData(newData) {
const dataArray = Array.isArray(newData) ? newData : [newData];
dataArray.forEach(value => {
// 写入当前位置
this.dataBuffer[this.currentIndex] = value;
// 擦除前方数据(制造擦除线效果)
for (let i = 1; i <= this.eraseWidth; i++) {
const eraseIndex = (this.currentIndex + i) % this.totalPoints;
this.dataBuffer[eraseIndex] = null;
}
// 移动到下一个位置
this.currentIndex = (this.currentIndex + 1) % this.totalPoints;
});
// 更新擦除线位置
this.updateEraseLine();
// 更新图表
this.chart.data.datasets[0].data = this.dataBuffer;
this.chart.update('none');
}
/**
* 更新擦除线
*/
updateEraseLine() {
const eraseLineData = new Array(this.totalPoints).fill(null);
const eraseX = this.currentIndex;
// 在擦除线位置画一条竖线
if (eraseX < this.totalPoints) {
eraseLineData[eraseX] = 600; // Y轴最大值
}
this.chart.data.datasets[1].data = eraseLineData;
}
/**
* 清空数据
*/
clear() {
this.dataBuffer.fill(null);
this.currentIndex = 0;
this.chart.data.datasets[0].data = this.dataBuffer;
this.chart.update('none');
}
}
// 使用示例
const eraseChart = new RealtimeEraseChart('eraseChart', 10, 500);
// 模拟数据流
setInterval(() => {
const newData = Array.from({ length: 10 }, () =>
500 + Math.sin(Date.now() / 200) * 50 + Math.random() * 20
);
eraseChart.addData(newData);
}, 20);
效果特点:
- ✅ 经典风格:模拟医院心电监护仪
- ✅ 循环显示:到达末尾后自动从头开始
- ✅ 擦除线:红色虚线标识当前位置
- ✅ 固定内存:数据循环覆盖,无需清理
模式3:延迟缓冲模式
效果说明:数据先缓冲 2-3 秒,然后批量更新,避免频繁刷新导致的卡顿。
/**
* 延迟缓冲实时图表类
*/
class RealtimeBufferedChart {
constructor(canvasId, bufferDelay = 2, windowSize = 10, samplingRate = 500) {
this.bufferDelay = bufferDelay * 1000; // 缓冲延迟(毫秒)
this.windowSize = windowSize;
this.samplingRate = samplingRate;
this.maxPoints = windowSize * samplingRate;
this.dataBuffer = []; // 显示缓冲区
this.timeBuffer = [];
this.pendingData = []; // 待处理数据
this.pendingTime = [];
this.startTime = Date.now();
this.lastUpdateTime = Date.now();
this.initChart(canvasId);
this.startUpdateLoop();
}
initChart(canvasId) {
const ctx = document.getElementById(canvasId).getContext('2d');
this.chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'ECG 信号(延迟 2秒)',
data: [],
borderColor: '#00A86B',
borderWidth: 1.5,
pointRadius: 0,
tension: 0,
fill: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
title: {
display: true,
text: '实时 ECG 监控(延迟缓冲模式)',
font: { size: 16, weight: 'bold' }
},
legend: { display: true }
},
scales: {
x: {
title: { display: true, text: '时间 (秒)' },
ticks: { maxTicksLimit: 10 }
},
y: {
title: { display: true, text: '幅值' }
}
}
}
});
}
/**
* 接收新数据(存入待处理队列)
*/
receiveData(newData) {
const dataArray = Array.isArray(newData) ? newData : [newData];
const currentTime = Date.now();
dataArray.forEach(value => {
this.pendingData.push(value);
this.pendingTime.push(currentTime);
});
}
/**
* 启动定时更新循环
*/
startUpdateLoop() {
setInterval(() => {
this.processBuffer();
}, 100); // 每 100ms 检查一次
}
/**
* 处理缓冲区(将延迟足够的数据移到显示区)
*/
processBuffer() {
const currentTime = Date.now();
const moveIndices = [];
// 找出延迟超过阈值的数据
this.pendingTime.forEach((time, index) => {
if (currentTime - time >= this.bufferDelay) {
moveIndices.push(index);
}
});
if (moveIndices.length === 0) return;
// 移动数据到显示缓冲区
moveIndices.forEach(index => {
const value = this.pendingData[index];
const time = ((this.pendingTime[index] - this.startTime) / 1000).toFixed(2);
this.dataBuffer.push(value);
this.timeBuffer.push(time);
// 超过窗口大小时移除旧数据
if (this.dataBuffer.length > this.maxPoints) {
this.dataBuffer.shift();
this.timeBuffer.shift();
}
});
// 清除已处理的数据
this.pendingData = this.pendingData.filter((_, i) => !moveIndices.includes(i));
this.pendingTime = this.pendingTime.filter((_, i) => !moveIndices.includes(i));
// 更新图表
this.updateChart();
}
/**
* 更新图表
*/
updateChart() {
this.chart.data.labels = this.timeBuffer;
this.chart.data.datasets[0].data = this.dataBuffer;
this.chart.update('none');
}
/**
* 清空所有数据
*/
clear() {
this.dataBuffer = [];
this.timeBuffer = [];
this.pendingData = [];
this.pendingTime = [];
this.startTime = Date.now();
this.updateChart();
}
}
// 使用示例
const bufferedChart = new RealtimeBufferedChart('bufferedChart', 2, 10, 500);
// 模拟实时接收数据
setInterval(() => {
const newData = Array.from({ length: 10 }, () =>
500 + Math.sin(Date.now() / 200) * 50 + Math.random() * 20
);
bufferedChart.receiveData(newData); // 数据先进入缓冲队列
}, 20);
效果特点:
- ✅ 平滑显示:避免频繁更新导致的闪烁
- ✅ 延迟可控:可设置 1-5 秒缓冲延迟
- ✅ 批量更新:减少渲染次数,性能更好
- ✅ 适合网络波动:缓冲可以平滑网络延迟
WebSocket 集成示例
/**
* WebSocket + 实时图表完整示例
*/
class WebSocketRealtimeChart {
constructor(canvasId, wsUrl) {
this.chart = new RealtimeScrollChart(canvasId, 10, 500);
this.connectWebSocket(wsUrl);
}
connectWebSocket(wsUrl) {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket 连接成功');
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// 假设服务器发送格式:{ type: 'ecg_data', data: [512, 515, ...] }
if (message.type === 'ecg_data' && Array.isArray(message.data)) {
this.chart.addData(message.data);
}
} catch (error) {
console.error('解析 WebSocket 消息失败:', error);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
};
this.ws.onclose = () => {
console.log('WebSocket 连接关闭,3秒后重连...');
setTimeout(() => {
this.connectWebSocket(wsUrl);
}, 3000);
};
}
disconnect() {
if (this.ws) {
this.ws.close();
}
}
}
// 使用示例
const wsChart = new WebSocketRealtimeChart(
'wsChart',
'wss://api.aiecg.com/realtime/ecg'
);
// 页面卸载时断开连接
window.addEventListener('beforeunload', () => {
wsChart.disconnect();
});
性能优化建议
1. 控制更新频率
// ❌ 不好:每接收一个点就更新(太频繁)
ws.onmessage = (event) => {
chart.addData(event.data);
chart.update(); // 可能每秒更新 500 次!
};
// ✅ 好:批量更新
let buffer = [];
ws.onmessage = (event) => {
buffer.push(...event.data);
};
setInterval(() => {
if (buffer.length > 0) {
chart.addData(buffer);
buffer = [];
}
}, 50); // 每 50ms 更新一次(20 FPS)
2. 使用 requestAnimationFrame
class OptimizedRealtimeChart {
constructor(canvasId) {
this.chart = new RealtimeScrollChart(canvasId);
this.pendingData = [];
this.isUpdateScheduled = false;
this.startAnimationLoop();
}
addData(newData) {
this.pendingData.push(...(Array.isArray(newData) ? newData : [newData]));
// 如果还没有安排更新,则安排一次
if (!this.isUpdateScheduled) {
this.isUpdateScheduled = true;
}
}
startAnimationLoop() {
const loop = () => {
if (this.isUpdateScheduled && this.pendingData.length > 0) {
this.chart.addData(this.pendingData);
this.pendingData = [];
this.isUpdateScheduled = false;
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
}
3. 降采样显示
// 如果采样率很高(如 1000Hz),可以降采样显示
function downsample(data, factor = 2) {
return data.filter((_, index) => index % factor === 0);
}
// 实际采样率 1000Hz,显示时降为 500Hz
const displayData = downsample(receivedData, 2);
chart.addData(displayData);
三种模式对比
| 特性 | 滚动窗口 | 擦除模式 | 延迟缓冲 |
|---|---|---|---|
| 视觉效果 | 流畅滚动 | 经典监护仪 | 平滑显示 |
| 内存占用 | 固定 | 固定 | 稍高(双缓冲) |
| CPU占用 | 低 | 中 | 低 |
| 实时性 | 实时 | 实时 | 延迟2-3秒 |
| 适用场景 | 通用推荐 | 展示/演示 | 网络不稳定 |
| 实现难度 | ⭐⭐ 中 | ⭐⭐⭐ 较难 | ⭐⭐⭐ 较难 |
推荐选择
- 🎯 一般应用:选择"滚动窗口模式"(实现简单,效果好)
- 🏥 医疗展示:选择"擦除模式"(专业感强)
- 📡 网络传输:选择"延迟缓冲模式"(容错性好)
📊 图表库推荐
根据不同场景选择合适的图表库:
| 图表库 | 特点 | 适用场景 | 学习难度 |
|---|---|---|---|
| Chart.js | • 轻量(60KB) • API简单 • 响应式 | • 快速原型 • 中小型数据 • 简单交互 | ⭐ 易 |
| ECharts | • 功能强大 • 中文文档 • 交互丰富 | • 复杂图表 • 多图表联动 • 数据分析 | ⭐⭐ 中 |
| Plotly.js | • WebGL 加速 • 科学绘图 • 3D 图表 | • 大数据量(>100K点) • 实时数据流 • 高性能需求 | ⭐⭐⭐ 难 |
| D3.js | • 底层控制 • 高度定制 • SVG 操作 | • 特殊需求 • 完全自定义 • 复杂动画 | ⭐⭐⭐⭐ 很难 |
| uPlot | • 极致性能 • 超轻量(45KB) • 专注时序 | • 实时监控 • 嵌入式设备 • 移动端 | ⭐⭐ 中 |
推荐方案
- 🚀 快速开始:Chart.js
- 🎯 生产环境:ECharts
- ⚡ 大数据量:Plotly.js 或 uPlot
- 🎨 定制需求:D3.js
💡 常见问题
Q1: 图表显示不全或被截断?
确保容器有明确的高度:
<!-- ❌ 错误:没有设置高度 -->
<canvas id="ecgChart"></canvas>
<!-- ✅ 正确:明确高度 -->
<canvas id="ecgChart" style="height: 400px;"></canvas>
Q2: 大数据量时图表卡顿?
使用以下优化方案:
- 关闭动画:设置
animation: false - 隐藏数据点:设置
pointRadius: 0 - 减少数据点:对原始数据进行合理的降采样
- 使用 dataZoom:ECharts 的 dataZoom 功能只渲染可见区域
- 切换到高性能库:对于超大数据量(>10万点),考虑使用 Plotly.js(支持WebGL)或 uPlot
Q3: 如何导出图表为图片?
// Chart.js / ECharts 通用方法
function exportChart(chart, filename = 'chart.png') {
const canvas = chart.canvas; // Chart.js
// const canvas = chart.getDom().querySelector('canvas'); // ECharts
const url = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = filename;
link.href = url;
link.click();
}
📞 技术支持
如需帮助,请联系:
- 📧 技术支持:contact@heartvoice.com.cn
- ☎ 合作热线:400-0551-927
- 📱 电话:18055165745(祺经理)