Skip to main content

Data Visualization Guide

📊 Overview

This guide helps you visualize ECG/PPG data returned by the AIECG API, providing integration examples and detailed parameter descriptions of commonly used charting libraries.

Content Navigation

  • 📈 ECG signal drawing: single-lead and 12-lead waveform graphs
  • 💓 PPG signal drawing: multi-channel PPG waveform display
  • 📊 Chart Library Recommendation: The best choice for different scenarios
  • Performance Optimization: Real-time drawing techniques for large amounts of data

📈 ECG signal visualization

Data format description

ECG data format obtained from AIECG API:

//Single lead data example
const singleLeadData = {
ecgData: [512, 515, 520, 518, 525, 530, ...], // Array of signal data points
ecgSampleRate: 500, // Sampling rate 500Hz (500 points per second)
adcGain: 1000.0, // ADC gain coefficient
adcZero: 0.0 // ADC zero voltage
};

// 12-lead data example
const twelveLeadData = {
ecgSampleRate: 500, // sampling rate
lead_I: [512, 515, 520, ...], // I lead data
lead_II: [510, 518, 522, ...], // II lead data
lead_III: [508, 520, 525, ...], // III lead data
lead_aVR: [505, 512, 518, ...], // aVR lead data
lead_aVL: [515, 520, 528, ...], // aVL lead data
lead_aVF: [518, 525, 532, ...], // aVF lead data
lead_V1: [520, 530, 538, ...], // V1 lead data
lead_V2: [522, 532, 540, ...], // V2 lead data
lead_V3: [525, 535, 542, ...], // V3 lead data
lead_V4: [528, 538, 545, ...], // V4 lead data
lead_V5: [530, 540, 548, ...], // V5 lead data
lead_V6: [532, 542, 550, ...] // V6 lead data
};

Option 1: Draw using Chart.js

Install

npm install chart.js

Single lead ECG drawing

import Chart from 'chart.js/auto';

/**
* Draw single-lead ECG waveform graph
* @param {Array} ecgData - ECG signal data array
* @param {number} samplingRate - sampling rate (Hz)
* @returns {Chart} Chart.js instance
*/
function drawECGChart(ecgData, samplingRate) {
const ctx = document.getElementById('ecgChart').getContext('2d');

// Generate timeline data (unit: seconds)
// For example: sampling rate 500Hz, 15000 points = 30 seconds of data
const timeData = ecgData.map((_, index) => (index / samplingRate).toFixed(3));

const chart = new Chart(ctx, {
type: 'line',
data: {
labels: timeData,
datasets: [{
label: 'ECG signal',
data: ecgData,
borderColor: '#00A86B', // Line color: medical green
borderWidth: 1.5, // line width
pointRadius: 0, // Do not display data points (improve performance)
tension: 0, // Line smoothness (0=straight line, 0.4=smooth curve)
fill: false // Do not fill the area
}]
},
options: {
responsive: true, // responsive layout
maintainAspectRatio: false, //Do not maintain aspect ratio, allow custom height
animation: false, // Disable animation (to improve performance when using large amounts of data)

interaction: {
mode: 'nearest', // mouse interaction mode
axis: 'x', // Only trigger on the X axis
intersect: false // No need to hover exactly on the point
},

plugins: {
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true, // Use dot style
padding: 15
}
},
title: {
display: true,
text: 'ECG waveform',
font: {
size: 16,
weight: 'bold'
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0,0,0,0.8)',
callbacks: {
// Customize prompt box content
label: function(context) {
return `Time: ${context.label}s, Amplitude: ${context.parsed.y}`;
}
}
}
},

scales: {
x: {
type: 'linear', // linear coordinate axis
title: {
display: true,
text: 'Time (seconds)',
font: { size: 14 }
},
ticks: {
maxTicksLimit: 20, // Display up to 20 tick labels
callback: function(value) {
return value.toFixed(1); // Keep 1 decimal place
}
},
grid: {
color: '#e8e8e8', // Grid line color
lineWidth: 1
}
},
y: {
title: {
display: true,
text: 'magnitude',
font: { size: 14 }
},
grid: {
color: '#e8e8e8',
lineWidth: 1
}
}
}
}
});

return chart;
}

