简介:这份资源面向需要在地图上呈现气象风场的前端开发者与GIS爱好者,围绕leaflet-windy插件展开,解决风向风速数据难以直观可视化的问题。通过将Leaflet与高德地图底图结合,可在熟悉的街道与卫星图层上叠加粒子动画,并借助极夜黑主题适配夜间或低光照场景,同时用Vue.js组织地图容器、控制按钮与风场参数等组件。压缩包共214个文件,约2.09MB,以png图片、js脚本、css样式、vue组件为主,另含少量gif动图、svg图标与less样式,以及config、json、lock等工程配置,覆盖从资源素材到构建配置的完整结构。目前已有2658人学习下载。读者可据此掌握风场粒子渲染、高德底图接入、暗色主题切换与Vue项目配置的完整思路,并参考目录组织方式快速搭建可维护的气象可视化应用。
1. leaflet-windy 叠加高德瓦片:风场粒子动画到底解决什么问题
做气象可视化或者环境监测大屏的同行,大概率都遇到过这个需求:底图用高德地图瓦片,上面叠一层会流动的风场粒子动画,箭头或者流线跟着真实风向风速跑起来。leaflet-windy 就是干这个的,它是 Leaflet 的一个插件,专门把 GFS 之类的风场数据渲染成 Canvas 粒子动画,叠加在任意 Leaflet 图层之上。而高德地图瓦片本身不是标准 Leaflet 图层,需要做坐标系转换和 URL 模板适配才能接进来。这两件事单独做都不算难,合在一起就有不少细节要抠:瓦片偏移、粒子层跟底图的缩放联动、风速数据的插值方式、Canvas 重绘性能。这篇笔记面向的是已经会用 Leaflet 打底图、想快速把风场粒子动画跑起来的工程师,从坐标系对齐讲到参数调优,每一步都能直接抄。
2. 高德瓦片接入 Leaflet 的坐标系对齐与 URL 模板
2.1 为什么高德瓦片不能直接塞进 L.tileLayer
Leaflet 默认的瓦片坐标系是 Web Mercator(EPSG:3857),高德地图瓦片用的也是 Web Mercator 投影,但它的瓦片原点在左上角,且 URL 路径规则和标准 OSM 不一样。高德瓦片地址格式通常是https://webrd0{1-4}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z},其中 style=8 是路网图,style=6 是卫星图。直接把这个 URL 丢给 L.tileLayer 能出图,但会出现两个问题:一是子域名轮询需要手动配 subdomains,二是高德的瓦片编号和 Leaflet 默认的 TMS 编号在 y 轴上是一致的(都是从左上角算起),所以不需要做 y 轴翻转。真正要处理的是 GCJ-02 偏移——高德瓦片本身是 GCJ-02 坐标加密的,而 Leaflet 的经纬度是 WGS-84,如果你直接在 WGS-84 坐标上叠高德瓦片,位置会偏几百米。
常见做法是:底图用高德瓦片,所有叠加层的数据也统一转成 GCJ-02 坐标,或者反过来,底图用高德瓦片但接受偏移,叠加层用 WGS-84 然后做视觉上的手动微调。我一般会选前者,因为风场粒子动画的粒子位置是直接画在 Canvas 上的,如果坐标系不统一,粒子会飘到错误的地理位置上。
2.2 最小可跑的 Leaflet + 高德底图代码
// 引入 Leaflet 和高德瓦片 const map = L.map('map', { center: [39.9042, 116.4074], // 北京,GCJ-02 坐标 zoom: 5, zoomControl: true, attributionControl: false }); // 高德矢量底图瓦片 L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', { subdomains: ['1', '2', '3', '4'], maxZoom: 18, minZoom: 3, tileSize: 256, zoomOffset: 0 }).addTo(map);这段代码里subdomains对应 URL 里的{s}占位符,高德有 1 到 4 四个子域,轮询能分散请求压力。tileSize保持 256,不要改成 512,高德瓦片就是 256 的。zoomOffset设为 0,因为高德的 z 值和 Leaflet 的 zoom 级别是对齐的。如果你用的是高德卫星图,把 style 改成 6 即可。注意attributionControl关掉是因为高德瓦片不强制要求署名,但生产环境建议保留一个简短的来源说明。
2.3 坐标系转换的实操参数
如果你拿到的风场数据是 WGS-84 经纬度,而底图是高德瓦片,需要做 WGS-84 到 GCJ-02 的转换。网上有现成的coordtransform库,核心转换函数大概长这样:
// WGS-84 转 GCJ-02,输入输出都是 [lng, lat] function wgs84ToGcj02(lng, lat) { const a = 6378245.0; const ee = 0.00669342162296594323; const PI = Math.PI; function transformLat(x, y) { let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)); ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0; ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0; ret += (160.0 * Math.sin(y / 12.0 * PI) + 320 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0; return ret; } function transformLng(x, y) { let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0; ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0; ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0; return ret; } let dLat = transformLat(lng - 105.0, lat - 35.0); let dLng = transformLng(lng - 105.0, lat - 35.0); const radLat = lat / 180.0 * PI; let magic = Math.sin(radLat); magic = 1 - ee * magic * magic; const sqrtMagic = Math.sqrt(magic); dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * PI); dLng = (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * PI); return [lng + dLng, lat + dLat]; }这个函数是经典的 GCJ-02 加密算法实现,参数a是克拉索夫斯基椭球长半轴,ee是第一偏心率平方。输入输出都是[经度, 纬度]数组。转换精度在米级,对于风场粒子动画这种视觉化场景完全够用。如果你要反向转换(GCJ-02 转 WGS-84),可以用迭代法逼近,但一般风场数据都是 WGS-84 的 GFS 数据,所以只需要正向转换。
3. leaflet-windy 粒子动画的渲染机制与数据格式
3.1 粒子系统是怎么跑起来的
leaflet-windy 的核心是一个 Canvas 覆盖层,它在每个动画帧里做三件事:更新每个粒子的位置、根据风场数据插值出当前位置的风速风向、把粒子画到 Canvas 上。粒子本身不存储历史轨迹,每一帧都是重新计算位置,所以看起来是连续的流动效果。风场数据通常是一个二维网格,每个网格点上有 U 和 V 两个分量,U 代表东西方向风速,V 代表南北方向风速。leaflet-windy 在渲染时会根据粒子当前的经纬度,在网格中做双线性插值,得到该点的 U、V,然后按时间步长移动粒子。
这个机制决定了几个关键参数:粒子数量、速度因子、淡出透明度、线宽。粒子数量越多动画越细腻,但 CPU 和 GPU 压力也越大。速度因子控制粒子每帧移动的距离,太大就会跳帧,太小就看起来不动。淡出透明度决定了轨迹拖尾的长度,值越小拖尾越长。线宽就是粒子轨迹的粗细。
3.2 风场数据的准备与格式转换
leaflet-windy 接受的风场数据格式一般是 JSON,结构如下:
{ "header": { "la1": 90, "lo1": 0, "la2": -90, "lo2": 360, "dx": 1, "dy": 1, "nx": 360, "ny": 180 }, "data": [ { "U": 10, "V": 5 }, { "U": 12, "V": 3 } ] }header里la1、lo1是网格左上角的纬度和经度,la2、lo2是右下角,dx、dy是网格间距,nx、ny是网格数量。data是一个扁平数组,按行优先排列,每个元素包含 U 和 V。如果你拿到的 GFS 数据是 GRIB 格式,需要先用工具转成 JSON。常见做法是用 Python 的pygrib或者cfgrib读取,然后导出成上面的结构。
import cfgrib import json import numpy as np # 读取 GRIB2 文件中的 10 米 U 和 V 风分量 ds_u = cfgrib.open_dataset('gfs.t00z.pgrb2.0p25.f000', filter_by_keys={'shortName': '10u'}) ds_v = cfgrib.open_dataset('gfs.t00z.pgrb2.0p25.f000', filter_by_keys={'shortName': '10v'}) u = ds_u['u10'].values # 形状 (ny, nx) v = ds_v['v10'].values # 构建 leaflet-windy 需要的 JSON header = { "la1": float(ds_u['latitude'].values[0]), "lo1": float(ds_u['longitude'].values[0]), "la2": float(ds_u['latitude'].values[-1]), "lo2": float(ds_u['longitude'].values[-1]), "dx": float(ds_u['longitude'].values[1] - ds_u['longitude'].values[0]), "dy": float(ds_u['latitude'].values[0] - ds_u['latitude'].values[1]), "nx": int(len(ds_u['longitude'])), "ny": int(len(ds_u['latitude'])) } data = [] for j in range(header['ny']): for i in range(header['nx']): data.append({"U": float(u[j, i]), "V": float(v[j, i])}) with open('wind.json', 'w') as f: json.dump({"header": header, "data": data}, f)这段 Python 脚本用 cfgrib 读取 GRIB2 文件,提取 10 米高度的 U、V 分量,然后按 leaflet-windy 要求的格式导出 JSON。注意dy的计算是la1 - la2除以ny-1,因为纬度是从北向南递减的。data的排列顺序必须和 header 里的网格顺序一致,否则风场会错乱。如果你的数据分辨率是 0.25 度,nx 就是 1440,ny 是 721,data 数组会有超过一百万个元素,JSON 文件可能几十 MB,需要考虑压缩或者按需加载。
3.3 把 leaflet-windy 挂到地图上的完整代码
// 假设 wind.json 已经通过 fetch 加载 fetch('/data/wind.json') .then(res => res.json()) .then(windData => { const windLayer = L.windy({ data: windData, particleCount: 3000, // 粒子数量 particleAge: 80, // 粒子最大存活帧数 lineWidth: 1.2, // 轨迹线宽 velocityScale: 0.005, // 速度缩放因子 colorScale: ['#3288bd', '#66c2a5', '#abdda4', '#e6f598', '#fee08b', '#fdae61', '#f46d43', '#d53e4f'], opacity: 0.9, // 图层透明度 frameRate: 15 // 目标帧率 }); windLayer.addTo(map); });particleCount设 3000 是一个平衡值,在 1080p 屏幕上流畅度可以接受,如果掉帧就降到 1500。particleAge控制粒子存活时间,80 帧在 15fps 下大约 5 秒多,拖尾效果比较自然。velocityScale是最关键的参数,它把风速值映射到像素位移,0.005 意味着 10m/s 的风速每帧移动 0.05 像素,实际效果需要根据地图缩放级别微调。colorScale是风速对应的颜色渐变,从蓝色到红色表示风速由小到大。frameRate设 15 是为了省电,设 30 会更流畅但笔记本风扇会转。
4. 风场粒子动画的避坑与排查清单
4.1 粒子全部堆在左上角不动
现象:加载完数据后,Canvas 上粒子密集出现在地图左上角,不流动或者流动方向明显错误。原因通常是 header 里的la1、lo1和实际数据排列不匹配。leaflet-windy 默认认为数据是从左上角开始按行优先排列,如果你的 GRIB 数据纬度是从南到北递增的,直接导出就会导致上下颠倒。解决方法是导出前把纬度数组翻转,或者手动调整la1和la2的值,确保la1大于la2。
4.2 粒子动画跟底图缩放不同步
现象:缩放地图后,粒子层的位置偏移了,或者粒子大小没有跟着缩放变化。原因是 leaflet-windy 的 Canvas 覆盖层默认监听moveend和zoomend事件重绘,但如果你在zoomstart时就触发了重绘,可能会拿到旧的投影参数。解决方法是确保 windLayer 在zoomend之后才更新,或者直接调用windLayer.redraw()。另外,粒子的像素位移不随缩放级别变化,所以在大比例尺下看起来移动很慢,需要动态调整velocityScale。
4.3 风速数据插值出现 NaN
现象:控制台报错或者粒子在某些区域消失。原因是风场网格的边缘区域,双线性插值需要四个角点,如果粒子位置超出了网格范围,插值就会返回 NaN。解决方法是在 leaflet-windy 的配置里加一个边界检查,或者把风场数据向外扩展一圈,用最近邻填充边缘值。我一般会在 Python 导出时用np.pad把 U、V 数组各扩一圈,这样边缘粒子也能正常渲染。
4.4 高德瓦片加载失败或跨域
现象:底图显示空白,控制台有 403 或者 CORS 错误。高德瓦片对 Referer 有校验,如果直接从本地文件或者非授权域名请求,可能会被拒绝。解决方法是在开发环境用代理服务器转发瓦片请求,生产环境确保域名在高德开放平台的白名单里。另外,高德瓦片的style=8是路网图,style=6是卫星图,style=7是路网+卫星混合,别搞混了。
4.5 移动端粒子动画卡顿
现象:在手机上打开页面,粒子动画只有几帧每秒。原因是移动端 GPU 对 Canvas 逐帧重绘的支持不如桌面端,粒子数量一多就掉帧。解决方法是根据设备像素比动态调整particleCount,移动端降到 800 到 1200,同时把frameRate降到 10。另外,把 Canvas 的willReadFrequently设为 false,避免浏览器强制使用软件渲染。
5. 让风场粒子动画更耐看的三个调优技巧
5.1 按缩放级别动态调整粒子密度和速度
固定参数在不同缩放级别下表现差异很大。放大到城市级别时,粒子移动太快会一闪而过;缩小到全国范围时,粒子又慢得像静止。我一般会监听zoomend事件,根据当前 zoom 值动态设置velocityScale和particleCount:
map.on('zoomend', () => { const z = map.getZoom(); let scale, count; if (z <= 4) { scale = 0.002; count = 2000; } else if (z <= 7) { scale = 0.005; count = 3000; } else { scale = 0.01; count = 4000; } windLayer.setOptions({ velocityScale: scale, particleCount: count }); windLayer.redraw(); });这段代码在缩放结束后根据 zoom 级别切换参数。setOptions是 leaflet-windy 提供的方法,改完参数后调用redraw让 Canvas 重新初始化粒子。注意redraw会清空当前所有粒子,视觉上会闪一下,如果介意可以用requestAnimationFrame延迟一帧再重绘。
5.2 用颜色映射表让风速高低一目了然
leaflet-windy 默认的颜色渐变是均匀分布的,但实际风速分布往往集中在某个区间。更好的做法是根据数据的实际 min/max 做非线性映射,比如用平方根变换拉开低风速区的颜色差异:
const speeds = windData.data.map(d => Math.sqrt(d.U * d.U + d.V * d.V)); const maxSpeed = Math.max(...speeds); const colorScale = [ '#3288bd', '#66c2a5', '#abdda4', '#e6f598', '#fee08b', '#fdae61', '#f46d43', '#d53e4f' ]; // 在 windLayer 配置里传入自定义颜色函数 windLayer.setOptions({ colorScale: colorScale, colorScaleRange: [0, maxSpeed] });colorScaleRange告诉 leaflet-windy 颜色映射的数值范围,这样低风速区不会全部挤在蓝色里。如果你的数据里有个别极端值,建议用百分位数(比如 95% 分位)作为上限,避免颜色被少数极值拉偏。
5.3 用 requestAnimationFrame 节流控制 CPU 占用
leaflet-windy 内部是用setInterval还是requestAnimationFrame取决于版本,但你可以通过frameRate参数间接控制。如果发现页面滚动或者交互时卡顿,可以在map.on('movestart')时暂停动画,moveend时恢复:
map.on('movestart', () => { windLayer.setOptions({ frameRate: 0 }); // 暂停 }); map.on('moveend', () => { windLayer.setOptions({ frameRate: 15 }); // 恢复 });frameRate设为 0 时 leaflet-windy 会停止动画循环,这样地图拖动时不会和粒子渲染抢资源。这个技巧在低配设备上效果很明显,拖动地图的流畅度能提升一个档次。
5.4 验证风场方向是否正确的土办法
风场粒子动画最容易翻车的地方是方向反了。U 是东西分量,正值表示向西还是向东,V 是南北分量,正值表示向北还是向南,不同数据源的定义可能不一样。我一般会拿一个已知风向的站点数据做对照,比如北京冬季盛行西北风,如果动画上粒子从东南往西北跑,那就对了。另一个办法是在地图上放一个箭头图层,手动指定一个方向,看粒子是否和箭头一致。这个步骤花不了几分钟,但能省掉后面反复调试的后悔药。
希望帮到你。
本文还有配套的精品资源,点击获取