☰
Canvas 2D 实时渲染心电波形:高频数据流可视化与性能优化实践
2026/10/18 8:17:21 网站建设 项目流程

简介:这份资源面向前端开发者与医疗健康类应用开发者,聚焦如何用HTML5 Canvas实现动态心电图绘制,解决浏览器端实时展示ECG波形、模拟监护仪效果的问题。包内共7个文件,以js脚本、html页面、css样式为主,另含txt说明与url快捷方式,压缩包约41KB,结构轻量,便于快速运行与二次改造。核心逻辑围绕clearRect清屏、moveTo与lineTo连线、定时器驱动数据刷新展开,并涉及时间轴与电压值到像素坐标的映射、线条颜色与粗细设置、网格与刻度标注等视觉细节,同时给出数据异常、缺失值填充与性能优化的处理思路。已有3003人学习,适合希望掌握Canvas动态绘图、数据可视化与实时渲染技巧的读者参考实践。

1. 用 Canvas 画一张会动的心电图,到底能踩多少坑

第一次接到“把心电波形画到浏览器里”这个需求时,我脑子里蹦出来的方案是 SVG——毕竟路径动画现成的。结果数据一压上来就翻车了:每秒 500 个采样点,画布上要同时保留 8 秒的波形,也就是 4000 个点,SVG 的 DOM 节点直接把页面拖到个位数帧率。后来换成 Canvas,同样的数据量稳在 60fps,内存占用还降了一个数量级。这个项目做的就是这件事:用 Canvas 2D 把心电采样数据实时渲染成滚动的波形图,支持多导联、网格背景、扫描速度调节和暂停回看。适合两类人:一类是做过前端可视化、想搞明白高频数据流怎么不卡的人;另一类是医疗或健康类硬件配套的前端,需要把设备吐出来的数据画成能看的图。代码不依赖任何图表库,纯 Canvas API,拿到就能嵌进现有页面。

2. 架构拆解:数据从设备到画布要过几道手

2.1 整体分层与数据流

这个项目没有用任何框架,就是一个 HTML 文件加一个 JS 模块。但麻雀虽小,分层是清楚的。最底层是数据源层,负责从 WebSocket 或者模拟器拿原始采样值;中间是缓冲层,用一个环形缓冲区存最近 N 秒的数据;最上面是渲染层,每帧从缓冲区取数据画到 Canvas 上。三层之间通过一个简单的发布订阅模式通信,数据源只管往里推,渲染层只管按自己的节奏取,互不阻塞。

为什么不用 requestAnimationFrame 直接绑数据回调?因为数据到达的频率和屏幕刷新率往往对不上。设备可能每 2ms 推一个点,而屏幕 16.7ms 才刷一次。如果来一个点画一次,一是浪费,二是容易撕裂。正确做法是数据先入缓冲区,渲染层按自己的节奏消费。

// 环形缓冲区:固定容量,写满后覆盖最旧的数据 class RingBuffer { constructor(capacity) { this.capacity = capacity; this.buffer = new Float32Array(capacity); this.head = 0; // 写指针 this.tail = 0; // 读指针 this.size = 0; // 当前有效数据量 } push(value) { this.buffer[this.head] = value; this.head = (this.head + 1) % this.capacity; if (this.size < this.capacity) { this.size++; } else { // 缓冲区满了,读指针跟着往前走,丢弃最旧数据 this.tail = (this.tail + 1) % this.capacity; } } // 按时间顺序取出所有有效数据 toArray() { const result = new Float32Array(this.size); for (let i = 0; i < this.size; i++) { result[i] = this.buffer[(this.tail + i) % this.capacity]; } return result; } }

这个环形缓冲区的容量怎么定?假设采样率是 500Hz,你想保留 10 秒的数据,容量就是 5000。用 Float32Array 而不是普通数组,是因为心电数据都是浮点数,TypedArray 在内存和遍历速度上都更优。push方法里那个tail的移动是关键——缓冲区满了之后不是停止写入,而是覆盖最旧的,这样渲染层永远拿到的是最新的窗口数据。

