WebGL 方式接入
通过 WebGL 方式展示气象图层,支持动态调色、高性能渲染及前端直接点击取值,适用于大屏展示和复杂交互场景。
WebGL 渲染方式能够带来更高的渲染效率、更平滑的视觉效果,并支持在前端直接进行点击取值(而不需要为了取值额外发起网络请求)。本指南将展示如何使用 leaflet-wind 在项目中加载图层。
本教程使用 leaflet-wind 插件,它内置了硬件加速渲染支持。
1. 业务流程
与普通的图片图层不同,WebGL 方式返回的是编码后的数据栅格(通常为 jpeg 格式的带 Exif 数据的单张图像)。前端获取图像后,利用 Shader 对数据进行解码,并根据配置的色板进行染色渲染。
- 获取时间:调用 meta 接口获取所需的最新数据时间。
- 构建编码图 URL:将
time、model、element_level组合。注意 WebGL 使用的接口路径最后通常带有.jpeg?size=2048,例如https://api.mirror-earth.com/api/vis/{model}/{element}/{time}.jpeg?size=2048&apikey={apikey}。 - 初始化图层数据源:利用
leaflet-wind的ImageSource并指定decodeType: DecodeType.imageWithExif载入图像。 - 加载 WebGL 图层:创建
WebglLayer,通过styleSpec配置色板与透明度,添加到地图上。 - 前端直接取值:使用图层自带的
picker方法直接读取点击处的色值/数据值。
2. 示例应用 (Playground)
下面示例展示了如何在 Vue 和 React 项目中,异步加载 leaflet 和 leaflet-wind 渲染温度图层,并使用本地提供的取值方法。
<template>
<div class="playground-content">
<div class="map" ref="mapRef"></div>
<div v-if="clickedValue" class="result-box">
点击结果: {{ clickedValue }}
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
const mapRef = ref<HTMLDivElement>();
const clickedValue = ref<string | null>(null);
const API_KEY = 'YOUR_API_KEY';
let map: any;
let heatmapLayer: any;
const fetchTime = async (model: string) => {
const metaUrl = `https://api.mirror-earth.com/api/vis/${model}/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`;
const response = await fetch(metaUrl);
const result = await response.json();
return result.data[0];
};
async function initMap() {
if (!mapRef.value) return;
// 异步加载相关依赖,避免在 ssg 模式下构建报错
const L: any = await import('leaflet');
const { WebglLayer, ImageSource, DecodeType, RenderFrom, RenderType } = await import('leaflet-wind');
map = new L.Map(mapRef.value, {
zoom: 3,
center: [35.0, 105.0]
});
const tileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
subdomains: ['a', 'b', 'c', 'd'],
});
map.addLayer(tileLayer);
// 1. 获取模型最新时间
const time = await fetchTime('ecmwf');
// 2. 构建 WebGL 瓦片 URL
const dataUrl = `https://api.mirror-earth.com/api/vis/ecmwf/temperature_2m/${time}.jpeg?size=2048&apikey=${API_KEY}&timezone=Asia/Shanghai`;
const source = new ImageSource('temperature', {
url: dataUrl,
coordinates: [
[-180, 85.051129],
[180, 85.051129],
[180, -85.051129],
[-180, -85.051129],
],
decodeType: DecodeType.imageWithExif,
wrapX: true,
});
// 自定义色板配置(以温度为例)
const tempColorConfig = [
[-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 interpolateColor = tempColorConfig.reduce(
(result, item) => result.concat(item[0], `rgba(${item[1].join(',')})`),
[] as any[]
);
heatmapLayer = new WebglLayer('temperature', source, {
styleSpec: {
'fill-color': ['interpolate', ['linear'], ['get', 'value'], ...interpolateColor],
opacity: 0.8,
},
renderFrom: RenderFrom.rg,
displayRange: [-50, 50],
renderType: RenderType.colorize,
});
map.addLayer(heatmapLayer);
// 3. 前端直接点击取值
map.on('click', async (e: any) => {
if (!heatmapLayer) return;
// 使用图层自带的 picker 取值
// 返回格式为数组,单层图层数值通常对应数组的第一项 v1
const [v1, v2, v3, v4] = await heatmapLayer.picker(e.latlng);
if (v1 !== undefined && v1 !== null) {
clickedValue.value = `坐标: ${e.latlng.lat.toFixed(2)}, ${e.latlng.lng.toFixed(2)} | 数值: ${v1.toFixed(2)}`;
} else {
clickedValue.value = `无数据 (可能超出图层范围)`;
}
});
}
onMounted(() => {
initMap();
});
onUnmounted(() => {
if (map) {
map.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%;
}
.result-box {
position: absolute;
bottom: 20px;
left: 20px;
z-index: 1000;
padding: 10px;
background: white;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
color: #333;
}
</style>import React, { useEffect, useRef, useState } from 'react';
const API_KEY = 'YOUR_API_KEY';
const fetchTime = async (model: string) => {
const metaUrl = `https://api.mirror-earth.com/api/vis/${model}/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`;
const response = await fetch(metaUrl);
const result = await response.json();
return result.data[0];
};
const MapComponent = () => {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<any>(null);
const layerInstance = useRef<any>(null);
const [clickedValue, setClickedValue] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const initMap = async () => {
if (!mapRef.current) return;
const L: any = await import('leaflet');
const { WebglLayer, ImageSource, DecodeType, RenderFrom, RenderType } = await import('leaflet-wind');
// 引入样式
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) return;
const map = L.map(mapRef.current).setView([35.0, 105.0], 3);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
subdomains: ['a', 'b', 'c', 'd'],
}).addTo(map);
mapInstance.current = map;
const time = await fetchTime('ecmwf');
const dataUrl = `https://api.mirror-earth.com/api/vis/ecmwf/temperature_2m/${time}.jpeg?size=2048&apikey=${API_KEY}&timezone=Asia/Shanghai`;
const source = new ImageSource('temperature', {
url: dataUrl,
coordinates: [
[-180, 85.051129],
[180, 85.051129],
[180, -85.051129],
[-180, -85.051129],
],
decodeType: DecodeType.imageWithExif,
wrapX: true,
});
const tempColorConfig = [
[-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 interpolateColor = tempColorConfig.reduce(
(result, item) => result.concat(item[0], `rgba(${(item[1] as number[]).join(',')})`),
[] as any[]
);
const heatmapLayer = new WebglLayer('temperature', source, {
styleSpec: {
'fill-color': ['interpolate', ['linear'], ['get', 'value'], ...interpolateColor],
opacity: 0.8,
},
renderFrom: RenderFrom.rg,
displayRange: [-50, 50],
renderType: RenderType.colorize,
});
map.addLayer(heatmapLayer);
layerInstance.current = heatmapLayer;
map.on('click', async (e: any) => {
if (!layerInstance.current) return;
const [v1, v2, v3, v4] = await layerInstance.current.picker(e.latlng);
if (v1 !== undefined && v1 !== null) {
setClickedValue(`坐标: ${e.latlng.lat.toFixed(2)}, ${e.latlng.lng.toFixed(2)} | 数值: ${v1.toFixed(2)}`);
} else {
setClickedValue(`无数据 (可能超出图层范围)`);
}
});
};
initMap();
return () => {
isMounted = false;
if (mapInstance.current) {
mapInstance.current.remove();
mapInstance.current = null;
}
};
}, []);
return (
<div style={{ position: 'relative', width: '100%', height: '500px' }}>
<div ref={mapRef} style={{ width: '100%', height: '100%' }} />
{clickedValue && (
<div style={{
position: 'absolute',
bottom: '20px',
left: '20px',
zIndex: 1000,
padding: '10px',
background: 'white',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
color: '#333'
}}>
点击结果: {clickedValue}
</div>
)}
</div>
);
};
export default MapComponent;