☰
Canvas动态雷达扫描效果:极坐标绘制与requestAnimationFrame动画实战
2026/10/12 4:10:28 网站建设 项目流程

前阵子接了一个可视化大屏的需求,客户开口就要一个“动态雷达”效果。一开始我以为是那种多指标打分雷达图,聊下来才明白,他要的是监控大屏上那种旋转扫描、目标点闪烁的雷达扫描效果。于是趁着项目间隙,我用 Canvas 从头撸了一个可复用的动态雷达示例,这里把实现思路、核心代码、以及踩过的坑一起整理出来,供同样被“动态雷达”这个词坑过的同学参考。

这个示例能做什么?简单说:用原生 HTML5 Canvas 绘制一个雷达界面,包含极坐标网格、旋转扫描线、由数据驱动的目标点,目标点带闪烁和出现/消失动画。它可以放在监控大屏里当态势展示组件,也可以作为雷达扫描原理的教学演示,稍微改改还能接实时数据流,扩展成带动态效果的指标雷达图。

如果你有 JavaScript 基础,想了解 Canvas 动画、极坐标绘图、requestAnimationFrame 性能优化,或者正在做可视化大屏,想要炫但不花哨的动效,这篇文章应该能给你一些可以直接抄作业的代码和思路。

1. 项目拆解:这个动态雷达到底在做什么

1.1 把“就是那种雷达”翻译成视觉元素

接需求时第一件事,是把客户口中“就是那种雷达”翻译成四个明确的视觉元素。

第一是圆形区域,整个画面以一个圆为边界,外面通常还有一圈描边和刻度。第二是等距同心圆环,从中心到边缘一般画 4 到 6 圈,用来表示距离或层级。第三是辐射刻度线,从圆心向四周引出 8 到 12 条线,代表方位或维度。第四是一根不断旋转的扫描线,它在圆内扫过,扫到目标点的时候,目标点会变亮或者出现光圈。

这四件事看着简单,但如果一开始没想清楚,写代码的时候就容易东一榔头西一棒子。我习惯先把自己要画的画面拆成“静态层”和“动态层”:网格、刻度、外圈属于静态层,绘制一次后基本不变;扫描线、目标点、闪烁光圈属于动态层,需要每帧重绘。这个拆法直接决定了后面的架构和性能优化方式。

1.2 极坐标思维才是关键

雷达界面天然适合用极坐标来描述。任何一个目标点,只需要一个距离 r 和一个角度 θ 就能确定位置。而 Canvas 的绘图 API 是基于直角坐标系的,所有arc、moveTo、lineTo都要用 x 和 y。所以核心工作就是一句极坐标到直角坐标的换算:

x = cx + r * cos(θ) y = cy + r * sin(θ)

其中(cx, cy)是圆心坐标。这个公式后面会反复用到,我建议在一开始就封装成一个独立的函数,而不是散落在各个绘制函数里。一个额外要注意的点是角度方向:Canvas 的弧度制里,0 弧度在圆心正右方,顺时针方向为正。习惯上雷达图喜欢从正上方开始转,所以如果你想让 0 度对齐正上方,可以在传入角度时统一减去一个Math.PI / 2,或者在画刻度时分段处理。这个细节很容易被忽略,后面排查问题部分还会再提。

2. Canvas 选型与整体架构

2.1 为什么不用 SVG、不用 WebGL、不用现成图表库

拿到需求后其实也犹豫过,要不要直接用现成的图表库。后来想了几个理由,还是决定用原生 Canvas 手写。

SVG 适合元素数量少、需要大量交互的图形,但雷达扫描这个效果动辄每帧要画几十个圆和线,SVG 的 DOM 节点更新成本偏高,颜色渐变和尾迹效果做起来也别扭。WebGL 性能上限最高,但对这种 2D 界面来说属于杀鸡用牛刀,shader 写起来复杂,浏览器兼容和调试成本都上来了。第三方图表库能画雷达图,但你要的“扫描线旋转、目标点闪烁”这些动态效果,往往不在库的默认能力范围内,最后还是要用底层 Canvas 或事件钩子去折腾,反而被库的抽象束缚住。

