Canvas动态心电图实战:从像素平移到滚动窗口的完整指南
2026/9/7 10:58:24 网站建设 项目流程

简介:Canvas动态心电图绘制资源,面向Web前端开发者与医疗健康可视化初学者,解决在浏览器中实时渲染心电数据的问题。示例展示了利用HTML5画布与脚本实现类似监护仪的动态曲线效果,可应用到生理数据监测界面、远程医疗演示和课堂实训等场景。资源包共7个文件,压缩包大小41KB,包含页面骨架、样式表、两个JavaScript脚本,其中主绘制脚本负责数据读取与连线渲染,辅助脚本提供基础库支持,另有说明文档和来源链接方便查阅。目前已有3001人学习下载。通过这份资源,可以掌握画布的清除重绘、移动画点与连线绘制、定时器驱动数据更新等关键技术;同时理解时间与电压值到画布坐标的映射、线条平滑处理,以及数据从接口或硬件获取后的解析思路、缺失值填充、异常值处理和性能优化等实践要点。预览中的完整示例页面可直接运行调试,适合作为动态可视化项目的起步模板,也可按需扩展为多通道或多参数显示。 最近一直在折腾 Canvas 绘图引擎相关的东西,起因是帮一个医疗 demo 项目做了块心电监护大屏。需求看上去很简单:黑底、绿线、带刻度网格,波形从右往左匀速滚动,要做出医院 ICU 监护仪那种"活"的感觉。但真正动手之后才发现,一个 canvas 动态心电图项目,几乎把 Canvas 最核心的几个能力全过了一遍:高分屏适配、离屏绘制、像素级位图操作、requestAnimationFrame 驱动动画,还有滚动窗口的数据设计。这篇文章我就把这个项目从思路到代码完整拆一遍。默认你写过基础 Canvas,但不要求有动画经验,我会把每一步为什么这么做交代清楚,最后给一份能直接跑起来的完整代码,你复制到本地就能看到那个绿色波形在屏幕上潺潺流过。

1. 把需求看透:动态心电图到底在画什么

1.1 监护仪上的波形,和普通折线图有什么不同

如果你用平时做数据可视化的思路来画心电图,第一反应肯定是:拿一个数组存数据,x 轴是时间,y 轴是电压值,然后把整条折线画出来。这能画出静态心电图,但和监护仪上的动态效果是两码事。

监护仪本质是个"滚动窗口"。最新的数据永远出现在最右侧,旧波形整体往左移动,移出左边界就被丢掉了。这个设计不是随便定的,而是因为心电信号是连续采集的,如果不滚动,数据量会无限增长,画面很快就看不到最新状态。工程上等价于一个固定长度的滑动窗口:窗口宽度就是画布宽度,每个横坐标对应一个采样时刻,窗口外面的事情一概不关心。

这个理解直接影响绘制策略。它意味着:你不需要保存无限长的历史数据,只要保证"当前窗口内可见的那一段信号"还在就行。至于怎么让旧画面"整体左移",51 就是 Canvas 的拿手好戏——直接对像素块做平移,而不必把所有数据重新画一遍。这一点在后面滚动实现里是关键,先把"滑动窗口"这个心智模型立住,后面所有代码都好理解了。

1.2 为什么 Canvas 是这里的最优选

抛开习惯不谈,这个场景选 Canvas 而不是 SVG 或纯 DOM 动画,是有明确理由的。

SVG 适合节点数量不多、需要事件绑定和交互的图形。但心电图在 60fps 下滚动,每帧都有大量路径点参与重绘,SVG 的 DOM 节点数量和事件系统反而成了负担,而且它没法做"把已有像素直接搬走"这种操作。DOM 动画就更不用说了,让几百个节点去模拟波形滚动,性能注定拉胯。

Canvas 是位图绘制,你操作的是一块像素缓冲区,灵活性极高:可以把画布当源图像再画回自己,实现平移;可以通过离屏 Canvas 预渲染网格;可以只对新增的 2 像素宽度做局部绘制。这些能力组合起来,恰好覆盖动态心电图的所有需求。另外,Canvas 的兼容性非常稳定,从移动端到 PC 端,基本上没有环境问题,对医疗大屏这类需要长时间稳定运行的场景很重要。

