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