向粒子動(dòng)效實(shí)戰(zhàn):JS 引擎與 JSON 風(fēng)場(chǎng)數(shù)據(jù)解析)
簡(jiǎn)介這份資源面向需要在 Web 地圖中呈現(xiàn)動(dòng)態(tài)風(fēng)場(chǎng)的開(kāi)發(fā)者提供 Leaflet 風(fēng)向粒子效果所需的插件與數(shù)據(jù)文件。包內(nèi)共 3 個(gè)文件包含 1 個(gè) js 腳本、1 個(gè) css 樣式和 1 個(gè) json 數(shù)據(jù)壓縮包約 231KB體積輕巧便于集成。其中 js 文件負(fù)責(zé)解析風(fēng)速風(fēng)向數(shù)據(jù)并驅(qū)動(dòng)粒子動(dòng)畫(huà)css 用于定義粒子大小、顏色與透明度等視覺(jué)樣式j(luò)son 則以網(wǎng)格形式存儲(chǔ)全球風(fēng)向風(fēng)速信息三者配合即可在 Leaflet 底圖上疊加流動(dòng)的風(fēng)場(chǎng)圖層。資源已獲得 873 人學(xué)習(xí)適合具備一定 Leaflet 基礎(chǔ)、希望快速實(shí)現(xiàn)氣象可視化效果的前端或 GIS 開(kāi)發(fā)者參考可省去自行尋找插件與樣例數(shù)據(jù)的環(huán)節(jié)直接用于地圖應(yīng)用的風(fēng)場(chǎng)展示與教學(xué)演示。1. 風(fēng)向粒子動(dòng)效為什么值得用 Leaflet 重做一遍在氣象可視化大屏、環(huán)保監(jiān)測(cè)平臺(tái)或者農(nóng)業(yè)物聯(lián)網(wǎng)項(xiàng)目里風(fēng)向風(fēng)速的呈現(xiàn)方式往往決定了整個(gè)頁(yè)面的專(zhuān)業(yè)感。靜態(tài)箭頭圖能表達(dá)方向但看不出流速的連續(xù)變化風(fēng)玫瑰圖適合統(tǒng)計(jì)卻不適合實(shí)時(shí)展示。真正讓數(shù)據(jù)“活”起來(lái)的方案是粒子流場(chǎng)動(dòng)畫(huà)——成千上萬(wàn)個(gè)細(xì)小粒子沿著風(fēng)場(chǎng)矢量漂移形成類(lèi)似風(fēng)跡的流動(dòng)效果。Leaflet 作為輕量級(jí)地圖庫(kù)本身不提供粒子渲染能力需要借助 Canvas 圖層和一套風(fēng)場(chǎng)數(shù)據(jù)來(lái)驅(qū)動(dòng)。標(biāo)題里提到的“js 和 json 資源”指的正是兩樣?xùn)|西一份負(fù)責(zé)粒子運(yùn)動(dòng)邏輯與 Canvas 繪制的 JavaScript 文件一份描述網(wǎng)格化風(fēng)向風(fēng)速的 JSON 數(shù)據(jù)文件。前者是引擎后者是燃料。這套組合適合有地圖開(kāi)發(fā)基礎(chǔ)、需要在 Web 端做風(fēng)場(chǎng)可視化的前端或 GIS 工程師也適合想從零搭一個(gè)氣象圖層的數(shù)據(jù)可視化從業(yè)者。接下來(lái)我會(huì)把這兩類(lèi)資源的格式、調(diào)用方式、參數(shù)調(diào)優(yōu)和常見(jiàn)翻車(chē)點(diǎn)逐一拆開(kāi)講。2. 風(fēng)場(chǎng) JSON 的數(shù)據(jù)結(jié)構(gòu)從網(wǎng)格定義到矢量分量2.1 為什么不用逐點(diǎn)經(jīng)緯度列表最常見(jiàn)的錯(cuò)誤做法是把每個(gè)氣象站點(diǎn)的經(jīng)緯度和風(fēng)向風(fēng)速寫(xiě)成一條記錄前端拿到后逐點(diǎn)畫(huà)箭頭。這種結(jié)構(gòu)在站點(diǎn)稀疏時(shí)能用一旦要做粒子流場(chǎng)就徹底失效——粒子需要在任意位置插值出風(fēng)向風(fēng)速而不是只在離散點(diǎn)上有值。正確的做法是規(guī)則網(wǎng)格把目標(biāo)區(qū)域按等經(jīng)緯度間隔切成若干行列每個(gè)網(wǎng)格節(jié)點(diǎn)存兩個(gè)分量 u 和 v。u 表示東西方向風(fēng)速分量v 表示南北方向風(fēng)速分量正值分別代表向東和向北。粒子在任意位置的速度通過(guò)對(duì)周?chē)膫€(gè)網(wǎng)格節(jié)點(diǎn)做雙線(xiàn)性插值得到這樣粒子才能平滑地穿過(guò)整個(gè)區(qū)域。2.2 一份可用的風(fēng)場(chǎng) JSON 長(zhǎng)什么樣下面是一個(gè)最小可用的風(fēng)場(chǎng) JSON 結(jié)構(gòu)覆蓋經(jīng)度 100 到 120、緯度 20 到 40 的區(qū)域網(wǎng)格間隔 1 度共 21 列 21 行。{ header: { la1: 40, lo1: 100, la2: 20, lo2: 120, dx: 1, dy: 1, nx: 21, ny: 21, refTime: 2025-01-01T00:00:00Z }, data: [ { u: 3.2, v: -1.1 }, { u: 2.8, v: -0.9 } ] }header 里 la1/lo1 是左上角緯度和經(jīng)度la2/lo2 是右下角dx/dy 是網(wǎng)格間距nx/ny 是列數(shù)和行數(shù)。data 是一個(gè)扁平數(shù)組按行優(yōu)先排列長(zhǎng)度必須等于 nx 乘以 ny。每個(gè)元素包含 u 和 v 兩個(gè)數(shù)值單位通常是米每秒。實(shí)際項(xiàng)目中 data 數(shù)組會(huì)有幾百到幾萬(wàn)個(gè)元素這里只示意前兩個(gè)。2.3 生成 JSON 的 Python 腳本與三個(gè)校驗(yàn)點(diǎn)手工拼 JSON 不現(xiàn)實(shí)通常用 Python 從 NetCDF 或 GRIB 文件轉(zhuǎn)換。下面這段腳本讀取一個(gè)假設(shè)的二維數(shù)組并輸出上述結(jié)構(gòu)。import json import numpy as np # 假設(shè) u_data 和 v_data 是 shape 為 (ny, nx) 的二維數(shù)組 ny, nx 21, 21 u_data np.random.uniform(-5, 5, (ny, nx)) v_data np.random.uniform(-5, 5, (ny, nx)) header { la1: 40, lo1: 100, la2: 20, lo2: 120, dx: 1, dy: 1, nx: nx, ny: ny, refTime: 2025-01-01T00:00:00Z } data [] for j in range(ny): for i in range(nx): data.append({ u: round(float(u_data[j, i]), 2), v: round(float(v_data[j, i]), 2) }) with open(wind_field.json, w, encodingutf-8) as f: json.dump({header: header, data: data}, f, ensure_asciiFalse) print(grid points:, len(data), expected:, nx * ny)邏輯說(shuō)明雙重循環(huán)按行優(yōu)先順序遍歷保證 data 的順序和 header 中 nx/ny 定義的網(wǎng)格一致。round 保留兩位小數(shù)避免 JSON 體積過(guò)大。參數(shù)說(shuō)明la1 必須大于 la2因?yàn)榫暥葟谋毕蚰线f減dx 和 dy 的單位是度不是米refTime 用 ISO 8601 格式方便前端顯示數(shù)據(jù)時(shí)間。三個(gè)校驗(yàn)點(diǎn)第一len(data) 必須等于 nx*ny否則粒子插值會(huì)越界第二u 和 v 不能出現(xiàn) null 或 NaNJSON 不支持這些值遇到缺測(cè)要填 0 或做插補(bǔ)第三經(jīng)度跨越 180 度時(shí)要特殊處理否則粒子會(huì)在日期變更線(xiàn)附近跳變。3. 粒子引擎 JS 的調(diào)用方式從初始化到逐幀渲染3.1 核心思路Canvas 覆蓋層加雙緩沖Leaflet 的粒子層不能直接畫(huà)在 DOM 上否則每幀創(chuàng)建銷(xiāo)毀元素會(huì)拖垮瀏覽器。常見(jiàn)做法是創(chuàng)建一個(gè)繼承自 L.Layer 的自定義圖層內(nèi)部持有一個(gè) Canvas 元素用 requestAnimationFrame 驅(qū)動(dòng)逐幀繪制。粒子本身不存經(jīng)緯度而是存屏幕坐標(biāo)和年齡。每幀根據(jù)當(dāng)前屏幕坐標(biāo)反算經(jīng)緯度再?gòu)娘L(fēng)場(chǎng) JSON 中插值出 u/v換算成像素位移更新粒子位置。粒子超出邊界或年齡耗盡就重置到隨機(jī)位置。為了做出拖尾效果每幀不是清空畫(huà)布而是用半透明黑色矩形覆蓋讓舊軌跡逐漸淡出。3.2 初始化參數(shù)與調(diào)用代碼下面是一個(gè)簡(jiǎn)化的粒子圖層實(shí)現(xiàn)可以直接嵌入 Leaflet 項(xiàng)目。L.WindParticleLayer L.Layer.extend({ options: { particleCount: 2000, particleAge: 80, velocityScale: 0.01, fadeOpacity: 0.96, lineWidth: 1.2, color: rgba(255,255,255,0.9) }, initialize: function (windData, options) { L.setOptions(this, options); this._windData windData; this._particles []; }, onAdd: function (map) { this._map map; var canvas this._canvas L.DomUtil.create(canvas, leaflet-wind-layer); canvas.style.position absolute; canvas.style.pointerEvents none; map.getPanes().overlayPane.appendChild(canvas); this._ctx canvas.getContext(2d); this._resize(); map.on(moveend zoomend resize, this._resize, this); this._initParticles(); this._animate(); }, _resize: function () { var size this._map.getSize(); this._canvas.width size.x; this._canvas.height size.y; this._canvas.style.width size.x px; this._canvas.style.height size.y px; }, _initParticles: function () { var size this._map.getSize(); this._particles []; for (var i 0; i this.options.particleCount; i) { this._particles.push({ x: Math.random() * size.x, y: Math.random() * size.y, age: Math.floor(Math.random() * this.options.particleAge) }); } }, _animate: function () { var self this; function frame() { self._drawFrame(); self._rafId requestAnimationFrame(frame); } frame(); }, _drawFrame: function () { var ctx this._ctx; var size this._map.getSize(); ctx.fillStyle rgba(0,0,0, this.options.fadeOpacity ); ctx.fillRect(0, 0, size.x, size.y); ctx.lineWidth this.options.lineWidth; ctx.strokeStyle this.options.color; ctx.beginPath(); for (var i 0; i this._particles.length; i) { var p this._particles[i]; var geo this._map.containerPointToLatLng([p.x, p.y]); var uv this._interpolate(geo.lng, geo.lat); if (!uv) { this._resetParticle(p); continue; } var next this._map.latLngToContainerPoint([ geo.lat uv.v * this.options.velocityScale, geo.lng uv.u * this.options.velocityScale ]); ctx.moveTo(p.x, p.y); ctx.lineTo(next.x, next.y); p.x next.x; p.y next.y; p.age; if (p.age this.options.particleAge) this._resetParticle(p); } ctx.stroke(); }, _resetParticle: function (p) { var size this._map.getSize(); p.x Math.random() * size.x; p.y Math.random() * size.y; p.age 0; }, _interpolate: function (lng, lat) { var h this._windData.header; var fx (lng - h.lo1) / h.dx; var fy (h.la1 - lat) / h.dy; var i Math.floor(fx), j Math.floor(fy); if (i 0 || j 0 || i h.nx - 1 || j h.ny - 1) return null; var tx fx - i, ty fy - j; var idx function (r, c) { return r * h.nx c; }; var d this._windData.data; var u (1 - ty) * ((1 - tx) * d[idx(j, i)].u tx * d[idx(j, i 1)].u) ty * ((1 - tx) * d[idx(j 1, i)].u tx * d[idx(j 1, i 1)].u); var v (1 - ty) * ((1 - tx) * d[idx(j, i)].v tx * d[idx(j, i 1)].v) ty * ((1 - tx) * d[idx(j 1, i)].v tx * d[idx(j 1, i 1)].v); return { u: u, v: v }; }, onRemove: function (map) { cancelAnimationFrame(this._rafId); map.off(moveend zoomend resize, this._resize, this); L.DomUtil.remove(this._canvas); } });邏輯說(shuō)明onAdd 里創(chuàng)建 Canvas 并掛到 overlayPane保證粒子層在地圖平移縮放時(shí)跟隨。_drawFrame 每幀用半透明黑覆蓋實(shí)現(xiàn)拖尾然后遍歷粒子把屏幕坐標(biāo)轉(zhuǎn)成經(jīng)緯度插值出 u/v再換算回屏幕坐標(biāo)畫(huà)線(xiàn)段。_interpolate 做雙線(xiàn)性插值越界返回 null 觸發(fā)粒子重置。參數(shù)說(shuō)明particleCount 控制粒子總數(shù)2000 在普通筆記本上能跑滿(mǎn) 60 幀超過(guò) 5000 會(huì)明顯掉幀particleAge 是粒子壽命值越大拖尾越長(zhǎng)但粒子更新越慢velocityScale 是風(fēng)速到像素的縮放系數(shù)需要根據(jù)地圖縮放級(jí)別調(diào)整通常 0.005 到 0.02 之間fadeOpacity 越接近 1 拖尾越長(zhǎng)0.9 以下會(huì)顯得很干凈但缺少流動(dòng)感。3.3 把 JSON 和 JS 接起來(lái)初始化時(shí)先 fetch JSON再創(chuàng)建圖層并加到地圖上。fetch(wind_field.json) .then(function (res) { return res.json(); }) .then(function (windData) { var windLayer new L.WindParticleLayer(windData, { particleCount: 3000, velocityScale: 0.008, color: rgba(180,220,255,0.85) }); windLayer.addTo(map); });這段代碼里 fetch 拿到 JSON 后直接傳給圖層構(gòu)造函數(shù)。注意 JSON 的 header 字段名必須和 _interpolate 里讀取的一致否則插值會(huì)返回 undefined。如果后端返回的是壓縮過(guò)的數(shù)組而不是對(duì)象數(shù)組需要在傳給圖層前做一次轉(zhuǎn)換把扁平數(shù)組還原成 {u, v} 對(duì)象。4. 避坑與排查粒子不動(dòng)、拖尾異常、縮放錯(cuò)位4.1 粒子全部靜止不動(dòng)現(xiàn)象Canvas 上能看到粒子點(diǎn)但完全不移動(dòng)。原因通常是 u 和 v 全為 0或者 velocityScale 太小導(dǎo)致位移不足一個(gè)像素。解決先檢查 JSON 里 data 數(shù)組的 u/v 是否有非零值再逐步調(diào)大 velocityScale 到 0.05 觀察。另一個(gè)可能是 _interpolate 里經(jīng)緯度順序?qū)懛磳?dǎo)致始終返回 null粒子不斷重置看起來(lái)像靜止。4.2 拖尾變成一團(tuán)糊現(xiàn)象粒子軌跡糊成一片看不出流動(dòng)方向。原因是 fadeOpacity 設(shè)得太高舊幀沒(méi)有及時(shí)淡出。解決把 fadeOpacity 降到 0.9 以下同時(shí)減小 particleAge讓粒子更快重置。如果地圖本身有深色底圖可以把覆蓋色改成半透明白色避免和底圖混在一起。4.3 地圖縮放后粒子偏移現(xiàn)象放大或縮小地圖后粒子位置和實(shí)際風(fēng)向?qū)Σ簧?。原因?Canvas 尺寸沒(méi)有隨地圖容器更新或者 containerPointToLatLng 在縮放動(dòng)畫(huà)期間返回了中間狀態(tài)的值。解決在 moveend 和 zoomend 事件里重新調(diào)用 _resize 并重置所有粒子不要在 zoom 事件里做插值計(jì)算。另外確保 Canvas 的 CSS 寬高和屬性寬高一致否則會(huì)出現(xiàn)坐標(biāo)縮放。4.4 JSON 加載成功但粒子不顯示現(xiàn)象控制臺(tái)沒(méi)有報(bào)錯(cuò)fetch 也返回了 200但 Canvas 上什么都沒(méi)有。原因可能是 Canvas 的 z-index 被底圖蓋住或者 pointerEvents 設(shè)置導(dǎo)致 Canvas 不可見(jiàn)。解決檢查 overlayPane 里 Canvas 是否在 tilePane 之上必要時(shí)手動(dòng)設(shè)置 z-index。另外確認(rèn) _animate 里的 requestAnimationFrame 確實(shí)在循環(huán)調(diào)用可以在 frame 函數(shù)里打一個(gè)計(jì)數(shù)器驗(yàn)證。4.5 移動(dòng)端幀率驟降現(xiàn)象桌面端流暢手機(jī)瀏覽器上粒子卡頓。原因是 particleCount 過(guò)高移動(dòng)端 GPU 填充率不足。解決根據(jù) window.devicePixelRatio 和屏幕寬度動(dòng)態(tài)調(diào)整粒子數(shù)移動(dòng)端降到 800 到 1200。同時(shí)把 lineWidth 降到 1 以下減少每幀的繪制開(kāi)銷(xiāo)。5. 進(jìn)階技巧讓風(fēng)場(chǎng)粒子更貼近真實(shí)氣象5.1 用顏色映射風(fēng)速大小默認(rèn)粒子是單色看不出風(fēng)速?gòu)?qiáng)弱??梢栽?_drawFrame 里根據(jù)插值出的 u/v 計(jì)算風(fēng)速再映射到顏色。var speed Math.sqrt(uv.u * uv.u uv.v * uv.v); var ratio Math.min(speed / 15, 1); var r Math.floor(100 155 * ratio); var g Math.floor(180 - 80 * ratio); var b Math.floor(255 - 155 * ratio); ctx.strokeStyle rgba( r , g , b ,0.9);這段邏輯把風(fēng)速 0 到 15 米每秒映射到藍(lán)到紅的漸變。參數(shù)說(shuō)明15 是最大參考風(fēng)速超過(guò)按 1 處理顏色分量根據(jù)項(xiàng)目配色調(diào)整不必照搬。注意每幀改 strokeStyle 會(huì)增加狀態(tài)切換開(kāi)銷(xiāo)可以按風(fēng)速分檔每檔一個(gè)顏色減少切換次數(shù)。5.2 按縮放級(jí)別動(dòng)態(tài)調(diào)整粒子密度地圖縮小時(shí)粒子顯得稀疏放大時(shí)又太密??梢栽?zoomend 里根據(jù)當(dāng)前縮放級(jí)別重新計(jì)算 particleCount。map.on(zoomend, function () { var z map.getZoom(); var count Math.floor(800 (z - 3) * 400); windLayer.options.particleCount Math.max(500, Math.min(count, 5000)); windLayer._initParticles(); });邏輯說(shuō)明縮放級(jí)別 3 時(shí)約 800 個(gè)粒子每增加一級(jí)多 400 個(gè)上限 5000。這樣小比例尺下不會(huì)太卡大比例尺下細(xì)節(jié)足夠。參數(shù)說(shuō)明基數(shù)和增量根據(jù)實(shí)際地圖范圍和屏幕尺寸調(diào)整沒(méi)有萬(wàn)能值。5.3 用時(shí)間序列 JSON 做多幀切換如果 JSON 里包含多個(gè)時(shí)刻的數(shù)據(jù)可以把 data 改成三維數(shù)組增加一個(gè)時(shí)間維度。前端用定時(shí)器每隔幾秒切換一幀并更新 refTime 顯示。這種做法適合展示風(fēng)場(chǎng)演變過(guò)程但要注意 JSON 體積會(huì)成倍增長(zhǎng)建議按需加載而不是一次性全量拉取。5.4 一個(gè)我常犯的錯(cuò)誤早期做風(fēng)場(chǎng)粒子時(shí)我總想把 particleCount 調(diào)到最大覺(jué)得粒子越多越好看。結(jié)果在一臺(tái)老筆記本上直接卡成幻燈片瀏覽器風(fēng)扇狂轉(zhuǎn)。后來(lái)才明白粒子數(shù)量要和屏幕分辨率、幀率預(yù)算匹配2000 個(gè)粒子在 1080p 下已經(jīng)足夠形成連續(xù)流場(chǎng)再多就是浪費(fèi)。另一個(gè)血淚經(jīng)驗(yàn)是JSON 里的 u/v 單位一定要和 velocityScale 匹配如果數(shù)據(jù)是節(jié)而不是米每秒位移會(huì)差一倍以上粒子看起來(lái)像被凍住。每次換數(shù)據(jù)源先拿一個(gè)已知風(fēng)速值手動(dòng)算一遍像素位移確認(rèn)量級(jí)對(duì)了再調(diào)其他參數(shù)。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取