
简介Canvas动态心电图绘制示例面向前端开发者和健康监测类Web应用开发者演示如何利用HTML5 Canvas与JavaScript在浏览器中实时渲染心电图波形。示例包含完整的页面入口、样式与绘图逻辑核心代码通过定时器不断更新画布并将模拟或实时数据映射为像素坐标形成类似监护仪的动态曲线。资源压缩包共7个文件包含html、js、css等主要类型其中ecgcanvas.js承担Canvas绘制核心jquery-min.js提供基础支持css负责界面样式txt与url文件包含使用说明与来源信息整体仅41KB轻量易部署。目前已有3002人学习/下载适合希望快速上手Canvas数据可视化、或需要心率波形展示方案的开发者。通过研究该示例可掌握Canvas常用API、坐标换算、数据平滑与重绘优化等关键技巧为扩展真实数据源或接入医疗设备打下基础。1. 动态心电图 Canvas别把一条折线图当成心电图很多前端第一次接这个需求时都以为“动态心电图”就是把 WebSocket 推过来的数字用lineTo连一条线。真正动手后才发现用 canvas 绘制动态心电图至少要把三件事同时做对数据帧要按采样率连续刷新显示窗口要像监护仪一样右进左出波形还要经得住临床医生的眼睛——P 波、QRS 波群、T 波一个都别糊成一团。我接手过两套监护类的前端模块第一版做得非常狼狈波形肉眼可见地一顿一顿换 12 秒窗口后画出来的东西像毛毛虫后来才知道是“每个采样点都画线”引起的视觉噪声。这篇笔记就是从那段时间的踩坑里整理出来的从坐标映射讲到最小可复现代码再把时基、增益、滤波这些细节和真机上的坑一次说清。适合正在做实时监护大屏、Web 端心电回放、或者想基于 Canvas 做一个能看的生理波形动画页面的开发者。2. 先立住渲染模型心电数据怎么从采样点落到屏幕坐标2.1 心电数据的物理含义与坐标单位心电设备输出的原始信号并不是我们眼睛看到的“绿色曲线”而是一串带时间戳的浮点数单位通常是毫伏mV。采样率常见 250Hz 或 500Hz也就是说每秒钟会连续到达 250 或 500 个点。如果设备输出的是 ADC 码值还要先乘以标定系数再换算成 mV不能直接拿码值当成电压值去画。画布上 X 轴的物理含义是“时间”Y 轴的物理含义是“电压幅值”。换算关系就这么两条x leftMargin (t - windowStart) * pxPerSecond y centerY - voltage * pxPerMillivoltpxPerSecond是屏幕上一秒占多少像素pxPerMillivolt是一毫伏占多少像素。临床心电图纸的标准是 25mm/s 走纸速度、10mm/mV 增益但屏幕和纸张不一样我们会让这两个值变成可调的显示参数这就是后面提到的时基和增益。一个重要习惯不要把 Canvas 的物理像素直接当成逻辑坐标来算。高分屏的devicePixelRatioDPR是 2 甚至 3如果不处理画出来的波形在 MacBook 和手机上会发虚而且后续做波形局部放大的时候坐标偏移会让你怀疑人生。通常的做法是让 Canvas 的实际像素数为 CSS 尺寸乘以 DPR再调一次ctx.scale(dpr, dpr)之后所有绘制逻辑仍然按 CSS 像素坐标写。2.2 三种动态效果实现方式重绘全窗、平移旧帧、增量描点实现“动态”有几种常见做法我把它们都跑过一遍结论很明确。第一种是全窗重绘。每来一帧新数据就从当前时间窗口起点一直画到最新采样点整条曲线重新描一遍。这个方案实现最稳网格缩放、冻结波形都顺手。有人担心性能但算一笔账500Hz 采样、显示 8 秒窗口一帧也就是 4000 个采样点Canvas2D 用一次beginPath加批量lineTo绘制耗时通常在 12ms 以内浏览器 60fps 的刷新完全扛得住。我推荐默认用这种。第二种是平移旧帧。把整块画布用drawImage向左移动一个固定步长只在右侧新进来的区域补充绘制。视觉上有早年 CRT 荧光屏那味儿但实际工程里很容易出问题移动步长与采样间隔不同步时会出现锯齿条纹配合 DPR 时drawImage的像素源和目标尺寸换算也容易出边界误差。它只适合数据量极大、重绘成本顶不住的特殊场景普通心电窗口不建议碰。第三种是增量描点。记住上一个点的坐标每次只画“新点连旧点”的一小段折线。性能最高但缺点明显——一旦需要改时基、切增益、做波形冻结或者横竖屏切换你就得维护一整套复杂的坐标复位逻辑。而且窗口左侧的旧波形会逐渐消失无法回看。所以它更适合波形装饰动画不适合正经监护界面。2.3 画像素还是画采样点min-max 合并是监护仪的秘密这里有一个新手最容易踩的坑。500Hz 采样、8 秒窗口、横向只有 800 个 CSS 像素意味着平均每个像素格子里要塞进 5 个采样点。如果老老实实把全部点用lineTo连起来高频的 QRS 波群部分就会变成一坨实心的“毛刷”而基线部分又看起来全是噪声毛刺。监护仪上对这个问题的处理手法是 min-max 合并在时间轴上把每个像素列覆盖到的所有采样点取最小值和最大值然后画一条竖向线段。这样既保留了波峰波谷的真实幅度又不会画出叠成黑团的视觉垃圾。如果某个像素里只有一个采样点那就退化成普通线段。我们做 Web 端心电显示时可以按同样的思路做一个显示层聚合先按屏幕像素宽度决定每像素对应多少个采样点再决定用直接连线还是 min-max 竖线。在 12 秒以上的长窗口这个策略对可读性的提升非常明显。2.4 Canvas 绘制的性能预期多少点一帧不会卡画布性能总被人当成玄学其实摸一次底就有数了。给人眼看到流畅动画帧间隔别超过 33ms。Canvas2D 的常规绘制耗时大致是这样每帧描点数批量 lineTo 耗时体感1000 点以内 1ms无压力随便画4000 点13ms主力业务场景完全够用10000 点612ms开始吃紧不要再叠加阴影模糊50000 点以上30ms掉帧明显必须分块或降采样真正拖垮帧率的往往不是描线本身而是在数据回调里直接触发绘制。WebSocket 每 200ms 推一批点如果每条消息都调用一次 Canvas 绘制主线程就被反复打断。我习惯把数据先推进一个环形缓冲再由requestAnimationFrame统一检查并绘制一帧这样渲染频率始终跟屏幕刷新对齐。提示Canvas 上的渐变、阴影和字体绘制都比普通的lineTo贵一个数量级。心电波形这种单调曲线strokeStyle用预定义字符串常量就行不需要为每帧创建新对象。3. 可复现的最小版本在浏览器本地跑通动态心电图3.1 准备模拟采样数据先造一串像样的心拍做开发板阶段没有真实设备时可以用程序生成心电形态的模拟数据。注意不要用正弦波那画出来跟心电图没有半点关系。我用高斯函数叠加出 P 波、QRS 波群和 T 波再把它们按心拍周期重复顺便加一点基线漂移。// 高斯函数用于生成单个波峰 function gauss(x, center, sigma, amplitude) { const dx (x - center) / sigma; return amplitude * Math.exp(-0.5 * dx * dx); } // 生成一段模拟心电数据单位 mV function generateEcgSamples(sampleRate 250, seconds 5) { const n Math.floor(sampleRate * seconds); const out new Float32Array(n); for (let i 0; i n; i) { const t i / sampleRate; const beatT t % 0.8; // 心率 75bpm每个心拍周期 0.8 秒 let v 0; v gauss(beatT, 0.08, 0.025, 0.12); // P 波 v gauss(beatT, 0.18, 0.012, -0.20); // Q 波 v gauss(beatT, 0.22, 0.018, 1.00); // R 波最高最尖 v gauss(beatT, 0.27, 0.015, -0.25); // S 波 v gauss(beatT, 0.42, 0.040, 0.28); // T 波 v 0.06 * Math.sin(2 * Math.PI * t / 10); // 低频基线漂移 out[i] v; } return out; }这段代码生成的是“长得像心电图”的测试数据不是临床诊断数据。实际项目里开发阶段更推荐用真实设备录一段原始采样值存成离线文件前端按采样率循环回放那样你能从第一步就发现滤波、增益和坐标换算的问题。我见过的很多团队用伪数据调好了界面一接真机就全崩就是因为伪数据太“干净”了。3.2 画布创建与 DPR 适配高分屏不再发虚创建画布时先把 CSS 尺寸和物理像素尺寸分开设置否则 Mac 上看着像蒙了一层雾。const canvas document.getElementById(ecg-canvas); const ctx canvas.getContext(2d); const cssWidth 960; // CSS 像素也就是布局宽度 const cssHeight 320; const dpr window.devicePixelRatio || 1; canvas.style.width cssWidth px; canvas.style.height cssHeight px; canvas.width Math.round(cssWidth * dpr); // 物理像素 canvas.height Math.round(cssHeight * dpr); ctx.scale(dpr, dpr); // 之后所有坐标仍按 CSS 像素算参数说明dpr在高分屏下是 2在部分手机上是 3设了ctx.scale(dpr, dpr)之后你画 100px 的一条线物理上会对应 200 个物理像素点边缘才会锐利。这里要注意ctx.scale会影响所有后续绘制包括线宽。想让波形永远是 1 个 CSS 像素宽就别再额外乘以物理像素比。3.3 绘制心电网格三条指令把底图画出来心电波形必须搭配网格不然医生没法估读时限和幅值。这里不需要追求临床图纸上的每毫米刻度屏幕端更常用的做法是X 方向每 0.2 秒一条细线、每 1 秒一条较亮的分隔线Y 方向按 0.5mV 或 1mV 分格。const leftMargin 48; const topMargin 20; function drawGrid(pxPerSec, pxPerMv) { ctx.save(); ctx.strokeStyle rgba(0, 255, 0, 0.25); ctx.lineWidth 0.5; // 竖向时间网格8 秒窗口内每 0.2 秒一条 ctx.beginPath(); for (let t 0; t 8; t 0.2) { const x leftMargin t * pxPerSec; ctx.moveTo(x, 0); ctx.lineTo(x, cssHeight); } ctx.stroke(); // 横向幅值网格上下各 2mV每 0.5mV 一条 ctx.beginPath(); for (let mv -2; mv 2; mv 0.5) { const y cssHeight / 2 - mv * pxPerMv; ctx.moveTo(0, y); ctx.lineTo(cssWidth, y); } ctx.stroke(); ctx.restore(); }网格线的宽度我控制在 0.5px视觉上能隐约看见即可。如果网格比波形还亮抢视线那就是参数调反了。3.4 滚动窗口加数据缓冲动态效果的核心循环数据进缓冲、渲染统一走requestAnimationFrame这两件事要分开。先把模拟数据按 1 秒的块推进缓冲class EcgBuffer { constructor(capacity) { this.values new Float32Array(capacity); this.count 0; } append(chunk) { for (let i 0; i chunk.length; i) { if (this.count this.values.length) { // 缓冲满了保留后半段腾出前半段接新数据 const half Math.floor(this.values.length / 2); this.values.copyWithin(0, half); this.count half; } this.values[this.count] chunk[i]; } } } const buffer new EcgBuffer(250 * 60); // 存最近 60 秒 setInterval(() { const chunk generateEcgSamples(250, 1); // 每秒生成 250 个点 buffer.append(chunk); }, 1000);然后是绘制循环。每帧计算“当前时间往前 8 秒”的窗口把所有在窗口内的采样点映射到画布 X 轴。采样点对应的时间用“数据总个数除以采样率”往前推const pxPerSec 100; // 1 秒占 100 个 CSS 像素 const pxPerMv 80; // 1mV 占 80 个像素 function drawEcg() { ctx.clearRect(0, 0, cssWidth, cssHeight); drawGrid(pxPerSec, pxPerMv); const windowSec 8; const nowSampleIndex buffer.count; const startIndex Math.max(0, nowSampleIndex - windowSec * 250); ctx.strokeStyle rgba(0, 255, 0, 0.9); ctx.lineWidth 1; ctx.beginPath(); for (let i startIndex; i buffer.count; i) { const timeFromNow (buffer.count - 1 - i) / 250; // 单位秒 const x leftMargin (windowSec - timeFromNow) * pxPerSec; const y cssHeight / 2 - buffer.values[i] * pxPerMv; if (i startIndex) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } } ctx.stroke(); } let lastDraw 0; function frame(now) { requestAnimationFrame(frame); if (now - lastDraw 1000 / 30) return; // 最高 30fps 足够省电 lastDraw now; drawEcg(); } requestAnimationFrame(frame);这里刻意把渲染帧率限制在 30fps。监护波形不需要 60fps 的顺滑度30 帧对大部分显示器已经足够放在嵌入式 WebView 上还能省下 CPU 给解码和通信。模拟数据每 1 秒推一批绘制循环每 33ms 跑一次波形看起来就是持续滚动的。另外示例中的pxPerSec和pxPerMv是硬编码的值。真正做成模块时我会把它们放进一个displayConfig对象由外部事件按钮、快捷键修改修改后下一帧自动生效。这就是下一章要展开的调参逻辑。4. 波形显示的三组调节参数时基、增益与平滑4.1 时基切换6 秒到 30 秒的 X 轴调度时基就是显示窗口的时间跨度。监护仪常见 6 秒、12 秒、30 秒三档。切换时基时不需要清空缓冲只需要把windowSec换成新值并重算pxPerSecfunction setTimeBase(seconds) { windowSec seconds; pxPerSec (cssWidth - leftMargin) / windowSec; }切到 30 秒窗口时每个像素点覆盖的时间跨度变大如果仍然逐点连线QRS 波群会被压缩成一团。这时候就必须用到第二章节说的 min-max 合并绘制把每个像素列内的采样点聚合后再画。一个粗略的判断标准当pxPerSec * sampleRate大于 3 时也就是每像素覆盖超过 3 个采样点就该走 min-max 逻辑。4.2 增益调整波形太高截顶还是太低看不清增益控制的是 Y 轴缩放。同一个信号增益调到 2 倍时 R 波会顶到画布边缘调到 0.5 倍时 P 波和 T 波又可能小到看不清。调增益的代码很简单function setGain(gain) { pxPerMv basePxPerMv * gain; }关键是给增益一个合理的上下限。画布高度只有 320px上下边距各 20px 时可绘制高度约 280px。如果基础增益是 80px/mV那 ±1.75mV 就已经顶到边界切到 2x 后 ±0.875mV 就到边。心电信号 R 波峰值通常 1mV 左右所以 2x 增益下 R 波会触边这是正常的——医生靠这个来判断波形“满幅”了反而会提醒操作者调节增益到合适档位。4.3 平滑与工频干扰滤波放在数据入口而不是绘制层真实设备出来的信号带着 50Hz 工频干扰、肌电噪声和基线漂移。直接画出来波形上下抖动得很碎。常见做法是在数据进入缓冲之前先做一次一阶低通滤波let filteredValue 0; function onEcgSample(rawValue) { if (filteredValue 0) { filteredValue rawValue; return rawValue; } // alpha 为滤波系数250Hz 采样率时取 0.2500Hz 时取 0.1 const alpha 0.2; filteredValue alpha * (rawValue - filteredValue); return filteredValue; }这个滤波器的本质是让新样本只贡献 20% 的权重平滑掉高频毛刺。注意alpha必须跟着采样率变采样率越高相邻样本越接近滤波系数要适当调小否则会把 QRS 波群削平。临床设备上还有专门的 50Hz 陷波器但前端做实时显示时一阶低通已经能把波形修整得比较舒服再重的滤波交给后端处理。注意绝对不要在绘制循环里做滤波。绘图函数每帧跑 30 次如果每次都把整段数据重新算一遍滤波CPU 开销会随着窗口加长而线性增长。滤波只应该发生在数据刚到达时对单个新样本做一次计算。4.4 数据断流与时间戳对齐丢了包就不能硬连动态心电图在真实网络里不总是稳定的。WebSocket 断开几秒或者设备侧临时无数据输出再恢复时缓冲区里的时间线和当前真实时间之间就出现一个空洞。正确做法是让每个采样点记录自己的采样时间或者至少记录“最后一次采样的时间戳”。绘制时如果两个相邻采样点之间的时间间隔超过一个采样周期的 1.5 倍就判定为断点直接停笔留出空白而不是硬把两点连起来。补一个断流标记的逻辑if (timeGap 1.5 / sampleRate) { ctx.moveTo(x, y); // 抬笔不连线 } else { ctx.lineTo(x, y); }留白的好处是让操作者一眼看出“这段没有数据”而不是误以为波形平直。对监护场景来说数据缺失本身就是重要的告警信息不能靠视觉美化把它掩盖掉。5. Canvas 绘制动态心电图避坑记录五个高频事故5.1 iOS Safari 上 toDataURL 导出白图现象页面在 iOS 上正常运行心电图滚动也正常但调用canvas.toDataURL(image/png)导出截图时返回的图片是全白的或者偶尔是 null。原因WebKit 在 iOS 上对 Canvas 的 GPU 合成做了优化连续多次快速调用导出接口时画布内容可能还没被提交到可读缓冲区。用 uniapp 的 canvas 队列功能时这类现象尤其在canvasToTempFilePath之后出现因为队列里上一次导出还没完成第二次导出拿到的就是空帧。解决导出前先手动读取一次像素数据强制浏览器把画布内容同步到 CPU 端function exportAsImage(canvas) { const ctx canvas.getContext(2d); // 强制一次像素回读触发内部同步提交 ctx.getImageData(0, 0, 1, 1); requestAnimationFrame(() { const dataUrl canvas.toDataURL(image/png); // 生成临时链接并下载 }); }如果是 uniapp 环境尽量把导出操作放进 promise 串行队列不要并发调用每次导出间隔至少一帧。我在一个远程会诊项目里就是因为并发导出吃了大亏改成串行后问题消失。5.2 高清屏未设 DPR波形发虚且定位偏移现象同样的代码在普通屏幕正常换到 MacBook Pro 上波形边缘发虚线宽看起来忽粗忽细做 canvas 叠加点击热区时明明点的是 QRS 波峰取到的坐标却偏了。原因canvas 的width和height属性代表物理像素数CSS 上的style.width代表布局尺寸。没设 DPR 适配时高分屏上浏览器会把 960 物理像素拉伸到 1920 布局像素图形自然模糊坐标也不对应。解决按第三节的做法在初始化时就把width cssWidth * dpr和ctx.scale(dpr, dpr)都设置好。坐标换算从此以 CSS 像素为准导出图片再乘以 DPR 倍率就不会出现“导出图片尺寸对不上原图”的尴尬。5.3 切到后台标签页再回来波形突然跳变现象用户把页面切到别的标签页几分钟再切回来时心电图不是从断开处继续走而是直接跳了一段看起来像数据丢了几秒。原因浏览器对后台标签页做了节流requestAnimationFrame直接暂停setInterval被降到每分钟一次。切回前台时第一帧计算的时间增量可能是几百毫秒甚至几十秒如果绘制逻辑把“这段时间内所有缓冲数据”一次性画出来波形就被压缩在一帧里视觉上就是“跳变”。解决记录上一帧的时间计算时间差后设置上限let lastFrameTime performance.now(); function frame(now) { requestAnimationFrame(frame); const elapsed now - lastFrameTime; lastFrameTime now; if (elapsed 500) { // 超过 500ms放弃补帧直接从当前缓冲尾部开始画 lastFrameTime now; return; } drawEcg(); } requestAnimationFrame(frame);放弃补帧比硬补更符合监护需求。心电图是实时波形不是慢动作回放中间缺失的时间让网格刻度明确标出来即可。5.4 长时间运行内存只增不减现象页面跑一晚上浏览器内存从 200MB 涨到 1GBFPS 从流畅掉到十几帧。原因多数情况下不是 Canvas 本身泄漏而是数据缓冲无限增长。比如EcgBuffer如果直接用push进数组又不做截断跑一天下来几十万个浮点数占用的内存非常可观。另一种情况是在绘制函数里每帧调用ctx.createLinearGradient或new Date()生成对象造成频繁 GC。旧版浏览器对setInterval里持续创建闭包也容易积累垃圾。解决缓冲类必须设置容量上限满员时用copyWithin或环形指针覆盖旧数据绘制函数里只做绘制不创建对象。可以用一个简单的统计在控制台观察内存setInterval(() { const mem performance.memory; if (mem) { console.log(used JS heap:, Math.round(mem.usedJSHeapSize / 1024 / 1024), MB); } }, 60000);正常心电显示场景下JS 堆内存应该保持在一个稳定的区间而不是单调上涨。5.5 波形上下颠倒或幅值差十倍现象接入真实设备后R 波朝下或者幅值明显偏大。调试时把原始值打出来数值看起来正常画出来却是反的。原因Y 轴坐标系默认向下增长而心电波形习惯上把正电压显示在上方。如果直接y value * pxPerMv正值会跑到画布下方。幅值差十倍的情况通常是设备输出的码值和毫伏之间还有一层标定关系没做换算比如 1mV 对应 100 个 ADC 单位直接拿 500 当成 500mV 画自然爆表。解决统一封装一个坐标转换函数并加上单测function voltageToY(mv) { return cssHeight / 2 - mv * pxPerMv; }接新设备时先打一个已知幅值的方波或正弦波信号确认屏幕上显示的峰值和输入值一致再开始调试真实波形。这个步骤我做一次踩一次每次都有效。6. 进阶把渲染层接到真实设备数据并用一个指标验收6.1 用 WebSocket 替换模拟数据源的最小改动前面的模拟数据源只需要替换成 WebSocket 接收缓冲和绘制都不用动ws.binaryType arraybuffer; ws.onmessage (evt) { const dv new DataView(evt.data); const sampleCount dv.byteLength / 2; // 假设每个点以 16bit 存储 const chunk new Float32Array(sampleCount); for (let i 0; i sampleCount; i) { const adcValue dv.getInt16(i * 2, true); chunk[i] adcValue * calibration; // calibration设备标定系数 } buffer.append(chunk); };重点是calibration必须从设备协议里查清楚不能猜。很多设备用0.0024这个量级的系数一位误差就会让波形满屏。6.2 渲染层性能基线验证给自己定一个硬指标静态窗口连续运行 30 分钟平均帧间隔不超过 50ms内存曲线不持续上涨。验证用performance.now()打点即可let drawCost 0; function drawEcg() { const t0 performance.now(); // ...原有绘制逻辑 const t1 performance.now(); drawCost t1 - t0; }配合浏览器的 Performance 面板看长任务记录。单帧绘制超过 16ms 要警惕超过 33ms 基本能肉眼看到卡顿。绘制耗时如果随机波动大优先检查 GC 和事件队列里是否有别的任务抢占主线程。6.3 我保留的最小架构与一个习惯如果让我重做一版我会保留三类东西一个带容量上限的EcgBuffer、一个只做绘制的drawEcg函数、一个独立于业务 UI 的displayConfig配置对象。业务代码不管画布像素只改配置下一帧自然生效。这是我这几年做 Canvas 绘图最顺手的一个模式。我还养成了一个习惯每次调完时基或增益一定截一张图记录当时的窗口秒数、增益倍数、采样率三个参数和波形形态放在一起对比。这样下次别人说“波形不对”我能先问一句“你用的哪一档时基”少走很多弯路。这个方向只要你照着上面的框架落一遍把模拟数据换成真流一套能用的动态心电图渲染层就有了。希望帮到你。本文还有配套的精品资源点击获取