2.2 Canvas 初始化与高分屏适配

Canvas 在 Retina 屏上如果不做处理,画出来的线是糊的。原因是 CSS 像素和物理像素不一致,浏览器会把画布拉伸。解决办法是根据devicePixelRatio放大画布的物理尺寸,再用 CSS 缩回去。

function setupCanvas(canvas, cssWidth, cssHeight) { const dpr = window.devicePixelRatio || 1; // 物理像素尺寸 = CSS 尺寸 × 像素比 canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; // CSS 尺寸保持不变,浏览器负责缩放 canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; const ctx = canvas.getContext('2d'); // 缩放上下文,之后所有绘制都按 CSS 像素坐标来 ctx.scale(dpr, dpr); return ctx; }

这里有个容易忽略的点:ctx.scale是累积的。如果你在 resize 时反复调用setupCanvas,缩放会叠加,画出来的东西越来越大。正确做法是在 resize 时先ctx.setTransform(1, 0, 0, 1, 0, 0)重置变换矩阵,再重新 scale。我一般会把这段逻辑封装成一个resize函数,监听window.resize事件时调用。

2.3 网格背景的绘制策略

心电图的网格不是随便画的,标准是 1mm 一个小格,5mm 一个大格。在屏幕上怎么对应?这取决于你设定的“扫描速度”。常见的 25mm/s 意味着 1 秒的波形占 25 个小格。如果画布宽度是 800px,想显示 8 秒数据,那 1 秒就是 100px,一个小格就是 4px。

网格线如果每帧重画,开销不小。优化思路是把网格画到一个离屏 Canvas 上,每帧直接drawImage贴过来。只有尺寸变化时才重绘离屏画布。

function createGridLayer(width, height, smallStep, largeStep) { const offscreen = document.createElement('canvas'); const dpr = window.devicePixelRatio || 1; offscreen.width = width * dpr; offscreen.height = height * dpr; const ctx = offscreen.getContext('2d'); ctx.scale(dpr, dpr); // 小格线 ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 0.5; ctx.beginPath(); for (let x = 0; x <= width; x += smallStep) { ctx.moveTo(x, 0); ctx.lineTo(x, height); } for (let y = 0; y <= height; y += smallStep) { ctx.moveTo(0, y); ctx.lineTo(width, y); } ctx.stroke(); // 大格线 ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1; ctx.beginPath(); for (let x = 0; x <= width; x += largeStep) { ctx.moveTo(x, 0); ctx.lineTo(x, height); } for (let y = 0; y <= height; y += largeStep) { ctx.moveTo(0, y); ctx.lineTo(width, y); } ctx.stroke(); return offscreen; }

smallStep和largeStep根据扫描速度和画布宽度算出来。比如 25mm/s、画布 800px 显示 8 秒,那 1 秒 100px,1 个小格 4px,大格 20px。这些参数应该做成可配置的,不同设备可能用不同的走纸速度。

3. 核心渲染:波形怎么画才能又稳又跟手

3.1 逐帧绘制的主循环

渲染主循环用requestAnimationFrame驱动,每帧做三件事:清空画布、贴网格、画波形。清空不是必须的,因为网格会覆盖整个画布,但为了保险还是清一下。

class ECGMonitor { constructor(canvas, options) { this.canvas = canvas; this.ctx = setupCanvas(canvas, options.width, options.height); this.buffer = new RingBuffer(options.sampleRate * options.windowSeconds); this.gridLayer = createGridLayer(options.width, options.height, 4, 20); this.speed = options.speed || 25; // mm/s this.gain = options.gain || 10; // mm/mV this.running = true; this.lastTimestamp = 0; } start() { const loop = (timestamp) => { if (!this.running) return; this.render(timestamp); requestAnimationFrame(loop); }; requestAnimationFrame(loop); } render(timestamp) { const { ctx, canvas } = this; const cssWidth = canvas.width / (window.devicePixelRatio || 1); const cssHeight = canvas.height / (window.devicePixelRatio || 1); // 清空 ctx.clearRect(0, 0, cssWidth, cssHeight); // 贴网格 ctx.drawImage(this.gridLayer, 0, 0, cssWidth, cssHeight); // 画波形 this.drawWaveform(ctx, cssWidth, cssHeight); } }

render方法里没有直接用timestamp做动画插值,因为心电数据本身就是按时间采样的,直接按数据顺序画就行。timestamp在这里主要是给后续可能的“扫描线”效果留的。

3.2 波形路径的构建与绘制

画波形就是把缓冲区里的数据点映射到画布坐标,然后连成线。映射关系是:x 轴按时间均匀分布,y 轴按电压值缩放。

drawWaveform(ctx, width, height) { const data = this.buffer.toArray(); if (data.length < 2) return; const midY = height / 2; // 每毫伏对应的像素数:gain(mm/mV) × 每毫米像素数 const pxPerMm = width / (this.speed * (this.buffer.capacity / this.buffer.capacity)); // 更准确:画布宽度对应 windowSeconds 秒,每秒 speed 毫米 const windowSeconds = this.buffer.capacity / this.sampleRate; const pxPerMm = width / (this.speed * windowSeconds); const pxPerMv = pxPerMm * this.gain; ctx.beginPath(); ctx.strokeStyle = '#00b894'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; const stepX = width / (data.length - 1); for (let i = 0; i < data.length; i++) { const x = i * stepX; const y = midY - data[i] * pxPerMv; if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } } ctx.stroke(); }

这里有几个参数需要解释。pxPerMm是每毫米对应多少像素,由画布宽度除以“画布上显示的总毫米数”得到。总毫米数 = 扫描速度(mm/s) × 窗口秒数。pxPerMv是每毫伏对应多少像素,等于pxPerMm × gain。gain是增益,单位是 mm/mV,标准心电图一般是 10mm/mV,意思是 1mV 的电压变化在纸上走 10mm。

stepX是每个数据点在 x 轴上的间距。如果数据点比像素还密,比如 5000 个点画在 800px 上,那很多点会落在同一个像素列,lineTo会重复调用。优化办法是每列只取一个代表值,或者用Math.round合并。不过在现代浏览器上,Canvas 的路径绘制对重复点有优化,5000 个点的路径描边通常在 1ms 以内,不优化也能跑。

3.3 多导联的布局与同步

实际心电图机通常同时显示 12 个导联,但浏览器里一般显示 1 到 6 个。多导联的布局有两种:垂直堆叠和网格排列。垂直堆叠最简单,每个导联占一条水平带,共用同一个时间轴。

drawMultiLead(ctx, width, height, leads) { const leadCount = leads.length; const leadHeight = height / leadCount; leads.forEach((leadData, index) => { const offsetY = index * leadHeight; const midY = offsetY + leadHeight / 2; ctx.save(); // 裁剪到当前导联的区域,防止波形溢出 ctx.beginPath(); ctx.rect(0, offsetY, width, leadHeight); ctx.clip(); ctx.beginPath(); ctx.strokeStyle = leadData.color || '#00b894'; ctx.lineWidth = 1.5; const stepX = width / (leadData.samples.length - 1); for (let i = 0; i < leadData.samples.length; i++) { const x = i * stepX; const y = midY - leadData.samples[i] * pxPerMv; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); ctx.restore(); // 画导联标签 ctx.fillStyle = '#333'; ctx.font = '12px monospace'; ctx.fillText(leadData.name, 8, offsetY + 16); }); }

ctx.save()和ctx.restore()配合clip()使用,确保每个导联的波形不会画到别人的区域里。clip()之后所有绘制都被限制在矩形内,这在多导联且波形幅度大时特别重要,否则一个导联的 QRS 波会盖到相邻导联上。

4. 避坑与排查:那些让我加班到凌晨的细节

4.1 波形抖动或锯齿严重

现象:画出来的线毛毛糙糙,像加了噪声。原因通常是数据本身有高频噪声,或者采样率远高于像素密度导致混叠。解决:在绘制前做一次简单的滑动平均滤波,窗口大小取 3 到 5 个点。如果不想改数据,也可以在绘制时用quadraticCurveTo做平滑,但会损失一些波形细节,对诊断级应用不合适。

4.2 页面切到后台再回来波形断裂

现象:切换标签页一段时间后回来,波形中间空了一截。原因:requestAnimationFrame在后台会被暂停,但数据源还在推数据,缓冲区被写满了,旧数据被覆盖。解决:监听visibilitychange事件,页面隐藏时暂停数据推入或者暂停渲染但继续缓冲,回来时根据时间戳判断是否需要清空重来。我一般会在页面隐藏时记录时间,回来时如果间隔超过窗口时长,直接清空缓冲区重新开始。

4.3 高 DPI 屏上线条模糊

现象:在 MacBook 或者 4K 屏上,线条边缘发虚。原因:前面说的devicePixelRatio没处理,或者处理了但ctx.scale被重复调用。解决:确保setupCanvas只在初始化时调用一次,resize 时先setTransform重置再 scale。另外lineWidth不要设成小于 1 的值,0.5 的线在非整数坐标上会糊。

4.4 数据量大了之后内存暴涨

现象:跑了几分钟后浏览器标签页内存占用飙升。原因:每帧都toArray()创建新数组,垃圾回收跟不上。解决:复用同一个 Float32Array,toArray改成copyTo(target),渲染层持有一个固定大小的数组,每帧往里拷贝。另外环形缓冲区的容量要设上限,不能无限增长。

4.5 暂停后恢复,波形跳变

现象:点暂停再点继续,波形出现一个不连续的跳变。原因:暂停期间数据源可能还在推数据,恢复时缓冲区里的数据时间戳不连续。解决:暂停时同时暂停数据源,或者记录暂停时的最后一个时间戳,恢复时把之后的数据丢弃。如果数据源不可控,就在渲染层做时间戳校验,发现间隔超过阈值就断开路径重新moveTo。

5. 二次开发:从能跑到好用还差哪几步

这个项目的基础版本跑通之后,真正要放到实际场景里用,还有几个地方值得改。第一个是性能,如果你要同时画 12 导联、每个导联 5000 个点,那就是 6 万个坐标计算。我一般会把波形绘制放到OffscreenCanvas里,用 Worker 线程算坐标,主线程只负责贴图。这样即使数据量再翻一倍,主线程也不会卡。

第二个是交互。基础版只有滚动,实际用的时候医生或者用户会想暂停、回看、缩放。暂停好做,加个标志位就行。回看需要把缓冲区改成可索引的,或者干脆存两份数据:一份实时滚动的,一份全量的。缩放涉及坐标映射的重新计算,pxPerMm和stepX都要跟着变,建议把这些参数抽成一个Viewport对象统一管理。

第三个是数据源适配。现在代码里是模拟数据,实际设备可能走 WebSocket、串口转 WebSocket、或者蓝牙。不管哪种,只要把数据推入缓冲区的接口统一成pushSample(value),上层渲染就不用动。我一般会定义一个DataSource接口,有connect、disconnect、onData三个方法,具体实现按协议换。

// 数据源接口示例 class DataSource { connect() { throw new Error('Not implemented'); } disconnect() { throw new Error('Not implemented'); } onData(callback) { this.callback = callback; } } class WebSocketSource extends DataSource { constructor(url) { super(); this.url = url; } connect() { this.ws = new WebSocket(this.url); this.ws.onmessage = (event) => { // 假设服务端推的是 JSON 数组 const samples = JSON.parse(event.data); samples.forEach(v => this.callback && this.callback(v)); }; } disconnect() { if (this.ws) this.ws.close(); } }

验证改造是否成功,我习惯用三个指标:帧率是否稳定在 60fps、内存占用是否在几分钟内保持平稳、暂停恢复后波形是否连续。前两个用 Chrome DevTools 的 Performance 和 Memory 面板看,第三个只能靠眼睛,但可以写个简单的自动化测试,往缓冲区里灌已知模式的数据,然后检查渲染结果的像素值是否符合预期。

从那以后我每次接可视化项目,都强制先跑一遍数据量和帧率的基准测试,再动手写渲染逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询