所以最终选型是原生 Canvas:足够轻、兼容性好、所有动效都能掌控,代码量也并不大。做这个决定时心里就清楚,Canvas 是“每一帧都清屏重画”的模式,只要保证单帧绘制量可控,60fps 是很容易达到的。

2.2 分层绘制:把画面拆成两层

前面提到把画面拆成静态层和动态层,具体到代码架构上也是这样分文件或者分模块的。

我这里推荐的结构是四个职责清晰的模块:

  • 初始化模块:负责创建 Canvas、处理尺寸和高分屏适配。
  • 静态绘制模块:负责画网格、刻度、外圈,输出到一个离屏 Canvas。
  • 动态绘制模块:负责画扫描线、目标点、闪烁光圈。
  • 动画驱动模块:负责 requestAnimationFrame 循环、时间计算和帧调度。

静态层单独画到一个离屏 Canvas 上,是后面性能优化的关键一招。因为网格和刻度每帧都一样,与其在动画循环里重复走一遍 path,不如只画一次,然后每帧用drawImage把静态图贴回去。这个优化在后面 4.3 节会详细展开。

3. 核心实现:从静态网格到动态扫描

3.1 初始化与高分屏适配

初始化 Canvas 时最容易踩的坑就是模糊。很多人直接canvas.width = 400,写完后发现边缘锯齿、字体发虚,其实是没处理设备像素比 DPR。

做法是让 Canvas 的物理像素等于 CSS 逻辑像素乘以 DPR,然后通过ctx.scale把坐标系缩放回逻辑尺寸:

const canvas = document.getElementById('radar'); const ctx = canvas.getContext('2d'); const DPR = window.devicePixelRatio || 1; const SIZE = 480; // 逻辑尺寸,单位 px canvas.style.width = SIZE + 'px'; canvas.style.height = SIZE + 'px'; canvas.width = SIZE * DPR; canvas.height = SIZE * DPR; ctx.scale(DPR, DPR);

这样做的原理是:CSS 像素和物理像素在高分屏上不是 1:1。比如 DPR=2 的屏幕上,1 个 CSS 像素对应 2x2 个物理像素。如果 Canvas 只分配了 CSS 像素大小的缓冲,浏览器就要把它拉伸到物理屏,自然就糊了。设成SIZE * DPR后,缓冲足够大,再通过ctx.scale(DPR, DPR)让绘图坐标不变,一举两得。

另外我习惯把画布尺寸声明成正方形,因为雷达本质是个圆,圆形区域外留一点边距用来画描边。maxRadius = SIZE / 2 - 20,这 20px 就是留给外圈描边和刻度文字的呼吸空间。

3.2 网格、刻度与中心点:先把骨架搭起来

静态层我按同心圆、辐射刻度、外圈、中心点四步画。先说同心圆,数量取 5 圈,半径等分:

function drawGrid(ctx) { const cx = SIZE / 2; const cy = SIZE / 2; const maxRadius = SIZE / 2 - 20; // 同心圆环 const rings = 5; for (let i = 1; i <= rings; i++) { ctx.beginPath(); ctx.arc(cx, cy, maxRadius * i / rings, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(0, 200, 255, 0.35)'; ctx.lineWidth = 1; ctx.stroke(); } // 辐射刻度线 const divisions = 12; for (let i = 0; i < divisions; i++) { const angle = (Math.PI * 2 / divisions) * i; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + maxRadius * Math.cos(angle), cy + maxRadius * Math.sin(angle)); ctx.strokeStyle = 'rgba(0, 200, 255, 0.25)'; ctx.stroke(); } }

刻度线数量我取了 12,对应表盘上的 12 个方位。如果你要配套方位标签(比如角度数字、方位说明),可以把标签绘制放进静态层,一次画好。这里有个小经验:画文字标签时记得设置ctx.textAlign = 'center'和ctx.textBaseline = 'middle',这样标签会以目标点为基准居中,不会东倒西歪。标签的位置一般在圆的外侧,半径取maxRadius + 16左右,避免跟最外圈重合。

颜色方面,我用了经典的荧光青rgba(0, 200, 255, ...),这个颜色在深色大屏上非常耐看。想要复古雷达那种荧光绿的,改成rgba(57, 255, 20, ...)也完全没问题。关键是透明度梯度要拉开:网格主体透明度 0.3 左右,外圈再亮一点,这样画面才有层次。

3.3 扫描线与目标点动画

扫描线是整个动效的灵魂。我的做法是维护一个不断增加的sweepAngle,每帧用它画一个扇形尾迹和一条射线。扇形尾迹让扫描看起来有“扫过留痕”的感觉:

function drawSweep(ctx, sweepAngle) { const cx = SIZE / 2; const cy = SIZE / 2; const maxRadius = SIZE / 2 - 20; const tail = 0.6; // 尾迹角度范围,弧度 // 扇形尾迹 ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, maxRadius, sweepAngle - tail, sweepAngle); ctx.closePath(); ctx.fillStyle = 'rgba(0, 220, 255, 0.08)'; ctx.fill(); // 扫描射线 ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo( cx + maxRadius * Math.cos(sweepAngle), cy + maxRadius * Math.sin(sweepAngle) ); ctx.strokeStyle = 'rgba(0, 230, 255, 0.9)'; ctx.lineWidth = 2; ctx.stroke(); }

目标点可以简单地用数据驱动:每个目标有基准半径、基准角度、运动幅度和速度。在极坐标里让目标绕着圆心做小幅运动,再叠加一个基于时间的正弦波动,就能模拟出“目标在移动”的感觉。

const targets = [ { r: 80, angle: 0.5, amplitude: 15, speed: 0.01 }, { r: 140, angle: 2.2, amplitude: 30, speed: 0.02 }, { r: 180, angle: 4.1, amplitude: 12, speed: 0.015 } ]; function drawTargets(ctx, time) { targets.forEach((t, idx) => { const currentAngle = t.angle + time * t.speed; const currentR = t.r + Math.sin(time * 0.8 + idx) * t.amplitude; const pos = polarToXY(currentR, currentAngle); // 光圈 ctx.beginPath(); ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255, 80, 120, 0.15)'; ctx.fill(); // 光点 ctx.beginPath(); ctx.arc(pos.x, pos.y, 3, 0, Math.PI * 2); ctx.fillStyle = '#ff4d6a'; ctx.fill(); }); }

注意time是一个从动画开始累计的秒数,而不是帧数。用时间而不是帧数驱动动画,是保证不同刷新率下速度一致的关键。

3.4 动画主循环与时间驱动

动画循环用requestAnimationFrame,它天然跟屏幕刷新率同步,比setInterval更适合做画面更新:

let startTime = null; function animate(time) { if (!startTime) startTime = time; const elapsed = (time - startTime) / 1000; const sweepAngle = (elapsed * 0.7) % (Math.PI * 2); ctx.clearRect(0, 0, SIZE, SIZE); ctx.drawImage(staticCanvas, 0, 0, SIZE, SIZE); drawSweep(ctx, sweepAngle); drawTargets(ctx, elapsed); requestAnimationFrame(animate); } requestAnimationFrame(animate);

这里elapsed * 0.7里的 0.7 表示每秒扫过 0.7 圈,也就是大约 1.4 秒转一圈。这个速度在监控大屏上看起来比较从容。如果你要更紧张的效果,改成 1.5 到 2 就行。用% (Math.PI * 2)取模是为了让角度始终在 0 到 2π 之间,避免浮点数无限增大导致精度问题。

如果直接把sweepAngle = elapsed * 0.7丢进去,即使刷新率是 120Hz,每帧时间戳间隔更小,累计速度也不会变快。这就是时间驱动的好处。换做sweepAngle += 0.02这种帧增量写法,在 120Hz 屏上转得会比 60Hz 快一倍,这种问题很难排查。

4. 动画、交互与性能优化

4.1 拖尾效果的两种实现

雷达扫描之所以有味道,很大程度上靠扫描线后面的“余晖”。我第一次做时用clearRect每帧清屏,扫过去的地方干干净净,完全不像雷达。后来试了两个方案。

第一个方案是画扇形尾迹,就是我上面写的drawSweep里的arc扇形,透明度很低。它的原理很简单,视觉上就是一条光带,而且不会污染后面的画面。

第二个方案是“半透明覆盖”尾迹,每帧不clearRect,而是用一个半透明深色矩形盖住整个画布,让上一帧的画面残留在下面,自然形成拖影:

ctx.fillStyle = 'rgba(3, 12, 22, 0.2)'; ctx.fillRect(0, 0, SIZE, SIZE);

这个方案效果更炫,但有个大坑:如果背景不是纯色,叠加几十帧后画布会被盖成不透明的深色,目标点也会留下长长的残影。所以我一般只在深色纯背景的演示场景用,产品级的还是偏向扇形尾迹加少量透明层。

如果想要更亮的光效,可以在渲染扫描线时临时把ctx.globalCompositeOperation设成'lighter',这样亮色叠加会变亮,扫描线扫过目标点时会产生一种发光感。但别整帧都用,画完记得切回'source-over',否则背景会变得很奇怪。

4.2 目标点生命周期与数据接入

真实场景里,目标点不应该是我写死的三个坐标,而是来自接口、WebSocket 之类的实时数据。我通常会为每个目标加一个life字段,从 0 增长到 1,控制它从出现到消失的时间曲线:

function updateTargets(rawList, deltaTime) { // rawList: [{ id, r, angle }] // 用 map 维护 targets,id 相同就更新,id 不存在就新增 // 每帧把所有目标的 life 增加,life > 1 的移除 }

这个生命周期模型用在实际项目中很实用。比如某个目标在一段时间内没有上报数据,就可以让它“渐渐消失”而不是直接消失,视觉上更平滑。消失的逻辑就是opacity = 1 - life或者半径缩小。

如果拿它来做指标雷达图(那个多边形的雷达图),原理也一样:把雷达图的多边形顶点当作“目标点”,顶点的值随数据更新,再对顶点的过渡做插值动画。扫描线可以退化成外围光晕,核心的极坐标和动画框架完全能复用。

4.3 离屏 Canvas 缓存静态层

性能优化上,离屏缓存是性价比最高的一招。静态层每帧都在画 5 个圆加 12 条线,虽然不多,但放大后加上渐变就是不少 path 操作。思路是把静态层画到一个不显示的 Canvas 上,然后动画循环里直接drawImage整图贴回去:

const staticCanvas = document.createElement('canvas'); staticCanvas.width = SIZE * DPR; staticCanvas.height = SIZE * DPR; const staticCtx = staticCanvas.getContext('2d'); staticCtx.scale(DPR, DPR); drawGrid(staticCtx); // 也可以把外圈、中心点、刻度标签都画进去

然后在 animate 里,clearRect 之后:

ctx.drawImage(staticCanvas, 0, 0, SIZE, SIZE);

实测下来,当画面里目标点多到几十个时,这个优化能把帧耗时从 12ms 左右压到 6ms 以下,而且代码只多了三四行。我每次写 Canvas 动效都会默认先做这步。

4.4 减少重绘区域的进一步优化

如果你把 Canvas 用在滚动页面里,还有一个细节:只在 Canvas 进入可视区时才启动requestAnimationFrame循环,离开可视区就cancelAnimationFrame暂停。做法是监听IntersectionObserver:

const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { requestAnimationFrame(animate); } else { cancelAnimationFrame(rafId); } }); }); observer.observe(canvas);

这点优化在做大屏时可能用不上,但如果你是把雷达组件嵌在普通管理系统页面里,它能省不少 CPU。还有一个顺手的小技巧:动画循环里避免在每次绘制时创建对象,比如polarToXY每次返回一个新对象,在目标点多的场景会频繁触发垃圾回收。可以改成直接修改传入对象,或者在循环里复用临时变量。

5. 常见问题与排查实录

5.1 高分屏下画面模糊

这个是最常见的问题。症状是文字和线条边缘发虚、粗细不均。原因就是没处理 DPR。排查方法很简单:打开浏览器控制台,检查 canvas 的实际像素尺寸和 CSS 尺寸是否一致。如果canvas.width不等于CSS宽度 * devicePixelRatio,多半就是没适配。

解决办法就是前面写的初始化代码。这里再补充一个点:如果你用了ctx.scale(DPR, DPR),离屏 Canvas 也要做同样处理,否则drawImage贴回去时位置会偏半像素,出现细白缝。

5.2 扫描线“断层”或者旋转方向不对

有同学会遇到扫描线每帧跳一下、看起来一顿一顿。先别怀疑 requestAnimationFrame,大概率是角度更新用了帧增量,在某些刷新率下速度翻倍或者卡顿。改用时间戳驱动后,问题通常会消失。

旋转方向不符合直觉也常见:Canvas 默认 0 度在右侧,顺时针增大。如果你画刻度时没做角度偏移,那么扫描线是从右侧开始转的,而不是从顶部。业务上雷达通常从正上方开始,所以初始化时要决定统一的角度偏移。我给团队定的约定是:业务角度 0 度对正北(正上方),代码里换算成Math.PI / 2 - angle,这样刻度、目标点、扫描线共用一个换算函数,不会各转各的。

5.3 目标点定位总是偏差半个点

这个坑通常不是角度问题,而是半像素渲染。比如目标点中心在x=100.5,Canvas 在绘制 1px 线条时会把它落到两个像素上各画一半,看起来就是糊的。解决办法是绘制点、线条时对坐标做Math.round或+ 0.5对齐。不过在动态雷达里目标点在持续运动,半像素抖动肉眼基本看不出来,所以只在画静态外圈和中心点时对齐就够了。

5.4 常见问题速查表

问题可能原因解决建议
画面模糊未处理 DPR按 DPR 设置物理尺寸,ctx.scale 缩放
扫描线快慢不一致帧增量驱动用时间戳计算角度
线条发虚带锯齿半像素坐标静态元素坐标取整
拖尾把画面涂脏半透明覆盖层叠加过重降低透明度或改用扇形尾迹
目标点突然消失生命周期未平滑过渡加 life 字段淡出
页面滚动卡顿动画循环常驻IntersectionObserver 暂停/恢复
多目标 GC 卡顿每帧创建对象复用临时变量,避免每帧 new 对象

6. 踩坑心得与扩展建议

6.1 配色和观感比动效本身更重要

做这个示例的过程中,我发现一个有意思的事:最终让客户觉得“厉害”的,往往不是扫描线转得多流畅,而是配色、透明度、光晕这些细节。深色底、荧光青网格、大红色目标点,这套组合在大屏上非常出效果。反过来说,如果网格颜色太亮,目标点就会被淹没,所以我把网格透明度压在 0.35 以下,目标点用高饱和度的红色,层次一下就分开了。

6.2 想继续扩展的方向

如果你照着这个示例做完了,想再往上走,有几个方向可以试试。一是给网格加方位标签和距离刻度,看起来更完整。二是扫描线扫到目标点的时候,触发一个“击中”脉冲效果,也就是目标点突然放大再缩小,这个可以用简单的缩放动画实现。三是把 Canvas 接到真实数据流上,用 WebSocket 推送目标位置,把 updateTargets 写稳,基本就是一个轻量级的实时态势组件了。

6.3 结尾想说的

我个人在实际操作中的体会是:这种动效类需求,先把视觉元素拆清楚,再谈代码。拆成静态层、动态层、数据驱动三层之后,写起来会特别顺手。还有一个习惯养成后非常受益:任何角度、距离相关的计算,都统一走到一个极坐标转换函数里,不要这里直接写 cos、那里直接写 sin,不然团队协作时每个人对“0 度到底在哪”的理解都不一样,最后合并代码就是一场灾难。

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

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

立即咨询