// Usage example
const ecgData = [512, 515, 520, 518, 525, ...]; // your ECG data
const samplingRate = 500; // sampling rate

const chart = drawECGChart(ecgData, samplingRate);

Implementation effect description

Canvas size settings:

<!-- It is recommended to set a fixed height and adaptive width -->
<canvas id="ecgChart" style="height: 400px;"></canvas>

Display effect:

  • 📊 X-axis: Displays the time axis, ranging from 0 to the total duration of the data (such as 30 seconds)
  • 📈 Y axis: Display amplitude, automatically adjusted according to the data range
  • 🎨 Waveform: Green continuous lines clearly display the P, QRS, and T wave characteristics of the ECG waveform.
  • 🖱️ Interactive: mouse hover displays precise time and amplitude

Parameter Description:

ParametersDescriptionRecommended values ​​Effects
borderWidthLine thickness1.5-2Too thick will affect details, too thin will make it unclear
pointRadiusData point size0Set to 0 hidden points to improve performance
tensionCurve smoothness0ECG requires sharp waveform, not smooth
animationAnimation effectfalseTurn off animation to accelerate rendering
maxTicksLimitNumber of X-axis labels15-20Avoid overlapping labels

Option 2: Draw using ECharts

Install

npm install echarts

Single-lead ECG drawing (with zoom function)

import * as echarts from 'echarts';

/**
* Use ECharts to draw ECG waveforms
* @param {Array} ecgData - ECG signal data array
* @param {number} samplingRate - sampling rate (Hz)
* @returns {echarts.ECharts} ECharts instance
*/
function drawECGWithECharts(ecgData, samplingRate) {
const chartDom = document.getElementById('ecgChart');
const myChart = echarts.init(chartDom);

// Generate timeline (milliseconds)
// For example: sampling rate 500Hz, interval between each point 1000/500 = 2ms
const timeData = ecgData.map((_, index) =>
((index / samplingRate) * 1000).toFixed(1)
);

const option = {
title: {
text: 'ECG waveform',
left: 'center',
textStyle: {
fontSize: 18,
fontWeight: 'bold'
}
},

tooltip: {
trigger: 'axis', // coordinate axis trigger
axisPointer: {
type: 'cross', // crosshair indicator
label: {
backgroundColor: '#6a7985'
}
},
formatter: function(params) {
const time = parseFloat(params[0].axisValue);
const value = params[0].data;
return `Time: ${time.toFixed(1)}ms<br/>Amplitude: ${value}`;
}
},

grid: {
left: '5%',
right: '5%',
bottom: '15%', // Leave space for dataZoom
top: '10%',
containLabel: true,
backgroundColor: '#fafafa'
},

xAxis: {
type: 'category',
data: timeData,
name: 'Time (ms)',
nameLocation: 'middle',
nameGap: 35,
nameTextStyle: {
fontSize: 14,
fontWeight: 'bold'
},
axisLine: {
lineStyle: {
color: '#333'
}
},
axisTick: {
alignWithLabel: true
}
},

yAxis: {
type: 'value',
name: 'amplitude',
nameLocation: 'middle',
nameGap: 50,
nameTextStyle: {
fontSize: 14,
fontWeight: 'bold'
},
splitLine: {
lineStyle: {
color: '#e8e8e8',
type: 'solid'
}
}
},

series: [{
name: 'ECG',
type: 'line',
data: ecgData,
smooth: false, // No smoothing, keep the original waveform
symbol: 'none', // do not display data point markers
sampling: 'lttb', // Sampling strategy: Largest-Triangle-Three-Buckets
lineStyle: {
color: '#00A86B',
width: 1.5
},
animation: false // turn off animation
}],

// Zoom and scroll functions
dataZoom: [
{
type: 'inside', // Built-in zoom (mouse wheel)
start: 0, // Starting position of initial display data (percentage)
end: 100, //End position of initial display data (percentage)
zoomOnMouseWheel: true, // Wheel zoom
moveOnMouseMove: false, // Mouse movement does not trigger translation
moveOnMouseWheel: true // Hold down Shift + scroll wheel to pan
},
{
type: 'slider', // Slider zoom
start: 0,
end: 100,
height: 30,
bottom: 10,
borderColor: '#ccc',
fillerColor: 'rgba(0, 168, 107, 0.2)',
handleStyle: {
color: '#00A86B'
},
textStyle: {
color: '#333'
}
}
],

// toolbox
toolbox: {
feature: {
dataZoom: {
yAxisIndex: 'none' // Y-axis does not scale
},
restore: {}, // Reset
saveAsImage: { // Save as image
pixelRatio: 2
}
}
}
};

myChart.setOption(option);

//Response to window size changes
window.addEventListener('resize', () => {
myChart.resize();
});

return myChart;
}

