简介:一款基于HTML5的地图分布动画演示DEMO,适合前端开发者、数据可视化爱好者学习地理数据动态展示。该示例利用画布绘制地图,配合图表库实现区域渐变、散点分布、热力呈现、平滑移动等交互动画,能直观表现人口密度、销售分布等位置信息。压缩包共20个文件,包含1个主页面与19个脚本文件,脚本中既有核心库也涵盖柱状图、饼图、地图、热力图等常见配置;直接打开主页面即可查看完整演示,整体体积仅474KB,轻量易部署。已有1133人学习参考。通过研读源码,能深入掌握地理数据解析、动画循环、事件绑定等关键思路;示例覆盖从基础统计图到地理分布图的多种场景,既能作为课程设计或毕业设计的参考,也可迁移到企业大屏与可视化看板项目中,实用价值明显。
1. 地图分布动画,别一上来就想着写粒子系统
做数据可视化这两年,我见过太多人在“地图分布动画”上栽跟头。有的是把高德或者天地图的官方示例抄过来,改改坐标就当交付;有的是从 github 上拉了一个看起来酷炫的 WebGL 项目,结果数据一换,动画就卡成幻灯片。其实地图分布动画真正的难点不在渲染引擎,而在“数据变化和组织方式”上。栅格地图、天地图坐标拾取、瓦片图层叠加、SVG 覆盖物、Canvas 粒子这些词,分别对应完全不同的技术路线。这篇文章会把最常用的几条路掰开来讲,从原理到参数,再到性能坑,每条都给你能直接跑起来的最简 DEMO。
这篇文章适合三种人:做数据大屏的前端工程师,需要给运营做城市分布热力展示的开发者,以及单纯想用 HTML5 动画点缀自己页面的技术爱好者。目标是让一个地图分布动画从“能看”变成“能上线”。先说结论:最有性价比的方案不是纯 WebGL,而是基于 Canvas 2D 和 SVG 的混合渲染,配合做好数据分层。
2. 渲染路径怎么选:散点动画、路径动画、栅格动画三选一
地图分布动画之所以看起来“绚丽”,是因为运动的东西多。但运动不一定是粒子,很多时候只是一堆点在不同时间点出现在不同位置。所以先要把场景归类,常见的大概三种:静态分布的入场动画(点长出来)、路径迁移的轨迹动画(线在走)、时间序列的栅格动画(颜色块在变)。不同场景对应不同的渲染路径。
2.1 Canvas 2D 散点覆盖物:最低门槛的分布入场动画
纯 Canvas 2D 做散点动画的优势是上手快,不需要任何第三方库。核心逻辑是:把经纬度通过墨卡托投影转换成屏幕坐标,然后在 requestAnimationFrame 的回调里按时间比例画出半径变化的圆。这里有个容易踩的坑:地图在拖动缩放时,Canvas 覆盖物没有自动重新定位,所以必须监听地图 viewchange 事件,重算投影坐标。
// 一个最基础的点分布动画 DEMO const canvas = document.getElementById('mapOverlay'); const ctx = canvas.getContext('2d'); const points = [ { lng: 116.40, lat: 39.90, start: 0, end: 2000 }, { lng: 121.47, lat: 31.23, start: 500, end: 3500 }, { lng: 113.26, lat: 23.13, start: 1000, end: 5000 } ]; let startTime = null; function project(lng, lat) { // 简化的 Web 墨卡托投影,用于非地图底图场景;接入真实地图时用地图自带方法 const x = (lng + 180) / 360 * canvas.width; const y = (90 - lat) / 180 * canvas.height; return { x, y }; } function draw(timestamp) { if (!startTime) startTime = timestamp; const elapsed = timestamp - startTime; ctx.clearRect(0, 0, canvas.width, canvas.height); points.forEach(p => { if (elapsed < p.start) return; const progress = Math.min((elapsed - p.start) / (p.end - p.start), 1); const { x, y } = project(p.lng, p.lat); const radius = 5 + progress * 20; ctx.globalAlpha = 1 - progress; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = '#1890ff'; ctx.fill(); }); requestAnimationFrame(draw); } requestAnimationFrame(draw);这段代码的逻辑是这样的:每个点记录自己的入场开始时间和结束时间,progress 是一个 0 到 1 的小数,半径从 5 增长到 25,透明度从 1 降到 0,视觉上就是一个“炸开然后消失”的效果。project 函数做的是简化的经纬度到平面坐标的映射,如果你用的是天地图或高德底图,请用库提供的经纬度转换方法,否则点会偏移。
参数说明:start 和 end 的单位是毫秒,控制动画开始的先后和持续时长;ctx.globalAlpha 在循环里赋值是必要的,因为 Canvas 的 fillStyle 只控制颜色,不控制透明度。这种写法的问题在于,如果点数超过 2000 个,clearRect 每帧清理全屏,性能会明显下降。
2.2 轨迹连线动画:用 SVG 还是 Canvas 有讲究
轨迹动画常见的做法是:把一条折线拆成多个线段,每一帧只绘制前 n 个点,然后用定时器让 n 增加。SVG 的实现很简单,直接改 polyline 的 points 属性就行,但点数一多 DOM 操作就很重。所以我一般建议用 Canvas 画路径,用打点法控制绘制进度。注意,这里不是指经纬度真实运动,而是视觉上的“线段生长”。
有一种情况需要用 Canvas:轨迹数量超过 50 条,每条轨迹 500 个以上的点。这种情况下 SVG 会有明显的 DOM 节点压力,Canvas 则没有这个瓶颈。反过来,如果轨迹只有两三条,且需要支持鼠标 hover 查看轨迹详情,用 SVG 更划算,因为事件绑定是现成的。
一个折中的方案是:静态路径用 Canvas 画,交互点用 SVG 叠加。这样既能让轨迹动画保持流畅,又能利用 DOM 的事件能力。另一个关键是不要把动画进度计算放在地图 resize 事件里,应该放在独立的动画循环里,避免频繁高耗时计算。
2.3 栅格动画和瓦片:别把颜色渐变做成图片循环
栅格地图分布动画(比如历史卫星图、空气质量时间序列)的原理不一样:它不是在地图上画点,而是把多张栅格图按时间顺序播放。最容易出错的实现方式是用图片的 src 循环切换,这个对网络和时间同步要求太高,而且图片一多就来不及加载。常见做法是用 Canvas 的 drawImage 按帧绘制瓦片拼接结果。
requestAnimationFrame在这种场景下要配合document.hidden判断:页面切到后台时浏览器会暂停动画帧,如果你用了 setInterval 补帧会出现动画时间直接跳变的问题。正确做法是记录上次动画时间戳,用 deltaTime 来推进当前帧索引,不要用累计次数推进。
3. 动手搭一个能跑的 DEMO:天地图底图加载与 Canvas 叠加层整合
对一个地图分布动画来说,动画本身只是最后一步,前面需要把底图、坐标系、缩放联动这几个基础打稳。下面以天地图为例,串联起完整链路,这样你做其他地图时能举一反三。
3.1 动态加载天地图瓦片底图
天地图用的是 EPSG:4326 的投影,和高德、Leaflet 默认的 Web 墨卡托(EPSG:3857)不一样,这是一个很关键的兼容问题。如果你用 Leaflet,需要把 crs 设为L.CRS.EPSG4326,否则瓦片位置会错位。代码里还需要引入天地图的 tk 密钥。
// 天地图底图加载 DEMO(使用 Leaflet 1.9.x) const map = L.map('map', { crs: L.CRS.EPSG4326, center: [34.34, 108.93], zoom: 5 }); L.tileLayer('https://t{s}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={tk}', { subdomains: ['0', '1', '2', '3'], tk: '你的天地图密钥', maxZoom: 18 }).addTo(map);这里有几个参数要说明:vec_w是天地图矢量底图服务,w是它的瓦片矩阵集名字,代表全球范围;如果你要影像底图,把vec_w换成img_w就可以。center和zoom限定了初始视野,但用户拖动后动画点位必须跟着地图变换重新计算,这一步很多人会忘记。
除了底图本身,还需要挂载一个map.on('move zoom')监听。移动端上move事件触发频率很高,需要做节流,不然 Canvas 重绘会让页面掉帧。推荐时间阈值在 60ms 左右,既能保证动画跟随,又不会把 CPU 吃满。
3.2 把 Canvas 覆盖物挂到地图上并做坐标换算
覆盖物的挂载有两种方式:一是用 Leaflet 的Overlay类,二是用一个独立的DIV包装 Canvas,手动监听地图变换。第一种做缩放同步省力,第二种更容易做复杂的动画效果。我推荐的是第二种,因为很多动画需求不限于地图坐标,比如涟漪扩散需要相对屏幕的固定像素半径。
具体做法:先创建一个比地图容器大一圈的 div,位置设为 absolute 并放在地图容器内,Canvas 铺满这个 div。地图平移时,把经纬度数组统一转换成当前缩放级别的像素坐标,再重新画一遍。如果你的动画是持续进行的(比如多个点循环呼吸),重绘时不要调ctx.setTransform或ctx.scale,直接手动算坐标更可控。
这个 DEMO 的关键点是:动画循环和地图事件循环分开。动画循环只管画,地图事件只管改“投影基准”,两者通过一个全局变量window.__mapProjection = { zoom, centerPixel }通信。这样可以防止地图一拖动,动画就从头播放的错误。
4. 性能调优与常见坑:为什么你的动画一上真实数据就卡
纯演示 DEMO 没人会挑毛病,但一接真实数据,比如全国 300 个地级市、每条数据每秒刷新一次,卡顿问题马上就出来了。做地图分布动画,卡顿来源有三个:Canvas 绘制过多、动画计算在 JS 主线程、以及后台数据更新强制触发了 Canvas 重绘。
4.1 FPS 监控和绘制面积控制
首先要做的不是“优化”,而是“可量化”。在页面右上角加一个简易 FPS 计数器,代码很简单:每秒统计一次 requestAnimationFrame 的调用次数。任何优化做完以后,用这个数值说话。
常见的一个大坑是 Canvas 的全屏重绘。有些工程师为了省事,每帧执行ctx.clearRect(0, 0, width, height),然后重新画所有点上万个点。实际上,如果点与点之间无交叠,可以直接用save()恢复局部区域的方式;如果动画点只有几百个,可以用“脏矩形”的思路:
// 只清除上一帧动画点所占的矩形区域 ctx.save(); ctx.beginPath(); ctx.rect(lastPoint.x - lastRadius, lastPoint.y - lastRadius, lastRadius * 2, lastRadius * 2); ctx.clip(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.restore();这段代码的思路是:记录每个动画点上一次的位置和半径,下一帧清除时只清这个矩形块,而不是整张画布。对于几百个点的小规模动画,这一招能省掉不少绘制时间。参数注意点:clip和restore成对出现,否则后续绘制会一直限制在矩形区域内,造成奇怪的视觉缺损。
4.2 数据更新频率与动画解耦
真实业务中数据是变化的,比如“当前在线人数”每分钟更新一次。如果你把数据更新直接放进动画帧回调里,那么数据变化时会冲掉正在执行的动画曲线,出现闪跳。常见做法是把数据放在一个队列里,动画帧不是直接读最新数据,而是读“上一个状态”和“目标状态”,做差值过渡。
伪代码不贴了,贴一个常用配置表格:
| 优化项 | 推荐值 | 说明 |
|---|---|---|
| 动画帧率 | 30 FPS 以下 | 地图动画不是玩游戏,10-20 FPS 视觉已经很顺,守住 30 省电省 CPU |
| Canvas 像素比 | 限于 1.5 | 不要直接取 devicePixelRatio,否则高分屏绘制压力太大 |
| 单帧绘制点数 | 少于 2000 | 超过可以用 quadtree 做可视区裁剪 |
| 动画元素总数 | 少于 500 | 对动画进行视口剔除,视口以外的点不作绘制 |
关于帧率这点多说两句:requestAnimationFrame 默认是 60FPS,但地图动画大量涉及位移动效,30 帧其实视觉上完全够用。你可以加一个const frameInterval = 1000 / 30; let lastFrameTime = 0;来控制实际绘制频率。有些开发者看到 CPU 占用直接飙高,多半就是没做这一步。
5. 送一个能直接用的技巧:动画时间轴错峰生成器
最后一章不写大而全的框架,讲一个做“超绚丽”效果最常见的手段:大量同类动画元素的错峰循环。这个技巧能瞬间让 DEMO 看起来有生命力,而且代码量极少。
做法是所有动画元素共用一个 timeline,而不是各算各的时间。写一个 timeline 管理器,它对外只提供getCurrentTime()接口,内部维护一个从 0 持续到 3600 秒的循环时间。动画元素在制作动画时,不是直接读系统时间,而是读这个时间轴,再加上各自的偏移量phaseOffset。这样暂停、加速、回放在全局层面执行,个别元素的开始时间不会乱。
class Timeline { constructor() { this.offset = 0; this.speed = 1.0; this.lastTime = performance.now(); } getCurrentTime() { const now = performance.now(); this.offset += (now - this.lastTime) * this.speed; this.lastTime = now; return this.offset % 60000; // 60 秒一循环 } }使用方式:每个点取const t = timeline.getCurrentTime(); const localT = (t + phaseOffset) % 60000;,然后再把 localT 归一化到 0-1 区间。这样做的最大好处是:你可以通过修改speed参数,让整个页面的动画统一变快或变慢,而不用改每个点的代码。
这个技巧在大型地图大屏项目里尤其有用。比如运营要在演示时把动画速度调到 0.3 倍,让观众看清某条路径,全局时间轴就能平滑实现,而分散在各处的代码做不到这种整体联动。验证方式也可以很简单:按 F12 打开 Chrome DevTools 的 Performance 面板,录制 5 秒动画,看主线程脚本耗时是否稳定在每帧 16ms 以内(60FPS)或 33ms 以内(30FPS)。如果某一段突然飙高,基本就是有同步 I/O 或者过度重绘,按第四章的排查顺序走一遍即可。
本文还有配套的精品资源,点击获取