[特殊字符]️超震撼!用Cesium实现3D风场可视化,动态流线美到窒息

🌪️ 超震撼!用Cesium实现3D风场可视化,动态流线美到窒息✨

家人们谁懂啊😭!当地理数据遇上3D可视化,原来风的流动可以这么直观、这么有氛围感——

想象一下:在数字地球的上空,无数条彩色流线随风向舒展、蜿蜒,快慢交织、明暗渐变,既有真实气象数据的严谨,又有视觉艺术的灵动,这就是Cesium风场可视化的魅力!今天就带大家拆解一款超完整的3D风场实现方案,代码可直接复用,新手也能快速上手👇

🎬 先看效果:风场可视化有多绝?

话不多说,直接上核心亮点,看完你一定会被惊艳到!
在这里插入图片描述

  • 3D立体呈现:基于Cesium数字地球,流线悬浮于地表之上,高度可自由调节,告别平面化的单调展示

  • 动态光影质感:自定义流线材质,光点流动+渐变淡出,每一条流线都有“呼吸感”,视觉冲击力拉满

  • 双数据源切换:支持真实GRIB气象数据加载,也能随机生成合成数据,测试、实战两不误

  • 全参数可控:流动速度、流线高度、轨迹长度、数量一键调节,实时预览效果

  • 平滑衔接过渡:流线首尾衔接自然,弧线路径贴合风向,模拟真实风的流动轨迹

放一张动态示意图(实际效果更丝滑):无数条彩色流线在数字地球上穿梭,蓝色代表微风,红色代表强风,明暗交替间,风的走向、强弱一目了然🌬️

🔧 核心功能拆解:每一处细节都拉满

这款风场可视化组件,不仅颜值能打,功能更是实用到飞起,每一个设计都藏着巧思,咱们逐一看~

1. 🗺️ 数字地球底座:Cesium加持,质感拉满

以Cesium为核心,搭建高性能3D地球场景,搭配天地图影像底图+标注图层,还原真实地理环境,同时隐藏多余控件,专注风场展示体验。

初始视角默认定位中国区域,一键复位功能,再也不怕不小心拖动视角找不到目标范围,新手也能轻松操作✅

2. 🌬️ 风场数据:真实与合成双模式,灵活切换

这是组件的核心灵魂!支持两种数据源,满足不同使用场景:

  • 📊 真实GRIB数据:加载本地JSON格式的风场数据(包含u/v分量、经纬度范围、风速范围),精准还原真实气象情况,适用于科研、气象展示等专业场景

  • 🎲 随机合成数据:基于多层噪声+旋涡算法生成风场,无需依赖外部数据,快速测试效果,适合demo演示、前端开发调试

更贴心的是,数据加载失败时会自动切换到合成数据,避免页面报错,用户体验直接拉满💯

3. 🎨 流线效果:自定义材质,颜值与实用并存

流线不是单调的线条,而是有“生命”的流动光影:

  • 🌈 颜色映射:根据风速自动变色——微风是清新蓝色,强风是热烈红色,风速越大概率越红,直观区分风的强弱

  • 动态效果:自定义流线材质,光点随时间流动,轨迹有渐变淡出效果,每条流线都有独立生命周期,避免视觉杂乱

  • 📏 弧线路径:不是生硬的直线,而是贴合风向的平滑弧线,模拟真实风的流动轨迹,更具真实感

4. 🎛️ 控制面板:全参数可调,实时预览

左侧控制面板,操作简单易懂,小白也能轻松上手,支持5大核心参数调节:

  • ⏩ 流动速度:20~150可调,数值越大,流线流动越快

  • 📈 流线高度:100~5000米可调,按需展示不同高度的风场

  • 📏 轨迹长度:0.2~1.0可调,控制流线光点的显示范围

  • 🔢 流线数量:5~100可调,数量越多,风场覆盖越密集

  • 🔄 数据源切换:一键切换真实数据/随机生成,重新生成风场数据

还有播放/暂停、复位视角按钮,操作逻辑清晰,无需复杂操作,就能调出想要的效果👏

5. 📊 信息面板+加载提示:细节拉满,体验更佳

底部信息面板,实时显示当前数据源、流线数量、数据覆盖范围,让你随时掌握组件状态;加载数据时,还有动态加载动画+提示文字,避免用户误以为页面卡顿,细节感直接拉满~

💻 技术亮点:新手也能复用的核心代码

这款组件基于Vue+Cesium开发,代码结构清晰,注释完整,核心亮点值得借鉴:

  • ✅ 自定义Cesium材质:封装WindTrailMaterialProperty,实现流线的动态光影效果,可直接复用

  • ✅ 风场网格优化:对真实数据进行下采样处理,提升性能,避免页面卡顿

  • ✅ 生命周期管理:流线自动创建、过期删除,实现循环流动,避免内存泄漏

  • ✅ 异常处理:数据加载失败、边界判断,全方位保障组件稳定性

关键代码已经整理好(就是开头给的完整代码),复制粘贴到Vue项目中,替换天地图key,就能快速运行,省去大量开发时间⏳

🎯 适用场景:不止于好看,更实用

这款3D风场可视化组件,可不是单纯的“花架子”,实用性拉满,适用于多种场景:

  • 🌡️ 气象展示:科研机构、气象部门,直观展示风场分布、风速变化

  • 🗺️ 地理教学:学校、培训机构,让学生直观理解风向、风场的概念

  • 💻 前端演示:开发者用于Cesium项目demo,提升项目质感和说服力

  • 🚀 行业应用:无人机、航空、航海等领域,辅助分析风场对作业的影响

✨ 最后:福利送上,快速上手

看到这里,是不是已经迫不及待想试试了?

