☰
Canvas 心电图滚动重绘:环形缓冲与 drawImage 自拷贝平移实战
2026/10/27 22:35:34 网站建设 项目流程

简介:面向前端开发与医疗健康数据可视化学习者的 Canvas 动态心电图实践素材,聚焦在浏览器中实时绘制连续 ECG 波形的核心问题。压缩包共 7 个文件、约 41KB,以 HTML 页面、JavaScript 绘图脚本和 CSS 样式为主,分别承担画布结构、数据更新与波形绘制、界面呈现等职责,并配有说明文本与链接文件辅助理解。已有 3005 人学习。内容围绕画布清屏重绘、时间与电压到像素坐标的映射、定时刷新与曲线平滑等关键环节展开,同时涉及线条颜色、粗细、背景网格与刻度标注等视觉处理,也提示异常数据、缺失值填充和重绘性能优化等工程问题。适合需要参考 Canvas 动画机制、心电图实时渲染流程与基础性能调优思路的读者,对照包内示例理解动态波形从数据到画面的组织方式。

1. 先别急着画线:Canvas 心电图最容易被低估的是“擦除”

某开发者第一次做监护界面,把后端推来的心电数据用 SVG<polyline>一个点一个点往后接,不到 20 秒页面就开始顿,波形还带锯齿。换成 Canvas 之后,同样是 500Hz 采样、25mm/s 走纸,屏幕上密密麻麻三千多个点跑得干干净净。这类 Canvas 心电图的难点从来不是“怎么把点连起来”,而是“怎么在每个刷新周期里,把已经画完的旧画面低成本地挪走,再补上新来的一小段”。这个项目就是围绕滚动重绘搭起来的:静态网格层、波形层、定长采样缓冲、时间驱动的走纸速度,外加可调的增益和心率。它适合拿来做监护界面原型、教学演示,或者给心电算法做一个能实时看波形的可视化前端,新手照着跑一遍就能理解“实时波形”到底是怎么被制造出来的。

2. 工程结构:一个能跑的 Canvas 心电图只用这四个文件

2.1 目录分层与各模块职责

先把文件摊开看。这类项目不需要构建工具,index.html直接引type="module"的脚本就行,本地起一个静态服务就能跑。分层原则只有一条:静态的东西和每帧变的东西必须分开。网格是静态的,画一次就够;波形是每帧变的,只允许在专门的图层上动刀;缓冲和信号源是纯数据,不碰任何 DOM 和 canvas 接口,方便单独测试。

ecg-canvas/ ├── index.html # 两层 canvas + 参数面板 ├── css/ │ └── app.css # 只负责布局与面板样式 └── js/ ├── main.js # 启动、装配、requestAnimationFrame 主循环 ├── grid.js # 心电纸网格,产出离屏 canvas ├── renderer.js # 波形层:滚动、补点、坐标映射 ├── ring-buffer.js # 定长环形缓冲,存 mV 采样值 ├── waveform.js # PQRST 合成信号源,模拟真实心跳 └── control.js # 走纸速度、增益、暂停按钮

main.js是唯一知道所有模块的地方,其余文件互相之间只通过参数和函数调用耦合。waveform.js对外只暴露next(),将来换成 WebSocket 推送的真实数据,改这一个文件即可,renderer.js完全不用动。这种拆法看起来很小题大做,但当你需要同时观察“算法输出的值”和“屏幕上画出来的线”是否一致时,纯数据模块能让你直接console.table打点,省掉大量截图对比的时间。

模块输入输出是否每帧执行
grid.js画布逻辑尺寸、dpr一张离屏网格 canvas否,初始化一次
ring-buffer.jsmV 浮点值可顺序遍历的定长队列是(写入)
waveform.js时间步长一个 mV 值是
renderer.js新采样点、滚动像素波形层像素是
control.js用户点击/拖动配置对象否

2.2 为什么不用 SVG 和 DOM 画这条线

心电波形的数据量有一个很硬的门槛:临床上常见的采样率是 250Hz 到 500Hz,一屏显示 5 秒就是 1250 到 2500 个点,如果做成可回看的长条,几万个点是常态。SVG 的polyline靠一个属性字符串承载全部点,每次追加都要重新解析整条points,浏览器还得重新做一遍几何计算和布局。几百个点还能忍,上千点之后每次更新都是一次全量重排,帧率断崖式下跌,这在血泪经验里几乎是必踩的一脚。

