Cesium绘制风场

155 阅读8分钟

绘制网格参数, 创建windField.ts

export type TWindFieldHeader = {
    lo1: number        // 西(起点)
    la1: number;        // 北(起点)
    lo2: number;        // 东(起点)
    la2: number;        // 南部(起点)
    ny: number;         // 列数
    nx: number;         // 行数
    dy: number;
    dx: number;
    parameterUnit: string;
    refTime: string;     // 时间
    [key: string]: unknown;
}
export type TWindField = {
    uComponent: number[];
    vComponent: number[];
    header: TWindFieldHeader
}
/**
 * 网格类
 * 根据风场数据生产风场网格
 */
export default class WindField {
    public west: number = 0;
    public east: number = 0;
    public south: number = 0;
    public north: number = 0;
    public rows: number = 1;
    public cols: number = 1;
    public dx: number = 1;
    public dy: number = 1;
    public unit: string = '';
    public refTime: string = '';
    public grid: any = [];

    constructor(obj: TWindField) {
        const { uComponent, vComponent, header } = obj
        this.west = Number(header.lo1)
        this.east = Number(header.lo2);
        this.north = Number(header.la1);
        this.south = Number(header.la2);
        this.rows = Number(header.ny);
        this.cols = Number(header.nx);
        this.dx = Number(header.dx);
        this.dy = Number(header.dy);
        this.unit = header.parameterUnit;
        this.refTime = header.refTime;

        let rows: any = [], k: number = 0, uv = null;
        for (let j = 0; j < this.rows; j++) {
            rows = []
            for (let i = 0; i < this.cols; i++, k++) {
                uv = this.calcUV(uComponent[k], vComponent[k])
                rows.push(uv)
            }
            this.grid.push(rows)
        }
    }
    /**
     * 经纬度中间的斜向速度
     * @param u 
     * @param v 
     * @returns 
     */
    calcUV(u: number, v: number) {
        // 经度, 纬度, 斜向速度(真实的粒子移动速度)
        return [u, v, Math.sqrt(u * u + v + v)]
    }
    /**
     * 二分差值算法计算给定节点的速度
     * @param x 
     * @param y 
     * @param g00 
     * @param g10 
     * @param g01 
     * @param g11 
     * @returns 
     */
    bilinearInterpolation(x: number, y: number, g00: number[], g10: number[], g01: number[], g11: number[]) {
        const rx = (1 - x);
        const ry = (1 - y);
        const a = rx * ry, b = x * ry, c = rx * y, d = x * y;
        const u = g00[0] * a + g10[0] * b + g01[0] * c + g11[0] * d;
        const v = g00[1] * a + g10[1] * b + g01[1] * c + g11[1] * d;
        return this.calcUV(u, v);
    }
    getIn(x: number, y: number) {
        // console.log('getIn', x , y)
        if (x < 0 || x >= 359 || y >= 180) {
            return [0, 0, 0];
        }
        const x0 = Math.floor(x);
        const y0 = Math.floor(y);
        if (x0 === x && y0 === y) return this.grid[y][x];

        const x1 = x0 + 1;
        const y1 = y0 + 1;

        const g00 = this.getIn(x0, y0),
            g10 = this.getIn(x1, y0),
            g01 = this.getIn(x0, y1),
            g11 = this.getIn(x1, y1);
        let result = null;
        try {
            result = this.bilinearInterpolation(x - x0, y - y0, g00, g10, g01, g11);
        } catch (e) {
            // console.log(x,y);
        }
        return result;
    }
    isInBound(x: number, y: number) {
        if ((x >= 0 && x < this.cols - 1) && (y >= 0 && y < this.rows - 1)) return true;
        return false;
    }
}

创建单个粒子, 创建particle.ts

export type TParticle = {
    longitude: number;          // 粒子初始经度
    latitude: number;           // 粒子初始纬度
    x: number;                  // 粒子初始x位置(相对于风场网格,比如x方向有360个格,x取值就是0-360,这个是初始化时随机生成的)
    y: number;                  // 粒子初始y位置(同上)
    nextLongitude: number;      // 粒子下一步将要移动的经度,这个需要计算得来
    nextLatitude: number;       // 粒子下一步将要移动的y纬度,这个需要计算得来
    age: number;                // 粒子生命周期计时器,每次-1
    speed: number;              // 粒子移动速度,可以根据速度渲染不同颜色
}
/**
 * 粒子对象
 */
