风场粒子动画接入
通过解析带有风场 UV 数据的编码图片,并在前端生成风场粒子动画。
对于风场数据(如 wind_10m),通过请求包含带有 Exif 区间的风场编码图片,前端可以解析图片的 R 和 G 通道,分别还原横向风速(U)和纵向风速(V),之后使用 leaflet-wind 创建动态风场粒子图层。
1. 业务流程
- 请求风场编码图:获取特定时间的 JPEG 图像,该图像不仅包含像素颜色,还包含存放 U/V 风场极值的 Exif 信息。
- 解析 Exif 区间:使用第三方库(如
exifr)解析图片,获取 U 和 V 分量的最小值和最大值。 - 提取像素数据:通过
OffscreenCanvas和getImageData读取图片的 RGBA 数据,将 R、G 像素解析为 U 数据与 V 数据。 - 格式转换:通过自定义
ParseWind类,将像素数据转换为leaflet-wind可以直接识别的标准网格数据格式。 - 渲染动画图层:利用
WindLayer进行渲染,并根据地图缩放层级(zoom)动态调整粒子的移动速度(velocityScale)。
2. 示例应用 (Playground)
下面展示了如何在 Vue 和 React 项目中解析风场数据,并生成粒子动画。请确保项目中已安装所需依赖:
npm install leaflet-wind exifr
<template>
<div class="playground-content">
<div class="map" ref="mapRef"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
const mapRef = ref<HTMLDivElement>();
const map = shallowRef<any>();
const windLayer = shallowRef<any>();
const API_KEY = 'YOUR_API_KEY';
// 粒子速度字典,按缩放层级匹配
const velocityTable: Record<number, number> = {
0: 0.03, 1: 0.03, 2: 0.03, 3: 0.03, 4: 0.03,
5: 0.02, 6: 0.02, 7: 0.01, 8: 0.008, 9: 0.002,
10: 0.001, 11: 0.001, 12: 0.0005, 13: 0.0003,
14: 0.0001, 15: 0.00005, 16: 0.00001, 17: 0.000008, 18: 0.000003,
};
class ParseWind {
data: any;
constructor(surfaceData: any) {
this.data = surfaceData;
}
getData() {
const windu = this.setWindU();
const windv = this.setWindV();
return [windu, windv];
}
setWindU() {
const surfaceData = this.data;
const windUObj = {
data: [] as number[],
header: {
dx: surfaceData.solutionX,
dy: surfaceData.solutionY,
la1: surfaceData.latmin ?? 55.04123711340206,
la2: surfaceData.latmax ?? 14.956005998717202,
lo1: surfaceData.lonmin ?? 69.95880075,
lo2: surfaceData.lonmax ?? 140.06644640376174,
nx: surfaceData.width,
ny: surfaceData.height,
parameterCategory: 2,
parameterNumber: 2, // U component
parameterUnit: 'm.s-1',
refTime: '2017-02-01 23:00:00',
},
};
const { umin, umax, uData } = surfaceData;
const length = uData.length;
for (let i = 0; i < length; i += 1) {
windUObj.data.push(this.reverse(uData[i], umin, umax));
}
return windUObj;
}
setWindV() {
const surfaceData = this.data;
const windVObj = {
data: [] as number[],
header: {
dx: surfaceData.solutionX,
dy: surfaceData.solutionY,
la1: surfaceData.latmin ?? 55.04123711340206,
la2: surfaceData.latmax ?? 14.956005998717202,
lo1: surfaceData.lonmin ?? 69.95880075,
lo2: surfaceData.lonmax ?? 140.06644640376174,
nx: surfaceData.width,
ny: surfaceData.height,
parameterCategory: 2,
parameterNumber: 3, // V component
parameterUnit: 'm.s-1',
},
};
const { vmin, vmax, vData } = surfaceData;
const length = vData.length;
for (let i = 0; i < length; i += 1) {
windVObj.data.push(this.reverse(vData[i], vmin, vmax));
}
return windVObj;
}
reverse(val: number, min: number, max: number) {
return min + (val * (max - min)) / 255;
}
}
const parseRange = (exif: any) => {
const string = exif?.ImageDescription || '';
const group = string.split(';');
const gs = group.filter((item: string) => item !== '');
return gs.map((item: string) => item.split(',').map((v: string) => parseFloat(v)));
};
const getPixelDataFromImageBitmap = (imageBitmap: ImageBitmap) => {
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
const ctx = canvas.getContext('2d');
if (!ctx) return { uData: [], vData: [], width: 0, height: 0 };
ctx.drawImage(imageBitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, imageBitmap.width, imageBitmap.height);
const pixelData = imageData.data;
const width = imageBitmap.width;
const height = imageBitmap.height;
const totalPixels = width * height;
const uData = new Float32Array(totalPixels);
const vData = new Float32Array(totalPixels);
for (let i = 0; i < totalPixels; i++) {
const pixelIndex = i * 4;
uData[i] = pixelData[pixelIndex]; // R 通道为 U 数据
vData[i] = pixelData[pixelIndex + 1]; // G 通道为 V 数据
}
return { uData, vData, width, height };
};
onMounted(async () => {
if (!mapRef.value) return;
const L: any = await import('leaflet');
const { WindLayer } = await import('leaflet-wind');
const { parse } = await import('exifr/dist/lite.esm.js');
const mapInstance = L.map(mapRef.value).setView([35.0, 105.0], 4);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
subdomains: ['a', 'b', 'c', 'd'],
}).addTo(mapInstance);
map.value = mapInstance;
// 获取时间
const metaUrl = `https://api.mirror-earth.com/api/vis/hres/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`;
const metaResponse = await fetch(metaUrl);
const metaResult = await metaResponse.json();
const time = metaResult.data[0];
// 加载包含风场数据的图片(注:需确认此接口与您的实际部署路径一致)
const src = `https://api.mirror-earth.com/api/vis/part/hres/wind_10m/${time}.jpeg?size=2048&apikey=${API_KEY}`;
try {
const response = await fetch(src);
const data = await response.arrayBuffer();
const blob = new Blob([new Uint8Array(data)], { type: 'image/jpeg' });
const imageBitmap = await createImageBitmap(blob);
const exif = await parse(data);
const range = parseRange(exif);
const [[umin, umax], [vmin, vmax]] = range;
const [latmin, lonmin, latmax, lonmax] = [14.958762886597938, 69.95880075, 55.04123711340206, 140.07992424999702];
const { uData, vData, width, height } = getPixelDataFromImageBitmap(imageBitmap);
const imageParsed = {
uData,
vData,
width,
height,
umin, umax,
vmin, vmax,
lonmin, lonmax,
latmin, latmax
};
let solutionX = (lonmax - lonmin) / width;
let solutionY = (latmax - latmin) / height;
const windDataOptions = {
...imageParsed,
solutionX,
solutionY,
};
const windData = new ParseWind(windDataOptions).getData();
const panelOptions = {
colorScale: [
'rgb(255,255,255)', 'rgb(255,255,255)', 'rgb(255,255,255)', 'rgb(255,255,255)',
'rgb(255,255,255)', 'rgb(255,255,255)', 'rgb(255,255,255)', 'rgb(255,255,255)',
],
frameRate: 60,
globalAlpha: 0.9,
paths: 2000,
};
windLayer.value = new WindLayer('wind', windData, {
windOptions: {
...panelOptions,
velocityScale: () => {
return velocityTable[mapInstance.getZoom()] || 0.001;
},
},
});
mapInstance.addLayer(windLayer.value);
} catch (error) {
console.error('风场图片加载失败:', error);
}
});
onUnmounted(() => {
if (map.value) {
map.value.remove();
}
});
</script>
<style scoped>
@import 'https://esm.sh/leaflet/dist/leaflet.css';
.playground-content {
width: 100%;
height: 500px;
position: relative;
}
.map {
width: 100%;
height: 100%;
}
</style>import React, { useEffect, useRef } from 'react';
const API_KEY = 'YOUR_API_KEY';
const velocityTable: Record<number, number> = {
0: 0.03, 1: 0.03, 2: 0.03, 3: 0.03, 4: 0.03,
5: 0.02, 6: 0.02, 7: 0.01, 8: 0.008, 9: 0.002,
10: 0.001, 11: 0.001, 12: 0.0005, 13: 0.0003,
14: 0.0001, 15: 0.00005, 16: 0.00001, 17: 0.000008, 18: 0.000003,
};
class ParseWind {
data: any;
constructor(surfaceData: any) {
this.data = surfaceData;
}
getData() {
return [this.setWindU(), this.setWindV()];
}
setWindU() {
const surfaceData = this.data;
const windUObj = {
data: [] as number[],
header: {
dx: surfaceData.solutionX, dy: surfaceData.solutionY,
la1: surfaceData.latmin ?? 55.04123711340206, la2: surfaceData.latmax ?? 14.956005998717202,
lo1: surfaceData.lonmin ?? 69.95880075, lo2: surfaceData.lonmax ?? 140.06644640376174,
nx: surfaceData.width, ny: surfaceData.height,
parameterCategory: 2, parameterNumber: 2, parameterUnit: 'm.s-1', refTime: '2017-02-01 23:00:00',
},
};
const { umin, umax, uData } = surfaceData;
for (let i = 0; i < uData.length; i += 1) {
windUObj.data.push(this.reverse(uData[i], umin, umax));
}
return windUObj;
}
setWindV() {
const surfaceData = this.data;
const windVObj = {
data: [] as number[],
header: {
dx: surfaceData.solutionX, dy: surfaceData.solutionY,
la1: surfaceData.latmin ?? 55.04123711340206, la2: surfaceData.latmax ?? 14.956005998717202,
lo1: surfaceData.lonmin ?? 69.95880075, lo2: surfaceData.lonmax ?? 140.06644640376174,
nx: surfaceData.width, ny: surfaceData.height,
parameterCategory: 2, parameterNumber: 3, parameterUnit: 'm.s-1',
},
};
const { vmin, vmax, vData } = surfaceData;
for (let i = 0; i < vData.length; i += 1) {
windVObj.data.push(this.reverse(vData[i], vmin, vmax));
}
return windVObj;
}
reverse(val: number, min: number, max: number) {
return min + (val * (max - min)) / 255;
}
}
const parseRange = (exif: any) => {
const string = exif?.ImageDescription || '';
return string.split(';').filter(Boolean).map((item: string) => item.split(',').map(parseFloat));
};
const getPixelData = (imageBitmap: ImageBitmap) => {
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
const ctx = canvas.getContext('2d');
if (!ctx) return { uData: [], vData: [], width: 0, height: 0 };
ctx.drawImage(imageBitmap, 0, 0);
const { data } = ctx.getImageData(0, 0, imageBitmap.width, imageBitmap.height);
const total = imageBitmap.width * imageBitmap.height;
const [uData, vData] = [new Float32Array(total), new Float32Array(total)];
for (let i = 0; i < total; i++) {
uData[i] = data[i * 4];
vData[i] = data[i * 4 + 1];
}
return { uData, vData, width: imageBitmap.width, height: imageBitmap.height };
};
const MapComponent = () => {
const mapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let isMounted = true;
let mapInstance: any;
const init = async () => {
const L: any = await import('leaflet');
const { WindLayer } = await import('leaflet-wind');
const { parse } = await import('exifr/dist/lite.esm.js');
if (!document.getElementById('leaflet-css')) {
const link = document.createElement('link');
link.id = 'leaflet-css';
link.rel = 'stylesheet';
link.href = 'https://esm.sh/leaflet/dist/leaflet.css';
document.head.appendChild(link);
}
if (!isMounted || !mapRef.current) return;
mapInstance = L.map(mapRef.current).setView([35.0, 105.0], 4);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
subdomains: ['a', 'b', 'c', 'd'],
}).addTo(mapInstance);
try {
const metaUrl = `https://api.mirror-earth.com/api/vis/hres/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`;
const mRes = await fetch(metaUrl);
const { data: [time] } = await mRes.json();
// 获取风场图图片
const src = `https://api.mirror-earth.com/api/vis/part/hres/wind_10m/${time}.jpeg?size=2048&apikey=${API_KEY}`;
const response = await fetch(src);
const data = await response.arrayBuffer();
const blob = new Blob([new Uint8Array(data)], { type: 'image/jpeg' });
const imageBitmap = await createImageBitmap(blob);
const exif = await parse(data);
const [[umin, umax], [vmin, vmax]] = parseRange(exif);
const [latmin, lonmin, latmax, lonmax] = [14.958762886597938, 69.95880075, 55.04123711340206, 140.07992424999702];
const { uData, vData, width, height } = getPixelData(imageBitmap);
const solutionX = (lonmax - lonmin) / width;
const solutionY = (latmax - latmin) / height;
const windData = new ParseWind({
uData, vData, width, height, umin, umax, vmin, vmax,
lonmin, lonmax, latmin, latmax, solutionX, solutionY
}).getData();
const windLayer = new WindLayer('wind', windData, {
windOptions: {
colorScale: ['rgb(255,255,255)', 'rgb(255,255,255)', 'rgb(255,255,255)'],
frameRate: 60,
globalAlpha: 0.9,
paths: 2000,
velocityScale: () => velocityTable[mapInstance.getZoom()] || 0.001,
},
});
mapInstance.addLayer(windLayer);
} catch (e) {
console.error('风场加载出错: ', e);
}
};
init();
return () => {
isMounted = false;
if (mapInstance) mapInstance.remove();
};
}, []);
return (
<div style={{ width: '100%', height: '500px' }} ref={mapRef} />
);
};
export default MapComponent;