ECMWF 全球预报风场粒子接入
解析 ECMWF 全球风场编码图的 Exif UV 分量,还原全球风速网格并使用 leaflet-wind WindLayer 渲染风场粒子动画。
ECMWF 全球风场粒子接入与 GFS 完全一致,仅 URL 路径中的模型名称不同(ecmwf vs archive_gfs)。ECMWF 以其高精度全球预报能力著称,同样覆盖全球,风场 URL 也需要携带 tms=4326 参数。
1. 关键参数说明
| 参数 | 说明 |
|---|---|
model | ecmwf |
| 风场编码图 URL | https://api.mirror-earth.com/api/vis/ecmwf/wind_10m/{time}.jpeg?size=2048&tms=4326&apikey={apikey} |
tms=4326 | 全球模型风场 URL 必须携带此参数 |
| 全球风场范围 | 同 GFS:latmin: -85.05, latmax: 85.05, lonmin: -180, lonmax: 180 |
与 GFS 风场的唯一区别:URL 中 archive_gfs 替换为 ecmwf,meta 接口路径同理。
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';
const ECMWF_BOUNDS = {
latmin: -85.051129, latmax: 85.051129,
lonmin: -180, lonmax: 180,
};
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',
},
};
}
}
export default function EcmwfWindDemo() {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<any>(null);
const [status, setStatus] = useState('正在加载 ECMWF 全球风场数据...');
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: 2, center: [20.0, 0.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 {
// 注意:路径为 ecmwf(非 archive_gfs)
const { data: [time] } = await fetch(
`https://api.mirror-earth.com/api/vis/ecmwf/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`
).then(r => r.json());
// ECMWF 全球风场:必须加 tms=4326
const src = `https://api.mirror-earth.com/api/vis/ecmwf/wind_10m/${time}.jpeg?size=2048&tms=4326&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 } = ECMWF_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: 3000,
velocityScale: () => VELOCITY_TABLE[map.getZoom()] ?? 0.001,
},
});
map.addLayer(windLayer);
if (isMounted) setStatus('ECMWF 全球风场粒子已加载,可缩放地图观察效果');
} 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 ECMWF_BOUNDS = {
latmin: -85.051129, latmax: 85.051129,
lonmin: -180, lonmax: 180,
};
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('正在加载 ECMWF 全球风场数据...');
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: 2, center: [20.0, 0.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/ecmwf/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`
).then(r => r.json());
// ECMWF 全球风场:必须加 tms=4326
const src = `https://api.mirror-earth.com/api/vis/ecmwf/wind_10m/${time}.jpeg?size=2048&tms=4326&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 } = ECMWF_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: 3000,
velocityScale: () => VELOCITY_TABLE[map.getZoom()] ?? 0.001,
},
});
map.addLayer(windLayer);
status.value = 'ECMWF 全球风场粒子已加载,可缩放地图观察效果';
} 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(选择 ECMWF 模型) - Vue 示例:
vue-demo/src/components/WindParticleDemo.vue(选择 ECMWF 模型)