2. 画布准备:清晰度和网格这两个地基不能省

2.1 高分屏适配:不把 DPR 算进去,波形一定发虚

动态心电图的视觉核心是那条清晰锐利的绿色曲线。如果你只是简单写一句<canvas width="900" height="300">,在 Retina 屏幕上显示出来一定是糊的。原因在于 CSS 像素和物理像素不是一比一,浏览器默认把 Canvas 的位图拉伸铺满 CSS 尺寸,像素不够就会被插值模糊。

解决方案是读取window.devicePixelRatio(DPR),把 Canvas 的真实分辨率设置为CSS 尺寸 × DPR,再用ctx.scale(dpr, dpr)把坐标系缩放回 CSS 像素。这样画的时候仍然按逻辑尺寸思考,但底层物理像素是够的,线条自然锐利。

const canvas = document.getElementById('ecg'); const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const width = canvas.clientWidth; const height = canvas.clientHeight; canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); ctx.scale(dpr, dpr);

注意clientWidth要在设置canvas.width之前读取,因为一旦改变 Canvas 的像素宽高,它内部的 CSS 尺寸也会被重置,拿到的就是旧值了。这个顺序踩坑率极高,我最初就是先设width再去读clientWidth,结果所有坐标全都错位。还有一个细节:DPR 可能是 2、3 甚至 1.5 这种小数,记得Math.round,不然可能出现半个物理像素的偏差。

2.2 网格背景:小格大格各代表什么,为什么要单独画

监护仪背景那些小方格不是装饰,在真实心电图纸上,每一个小格宽度代表 0.04 秒(对应 25mm/s 的走纸速度),5 个小格组成一个大格,代表 0.2 秒。医生就是靠这些刻度估算心率和波形间期的。做 demo 虽然不需要严格医学精度,但保留这个视觉层级会让效果非常"像那么回事"。

网格的实现有个关键决策:不要每帧都重绘网格。因为网格是静态的,把它单独放在一个离屏 Canvas 上,只画一次,之后波形 Canvas 叠在上面滚动,互不干扰。如果你把网格放在波形同一个 Canvas 里,平移像素时网格也会被移动,然后你又重绘网格,两层网格叠在一起就会出现密度不一致的脏效果。

