原生Canvas实现迁徙线动效:贝塞尔曲线、粒子流与飞机调度
在可视化大屏、数据监控后台、地理信息展示里迁徙线几乎是个绕不开的经典需求。航班轨迹、候鸟迁徙、物流运输、人员流动就连一些酷炫的科技感大屏都少不了在地图上画一条带箭头的弧线再让一个小飞机或者光点沿着线路飞过去。我大概在两年前第一次接到这种需求当时满脑子想的都是“这玩意儿怎么搞”翻了不少库、试了不少方案最后自己用原生Canvas从零撸了一套效果竟然比很多现成的可视化库还顺手。这篇就把我踩过的坑和最终沉淀下来的做法完完整整拆给你看。本文适合的前端朋友有一定Canvas基础但被“动效迁移线”这种偏视觉类的需求卡住过的也适合那些在技术选型上纠结过“该用ECharts还是自研”的人。我会从最底层的数据处理讲起再到贝塞尔曲线的生成、流动效果、小飞机图标、性能优化和多飞机调度末尾还会附上我实际调试时遇到的一堆鬼问题。跟着走一遍你基本就能在自己项目里徒手画出带飞机动态的迁徙线了。1. 项目需求拆解与整体设计思路1.1 核心需求解析你在画的到底是什么说白了迁徙线的本质是“从A点到B点的一条带弧度的连线”再加上一个“不断流动”的视觉暗示让观看者感觉这条线是有数据在传输、有东西在移动的。小飞机则是这条路径上最直观的载体让人一眼就知道“方向是从哪到哪”。但落到Canvas里你要处理的远不止“画一条线”这么简单。我把它拆成了四个独立任务路径生成把两个点的坐标变成一条平滑的二维贝塞尔曲线。线体绘制把这条曲线画出来并且区分静态底线和动态流动层。动效实现让线条或粒子“流动”起来。飞机元素计算飞机在路径上的位置和旋转方向循环飞行。这四个任务听起来是串联关系但实际做的时候它们彼此独立、单独调试、最后再叠加。千万别想着一次性把代码全写完再一起调那就是灾难现场。我最初犯的错就是把“画线”和“飞机”搞在一个函数里结果调线的时候飞机跟着乱飞调飞机的时候线又在闪烁。后来强行拆开各自独立渲染问题立刻变得清晰多了。1.2 为什么选原生Canvas而不是ECharts或SVG很多人的第一反应是ECharts不就有“航线图”吗是的ECharts确实能画迁徙线在快速出Demo的场合它甚至是最优解。但当你需要“小飞机”这种自定义图标、需要精细控制每一帧的运动状态、需要在大屏上跑几十上百条线还不卡的时候ECharts的定制边界就开始让你难受了。ECharts里要自定义飞机动效你得写自定义系列custom series得去操作它的graphic元素说实话这个学习成本一点不比Canvas低而且灵活性仍然受限。而SVG呢SVG的DOM节点一多几百条线加上动画帧浏览器直接喘不过气尤其在高分屏和低端机器上差距更加明显。Canvas的优势在于(1) 像素级绘制你想点什么就点什么(2) 一条requestAnimationFrame动画循环所有元素统一管理性能可控(3) 不需要维护DOM节点几十上百条线也就一次draw调用的事。以我的经验如果项目里已经用了ECharts且迁徙线只是辅助展示、不需要飞机图标那就用ECharts赶紧交差。一旦你需要在线上挂飞机、挂光点、做流光效果果断上原生Canvas。1.3 分层设计一套最省心的渲染架构我最终采用的架构很简单就三层底层静态地图或静态背景单独一个canvas只画一次。中层所有迁徙线的静态底色部分。理论上也可以画在底层但分开更便于单独调整透明度、颜色。顶层动态内容全部画在这里包括流动的光点、虚线偏移、每一架飞机。这三层在代码里用三个canvas元素叠放通过CSS的position: absolute z-index实现。顶层每一帧都clearRect重绘中层和底层只在数据变化时重绘。这样分层的好处是动态层再乱也不会污染下面的静态内容你甚至可以直接把中层的canvas导出成静态图当背景用。我第一次做的时候把所有内容画在一个canvas上每次重绘都得把地图也重画一遍地图底图是图片还好要是地图本身是SVG转换绘制的性能就彻底爆炸了。2. 坐标体系与路径数据预处理2.1 经纬度坐标如何变成屏幕坐标迁徙线的数据来源通常是两个端点的经纬度比如北京到上海就是[116.40, 39.90]和[121.47, 31.23]。而Canvas画图用的是像素坐标。所以第一步就是把经纬度映射到canvas坐标系。如果是纯手绘场景不需要对接真实地图那最简单的方式就是直接线性缩放把经度映射到canvas宽度范围纬度映射到高度范围。function lonLat2XY(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越小。这个细节做反了整个迁徙线就是镜像倒置的。如果你接的是高德、天地图这一类的地图SDK它们通常都提供了经纬度转屏幕坐标的接口直接调用即可。但如果你自己维护了一份地图底图比如一张世界地图PNG你就得手动算一下这个bounds也就是这张图左下角、右上角的经纬度范围这样才缩放准确。我遇到过一个项目别人直接拿墨卡托投影后的图片当底图但坐标却用的是WGS84经纬度画出来的线全都偏到海里去了排查了整整一天最后发现是投影算法不匹配。2.2 三次贝塞尔曲线的数学基础拿到两个端点之后如果直接画直线那就叫“连线”不叫“迁徙线”。视觉上我们需要一条“拱起来”的弧线像地球表面飞行轨迹的投影。这里最常用的就是二次贝塞尔曲线quadratic Bezier一个起点、一个终点、一个控制点简洁够用。二次贝塞尔曲线的公式是B(t) (1-t)²·P0 2t(1-t)·P1 t²·P2t 从 0 到 1代表曲线上的进度P0 是起点P1 是控制点P2 是终点在Canvas里你不需要手动算这个公式因为ctx.quadraticCurveTo(cpx, cpy, x, y)已经帮你封装好了。你唯一需要自己算的就是控制点P1的坐标。2.3 控制点计算让弧线弧度可控控制点的核心思路是取起点和终点的中点然后沿中垂线方向偏移一定距离。偏移量控制弧线的高度也就是弧度偏移方向决定弧线向上拱还是向下凹。function calcControlPoint(start, end, curvature) { const midX (start[0] end[0]) / 2; const midY (start[1] end[1]) / 2; const dx end[0] - start[0]; const dy end[1] - start[1]; // 垂直方向向量 const nx -dy; const ny dx; const len Math.sqrt(nx * nx ny * ny) || 1; const distance Math.sqrt(dx * dx dy * dy); // 偏移距离按距离的比例控制弧度 const offset distance * curvature; return [midX (nx / len) * offset, midY (ny / len) * offset]; }这里的curvature我通常取 0.2 到 0.5 之间。0.2 是比较平缓的弧线适合短距离迁徙0.5 以上弧线就很夸张了适合长距离或者视觉上想强调“跨越大洋”的冲击感。但你得注意控制点偏移方向是固定的垂直方向如果起点和终点的连线本身就接近垂直那么生成的弧线弧度就会比较“拧”。这时候可以给垂直向量做一个旋转补偿先旋转一个角度再偏移但这是精修路线了。大部分场景下直接用这个垂直偏移就够了。注意curvature 不要太大超过 0.8 之后弧线高到离谱反而影响观感。实际经验是 0.3 左右最好看既有弧度又不至于盖住地图上的其他内容。3. 迁徙线动效实现从静态到会“流动”3.1 动效方案对比渐变扫过 vs 虚线偏移 vs 粒子流画线本身很简单关键是“动起来”。我调研过主流可视化库里迁徙线的实现方式大致有三种流派方案原理优点缺点渐变扫过在路径上画一个半透明的径向渐变渐变中心沿路径移动效果柔和适合科技感每帧都要创建渐变对象开销较大虚线偏移用 ctx.setLineDash 把线变成虚线然后每一帧偏移 dashOffset实现极简性能好视觉偏“机械”不够细腻粒子流在路径上采样多个点每个点绘制成小圆或光点沿路径移动最华丽最可控代码量较大需要注意性能我最终采用的是“粒子流 虚线偏移”混合方案底层画一条半透明虚线让线路有“断续感”上层再用粒子流补充光感和方向感。这样既有大屏科技感的味道又不会因为纯粒子流导致低端机上掉帧。3.2 流动虚线实现setLineDash 的妙用流动虚线是最容易上手的一种动效。它的核心逻辑就两条设置虚线样式不断改变lineDashOffset。function drawFlowLine(ctx, points, color, speed) { ctx.save(); ctx.beginPath(); ctx.moveTo(points[0][0], points[0][1]); for (let i 0; i points.length - 1; i) { ctx.quadraticCurveTo( controlPoint[0], controlPoint[1], points[i 1][0], points[i 1][1] ); } ctx.strokeStyle color; ctx.lineWidth 2; ctx.setLineDash([8, 12]); ctx.lineDashOffset -offset; // offset 随时间累加 ctx.stroke(); ctx.restore(); }这里的offset在动画循环里每次都加一点速度值。比如每帧加2那虚线就会沿着路径“向前跑”。如果为负方向就反过来。这个方案的好处是代码量极少坏处是虚线本质上还是“等距断点”看久了你会觉得它缺少一点生命力。所以它适合作为底层铺垫真正的主角是下面要说的粒子流。提示setLineDash 里的数组[8, 12]表示实线8像素、空白12像素。这两个值的比例决定虚线的疏密程度你可以根据线长和视觉需求调整。3.3 粒子流实现让线有“血液”流动粒子流的核心思路是从路径上取一些等间距的采样点让这些点上的小圆球沿着路径方向移动。当小球移出路径末端时回到起点重新开始。这一步的关键是你要能把曲线“按距离均匀采样”。我用的最朴素的方案把贝塞尔曲线按 t 从0到1均匀切分成 100 ~ 200 段每段计算出一个点然后把这些点连成一个路径点数组。function getBezierPoints(p0, ctrl, p1, count 100) { const points []; for (let i 0; i count; i) { const t i / count; const x Math.pow(1 - t, 2) * p0[0] 2 * t * (1 - t) * ctrl[0] Math.pow(t, 2) * p1[0]; const y Math.pow(1 - t, 2) * p0[1] 2 * t * (1 - t) * ctrl[1] Math.pow(t, 2) * p1[1]; points.push([x, y]); } return points; }有了点数组之后粒子每帧的位置就是按照“当前进度 粒子间距”来推进的。比如一条线上挂5个粒子每个粒子之间间隔总长度的 1/5然后整体进度每帧前进 0.008。这样5个粒子均匀分布在路径上像一串匀速移动的光点。function drawParticles(ctx, pathPoints, progress, color) { for (let i 0; i particleCount; i) { const idx (progress i / particleCount) % 1; const pos getPointAt(pathPoints, idx); ctx.beginPath(); ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2); ctx.fillStyle color; ctx.fill(); } }这里的getPointAt就是根据进度 idx0~1去路径点数组里找对应位置可以线性插值一下让位置更平滑。粒子半径、间距、数量都需要反复试。我常用的组合是粒子数 5~8 个、半径 2~3 像素、每帧进度增量 0.005~0.01。数量太多会让线变糊增量太大则视觉上闪跳不像流动、像蹦迪。4. 小飞机图标沿路径飞行与朝向计算4.1 获取路径点位与切线角度小飞机和粒子的最大区别是粒子是圆点无方向性飞机是一个有朝向的图标必须知道它当前时刻在路径上的“前进方向”并且让机头对准这个方向。计算方向的数学思路是取当前位置的前一个点和后一个点两点连线的方向就是当前朝向。function getDirection(pathPoints, idx) { const i Math.floor(idx * (pathPoints.length - 1)); const i1 Math.min(i 1, pathPoints.length - 1); const p0 pathPoints[i]; const p1 pathPoints[i1]; return { angle: Math.atan2(p1[1] - p0[1], p1[0] - p0[0]), x: p0[0], y: p0[1] }; }Math.atan2(deltaY, deltaX)返回的是与x轴正方向的夹角单位是弧度。Canvas 里角度 0 表示向右Math.PI / 2表示向下。有了这个角度你就可以用ctx.translate(x, y)和ctx.rotate(angle)把飞机摆正。注意这里的切向量计算有个坑如果你直接用采样点数组里相邻的两个点在点数较少时角度会有抖动。因为贝塞尔曲线曲率变化大的地方两点连线方向和真实切线方向偏差会被放大。我一般会在当前点前后各多取几个点做平均。这个方法不严谨但工程上非常见效。4.2 飞机绘制从图标素材到Canvas嵌入飞机图标主要有三种获取方式用一张 PNG 图片ctx.drawImage绘制。用 SVG 转 path再用ctx.fill绘制。直接用 Canvas 画出一个简易飞机形状。我的经验是项目装饰性不强时直接用 Font Awesome 或者 emoji 里的飞机字符画也行真正上生产大屏我会选择让设计切一张透明背景的PNG或者SVG图标然后在Canvas里绘制。function drawPlane(ctx, x, y, angle, img) { ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.drawImage(img, -img.width / 2, -img.height / 2); ctx.restore(); }这里把-img.width / 2、-img.height / 2作为绘制起点是为了让飞机的中心点对准路径位置。如果不做这个偏移你会看到飞机围着路径点转圈机头完全不对。还有一个细节图标本身的朝向。很多飞机PNG图标默认是朝右的x方向这和angle 0时旋转后绘制出来的朝向一致。如果你的图标默认朝上那你需要额外加一个Math.PI / 2的角度偏移。4.3 时间同步与多飞机调度单架飞机容易实现难的是多架飞机在同一张图上同时飞。这里核心问题是每架飞机要有独立的“飞行进度”而且它们不能全部同步出发否则你会看到多架飞机叠成一个点。我的做法是给每架飞机分配一个offset偏移比例。比如三架飞机在同一路径上偏移量分别是 0、0.33、0.66。这样它们均匀分布在路径上互不遮挡。推进时统一加一个增量保证速度一致。更进一步如果有多条迁徙线每条线可能有 1~3 架飞机那么每条线的飞机进度都要单独维护。我用一个数组来管理所有飞机的状态const planes [ { pathId: 0, offset: 0, speed: 0.005 }, { pathId: 0, offset: 0.33, speed: 0.005 }, { pathId: 1, offset: 0.1, speed: 0.008 } ];这样我可以为不同线路配置不同的飞行速度有的线路“物流快”有的“航班慢”视觉层次更丰富也贴近真实业务语感。注意飞机飞到终点后不要硬生生让它消失。你可以让它继续移动超过终点一小段距离比如超出10像素然后通过透明度渐变淡出。直接消失在大屏上非常突兀。5. 性能优化与渲染帧率控制5.1 分层Canvas与离屏Canvas性能优化中最立竿见影的一步就是上面说的分层Canvas。如果地图底图、静态迁徙线和动态飞机全部画在一个canvas上那么每一帧都要重新绘制底图。如果底图是一张大图光是图片绘制就要消耗几毫秒再叠加几千条动态坐标计算帧率想稳定60fps非常困难。把底层静态内容单独画到一个canvas上之后动画循环永远只重绘顶层动态内容。底图和中层只在初始化时绘制一次后续动画循环不碰它们性能就稳了一大半。离屏Canvas是另一个技巧。如果你有一些“一次性绘制但是经常要复制”的内容比如一个半透明的发光圆点、一个复杂的装饰图案你可以先把它们画到一个隐藏的canvas上然后每一帧用ctx.drawImage(offscreenCanvas, x, y)快速拷贝。这个操作比每次重新调用绘图API快一个数量级。5.2 requestAnimationFrame 与增量时间动画主循环推荐用requestAnimationFrame而不是setInterval或setTimeout。requestAnimationFrame会自动匹配屏幕刷新率通常是60Hz并且在浏览器标签页切到后台时自动暂停减少无谓的CPU消耗。但要注意requestAnimationFrame的回调参数会传入一个时间戳不要直接用Date.now()也不要用“每帧固定增加多少进度”的方式来控制速度。因为帧率不稳定如果某帧时间间隔更长进度增量应该更大。我一般用当前帧与上一帧的时间差来计算速度倍数let lastTime 0; function animate(time) { const delta (time - lastTime) / 16.67; lastTime time; progress 0.005 * delta; draw(); requestAnimationFrame(animate); }这里的16.67是60fps下一帧的毫秒数。delta就是当前帧相对标准帧的倍数。如果某一帧间隔是33毫秒delta就是2进度增量也就变成两倍。这样在低刷新率或掉帧情况下动画速度仍然保持一致。5.3 粒子系统与内存管理粒子系统的性能瓶颈主要在ctx.arcctx.fill的调用次数。如果你有20条线、每条线8个粒子那一帧就是160次fill对Canvas来说完全没压力。但如果上百条线、每条线几十个粒子就得做优化了。我的两个优化手段用ctx.globalCompositeOperation lighter把粒子的叠加模式改成“加亮”。这样多个半透明粒子重叠后亮度更高视觉上更有光感而且你不需要为每个粒子单独调透明度一个全局设置就够了。用矩形代替圆形。ctx.fillRect(x, y, size, size)比ctx.arc快得多。粒子物理解释通常只有2~3像素圆不圆根本看不出区别但性能差距在大量粒子时很明显。内存方面最怕的是在动画循环里频繁创建对象。比如每帧都new一个数组来存点坐标或者每帧都调用ctx.createLinearGradient()这些都会触发GC最终导致动画卡顿。正确的做法是在初始化时把所有路径点、控制点、飞机状态对象都准备好动画循环里只做数值更新和绘制。6. 常见问题排查与经验技巧6.1 坐标偏移画出来的线不在预期位置这是我最常遇到的问题也是最容易让人崩溃的。表现是线画出来了弧线、颜色、动效都对但它就是不在地图上的正确位置偏了几十像素到几百像素不等。排查顺序很重要先确认经纬度转屏幕坐标的映射是否经过缩放和偏移。底图的真实经纬度范围和bg图片实际渲染尺寸是否严格对应。再确认设备像素比。Canvas 在 Retina 屏幕上如果不做canvas.width width * devicePixelRatio的处理绘制的图形会模糊而且坐标可能出现缩放导致的偏移。最后检查你的canvas是否使用了CSS缩放。style.width和canvas.width属性不一致会导致坐标扭曲。我记得有一次项目里所有线都偏得离谱后来发现是底图img标签被CSS加了个object-fit: cover而Canvas坐标始终按底图原始尺寸计算二者对不上。把object-fit去掉、直接用精确尺寸定位问题立刻解决。6.2 飞机翻转飞着飞着就倒立飞机在路径上朝向跟随切线方向移动这本身没问题。问题在于atan2返回的角度范围是-PI到PI当角度从PI变化到-PI附近时飞机可能在某一帧突然旋转180度看起来像“翻车”了。这通常发生在路径上那些曲率比较大、切线方向快速变化的区域。解决办法有两种让飞机的旋转角度也做插值避免突变。记录上一帧的角度如果当前帧角度和上一帧角度差距超过PI就认为发生了“绕圈”手动修正为差值加上或减去2*PI保证角度变化是连续的。对角度做平滑处理比如用angle lastAngle * 0.8 targetAngle * 0.2来过渡。第一种方法逻辑简单第二种更平滑但要让飞机快速转向时反应慢半拍。大屏上飞机很小我一般用第一种方法代码逻辑清晰也不容易出诡异效果。6.3 高速移动时轨迹与飞机不同步有段时间我发现飞机明明在线上飞但仔细一看飞机的位置和粒子流的位置相互独立快慢不一致甚至有时候飞机会从线上“飞出”去。排查下来发现粒子的位置是用贝塞尔曲线采样点插值算出来的而我复用同一个路径点数组来计算飞机位置。但如果我在粒子推进和飞机推进时用了不同的进度变量比如粒子进度是0.5飞机进度是0.45它们看起来就不是同步的。解决办法很统一所有“路径上的东西”都基于同一个进度变量只是各自可以加一个固定偏移量。飞机和粒子共用一套“位置计算函数”只用偏移量来区分彼此位置。这样无论怎么调速度他们永远在同一条时空曲线上。6.4 页面切后台再回来动画日期跳变这个坑特别隐蔽。浏览器切后台后requestAnimationFrame会暂停但时间戳time还是按照真实时间增长。某些实现里如果你用绝对时间来计算进度就会出现“页面切回来所有飞机瞬间到终点”的诡异现象。我的规避方式只用delta增量来计算进度并且限制单帧delta的最大值。如果某一帧间隔超过200毫秒直接舍弃本次增量跳回正常速度。这样就算切后台很久回来之后动画也是从原进度继续走而不是飞完整个航线。const delta Math.min((time - lastTime) / 16.67, 12);上限取12大概允许200毫秒的间隔。超过这个数直接截断防止跳变。6.5 其他零散技巧汇总线体颜色建议使用带透明度的RGBA比如rgba(0, 200, 255, 0.8)比纯色更有呼吸感。如果迁徙线端点是城市点记得在端点上画一个圆点、或者一个“呼吸圆环”否则线头会显得很秃。每条迁徙线的流动方向如果和业务数据方向有关务必确认“数据是从哪边流向哪边”。有些物流场景方向和视觉流动不一致业务方会较真。所有 Canvas 元素记得设置position: absolute; top: 0; left: 0;并在外层容器上设置固定尺寸。Canvas 本身没有默认定位三个Canvas叠放时容易错乱。7. 实战演示复现一个完整的最小示例理论讲了这么多我直接给你一个缩到最小的可运行示例。这个示例画了一条从北京到上海的迁徙线上面有流动粒子、一架小飞机代码无依赖复制到一个 HTML 文件里就能跑。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / titleCanvas 迁徙线 Demo/title style body { background: #0b1a2e; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; } #stage { position: relative; width: 800px; height: 600px; /* 这里可以放一张地图底图 */ } #stage canvas { position: absolute; top: 0; left: 0; } /style /head body div idstage canvas idbg width800 height600/canvas canvas iddynamic width800 height600/canvas /div script // 模拟经纬度范围 const bounds { minLon: 100, maxLon: 130, minLat: 20, maxLat: 45 }; function lonLat2XY(lon, lat) { const x ((lon - bounds.minLon) / (bounds.maxLon - bounds.minLon)) * 800; const y ((bounds.maxLat - lat) / (bounds.maxLat - bounds.minLat)) * 600; return [x, y]; } function calcControlPoint(start, end, curvature 0.3) { const midX (start[0] end[0]) / 2; const midY (start[1] end[1]) / 2; const dx end[0] - start[0]; const dy end[1] - start[1]; const len Math.sqrt(dx * dx dy * dy) || 1; const offset len * curvature; return [midX - dy / len * offset, midY dx / len * offset]; } function getBezierPoints(start, control, end, count 150) { const points []; for (let i 0; i count; i) { const t i / count; const x (1 - t) * (1 - t) * start[0] 2 * t * (1 - t) * control[0] t * t * end[0]; const y (1 - t) * (1 - t) * start[1] 2 * t * (1 - t) * control[1] t * t * end[1]; points.push([x, y]); } return points; } // 取路径上某一点 function getPointAt(pathPoints, ratio) { const idx Math.min(pathPoints.length - 1, Math.floor(ratio * (pathPoints.length - 1))); return pathPoints[idx]; } const start lonLat2XY(116.4, 39.9); // 北京 const end lonLat2XY(121.47, 31.23); // 上海 const control calcControlPoint(start, end, 0.25); const pathPoints getBezierPoints(start, control, end); // 初始化底层静态线 const bgCanvas document.getElementById(bg); const bgCtx bgCanvas.getContext(2d); bgCtx.beginPath(); bgCtx.moveTo(start[0], start[1]); bgCtx.quadraticCurveTo(control[0], control[1], end[0], end[1]); bgCtx.strokeStyle rgba(100, 200, 255, 0.25); bgCtx.lineWidth 2; bgCtx.stroke(); // 画端点 [start, end].forEach((p) { bgCtx.beginPath(); bgCtx.arc(p[0], p[1], 4, 0, Math.PI * 2); bgCtx.fillStyle #00d8ff; bgCtx.fill(); }); const dynCtx document.getElementById(dynamic).getContext(2d); let progress 0; let lastTime 0; // 用 Canvas 手画一个简单的飞机 function drawSimplePlane(ctx) { ctx.beginPath(); ctx.moveTo(10, 0); ctx.lineTo(-6, -4); ctx.lineTo(-2, 0); ctx.lineTo(-6, 4); ctx.closePath(); ctx.fillStyle #ffffff; ctx.fill(); ctx.strokeStyle rgba(255,255,255,0.6); ctx.stroke(); } function animate(time) { const delta Math.min((time - lastTime) / 16.67, 12); lastTime time; progress 0.004 * delta; if (progress 1) progress - 1; dynCtx.clearRect(0, 0, 800, 600); // 粒子流一串光点 for (let i 0; i 6; i) { const localProgress (progress i / 6) % 1; const pos getPointAt(pathPoints, localProgress); dynCtx.beginPath(); dynCtx.arc(pos[0], pos[1], 2.5, 0, Math.PI * 2); dynCtx.fillStyle rgba(0, 220, 255, 0.9); dynCtx.fill(); } // 飞机 const planePos getPointAt(pathPoints, progress); const idx Math.min(pathPoints.length - 2, Math.floor(progress * (pathPoints.length - 1))); const p0 pathPoints[idx]; const p1 pathPoints[idx 1]; const angle Math.atan2(p1[1] - p0[1], p1[0] - p0[0]); dynCtx.save(); dynCtx.translate(planePos[0], planePos[1]); dynCtx.rotate(angle); drawSimplePlane(dynCtx); dynCtx.restore(); requestAnimationFrame(animate); } requestAnimationFrame(animate); /script /body /html这段代码里我特意把飞机画成了一个三角形状这样你可以直接看到机头的朝向方便理解旋转逻辑。如果要换成真实飞机PNG只需要把drawSimplePlane替换成ctx.drawImage(planeImg, -imgWidth/2, -imgHeight/2)即可。如果你想把弧度调大就改calcControlPoint里的 curvature 参数从 0.25 改成 0.4线的拱高会明显增加。再多加几条线复制同样的代码块改不同的起终点就是一个大屏迁徙图了。写在最后的一些心得做这种Canvas可视化需求最大的挑战往往不在技术本身而在于“你想要什么效果、数据长什么样、性能瓶颈在哪”这三者的平衡。我第一次做的时候光调飞机旋转方向就耗了一下午最后发现是图标素材初始朝上我的代码却假设它初始朝右。这种小细节文档里永远查不到只有自己踩过才会长记性。如果你也是第一次实现迁徙线我建议你按这个顺序来先画静态弧线再让粒子动起来最后再加飞机。每加一层都单独验证确保前面的没有坏掉。一旦你走通了这一条最小的路径后面加城市点、加多条线、加鼠标交互都只是复制粘贴加微调的体力活。还有一个小技巧分享给你调试Canvas动画时打开浏览器开发者工具在Performance面板里看每一帧的绘制耗时会比你肉眼判断准确得多。很多“看起来有点卡”的问题实际是GC触发频繁这时候你需要检查动画循环里有没有不必要的对象创建。希望这篇能帮你少踩几个坑。去画吧画出来你就发现迁徙线真的一点都不神秘。