// Usage example
const ecgData = [512, 515, 520, 518, ...]; // your ECG data
const samplingRate = 500;

const chart = drawECGWithECharts(ecgData, samplingRate);

Implementation effect description

Container size settings:

<div id="ecgChart" style="width: 100%; height: 500px;"></div>

Display effect:

  • 🔍 Zoom function:

  • Mouse wheel: scroll up and down to zoom the timeline

  • Shift + scroll wheel: pan left and right to view different time periods

  • Bottom slider: drag to view any time period

  • 📊 Scroll display effect:

Initial state: Display full 30 seconds of data ┌────────────────────────────────────┐ │ ECG waveform (0-30s) │ │ P QRS T P QRS T ... │ └────────────────────────────────────┘

After zooming in: The bottom slider displays the current viewing position ┌────────────────────────────────────┐ │ ECG waveform (5-10s zoom) │ │ P wave QRS complex T wave clear details │ └────────────────────────────────────┘ Slider: [━━■━━━━━━━━━━━━━━━━━━━━━] ↑ Current viewing position (5-10 seconds)


- 🎯 **Interactive Features**:
- Crosshair: Displays precise coordinates when the mouse is moved
- Toolbar: The upper right corner provides reset, screenshot and other functions
- Data sampling: automatically optimized display (LTTB algorithm)

**Detailed explanation of parameters**:

| Parameters | Description | Recommended values ​​| Effects |
|------|------|--------|------|
| `dataZoom[0].type` | Zoom type | 'inside' | Built-in zoom, no UI, mouse operation |
| `dataZoom[1].type` | Zoom type | 'slider' | Slider to visualize the current position |
| `sampling` | Data sampling strategy | 'lttb' | Automatic sampling for large amounts of data |
| `grid.bottom` | Bottom margin | '15%' | Reserve space for the slider |
| `symbol` | Data point markers | 'none' | Hide marker points |

---

### 12-lead ECG displayed simultaneously