export default class Particle {
    public longitude: number = 0;           // 粒子初始经度
    public latitude: number = 0;            // 粒子初始纬度
    public x: number = 0;                   // 粒子初始x位置(相对于风场网格,比如x方向有360个格,x取值就是0-360,这个是初始化时随机生成的)
    public y: number = 0;                   // 粒子初始y位置(同上)
    public nextLongitude: number = 0;       // 粒子下一步将要移动的经度,这个需要计算得来
    public nextLatitude: number = 0;        // 粒子下一步将要移动的y纬度,这个需要计算得来
    public age: number = 0;                 // 粒子生命周期计时器,每次-1
    public speed: number = 0;               // 粒子移动速度,可以根据速度渲染不同颜色
    constructor (maxAge: number) {
        this.age = Math.round(Math.random() * maxAge)
        // this.lng = obj.lng;
        // this.lat = obj.lat;
        // this.x = obj.x;
        // this.y = obj.y;
        // this.nextLng = obj.nextLng;
        // this.nextLat = obj.nextLat;
        // this.speed = obj.speed;
    }

}

创建风场windy.ts

import WindField, { type TWindFieldHeader } from './windField'
import * as Cesium from 'cesium'
import Particle, { type TParticle } from './particle'
import {
  getCurrentMultiple,
  cartesian3ToLatLong,
  windowPositionToCartesian3
} from '../useCoordinate'
export type TWindData = {
  header: {
    parameterCategory: string
    parameterNumber: string
    [key: string]: string
  }
  data: number[]
}
export type TExtent = {
  latitude: [number, number] // 纬度范围
  longitude: [number, number] // 经度范围
}
export type TWindExtent = [number, number, number, number] // [纬度开始, 纬度结束, 经度开始, 经度结束]
type TParams = {
  canvas?: HTMLCanvasElement
  viewer: Cesium.Viewer
  extent?: TExtent //风场绘制时的地图范围,范围不应该大于风场数据的范围
  // 顺序:west/east/south/north,有正负区分,如:[110,120,30,36];
  canvasWidth?: number // 画板宽度
  canvasHeight?: number // 画板高度
  speedRate?: number // 风前进速率,意思是将当前风场横向纵向分成100份,再乘以风速就能得到移动位置,无论地图缩放到哪一级别都是一样的速度,可以用该数值控制线流动的快慢,值越大,越慢
  particlesNumber?: number // 初始粒子总数,根据实际需要进行调节
  maxAge?: number // 每个粒子的最大生存周期
  frameRate?: number // 每秒刷新次数,因为requestAnimationFrame固定每秒60次的渲染,所以如果不想这么快,就把该数值调小一些, canvas绘制消耗性能
  color?: string // 线颜色;
  lineWidth?: number // 线宽度
}

export default class CanvasWindy {
  public windData: TWindData[] = [] // 风场JSON数据
  public viewer: Cesium.Viewer | undefined = undefined
  public canvas: HTMLCanvasElement | undefined = undefined
  public extent: TExtent | undefined = undefined
  public canvasContext: CanvasRenderingContext2D | null = null // canvas上下文
  public canvasWidth: number | undefined = window.innerWidth // 画板宽度
  public canvasHeight: number | undefined = window.innerHeight // 画板高度
  public speedRate: number | undefined = 15000 // 速率
  public particlesNumber: number = 2000 // 粒子数
  public maxAge: number | undefined = 120
  public frameTime: number | undefined
  public color: string | undefined = '#fff' // 粒子颜色
  public lineWidth: number | undefined = 2

  public initExtent: TWindExtent = [0, 360, -90, 90] // 风场初始范围,数据配置文件范围一般是 纬度[0, 360],经度[-90, 90]
  public windField: WindField | null = null
  public particles: TParticle[] = [] // 存放粒子
  public destroyRequestAnimationFrame: number | null = null // requestAnimationFrame事件句柄,用来清除操作
  public isDistorted: boolean = false // 是否销毁