Canvas 是立即模式,画完就忘,没有节点树,没有样式计算。滚动场景下最值钱的一点是:它允许你把画布自己当成图片,贴回自己身上,一次drawImage就把整条历史波形左移了若干像素,代价和画一张图差不多。SVG 做不到这种“自拷贝平移”,只能逐点改坐标。所以选型上没有太多纠结空间:只要波形是持续滚动的,Canvas 就是默认答案;而如果只是画一条静态的十二导联报告图,SVG 反而更合适,因为能导出矢量、能选中、能放大不失真。

2.3 画布尺寸、设备像素比与坐标系的建立

新手最容易翻车的地方在这里。<canvas>有两条尺寸:CSS 样式尺寸和width/height属性尺寸,后者是真正的像素缓冲区大小。只写 CSS 不写属性,画布会按默认 300×150 拉伸,波形糊成一团。正确做法是让属性尺寸等于 CSS 尺寸乘以devicePixelRatio,再用ctx.scale(dpr, dpr)把坐标系拉回逻辑像素,后面所有代码都按逻辑像素写,不用到处乘 dpr。

// main.js —— 建立主画布与波形层,两者尺寸严格一致 const dpr = window.devicePixelRatio || 1; function setupCanvas(el, cssW, cssH) { el.style.width = cssW + 'px'; el.style.height = cssH + 'px'; el.width = Math.round(cssW * dpr); // 缓冲区用设备像素 el.height = Math.round(cssH * dpr); const ctx = el.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后一律用逻辑像素 return ctx; } const W = 900; // 逻辑宽度,对应屏幕上的 CSS 像素 const H = 360; const gridCtx = setupCanvas(document.querySelector('#grid'), W, H); const waveCtx = setupCanvas(document.querySelector('#wave'), W, H);

参数说明:W和H是逻辑像素,直接决定网格里能放下多少毫米;按 5px 等于 1mm 算,900px 宽大概是 180mm,也就是 7.2 秒的走纸长度,正好够看几个心动周期。dpr在普通屏上是 1,在高分屏上是 2 或 3,不改这个,波形边缘会出现明显毛刺。

注意:波形层和网格层必须是两个 canvas,用绝对定位叠在一起。如果把网格和波形画在同一张画布上,滚动平移时会把网格一起挪走,右侧补出来的新区域就成了白板。

3. 绘制链路拆解:从采样点到屏幕像素的每一跳

3.1 心电网格:5px 小格与 25px 大格怎么画得快

心电纸的规格是固定的:1mm 一个小格,5mm 一个大格,走纸 25mm/s、增益 10mm/mV。换算到像素,只要定死 1mm 等于 5 逻辑像素,剩下的全是乘法。小格 5px,大格 25px,画的时候先铺一层浅红底色,再画小格细线,最后画大格粗线,颜色和线宽分三档,视觉上才像真的心电图纸。

// grid.js —— 一次性生成离屏网格,主循环里只做一次 drawImage const PX_PER_MM = 5; const SMALL = PX_PER_MM; // 1mm 小格 5px const BIG = PX_PER_MM * 5; // 5mm 大格 25px export function buildGrid(W, H, dpr) { const off = document.createElement('canvas'); off.width = Math.round(W * dpr); off.height = Math.round(H * dpr); const ctx = off.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = '#fff7f7'; ctx.fillRect(0, 0, W, H); // 小格:1px 细线,颜色淡,靠半像素对齐保证清晰 ctx.strokeStyle = '#f3c9c9'; ctx.lineWidth = 1; ctx.beginPath(); for (let x = 0; x <= W; x += SMALL) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); } for (let y = 0; y <= H; y += SMALL) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); } ctx.stroke(); // 大格:2px 粗线,同一批路径一次描边,比逐条 stroke 快得多 ctx.strokeStyle = '#e39a9a'; ctx.lineWidth = 2; ctx.beginPath(); for (let x = 0; x <= W; x += BIG) { ctx.moveTo(x + 1, 0); ctx.lineTo(x + 1, H); } for (let y = 0; y <= H; y += BIG) { ctx.moveTo(0, y + 1); ctx.lineTo(W, y + 1); } ctx.stroke(); return off; }

