最近在给自研的 mineMap 轻量地图框架补一个很具体、也很显眼的功能:水波动效椭圆定位点。通俗点讲,就是地图上某个点位除了常规的标记图标之外,要有一圈一圈向外扩散、逐渐变淡的椭圆波纹,用来表达“这个位置正在发生事件”“这是一个汇聚点”或者“设备当前处于告警状态”。这类效果在安防监控大屏、车辆调度、人员定位、设备态势感知这些场景里几乎成了标配,尤其是大屏上放几个带水波扩散的点,整个界面的实时感和数据冲击力一下就上来了。
这类动效看起来只是“画几个圆圈再淡出”,但真落到地图坐标系里,会遇到一堆绕不开的问题:椭圆怎么跟随经纬度定位?波纹的扩散速度如何跟地图缩放级别匹配?多个定位点同时存在时怎么保证帧率不掉?地图拖拽、缩放时波纹会不会原地漂移?这篇文章就把我在 mineMap 里实现水波动效椭圆定位点的完整思路、代码方案和踩坑记录整理出来,给要做同类地图标记动效的开发者一个可复现的参考。内容不绑定具体的地图 SDK,底图用 Canvas 叠加渲染的思路基本都能迁移过去。
1. 项目概述与核心需求拆解
1.1 这个功能到底在解决什么问题
先说你拿到手里的需求通常长什么样:地图上有若干个坐标点,每个点要显示一个固定位置的标记,标记周围持续有水波向外扩散,视觉上强调“这个点很活跃”。有些需求还会要求每个点的水波颜色不一样、扩散频率不一样,甚至要求椭圆形状跟随某个方向旋转。
这是典型的“地图覆盖物动画”需求。地图底图本身是瓦片或者矢量数据,定位标记必须叠加在地图之上。最简单的做法是用绝对定位的 DOM 元素加 CSS 动画,几个点没问题,但点数一多或者需要跟地图坐标系严格同步时,DOM 方案就开始吃力了。所以在 mineMap 里我选择了 Canvas 统一渲染:地图是一个 Canvas 图层,所有定位点波纹都在同一块画布上按帧绘制,这样动画时间轴完全由自己控制,位置计算也能直接复用地图的坐标转换逻辑。
这个功能从产品视角看解决了两个问题。第一是“信息层级”:地图上元素很多,静态的点容易被忽略,水波扩散能把用户视线快速引到正在发生变化的点上。第二是“状态表达”:波纹的频率和颜色可以编码不同含义,比如红色高频代表紧急告警,蓝色低频代表在线待命,绿色代表正常巡检,让大屏或者监控界面不需要额外文字就能传递状态信息。
1.2 椭圆定位点:从“视觉需求”到“投影需求”
标题里特意强调“椭圆”而不是“圆形”,这里其实藏着两个层面的需求。
一层是纯视觉设计。某些地图场景中,设计稿要求定位点是横向或纵向的椭圆,用来模拟雷达扫描区、信号覆盖区、或者某个设施的影响范围。这种椭圆是设计上故意为之,长短轴比例是固定的,跟地图投影无关。
另一层是投影修正需求,这个更有技术含量。很多轻量地图框架内部使用的是经纬度线性映射投影,也就是把经度直接映射到屏幕 x 方向、纬度映射到屏幕 y 方向。这种投影方式简单、性能好,但存在一个天然问题:在同一个屏幕上,东西方向和南北方向代表的地面距离不一样。纬度越高,相同的地面距离跨过的经度差越大,地面上的一个圆形区域投射到屏幕上会变成一个横向拉长的椭圆。
用公式表达就是:如果定位点标记的地理范围是一个半径为 r 的圆,那么在纬度 φ 处,屏幕上的长轴(东西方向)像素半径会变成 baseRadius / cos(φ),短轴(南北方向)像素半径基本保持 baseRadius 不变。这就是为什么很多轻量地图上画“圆形覆盖物”时,要在高纬度地区画成扁扁的椭圆才准确。
我的做法是把这两种模式都支持:坐标转换函数中预留一个纬度修正开关。上层如果传的是“屏幕像素椭圆参数”,就按设计稿直接画;如果传的是“地理半径”,就自动按当前中心纬度换算成屏幕上的长短轴像素。这样既满足了视觉稿,又不会在需要精确表达地理范围时出偏差。
2. 整体方案与渲染选型
2.1 为什么不用 DOM/CSS 定位点做水波
先说一个我踩过的坑。最早实现水波效果时,我图省事,直接在地图容器里为每个定位点塞了一个<div>,水波用两层绝对定位的<span>加 CSS 的transform: scale()和opacity动画来做。单个点位效果确实不错,代码也很短,但一上线就发现两个问题。
第一个问题是动画和地图不同步。地图容器在拖拽或缩放时,浏览器会对整个容器做合成,CSS 动画的变换可能跟不上地图图层的位置变化,出现波纹在底图上“滑动”而不是“钉”在地理坐标上。这个问题在缩放过程中尤其明显,波纹的扩散半径没有跟地图分辨率联动,一会儿感觉扩散太快,一会儿又像不动。
第二个问题是性能。当定位点数量增长到几十个,每个点要维持两到三圈波纹,页面上就有上百个不断改变样式属性的 DOM 节点。这些节点不断触发样式重算和图层合成,帧率掉到二十帧以下,大屏上能明显感觉到掉帧。
换到 Canvas 之后,这些问题基本都消失了。所有波纹绘制都在同一个画布上下文中完成,每一帧的绘制量由我们自己控制,位置坐标每一帧都从经纬度重新换算,天然跟地图同步。更重要的是,Canvas 绘制路径的方式对性能很友好:大量相同样式的椭圆路径可以合并成一条路径批量绘制,几十个点的渲染开销远小于同数量级的 DOM 动画。
2.2 Canvas 渲染层的架构设计
整个水波动效模块我设计成三层结构:定位点数据层、动画状态层、渲染输出层。
定位点数据层只存“静态参数”:经纬度、椭圆基准长轴、短轴、旋转角、波纹颜色、扩散周期、当前是否启用水波。这些参数由业务侧传入,不参与每帧计算。
动画状态层负责维护“动态变量”:每个定位点当前的水波相位、需要绘制的波纹圈数、以及是否需要对波纹做渐变淡出。这一层由统一的动画时钟驱动,时钟来源于performance.now(),而不是每个点各自开一个定时器,保证所有波纹的时间基准一致。
渲染输出层是最核心的部分。它接收动画状态层的计算结果,加上地图当前的中心经纬度和缩放级别,实时计算出每个波纹在屏幕上的椭圆参数,然后调用 Canvas API 绘制。
这里要特别强调一个设计原则:不要在动画循环里做任何“非必要”的计算。比如经纬度到屏幕坐标的换算,依赖地图当前的缩放级别和中心点,地图平移或缩放时必然要重算;但椭圆长短轴的基准像素值在地图缩放级别变化之前是可以缓存的,相位进度也可以直接用全局时间取模得到,不需要每个点都维护一个自增计数器。把这些思路理清楚后,后面代码实现起来就顺了。
3. 核心实现细节:椭圆与水波的数学表达
3.1 经纬度坐标到屏幕坐标的换算
做地图动效第一步永远是坐标转换,这一步错了后面全白搭。我的做法是给每个定位点保存经纬度,在每一帧渲染前计算它在当前地图视口中的屏幕坐标。
假设地图模块已经提供了一个lngLatToContainerPoint(lng, lat)方法,输入经纬度,输出当前地图容器内的像素坐标。这个方法是地图基础能力,mineMap 内部已经实现。关键点在于,水波椭圆的长短轴和旋转方向也要跟地图的当前状态联动。
如果你的定位点有旋转角需求,而这个角度是定义在地理坐标系里的,那么在地图带旋转角时,屏幕上实际要绘制的椭圆旋转角应该是“定位点地理旋转角 + 当前地图旋转角”。当时我的地图模块支持旋转,第一版没有加这个角度,结果地图一旋转,所有椭圆定位点的方向全都错了,看起来像是波纹在画布上整体拧了一下。这个坑后面在问题速查表里会再提。
坐标换算回传的结果要精确到亚像素级别。因为水波波纹在扩散过程中,每一帧椭圆中心位置都可能有微小的偏移,如果坐标被四舍五入成整数,波纹扩散时会明显出现“抖动感”。实际编码中我保留浮点坐标,只在最后调用 Canvas 绘制时才让渲染管线去处理亚像素抗锯齿。
3.2 椭圆长短轴参数的计算与归一化
椭圆参数是水波动效里最需要注意的部分。先区分两种输入模式。
如果业务侧传入的是屏幕像素椭圆参数,那处理很简单:直接拿长轴rx和短轴ry去画即可。什么时候需要算?当传入的是“地理半径”时,必须根据当前纬度做投影修正。
公式拆解如下:假设定位点纬度是lat(弧度制),基准南北像素半径是baseR(也就是地面半径在当前缩放级别下对应的像素值),那么:
const latRad = lat * Math.PI / 180; const rx = baseR / Math.cos(latRad); // 东西方向 const ry = baseR; // 南北方向纬度越高,cos(latRad)越小,rx越大,椭圆越扁。纬度为 0 时rx等于ry,退化为正圆。这套修正模型对经纬度线性映射的投影非常关键,否则你在北纬四五十度的地方画一个“地理范围圆圈”,屏幕上看着就是一个明显偏窄的竖椭圆。
实际业务中我还会做一个限制:当Math.cos(latRad)过于接近 0(比如纬度超过 85 度)时,直接把rx钳制在一个合理范围内,避免出现长轴爆长的情况。虽然大多数业务场景不会跑到这么高的纬度,但地图可以随意拖动,保不齐用户就把地图拖到极地区域了,不限制的话那一帧计算出来的椭圆会大到离谱。
为了性能,这个长短轴计算不放在每个点的每帧更新里。地图只在缩放级别变化或者定位点经纬度变化时才重算基准像素半径,然后存到定位点状态对象中。波纹扩散时只需要在基准rx / ry上乘一个动态扩张系数即可。
3.3 水波扩散动画的公式与相位设计
水波效果的核心其实就是一个圆环(椭圆环)从中心向外扩散,同时透明度逐渐降为零。要做到“连续不断”的效果,需要用多圈波纹错开相位。
我给每个定位点维护一个基于全局时间的相位值phase,公式为:
const now = performance.now() / 1000; const period = point.wavePeriod; // 单圈波纹的周期,例如 2 秒 const progress = (now % period) / period; // 0 到 1扩散半径系数expandRatio从 1 增长到某个最大值,比如 2.4:
const expandRatio = 1 + progress * 1.4;透明度alpha从初始值随 progress 线性衰减到 0,为了让淡出更柔和,也可以用二次曲线:
const alpha = baseAlpha * (1 - progress) * (1 - progress);只有一圈波纹的话,会出现“波和波之间有空档,感觉一顿一顿”的体验。我的做法是同时绘制三圈波纹,每圈的相位错开period / 3:
const offsets = [0, 0.33, 0.66]; for (const offset of offsets) { const p = (now / period + offset) % 1; // 绘制对应的椭圆环 }这样任意时刻屏幕上都有三圈处于不同扩散状态的波纹,视觉上是一组连续推进的波,效果自然很多。有的设计稿要求波纹像雷达一样扫射,那就把圆环改成弧线,只需要把ctx.ellipse的起始角和结束角从整圆改成扇形区间。
水波的速度也要跟地图缩放级别联动。刚开始我用的固定周期,结果地图放大到 18 级时,波纹几秒钟就扩出半个屏幕,看起来很突兀。后来我把周期跟缩放级别做了关系:缩放级别越高,周期适当调大,让波纹在屏幕上的移动速度保持在一个相对稳定的视觉水平。具体系数可以根据实际效果调,没有标准答案,只要保证从 14 级到 18 级拖拽缩放时水波的“视觉速度”不会突变就行。
4. 实操过程与关键代码实现
4.1 定位点对象与参数设计
先定义定位点的数据结构。我用一个普通对象承载静态参数和运行时缓存字段,这样在 Canvas 绘制主循环中可以直接遍历,不需要频繁解构嵌套对象。
class WaveMarker { constructor(options) { this.lng = options.lng; this.lat = options.lat; // 椭圆基准参数 this.radiusMeters = options.radiusMeters || 50; this.rotation = options.rotation || 0; // 水波动画参数 this.period = options.period || 2.0; this.baseAlpha = options.baseAlpha ?? 0.6; this.color = options.color || '#1f91ff'; this.waveCount = options.waveCount || 3; this.enabled = options.enabled !== false; // 运行时缓存,避免每帧重复计算 this.cachedPixelRadiusX = 0; this.cachedPixelRadiusY = 0; this.cachedRotation = 0; } }radiusMeters字段很灵活。业务里如果只需要视觉椭圆,可以直接换算成等效的地图半径传入;如果明确知道要覆盖的地理范围,就按真实米数传。waveCount控制波纹圈数,我默认三圈,有的密集场景两圈也够。
这里有个细节值得说一下:baseAlpha ?? 0.6用了空值合并运算符而不是||,因为有的业务场景确实想传 alpha 为 0,用||会把 0 误当成空值导致回退成默认值。这种小细节在写公共组件时尤其要注意,宁可多写一个字符,也不要埋一个诡异 bug。
4.2 动画绘制主循环实现
Canvas 动画需要一个持续运行的主循环。我采用的是地图框架内已有的全局渲染调度,用一个requestAnimationFrame驱动,而不是每个定位点单独注册回调。
主循环的核心逻辑有四个步骤:清理画布、遍历定位点计算当前帧椭圆参数、按波纹圈数绘制椭圆环、处理后续操作。
function renderWaveMarkers(ctx, mapState, markers) { const now = performance.now() / 1000; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); for (const marker of markers) { if (!marker.enabled) continue; const point = mapState.lngLatToContainerPoint(marker.lng, marker.lat); // 如果定位点不在视口范围内,跳过 if (point.x < -100 || point.y < -100 || point.x > ctx.canvas.width + 100 || point.y > ctx.canvas.height + 100) { continue; } const rx = marker.cachedPixelRadiusX; const ry = marker.cachedPixelRadiusY; const rotation = marker.rotation + mapState.rotation; ctx.strokeStyle = marker.color; ctx.lineWidth = 2; for (let i = 0; i < marker.waveCount; i++) { const offset = i / marker.waveCount; const progress = ((now / marker.period) + offset) % 1; const expandRatio = 1 + progress * 1.4; const alpha = marker.baseAlpha * (1 - progress) * (1 - progress); ctx.globalAlpha = alpha; ctx.beginPath(); ctx.ellipse( point.x, point.y, rx * expandRatio, ry * expandRatio, rotation, 0, Math.PI * 2 ); ctx.stroke(); } } ctx.globalAlpha = 1; }这个循环看起来简单,但有几个重点容易被忽略。首先,ctx.globalAlpha在每帧结束后必须重置为 1,否则下一帧其他图层绘制会继承上一帧残留的透明度。其次,ctx.beginPath()必须在每个椭圆环绘制前调用,同一个 path 里连续画多个椭圆再统一 stroke,会让内圈波纹和外圈波纹之间出现连线,视觉上是一团乱麻。第三,视口剔除的阈值我留了 100 像素的余量,因为波纹扩散半径最大时会超出定位点本身很多,如果按定位点的精确位置做剔除,波纹刚扩散出视口边缘时就会突然消失,边界处会有明显的“割裂感”。
4.3 地图缩放与平移时的动态重算
这是整个功能里最容易出错的地方。地图模块在缩放或平移时,定位点的屏幕坐标每帧都会变化,但椭圆基准半径只在缩放时变化,平移时不变。
我在地图模块的缩放回调中挂了一个重算函数:
function recalcMarkerRadius(mapState, marker) { const baseR = mapState.metersToPixels(marker.radiusMeters); const latRad = marker.lat * Math.PI / 180; const cosLat = Math.max(0.01, Math.cos(latRad)); marker.cachedPixelRadiusX = baseR / cosLat; marker.cachedPixelRadiusY = baseR; }metersToPixels是地图模块的核心能力,输入地面米数,返回当前缩放级别下的屏幕像素数。这样缩放级别一变,miker 的基准像素半径立即更新,波纹在下一帧绘制时自然匹配新的尺度。
平移时不需要重算半径,但每帧都会通过lngLatToContainerPoint得到新的屏幕坐标,波纹会跟着地图内容移动,视觉上就像“钉”在地理位置上。这里有个性能细节:如果地图在平移过程中每帧都强制重新计算所有定位点的经纬度到屏幕坐标,点位很多时还是会有点压力。我加了一个简单的惰性策略:如果地图当前没有发生平移和缩放,屏幕坐标缓存可以直接复用;只有当地图变换事件触发时,下一帧才强制刷新坐标缓存。
在实际代码中,我用一个dirty标志位标记地图状态。这个策略让静态场景下主循环几乎不做额外计算,定位点只是按时间推进波纹扩散,性能非常好;地图一操作,所有点下一帧立即重算,也不会出现拖影或者错位。
5. 性能优化与实战经验
5.1 多标记点场景下的渲染优化
水波动效在实际项目中很少只挂一个点,动辄几十个点位一起显示。这时 Canvas 的性能优势就体现出来了,但优化不到位仍然会卡。
最大的性能杀手是每帧重复设置画笔样式。比如所有定位点的颜色相同,那完全可以把相同颜色的椭圆路径合并起来,先beginPath(),把所有同色椭圆都画进当前路径,最后统一stroke()一次。Canvas 的stroke()是相对昂贵的光栅化操作,减少调用次数能显著提升帧率。
function renderMarkersByColor(ctx, markersByColor) { for (const color in markersByColor) { ctx.strokeStyle = color; ctx.beginPath(); for (const item of markersByColor[color]) { ctx.ellipse(item.x, item.y, item.rx, item.ry, item.rot, 0, Math.PI * 2); } ctx.stroke(); } }这个优化在相同颜色的点位超过五个时效果就很明显。如果每个点都不同颜色,那就没法合并路径了,此时可以把颜色相近的点做一些分组,视觉上几乎没有差别,但批量绘制性能更好。
另一个优化点是避免每帧分配临时对象。遍历定位点时,如果每帧都new一个坐标对象或者数组,V8 引擎的垃圾回收压力会变大,表现为“帧率突然掉一下然后又恢复”。我让WaveMarker内部缓存一个可复用的坐标对象,每一帧直接更新属性而不是新建。
5.2 DPR 适配与离屏缓存
Canvas 在高分屏上如果不做适配,波纹会糊成一片。常规做法是把画布的物理尺寸设置为 CSS 尺寸乘以devicePixelRatio,绘制时再通过ctx.setTransform(dpr, 0, 0, dpr, 0, 0)缩放坐标系。
const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);这里有个坑:地图切换 DPR 时,画布尺寸变化会导致绘制内容被清空,需要在 resize 后立即触发一次重绘。我当时一度以为clearRect没有生效,排查了半天才发现是canvas.width赋值本身就清空了画布。
如果定位点有固定的内部实心标记(比如一个圆形原点),这个实心圆可以不参与水波动画,而是预先绘制到一个离屏 Canvas 上,每帧用drawImage直接贴到主画布。drawImage的复制开销比重新走一遍渐变填充便宜很多。这样做还有一个附带好处:离屏小图可以预先做好抗锯齿,显示质量更稳定。
5.3 常见问题与排查速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 波纹在拖拽地图时左右漂移 | 屏幕坐标每帧没有重新从经纬度换算,复用了旧坐标 | 在地图 move 回调中置 dirty 标志,强制下一帧重算坐标 |
| 缩放地图后波纹扩散速度突然变化 | 扩散周期没有跟缩放级别联动 | 缩放级别越大,周期适当调大;通过视觉速度公式做联动 |
| 波纹显示为竖椭圆 | 投影修正用反了,短轴除到了 cos(lat) | 东西方向除以 cos(lat),南北方向不除 |
| 波纹边缘有锯齿 | 没有做 DPR 适配 | canvas 物理尺寸乘 devicePixelRatio,并调用 setTransform |
| 多个同色点有连线 | 多个椭圆路径没分开 beginPath | 每个椭圆环前都调用 beginPath;批量合并时统一 stroke |
| 地图旋转后椭圆方向不对 | 绘制时没有叠加地图旋转角 | 在 ctx.ellipse 的 rotation 参数中加上 mapState.rotation |
| 点位一多帧率骤降 | 每帧重复设置 strokeStyle 并多次 stroke | 按颜色合并路径,减少 stroke 调用次数 |
| 定位点出视口时波纹突然消失 | 视口剔除阈值太小 | 剔除边界预留扩散半径余量,比如 100 像素 |
还有一个隐藏比较深的问题:当页面切换到后台标签页时,requestAnimationFrame会被浏览器暂停,回到前台时performance.now()已经是切换之后的值,波纹会瞬间跳到很靠后的相位,看起来像“闪现”。解决办法是记录上一次时间戳,每帧计算时间差,超过一定阈值(比如 1 秒)就按当前时间重新初始化相位,不做跨帧连续推进。
6. 一些走过的弯路和最终效果
水波动效椭圆定位点从实现到打磨,前后改了三版。第一版用了 DOM 加 CSS,点位少的时候效果还行,点位一多就暴露了同步和性能问题。第二版切到 Canvas,但一开始没有做投影修正,椭圆写死了长短轴比例,高纬度地区始终觉得“哪里不对”。第三版加入纬度修正、旋转角度联动和视口剔除,才算是真正达到了生产可用状态。
如果让我重新做一遍这个功能,我会在一开始就定义好数据模型和渲染循环的分工,不要急着写绘制代码。先想清楚哪些参数只在地图变化时重算,哪些每帧都要算,哪些可以缓存——这个边界理清了,后面优化时的改动量会小一个数量级。水波动效本身只是地图可视化里很小的一个点,但它能牵扯出坐标投影、动画调度、渲染性能、事件同步这些地图渲染最核心的问题,把这一个小功能做扎实,确实能帮你把地图引擎的底子摸透不少。