完整代码已经为大家准备好,包含Vue模板、JS逻辑、CSS样式,无需额外配置,只需3步即可运行:

  1. 将代码复制到Vue项目组件中

  2. 替换代码中的天地图KEY(自行申请,免费可用)

  3. 安装Cesium依赖,运行项目,即可看到完整风场效果

<template>
  <div class="wind-field-container">
    <div class="cesium-container" ref="cesiumContainer"></div>
    <!-- 控制面板 -->
    <div class="control-panel">
      <div class="panel-header">
        <span>风场控制</span>
        <button class="close-btn" @click="togglePanel">−</button>
      </div>
      <div class="panel-content" v-show="panelVisible">
        <div class="control-item">
          <label>流动速度:</label>
          <input
            type="range"
            v-model.number="flowSpeed"
            min="20"
            max="150"
            step="10"
            @change="updateSpeed"
          />
          <span>{{ flowSpeed }}</span>
        </div>
        <div class="control-item">
          <label>流线高度:</label>
          <input
            type="range"
            v-model.number="lineHeight"
            min="100"
            max="5000"
            step="100"
            @change="updateHeight"
          />
          <span>{{ lineHeight }}m</span>
        </div>
        <div class="control-item">
          <label>轨迹长度:</label>
          <input
            type="range"
            v-model.number="trailLength"
            min="0.2"
            max="1.0"
            step="0.1"
            @change="updateTrail"
          />
          <span>{{ trailLength }}</span>
        </div>
        <div class="control-item">
          <label>流线数量:</label>
          <input
            type="range"
            v-model.number="flowCount"
            min="5"
            max="100"
            step="5"
            @change="updateFlowCount"
          />
          <span>{{ flowCount }}</span>
        </div>
        <div class="control-item">
          <label>数据源:</label>
          <select v-model="dataSource" @change="switchDataSource" style="flex: 1; padding: 4px 8px; background: #1E3A5F; border: 1px solid #1E90FF; color: #fff; border-radius: 4px;">
            <option value="real">真实数据 (GRIB)</option>
            <option value="synthetic">随机生成</option>
          </select>
        </div>
        <div class="control-item">
          <label>风场数据:</label>
          <button class="btn" @click="regenerateWindData">重新生成</button>
        </div>
        <div class="control-item">
          <button class="btn btn-primary" @click="toggleAnimation">
            {{ isAnimating ? '暂停' : '播放' }}
          </button>
          <button class="btn" @click="resetView">复位视角</button>
        </div>
      </div>
    </div>
    <!-- 信息显示 -->
    <div class="info-panel">
      <div>🌬️ 数据源: {{ dataSource === 'real' ? '真实数据' : '随机生成' }}</div>
      <div v-if="dataSource === 'real' && windData" style="color: #87CEEB; font-size: 11px; margin: 4px 0;">
        覆盖: {{ windData.header.lo1 }}°~{{ windData.header.lo2 }}°E, {{ windData.header.la2 }}°~{{ windData.header.la1 }}°N
      </div>
      <div v-if="dataSource === 'synthetic'" style="color: #87CEEB; font-size: 11px; margin: 4px 0;">
        基于多层噪声+旋涡生成
      </div>
      <div>流线数: {{ flowLineEntities.length }} / {{ flowCount }}</div>
      <div v-if="isLoading" style="color: #87CEEB; margin-top: 8px;">{{ loadingMessage }}</div>
    </div>
    <!-- 加载指示器 -->
    <div v-if="isLoading" class="loading-overlay">
      <div class="loading-spinner"></div>
      <div class="loading-text">{{ loadingMessage }}</div>
    </div>
  </div>