这段代码里有两个关键决策。第一,+0.5的半像素偏移,是为了让 1px 宽的线正好落在像素中心,否则浏览器会把线均摊到相邻两个像素上,整张网格看起来像蒙了一层灰。第二,所有小格攒成一条beginPath一次stroke,比循环里每次stroke快一个数量级——网格虽然只画一次,但在窗口 resize 或者打印导出时会重画,这个习惯值得保留。

关于大格的线宽 2px,起始坐标写+1而不是+0.5,是因为 2px 线需要占据两个完整像素,起点落在整数像素边界上才不糊。

3.2 波形缓冲:用定长环形队列替代数组 push

绝大多数教程里的写法是一个普通数组,每次arr.push(v),再从头遍历画线。演示几百个点没问题,跑上十分钟就是几十万个浮点数常驻内存,Array.prototype.shift更是 O(n) 的隐形杀手。这里用Float32Array加读写指针做环形缓冲,写入是 O(1),内存占用在初始化时就固定死了,不会随着运行时间上涨。

// ring-buffer.js —— 定长环形队列,只存 mV 采样值 export class RingBuffer { constructor(capacity) { this.capacity = capacity; this.data = new Float32Array(capacity); // 连续内存,无对象开销 this.head = 0; // 下一次写入的位置 this.size = 0; // 当前有效元素个数 } push(v) { this.data[this.head] = v; this.head = (this.head + 1) % this.capacity; if (this.size < this.capacity) this.size++; } // 按时间顺序遍历,i = 0 表示最旧的点 forEach(fn) { const start = (this.head - this.size + this.capacity) % this.capacity; for (let i = 0; i < this.size; i++) { const idx = (start + i) % this.capacity; fn(this.data[idx], i); } } at(i) { const start = (this.head - this.size + this.capacity) % this.capacity; return this.data[(start + i) % this.capacity]; } }

参数上,capacity决定回溯窗口。按 500Hz、10 秒计算就是 5000,占 20KB 内存,可以忽略。注意这里只存电压值不存时间戳,时间靠固定采样率反推。这在真实设备接入时是个约束:如果数据源的时间间隔不均匀,就必须额外存一个时间数组,否则波形会被拉长或压扁。常见做法是缓存两个Float32Array,一个存值一个存相对时间,绘制时按时间线性插值出 x 坐标。

forEach里的取模运算在每次迭代都会执行,5000 个点下来开销不算小。如果确实到了性能瓶颈,可以把环形缓冲先复制成一段连续数组再遍历,或者干脆用双倍容量、每次写入同时写到两个位置,让任意窗口都连续——这是更进阶的做法,先用简单版跑通再说。

3.3 滚动推进:drawImage 自拷贝平移的擦除技巧

核心来了。每一帧要把历史波形整体左移,右边的空档补上新点。最朴素的写法是清空整层、把缓冲区里所有点重画一遍,5000 个点乘以 60 帧,一秒三十万次lineTo,风扇立刻开始叫。真正的做法是“自拷贝平移”:把画布内容向左复制一份贴回自己身上,重叠部分浏览器内部走的是内存块拷贝,比逐点描边便宜太多。

// renderer.js —— 每帧滚动的核心三步 export class WaveRenderer { constructor(ctx, canvas, W, H, dpr) { this.ctx = ctx; this.canvas = canvas; this.W = W; this.H = H; this.dpr = dpr; this.pxPerSample = 0.25; // 500Hz、25mm/s、5px/mm 推算:125px/s ÷ 500 this.pending = 0; // 累积的亚像素滚动量 } // dxLogical:这一批新点应该推进的逻辑像素宽度 scroll(dxLogical, drawNewPoints) { const dxDev = Math.round(dxLogical * this.dpr); // 转设备像素并取整 if (dxDev <= 0) return; const c = this.canvas; // 1) 整体左移:源矩形与目标矩形同尺寸,只做偏移 this.ctx.drawImage( c, dxDev, 0, c.width - dxDev, c.height, // 源:从 dxDev 开始的右侧区域 0, 0, c.width - dxDev, c.height // 目标:贴到左边 ); // 2) 清空右侧新露出的条带(必须先清,否则会有旧波形残影) this.ctx.clearRect(c.width - dxDev, 0, dxDev, c.height); // 3) 在新条带里补画采样点 drawNewPoints(dxDev); } }