```javascript
/**
* Draw 12-lead ECG (vertical arrangement)
* @param {Object} leadData - object containing 12 lead data
* @param {number} samplingRate - sampling rate
* @returns {echarts.ECharts} ECharts instance
*/
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'
];

// Calculate the height occupied by each lead (percentage)
const gridHeight = 100 / 12 - 0.8; // Each grid height, leaving 0.8% gap
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));

// Configure the grid
grids.push({
left: '8%',
right: '3%',
top: `${index * (gridHeight + 0.8) + 1}%`,
height: `${gridHeight}%`,
backgroundColor: index % 2 === 0 ? '#fafafa' : '#ffffff' // Zebra pattern
});

//X-axis configuration (only the last one is displayed)
xAxes.push({
type: 'category',
data: timeData,
gridIndex: index,
show: index === leads.length - 1, // Only display the X-axis at the bottom
axisLabel: {
show: index === leads.length - 1,
formatter: '{value}s'
},
axisTick: { show: false },
axisLine: { show: index === leads.length - 1 }
});

// Y axis configuration
yAxes.push({
type: 'value',
gridIndex: index,
name: lead, // lead name
nameLocation: 'middle',
nameGap: 35,
nameTextStyle: {
fontSize: 12,
fontWeight: 'bold',
color: colors[index]
},
splitLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false } // Do not display the Y-axis scale value
});

//data series
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-lead ECG',
left: 'center',
textStyle: {
fontSize: 18,
fontWeight: 'bold'
}
},
grid: grids,
xAxis: xAxes,
yAxis: yAxes,
series: series,

//Global zoom control
dataZoom: [{
type: 'inside',
xAxisIndex: Array.from({length: 12}, (_, i) => i), // Control all 12 X axes
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-lead implementation effect

Container Size:

<div id="ecg12LeadChart" style="width: 100%; height: 1200px;"></div>

Display effect layout:

┌─────────────────────────────────────────┐
│ 12-lead ECG │
├─────────────────────────────────────────┤
│ I │ ~~~~~~~~~~~~~ │ ← Lead 1 (red)
├─────────────────────────────────────────┤
│ II │ ~~~~~~~~~~~~~ │ ← Lead 2 (cyan)
├─────────────────────────────────────────┤
│ III │ ~~~~~~~~~~~~~ │ ← Lead 3 (blue)
├─────────────────────────────────────────┤
│ aVR │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ aVL │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ aVF │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V1 │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V2 │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V3 │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V4 │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V5 │ ~~~~~~~~~~~~~ │
├─────────────────────────────────────────┤
│ V6 │ ~~~~~~~~~~~~~ │
└──────────────────────────────────0-10s──┘
↑ Timeline

Interactive Features:

  • 🔗 SYNC ZOOM: Mouse wheel zoom, all 12 leads synchronized
  • 🎨 Color Differentiation: Each lead uses a different color for easy identification
  • 📏 Aligned Display: All lead timelines are aligned for easy comparison and analysis

💓 PPG signal visualization

PPG data format

const ppgData = {
ppgData: {
red: [120.5, 118.3, 122.1, 125.6, ...], // red channel (660nm)
green: [125.6, 123.2, 127.8, 130.1, ...], // Green light channel (525nm, most commonly used)
infrared: [115.2, 113.8, 117.5, 119.3, ...] // Infrared channel (940nm)
},
ppgSampleRate: 100, // PPG sampling rate is usually 25-125Hz
duration: 30 // Duration (seconds)
};

Draw multi-channel PPG using Chart.js

/**
* Draw multi-channel PPG signals
* @param {Object} ppgData - object containing red/green/infrared
* @param {number} samplingRate - sampling rate
* @returns {Chart} Chart.js instance
*/
function drawPPGChart(ppgData, samplingRate) {
const ctx = document.getElementById('ppgChart').getContext('2d');

// Generate timeline
const timeData = ppgData.green.map((_, index) =>
(index / samplingRate).toFixed(2)
);

const chart = new Chart(ctx, {
type: 'line',
data: {
labels: timeData,
datasets: [
{
label: 'Green light channel (Green)',
data: ppgData.green,
borderColor: '#2ECC71', // green
backgroundColor: 'rgba(46, 204, 113, 0.1)',
borderWidth: 2,
pointRadius: 0,
tension: 0.2, // Slightly smooth (PPG waveform is rounder)
fill: false,
yAxisID: 'y'
},
{
label: 'Red light channel (Red)',
data: ppgData.red,
borderColor: '#E74C3C', // red
backgroundColor: 'rgba(231, 76, 60, 0.1)',
borderWidth: 2,
pointRadius: 0,
tension: 0.2,
fill: false,
yAxisID: 'y'
},
{
label: 'Infrared channel (IR)',
data: ppgData.infrared,
borderColor: '#9B59B6', // purple
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', // Display all channel data at the same time point
intersect: false
},

plugins: {
title: {
display: true,
text: 'PPG multi-channel signal',
font: { size: 16, weight: 'bold' }
},
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true,
padding: 15
}
},
tooltip: {
callbacks: {
title: function(context) {
return `Time: ${context[0].label}s`;
},
label: function(context) {
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)}`;
}
}
}
},

scales: {
x: {
title: {
display: true,
text: 'Time (seconds)',
font: { size: 14 }
},
grid: {
color: '#e8e8e8'
}
},
y: {
title: {
display: true,
text: 'Light intensity value',
font: { size: 14 }
},
grid: {
color: '#e8e8e8'
}
}
}
}
});

return chart;
}

PPG display effect

Canvas Settings:

<canvas id="ppgChart" style="height: 450px;"></canvas>

Display effect:

light intensity

140 │ Green light ~~~~~~
130 │ / \ / \ / \ ← Green light channel (the most obvious pulse wave)
120 │ / \/ \/ \
110 │
100 │ Red light ~~~~~~ ← Red light channel (used for blood oxygen calculation)
90 │ / \ / \ / \
80 │ / \/ \/ \

