拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Canvas动态心电图实战:从像素平移到滚动窗口的完整指南

简介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 width900 height300在 Retina 屏幕上显示出来一定是糊的。原因在于 CSS 像素和物理像素不是一比一浏览器默认把 Canvas 的位图拉伸铺满 CSS 尺寸像素不够就会被插值模糊。解决方案是读取window.devicePixelRatioDPR把 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幅度 9sigma 0.22所以矮而宽。这样叠加出来的波形虽然不至于通过医学验证但视觉上已经很有心电味了。想调心跳形态的话核心就是改 center 和 sigma。center 控制峰出现在一个周期里的哪个时间点sigma 控制峰有多宽。需要记住QRS 波群的 sigma 一定要小因为现实中这段电活动本身就是短促有力的T 波和 P 波则可以宽一些太平了反而不像。3.3 相位步长与波形周期模拟数据有了接下来是一次推进一步。画布上每个新采样的横坐标都对应波形相位phase的一次增加。相位走完2π就是一个完整心动周期。要让一个心动周期在画布上占据大约 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 langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleCanvas 动态心电图/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 classmonitor canvas idgrid/canvas canvas idecg/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.width和canvas.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 的滚动型可视化套路就是这一套。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门