做数据可视化这么多年,“飞线图”是出现频率非常高的一种形态。无论是地图上表示用户从哪个城市进入站点,还是后台监控里展示一条链路从入口到服务的调用关系,最终效果几乎都是同一种:一根弧线从A点到B点,弧线上有一个光点带着尾巴不停流动。看起来高级,其实如果自己用canvas绘图引擎实现,一套核心逻辑也就几百行。我在这篇文章里会把飞线图从0到1的实现拆开讲透:坐标怎么映射、弧线怎么控制、粒子怎么动、性能怎么压,以及我在多个项目中踩到的坑。适合会用一点canvas、想手写飞线图或加深地图可视化理解的开发者参考。
1. 一张飞线图其实由三层合成,选canvas是因为动画规模
先说结论:飞线图不是“画一条线然后让它动”这么简单。拆开看,每一张能正常看的飞线图,起码包含三层内容,而且这三层的更新频率完全不同。
1.1 三层内容和它们的更新频率差异
静态底图层:包括地图轮廓、城市节点坐标点、文字标签、图例等。这一层在一段时间内基本不变,数据不更新就不需要重绘。静态路径层:每一条飞线的完整弧线轨迹,用半透明颜色画在底图上方,作用是让观看者先建立“哪里连接到哪里”的认知。动态粒子层:在弧线上移动的光点、光点拖出的尾巴、光晕效果。这一层每一帧都在变化,是整个飞线图真正“活”起来的地方。
我会习惯性把这三层拆开处理:底图和路径层直接缓存,动态粒子层每帧重绘。刚开始做飞线图的时候,我走过一次弯路,把所有内容丢到同一个canvas里逐帧重画,结果数据一多就开始闪、闪、闪。分层是解决这个问题的最底层思路,后面的性能优化章节还会展开说。
1.2 为什么SVG方案会在数据规模上来后先崩
我也在早期项目里用SVG写过飞线图。当时觉得SVG操作简单,一条path加一个circle就能组成一条飞线,而且CSS可以控制动画,代码写起来很快。但后来数据量从10条、20条涨到100条、200条,问题就很明显了:每条飞线至少要两组DOM节点,100条线就是几百个节点,浏览器每帧都要做样式计算和重绘,拖动地图、缩放的时候帧率掉得厉害。
canvas是即时模式绘图,整个画布就是一个后台缓冲区,你每帧把画面全部重画一遍。它的逻辑是“我自己管好每一帧画什么”,没有DOM节点层级的开销,也不存在几百个节点同步动画的同步问题。对于飞线这种高频率、动态粒子很多的场景,canvas的掌控力更强,也更能压性能。另外,光晕、渐变、粒子叠加这些像素级的东西,canvas操作起来更直接,想画成什么样就是什么样。
2. 坐标转换与控制点:飞线弧度美不美,全看这里
很多新手画飞线,卡住的不是动画,而是弧线位置不对、弧度不自然。这一章先解决最核心的两个问题:把业务坐标变成画布坐标,然后决定贝塞尔曲线的控制点放哪里。
2.1 把业务坐标映射到画布坐标
飞线图最常见的使用场景是地图迁徙图,业务数据拿到的一般是经纬度。但canvas本身不认识经纬度,它只认像素坐标,所以在画之前必须先做映射。如果你的底图是一张已经渲染好的地图图片,那你不能拿经纬度直接画,必须先知道当前地图可视区的经纬度边界(minLon、maxLon、minLat、maxLat),然后做线性映射:
function lonLatToXY(lon, lat, bounds, width, height) { const x = (lon - bounds.minLon) / (bounds.maxLon - bounds.minLon) * width; const y = (bounds.maxLat - lat) / (bounds.maxLat - bounds.minLat) * height; return [x, y]; }这里有个容易看漏的细节:纬度是反的。在经纬度坐标里,纬度越大越靠北,但在canvas坐标系里,y轴往下是增大,所以y坐标用maxLat - lat,否则整个地图会上下颠倒。我见过不止一次这个错误。
需要注意的是,线性映射只适用于拿一张平面示意图做底图的场景。如果你的底图是标准墨卡托投影的地图,飞线坐标必须用同一套投影公式去算,不能拿经纬度线性映射。具体来说,如果用的是Leaflet,可以调用latLngToContainerPoint;如果是Mapbox,可以调用project方法。总之基本原则是:底图用什么投影,飞线就用什么投影,两者必须完全一致,否则线的起点和城市节点对不上。
2.2 二次贝塞尔曲线的控制点怎么算
飞线之所以是弧线而不是直线,是因为直线太单调,也不符合“跨越空间流动”的视觉张力。画弧线最常用的方案是二次贝塞尔曲线,它的绘制需要3个点:起点P0、控制点C、终点P1。
起点和终点就是两个点的坐标,真正需要算的是控制点C。控制点决定了弧线的弯曲方向和弯曲程度。我先说一个简单但不推荐的做法:把控制点直接放在起点和终点的中点上方,cx = (x1+x2)/2,cy = Math.min(y1,y2) - 80。这个做法在横向线条上没问题,但如果两点是纵向排列的,它会让弧线弯到侧面去,视觉上很怪。
更通用的做法是用法线方向偏移。先算出从起点到终点的方向向量(dx, dy),它的垂直方向就是(-dy, dx)或(dy, -dx),取其中一个方向,再根据想要的弯曲程度做一个偏移量:
function getControlPoint(x1, y1, x2, y2, arcHeight = 80) { const dx = x2 - x1; const dy = y2 - y1; const len = Math.hypot(dx, dy); if (len === 0) return { x: x1, y: y1 - arcHeight }; // 垂直方向,统一取“朝屏幕上方”的方向 let nx = -dy / len; let ny = dx / len; if (ny > 0) { nx = -nx; ny = -ny; } const midX = (x1 + x2) / 2; const midY = (y1 + y2) / 2; return { x: midX + nx * arcHeight, y: midY + ny * arcHeight }; }这个函数里,arcHeight就是弧线拱起的高度,我一般取60到120,如果两地距离特别远,就适当再大一点。为什么要统一取“朝屏幕上方”的方向?因为地图页面通常希望弧线往上拱起,不要遮挡地图下方的关键信息,也不要在视觉上和实际地理方位产生歧义。
2.3 绘制一条飞线的完整代码
有了控制点,画一条飞线的静态路径就是标准的Canvas操作:
const p0 = lonLatToXY(lon1, lat1, bounds, W, H); const p1 = lonLatToXY(lon2, lat2, bounds, W, H); const cp = getControlPoint(p0[0], p0[1], p1[0], p1[1], 80); ctx.beginPath(); ctx.moveTo(p0[0], p0[1]); ctx.quadraticCurveTo(cp.x, cp.y, p1[0], p1[1]); ctx.strokeStyle = 'rgba(58, 213, 255, 0.25)'; ctx.lineWidth = 1; ctx.stroke();quadraticCurveTo的用法是ctx.quadraticCurveTo(cp.x, cp.y, endX, endY),注意前两个参数是控制点,后两个是终点。如果觉得二次贝塞尔曲线的两头不够顺滑,可以换三次贝塞尔曲线bezierCurveTo,控制两个控制点分别往起点和终点的切线方向延伸,能做出更扁平的弧线。但对大多数飞线图来说,二次贝塞尔曲线已经够用,而且计算简单,性能更好。
3. 粒子沿轨迹跑的动画模型:进度、尾巴与帧率
静态路径画好之后,接下来要做的是让光点在曲线上动起来。这个章节是整个飞线图的灵魂。
3.1 贝塞尔曲线上取点的公式
在画动画之前,先要能在任意时刻拿到曲线上的坐标。二次贝塞尔曲线的参数公式很简单:
function quadBezierPoint(t, p0, cp, p1) { 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的取值范围是0到1,0对应起点,1对应终点。quadBezierPoint(0.5, ...)就是曲线上正中间那个点。这个函数在动画循环里会被高频调用,所以不要在里面创建多余的对象,直接返回一个对象也没问题,但要注意调用频率和垃圾回收。
3.2 用帧时间驱动进度,而不是每帧固定步长
动画驱动的核心是维护一条飞线的progress,表示光点已经走了整条曲线的百分之几。每次刷新时,让progress增加一个增量。这个增量的计算,我强烈建议用时间差而不是固定步长。
let lastTime = performance.now(); function frame(now) { const dt = (now - lastTime) / 1000; // 转换成秒 lastTime = now; flyLines.forEach(f => { f.progress += f.speed * dt; if (f.progress > 1) f.progress -= 1; // 循环 }); // ... 绘制逻辑 requestAnimationFrame(frame); } requestAnimationFrame(frame);speed的单位是“每秒走完整条曲线的比例”,比如speed = 0.3代表这条飞线3秒多走完。用时间差驱动的好处是:不管屏幕刷新率是60Hz还是120Hz,也不管用户切走标签页再切回来,动画速度始终一致。如果你直接用progress += 0.01这种固定增量,在高刷新率屏幕上会快一倍,在低帧率设备上又会显得很卡。
另外,多条飞线不能全部从progress = 0开始,否则会像一排士兵同时出发,出现很奇怪的“脉冲”效果。我习惯在初始化时给每条线一个随机初始进度:f.progress = Math.random()。
3.3 尾巴和光点的绘制分离
光点其实是两个东西:一个拖在身后的尾巴,一个顶在前面的发光圆点。尾巴用历史轨迹点来绘制最灵活。
// 更新时,把当前点加入历史数组 f.trail.push(currentPoint); if (f.trail.length > f.tailLength) { f.trail.shift(); }然后绘制尾巴时,从数组的头部(最旧位置)到尾部(最新位置)循环,透明度从0渐变到接近1,线宽也可以从细到粗变化:
f.trail.forEach((p, index) => { const ratio = (index + 1) / f.trail.length; ctx.strokeStyle = `rgba(58, 213, 255, ${ratio * 0.8})`; ctx.lineWidth = ratio * 3; ctx.beginPath(); ctx.moveTo(p.x, p.y); if (f.trail[index + 1]) { ctx.lineTo(f.trail[index + 1].x, f.trail[index + 1].y); } ctx.stroke(); });尾巴长度tailLength我一般取20到40,这个值跟帧率有关。如果帧率是60fps,取30个点就是半秒的尾巴长度,看起来比较自然。如果帧率只有30fps,那30个点就是一秒的长度,会显得尾巴拖得太长,需要自己根据实际帧率调整。
头部光点也不难画,用径向渐变做出光晕感:
function drawGlow(ctx, x, y, radius, color) { const g = ctx.createRadialGradient(x, y, 0, x, y, radius); g.addColorStop(0, 'rgba(255, 255, 255, 0.9)'); g.addColorStop(0.3, color); g.addColorStop(1, 'rgba(0, 0, 0, 0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); }如果你的飞线数量很多,可以先用一个普通半透明圆画外层,再用白色实心圆画内芯,这样比每帧渲染多个radialGradient要便宜一些,视觉差距也不大。
4. 视觉质感调优:光晕、渐变与配色的一线经验
飞线图做出来是一回事,做得好不好看是另一回事。视觉质感的细节,决定了这个图是能搬上数据大屏,还是只配停留在调试页面。
4.1 颜色渐变方案的选择
静态路径层的颜色一般用半透明色,避免抢了动态光点的注意力。但如果你想做更高级的效果,可以让路径本身也有颜色渐变。两种常见做法:
一种是直接用ctx.createLinearGradient(startX, startY, endX, endY)。它简单,但有一个问题:它是基于屏幕坐标的线性渐变,对于弯曲的贝塞尔曲线,渐变方向和曲线走向不一定贴合,视觉上会出现颜色的方向感不对。
另一种是把曲线采样成多个点,逐段绘制,每段用不同的颜色。这种方式更精确,但会多出不少绘制调用。我个人的经验是:在飞线图上,路径层用线性渐变已经够用,因为路径本身比较细,人眼对渐变的细微偏差并不敏感。真正需要颜色变化的更多是光点本身,用t参数去插值两个颜色,就能实现“从起点颜色到终点颜色”的动态渐变:
function lerpColor(c1, c2, t) { return { r: Math.round(c1.r + (c2.r - c1.r) * t), g: Math.round(c1.g + (c2.g - c1.g) * t), b: Math.round(c1.b + (c2.b - c1.b) * t) }; }4.2 光晕效果别迷信shadowBlur
很多初学者做光晕,第一反应是给ctx设置shadowBlur和shadowColor。这个API写起来确实方便,效果也软,但它有一个致命问题:性能开销非常大。在多条飞线同时发光的情况下,每帧要处理多次投影计算,帧率会肉眼可见地掉下去。
我通常用globalCompositeOperation = 'lighter'配合径向渐变来模拟发光。lighter模式会让重叠的像素颜色叠加变亮,很适合深色背景上的粒子效果:路径层、光晕层、光点层叠在一起,自然就有了一种发光的感觉。实际效果比shadowBlur更硬朗,但做数据大屏非常够用。
4.3 我常调的一组经验和参数表格
飞线图的视觉参数没有唯一标准,但有一套比较稳的起步配置:
| 参数 | 建议范围 | 说明 |
|---|---|---|
| 静态路径透明度 | 0.15 ~ 0.35 | 太低看不见,太高抢光点 |
| 光点半径 | 3 ~ 5 | 太大的光点会糊成一片 |
| 尾巴长度 | 20 ~ 40 个历史点 | 越长越飘逸,越短越利落 |
| speed(每秒进度) | 0.2 ~ 0.5 | 太快像电火花,太慢像死线 |
| arcHeight(拱高) | 40 ~ 120 | 根据两点距离适当加大 |
| 光点外圈透明度 | 0.3 ~ 0.5 | 控制光晕厚度 |
配色方面,深色背景是飞线图的主场,推荐底图用深蓝(比如#0a1633),飞线主色用青蓝(#3ad5ff)或荧光绿(#66ffcc),光点内芯用白色。如果背景是浅色的,建议飞线用中深度的蓝紫色,光晕的透明度调低,不然浅色底图上发光效果会非常刺眼。
另外一个小技巧:把飞线的所有视觉参数都做成一个配置对象,暴露到demo页面上用滑块实时调。我每次做新项目都会先调好一组参数,再写进代码,而不是在代码里改数字然后刷新页面看效果,后者实在太浪费时间。
5. 性能优化:从几十条飞线到几百条飞线
飞线图最考验性能的就是数量。10条线怎么画都流畅,100条线就开始有压力,500条线如果还用每帧全量绘制的思路,基本会卡成PPT。这个章节讲我在实战里用到的几个优化手段。
5.1 底图离屏缓存,动画层只画动的部分
最简单的优化,也是我最早做的分层优化:把静态底图和静态路径层先画到一个离屏canvas上,之后每帧只需要把这个离屏canvas用drawImage贴到主canvas上,再在它上面画动态光点。
const offscreen = document.createElement('canvas'); offscreen.width = W * dpr; offscreen.height = H * dpr; // ... 在 offscreen 上绘制底图、城市点、静态路径 // 动画主循环里 ctx.clearRect(0, 0, W, H); ctx.drawImage(offscreen, 0, 0, W, H); // 再画动态粒子层这个优化把每帧的绘制量从“底图+路径+粒子”降为“贴图+粒子”,省下来的开销非常可观。注意离屏canvas的尺寸要和主canvas保持一致,否则贴图的时候要么变形要么位置偏移。
5.2 对象池与环形缓冲,减少GC
另一个容易忽略的性能瓶颈是JavaScript层面的内存分配。动画循环每帧都在跑,如果每帧都new一堆对象,比如新建尾点数组、新建颜色对象,用不了多久就会触发垃圾回收,造成肉眼可见的卡顿。
我的做法是:给每条飞线预分配一个固定长度的尾点数组,用“环形缓冲”的方式来维护。也就是数组一开始就建好,用取模运算覆盖最旧的点:
f.trail = new Array(f.tailLength); f.trailIndex = 0; // 每帧覆盖一个点 f.trail[f.trailIndex % f.tailLength] = currentPoint; f.trailIndex++;绘制的时候从trailIndex % tailLength开始,逆序往前数tailLength个点即可。这个方案避免了shift()的数组移除开销,也不会有频繁的数组扩容。初始化飞线对象的时候也尽量只创建一次,不要把整个飞线对象放进动画循环里重新创建。
5.3 高清屏适配,别让画布发虚
飞线图在普通屏幕上可能看不出问题,一放到2K、4K屏幕上就会发虚。原因是canvas的实际绘制分辨率不等于CSS显示尺寸。高清屏的像素密度更高,必须把canvas的物理像素设置成CSS尺寸乘以devicePixelRatio:
function setupCanvas(canvas, width, height) { const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }这里的setTransform(dpr, 0, 0, dpr, 0, 0)保证了之后所有的绘制坐标仍然按CSS像素来写,canvas内部会自动映射到物理像素上。这个适配必须在初始化时做,如果项目里有窗口resize的监听,也要在resize时重新设置并重新绘制底图。
5.4 超出承载时的取舍:粒子池限制策略
如果你的飞线数量真的非常大,比如500条以上,连粒子都不建议“每条飞线始终有一个光点”。一种更稳妥的做法是:维护一个全局的光点池,比如固定60个光点,每条飞线在它的周期内轮流占用光点。也就是说,某一时刻有的线上能看到光点在跑,有的线上只有静态路径,过一会儿光点才会跳过去。
这个策略的视觉逻辑是“数据流是一波一波走的”,非常适合宏观链路监控、批量数据流转之类的场景。实现上,光点池本质上就是一个数组,数组里的每个元素记录当前占用哪条飞线、进度到哪了。这样不管有多少条线,每帧需要绘制的光点数量都是可控的,帧率稳定。
6. 我在实际落地中踩过的坑和对应解决思路
最后分享几个真实项目里踩过的坑。很多坑不大,但排查起来很耗时间,写出来希望能帮你省点力。
6.1 飞线起点对不上底图,90%是投影不一致
有一次我用Leaflet做了一张地图,飞线数据是从后端拿的经纬度,我图省事直接做了线性映射,结果国内地图看着还行,一到高纬度地区就明显偏。排查了半天才发现,Leaflet底图本身使用了Web墨卡托投影,而我的线性映射相当于等距圆柱投影,高纬度地区差异非常大。解决办法是调用Leaflet提供的latLngToContainerPoint接口,把经纬度转成当前容器的像素坐标,而不是自己写映射函数。
所以在动手之前,先搞清楚底图到底用的什么投影,这是最值得花5分钟确认的事。
6.2 resize之后白屏/闪烁
浏览器窗口尺寸变化时,页面一般会触发resize事件。如果在resize里直接修改canvas的宽度高度,canvas会被清空且所有绘制状态都会被重置,如果你不重新绘制底图,画面就会白屏。我的处理方式是:resize后先重新设置canvas尺寸,再重新渲染离屏底图,然后再继续动画。否则动画循环还在跑,但底图已经没了,视觉上就是闪烁。
6.3 曲线弯向反了
控制点计算里那个“朝屏幕上方”的判断,一开始我也踩过坑。前两条线看着正常,第三条线因为终点比起点低,法线方向变了,弧线直接弯到屏幕下方去了,看起来像是线掉到地图外面。后来我统一在控制点函数里判断法线方向的y分量,如果y分量大于0就取反,保证弧线始终向上拱。这个逻辑对地图场景基本是通用最优解。
6.4 所有飞线一起出发的“脉冲效果”
第一次做出多条飞线动画的时候,所有光点都从起点出发,整整齐齐排成一列往前冲,视觉效果非常奇怪,像一条脉冲波而不是流动的飞线。解决的办法在3.2里提过:初始化时给每条线随机progress。这个细节很小,但对观感影响极大,尤其是飞线数量超过20条时。
6.5 帧率骤降,查了半天是shadowBlur
有一版飞线图在同事的笔记本上卡得不行,我的机器上却流畅。后来发现是他那边的显示器触发的是集成显卡,对阴影计算特别不友好。排查到最后,我移除了所有shadowBlur,全部改成径向渐变光晕,帧率立刻回来了。所以在飞线图这类高频动画里,我建议从一开始就不要用shadowBlur。
最后再分享一个调试经验:我想做点击某条飞线高亮的效果时,发现直接用贝塞尔曲线的t参数做命中检测很麻烦,后面改成在动画循环里额外维护每个光点当前的位置,点击时判断鼠标和光点的距离。这个思路比在曲线几何上做碰撞检测简单得多,也够用。飞线图的功能远不止“画一条流动的线”,它背后是坐标投影、曲线拟合、动画调度和渲染性能一整条链路,把这条链路理顺,你就能在任何场景下快速实现自己的飞线效果。