70 │ Infrared ~~~~~~ ← Infrared channel (deep penetration, basic signal)
60 │ / \ / \ / \
└────────────────────→ Time (seconds)
0 5 10 15 20 25 30

Parameter Description:

ParametersDescriptionPPG special settings
tensionCurve smoothness0.2 (PPG waveform is smoother)
interaction.modeInteraction mode'index' (display three channels at the same time)
borderWidthLine thickness2 (slightly thicker, easier to distinguish channels)

⚡ Real-time data flow visualization

Application scenarios

Real-time data flow visualization is suitable for the following scenarios:

  • 🏥 Wearable device monitoring: real-time display of heart rate and ECG waveforms
  • 🔴 Real-time collection: The hardware device continuously sends data to the client
  • 📡 WebSocket push: The server pushes the analysis results in real time
  • 🎯 Delayed display: Buffer for 2-3 seconds before displaying (smooth transition)

Three real-time display modes

Effect description: Fixed display of the last N seconds of data, old data moves out from the left, and new data enters from the right.

Time T=0s:
┌────────────────────────┐
│ ~~~~~~~ │ ← Display 0-10 seconds
└────────────────────────┘
0 5 10

Time T=5s (added 5 seconds of data):
┌────────────────────────┐
│ ~~~~~~~~ │ ← Display for 5-15 seconds (data on the left is moved out)
└────────────────────────┘
5 10 15

Time T=10s:
┌────────────────────────┐
│ ~~~~~~~ │ ← Display for 10-20 seconds
└────────────────────────┘
10 15 20

Chart.js implementation:

/**
* Real-time rolling window chart class
*/
class RealtimeScrollChart {
constructor(canvasId, windowSize = 10, samplingRate = 500) {
this.windowSize = windowSize; // Display window (seconds)
this.samplingRate = samplingRate; // Sampling rate (Hz)
this.maxPoints = windowSize * samplingRate; // Maximum number of points
this.dataBuffer = []; // data buffer
this.timeBuffer = []; // time buffer
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 real-time signal',
data: [],
borderColor: '#00A86B',
borderWidth: 1.5,
pointRadius: 0,
tension: 0,
fill: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false, // Key: animation must be turned off

plugins: {
title: {
display: true,
text: 'Real-time ECG monitoring (rolling window)',
font: { size: 16, weight: 'bold' }
},
legend: { display: true }
},

scales: {
x: {
title: {
display: true,
text: 'Time (seconds)'
},
ticks: {
maxTicksLimit: 10,
callback: function(value, index, values) {
// Display relative time
return value.toFixed(1);
}
}
},
y: {
title: {
display: true,
text: 'magnitude'
}
}
}
}
});
}

/**
* Add new data points (single or batch)
* @param {Array|Number} newData - new data (array or single value)
*/
addData(newData) {
// Unified processing as array
const dataArray = Array.isArray(newData) ? newData : [newData];

dataArray.forEach(value => {
//Add to buffer
this.dataBuffer.push(value);

// Calculate relative time (seconds)
const elapsedTime = (Date.now() - this.startTime) / 1000;
this.timeBuffer.push(elapsedTime.toFixed(2));

//When the window size is exceeded, remove the oldest data
if (this.dataBuffer.length > this.maxPoints) {
this.dataBuffer.shift();
this.timeBuffer.shift();
}
});

//Update chart
this.updateChart();
}

/**
* Update chart display
*/
updateChart() {
this.chart.data.labels = this.timeBuffer;
this.chart.data.datasets[0].data = this.dataBuffer;
this.chart.update('none'); // 'none' mode: no animation, update immediately
}

/**
*Clear data
*/
clear() {
this.dataBuffer = [];
this.timeBuffer = [];
this.startTime = Date.now();
this.updateChart();
}
}

// Usage example: simulate real-time data flow
const realtimeChart = new RealtimeScrollChart('realtimeChart', 10, 500);

// Simulate WebSocket to receive data
function simulateWebSocket() {
setInterval(() => {
// Simulate receiving 10 new data points (20ms interval)
const newData = Array.from({ length: 10 }, () =>
500 + Math.sin(Date.now() / 200) * 50 + Math.random() * 20
);

realtimeChart.addData(newData);
}, 20); //Receive a batch of data every 20ms
}

