Skip to Content

镜像地球开放平台

GFS 全球预报风场粒子接入

解析 GFS 全球风场编码图的 Exif UV 分量,还原全球风速网格并使用 leaflet-wind WindLayer 渲染全球风场粒子动画。

GFS 全球风场粒子与 HRES 区域风场的处理逻辑完全一致,差异主要体现在:

  1. URL 路径:使用 archive_gfs,且全球模型风场 URL 需要加 tms=4326 参数
  2. 数据范围:覆盖全球(-180° ~ 180° 经度),ParseWind 构建的 header 中经纬度范围也需调整为全球边界

1. 关键参数说明

参数说明
modelarchive_gfs
风场编码图 URLhttps://api.mirror-earth.com/api/vis/archive_gfs/wind_10m/{time}.jpeg?size=2048&tms=4326&apikey={apikey}
tms=4326全球模型风场 URL 必须携带此参数(区域 HRES 不需要)
全球风场范围latmin: -85.051129, latmax: 85.051129, lonmin: -180, lonmax: 180
ParseWind headerlo1: -180, lo2: 180, la1: 85.05, la2: -85.05(全球范围)

与 HRES 风场的关键区别:GFS 风场 URL 必须加 tms=4326,且 bounds 覆盖全球。其余 ParseWind 逻辑、Exif 解析、像素提取完全相同。

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';

// GFS 全球风场范围
const GFS_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 GfsWindDemo() {
  const mapRef = useRef<HTMLDivElement>(null);
  const mapInstance = useRef<any>(null);
  const [status, setStatus] = useState('正在加载 GFS 全球风场数据...');

  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 {
        const { data: [time] } = await fetch(
          `https://api.mirror-earth.com/api/vis/archive_gfs/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`
        ).then(r => r.json());

        // GFS 全球风场:必须加 tms=4326
        const src = `https://api.mirror-earth.com/api/vis/archive_gfs/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 } = GFS_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('GFS 全球风场粒子已加载,可缩放地图观察效果');
      } 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:

  • 仓库:https://gitee.com/gfyml/me-layer-demo.git
  • React 示例:react-demo/src/components/WindParticleDemo.tsx(选择 GFS 模型)
  • Vue 示例:vue-demo/src/components/WindParticleDemo.vue(选择 GFS 模型)

Previous

ECMWF 全球预报 WebGL 接入

Next

ECMWF 全球预报风场粒子接入