  constructor(json: TWindData[], params: TParams) {
    this.windData = json
    this.viewer = params.viewer
    this.extent = params.extent
    this.canvasWidth = params.canvasWidth ? params.canvasWidth : this.canvasWidth
    this.canvasHeight = params.canvasHeight ? params.canvasHeight : this.canvasHeight
    this.initWindyCanvas()
    this.canvasContext = (this.canvas as HTMLCanvasElement).getContext('2d')
    this.speedRate = params.speedRate ? params.speedRate : this.speedRate
    this.particlesNumber = params.particlesNumber ? params.particlesNumber : this.particlesNumber
    this.maxAge = params.maxAge ? params.maxAge : this.maxAge
    this.frameTime = 1000 / (params.frameRate || 100)
    this.color = params.color ? params.color : this.color
    this.lineWidth = params.lineWidth ? params.lineWidth : this.lineWidth
    this.init()
  }
  initWindyCanvas() {
    const canvas: HTMLCanvasElement = document.createElement('canvas')
    canvas.width = this.canvasWidth as number
    canvas.height = this.canvasHeight as number
    canvas.style.position = 'absolute'
    canvas.style.pointerEvents = 'none'
    canvas.style.zIndex = '10'
    canvas.style.top = '0'
    canvas.style.left = '0'
    this.canvas = canvas
    document.getElementById('cesiumContainer')?.appendChild(canvas)
  }
  /**
   * 格式化数据
   */
  parseWindJson() {
    let uComponent: number[] = [],
      vComponent: number[] = [],
      header: TWindFieldHeader | {} = {}
    this.windData.forEach((record) => {
      const type = `${record.header.parameterCategory},${record.header.parameterNumber}`
      switch (type) {
        case '2,2':
          uComponent = record.data
          header = record.header
          break
        case '2,3':
          vComponent = record.data
          break
        default:
          break
      }
    })
    return {
      header: header as TWindFieldHeader,
      uComponent,
      vComponent
    }
  }
  /**
   * 创建风场网格
   * 初始化相关参数
   */
  createWindField() {
    const data = this.parseWindJson()
    // 创建风场网格系统
    this.windField = new WindField(data)
    this.initExtent = [
      this.windField.west,
      this.windField.east,
      this.windField.south,
      this.windField.north
    ]
    // 如果风场创建时,有传入extent, 根据给定的extent, 随机生成粒子散落在extend范围内
  }
  /**
   * 创建粒子
   */
  createParticles() {
    for (let i = 0; i < this.particlesNumber; i++) {
      const particle = new Particle(this.maxAge as number)
      if (particle) {
        this.particles.push(particle)
      }
    }
  }
  /**
   * 初始化风场
   */
  init() {
    this.createWindField();
    this.createParticles();
    (this.canvasContext as CanvasRenderingContext2D).fillStyle = 'red';
    // (this.canvasContext as CanvasRenderingContext2D).globalAlpha = 0.6;
    let then = Date.now();
    const self = this;
    (function frame() {
      if (!self.isDistorted) {
        self.destroyRequestAnimationFrame = requestAnimationFrame(frame)
        const now = Date.now()
        const delta = now - then
        if (delta > (self.frameTime as number)) {
          then = now - (delta % (self.frameTime as number))
          self.animate()
        }
      } else {
        self.removeLines()
      }
    })()
  }
  /**
   * 根据speedRate参数计算每次步进的 经度 和 纬度 步进长度
   */
  computedCalcStep() {
    const calcExtent = this.initExtent
    // 计算当前当前倍数下的粒子速率
    const currentSpeedRate =
      (this.speedRate as number) / getCurrentMultiple(this.viewer as Cesium.Viewer)
    const calcSpeedRate = [
      (calcExtent[1] - calcExtent[0]) / (currentSpeedRate as number),
      (calcExtent[3] - calcExtent[2]) / (currentSpeedRate as number)
    ]
    return calcSpeedRate
  }
  /**
   * 粒子动画
   */
  animate() {
    this.particles.forEach((particle) => {
      // 粒子没赋值(刚刚初始化)或者粒子生命周期小于等于0
      if (particle.age === null || particle.age <= 0) {
        particle.age = Math.round(Math.random() * (this.maxAge as number))
        this.initParticle(particle)
      }

      if (particle.age > 0) {
        const { nextLongitude, nextLatitude } = particle
        if (this.isInExtent(nextLongitude, nextLatitude)) {
          this.assignmentParticle(particle)
          // 生命周期减少
          particle.age--
        } else {
          particle.age = 0
        }
      }
    })
    if (this.particles.length <= 0) {
      this.removeLines()
    }
    this.drawLines()
  }
  /**
   * 销毁粒子
   * @param particle 
   * @returns 
   */
  judgeParticleExtent(
    particle: TParticle
  ) {
    const extent = this.extent as TExtent
    const { longitude, latitude } = extent
    const lng = particle.longitude;
    const lat = particle.latitude
    if (!lng || !lat) {
      // console.log('销毁粒子', lat, lng)
      return true
    };
    if (extent && particle) {
      if (lat > longitude[1] || lat < longitude[0]) {
        particle.age = 0
        return true
      }
      if (lng > latitude[1] || lng < latitude[0]) {
        particle.age = 0
        return true
      }
    }
    return false
  }
  /**
   * 为粒子赋值
   * @param particle 
   * @param location 有就是初始化粒子参数, 没有就是更新粒子的下一步参数
   */
  assignmentParticle (particle: TParticle, location?: {
    longitude: number | null;
    latitude: number | null;
  }) {
    let x = 0, y = 0
    const field = this.windField as WindField
    const longitude = location ? location.longitude : particle.nextLongitude
    const latitude = location ? location.latitude : particle.nextLatitude
    particle.longitude = longitude as number
    particle.latitude = latitude as number
    if (longitude !== null && latitude !== null) {
      const gridPosition = this.computedGridLocation(longitude, latitude)
      x = gridPosition[0]
      y = gridPosition[1]
      particle.x = x
      particle.y = y
    }
    const _calcSpeedRate = this.computedCalcStep()
    const uv = field.getIn(x, y)
    const nextLongitude = (longitude as number) + _calcSpeedRate[0] * uv[0]
    const nextLatitude = (latitude as number) + _calcSpeedRate[1] * uv[1]
    particle.nextLongitude = nextLongitude
    particle.nextLatitude = nextLatitude
  }
  /**
   * 根据当前风场extent随机生成粒子, 初始化粒子的属性
   * @param particle  粒子
   */
  initParticle(particle: TParticle) {
    let longitude = null,
      latitude = null
      const calcExtent = this.initExtent
      try {
      // const isExtend = typeof this.extent !== 'undefined'
      // do {
        // try {
        //   // 在范围内显示, 不在范围之内, 就不显示
        //   if (isExtend) {
        //     const posX = this.generateIntegers(0, this.canvasWidth as number)
        //     const posY = this.generateIntegers(0, this.canvasHeight as number)
        //     const cartesian = windowPositionToCartesian3(this.viewer as Cesium.Viewer, posX, posY)

        //     if (cartesian) {
        //       const cartographic =
        //         this.viewer?.scene.globe.ellipsoid.cartesianToCartographic(cartesian)
        //       if (cartographic) {
        //         // 将弧度转为度的十进制
        //         longitude = Cesium.Math.toDegrees(cartographic.longitude)
        //         latitude = Cesium.Math.toDegrees(cartographic.latitude)
        //       }
        //     }
        //   } else {
        //     longitude = this.generateFloat(calcExtent[0], calcExtent[1])
        //     latitude = this.generateFloat(calcExtent[2], calcExtent[3])
        //   }
        // } catch (_error) {
        //   // console.log('获取坐标时', _error)
        // }
        longitude = this.generateFloat(calcExtent[0], calcExtent[1])
        latitude = this.generateFloat(calcExtent[2], calcExtent[3])
      // } while (this.windField?.getIn(x, y)[2] <= 0 && safe++ < 30)
    } catch (error: any) {
      console.log('发生异常', error)
    }
    try {
      this.assignmentParticle(particle, {
        longitude,
        latitude
      })
    } catch (e) {
      console.log('为粒子赋值发生异常', e)
    }
    return particle
  }
  /**
   * 在指定的范围内随机数生成小数
   * @param start
   * @param end
   * @returns
   */
  generateFloat(start: number, end: number) {
    return start + Math.random() * (end - start)
  }
  /**
   * 随机数生成一个范围内的整数
   * @param start
   * @param end
   * @returns
   */
  generateIntegers(start: number, end: number) {
    switch (arguments.length) {
      case 1:
        return parseInt(Math.random() * start + 1 + '')
      case 2:
        return parseInt(Math.random() * (end - start + 1) + start + '')
      default:
        return 0
    }
  }
  /**
   * 根据经纬度计算在网格系统中的位置
   * @param longitude 经度
   * @param latitude 纬度
   */
  computedGridLocation(longitude: number, latitude: number) {
    const field = this.windField as WindField
    const _initExtent = this.initExtent as [number, number, number, number]
    const x = ((longitude - _initExtent[0]) / (_initExtent[1] - _initExtent[0])) * (field.cols - 1)
    const y = ((_initExtent[3] - latitude) / (_initExtent[3] - _initExtent[2])) * (field.rows - 1)
    return [x, y]
  }
  /**
   * 粒子是否在风场范围内
   * @param lng
   * @param lat
   */
  isInExtent(lng: number, lat: number) {
    const calcExtent = this.initExtent as [number, number, number, number]
    return (
      lng >= calcExtent[0] && lng <= calcExtent[1] && lat >= calcExtent[2] && lat <= calcExtent[3]
    )
  }
  /**
   * 删除线
   */
  removeLines() {
    window.cancelAnimationFrame(this.destroyRequestAnimationFrame as number)
    this.isDistorted = true
    ;(this.canvas as HTMLCanvasElement).width = 1
    document.getElementById('cesiumContainer')?.removeChild(this.canvas as HTMLCanvasElement)
  }
  /**
   * 绘制
   */
  drawLines() {
    const particles = this.particles
    const ctx = this.canvasContext as CanvasRenderingContext2D
    ctx.lineWidth = this.lineWidth as number
    //后绘制的图形和前绘制的图形如果发生遮挡的话,只显示后绘制的图形跟前一个绘制的图形重合的前绘制的图形部分,示例:https://www.w3school.com.cn/tiy/t.asp?f=html5_canvas_globalcompop_all
    ctx.globalCompositeOperation = 'destination-in'
    ctx.fillRect(0, 0, this.canvasWidth as number, this.canvasHeight as number)
    ctx.globalCompositeOperation = 'lighter' // 重叠部分的颜色会被重新计算
    ctx.globalAlpha = 0.9
    // ;(this.canvasContext as CanvasRenderingContext2D).beginPath()
    // ;(this.canvasContext as CanvasRenderingContext2D).strokeStyle = this.color as string
    particles.forEach((particle) => {
      if (this.judgeParticleExtent(particle)) {
        return
      }
      const moveTopos = this.computedLocationByLngAndLat(particle.longitude, particle.latitude, particle)
      const lineTopos = this.computedLocationByLngAndLat(
        particle.nextLongitude,
        particle.nextLatitude,
        particle
      )
      this.canvasContext?.beginPath()
      if (moveTopos !== null && lineTopos !== null) {
        ctx.moveTo(moveTopos[0], moveTopos[1])
        ctx.lineTo(lineTopos[0], lineTopos[1])
        ctx.strokeStyle = this.color as string
        ctx.stroke()
      }
    })
    // ctx.stroke()
  }
  /**
   * 根据粒子当前所在的位置(相对网格系统),计算经纬度、在根据经纬度返回屏幕坐标
   * @param lng 经度
   * @param lat 纬度
   * @param particle 粒子
   */
  computedLocationByLngAndLat(lng: number, lat: number, particle: TParticle) {
    try {
      const ct3 = Cesium.Cartesian3.fromDegrees(lng, lat, 0)
      const position = Cesium.SceneTransforms.wgs84ToWindowCoordinates(
        (this.viewer as Cesium.Viewer)?.scene,
        ct3
      )
      // 判断当前点是否在地球可见端
      const isVisible = new (Cesium as any).EllipsoidalOccluder(
        Cesium.Ellipsoid.WGS84,
        this.viewer?.camera.position
      ).isPointVisible(ct3)
      if (!isVisible) {
        particle.age = 0
      }
      return (position && position.x) || position.y ? [position.x, position.y] : null
    } catch (e: any) {
      // console.log('computedLocationByLngAndLat', e, lng, lat);
      return null
    }
  }
  /**
   * 重绘
   * @param width
   * @param height
   */
  resize(width: number, height: number) {
    this.canvasWidth = width
    this.canvasHeight = height
  }
  /**
   * 重绘
   */
  redraw() {
    window.cancelAnimationFrame(this.destroyRequestAnimationFrame as number)
    this.particles = []
    this.init()
  }
}

调用

const geTWindData = () => {
   // map/data/windy/gfs.json 数据地址
    get('/map/data/windy/gfs.json', {}, {
        baseURL: '/'
    }).then(res => {
        windInstance = new CanvasWindy(res as any, {
            viewer,
            canvasWidth: mapRef.value?.clientWidth,
            canvasHeight: mapRef.value?.clientHeight,
            extent: extent
        })
    })
}