三个参数必须配对:dxDev是整数设备像素,取整是为了避免drawImage做双线性插值时把整层波形糊掉;源矩形的宽高和目标矩形完全一致,只是起点不同;clearRect的坐标同样是设备像素,因为此刻 ctx 上的变换要么是单位矩阵,要么已经被setTransform改过,两种情况下都要保证清空区域和源矩形严丝合缝。

补画新点时,x 坐标从W - dxLogical开始往右推,每点步进pxPerSample,y 坐标用H/2 - value * pxPerMv映射。因为一批新点通常只有 2 到 3 个逻辑像素宽,真正描边的点数很少,整帧开销稳定在毫秒级。

提示:drawImage支持把 canvas 画到它自己身上,规范里明确允许。但源矩形和目标矩形不能重叠到同一个位置,否则第一次运行就会看到波形糊成一片——这是很多人第一次写滚动波形时的经典翻车点。

4. 动态驱动:让 25mm/s 的走纸速度和屏幕帧率各走各的

4.1 用时间戳喂点,而不是按帧数喂点

很多人第一版是这样写的:requestAnimationFrame每触发一次,就产生一个点、平移 1px。在 60Hz 的屏幕上看起来没问题,换到 144Hz 的电竞屏,波形速度直接快了两倍多;浏览器标签页切到后台再切回来,rAF被节流到 1Hz,波形几乎停住。走纸速度是临床参数,25mm/s 是写在报告上的,不能随显示器刷新率漂移。

正确做法是把rAF的时间戳当作唯一时间源,累加经过的时间,用固定采样间隔去“消费”它。这样帧率高低只影响画面平滑度,不影响数据推进速度。

// main.js —— 时间累积驱动,帧率无关 const SAMPLE_RATE = 500; // 采样率 Hz const SAMPLE_MS = 1000 / SAMPLE_RATE; // 每点 2ms const PX_PER_SEC = 25 * 5; // 25mm/s × 5px/mm = 125px/s const PX_PER_SAMPLE = PX_PER_SEC / SAMPLE_RATE; // 0.25px let last = 0; let acc = 0; let scrollAcc = 0; function loop(now) { if (!last) last = now; let dt = now - last; last = now; // 关键保护:切后台回来时 dt 可能是几万毫秒,必须截断 if (dt > 200) dt = 200; acc += dt; let newPoints = 0; while (acc >= SAMPLE_MS) { acc -= SAMPLE_MS; buffer.push(source.next(SAMPLE_MS / 1000)); newPoints++; } if (newPoints > 0) { scrollAcc += newPoints * PX_PER_SAMPLE; const dx = Math.floor(scrollAcc); // 只滚动整数像素 scrollAcc -= dx; // 小数余量留到下一帧,避免累积漂移 renderer.scroll(dx, drawTail); } requestAnimationFrame(loop); }

参数说明:SAMPLE_MS是采样周期,2ms;一帧 16.7ms 大约消费 8 个点,推进 2 个逻辑像素,所以波形看起来是一格一格走而不是丝滑滚动——这恰恰是真实监护仪的样子。scrollAcc把不足 1px 的余量攒起来,保证长时间运行后波形位置不漂移,这是避免“跑了十分钟波形越走越慢”的关键。

dt截断到 200ms 是后悔药级别的保护。没有这一行,用户切走标签页喝杯水回来,页面会瞬间补几千个点、画布连滚几十次,卡到白屏。

4.2 PQRST 波形合成:几个高斯函数拼出像样的心跳

演示项目没有真实设备怎么办?合成一个。心电的每个心动周期由 P、Q、R、S、T 五个波组成,形状上都能用高斯钟形曲线近似,只是幅度、中心位置和宽度不同。R 波最尖(宽度极小、幅度最大),T 波最宽最缓,P 波小而圆。把它们在时间轴上叠加,肉眼看上去就非常像。

