向粒子資源:js與json實(shí)現(xiàn)動(dòng)態(tài)風(fēng)場(chǎng)可視化)
簡(jiǎn)介這份資源面向需要在 Web 地圖中呈現(xiàn)動(dòng)態(tài)風(fēng)場(chǎng)的開發(fā)者尤其是使用 Leaflet 構(gòu)建氣象可視化頁(yè)面的前端與 GIS 學(xué)習(xí)者。包內(nèi)共 3 個(gè)文件包含 1 個(gè) js 插件腳本、1 個(gè) css 樣式文件和 1 個(gè) json 數(shù)據(jù)文件壓縮包約 231KB分別承擔(dān)粒子動(dòng)畫邏輯、圖層視覺樣式與全球風(fēng)向風(fēng)速網(wǎng)格數(shù)據(jù)的職責(zé)。其中 js 插件借助 D3 解析流體動(dòng)力學(xué)數(shù)據(jù)將風(fēng)速與風(fēng)向轉(zhuǎn)化為直觀的粒子流動(dòng)效果css 用于調(diào)整粒子大小、顏色與透明度json 則以網(wǎng)格形式存儲(chǔ)各點(diǎn)位的風(fēng)向風(fēng)速信息加載后即可驅(qū)動(dòng)圖層動(dòng)態(tài)渲染。目前已有 873 人學(xué)習(xí)下載適合希望快速搭建風(fēng)向粒子演示、理解數(shù)據(jù)驅(qū)動(dòng)地圖渲染流程的讀者參考也可作為氣象類地圖應(yīng)用的原型素材。1. 從一張“會(huì)呼吸”的風(fēng)場(chǎng)圖說起leaflet 風(fēng)向粒子資源能干什么如果你在地圖項(xiàng)目里見過那種密密麻麻、順著風(fēng)向流動(dòng)的白色粒子線第一反應(yīng)大概率是“這玩意兒怎么做的”。它不像普通 Marker 那樣一個(gè)個(gè)點(diǎn)上去也不像熱力圖那樣鋪一層色塊而是用成千上萬個(gè)粒子在 canvas 上實(shí)時(shí)演算把風(fēng)向、風(fēng)速用流動(dòng)軌跡直接畫出來。這套 leaflet 風(fēng)向粒子調(diào)用的 js 和 json 資源解決的就是這個(gè)問題它把 leaflet-velocity 這類粒子圖層所需的腳本文件和風(fēng)場(chǎng)數(shù)據(jù)文件打包在一起讓你不用從零去啃 GRIB 或 NetCDF 氣象格式直接拿 json 就能在 Leaflet 地圖上疊一層動(dòng)態(tài)風(fēng)場(chǎng)。適合誰用做氣象可視化、環(huán)境監(jiān)測(cè)大屏、物流航線示意、甚至游戲地圖氛圍的開發(fā)者都能直接上手。尤其是那些已經(jīng)用 Leaflet 搭好底圖、只想加一層風(fēng)場(chǎng)動(dòng)效的前端這份資源省掉了數(shù)據(jù)轉(zhuǎn)換和圖層適配的重復(fù)勞動(dòng)。json 里存的是規(guī)則網(wǎng)格上的 U/V 分量js 負(fù)責(zé)把分量插值成粒子運(yùn)動(dòng)兩者配合就是一套能跑的風(fēng)場(chǎng)渲染方案。2. 資源構(gòu)成與運(yùn)行鏈路js 腳本和 json 數(shù)據(jù)怎么對(duì)上2.1 目錄里到底放了什么拿到這份資源先別急著往項(xiàng)目里塞。我一般會(huì)先掃一眼文件構(gòu)成因?yàn)?leaflet-velocity 對(duì)數(shù)據(jù)格式和腳本加載順序都有要求放錯(cuò)位置就是白屏。典型的結(jié)構(gòu)是這樣leaflet-velocity/ ├── leaflet-velocity.js # 粒子圖層核心腳本 ├── leaflet-velocity.css # 圖層控件與 canvas 樣式 ├── data/ │ ├── wind-global.json # 全球風(fēng)場(chǎng) U/V 分量數(shù)據(jù) │ └── wind-region.json # 區(qū)域高分辨率風(fēng)場(chǎng)數(shù)據(jù) └── demo/ └── index.html # 最小可運(yùn)行示例leaflet-velocity.js是主腳本內(nèi)部實(shí)現(xiàn)了粒子生成、速度插值、canvas 重繪和動(dòng)畫循環(huán)。leaflet-velocity.css管的是圖層切換按鈕和 canvas 容器的定位少了它粒子會(huì)蓋住地圖控件。data目錄下的 json 才是重點(diǎn)它們不是隨便的鍵值對(duì)而是有固定 schema 的網(wǎng)格數(shù)據(jù)。2.2 json 數(shù)據(jù)的字段含義風(fēng)場(chǎng) json 的結(jié)構(gòu)直接決定粒子能不能畫出來。常見做法是采用類似下面的格式{ header: { parameterCategory: 2, parameterNumber: 2, nx: 360, ny: 181, lo1: 0, la1: 90, lo2: 359, la2: -90, dx: 1, dy: 1 }, data: [ { header: { parameterNumber: 2, parameterCategory: 2 }, data: [ ... ] }, { header: { parameterNumber: 3, parameterCategory: 2 }, data: [ ... ] } ] }這里header描述網(wǎng)格范圍nx、ny是經(jīng)緯方向格點(diǎn)數(shù)lo1、la1是起始經(jīng)緯度dx、dy是步長(zhǎng)。data數(shù)組里通常放兩組第一組對(duì)應(yīng) U 分量東西向風(fēng)速第二組對(duì)應(yīng) V 分量南北向風(fēng)速。leaflet-velocity 會(huì)按parameterNumber區(qū)分2 是 U3 是 V。如果 json 里只有一組數(shù)據(jù)粒子就只會(huì)朝一個(gè)方向跑看起來像被風(fēng)吹歪的直線。提示json 里的data數(shù)組長(zhǎng)度必須等于nx * ny順序一般是從北到南、從西到東。順序錯(cuò)了粒子會(huì)出現(xiàn)在錯(cuò)誤經(jīng)緯度上排查時(shí)優(yōu)先核對(duì)這個(gè)。2.3 最小運(yùn)行示例把資源放進(jìn)項(xiàng)目后一個(gè)能跑起來的頁(yè)面長(zhǎng)這樣!DOCTYPE html html head meta charsetutf-8 link relstylesheet hrefhttps://unpkg.com/leaflet1.9.4/dist/leaflet.css / link relstylesheet href./leaflet-velocity.css / style #map { width: 100%; height: 600px; } /style /head body div idmap/div script srchttps://unpkg.com/leaflet1.9.4/dist/leaflet.js/script script src./leaflet-velocity.js/script script // 初始化底圖 var map L.map(map).setView([35, 105], 4); L.tileLayer(https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png).addTo(map); // 加載風(fēng)場(chǎng) json 并創(chuàng)建粒子圖層 fetch(./data/wind-global.json) .then(function (res) { return res.json(); }) .then(function (data) { var velocityLayer L.velocityLayer({ displayValues: true, // 鼠標(biāo)懸停顯示風(fēng)速風(fēng)向 displayOptions: { velocityType: Global Wind, position: bottomleft, emptyString: 無風(fēng)場(chǎng)數(shù)據(jù), angleConvention: bearingCCW, speedUnit: m/s }, data: data, maxVelocity: 15, // 最大風(fēng)速影響粒子顏色映射 colorScale: [#3288bd, #66c2a5, #abdda4, #e6f598, #fee08b, #fdae61, #f46d43], particleAge: 90, // 粒子壽命幀數(shù) lineWidth: 1.2, particleMultiplier: 1 / 300, frameRate: 15, opacity: 0.9 }).addTo(map); }); /script /body /html這段代碼的關(guān)鍵在L.velocityLayer的配置。data直接吃 jsonmaxVelocity決定顏色映射上限超過這個(gè)值的風(fēng)速會(huì)被截?cái)喑勺钌畹念伾?。particleAge控制粒子存活幀數(shù)太小粒子剛生成就消失太大又會(huì)拖尾嚴(yán)重。particleMultiplier是粒子密度系數(shù)數(shù)值越小粒子越多但性能壓力也越大。frameRate是每秒重繪次數(shù)15 幀在普通筆記本上比較穩(wěn)調(diào)到 30 會(huì)明顯吃 CPU。2.4 腳本加載順序與依賴leaflet-velocity 依賴 Leaflet 的L.Layer擴(kuò)展機(jī)制所以 Leaflet 必須在它之前加載。如果用的是模塊打包工具常見做法是import L from leaflet; import leaflet-velocity; import leaflet-velocity/dist/leaflet-velocity.css;注意leaflet-velocity在 npm 上的包名和文件路徑可能因版本不同有差異資源里給的 js 是已經(jīng)構(gòu)建好的 UMD 版本直接 script 引入最省事。如果項(xiàng)目用 webpack要確保 Leaflet 的全局變量L在 velocity 腳本執(zhí)行前已經(jīng)掛到 window 上否則會(huì)報(bào)L is not defined。3. 核心模塊拆解粒子從生成到繪制的完整鏈路3.1 數(shù)據(jù)插值把網(wǎng)格點(diǎn)變成任意經(jīng)緯度的風(fēng)速json 里存的是離散網(wǎng)格點(diǎn)上的 U/V 分量但粒子可能出現(xiàn)在任意經(jīng)緯度所以第一步是插值。leaflet-velocity 內(nèi)部用的是雙線性插值核心邏輯可以簡(jiǎn)化為// 根據(jù)經(jīng)緯度找到網(wǎng)格索引并做雙線性插值 function interpolateField(lon, lat, header, uData, vData) { var i Math.floor((lon - header.lo1) / header.dx); var j Math.floor((header.la1 - lat) / header.dy); // 邊界保護(hù)防止越界 if (i 0 || j 0 || i header.nx - 1 || j header.ny - 1) { return { u: 0, v: 0 }; } var x (lon - header.lo1) / header.dx - i; var y (header.la1 - lat) / header.dy - j; var idx j * header.nx i; // 四個(gè)角點(diǎn)加權(quán) var u (1 - x) * (1 - y) * uData[idx] x * (1 - y) * uData[idx 1] (1 - x) * y * uData[idx header.nx] x * y * uData[idx header.nx 1]; var v (1 - x) * (1 - y) * vData[idx] x * (1 - y) * vData[idx 1] (1 - x) * y * vData[idx header.nx] x * y * vData[idx header.nx 1]; return { u: u, v: v }; }header提供網(wǎng)格原點(diǎn)、步長(zhǎng)和格點(diǎn)數(shù)uData、vData是從 json 里拆出來的兩個(gè)一維數(shù)組。i、j是左上角格點(diǎn)索引x、y是落在格子內(nèi)的比例。四個(gè)角點(diǎn)按距離加權(quán)求和得到該經(jīng)緯度的風(fēng)速分量。如果粒子跑到網(wǎng)格范圍外直接返回零風(fēng)速粒子就會(huì)原地消散不會(huì)出現(xiàn)亂飛的情況。3.2 粒子運(yùn)動(dòng)速度、壽命與邊界處理每個(gè)粒子有當(dāng)前位置、年齡和速度。每一幀根據(jù)插值得到的 U/V 更新位置// 粒子單幀更新 function updateParticle(particle, field, dt) { var wind interpolateField(particle.lon, particle.lat, field.header, field.u, field.v); // 經(jīng)緯度增量0.01 是經(jīng)驗(yàn)縮放系數(shù)讓粒子速度看起來自然 particle.lon wind.u * dt * 0.01; particle.lat wind.v * dt * 0.01; particle.age 1; // 超出壽命或跑出地圖范圍就重置 if (particle.age field.particleAge || particle.lon field.bounds.west || particle.lon field.bounds.east || particle.lat field.bounds.south || particle.lat field.bounds.north) { resetParticle(particle, field); } }dt是幀間隔系數(shù)0.01這個(gè)縮放值不是固定的風(fēng)速單位是 m/s 時(shí)經(jīng)緯度變化太快會(huì)像閃電太慢又像靜止。我一般會(huì)先跑一遍看效果再微調(diào)這個(gè)系數(shù)。resetParticle會(huì)把粒子隨機(jī)撒回地圖范圍內(nèi)年齡歸零這樣粒子流看起來是連續(xù)的。3.3 canvas 繪制與顏色映射粒子位置算好后用 canvas 的fillRect或lineTo畫出來。顏色根據(jù)風(fēng)速大小從colorScale里取// 根據(jù)風(fēng)速取顏色 function getColor(speed, maxVelocity, colorScale) { var index Math.floor((speed / maxVelocity) * (colorScale.length - 1)); index Math.max(0, Math.min(index, colorScale.length - 1)); return colorScale[index]; } // 繪制單幀 function drawFrame(ctx, particles, field) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.globalAlpha field.opacity; particles.forEach(function (p) { var wind interpolateField(p.lon, p.lat, field.header, field.u, field.v); var speed Math.sqrt(wind.u * wind.u wind.v * wind.v); ctx.fillStyle getColor(speed, field.maxVelocity, field.colorScale); var point field.map.latLngToContainerPoint([p.lat, p.lon]); ctx.fillRect(point.x, point.y, field.lineWidth, field.lineWidth); }); }latLngToContainerPoint把經(jīng)緯度轉(zhuǎn)成 canvas 像素坐標(biāo)這一步依賴 Leaflet 的地圖投影。clearRect每幀清空畫布所以粒子是重繪而不是疊加看起來才有流動(dòng)感。globalAlpha控制整體透明度太低會(huì)看不清太高又會(huì)蓋住底圖。3.4 性能取舍粒子數(shù)量與幀率的平衡粒子圖層最怕的就是卡。particleMultiplier和frameRate是兩個(gè)主要調(diào)節(jié)旋鈕。我一般會(huì)按地圖容器面積估算粒子數(shù)參數(shù)建議值影響particleMultiplier1/300 1/500值越小粒子越多越吃 CPUframeRate12 20越高越流暢但重繪壓力大particleAge60 120越大拖尾越長(zhǎng)粒子數(shù)累積越多l(xiāng)ineWidth1 2太粗會(huì)糊太細(xì)看不清如果頁(yè)面還有別的動(dòng)畫或圖表建議把frameRate壓到 12particleMultiplier調(diào)到 1/500先保證不卡再談效果。移動(dòng)端更要保守粒子數(shù)控制在 2000 以內(nèi)比較穩(wěn)。4. 避坑與排查風(fēng)場(chǎng)粒子不顯示、亂飛、卡頓的常見原因4.1 粒子完全不顯示現(xiàn)象地圖出來了但沒有任何粒子線。原因通常是 json 沒加載成功或者data字段結(jié)構(gòu)不對(duì)。先看瀏覽器 Network 面板確認(rèn) json 返回 200 且內(nèi)容完整。如果 json 里data是空數(shù)組或者h(yuǎn)eader缺少nx、ny粒子初始化時(shí)就會(huì)直接跳過。解決方法是拿資源里的示例 json 對(duì)比字段確保data數(shù)組長(zhǎng)度等于nx * ny且兩組數(shù)據(jù)分別對(duì)應(yīng) U 和 V。4.2 粒子朝一個(gè)方向直線跑現(xiàn)象所有粒子都往同一個(gè)方向飛沒有旋轉(zhuǎn)和彎曲。原因一般是 json 里只放了一組數(shù)據(jù)或者兩組數(shù)據(jù)的parameterNumber都是 2。leaflet-velocity 靠parameterNumber區(qū)分 U/V2 是 U3 是 V。如果兩組都是 2V 分量就是零粒子只受東西向風(fēng)影響。解決方法是檢查 json 第二組數(shù)據(jù)的parameterNumber是否為 3不是就手動(dòng)改過來。4.3 粒子位置偏移或跑到地圖外現(xiàn)象粒子集中在某個(gè)角落或者跑到?jīng)]有數(shù)據(jù)的區(qū)域還在動(dòng)。原因通常是header里的lo1、la1和實(shí)際數(shù)據(jù)順序不匹配。有些氣象數(shù)據(jù)是從南到北存有些是從北到南存la1要對(duì)應(yīng)第一行數(shù)據(jù)的緯度。如果順序反了插值出來的風(fēng)速會(huì)整體翻轉(zhuǎn)。解決方法是拿一個(gè)已知坐標(biāo)點(diǎn)比如北京附近手動(dòng)算一下索引看插值結(jié)果是否合理。4.4 頁(yè)面卡頓、風(fēng)扇狂轉(zhuǎn)現(xiàn)象粒子圖層一開瀏覽器 CPU 飆升滾動(dòng)地圖明顯掉幀。原因是粒子數(shù)太多或幀率太高。先看particleMultiplier是不是設(shè)成了 1/100 以下再檢查frameRate是否超過 25。解決方法是把particleMultiplier調(diào)到 1/400 以上frameRate降到 15particleAge降到 80。如果還卡考慮只在縮放級(jí)別大于 4 時(shí)才顯示粒子圖層小比例尺下用靜態(tài)風(fēng)場(chǎng)箭頭代替。4.5 鼠標(biāo)懸停不顯示風(fēng)速值現(xiàn)象displayValues設(shè)為 true但鼠標(biāo)放上去沒有數(shù)值提示。原因是displayOptions里的velocityType和position沒配對(duì)或者 css 文件沒引入。leaflet-velocity 的數(shù)值面板依賴leaflet-velocity.css里的定位樣式少了它面板會(huì)跑到頁(yè)面底部看不見。解決方法是確認(rèn) css 已加載并且position用bottomleft或bottomright這類 Leaflet 控件位置。5. 二次開發(fā)與改造換數(shù)據(jù)源、調(diào)樣式、接業(yè)務(wù)字段5.1 換成自己的風(fēng)場(chǎng)數(shù)據(jù)資源里的 json 是示例數(shù)據(jù)實(shí)際項(xiàng)目多半要接自己的氣象接口。改造思路是保持 schema 不變只替換data內(nèi)容。如果后端返回的是 GRIB 或 NetCDF常見做法是用 Python 的xarray或cfgrib轉(zhuǎn)成 jsonimport xarray as xr import json # 讀取 GRIB 文件 ds xr.open_dataset(wind.grib, enginecfgrib) u ds[u10].values.flatten().tolist() v ds[v10].values.flatten().tolist() # 構(gòu)造 leaflet-velocity 需要的結(jié)構(gòu) header { nx: ds.dims[longitude], ny: ds.dims[latitude], lo1: float(ds.longitude.min()), la1: float(ds.latitude.max()), dx: float(ds.longitude[1] - ds.longitude[0]), dy: float(ds.latitude[0] - ds.latitude[1]) } result { header: header, data: [ {header: {parameterNumber: 2, parameterCategory: 2}, data: u}, {header: {parameterNumber: 3, parameterCategory: 2}, data: v} ] } with open(wind-custom.json, w) as f: json.dump(result, f)注意la1要取最大緯度因?yàn)?leaflet-velocity 默認(rèn)第一行是北邊。dx、dy用相鄰格點(diǎn)差值算別硬編碼。轉(zhuǎn)出來的 json 直接喂給L.velocityLayer就能用。5.2 自定義顏色和粒子樣式colorScale是數(shù)組按風(fēng)速?gòu)男〉酱笥成?。想改成業(yè)務(wù)色系直接換數(shù)組值就行。比如用藍(lán)色系表示低風(fēng)速、紅色系表示高風(fēng)速colorScale: [#313695, #4575b4, #74add1, #abd9e9, #e0f3f8, #ffffbf, #fee090, #fdae61, #f46d43, #d73027]lineWidth和opacity也可以按地圖底圖深淺調(diào)。深色底圖用亮色粒子、opacity0.8 左右淺色底圖用深色粒子、opacity0.6 左右避免蓋住路網(wǎng)。5.3 把風(fēng)速值接到業(yè)務(wù)面板displayValues只顯示鼠標(biāo)位置的風(fēng)速如果要在側(cè)邊欄展示區(qū)域平均風(fēng)速可以監(jiān)聽地圖mousemove事件自己調(diào)interpolateFieldmap.on(mousemove, function (e) { var wind interpolateField(e.latlng.lng, e.latlng.lat, header, uData, vData); var speed Math.sqrt(wind.u * wind.u wind.v * wind.v); document.getElementById(wind-speed).innerText speed.toFixed(2) m/s; });這樣就能把風(fēng)場(chǎng)數(shù)據(jù)和業(yè)務(wù)指標(biāo)綁在一起比如超過某個(gè)閾值就觸發(fā)告警樣式。5.4 一個(gè)我踩過的坑最早做風(fēng)場(chǎng)圖層時(shí)我直接把 json 里的data數(shù)組當(dāng)成[u, v]二維數(shù)組用結(jié)果粒子全擠在左上角。后來才發(fā)現(xiàn) leaflet-velocity 要的是兩個(gè)獨(dú)立對(duì)象每個(gè)對(duì)象里再包一層data數(shù)組。從那以后我每次接新數(shù)據(jù)源都先拿一個(gè)已知坐標(biāo)點(diǎn)手動(dòng)算一遍插值結(jié)果確認(rèn) U/V 方向沒反、網(wǎng)格原點(diǎn)沒偏再往地圖上疊。這個(gè)習(xí)慣幫我省了不少返工時(shí)間。如果你也在做地圖風(fēng)場(chǎng)可視化這份 leaflet 風(fēng)向粒子調(diào)用的 js 和 json 資源可以直接拿去跑改數(shù)據(jù)源和調(diào)樣式都有現(xiàn)成的入口。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取