最近在做一个数据可视化大屏的项目,地图上需要展示不同区域之间的迁徙关系。产品经理提了个需求:要用 canvas 画迁徙线,线上要有流动的动态效果,线的一端还要有一架小飞机图标沿着弧线飞过去。这个需求听起来不复杂,但真正动手做的时候,会发现从坐标换算、弧线生成到动画节奏和飞机朝向控制,每一步都有不少坑。所以我把整个实现过程整理出来,给正在做类似效果的朋友一个可以复现的参考。
先说清楚这东西是什么。迁徙线本质上是地图上两点之间的弧线路径,它解决的是“从哪到哪”的关联表达问题;动态效果解决的是“方向感”的问题,让静态线变成有指向性的流动线条;小飞机图标则是在方向感之上再进一步,直观告诉用户这是一条航线式的运动轨迹。适合谁来参考?主要面向做前端可视化、数据大屏、地图相关开发的同学,另外对 Canvas 动画感兴趣、想学习贝塞尔曲线和帧动画基础的人也可以手动跟着敲一遍。
1. 迁徙线的需求拆解与技术选型
1.1 这个需求背后到底要什么
接到需求千万不要立刻去写代码,先拆。迁徙线在视觉上至少由三部分组成:静态弧线、流动动画、终点标识。
静态弧线是基础,用来表达两个地点之间存在关联。它不能是直线,否则视觉上太硬,和地图大屏的风格不搭。弧线的弧度、粗细、颜色透明度都需要参数化控制。流动动画解决的是方向问题,起点到终点之间到底是从左往右还是从下往上,光靠一条静止的弧线是看不出来的。通常用光点沿路径运动、虚线流动、或者彗星拖尾来表现。小飞机图标是点睛之笔,让用户不用看任何图例说明,就能意识到这是一条航线式的迁徙路径。
具体到需求明细,大概是下面这几条:
- 两点之间画一条平滑的弧线,不是直线。
- 弧线有持续的流动效果,比如虚线滚动或光点位移。
- 有小飞机图标沿弧线飞行,飞机朝向与运动方向一致。
- 多条迁徙线同时存在时,节奏上要有错开,不能全军万马齐头并进。
- 整体视觉风格偏大屏雾蓝、发光科技感。
把需求拆到这一步,后面每一项其实就是独立的编码任务了。
1.2 为什么是 Canvas 而不是 SVG 或 WebGL
很多人在选型阶段会犹豫,SVG 不是也能做路径动画吗?确实能,SVG 有 stroke-dasharray 和 stroke-dashoffset,配合 CSS 动画就能做出线条流动效果,代码量还特别少。但问题是,迁徙线在大屏场景里往往是几十条甚至上百条同时存在,SVG 的每一个元素都是 DOM 节点,数量一多,DOM 树和样式计算都会拖慢性能。做地图可视化的人应该都有经验,节点一旦上百,滚动和缩放就明显卡顿。
Canvas 的优势在于命令式绘制,屏幕上画什么、什么时候画,全由 JavaScript 控制。每一帧都清空重绘,理论上如果逻辑写得足够高效,上千条线也能扛得住。缺点是所有动画细节都要自己实现,没有 stroke-dasharray 这种东西可以偷懒。但对于迁徙线这种高度定制化的视觉需求来说,自由度比便利性重要得多。
WebGL 的性能上限更高,可以做大规模粒子系统和复杂着色器效果,但学习成本和调试难度也高一个台阶。如果只是几十条迁徙线加几个小飞机图标,Canvas 2D 是性价比最高的方案。它不需要写着色器,不需要引入三维数学库,一个 canvas 上下文加几个基础 API 就能覆盖全部需求,实测下来无论从开发速度还是运行流畅度都很稳。
1.3 建议的分层与流程
我的做法是把整个效果拆成两层:静态层和动态层。静态层包含底图和静态弧线,底图可能是瓦片地图截图或者自己画的 GeoJSON 边界,弧线是那些固定不变的线条底襌。动态层则每帧更新,画光点、拖尾、虚线滚动和飞机图标。
分层的原因是性能优化。静态内容不需要每帧重绘,可以先渲染到一个离屏 canvas 上,动态层只在需要变化时叠加绘制。具体到实现流程,一共五步:
- 准备底图和坐标范围,确定可视区域经纬度边界。
- 写坐标换算函数,把经纬度映射成屏幕像素坐标。
- 用贝塞尔曲线生成每条迁徙线的弧线路径。
- 写动画循环,实现光点位移、拖尾和虚线流动。
- 绘制小飞机图标,处理朝向角度和旋转插值。
这篇文章后面就按这个顺序,逐步展开。
2. 坐标换算与弧线路径生成
2.1 经纬度转屏幕像素坐标,地基不能歪
所有地图可视化效果的地基是坐标换算。如果你的底图是固定尺寸的图片或 canvas,你得知道底图四角对应的经纬度范围,然后做线性映射。
假设底图的可视范围是viewBox,包含minLng、maxLng、minLat、maxLat,画布宽高是width和height,那么经纬度转屏幕坐标的函数大概长这样:
function lngLatToScreen(lng, lat, viewBox, canvasSize) { const { minLng, maxLng, minLat, maxLat } = viewBox; const x = ((lng - minLng) / (maxLng - minLng)) * canvasSize.width; const y = ((maxLat - lat) / (maxLat - minLat)) * canvasSize.height; return { x, y }; }这里有一个细节初学者很容易忽略:屏幕坐标系的 y 轴方向是向下的,而经纬度是从下往上递增的,所以转换 y 坐标时必须用maxLat - lat,否则整张图会上下颠倒。我见过同事第一次写就栽在这上面,画出来的迁徙线全部倒着飞。
如果你的底图来自标准 Web Mercator 投影的地图瓦片服务,直接用经纬度线性映射在低纬度区域问题不大,但纬度一高偏差就会越来越明显。严谨的做法是先做墨卡托投影换算,再进入线性映射流程。墨卡托投影的简化公式如下:
function lngLatToMercator(lng, lat) { const x = (lng * 20037508.34) / 180; let y = Math.log(Math.tan(((90 + lat) * Math.PI) / 360)); y = (y * 20037508.34) / 180; return { x, y }; }拿到墨卡托坐标之后再对整张底图做一次线性映射,就能和标准地图瓦片对上位置。如果只是自己画一张示意地图做效果,不需要和真实地理底图严格对齐,那线性映射就完全够用了。
2.2 用贝塞尔曲线生成弧线路径
坐标转换完成之后,两点之间的连线在屏幕上是一条直线。直线在数据大屏上看起来太生硬,我们需要把它改造成弧线。这里用到的是二次贝塞尔曲线,只需要一个控制点就能控制整条弧线的弯曲程度。
控制点怎么取?简单有效的做法是取起点和中点的中点,然后沿线段法线方向偏移一段距离。具体代码如下:
function buildArc(start, end, curvature = 0.2) { const dx = end.x - start.x; const dy = end.y - start.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist === 0) return null; const mx = (start.x + end.x) / 2; const my = (start.y + end.y) / 2; // 法线方向取 dy, -dx,让弧线朝屏幕上方凸起 const nx = dy / dist; const ny = -dx / dist; const offset = dist * curvature; const cp = { x: mx + nx * offset, y: my + ny * offset }; return { start, cp, end }; }这里的关键是法线方向的选择。线段方向是(dx, dy),它的逆时针法向量是(-dy, dx),顺时针法向量是(dy, -dx)。在屏幕坐标系里,y 轴向下,所以(dy, -dx)这个方向通常指向屏幕上方。如果你用了相反的法向量,画出来的弧线就会向下凹,看起来像是沉入地底的迁徙路径。想要让弧线反向时,直接把curvature取负值即可。
有了二次贝塞尔曲线的三个点,绘制弧线就很简单:
ctx.beginPath(); ctx.moveTo(arc.start.x, arc.start.y); ctx.quadraticCurveTo(arc.cp.x, arc.cp.y, arc.end.x, arc.end.y); ctx.stroke();弧线高度由curvature控制,值越大弧线越高。实际项目中两个点距离差很远时,固定值的 curvature 可能让弧线冲出底图边界。经验是让 curvature 和距离负相关,距离越大系数越小,保证视觉上弧线高度始终在一个合理范围内。
2.3 批量迁徙线的预处理思路
很多需求文档里写着几十条迁徙线,不要天真地在动画循环里临时计算每条线的贝塞尔路径,那样每帧都要做平方根和角度计算,性能扛不住。正确做法是启动动画前,把原始数据统一遍历一遍,全部转换成路径对象缓存起来:
const arcs = rawData.map(item => { const start = lngLatToScreen(item.from.lng, item.from.lat, viewBox, canvasSize); const end = lngLatToScreen(item.to.lng, item.to.lat, viewBox, canvasSize); return buildArc(start, end, item.curvature || 0.2); });缓存好的路径对象只包含三个坐标点,绘制和插值计算都可以直接复用,动画循环里只需要做加减乘除和贝塞尔求值。这个预计算带来的性能提升非常明显,尤其当迁徙线条数达到几十条时,每帧节省的运算量是相当可观的。
3. 动画机制与动态效果实现
3.1 requestAnimationFrame 驱动整个动画循环
Canvas 动画的核心是requestAnimationFrame。它的底层机制是和浏览器渲染帧同步的,相比setInterval更省电、更平滑,切到后台标签页还会自动暂停,对大屏这种长时间运行的页面非常重要。
动画循环的骨架如下:
let lastTime = 0; const duration = 3000; // 一条线光点跑完全程的毫秒数 function animate(time) { const delta = time - lastTime; lastTime = time; // 计算当前进度 const progress = ((time - startTime) % duration) / duration; // 清空画布并重绘 ctx.clearRect(0, 0, width, height); drawStaticLayer(); drawMovingDot(progress); drawPlane(progress); requestAnimationFrame(animate); } requestAnimationFrame(animate);进度计算是这里的关键。这里的progress是一个 0 到 1 之间的值,代表光点从起点到终点走到的比例。由于用了取模运算,光点到了终点后会回到起点重新开始,形成循环流动效果,不需要手动重置状态。
多条迁徙线的动画节奏不宜完全同步,否则视觉上像阅兵方阵,很呆板。给每条线设置不同的 delay,让进度变成((time - startTime - delay) % duration) / duration。一个常见的做法是让 delay 是 duration 的随机倍数,视觉上立刻错落有致。
3.2 光点位置计算与彗星拖尾效果
有了路径对象和进度之后,光点的位置就是贝塞尔曲线求值。二次贝塞尔曲线在参数 t 下的坐标公式是:
P(t) = (1-t)^2 * P0 + 2*(1-t)*t * CP + t^2 * P1对应代码如下:
function pointOnQuad(p0, cp, p1, t) { const mt = 1 - t; return { x: mt * mt * p0.x + 2 * mt * t * cp.x + t * t * p1.x, y: mt * mt * p0.y + 2 * mt * t * cp.y + t * t * p1.y, }; }直接用线性时间 t 去驱动光点会让运动看起来匀速而机械,自然界中的运动通常都有加速度。大屏上我习惯加一个缓动函数,让光点在中段加速、两端减速,看起来更像真实的飞行器:
function easeInOut(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } const eased = easeInOut(progress); const pos = pointOnQuad(arc.start, arc.cp, arc.end, eased);彗星拖尾效果是迁徙线动态效果里最出彩的一部分。实现方式是用一个数组保存最近若干帧的光点位置,绘制时按照由旧到新的顺序,让透明度逐渐递增,形成从淡到浓的拖尾:
const trail = []; const MAX_TRAIL = 30; function updateTrail(pos) { trail.push(pos); if (trail.length > MAX_TRAIL) trail.shift(); } function drawTrail() { for (let i = 0; i < trail.length - 1; i++) { const alpha = (i / trail.length) * 0.8; ctx.strokeStyle = `rgba(46, 213, 255, ${alpha})`; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(trail[i].x, trail[i].y); ctx.lineTo(trail[i + 1].x, trail[i + 1].y); ctx.stroke(); } }拖尾长度直接影响视觉手感。太短了看不出来,太长了会糊成一片。我试下来 30 到 40 个点是一个比较舒服的区间,再配合把光点绘制成一个小圆,整个动态元素就非常醒目了。
3.3 虚线滚动和光晕效果
除了光点拖尾,弧线本身也可以有流动效果。Canvas 提供了setLineDash和lineDashOffset两个 API,前者把线变成虚线,后者控制虚线的偏移量。每帧让偏移量递减,虚线看起来就在持续流动:
ctx.save(); ctx.setLineDash([6, 12]); ctx.lineDashOffset = -offset; // 每帧递减 offset,比如 offset -= 1.5 ctx.strokeStyle = 'rgba(30, 200, 255, 0.7)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore();使用setLineDash后线条会有明显的断裂感,视觉偏技术风,如果不需要虚线流动可以跳过这个效果。另外要注意lineDashOffset的正负方向,方向反了你得实际跑一眼才能发现,因为不同浏览器的实现有一点细微差异。我的建议是写完立即测试,不要凭直觉判断方向。
光晕效果可以让线条看起来更科技感。最容易想到的是ctx.shadowBlur,但它是一个非常吃性能的 API,几十条线同时开阴影,帧率很容易掉下来。我的做法是:静态弧线用 shadowBlur 预渲染到离屏 canvas 上,每帧只动态层使用简单的透明渐变或双色叠线来模拟光晕,视觉效果过得去,帧率也稳得住。
具体来说,动态光点外层先画一个大一点的半透明圆,再画一个小一点的实心圆,两层叠加就有发光感了,比开 shadowBlur 高效得多。
4. 小飞机图标的制作与朝向控制
4.1 画一个够用的小飞机,不用去找素材
一开始我考虑过用图片作为飞机图标,后来发现图片加载有延迟,而且不同屏幕分辨率下缩放会糊。最后干脆直接用 Canvas 路径画一架简化飞机。默认让飞机机头朝上,也就是指向 y 轴负方向,这样旋转角度的基准比较容易控制:
function drawPlane(ctx, x, y, angle, scale = 1) { ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.scale(scale, scale); ctx.fillStyle = '#FFFFFF'; ctx.beginPath(); ctx.moveTo(0, -12); // 机头 ctx.lineTo(5, 6); // 右翼后缘 ctx.lineTo(2, 6); // 右翼内收 ctx.lineTo(2, 10); // 右尾翼 ctx.lineTo(-2, 10); // 左尾翼 ctx.lineTo(-2, 6); // 左翼内收 ctx.lineTo(-5, 6); // 左翼后缘 ctx.closePath(); ctx.fill(); ctx.restore(); }这个飞机形状很简单,就是机头、机身、双翼和尾翼的闭合路径。如果需要更精细的飞机造型,可以在这个基础上增加机身长度、翼展比例或者驾驶舱的高光色块,实际开发中完全可以按需调整坐标点。之所以用save和restore包裹绘制代码,是为了避免 translate 和 rotate 污染后续的绘制状态。初学者最容易犯的错就是忘记恢复状态,导致后面画的所有东西位置越来越偏。
4.2 飞机朝向计算与平滑插值
飞机要沿弧线飞行,每帧需要根据当前位置和下一个位置计算出朝向角。当前位置可以从贝塞尔曲线求值得到,下一个位置则用progress + 0.01再求一次值。两点的方向角用atan2计算:
function angleBetween(p1, p2) { return Math.atan2(p2.y - p1.y, p2.x - p1.x); }注意atan2返回的角度是以 x 轴正方向为 0 弧度、顺时针为正的。默认机头朝上画时,旋转角还需要加上一个 90 度的偏移,也就是Math.PI / 2:
const targetAngle = angleBetween(prevPos, curPos) + Math.PI / 2;如果每一帧直接把飞机的角度设置为目标角度,会遇到一个非常恶心的视觉问题:飞机在弧线最高点附近会瞬间掉头。原因是弧线最高点的切线方向变化剧烈,上一帧还是朝右上飞,下一帧就变成朝右下飞,角度直接跳变了。
解决方法是做角度插值,让飞机的朝向变化有惯性。直接对数值插值不行,因为角度存在周期 2π,从 170 度插值到 -170 度,直接平均会得到 0 度,导致飞机绕一个大大的弯路。旋转角需要归一化到 [-π, π] 区间:
function lerpAngle(current, target, ratio = 0.1) { let delta = target - current; while (delta > Math.PI) delta -= Math.PI * 2; while (delta < -Math.PI) delta += Math.PI * 2; return current + delta * ratio; }这个函数要做的是:计算出 targetAngle 和当前飞机角度之间的差值,如果差值超出 π,就调整到等价的角度区间,然后再按比例逼近目标角。这样飞机转向时走的一定是较短的那条弧线,不会绕圈。
实际操作中,我会给每一架飞机维护一个独立的当前角度状态,比如plane.angle = lerpAngle(plane.angle, targetAngle, 0.08),而不是每帧从位置硬算一个临时角度。这个平滑度参数 0.08 也需要根据实际效果调试,值越大转向越灵敏,值越小转向越柔和。
4.3 飞机飞行节奏与并发控制
小飞机的飞行节奏要和光点运动配合好。我的习惯是让光点循环跑不断,而飞机走单程,飞完一个航程就消失,然后隔一段时间再出现新飞机。
实现思路是为每条迁徙线维护一个飞机对象,包含当前进度、当前角度、当前状态。飞机只在进度处于特定区间时显示,比如 0 到 0.6 进度之间是可见的,超过 0.6 就隐藏,然后下一轮延迟出发。
多架飞机同时飞的时候,记得控制并发数量。大屏项目里如果每条线都有一架飞机在飞,那么多条线同时画大量图形元素,Canvas 绘制压力会比较大。我通常会让飞机延迟出发的时间差控制在 duration 的三分之一到二分之一之间,这样视觉上既有连绵不绝的迁徙感,又不会所有飞机挤在同一帧疯狂绘制。
5. 性能优化与常见问题排查
5.1 高分辨率屏模糊,dpr 适配不能少
如果只在 canvas 上设置了 CSS 宽高,没有设置 canvas 自身的 width 和 height 属性,在 retina 屏上大概率会出现文字和图形发虚的情况。原理是 CSS 像素和物理像素在设备像素比超过 1 时不对等。
适配代码是每次初始化 canvas 时都做一次:
const dpr = window.devicePixelRatio || 1; const W = cssWidth; const H = cssHeight; canvas.width = W * dpr; canvas.height = H * dpr; canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);从这里开始,所有绘制坐标都用 CSS 像素,ctx 的变换矩阵会自动把内容映射到物理像素上。离屏静态层也按相同方式处理,这样从底层保证清晰度。
5.2 离屏 Canvas 缓存静态层
底图和静态弧线如果不变化,不要每帧重新绘制。正确做法是把它们画在一个不会显示出来的 canvas 上,每帧只需要用drawImage把这个离屏 canvas 整体盖到主画布上:
const staticLayer = document.createElement('canvas'); staticLayer.width = W * dpr; staticLayer.height = H * dpr; const sctx = staticLayer.getContext('2d'); sctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 在 sctx 上绘制底图、静态弧线等一次性内容 // 动画循环中 ctx.clearRect(0, 0, W, H); ctx.drawImage(staticLayer, 0, 0, W, H); // 再画动态光点、拖尾、飞机这样做的好处是把大量图形绘制操作从每帧的循环中剥离掉。静态线可能有几十条,按每帧重复绘制来算,对 CPU 的占用是完全不必要的。实测在迁徙线条数较多时,做了静态层缓存之后帧率提升非常明显。
5.3 常见问题与排查速查表
我把实际开发和测试中容易踩到的问题整理成了一个速查表,按症状排查比较方便。
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| 图形发虚模糊 | canvas 未适配 devicePixelRatio | 按 dpr 设置 canvas 物理尺寸并 setTransform |
| 弧线开口朝下 | 贝塞尔控制点法线方向取反 | 将法向量改为(dy, -dx),或让 curvature 取负 |
| 飞机转向跳变/绕圈 | 角度插值未归一化到 [-π, π] | 使用 lerpAngle 函数处理角度差 |
| 拖尾断成一段段 | 每帧光点间距过大或数组越界 | 增加路径采样密度,或减少缓存点间隔 |
| 动态元素卡顿 | shadowBlur 过多、路径未预计算 | 离屏缓存静态层,减少 shadow,预计算贝塞尔路径 |
| 光点飞到底图外 | curvature 过大,弧线冲出了可视区域 | curvature 与线段距离负相关 |
| 重新调整画布尺寸后错位 | 未重新计算坐标系 | 监听 resize 事件,重算视口参数和坐标转换 |
另外一个常见问题是窗口尺寸变化。大屏项目通常会用固定比例,但如果用户缩放浏览器,canvas 的 CSS 尺寸变化后需要重新计算 viewBox 和所有路径,否则坐标会和底图错位。稳妥的做法是 resize 之后重建静态层和弧线缓存,再做动画循环。
还有一点容易被忽视:动画循环里的时间参数time来自requestAnimationFrame回调,它是从页面加载开始累计的毫秒数,和Date.now()不是一个起点。不要在动画函数里混用这两种时间,否则进度计算会受到时间基准不一致的干扰,导致光点闪烁或突然跳进度。
5.4 让飞行器的数量保持在可控范围
当多条迁徙线的飞机动画叠加在一起时,我发现绘制是次要的,真正吃性能的是每帧多次贝塞尔求值和多次 canvas 状态切换。能预计算的尽量预计算,比如把贝塞尔曲线在 0 到 1 之间按照 0.01 间隔采样 100 个点,缓存成一个数组。运行时期只需要根据进度查表,配合少量线性插值,就能获得足够平滑的运动轨迹:
// 预计算路径点 const sampledPath = []; for (let i = 0; i <= 100; i++) { const t = i / 100; sampledPath.push(pointOnQuad(arc.start, arc.cp, arc.end, t)); } // 运行期根据 progress 查表 const index = Math.floor(eased * 100); const p0 = sampledPath[index]; const p1 = sampledPath[Math.min(index + 1, 100)]; // 在 p0, p1 之间做线性插值查表比直接做贝塞尔求值快得多,尤其是在浏览器对高频帧渲染压力很大的时候。如果连线性插值都不想做,可以只取整点位置,100 个采样点已经足够密,视觉上基本看不出折线感。
6. 最后再分享一点个人体会
这个迁徙线加小飞机的效果,我自己前前后后迭代过好几版。最开始只做了光点拖尾,感觉信息是传递到位了,但总觉得少了点活力;后来加上小飞机之后,整个画面一下子生动了起来。所以如果你的项目也需要做航线或迁徙关系可视化,我非常建议把小飞机这个细节保留下来,它对整体观感的提升远超预期。
还有一个小小的经验是:把动画时长和飞机出场节奏做成参数配置,而不是硬编码在代码里。大屏推广的时候运营同事经常会提“线条动得慢一点”“飞机不要太多”这类调整需求,有了参数配置,改起来只需要调数值,不用翻代码,能省下很多沟通和上线时间。动手做的时候,不妨也从这几个参数开始折腾,你很快就能找到适合自己页面氛围的那一组手感。