// Start simulation
simulateWebSocket();

Effect Features:

  • Smooth scrolling: Old data is automatically moved out, and new data continues to enter
  • FIXED WINDOW: Always display the last 10 seconds of data
  • Performance Optimization: Only retain data within the window, memory usage is constant
  • Suitable for long-term monitoring: It can run continuously for several hours without lagging.

Mode 2: Erase mode (cardiogram monitor style)

Effect Description: Simulates the effect of a traditional ECG monitor, displays an erasure line, and displays new data in the erased area.

Time T=0-5s:
┌────────────────────────┐
│ ~~~~ | │ ← Green waveform | Black erase line
└────────────────────────┘

Time T=5-10s (erasure line movement):
┌────────────────────────┐
│ ~~~~~~~~| │ ← Erase line moves to the right
└────────────────────────┘

Time T=10s (loop to starting point):
┌────────────────────────┐
│|~~~~~~~~~~ │ ← Erase the line and return to the starting point, displaying it in a loop
└────────────────────────┘

Chart.js + plug-in implementation:

/**
* Erase mode real-time chart class
*/
class RealtimeEraseChart {
constructor(canvasId, totalDuration = 10, samplingRate = 500) {
this.totalDuration = totalDuration; //Total duration (seconds)
this.samplingRate = samplingRate; // Sampling rate
this.totalPoints = totalDuration * samplingRate;
this.dataBuffer = new Array(this.totalPoints).fill(null); // Initialized to null
this.currentIndex = 0; // Current writing position
this.eraseWidth = samplingRate * 0.5; // Erase line width (0.5 seconds)

this.initChart(canvasId);
}

initChart(canvasId) {
const ctx = document.getElementById(canvasId).getContext('2d');

// Generate timeline
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 signal',
data: this.dataBuffer,
borderColor: '#00A86B',
borderWidth: 2,
pointRadius: 0,
tension: 0,
fill: false,
spanGaps: true // spans null values
},
{
label: 'erase line',
data: [], // dynamic update
borderColor: 'rgba(255, 0, 0, 0.5)',
borderWidth: 3,
pointRadius: 0,
fill: false,
borderDash: [5, 5] // dashed line
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,

plugins: {
title: {
display: true,
text: 'Real-time ECG monitoring (wipe mode)',
font: { size: 16, weight: 'bold' }
},
legend: { display: true }
},

scales: {
x: {
title: { display: true, text: 'Time (seconds)' },
ticks: { maxTicksLimit: 10 }
},
y: {
title: { display: true, text: 'magnitude' },
min: 400,
max: 600
}
}
}
});
}

/**
* Add new data points
*/
addData(newData) {
const dataArray = Array.isArray(newData) ? newData : [newData];

dataArray.forEach(value => {
//Write current position
this.dataBuffer[this.currentIndex] = value;

// Erase front data (create erasure line effect)
for (let i = 1; i <= this.eraseWidth; i++) {
const eraseIndex = (this.currentIndex + i) % this.totalPoints;
this.dataBuffer[eraseIndex] = null;
}

//Move to next position
this.currentIndex = (this.currentIndex + 1) % this.totalPoints;
});

//Update erase line position
this.updateEraseLine();

//Update chart
this.chart.data.datasets[0].data = this.dataBuffer;
this.chart.update('none');
}

/**
* Update erase lines
*/
updateEraseLine() {
const eraseLineData = new Array(this.totalPoints).fill(null);
const eraseX = this.currentIndex;

//Draw a vertical line at the erase line position
if (eraseX < this.totalPoints) {
eraseLineData[eraseX] = 600; // Maximum value of Y axis
}

this.chart.data.datasets[1].data = eraseLineData;
}

/**
*Clear data
*/
clear() {
this.dataBuffer.fill(null);
this.currentIndex = 0;
this.chart.data.datasets[0].data = this.dataBuffer;
this.chart.update('none');
}
}

// Usage example
const eraseChart = new RealtimeEraseChart('eraseChart', 10, 500);

// Simulate data flow
setInterval(() => {
const newData = Array.from({ length: 10 }, () =>
500 + Math.sin(Date.now() / 200) * 50 + Math.random() * 20
);
eraseChart.addData(newData);
}, 20);