// waveform.js —— 用高斯脉冲叠加生成 PQRST const gauss = (t, amp, mu, sigma) => amp * Math.exp(-((t - mu) ** 2) / (2 * sigma * sigma)); // t 的单位是秒,相对 R 峰的时间偏移;返回值单位 mV function beat(t) { return ( gauss(t, 0.15, -0.20, 0.025) + // P 波:幅度 0.15mV,R 峰前 200ms gauss(t, -0.12, -0.025, 0.008) + // Q 波:向下,紧贴 R 峰前 gauss(t, 1.20, 0.000, 0.009) + // R 波:1.2mV,最窄最高 gauss(t, -0.30, 0.028, 0.011) + // S 波:向下,R 峰后 gauss(t, 0.35, 0.250, 0.050) // T 波:0.35mV,宽缓 ); } export class SynthSource { constructor(heartRate = 72) { this.period = 60 / heartRate; // 心动周期,秒 this.t = 0; this.baseline = 0; } next(dt) { this.t += dt; let phase = this.t % this.period; // 把相位映射到 [-0.4, period-0.4],让 R 峰落在周期起点附近 if (phase > this.period - 0.4) phase -= this.period; const v = beat(phase); // 极缓慢的基线漂移,模拟呼吸,幅度 0.03mV this.baseline = 0.03 * Math.sin(this.t * 0.8); return v + this.baseline; } }

参数说明:幅度单位是 mV,配合 10mm/mV 增益和 5px/mm,1mV 在屏幕上就是 50px 高,R 波会占到 60px,视觉上比较接近真实。sigma控制宽度,R 波用 0.009 秒已经非常尖,再小就会出现台阶;P 波用 0.025 秒显得圆润。基线漂移单独加一个极低频正弦,是为了让演示看起来“活”,真实设备里这条漂移线往往是电极接触不良或者呼吸运动造成的,调试滤波器时要专门对付它。

改心率只需要动heartRate,周期自动重算,波形疏密立刻变化。想模拟房颤,可以让 P 波幅度随机抖动、周期随机化,这是往上加功能时最省事的一步。

4.3 参数调节:走纸速度、增益、心率怎么改

界面上放三个控件就够用:走纸速度、增益、暂停。它们不是随便调着玩的,25mm/s 和 10mm/mV 是标准值,改成 50mm/s 会让波形“拉开”,便于观察细节;增益调到 5mm/mV 用于幅度过高的信号,20mm/mV 用于低电压。这三个参数共同决定像素映射公式,改一处要同步改另一处,否则波形会跳。

参数默认值单位影响改动位置
走纸速度25mm/s波形横向疏密、每屏时间窗PX_PER_SEC = 走纸速度 × PX_PER_MM
增益10mm/mV波形纵向高度PX_PER_MV = 增益 × PX_PER_MM
心率72bpm合成波周期SynthSource构造参数
采样率500Hz波形细节、缓冲容量设备侧参数,改动需重算PX_PER_SAMPLE
环形缓冲容量5000点可回溯时长new RingBuffer(capacity)

增益的映射写成y = H / 2 - mv * PX_PER_MM * gain,H / 2是对齐中线的基线。有个细节值得注意:调整增益后,屏幕上已有的历史波形不会自动缩放,因为它们已经是像素了。要么接受这一点(监护仪上调整增益通常也会重新描一段),要么在参数变更时清空波形层重画缓冲区里的全部点,后者更符合直觉,代价是一次最多 5000 点的全量重绘,只在用户点按钮时发生,完全可接受。

5. 避坑排查:波形发虚、撕裂、越跑越卡到底卡在哪

5.1 绘制层:发虚、错位与残影

现象一:整张网格和波形都像蒙了一层雾,1px 的线看起来有两三个像素宽。原因通常有两个叠在一起:canvas 的width属性还是默认的 300,被 CSS 拉伸了;或者线画在整数坐标上,浏览器对 1px 线做了抗锯齿均摊。解决办法是把属性尺寸设成CSS 尺寸 × dpr,再用setTransform(dpr,0,0,dpr,0,0)归一化坐标系,所有描边坐标加 0.5 偏移。这个改动前后差别极大,做完会怀疑之前看的都是假波形。

现象二:波形滚动时右侧新补的一段和左边历史段对不齐,出现明显的断口或者横向撕裂。原因是滚动用了逻辑像素取整,补点却用了浮点坐标,或者滚动的取整方式和补点的起点计算不一致。解决办法是把“本帧应该推进多少像素”算成一个浮点值,取整后用于drawImage,小数部分累加到scrollAcc留给下一帧,补点的起点严格用W - dx计算,两边共用同一个变量。曾经为了省事在补点时用了W - dx + 0.5求清晰,结果每帧偏半像素,一分钟后就裂开一道缝,找了两小时才发现。

