Skip to Content

镜像地球开放平台

风场粒子动画接入

通过解析带有风场 UV 数据的编码图片,并在前端生成风场粒子动画。

对于风场数据(如 wind_10m),通过请求包含带有 Exif 区间的风场编码图片,前端可以解析图片的 R 和 G 通道,分别还原横向风速(U)和纵向风速(V),之后使用 leaflet-wind 创建动态风场粒子图层。

1. 业务流程

  1. 请求风场编码图:获取特定时间的 JPEG 图像,该图像不仅包含像素颜色,还包含存放 U/V 风场极值的 Exif 信息。
  2. 解析 Exif 区间:使用第三方库(如 exifr)解析图片,获取 U 和 V 分量的最小值和最大值。
  3. 提取像素数据:通过 OffscreenCanvasgetImageData 读取图片的 RGBA 数据,将 R、G 像素解析为 U 数据与 V 数据。
  4. 格式转换:通过自定义 ParseWind 类,将像素数据转换为 leaflet-wind 可以直接识别的标准网格数据格式。
  5. 渲染动画图层:利用 WindLayer 进行渲染,并根据地图缩放层级(zoom)动态调整粒子的移动速度(velocityScale)。

2. 示例应用 (Playground)

下面展示了如何在 Vue 和 React 项目中解析风场数据,并生成粒子动画。请确保项目中已安装所需依赖:

npm install leaflet-wind exifr
<template>
  <div class="playground-content">
    <div class="map" ref="mapRef"></div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, ref, shallowRef } from 'vue';

const mapRef = ref<HTMLDivElement>();
const map = shallowRef<any>();
const windLayer = shallowRef<any>();

const API_KEY = 'YOUR_API_KEY';

// 粒子速度字典,按缩放层级匹配
const velocityTable: 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, 17: 0.000008, 18: 0.000003,
};

class ParseWind {
  data: any;
  constructor(surfaceData: any) {
    this.data = surfaceData;
  }

  getData() {
    const windu = this.setWindU();
    const windv = this.setWindV();
    return [windu, windv];
  }

  setWindU() {
    const surfaceData = this.data;
    const windUObj = {
      data: [] as number[],
      header: {
        dx: surfaceData.solutionX,
        dy: surfaceData.solutionY,
        la1: surfaceData.latmin ?? 55.04123711340206,
        la2: surfaceData.latmax ?? 14.956005998717202,
        lo1: surfaceData.lonmin ?? 69.95880075,
        lo2: surfaceData.lonmax ?? 140.06644640376174,
        nx: surfaceData.width,
        ny: surfaceData.height,
        parameterCategory: 2,
        parameterNumber: 2, // U component
        parameterUnit: 'm.s-1',
        refTime: '2017-02-01 23:00:00',
      },
    };
    const { umin, umax, uData } = surfaceData;
    const length = uData.length;
    for (let i = 0; i < length; i += 1) {
      windUObj.data.push(this.reverse(uData[i], umin, umax));
    }
    return windUObj;
  }

  setWindV() {
    const surfaceData = this.data;
    const windVObj = {
      data: [] as number[],
      header: {
        dx: surfaceData.solutionX,
        dy: surfaceData.solutionY,
        la1: surfaceData.latmin ?? 55.04123711340206,
        la2: surfaceData.latmax ?? 14.956005998717202,
        lo1: surfaceData.lonmin ?? 69.95880075,
        lo2: surfaceData.lonmax ?? 140.06644640376174,
        nx: surfaceData.width,
        ny: surfaceData.height,
        parameterCategory: 2,
        parameterNumber: 3, // V component
        parameterUnit: 'm.s-1',
      },
    };
    const { vmin, vmax, vData } = surfaceData;
    const length = vData.length;
    for (let i = 0; i < length; i += 1) {
      windVObj.data.push(this.reverse(vData[i], vmin, vmax));
    }
    return windVObj;
  }

  reverse(val: number, min: number, max: number) {
    return min + (val * (max - min)) / 255;
  }
}