function drawGrid() { // gCtx 是网格画布的 2d context gCtx.lineWidth = 1; // 小格:半透明细线 gCtx.strokeStyle = 'rgba(0, 220, 130, 0.10)'; gCtx.beginPath(); for (let x = 0.5; x < width; x += smallGrid) { gCtx.moveTo(x, 0); gCtx.lineTo(x, height); } for (let y = 0.5; y < height; y += smallGrid) { gCtx.moveTo(0, y); gCtx.lineTo(width, y); } gCtx.stroke(); // 大格:更明显的线 gCtx.strokeStyle = 'rgba(0, 220, 130, 0.22)'; gCtx.beginPath(); // 同样的循环,步长换成 largeGrid = smallGrid * 5 gCtx.stroke(); }

这里的0.5偏移是我个人的强迫症:Canvas 的 1px 线条会落在像素网格线上,导致视觉上变成 2px 宽的半透明线。坐标加 0.5 之后,线条正好落在物理像素中心,画出来才是真正干净的 1px。这个技巧在做精细 UI 时非常实用。

3. 模拟心电数据:从零"造"出一个 PQRST 波形

3.1 一个心跳在屏幕上是什么形状:PQRST 波

做 demo 最尴尬的是没有真实心电数据源。你可以用 WebSocket 接设备,也可以读一个 CSV 文件,但本地调试时最方便的方式是直接"造"数据。要造得像,先得认识一个正常心动周期的形状:P 波是一个小圆丘,代表心房除极;紧接着 QRS 波群是大幅度的快速摆动,R 波是那个最高的尖峰;后面跟着一个平缓的 T 波。整个波形有点像"小丘——深谷——高塔——回弹——缓坡"。

如果只是用Math.sin()画个正弦波,那一眼就能看出是假的,完全没有心电图的辨识度。你需要的是几个形状可控的"峰",在合适的时间点叠加起来。

3.2 高斯函数叠加法:几行代码拼出心电信号

高斯函数exp(-((x-center)/sigma)^2)的曲线是一个钟形峰,峰的位置由center控制,胖瘦由sigma控制。把 P、Q、R、S、T 五个波分别用不同中心、不同宽度的高斯峰表示,再加起来,就得到一个很像样的模拟心电信号。

function gauss(x, center, sigma) { const d = (x - center) / sigma; return Math.exp(-d * d); } function ecgSample(t) { const x = t % (2 * Math.PI); return ( -7 * gauss(x, 0.60, 0.18) + -6 * gauss(x, 1.35, 0.08) + 58 * gauss(x, 1.50, 0.045) + -8 * gauss(x, 1.68, 0.08) + 9 * gauss(x, 2.40, 0.22) ); }

这套参数我是调了挺久的。x = 1.50处的 R 波峰值 58 是主力,所以它最突出;Q 波和 S 波分别在 1.35 和 1.68 位置,幅度小但宽度更集中,形成 R 波两侧的快速下探;P 波放在 0.60,幅度 7 对应它"小丘"的定位;T 波在 2.40,幅度 9,sigma 0.22,所以矮而宽。这样叠加出来的波形,虽然不至于通过医学验证,但视觉上已经很有"心电味"了。

想调心跳形态的话,核心就是改 center 和 sigma。center 控制峰出现在一个周期里的哪个时间点,sigma 控制峰有多宽。需要记住:QRS 波群的 sigma 一定要小,因为现实中这段电活动本身就是短促有力的;T 波和 P 波则可以宽一些,太平了反而不像。

3.3 相位步长与波形周期

模拟数据有了,接下来是"一次推进一步"。画布上每个新采样的横坐标,都对应波形相位phase的一次增加。相位走完,就是一个完整心动周期。要让一个心动周期在画布上占据大约 60% 的宽度,可以这样算步长:

const beatWidth = width * 0.6; const phaseStep = (2 * Math.PI) / beatWidth;

这个beatWidth就是"一个心跳画多宽"。你可以把它理解成真实心电图的走纸速度:beatWidth越大,单位宽度内包含的心跳越少,看起来心率越慢;beatWidth越小,波形越密,看起来心率越快。我把 0.6 作为默认值,是因为在 900px 宽的画布里一个完整周期大约 540px,兼顾了可读性和观感。调整这个参数比调帧率更符合直觉。

4. 滚动动画:从全量重绘到像素平移

4.1 先说不推荐的暴力方式

最容易想到的方式是:每个动画帧都清空整块画布,然后从数据数组的第一个点画到最后一个点。代码少,逻辑直白,但这有两个问题。第一个是性能浪费,画布只要 900×300,每秒 60 帧全量重绘,大部分区域画的都是上一帧已经存在的内容,等于反复做无用功。第二个是风险,如果数据数组越来越大,或者以后想叠加多导联波形,重绘点数会线性上涨,很快出现掉帧。

我并不是说全量重绘绝对不能用于动态心电图。窗口很小、数据点很少、设备性能充裕的情况下,它完全能跑。但既然 Canvas 提供了更优雅的工具,而滚动场景又这么典型,用它来展示"像素平移"这个思路再合适不过了。

4.2 像素平移:把 Canvas 当"移动的胶片"

核心想法是:既然上一帧的画面里,除了最右侧的一小条,其他内容在下一帧依然存在,那就不要重画它们,直接把整块位图往左平移 2 个像素,然后在右侧腾出的 2 像素宽区域里补上新数据点。

drawImage有个冷门但强大的用法:它可以以同一个 Canvas 自身作为源图像,再画回自己。规范要求实现时先做一次内部拷贝,所以不用担心画到一半源被破坏。用它实现平移的代码非常短:

function drawWaveform() { // 把现有波形整体左移 speed 像素 eCtx.drawImage( ecgCanvas, speed, 0, width - speed, height, 0, 0, width - speed, height ); // 清掉最右侧腾出的矩形区域,防止残留 eCtx.clearRect(width - speed, 0, speed, height); // 在右侧补画 speed 个新采样点 eCtx.beginPath(); eCtx.strokeStyle = '#00ff88'; eCtx.lineWidth = 1.6; eCtx.shadowColor = 'rgba(0, 255, 136, 0.5)'; eCtx.shadowBlur = 6; for (let i = 0; i < speed; i++) { phase += phaseStep; const x = width - speed + i; const y = baseY - (ecgSample(phase) / 58) * amp; if (i === 0) eCtx.moveTo(x, y); else eCtx.lineTo(x, y); } eCtx.stroke(); eCtx.shadowBlur = 0; }

这里speed = 2,也就是每帧左移 2 个 CSS 像素。为什么是 2 而不是 1.5 或 3?因为drawImage做非整数像素平移时会出现插值模糊,整数像素移动可以保证1:1像素映射,线条始终锐利。2px 在 60fps 下大约是每秒 120px 的滚动速度,视觉上很舒服。如果你想更快,用 3 或 4,但尽量保持整数。

整个流程里最容易被忽略的是clearRect这一步。如果不先清除右边界,上一帧的残影会蔓延到新数据下面,慢慢变成一条毛茸茸的绿色拖影。顺序也很重要:先平移,再清除,最后补新点。如果先清除再平移,那清除区域也会被平移到左边,留下一个透明缺口。

4.3 完整可运行代码

把上面的 DPR 适配、离屏网格、信号模拟和像素平移组合起来,就是下面这个完整的 HTML 文件。直接复制保存成ecg.html,浏览器打开就能看到一个黑底绿线、匀速滚动的动态心电图。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Canvas 动态心电图</title> <style> body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0b0f14; font-family: "Courier New", monospace; } .monitor { position: relative; width: 90vw; max-width: 900px; height: 300px; border-radius: 14px; overflow: hidden; background: #00100a; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); } .monitor canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } </style> </head> <body> <div class="monitor"> <canvas id="grid"></canvas> <canvas id="ecg"></canvas> </div> <script> (() => { const gridCanvas = document.getElementById('grid'); const ecgCanvas = document.getElementById('ecg'); const gCtx = gridCanvas.getContext('2d'); const eCtx = ecgCanvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const width = gridCanvas.clientWidth; const height = gridCanvas.clientHeight; for (const c of [gridCanvas, ecgCanvas]) { c.width = Math.round(width * dpr); c.height = Math.round(height * dpr); } gCtx.scale(dpr, dpr); eCtx.scale(dpr, dpr); const smallGrid = 12; const largeGrid = smallGrid * 5; function drawGrid() { gCtx.lineWidth = 1; gCtx.strokeStyle = 'rgba(0, 220, 130, 0.10)'; gCtx.beginPath(); for (let x = 0.5; x < width; x += smallGrid) { gCtx.moveTo(x, 0); gCtx.lineTo(x, height); } for (let y = 0.5; y < height; y += smallGrid) { gCtx.moveTo(0, y); gCtx.lineTo(width, y); } gCtx.stroke(); gCtx.strokeStyle = 'rgba(0, 220, 130, 0.22)'; gCtx.beginPath(); for (let x = 0.5; x < width; x += largeGrid) { gCtx.moveTo(x, 0); gCtx.lineTo(x, height); } for (let y = 0.5; y < height; y += largeGrid) { gCtx.moveTo(0, y); gCtx.lineTo(width, y); } gCtx.stroke(); } drawGrid(); const amp = 46; const baseY = height / 2; const speed = 2; const PI = Math.PI; const beatWidth = width * 0.6; const phaseStep = (2 * PI) / beatWidth; let phase = 0; function gauss(x, center, sigma) { const d = (x - center) / sigma; return Math.exp(-d * d); } function ecgSample(t) { const x = t % (2 * PI); return ( -7 * gauss(x, 0.60, 0.18) + -6 * gauss(x, 1.35, 0.08) + 58 * gauss(x, 1.50, 0.045) + -8 * gauss(x, 1.68, 0.08) + 9 * gauss(x, 2.40, 0.22) ); } function drawWaveform() { eCtx.drawImage( ecgCanvas, speed, 0, width - speed, height, 0, 0, width - speed, height ); eCtx.clearRect(width - speed, 0, speed, height); eCtx.beginPath(); eCtx.strokeStyle = '#00ff88'; eCtx.lineWidth = 1.6; eCtx.shadowColor = 'rgba(0, 255, 136, 0.5)'; eCtx.shadowBlur = 6; for (let i = 0; i < speed; i++) { phase += phaseStep; const x = width - speed + i; const y = baseY - (ecgSample(phase) / 58) * amp; if (i === 0) eCtx.moveTo(x, y); else eCtx.lineTo(x, y); } eCtx.stroke(); eCtx.shadowBlur = 0; } function loop() { drawWaveform(); requestAnimationFrame(loop); } loop(); })(); </script> </body> </html>

这套代码里我把放大系数写成(ecgSample(phase) / 58) * amp,是因为 R 波峰值最大 58,先归一化到 1 再乘amp = 46,这样不管你怎么调ecgSample里的幅度,最终波形高度始终受控在 46px 左右,不会突然冲出画布。

5. 我从这个项目中踩过的坑

5.1 波形整体发虚,屏幕看着像蒙了一层雾

这是最常见的 DPR 问题。表象是波形边缘有像素过渡,不锐利。排查思路很直接:打印canvas.widthcanvas.clientWidth,如果二者相等或差距很大,说明没有按 DPR 放大。还有人会在 CSS 里给 Canvas 设width: 100%,却忘了设置canvas.width属性,导致位图默认 300×150 被拉伸到大屏,那不仅虚,还变形。记住:canvas.width是物理像素,CSS 尺寸是逻辑像素,这里的比例关系要始终明确。

5.2 滚动一段时间后出现一条条横向拖影

这个问题我在迭代中最先遇到。现象是波形右侧有一片淡淡的绿色残留,像盖章一样越积越明显。原因基本都出在clearRect没清干净,或者清除顺序不对。你清的是右侧speed像素宽的区域,但如果先清了再平移,清除区域会被带进左侧;如果不清除,旧点会一直堆积。还有一个隐蔽版本:speed改成非整数后,drawImage的源区域和目标区域对不齐,边缘产生半透明像素,时间一长也会积累。所以滚动速度务必保持整数。

5.3 波形卡顿,帧率明显下降

动态心电图理论上 60fps 应该毫无压力,如果发现卡顿,先怀疑两件事:有没有每帧都在绘制网格,有没有每帧开启shadowBlur画大量路径。网格问题用离屏 Canvas 解决;阴影是 Canvas 里出了名的性能杀手,shadowBlur会触发额外的模糊计算,尤其当它作用在一长条折线上的时候。我这里只在补新点的那一小段开启阴影,画完立刻设回 0,效果和全屏阴影几乎一样,但性能开销小一个量级。

我把几个高频问题整理成表,方便你对照排查:

现象原因解决办法
波形模糊未按 DPR 设置 Canvas 像素尺寸canvas.width = clientWidth * dpr,再ctx.scale(dpr, dpr)
右侧有拖影clearRect没清或清除顺序不对先平移,再清除右边界,最后补新点
网格越来越密网格和波形画在同一个 Canvas 上网格放离屏 Canvas,只画一次
帧率掉到 30fps每帧重绘网格 /shadowBlur太重网格离屏,阴影只作用于局部新段
页面切后台恢复后波形停顿requestAnimationFrame在后台自动暂停正常现象,回来自然恢复;如需连续数据,用时间戳补帧

5.4 想暂停动画却发现停不下来

很多人会在loop里写setInterval(loop, 16),然后用clearInterval停止。这有两个坑:一是setInterval不感知页面状态,后台标签页里会堆积回调,回来时画面猛地跳一大截;二是有多个 Canvas 动画时还要手动管理句柄。用requestAnimationFrame就不会有这个问题,它的回调会跟随屏幕刷新率,切后台自动暂停,恢复时自动继续,也不需要额外清理资源。如果你确实需要"暂停/继续",用一个标志位控制就行,不要回到定时器的老路上去。

最后再分享一个小技巧:如果你后续想把它接到真实数据源上,把ecgSample(phase)换成dataQueue.shift(),再把phaseStep换成由采样率计算,其余代码几乎不用改。这个项目最值钱的地方在于滚动窗口、增量绘制、离屏分层这套组合拳,它们可以迁移到示波器、频谱流、股票分时图等几乎所有"数据从右往左流"的场景。我做完心电图之后,用它顺手改了个音频示波器 demo,替换数据源和颜色花了不到十分钟。Canvas 的滚动型可视化,套路就是这一套。

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

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

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

立即咨询