HRES 风场粒子接入
解析 HRES 风场编码 JPEG 图的 Exif UV 分量,在前端还原 U/V 风速网格,并使用 leaflet-wind WindLayer 渲染粒子动画。
HRES 风场数据通过 JPEG 图片编码:R 通道存储 U 分量(东西方向风速),G 通道存储 V 分量(南北方向风速),Exif ImageDescription 中存储 UV 的最小值和最大值。前端通过解析 Exif 和像素数据,还原出网格风速,再使用 leaflet-wind 的 WindLayer 渲染粒子动画。
1. 关键参数说明
| 参数 | 说明 |
|---|---|
| 风场编码图 URL | https://api.mirror-earth.com/api/vis/hres/wind_10m/{time}.jpeg?size=2048&apikey={apikey} |
tms=4326 | HRES 不需要此参数(区域模型,全球模型才需要) |
| HRES 风场范围 | latmin: 14.9587, latmax: 55.0412, lonmin: 69.9588, lonmax: 140.0799 |
| Exif 解析库 | exifr(exifr/dist/lite.esm.js) |
| Exif 字段 | ImageDescription → 格式为 umin,umax;vmin,vmax |
| 像素提取 | R 通道 → U 分量,G 通道 → V 分量(使用 OffscreenCanvas) |
ParseWind header | parameterNumber: 2(U),parameterNumber: 3(V) |
velocityScale | 按 zoom 层级动态调整,避免缩放时粒子速度异常 |
注意:HRES 风场 URL 不含 tms=4326;而 ECMWF、GFS 等全球模型因需要经度循环展示,风场 URL 中需要加 tms=4326。
2. 安装依赖
npm install leaflet leaflet-wind exifr
3. 示例代码
import { useEffect, useRef, useState } from 'react';
import 'leaflet/dist/leaflet.css';
const API_KEY = 'YOUR_API_KEY';
// HRES 风场数据范围
const HRES_BOUNDS = {
latmin: 14.9587, latmax: 55.0412,
lonmin: 69.9588, lonmax: 140.0799,
};
// 粒子速度表(按地图缩放层级)
const VELOCITY_TABLE: 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,
};
/** 解析 Exif ImageDescription 中的 UV 极值:格式 "umin,umax;vmin,vmax" */
const parseRange = (exif: any): number[][] =>
(exif?.ImageDescription || '').split(';').filter(Boolean)
.map((seg: string) => seg.split(',').map(parseFloat));
/** 从 ImageBitmap 中提取 R(U) 和 G(V) 通道像素值 */
const extractPixelData = (bitmap: ImageBitmap) => {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const total = bitmap.width * bitmap.height;
const uData = new Float32Array(total);
const vData = new Float32Array(total);
for (let i = 0; i < total; i++) {
uData[i] = data[i * 4]; // R → U
vData[i] = data[i * 4 + 1]; // G → V
}
return { uData, vData, width: bitmap.width, height: bitmap.height };
};
/** 将像素风速数据转换为 leaflet-wind 标准网格格式 */
class ParseWind {
constructor(private d: any) {}
getData() { return [this.buildComp(2), this.buildComp(3)]; }
private buildComp(paramNumber: number) {
const { solutionX, solutionY, latmin, latmax, lonmin, lonmax, width, height } = this.d;
const isU = paramNumber === 2;
const { min, max, pixels } = isU
? { min: this.d.umin, max: this.d.umax, pixels: this.d.uData }
: { min: this.d.vmin, max: this.d.vmax, pixels: this.d.vData };
const values: number[] = [];
for (let i = 0; i < pixels.length; i++) {
values.push(min + (pixels[i] * (max - min)) / 255);
}
return {
data: values,
header: {
dx: solutionX, dy: solutionY,
la1: latmax, la2: latmin,
lo1: lonmin, lo2: lonmax,
nx: width, ny: height,
parameterCategory: 2,
parameterNumber: paramNumber, // 注意:不能用对象简写,变量名与属性名不同
parameterUnit: 'm.s-1',
refTime: '2017-02-01 23:00:00',
},
};
}
}
export default function HresWindDemo() {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<any>(null);
const [status, setStatus] = useState('正在加载风场数据...');
useEffect(() => {
if (!mapRef.current) return;
let isMounted = true;
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 (!isMounted || !mapRef.current) return;
const map = new L.Map(mapRef.current, { zoom: 4, center: [35.0, 105.0] });
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
subdomains: ['a', 'b', 'c', 'd'],
attribution: '© CartoDB',
}).addTo(map);
mapInstance.current = map;
try {
// 1. 获取最新时间
const { data: [time] } = await fetch(
`https://api.mirror-earth.com/api/vis/hres/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`
).then(r => r.json());
// 2. 请求风场编码图(HRES 无需 tms=4326)
const src = `https://api.mirror-earth.com/api/vis/hres/wind_10m/${time}.jpeg?size=2048&apikey=${API_KEY}`;
const buffer = await fetch(src).then(r => r.arrayBuffer());
// 3. 解析 Exif 获取 UV 极值
const exif = await parse(buffer);
const [[umin, umax], [vmin, vmax]] = parseRange(exif);
// 4. 提取像素中的 UV 数据
const blob = new Blob([new Uint8Array(buffer)], { type: 'image/jpeg' });
const bitmap = await createImageBitmap(blob);
const { uData, vData, width, height } = extractPixelData(bitmap);
// 5. 计算分辨率并组装风场数据
const { latmin, latmax, lonmin, lonmax } = HRES_BOUNDS;
const solutionX = (lonmax - lonmin) / width;
const solutionY = (latmax - latmin) / height;
const windData = new ParseWind({
uData, vData, width, height,
umin, umax, vmin, vmax,
latmin, latmax, lonmin, lonmax,
solutionX, solutionY,
}).getData();
// 6. 创建风场粒子图层
const windLayer = new WindLayer('wind', windData, {
windOptions: {
colorScale: Array(8).fill('rgb(255,255,255)'),
frameRate: 60,
globalAlpha: 0.9,
paths: 2000,
velocityScale: () => VELOCITY_TABLE[map.getZoom()] ?? 0.001,
},
});
map.addLayer(windLayer);
if (isMounted) setStatus('HRES 风场粒子已加载,可缩放地图观察效果');
} catch (e) {
console.error(e);
if (isMounted) setStatus('风场数据加载失败,请检查 API Key');
}
};
init();
return () => {
isMounted = false;
mapInstance.current?.remove();
mapInstance.current = null;
};
}, []);
return (
<div>
<p style={{ padding: '8px 12px', background: '#fafafa', margin: 0 }}>{status}</p>
<div ref={mapRef} style={{ width: '100%', height: '500px' }} />
</div>
);
}<template>
<div>
<p style="padding: 8px 12px; background: #fafafa; margin: 0">{{ status }}</p>
<div ref="mapRef" style="width: 100%; height: 500px" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, shallowRef } from 'vue';
const API_KEY = 'YOUR_API_KEY';
const HRES_BOUNDS = {
latmin: 14.9587, latmax: 55.0412,
lonmin: 69.9588, lonmax: 140.0799,
};
const VELOCITY_TABLE: 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,
};
const parseRange = (exif: any): number[][] =>
(exif?.ImageDescription || '').split(';').filter(Boolean)
.map((seg: string) => seg.split(',').map(parseFloat));
const extractPixelData = (bitmap: ImageBitmap) => {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const total = bitmap.width * bitmap.height;
const uData = new Float32Array(total);
const vData = 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: bitmap.width, height: bitmap.height };
};
class ParseWind {
constructor(private d: any) {}
getData() { return [this.buildComp(2), this.buildComp(3)]; }
private buildComp(paramNumber: number) {
const { solutionX, solutionY, latmin, latmax, lonmin, lonmax, width, height } = this.d;
const isU = paramNumber === 2;
const { min, max, pixels } = isU
? { min: this.d.umin, max: this.d.umax, pixels: this.d.uData }
: { min: this.d.vmin, max: this.d.vmax, pixels: this.d.vData };
const values: number[] = [];
for (let i = 0; i < pixels.length; i++) {
values.push(min + (pixels[i] * (max - min)) / 255);
}
return {
data: values,
header: {
dx: solutionX, dy: solutionY,
la1: latmax, la2: latmin,
lo1: lonmin, lo2: lonmax,
nx: width, ny: height,
parameterCategory: 2,
parameterNumber: paramNumber,
parameterUnit: 'm.s-1',
refTime: '2017-02-01 23:00:00',
},
};
}
}
const mapRef = ref<HTMLDivElement>();
const status = ref('正在加载风场数据...');
const mapInstance = shallowRef<any>(null);
onMounted(async () => {
const L: any = await import('leaflet');
const { WindLayer } = await import('leaflet-wind');
const { parse } = await import('exifr/dist/lite.esm.js');
await import('leaflet/dist/leaflet.css');
const map = new L.Map(mapRef.value!, { zoom: 4, center: [35.0, 105.0] });
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
subdomains: ['a', 'b', 'c', 'd'],
attribution: '© CartoDB',
}).addTo(map);
mapInstance.value = map;
try {
const { data: [time] } = await fetch(
`https://api.mirror-earth.com/api/vis/hres/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`
).then(r => r.json());
// HRES 风场 URL 无需 tms=4326
const src = `https://api.mirror-earth.com/api/vis/hres/wind_10m/${time}.jpeg?size=2048&apikey=${API_KEY}`;
const buffer = await fetch(src).then(r => r.arrayBuffer());
const exif = await parse(buffer);
const [[umin, umax], [vmin, vmax]] = parseRange(exif);
const blob = new Blob([new Uint8Array(buffer)], { type: 'image/jpeg' });
const bitmap = await createImageBitmap(blob);
const { uData, vData, width, height } = extractPixelData(bitmap);
const { latmin, latmax, lonmin, lonmax } = HRES_BOUNDS;
const solutionX = (lonmax - lonmin) / width;
const solutionY = (latmax - latmin) / height;
const windData = new ParseWind({
uData, vData, width, height,
umin, umax, vmin, vmax,
latmin, latmax, lonmin, lonmax,
solutionX, solutionY,
}).getData();
const windLayer = new WindLayer('wind', windData, {
windOptions: {
colorScale: Array(8).fill('rgb(255,255,255)'),
frameRate: 60,
globalAlpha: 0.9,
paths: 2000,
velocityScale: () => VELOCITY_TABLE[map.getZoom()] ?? 0.001,
},
});
map.addLayer(windLayer);
status.value = 'HRES 风场粒子已加载,可缩放地图观察效果';
} catch (e) {
console.error(e);
status.value = '风场数据加载失败,请检查 API Key';
}
});
onUnmounted(() => {
mapInstance.value?.remove();
mapInstance.value = null;
});
</script>4. Demo 仓库
完整可运行示例已上传至 Gitee:
- 仓库:https://gitee.com/gfyml/me-layer-demo.git
- React 示例:
react-demo/src/components/WindParticleDemo.tsx - Vue 示例:
vue-demo/src/components/WindParticleDemo.vue