ECMWF 全球预报 WebGL 图层接入
使用 leaflet-wind WebglLayer 对 ECMWF IFS 全球预报温度数据进行 WebGL 渲染,支持全球覆盖、经度循环与前端点击取值。
ECMWF IFS(Integrated Forecasting System)是欧洲中期天气预报中心发布的全球预报模型,分辨率约 25km,覆盖全球,预报精度业界领先。
接入方式与 GFS 全球模型高度一致,仅模型标识和 URL 路径不同。
1. 关键参数说明
| 参数 | 说明 |
|---|---|
model | ecmwf |
| 编码图 URL | https://api.mirror-earth.com/api/vis/ecmwf/temperature_2m/{time}.jpeg?size=2048&apikey={apikey} |
| meta 接口 | https://api.mirror-earth.com/api/vis/ecmwf/meta?apikey={apikey}&timezone=Asia/Shanghai |
coordinates | 全球四角坐标(同 GFS):[[-180, 85.05], [180, 85.05], [180, -85.05], [-180, -85.05]] |
wrapX | true |
| 地图初始视角 | center: [20.0, 0.0],zoom: 2 |
与 GFS 的唯一区别:URL 中 archive_gfs 替换为 ecmwf,meta 接口路径同理。其余所有代码完全一致。
2. 安装依赖
npm install leaflet leaflet-wind
3. 示例代码
import { useEffect, useRef, useState } from 'react';
import 'leaflet/dist/leaflet.css';
const API_KEY = 'YOUR_API_KEY';
// 与 GFS 共用全球坐标
const GLOBAL_COORDS: [number, number][] = [
[-180, 85.051129], // NW
[180, 85.051129], // NE
[180, -85.051129], // SE
[-180, -85.051129], // SW
];
const TEMP_COLORS: [number, number[]][] = [
[-30, [98, 113, 183, 255]],
[-20, [57, 97, 159, 255]],
[-10, [74, 148, 169, 255]],
[0, [77, 141, 123, 255]],
[10, [83, 165, 83, 255]],
[20, [167, 157, 81, 255]],
[30, [159, 127, 58, 255]],
[40, [175, 80, 136, 255]],
];
export default function EcmwfWebglDemo() {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<any>(null);
const layerRef = useRef<any>(null);
const [clickedValue, setClickedValue] = useState<string | null>(null);
const [status, setStatus] = useState('正在加载 ECMWF 全球图层...');
useEffect(() => {
if (!mapRef.current) return;
let isMounted = true;
const init = async () => {
const L: any = await import('leaflet');
const { WebglLayer, ImageSource, DecodeType, RenderFrom, RenderType } =
await import('leaflet-wind');
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 {
// 1. 获取 ECMWF 最新时间(注意路径为 ecmwf)
const { data: [time] } = await fetch(
`https://api.mirror-earth.com/api/vis/ecmwf/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`
).then(r => r.json());
// 2. ECMWF 温度编码图 URL
const url = `https://api.mirror-earth.com/api/vis/ecmwf/temperature_2m/${time}.jpeg?size=2048&apikey=${API_KEY}`;
const source = new ImageSource('temperature', {
url,
coordinates: GLOBAL_COORDS,
decodeType: DecodeType.imageWithExif,
wrapX: true,
});
const interpolateColor = TEMP_COLORS.reduce(
(acc, [val, rgba]) => acc.concat(val, `rgba(${rgba.join(',')})`),
[] as any[]
);
const layer = new WebglLayer('temperature', source, {
styleSpec: {
'fill-color': ['interpolate', ['linear'], ['get', 'value'], ...interpolateColor],
opacity: 0.8,
},
renderFrom: RenderFrom.r,
displayRange: [-50, 50],
renderType: RenderType.colorize,
picking: true,
});
map.addLayer(layer);
layerRef.current = layer;
if (isMounted) setStatus('ECMWF 全球温度图层已加载,点击地图取值');
map.on('click', async (e: any) => {
if (!layerRef.current) return;
const [v] = await layerRef.current.picker(e.latlng);
setClickedValue(
v != null
? `坐标: ${e.latlng.lat.toFixed(2)}, ${e.latlng.lng.toFixed(2)} | 温度: ${v.toFixed(2)}℃`
: '无数据(超出图层范围)'
);
});
} catch {
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' }} />
{clickedValue && (
<div style={{ padding: '8px 12px', background: '#f6ffed', borderTop: '1px solid #b7eb8f' }}>
{clickedValue}
</div>
)}
</div>
);
}<template>
<div>
<p style="padding: 8px 12px; background: #fafafa; margin: 0">{{ status }}</p>
<div ref="mapRef" style="width: 100%; height: 500px" />
<div v-if="clickedValue" style="padding: 8px 12px; background: #f6ffed; border-top: 1px solid #b7eb8f">
{{ clickedValue }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, shallowRef } from 'vue';
const API_KEY = 'YOUR_API_KEY';
const GLOBAL_COORDS: [number, number][] = [
[-180, 85.051129],
[180, 85.051129],
[180, -85.051129],
[-180, -85.051129],
];
const TEMP_COLORS: [number, number[]][] = [
[-30, [98, 113, 183, 255]],
[-20, [57, 97, 159, 255]],
[-10, [74, 148, 169, 255]],
[0, [77, 141, 123, 255]],
[10, [83, 165, 83, 255]],
[20, [167, 157, 81, 255]],
[30, [159, 127, 58, 255]],
[40, [175, 80, 136, 255]],
];
const mapRef = ref<HTMLDivElement>();
const clickedValue = ref<string | null>(null);
const status = ref('正在加载 ECMWF 全球图层...');
const mapInstance = shallowRef<any>(null);
const layerInstance = shallowRef<any>(null);
onMounted(async () => {
const L: any = await import('leaflet');
const { WebglLayer, ImageSource, DecodeType, RenderFrom, RenderType } =
await import('leaflet-wind');
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 {
// 注意:路径为 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());
const url = `https://api.mirror-earth.com/api/vis/ecmwf/temperature_2m/${time}.jpeg?size=2048&apikey=${API_KEY}`;
const source = new ImageSource('temperature', {
url,
coordinates: GLOBAL_COORDS,
decodeType: DecodeType.imageWithExif,
wrapX: true,
});
const interpolateColor = TEMP_COLORS.reduce(
(acc, [val, rgba]) => acc.concat(val, `rgba(${rgba.join(',')})`),
[] as any[]
);
const layer = new WebglLayer('temperature', source, {
styleSpec: {
'fill-color': ['interpolate', ['linear'], ['get', 'value'], ...interpolateColor],
opacity: 0.8,
},
renderFrom: RenderFrom.r,
displayRange: [-50, 50],
renderType: RenderType.colorize,
picking: true,
});
map.addLayer(layer);
layerInstance.value = layer;
status.value = 'ECMWF 全球温度图层已加载,点击地图取值';
map.on('click', async (e: any) => {
if (!layerInstance.value) return;
const [v] = await layerInstance.value.picker(e.latlng);
clickedValue.value = v != null
? `坐标: ${e.latlng.lat.toFixed(2)}, ${e.latlng.lng.toFixed(2)} | 温度: ${v.toFixed(2)}℃`
: '无数据(超出图层范围)';
});
} catch {
status.value = '图层加载失败,请检查 API Key';
}
});
onUnmounted(() => {
mapInstance.value?.remove();
mapInstance.value = null;
});
</script>4. Demo 仓库
完整可运行示例(含 HRES/GFS/ECMWF 切换)已上传至 Gitee:
- 仓库:https://gitee.com/gfyml/me-layer-demo.git
- React 示例:
react-demo/src/components/WebglLayerDemo.tsx(选择 ECMWF 模型) - Vue 示例:
vue-demo/src/components/WebglLayerDemo.vue(选择 ECMWF 模型)