Effect Features:

  • Classic Style: simulate hospital ECG monitor
  • Loop Display: Automatically start from the beginning after reaching the end
  • Erase Line: The red dotted line identifies the current location
  • Pinned Memory: Data loop coverage, no need to clean up

Mode 3: Delay buffer mode

Effect description: The data is first buffered for 2-3 seconds, and then updated in batches to avoid lags caused by frequent refreshes.

/**
* Delayed buffering real-time chart class
*/
class RealtimeBufferedChart {
constructor(canvasId, bufferDelay = 2, windowSize = 10, samplingRate = 500) {
this.bufferDelay = bufferDelay * 1000; // Buffer delay (milliseconds)
this.windowSize = windowSize;
this.samplingRate = samplingRate;
this.maxPoints = windowSize * samplingRate;

this.dataBuffer = []; // Display buffer
this.timeBuffer = [];
this.pendingData = []; // Pending data
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 signal (delay 2 seconds)',
data: [],
borderColor: '#00A86B',
borderWidth: 1.5,
pointRadius: 0,
tension: 0,
fill: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,

plugins: {
title: {
display: true,
text: 'Real-time ECG monitoring (delay buffer mode)',
font: { size: 16, weight: 'bold' }
},
legend: { display: true }
},

scales: {
x: {
title: { display: true, text: 'Time (seconds)' },
ticks: { maxTicksLimit: 10 }
},
y: {
title: { display: true, text: 'magnitude' }
}
}
}
});
}

/**
* Receive new data (store in pending queue)
*/
receiveData(newData) {
const dataArray = Array.isArray(newData) ? newData : [newData];
const currentTime = Date.now();

dataArray.forEach(value => {
this.pendingData.push(value);
this.pendingTime.push(currentTime);
});
}

/**
* Start a scheduled update cycle
*/
startUpdateLoop() {
setInterval(() => {
this.processBuffer();
}, 100); // Check every 100ms
}

/**
* Process buffer (move data with sufficient delay to display area)
*/
processBuffer() {
const currentTime = Date.now();
const moveIndices = [];

// Find data whose delay exceeds the threshold
this.pendingTime.forEach((time, index) => {
if (currentTime - time >= this.bufferDelay) {
moveIndices.push(index);
}
});

if (moveIndices.length === 0) return;

//Move data to display buffer
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);

// Remove old data when window size is exceeded
if (this.dataBuffer.length > this.maxPoints) {
this.dataBuffer.shift();
this.timeBuffer.shift();
}
});

//Clear processed data
this.pendingData = this.pendingData.filter((_, i) => !moveIndices.includes(i));
this.pendingTime = this.pendingTime.filter((_, i) => !moveIndices.includes(i));

//Update chart
this.updateChart();
}

/**
* Update chart
*/
updateChart() {
this.chart.data.labels = this.timeBuffer;
this.chart.data.datasets[0].data = this.dataBuffer;
this.chart.update('none');
}

/**
* Clear all data
*/
clear() {
this.dataBuffer = [];
this.timeBuffer = [];
this.pendingData = [];
this.pendingTime = [];
this.startTime = Date.now();
this.updateChart();
}
}

// Usage example
const bufferedChart = new RealtimeBufferedChart('bufferedChart', 2, 10, 500);

// Simulate receiving data in real time
setInterval(() => {
const newData = Array.from({ length: 10 }, () =>
500 + Math.sin(Date.now() / 200) * 50 + Math.random() * 20
);
bufferedChart.receiveData(newData); // Data enters the buffer queue first
}, 20);

Effect Features:

  • Smooth Display: Avoid flickering caused by frequent updates
  • Controllable delay: 1-5 seconds buffer delay can be set
  • Batch Update: Reduce the number of renderings and achieve better performance
  • Suitable for network fluctuations: Buffering can smooth network delays

WebSocket integration example

/**
* WebSocket + real-time chart complete example
*/
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 connection successful');
};

this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);

// Assume the server sends the format: { type: 'ecg_data', data: [512, 515, ...] }
if (message.type === 'ecg_data' && Array.isArray(message.data)) {
this.chart.addData(message.data);
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};

this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};