</template>
<script>
/* global Cesium */
export default {
  name: 'CesiumWindField',
  data() {
    return {
      viewer: null,
      TIANDITU_KEY: '你的key',
      // 流线实体
      flowLineEntities: [],
      // 加载状态
      isLoading: false,
      loadingMessage: '',
      // 数据源类型
      dataSource: 'real', // 'real' 或 'synthetic'
      // 控制参数
      flowSpeed: 80,
      lineHeight: 1000,
      trailLength: 0.4,  // 缩短轨迹,只显示流动的光点
      flowCount: 30,  // 流线数量
      isAnimating: true,
      panelVisible: true,
      // 风场数据
      windData: null, // 存储加载的风场数据
      windGrid: [],
      gridSize: 30,
      // 流线生命周期管理
      lineLifetime: 4000, // 每条流线存在4秒
      respawnInterval: null
    };
  },
  mounted() {
    this.initCesium();
    this.loadBasemap();
    this.initTrailMaterial();
    this.loadWindData(); // 先加载风场数据
  },
  beforeDestroy() {
    this.cleanup();
  },
  methods: {
    initCesium() {
      const container = this.$refs.cesiumContainer;
      if (!container) return console.error('Cesium容器未找到');
      window.CESIUM_BASE_URL = './cesium';
      const viewerOptions = {
        baseLayer: false,
        timeline: false,
        animation: false,
        fullscreenButton: false,
        geocoder: false,
        homeButton: false,
        infoBox: false,
        sceneModePicker: false,
        selectionIndicator: false,
        navigationHelpButton: false,
        baseLayerPicker: false,
        terrainProvider: new Cesium.EllipsoidTerrainProvider(),
        imageryProvider: false,
        requestRenderMode: false,
        contextOptions: {
          webgl: {
            preserveDrawingBuffer: true,
            powerPreference: "high-performance",
            antialias: true
          }
        }
      };
      this.viewer = new Cesium.Viewer(container, viewerOptions);
      this.viewer.cesiumWidget.creditContainer.style.visibility = 'hidden';
      this.viewer.scene.fxaa = true;
      // 设置初始视角(中国区域)
      this.viewer.camera.setView({
        destination: Cesium.Cartesian3.fromDegrees(105, 35, 5000000),
        orientation: {
          heading: 0,
          pitch: -1.57,
          roll: 0
        }
      });
    },
    loadBasemap() {
      if (!this.viewer || !this.TIANDITU_KEY) return;
      const tdImgProvider = new Cesium.UrlTemplateImageryProvider({
        url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${this.TIANDITU_KEY}`,
        minimumLevel: 1,
        maximumLevel: 18,
        credit: '',
      });
      const tdAnnoProvider = new Cesium.UrlTemplateImageryProvider({
        url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${this.TIANDITU_KEY}`,
        minimumLevel: 1,
        maximumLevel: 18,
        credit: '',
      });
      this.viewer.imageryLayers.addImageryProvider(tdImgProvider);
      this.viewer.imageryLayers.addImageryProvider(tdAnnoProvider);
    },
    initTrailMaterial() {
      // 参考 CesiumFlylineDemo 的流线材质
      if (Cesium.Material.WIND_TRAIL_TYPE) return;
      Cesium.Material.WIND_TRAIL_TYPE = 'WindTrail';
      Cesium.Material.WIND_TRAIL_SOURCE = `
        float gauss(float x, float sigma){
          return exp(-(x*x) / (2.0*sigma*sigma));
        }
        czm_material czm_getMaterial(czm_materialInput materialInput)
        {
          czm_material material = czm_getDefaultMaterial(materialInput);
          vec2 st = materialInput.st;
          float w = abs(st.t - 0.5) * 3.0;
          float roundMask = pow(clamp(1.0 - w, 0.0, 1.0), edgeSoft);
          float dir = 1.0;
          float t = time * constantSpeed * 0.0035;
          float u = fract(st.s + dir * t);
          float headBall = gauss(u, 0.1);
          float headSmooth = pow(smoothstep(0.6, 0.0, u), 0.8);
          float head = headBall * 0.5 + headSmooth * 0.2;
          float tailMask = clamp((trailLength - u) / max(trailLength, 1e-4), 0.0, 1.0);
          float tail = pow(tailMask, trailPower);
          float tailLift = mix(tail, max(tail, 0.35), clamp(tailBoost, 0.0, 1.0));
          float intensity = (headBoost * head) * tailLift;
          float alpha = baseAlpha + intensity;
          material.emission = color.rgb * 1.8;
          material.alpha = color.a * clamp(alpha, 0.0, 0.9) * roundMask;
          return material;
        }
      `;
      Cesium.Material._materialCache.addMaterial(Cesium.Material.WIND_TRAIL_TYPE, {
        fabric: {
          type: Cesium.Material.WIND_TRAIL_TYPE,
          uniforms: {
            color: Cesium.Color.fromCssColorString('#00C8FF').withAlpha(0.95),
            time: 0.0,
            constantSpeed: 80.0,
            trailLength: 0.9,
            headSharp: 0.045,
            headBoost: 2.6,
            trailPower: 1.35,
            tailBoost: 0.75,
            baseAlpha: 0.0,
            edgeSoft: 0.75,
            reverse: 0.0
          },
          source: Cesium.Material.WIND_TRAIL_SOURCE
        },
        translucent: () => true
      });
      class WindTrailMaterialProperty {
        constructor(options) {
          this._definitionChanged = new Cesium.Event();
          this._color = Cesium.defaultValue(options.color, Cesium.Color.fromCssColorString('#00C8FF').withAlpha(0.95));
          this._constantSpeed = Cesium.defaultValue(options.constantSpeed, 80);
          this._trailLength = Cesium.defaultValue(options.trailLength, 0.9);
          this._headSharp = Cesium.defaultValue(options.headSharp, 0.045);
          this._headBoost = Cesium.defaultValue(options.headBoost, 2.6);
          this._trailPower = Cesium.defaultValue(options.trailPower, 1.35);
          this._tailBoost = Cesium.defaultValue(options.tailBoost, 0.75);
          this._baseAlpha = Cesium.defaultValue(options.baseAlpha, 0.0);
          this._edgeSoft = Cesium.defaultValue(options.edgeSoft, 0.75);
          this._reverse = Cesium.defaultValue(options.reverse, false);
          this._startMs = Date.now();
        }
        get isConstant() { return false; }
        get definitionChanged() { return this._definitionChanged; }
        getType() { return Cesium.Material.WIND_TRAIL_TYPE; }
        getValue(_time, result) {
          if (!Cesium.defined(result)) result = {};
          result.color = Cesium.Color.clone(this._color, result.color);
          result.time = (Date.now() - this._startMs) / 1000.0;
          result.constantSpeed = Number(this._constantSpeed);
          result.trailLength = Number(this._trailLength);
          result.headSharp = Number(this._headSharp);
          result.headBoost = Number(this._headBoost);
          result.trailPower = Number(this._trailPower);
          result.tailBoost = Number(this._tailBoost);
          result.baseAlpha = Number(this._baseAlpha);
          result.edgeSoft = Number(this._edgeSoft);
          result.reverse = this._reverse ? 1.0 : 0.0;
          return result;
        }
        equals(other) {
          return other instanceof WindTrailMaterialProperty &&
            Cesium.Color.equals(this._color, other._color) &&
            this._constantSpeed === other._constantSpeed &&
            this._trailLength === other._trailLength;
        }
      }
      Cesium.WindTrailMaterialProperty = WindTrailMaterialProperty;
    },
    async loadWindData() {
      try {
        this.isLoading = true;
        this.loadingMessage = '🌬️ 正在加载风场数据...';
        console.log(this.loadingMessage);
        const response = await fetch('./json/wind.json');
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        this.windData = {
          uData: data[0], // u-component of wind
          vData: data[1], // v-component of wind
          header: data[0].header
        };
        this.loadingMessage = '✅ 风场数据加载成功!';
        console.log(`✅ 风场数据加载成功!`);
        console.log(`   覆盖范围: 经度 ${this.windData.header.lo1}° ~ ${this.windData.header.lo2}°`);
        console.log(`            纬度 ${this.windData.header.la2}° ~ ${this.windData.header.la1}°`);
        console.log(`   网格尺寸: ${this.windData.header.nx} x ${this.windData.header.ny}`);
        console.log(`   风速范围: ${this.windData.header.min} ~ ${this.windData.header.max} m/s`);
        // 初始化风场网格
        this.generateWindField();
        this.createFlowLines();
        this.startRespawnLoop();
        setTimeout(() => {
          this.isLoading = false;
        }, 1000);
      } catch (error) {
        console.error('❌ 风场数据加载失败:', error);
        this.loadingMessage = '❌ 数据加载失败,使用合成数据';
        // 如果加载失败,使用生成的方式
        setTimeout(() => {
          this.isLoading = false;
          this.generateSyntheticWindField();
          this.createFlowLines();
          this.startRespawnLoop();
        }, 2000);
      }
    },
    generateWindField() {
      if (!this.windData) {
        console.log('⚠️ 使用生成风场数据');
        this.generateSyntheticWindField();
        return;
      }
      // 使用真实风场数据构建网格
      this.windGrid = [];
      const { uData, vData, header } = this.windData;
      const { nx, ny } = header; // 只解构需要的变量
      // 验证数据
      console.log(`📊 数据验证:`, {
        uDataLength: uData.data.length,
        vDataLength: vData.data.length,
        expectedLength: nx * ny,
        firstU: uData.data[0],
        firstV: vData.data[0]
      });
      // 下采样网格以提高性能
      const downsample = Math.max(1, Math.floor(nx / this.gridSize));
      console.log(`🔄 构建风场网格,下采样因子: ${downsample}`);
      for (let y = 0; y < this.gridSize; y++) {
        this.windGrid[y] = [];
        for (let x = 0; x < this.gridSize; x++) {
          // 映射到原始数据网格
          const origX = Math.floor(x * (nx - 1) / this.gridSize);
          const origY = Math.floor(y * (ny - 1) / this.gridSize);
          const idx = origY * nx + origX;
          if (idx < uData.data.length && idx < vData.data.length) {
            const u = uData.data[idx];
            const v = vData.data[idx];
            this.windGrid[y][x] = { u, v };
          } else {
            this.windGrid[y][x] = { u: 0, v: 0 };
          }
        }
      }
      // 验证风场网格数据
      const sampleWind = this.windGrid[Math.floor(this.gridSize/2)][Math.floor(this.gridSize/2)];
      console.log(`✅ 风场网格构建完成!样本数据:`, sampleWind);
      console.log(`🌍 使用真实GRIB风场数据,覆盖全球范围`);
    },
    generateSyntheticWindField() {
      // 原来的生成逻辑作为后备
      this.windGrid = [];
      const size = this.gridSize;
      for (let y = 0; y < size; y++) {
        this.windGrid[y] = [];
        for (let x = 0; x < size; x++) {
          const nx = x / size;
          const ny = y / size;
          const angle1 = nx * Math.PI * 4 + ny * Math.PI * 2;
          const angle2 = nx * Math.PI * 8 - ny * Math.PI * 4;
          const angle3 = nx * Math.PI * 2 + ny * Math.PI * 6;
          const u = Math.sin(angle1) * 0.5 + Math.sin(angle2) * 0.3 + Math.cos(angle3) * 0.2;
          const v = Math.cos(angle1) * 0.5 + Math.cos(angle2 - 1.5) * 0.3 + Math.sin(angle3 + 1) * 0.2;
          const cx = 0.5;
          const cy = 0.5;
          const dx = nx - cx;
          const dy = ny - cy;
          const dist = Math.sqrt(dx * dx + dy * dy);
          const vortexStrength = Math.exp(-dist * 5) * 0.5;
          const vortexAngle = Math.atan2(dy, dx) + Math.PI / 2;
          this.windGrid[y][x] = {
            u: (u + Math.cos(vortexAngle) * vortexStrength) * 12,
            v: (v + Math.sin(vortexAngle) * vortexStrength) * 12
          };
        }
      }
      console.log('⚠️ 生成风场网格(合成数据)');
    },
    getWindAt(lon, lat) {
      if (!this.windData) {
        // 使用默认边界(中国区域)
        const bounds = { west: 70, south: 15, east: 140, north: 55 };
        return this.getWindAtBounds(lon, lat, bounds);
      }
      // 使用真实风场数据的边界
      const { header } = this.windData;
      const bounds = {
        west: header.lo1,
        south: header.la2,
        east: header.lo2,
        north: header.la1
      };
      return this.getWindAtBounds(lon, lat, bounds);
    },
    getWindAtBounds(lon, lat, bounds) {
      const size = this.gridSize;
      const x = ((lon - bounds.west) / (bounds.east - bounds.west)) * (size - 1);
      const y = ((lat - bounds.south) / (bounds.north - bounds.south)) * (size - 1);
      const x0 = Math.floor(x);
      const y0 = Math.floor(y);
      if (x0 >= 0 && x0 < size && y0 >= 0 && y0 < size) {
        return this.windGrid[y0][x0];
      }
      return { u: 0, v: 0 };
    },
    createFlowLines() {
      if (!this.viewer) return;
      // 清除旧的流线
      this.flowLineEntities.forEach(entity => {
        this.viewer.entities.remove(entity);
      });
      this.flowLineEntities = [];
      // 使用真实风场数据的边界,或默认边界
      let bounds;
      if (this.windData) {
        const { header } = this.windData;
        bounds = {
          west: header.lo1,
          south: header.la2,
          east: header.lo2,
          north: header.la1
        };
      } else {
        bounds = { west: 70, south: 15, east: 140, north: 55 };
      }
      console.log(`🌬️ 创建流线,边界: [${bounds.west}, ${bounds.south}, ${bounds.east}, ${bounds.north}]`);
      let lastEndLon = null;
      let lastEndLat = null;
      let lastEndAngle = null; // 存储上一条线终点的角度
      for (let i = 0; i < this.flowCount; i++) {
        // 如果有上一条线的终点,则使用它作为起点
        let startLon, startLat, startAngle;
        if (lastEndLon !== null && lastEndLat !== null && lastEndAngle !== null) {
          // 检查上一个终点是否在边界内
          if (lastEndLon >= bounds.west && lastEndLon <= bounds.east &&
              lastEndLat >= bounds.south && lastEndLat <= bounds.north) {
            startLon = lastEndLon;
            startLat = lastEndLat;
            startAngle = lastEndAngle;
          } else {
            // 超出边界,重新随机起点
            startLon = bounds.west + Math.random() * (bounds.east - bounds.west);
            startLat = bounds.south + Math.random() * (bounds.north - bounds.south);
            startAngle = null;
          }
        } else {
          // 第一条线随机起点
          startLon = bounds.west + Math.random() * (bounds.east - bounds.west);
          startLat = bounds.south + Math.random() * (bounds.north - bounds.south);
          startAngle = null;
        }
        // 获取起点的风向
        const wind = this.getWindAt(startLon, startLat);
        const windAngle = Math.atan2(wind.v, wind.u);
        // 沿风向延伸得到终点
        const lineLength = 300000 + Math.random() * 200000; // 线的长度
        const endLon = startLon + Math.cos(windAngle) * lineLength * 0.00001;
        const endLat = startLat + Math.sin(windAngle) * lineLength * 0.00001;
        // 生成弧线路径
        const arcPositions = [];
        if (startAngle !== null) {
          // 如果有上一条线的方向,创建平滑过渡弧线
          const transitionLength = lineLength * 0.15; // 过渡段长度
          // 过渡段的起点(从上一条线的终点开始)
          const transStartPos = Cesium.Cartesian3.fromDegrees(startLon, startLat, this.lineHeight);
          // 过渡段的终点(沿着新方向延伸一小段)
          const transEndLon = startLon + Math.cos(windAngle) * transitionLength * 0.00001;
          const transEndLat = startLat + Math.sin(windAngle) * transitionLength * 0.00001;
          const transEndPos = Cesium.Cartesian3.fromDegrees(transEndLon, transEndLat, this.lineHeight);
          // 计算控制点,使用三次贝塞尔曲线实现平滑过渡
          // 第一个控制点:沿着上一个方向延伸
          const cp1Lon = startLon + Math.cos(startAngle) * transitionLength * 0.00001 * 0.5;
          const cp1Lat = startLat + Math.sin(startAngle) * transitionLength * 0.00001 * 0.5;
          const cp1Pos = Cesium.Cartesian3.fromDegrees(cp1Lon, cp1Lat, this.lineHeight);
          // 第二个控制点:沿着新方向反向延伸
          const cp2Lon = transEndLon - Math.cos(windAngle) * transitionLength * 0.00001 * 0.3;
          const cp2Lat = transEndLat - Math.sin(windAngle) * transitionLength * 0.00001 * 0.3;
          const cp2Pos = Cesium.Cartesian3.fromDegrees(cp2Lon, cp2Lat, this.lineHeight);
          // 使用三次贝塞尔曲线生成过渡段
          const transitionSamples = 30;
          for (let t = 0; t <= transitionSamples; t++) {
            const tt = t / transitionSamples;
            const mt = 1 - tt;
            // 三次贝塞尔曲线公式
            const x = mt * mt * mt * transStartPos.x +
                     3 * mt * mt * tt * cp1Pos.x +
                     3 * mt * tt * tt * cp2Pos.x +
                     tt * tt * tt * transEndPos.x;
            const y = mt * mt * mt * transStartPos.y +
                     3 * mt * mt * tt * cp1Pos.y +
                     3 * mt * tt * tt * cp2Pos.y +
                     tt * tt * tt * transEndPos.y;
            const z = mt * mt * mt * transStartPos.z +
                     3 * mt * mt * tt * cp1Pos.z +
                     3 * mt * tt * tt * cp2Pos.z +
                     tt * tt * tt * transEndPos.z;
            arcPositions.push(new Cesium.Cartesian3(x, y, z));
          }
          // 更新起点为过渡段终点
          startLon = transEndLon;
          startLat = transEndLat;
        }
        // 起点和终点的笛卡尔坐标
        const startPos = Cesium.Cartesian3.fromDegrees(startLon, startLat, this.lineHeight);
        const endPos = Cesium.Cartesian3.fromDegrees(endLon, endLat, this.lineHeight);
        // 计算弧线的控制点
        const midLon = (startLon + endLon) / 2;
        const midLat = (startLat + endLat) / 2;
        // 垂直于线段方向的偏移
        const perpAngle = windAngle + Math.PI / 2;
        const arcHeight = lineLength * 0.2; // 弧度高度
        const controlLon = midLon + Math.cos(perpAngle) * arcHeight * 0.00001;
        const controlLat = midLat + Math.sin(perpAngle) * arcHeight * 0.00001;
        const controlPos = Cesium.Cartesian3.fromDegrees(controlLon, controlLat, this.lineHeight + arcHeight * 0.5);
        // 使用二次贝塞尔曲线生成平滑弧线
        const arcSamples = 100;
        for (let j = 0; j <= arcSamples; j++) {
          const t = j / arcSamples;
          // 二次贝塞尔曲线公式: B(t) = (1-t)²P₀ + 2(1-t)tP₁ + t²P₂
          const mt = 1 - t;
          const x = mt * mt * startPos.x + 2 * mt * t * controlPos.x + t * t * endPos.x;
          const y = mt * mt * startPos.y + 2 * mt * t * controlPos.y + t * t * endPos.y;
          const z = mt * mt * startPos.z + 2 * mt * t * controlPos.z + t * t * endPos.z;
          arcPositions.push(new Cesium.Cartesian3(x, y, z));
        }
        // 计算风速强度用于颜色映射
        const windSpeed = Math.sqrt(wind.u * wind.u + wind.v * wind.v);
        const normalizedSpeed = Math.min(1, windSpeed / 15); // 归一化到0-1
        // 根据风速选择颜色(从蓝色到红色)
        const speedColor = new Cesium.Color(
          0.2 + normalizedSpeed * 0.8,  // R: 0.2 -> 1.0
          0.5 - normalizedSpeed * 0.3,  // G: 0.5 -> 0.2
          1.0 - normalizedSpeed * 0.8,  // B: 1.0 -> 0.2
          0.95
        );
        // 创建流线实体
        const entity = this.viewer.entities.add({
          polyline: {
            positions: arcPositions,
            width: 6, // 增加宽度让流线更明显
            clampToGround: false,
            material: new Cesium.WindTrailMaterialProperty({
              color: speedColor, // 使用基于风速的颜色
              constantSpeed: this.flowSpeed,
              trailLength: this.trailLength,
              headBoost: 3.5,
              trailPower: 0.2,
              edgeSoft: 0.95,
              baseAlpha: 0.08,
              reverse: true
            })
          }
        });
        // 为每条流线添加创建时间和生命周期
        entity._createTime = Date.now();
        entity._lifetime = this.lineLifetime + Math.random() * 1000; // 随机生命周期
        entity._endLon = endLon; // 存储终点坐标,供下一条线使用
        entity._endLat = endLat;
        entity._endAngle = windAngle; // 存储终点处的风向角度
        this.flowLineEntities.push(entity);
        // 更新lastEnd位置和角度供下一条线使用
        lastEndLon = endLon;
        lastEndLat = endLat;
        lastEndAngle = windAngle;
      }
      this.viewer.scene.globe.depthTestAgainstTerrain = false;
      console.log(`已创建 ${this.flowLineEntities.length} 条风场圆弧`);
    },
    // 定期重新生成流线
    startRespawnLoop() {
      const checkInterval = 500; // 每500ms检查一次
      this.respawnInterval = setInterval(() => {
        if (!this.isAnimating || !this.viewer) return;
        const now = Date.now();
        // 移除超时的流线
        this.flowLineEntities = this.flowLineEntities.filter(entity => {
          const age = now - entity._createTime;
          if (age > entity._lifetime) {
            // 移除过期流线
            this.viewer.entities.remove(entity);
            return false;
          }
          // 随着时间推移降低透明度(淡出效果)
          const fadeStartTime = entity._lifetime * 0.5;
          if (age > fadeStartTime) {
            const fadeProgress = (age - fadeStartTime) / (entity._lifetime - fadeStartTime);
            const alpha = 1 - fadeProgress;
            if (entity.polyline && entity.polyline.material) {
              const material = entity.polyline.material;
              material._color = material._color || Cesium.Color.fromCssColorString('#00C8FF');
              material._color = material._color.withAlpha(alpha);
            }
          }
          return true;
        });
        // 补充新流线
        const currentCount = this.flowLineEntities.length;
        if (currentCount < this.flowCount) {
          const needCount = this.flowCount - currentCount;
          this.createSingleFlowLine(needCount);
        }
      }, checkInterval);
    },
    // 创建单条流线(用于补充)
    createSingleFlowLine(count = 1) {
      const bounds = { west: 70, south: 15, east: 140, north: 55 };
      for (let k = 0; k < count; k++) {
        // 获取上一条流线的终点作为起点
        let startLon, startLat, startAngle;
        const lastEntity = this.flowLineEntities[this.flowLineEntities.length - 1];
        if (lastEntity && lastEntity._endLon !== undefined) {
          // 检查上一个终点是否在边界内
          if (lastEntity._endLon >= bounds.west && lastEntity._endLon <= bounds.east &&
              lastEntity._endLat >= bounds.south && lastEntity._endLat <= bounds.north) {
            startLon = lastEntity._endLon;
            startLat = lastEntity._endLat;
            startAngle = lastEntity._endAngle;
          } else {
            // 超出边界,重新随机起点
            startLon = bounds.west + Math.random() * (bounds.east - bounds.west);
            startLat = bounds.south + Math.random() * (bounds.north - bounds.south);
            startAngle = null;
          }
        } else {
          // 如果没有上一条线,则随机起点
          startLon = bounds.west + Math.random() * (bounds.east - bounds.west);
          startLat = bounds.south + Math.random() * (bounds.north - bounds.south);
          startAngle = null;
        }
        // 获取起点的风向
        const wind = this.getWindAt(startLon, startLat);
        const windAngle = Math.atan2(wind.v, wind.u);
        // 沿风向延伸得到终点
        const lineLength = 300000 + Math.random() * 200000;
        const endLon = startLon + Math.cos(windAngle) * lineLength * 0.00001;
        const endLat = startLat + Math.sin(windAngle) * lineLength * 0.00001;
        // 生成弧线路径
        const arcPositions = [];
        if (startAngle !== null) {
          // 如果有上一条线的方向,创建平滑过渡弧线
          const transitionLength = lineLength * 0.15; // 过渡段长度
          // 过渡段的起点(从上一条线的终点开始)
          const transStartPos = Cesium.Cartesian3.fromDegrees(startLon, startLat, this.lineHeight);
          // 过渡段的终点(沿着新方向延伸一小段)
          const transEndLon = startLon + Math.cos(windAngle) * transitionLength * 0.00001;
          const transEndLat = startLat + Math.sin(windAngle) * transitionLength * 0.00001;
          const transEndPos = Cesium.Cartesian3.fromDegrees(transEndLon, transEndLat, this.lineHeight);
          // 计算控制点,使用三次贝塞尔曲线实现平滑过渡
          const cp1Lon = startLon + Math.cos(startAngle) * transitionLength * 0.00001 * 0.5;
          const cp1Lat = startLat + Math.sin(startAngle) * transitionLength * 0.00001 * 0.5;
          const cp1Pos = Cesium.Cartesian3.fromDegrees(cp1Lon, cp1Lat, this.lineHeight);
          const cp2Lon = transEndLon - Math.cos(windAngle) * transitionLength * 0.00001 * 0.3;
          const cp2Lat = transEndLat - Math.sin(windAngle) * transitionLength * 0.00001 * 0.3;
          const cp2Pos = Cesium.Cartesian3.fromDegrees(cp2Lon, cp2Lat, this.lineHeight);
          // 使用三次贝塞尔曲线生成过渡段
          const transitionSamples = 30;
          for (let t = 0; t <= transitionSamples; t++) {
            const tt = t / transitionSamples;
            const mt = 1 - tt;
            const x = mt * mt * mt * transStartPos.x +
                     3 * mt * mt * tt * cp1Pos.x +
                     3 * mt * tt * tt * cp2Pos.x +
                     tt * tt * tt * transEndPos.x;
            const y = mt * mt * mt * transStartPos.y +
                     3 * mt * mt * tt * cp1Pos.y +
                     3 * mt * tt * tt * cp2Pos.y +
                     tt * tt * tt * transEndPos.y;
            const z = mt * mt * mt * transStartPos.z +
                     3 * mt * mt * tt * cp1Pos.z +
                     3 * mt * tt * tt * cp2Pos.z +
                     tt * tt * tt * transEndPos.z;
            arcPositions.push(new Cesium.Cartesian3(x, y, z));
          }
          // 更新起点为过渡段终点
          startLon = transEndLon;
          startLat = transEndLat;
        }
        // 起点和终点的笛卡尔坐标
        const startPos = Cesium.Cartesian3.fromDegrees(startLon, startLat, this.lineHeight);
        const endPos = Cesium.Cartesian3.fromDegrees(endLon, endLat, this.lineHeight);
        // 计算弧线的控制点
        const midLon = (startLon + endLon) / 2;
        const midLat = (startLat + endLat) / 2;
        // 垂直于线段方向的偏移
        const perpAngle = windAngle + Math.PI / 2;
        const arcHeight = lineLength * 0.2;
        const controlLon = midLon + Math.cos(perpAngle) * arcHeight * 0.00001;
        const controlLat = midLat + Math.sin(perpAngle) * arcHeight * 0.00001;
        const controlPos = Cesium.Cartesian3.fromDegrees(controlLon, controlLat, this.lineHeight + arcHeight * 0.5);
        // 使用二次贝塞尔曲线生成平滑弧线
        const arcSamples = 100;
        for (let j = 0; j <= arcSamples; j++) {
          const t = j / arcSamples;
          const mt = 1 - t;
          const x = mt * mt * startPos.x + 2 * mt * t * controlPos.x + t * t * endPos.x;
          const y = mt * mt * startPos.y + 2 * mt * t * controlPos.y + t * t * endPos.y;
          const z = mt * mt * startPos.z + 2 * mt * t * controlPos.z + t * t * endPos.z;
          arcPositions.push(new Cesium.Cartesian3(x, y, z));
        }
        // 计算风速强度用于颜色映射
        const windSpeed = Math.sqrt(wind.u * wind.u + wind.v * wind.v);
        const normalizedSpeed = Math.min(1, windSpeed / 15); // 归一化到0-1
        // 根据风速选择颜色(从蓝色到红色)
        const speedColor = new Cesium.Color(
          0.2 + normalizedSpeed * 0.8,  // R: 0.2 -> 1.0
          0.5 - normalizedSpeed * 0.3,  // G: 0.5 -> 0.2
          1.0 - normalizedSpeed * 0.8,  // B: 1.0 -> 0.2
          0.95
        );
        // 创建流线实体
        const entity = this.viewer.entities.add({
          polyline: {
            positions: arcPositions,
            width: 6, // 增加宽度让流线更明显
            clampToGround: false,
            material: new Cesium.WindTrailMaterialProperty({
              color: speedColor, // 使用基于风速的颜色
              constantSpeed: this.flowSpeed,
              trailLength: this.trailLength,
              headBoost: 3.5,
              trailPower: 0.2,
              edgeSoft: 0.95,
              baseAlpha: 0.08,
              reverse: true
            })
          }
        });
        // 设置创建时间和生命周期
        entity._createTime = Date.now();
        entity._lifetime = this.lineLifetime + Math.random() * 1000;
        entity._endLon = endLon; // 存储终点坐标,供下一条线使用
        entity._endLat = endLat;
        entity._endAngle = windAngle; // 存储终点处的风向角度
        this.flowLineEntities.push(entity);
      }
    },
    updateSpeed() {
      this.flowLineEntities.forEach(entity => {
        if (entity.polyline && entity.polyline.material) {
          entity.polyline.material._constantSpeed = this.flowSpeed;
        }
      });
    },
    updateHeight() {
      this.createFlowLines();
    },
    updateTrail() {
      this.flowLineEntities.forEach(entity => {
        if (entity.polyline && entity.polyline.material) {
          entity.polyline.material._trailLength = this.trailLength;
        }
      });
    },
    updateFlowCount() {
      // 清除所有现有流线
      this.flowLineEntities.forEach(entity => {
        this.viewer.entities.remove(entity);
      });
      this.flowLineEntities = [];
      // 创建新数量的流线
      this.createFlowLines();
      console.log(`流线数量已更新为 ${this.flowCount}`);
    },
    regenerateWindData() {
      if (this.windData) {
        // 如果已加载真实数据,重新构建网格
        this.generateWindField();
      } else {
        // 使用合成数据
        this.generateSyntheticWindField();
      }
      this.createFlowLines();
      console.log('🔄 风场数据已重新生成');
    },
    async switchDataSource() {
      console.log(`🔄 切换数据源到: ${this.dataSource === 'real' ? '真实数据' : '随机生成'}`);
      // 停止当前的重新生成循环
      if (this.respawnInterval) {
        clearInterval(this.respawnInterval);
        this.respawnInterval = null;
      }
      if (this.dataSource === 'real') {
        // 切换到真实数据
        if (!this.windData) {
          await this.loadWindData();
        } else {
          this.generateWindField();
          this.createFlowLines();
          this.startRespawnLoop();
        }
      } else {
        // 切换到随机生成数据
        this.windData = null; // 清除真实数据
        this.generateSyntheticWindField();
        this.createFlowLines();
        this.startRespawnLoop();
      }
      console.log(`✅ 数据源切换完成`);
    },
    toggleAnimation() {
      this.isAnimating = !this.isAnimating;
      // 流线材质会自动根据时间动画,这里主要用于显示状态
      console.log('动画状态:', this.isAnimating ? '播放' : '暂停');
    },
    togglePanel() {
      this.panelVisible = !this.panelVisible;
    },
    resetView() {
      if (this.viewer) {
        this.viewer.camera.setView({
          destination: Cesium.Cartesian3.fromDegrees(105, 35, 5000000),
          orientation: {
            heading: 0,
            pitch: -1.57,
            roll: 0
          }
        });
      }
    },
    cleanup() {
      // 清除定时器
      if (this.respawnInterval) {
        clearInterval(this.respawnInterval);
        this.respawnInterval = null;
      }
      // 清除流线实体
      this.flowLineEntities.forEach(entity => {
        if (this.viewer && entity) {
          this.viewer.entities.remove(entity);
        }
      });
      this.flowLineEntities = [];
      // 销毁 Viewer
      if (this.viewer) {
        this.viewer.destroy();
        this.viewer = null;
      }
    }
  }
};
</script>
<style scoped>
.wind-field-container {
  width: 100%;
  height: 100vh;
  position: relative;
  overflow: hidden;
}
.cesium-container {
  width: 100%;
  height: 100%;
}
.control-panel {
  position: absolute;
  top: 20px;
  left: 20px;
  width: 280px;
  background: rgba(10, 25, 47, 0.95);
  border: 1px solid #165DFF;
  border-radius: 8px;
  color: #fff;
  z-index: 999;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.panel-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid #2E4057;
  font-size: 16px;
  font-weight: bold;
}
.close-btn {
  background: none;
  border: none;
  color: #ccc;
  font-size: 20px;
  cursor: pointer;
  padding: 0;
  width: 24px;
  height: 24px;
  line-height: 1;
  transition: color 0.2s;
}
.close-btn:hover {
  color: #fff;
}
.panel-content {
  padding: 16px;
}
.control-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
  font-size: 14px;
}
.control-item label {
  color: #8CBFFF;
  flex-shrink: 0;
  margin-right: 8px;
}
.control-item input[type="range"] {
  flex: 1;
  margin: 0 8px;
  cursor: pointer;
}
.control-item span {
  min-width: 60px;
  text-align: right;
  color: #fff;
}
.btn {
  padding: 6px 16px;
  background: rgba(22, 93, 255, 0.3);
  border: 1px solid #165DFF;
  border-radius: 4px;
  color: #fff;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
  outline: none;
}
.btn:hover {
  background: rgba(22, 93, 255, 0.6);
  border-color: #4080FF;
}
.btn-primary {
  background: #165DFF;
  border-color: #165DFF;
}
.btn-primary:hover {
  background: #4080FF;
  border-color: #4080FF;
}
.info-panel {
  position: absolute;
  bottom: 20px;
  left: 20px;
  background: rgba(10, 25, 47, 0.8);
  border: 1px solid #165DFF;
  border-radius: 4px;
  padding: 8px 12px;
  color: #fff;
  font-size: 12px;
  z-index: 999;
}
.info-panel div {
  margin: 2px 0;
}
/* 加载指示器 */
.loading-overlay {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(20, 30, 48, 0.95);
  border: 2px solid #1E90FF;
  border-radius: 12px;
  padding: 30px 40px;
  text-align: center;
  z-index: 2000;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);
}
.loading-spinner {
  width: 50px;
  height: 50px;
  margin: 0 auto 20px;
  border: 4px solid #1E3A5F;
  border-top-color: #1E90FF;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
.loading-text {
  color: #87CEEB;
  font-size: 16px;
  font-weight: 500;
}
</style>

在这里插入图片描述

如果运行过程中遇到问题,比如Cesium加载失败、数据不显示,评论区留言,我会一一回复解答👇

其实3D可视化并不难,只要找对方法,就能把枯燥的数据变得生动又直观。这款风场组件,既有颜值又有实力,无论是用于学习、演示还是实战,都非常合适~

收藏起来,下次做3D风场可视化,直接拿来用!觉得有用的话,记得点赞+在看,转发给身边需要的朋友哦❤️

关注我,后续分享更多Cesium可视化实战案例,带你解锁更多3D开发小技巧✨

欢迎关注微信公众号“书图工厂

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值