Skip to Content

镜像地球开放平台

HRES 风场粒子接入

解析 HRES 风场编码 JPEG 图的 Exif UV 分量,在前端还原 U/V 风速网格,并使用 leaflet-wind WindLayer 渲染粒子动画。

HRES 风场数据通过 JPEG 图片编码:R 通道存储 U 分量(东西方向风速),G 通道存储 V 分量(南北方向风速),Exif ImageDescription 中存储 UV 的最小值和最大值。前端通过解析 Exif 和像素数据,还原出网格风速,再使用 leaflet-windWindLayer 渲染粒子动画。

1. 关键参数说明

参数说明
风场编码图 URLhttps://api.mirror-earth.com/api/vis/hres/wind_10m/{time}.jpeg?size=2048&apikey={apikey}
tms=4326HRES 不需要此参数(区域模型,全球模型才需要)
HRES 风场范围latmin: 14.9587, latmax: 55.0412, lonmin: 69.9588, lonmax: 140.0799
Exif 解析库exifrexifr/dist/lite.esm.js
Exif 字段ImageDescription → 格式为 umin,umax;vmin,vmax
像素提取R 通道 → U 分量,G 通道 → V 分量(使用 OffscreenCanvas
ParseWind headerparameterNumber: 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>
  );
}

4. Demo 仓库

完整可运行示例已上传至 Gitee:

Previous

HRES WebGL 图层接入

Next

GFS 全球预报 WebGL 接入