const parseRange = (exif: any) => {
  const string = exif?.ImageDescription || '';
  const group = string.split(';');
  const gs = group.filter((item: string) => item !== '');
  return gs.map((item: string) => item.split(',').map((v: string) => parseFloat(v)));
};

const getPixelDataFromImageBitmap = (imageBitmap: ImageBitmap) => {
  const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
  const ctx = canvas.getContext('2d');
  if (!ctx) return { uData: [], vData: [], width: 0, height: 0 };

  ctx.drawImage(imageBitmap, 0, 0);
  const imageData = ctx.getImageData(0, 0, imageBitmap.width, imageBitmap.height);
  const pixelData = imageData.data;

  const width = imageBitmap.width;
  const height = imageBitmap.height;
  const totalPixels = width * height;

  const uData = new Float32Array(totalPixels);
  const vData = new Float32Array(totalPixels);

  for (let i = 0; i < totalPixels; i++) {
    const pixelIndex = i * 4;
    uData[i] = pixelData[pixelIndex];     // R 通道为 U 数据
    vData[i] = pixelData[pixelIndex + 1]; // G 通道为 V 数据
  }
  return { uData, vData, width, height };
};

onMounted(async () => {
  if (!mapRef.value) return;

  const L: any = await import('leaflet');
  const { WindLayer } = await import('leaflet-wind');
  const { parse } = await import('exifr/dist/lite.esm.js');

  const mapInstance = L.map(mapRef.value).setView([35.0, 105.0], 4);
  L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
    subdomains: ['a', 'b', 'c', 'd'],
  }).addTo(mapInstance);
  map.value = mapInstance;

  // 获取时间
  const metaUrl = `https://api.mirror-earth.com/api/vis/hres/meta?apikey=${API_KEY}&timezone=Asia/Shanghai`;
  const metaResponse = await fetch(metaUrl);
  const metaResult = await metaResponse.json();
  const time = metaResult.data[0];

  // 加载包含风场数据的图片(注:需确认此接口与您的实际部署路径一致)
  const src = `https://api.mirror-earth.com/api/vis/part/hres/wind_10m/${time}.jpeg?size=2048&apikey=${API_KEY}`;
  
  try {
    const response = await fetch(src);
    const data = await response.arrayBuffer();

    const blob = new Blob([new Uint8Array(data)], { type: 'image/jpeg' });
    const imageBitmap = await createImageBitmap(blob);

    const exif = await parse(data);
    const range = parseRange(exif);
    const [[umin, umax], [vmin, vmax]] = range;
    const [latmin, lonmin, latmax, lonmax] = [14.958762886597938, 69.95880075, 55.04123711340206, 140.07992424999702];

    const { uData, vData, width, height } = getPixelDataFromImageBitmap(imageBitmap);

    const imageParsed = {
      uData,
      vData,
      width,
      height,
      umin, umax,
      vmin, vmax,
      lonmin, lonmax,
      latmin, latmax
    };

    let solutionX = (lonmax - lonmin) / width;
    let solutionY = (latmax - latmin) / height;

    const windDataOptions = {
      ...imageParsed,
      solutionX,
      solutionY,
    };

    const windData = new ParseWind(windDataOptions).getData();

    const panelOptions = {
      colorScale: [
        'rgb(255,255,255)', 'rgb(255,255,255)', 'rgb(255,255,255)', 'rgb(255,255,255)',
        'rgb(255,255,255)', 'rgb(255,255,255)', 'rgb(255,255,255)', 'rgb(255,255,255)',
      ],
      frameRate: 60,
      globalAlpha: 0.9,
      paths: 2000,
    };

    windLayer.value = new WindLayer('wind', windData, {
      windOptions: {
        ...panelOptions,
        velocityScale: () => {
          return velocityTable[mapInstance.getZoom()] || 0.001;
        },
      },
    });

    mapInstance.addLayer(windLayer.value);

  } catch (error) {
    console.error('风场图片加载失败:', error);
  }
});

onUnmounted(() => {
  if (map.value) {
    map.value.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%;
}
</style>

Previous

WebGL方式接入

Next

在线体验