现象三:画布右侧残留一小条旧波形,滚过去之后像有个鬼影跟着。这是clearRect的区域和drawImage的目标区域没对齐。drawImage把源图贴到了[0, c.width - dxDev),那么新露出来的空白就是[c.width - dxDev, c.width),clearRect必须精确覆盖这一段。如果 ctx 上还挂着scale(dpr)的变换,而坐标却按设备像素给,清空区域就会算错一半。处理方式是滚动和清空都在设备像素空间里做,画新点前再setTransform回逻辑坐标。

5.2 数据与性能层:卡顿、内存与时间漂移

现象四:页面跑了三四分钟开始明显掉帧,内存曲线一直往上爬,最后标签页崩掉。常见诱因有三个:用普通数组push且从不裁剪,历史数据无限增长;每帧都new Path2D()或者new Float32Array(size),把 GC 压垮;每帧重复设置ctx.font、strokeStyle这些状态,触发内部状态比较。解决方式是换成定长环形缓冲、把路径和数组提到循环外复用、状态设置只在初始化时做一次。改完内存曲线会变成一条水平直线,这是判断有没有改对的直观标准。

现象五:切到别的标签页再切回来,波形猛地往前冲一大段,甚至直接卡死几秒。原因是rAF在后台被降到每秒一两次,回到前台时now - last是几万毫秒,时间累积器一口气消费了几千个采样点,画布连滚几千次。解决办法是在拿到dt之后立刻做上限截断,超过 200ms 一律按 200ms 处理,宁愿丢掉后台这段时间的数据,也不要让主线程补课。真实设备场景里更稳妥的做法是监听visibilitychange,页面隐藏时把数据源暂停或降频,回来时重建时间基准。

注意:不要在rAF回调里做 JSON.stringify、数组排序、DOM 读取这类操作。主循环里每一毫秒都直接对应帧预算,16.7ms 用完就得掉帧,而这些操作在波形平稳时看不出问题,一到数据密集就集中爆发。

6. 二次开发:从演示 Demo 走到能接设备的小监护视图

改造的第一步是把数据源抽出来。定义一个极薄的接口:start(onSample)、stop(),onSample每来一个点回调一次 mV 值。合成源、WebSocket 推送、串口读数都实现这个接口,main.js只跟接口打交道。这样把演示换成真实设备,只需要在入口处换一行实例化代码,绘制链路完全不受影响。我一般会再加一个getSampleRate(),因为不同设备的采样率不一样,PX_PER_SAMPLE必须跟着重算,否则 250Hz 的数据按 500Hz 的步长画,波形会被压缩一半。

// source-adapter.js —— 统一数据源外壳 export class SourceAdapter { constructor(impl) { this.impl = impl; this.sampleRate = impl.sampleRate; // 由具体实现给出,单位 Hz } start(onSample) { this.timer = this.impl.start((mv) => onSample(mv)); } stop() { this.impl.stop?.(); clearInterval(this.timer); } }

第二步是加一个可验证的自测路径。实时波形最麻烦的是“看起来像对的”,实际可能漏点、重复点、时间轴缩放错误。我的习惯是准备一段已知特征的合成信号:1Hz 方波、0.5mV 幅度,跑十秒,然后用代码数一数屏幕上应该出现多少个上升沿、每个沿间隔多少像素。方波比方好数,任何丢点都会让沿的数量对不上。这个自测不写进界面,放在控制台里手动调,但对判断“改完渲染是否还正确”极其有效。

第三步才是往上堆功能:冻结/回放、测量游标、心律标记、多导联并列。这些都要在渲染层加,而不要在数据层加。比如冻结回放,本质是暂停主循环、把环形缓冲里的点全量重画一次,再在画布上叠一层游标;如果一开始就把测量逻辑塞进数据源,回放时数据早就被覆盖了。

改造目标落点注意事项
接真实设备新增 SourceAdapter 实现采样率必须回传,影响像素步长
波形回放renderer.js 增加全量重画入口回放期间必须停掉主循环
多导联每个导联一套 canvas + 缓冲共用一个 rAF 回调,别开多个循环
导出报告网格层 + 波形层合成大图导出前按 2× dpr 重建,否则打印发虚

从那以后我每次接手实时波形类的需求,都会先用一个 1Hz 方波把时间轴、像素步长、缓冲区容量这三件事对齐一遍,肉眼觉得“差不多”的波形,十有八九在时间刻度上是错的。这套顺序看着慢,实际比事后一帧一帧截图比对快得多。希望帮到你。

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

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

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

立即咨询