this.ws.onclose = () => {
console.log('WebSocket connection closed, reconnect after 3 seconds...');
setTimeout(() => {
this.connectWebSocket(wsUrl);
}, 3000);
};
}

disconnect() {
if (this.ws) {
this.ws.close();
}
}
}

// Usage example
const wsChart = new WebSocketRealtimeChart(
'wsChart',
'wss://api.aiecg.com/realtime/ecg'
);

// Disconnect when the page is unloaded
window.addEventListener('beforeunload', () => {
wsChart.disconnect();
});

Performance optimization suggestions

1. Control update frequency

// ❌ Bad: Update every time a point is received (too frequent)
ws.onmessage = (event) => {
chart.addData(event.data);
chart.update(); // Possibly 500 updates per second!
};

// ✅ Good: batch updates
let buffer = [];
ws.onmessage = (event) => {
buffer.push(...event.data);
};

setInterval(() => {
if (buffer.length > 0) {
chart.addData(buffer);
buffer = [];
}
}, 50); // Update every 50ms (20 FPS)

2. Use 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 no update has been scheduled yet, schedule one
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. Downsampling display

// If the sampling rate is very high (such as 1000Hz), it can be downsampled and displayed.
function downsample(data, factor = 2) {
return data.filter((_, index) => index % factor === 0);
}

//The actual sampling rate is 1000Hz, which is reduced to 500Hz when displayed.
const displayData = downsample(receivedData, 2);
chart.addData(displayData);

Comparison of three modes

FeaturesRolling WindowErase ModeDelay Buffering
Visual effectsSmooth scrollingClassic monitorSmooth display
Memory usageFixedFixedSlightly higher (double buffering)
CPU usageLowMediumLow
Real-timeReal-timeReal-timeDelay 2-3 seconds
Applicable ScenariosGeneral RecommendationDisplay/DemonstrationUnstable Network
Difficulty of Implementation⭐⭐ Medium⭐⭐⭐ Hard⭐⭐⭐ Hard
  • 🎯 General application: Select "rolling window mode" (simple implementation, good effect)
  • 🏥 Medical Display: Select "Erase Mode" (strong sense of professionalism)
  • 📡 Network Transmission: Select "Delay Buffering Mode" (good fault tolerance)

📊 Chart library recommendation

Choose the appropriate chart library according to different scenarios:

Chart libraryFeaturesApplicable scenariosLearning difficulty
Chart.js• Lightweight (60KB)
• Simple API
• Responsive
• Rapid prototyping
• Small to medium data
• Simple interaction
⭐ Easy
ECharts• Powerful functions
• Chinese documentation
• Rich interactions
• Complex charts
• Multi-chart linkage
• Data analysis
⭐⭐ Medium
Plotly.js• WebGL acceleration
• Scientific drawing
• 3D charts
• Large data volume (>100K points)
• Real-time data streaming
• High performance requirements
⭐⭐⭐ Difficult
D3.js• Low-level control
• Highly customizable
• SVG manipulation
• Special needs
• Fully customizable
• Complex animations
⭐⭐⭐⭐ Hard
uPlot• Extreme performance
• Ultra-lightweight (45KB)
• Focus on timing
• Real-time monitoring
• Embedded devices
• Mobile
⭐⭐ Medium
  • 🚀 Quick Start: Chart.js
  • 🎯 Production Environment: ECharts
  • Large data volume: Plotly.js or uPlot
  • 🎨 Customized requirements: D3.js

💡 FAQ

Q1: The chart display is incomplete or truncated?

Make sure the container has a clear height:

<!-- ❌ Error: No height set -->
<canvas id="ecgChart"></canvas>

<!-- ✅ Correct: Clear height -->
<canvas id="ecgChart" style="height: 400px;"></canvas>

Q2: Does the chart freeze when there is a large amount of data?

Use the following optimizations:

  1. Turn off animation: Set animation: false
  2. Hide data points: Set pointRadius: 0
  3. Reduce data points: Reasonably downsample the original data
  4. Use dataZoom: ECharts’ dataZoom function only renders the visible area
  5. Switch to a high-performance library: For extremely large data volumes (>100,000 points), consider using Plotly.js (supports WebGL) or uPlot

Q3: How to export charts as images?

// Chart.js / ECharts common methods
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();
}

📞Technical Support

For assistance, please contact: