5、Vue中使用Cesium实现交互式折线绘制详解

引言

Cesium是一款强大的开源3D地理信息可视化引擎,广泛应用于数字地球、地图可视化等领域。在Vue项目中集成Cesium可以快速构建高性能的地理信息应用。本文将详细介绍如何在Vue项目中实现交互式折线绘制功能,包括顶点添加、临时绘制、距离计算等核心功能,并为新手提供详细的代码注释和学习资源。

Cesium核心概念速览

在开始之前,我们先了解几个Cesium的核心概念:

  • Viewer:Cesium的核心实例,用于创建和管理3D场景
  • Entity:高层次的对象封装,用于创建和管理可视化对象(如点、线、面)
  • Primitive:低层次的渲染对象,比Entity更高效,适合大量数据渲染
  • Cartesian3:三维笛卡尔坐标,Cesium中表示位置的基本方式
  • ScreenSpaceEventHandler:用于处理用户输入事件(如点击、鼠标移动)

环境准备

假设已完成Cesium 2D地图初始化,需要安装以下依赖:

复制代码
npm install cesium lodash

核心功能实现

折线绘制的核心流程如下:

  1. 点击地图添加顶点
  2. 鼠标移动时更新临时折线
  3. 双击结束绘制并保存折线
  4. 右键取消绘制

代码解析

1. 数据属性定义

复制代码
data() {
  return {
    viewer: null, // Cesium Viewer实例
    isDrawing: false, // 是否处于绘制状态
    currentPositions: [], // 当前折线的顶点坐标数组
    tempPrimitive: null, // 临时折线Primitive对象
    allPolylines: [], // 保存所有已绘制的折线
    handler: null, // 屏幕空间事件处理器
    isFirstClick: true, // 是否是首次点击(用于开始绘制)
    vertexMarkers: [], // 顶点标记Entity数组
  };
}

2. 初始化事件处理器

复制代码
initDrawLine() {
  // 创建屏幕空间事件处理器,用于监听用户在地图上的交互
  this.handler = new Cesium.ScreenSpaceEventHandler(this.viewer.canvas);
  
  // 移除默认的双击事件,避免与自定义双击结束绘制冲突
  this.viewer.cesiumWidget.screenSpaceEventHandler.removeInputAction(
    Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK
  );
  
  // 显示操作提示
  this.showToast('点击添加顶点,双击结束绘制(至少需要3个顶点)');
  
  // 绑定左键单击事件 - 添加折线顶点
  this.handler.setInputAction((event) => {
    // 禁用地图交互,防止绘制时误操作(如旋转、缩放地图)
    this.disableMapInteraction();
    
    // 将鼠标点击位置转换为地球表面坐标
    const position = this.getPositionFromMouse(event.position);
    if (!position) return;
    
    // 检测与上一顶点的距离,避免过近的重复顶点
    if (this.currentPositions.length > 0) {
      const lastPosition = this.currentPositions[this.currentPositions.length - 1];
      const distance = Cesium.Cartesian3.distance(position, lastPosition);
      const DISTANCE_THRESHOLD = 1.0; // 距离阈值(米)
      
      if (distance < DISTANCE_THRESHOLD) {
        this.showToast(`点击位置与上一顶点距离过近(${distance.toFixed(2)}米),已忽略`);
        this.enableMapInteraction(); // 重新启用地图交互
        return;
      }
    }
    
    // 首次点击时标记开始绘制
    if (this.isFirstClick) {
      this.isDrawing = true;
      this.isFirstClick = false;
    }
    
    // 添加顶点坐标并显示标记
    this.currentPositions.push(position);
    this.addVertexMarker(position);
    
    // 绘制临时折线(此时鼠标未移动,传入null)
    this.drawTempLine(null);
  }, Cesium.ScreenSpaceEventType.LEFT_DOWN);
  
  // 鼠标移动 - 更新临时折线
  this.handler.setInputAction((event) => {
    if (!this.isDrawing || this.currentPositions.length === 0) return;
    const position = this.getPositionFromMouse(event.endPosition);
    if (position) {
      this.throttledDrawTempLine(position); // 使用节流优化性能
    }
  }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
  
  // 左键双击 - 结束绘制
  this.handler.setInputAction((event) => {
    if (!this.isDrawing) return;
    const position = this.getPositionFromMouse(event.position);
    if (position) {
      this.currentPositions.push(position);
      this.addVertexMarker(position);
    }
    
    // 验证顶点数量,至少需要3个顶点才能形成闭合区域
    if (this.currentPositions.length < 3) {
      this.showToast('折线至少需要3个顶点,请继续添加');
      return;
    }
    
    this.savePolyline(); // 保存折线
    this.clearTempLine(); // 清除临时折线
    this.resetDrawingState(); // 重置绘制状态
    this.enableMapInteraction(); // 重新启用地图交互
  }, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
  
  // 右键单击 - 取消绘制
  this.handler.setInputAction(() => {
    if (this.isDrawing) {
      this.clearTempLine();
      this.resetDrawingState();
      this.enableMapInteraction();
      this.showToast('已取消绘制');
    }
  }, Cesium.ScreenSpaceEventType.RIGHT_DOWN);

3. 坐标转换

javascript 复制代码
getPositionFromMouse(mousePosition) {
  // 创建从相机到鼠标位置的射线
  const ray = this.viewer.camera.getPickRay(mousePosition);
  if (!ray) return null;
  
  // 计算射线与地球表面的交点(获取地理坐标)
  const position = this.viewer.scene.globe.pick(ray, this.viewer.scene);
  if (!position) {
    this.showToast('请在地球表面点击');
  }
  return position;
}

4. 临时折线绘制

javascript 复制代码
drawTempLine(currentMousePosition) {
  this.clearTempLine(); // 清除已有临时折线
  if (this.currentPositions.length === 0 || !currentMousePosition) return;
  
  // 创建包含已有顶点和当前鼠标位置的临时坐标数组
  const tempPositions = [...this.currentPositions, currentMousePosition];
  
  // 创建临时折线Primitive
  this.tempPrimitive = new Cesium.Primitive({
    geometryInstances: new Cesium.GeometryInstance({
      geometry: new Cesium.PolylineGeometry({
        positions: tempPositions, // 折线顶点坐标
        width: 5, // 折线宽度
        vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
      }),
    }),
    appearance: new Cesium.PolylineMaterialAppearance({
      material: Cesium.Material.fromType('Color', {
        color: Cesium.Color.RED.withAlpha(0.8), // 临时折线为半透明红色
      }),
    }),
  });
  
  // 将临时折线添加到场景中
  this.viewer.scene.primitives.add(this.tempPrimitive);

5. 折线保存与长度计算

javascript 复制代码
savePolyline() {
  // 创建最终折线Primitive
  const polyline = new Cesium.Primitive({
    geometryInstances: new Cesium.GeometryInstance({
      geometry: new Cesium.PolylineGeometry({
        positions: this.currentPositions,
        width: 5,
        vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
      }),
    }),
    appearance: new Cesium.PolylineMaterialAppearance({
      material: Cesium.Material.fromType('Color', {
        color: Cesium.Color.BLUE.withAlpha(0.8), // 最终折线为半透明蓝色
      }),
    }),
  });
  
  // 添加到场景并保存引用
  this.viewer.scene.primitives.add(polyline);
  this.allPolylines.push(polyline);
  
  // 计算并显示折线总长度
  const totalLength = this.calculatePolylineLength(this.currentPositions);
  this.showToast(
    `折线绘制完成!顶点数: ${this.currentPositions.length}, 总长度: ${totalLength.toFixed(2)}米`
  );
}

// 计算折线总长度
calculatePolylineLength(positions) {
  let totalLength = 0;
  // 遍历所有顶点,累加相邻顶点间的距离
  for (let i = 0; i < positions.length - 1; i++) {
    // 使用Cesium提供的Cartesian3距离计算方法,单位为米
    totalLength += Cesium.Cartesian3.distance(
      positions[i], 
      positions[i + 1]
    );
  }
  return totalLength;
}

性能优化

  1. 节流处理:使用Lodash的throttle函数限制鼠标移动时的重绘频率
javascript 复制代码
created() {
  // 节流处理临时绘制,50ms内最多执行一次,优化性能
  this.throttledDrawTempLine = throttle(this.drawTempLine, 50);
}
  1. 顶点去重:通过距离检测避免添加过近的重复顶点

  2. 资源销毁:组件销毁时清理Cesium资源,避免内存泄漏

javascript 复制代码
beforeDestroy() {
  if (this.viewer) this.viewer.destroy(); // 销毁Viewer实例
  if (this.handler) this.handler.destroy(); // 销毁事件处理器
}

常见问题与调试

  1. 地图初始化失败:检查Cesium资源是否正确加载,确保API密钥有效
  2. 坐标获取不到:确保点击位置在地球表面,而非天空盒
  3. 折线不显示:检查坐标数组是否为空,材质颜色是否可见
  4. 性能问题 :使用viewer.scene.debugShowFramesPerSecond = true监控帧率

扩展功能建议

  1. 折线编辑:添加顶点拖拽、删除功能
  2. 样式自定义:允许用户修改折线颜色、宽度、材质
  3. 数据导出:将折线坐标导出为GeoJSON或其他格式
  4. 面积计算:对于闭合折线,添加面积计算功能

学习资源汇总

  1. Cesium官方文档Index - Cesium Documentation
  2. Cesium Sandcastle示例Cesium Sandcastle
  3. Vue-Cesium组件库A Vue 3 based component library of CesiumJS for developers | Vue for Cesium
  4. Lodash文档Lodash Documentation
  5. Cesium中文社区https://cesiumcn.org/

完整代码(带详细备注)

javascript 复制代码
<template>
  <div id="cesiumContainer" style="width: 100%; height: 100vh"></div>
</template>

<script>
// 导入地图初始化配置和工具函数
import initMap from '@/config/initMap.js'; // 地图初始化函数
import { mapConfig } from '@/config/mapConfig'; // 地图配置项(包含高德地图URL等)
import { throttle } from 'lodash'; // 导入节流函数用于性能优化

export default {
  data() {
    return {
      viewer: null, // Cesium Viewer实例,地图的核心控制器
      isDrawing: false, // 绘制状态标志:是否正在绘制折线
      currentPositions: [], // 存储当前折线的顶点坐标数组(Cartesian3类型)
      tempPrimitive: null, // 临时折线的Primitive对象,随鼠标移动更新
      allPolylines: [], // 存储所有已完成绘制的折线对象
      handler: null, // 屏幕空间事件处理器,用于监听鼠标交互
      isFirstClick: true, // 首次点击标志:用于判断是否开始绘制
      vertexMarkers: [], // 存储顶点标记的Entity对象数组
    };
  },
  created() {
    // 节流处理临时绘制函数,限制50ms内最多执行一次,优化鼠标移动时的性能
    this.throttledDrawTempLine = throttle(this.drawTempLine, 50);
  },
  mounted() {
    // 初始化Cesium地图,使用高德地图瓦片服务
    // initMap参数:地图瓦片URL,是否开启3D模式(false表示2D)
    this.viewer = initMap(mapConfig.gaode.url3, false);
    // 初始化折线绘制功能
    this.initDrawLine();
  },
  methods: {
    /**
     * 初始化折线绘制相关的事件处理器
     * 绑定鼠标点击、移动、双击等事件,实现交互式绘制逻辑
     */
    initDrawLine() {
      // 创建屏幕空间事件处理器,监听canvas上的鼠标事件
      this.handler = new Cesium.ScreenSpaceEventHandler(this.viewer.canvas);

      // 移除Cesium默认的左键双击事件(默认是放大地图),避免与我们的双击结束绘制冲突
      this.viewer.cesiumWidget.screenSpaceEventHandler.removeInputAction(
        Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK
      );

      // 显示操作提示信息
      this.showToast('点击添加顶点,双击结束绘制(至少需要3个顶点)');

      // 绑定左键单击事件 - 添加折线顶点
      this.handler.setInputAction((event) => {
        // 绘制过程中禁用地图默认交互(旋转、缩放等),防止误操作
        this.disableMapInteraction();

        // 将鼠标点击位置转换为地球表面的地理坐标(Cartesian3)
        const position = this.getPositionFromMouse(event.position);
        if (!position) return; // 如果获取坐标失败,直接返回

        // 重复顶点检测:如果不是第一个顶点,检查与上一个顶点的距离
        if (this.currentPositions.length > 0) {
          const lastPosition =
            this.currentPositions[this.currentPositions.length - 1];
          // 计算两点之间的直线距离(单位:米)
          const distance = Cesium.Cartesian3.distance(position, lastPosition);
          const DISTANCE_THRESHOLD = 1.0; // 距离阈值(米),可根据需求调整

          // 如果距离小于阈值,忽略本次点击
          if (distance < DISTANCE_THRESHOLD) {
            this.showToast(
              `点击位置与上一顶点距离过近(${distance.toFixed(2)}米),已忽略`
            );
            this.enableMapInteraction(); // 重新启用地图交互
            return;
          }
        }

        // 首次点击时,标记开始绘制状态
        if (this.isFirstClick) {
          this.isDrawing = true;
          this.isFirstClick = false;
        }

        // 添加顶点坐标到数组,并在地图上显示顶点标记
        this.currentPositions.push(position);
        this.addVertexMarker(position);

        // 绘制临时折线(此时鼠标未移动,传入null)
        this.drawTempLine(null);
      }, Cesium.ScreenSpaceEventType.LEFT_DOWN);

      // 绑定鼠标移动事件 - 更新临时折线
      this.handler.setInputAction((event) => {
        // 只有在绘制状态且已有顶点时才更新临时折线
        if (!this.isDrawing || this.currentPositions.length === 0) return;

        // 获取鼠标当前位置对应的地理坐标
        const position = this.getPositionFromMouse(event.endPosition);
        if (position) {
          // 使用节流后的方法更新临时折线
          this.throttledDrawTempLine(position);
        }
      }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);

      // 绑定左键双击事件 - 结束折线绘制
      this.handler.setInputAction((event) => {
        if (!this.isDrawing) return; // 非绘制状态不处理

        // 获取双击位置的坐标并添加为最后一个顶点
        const position = this.getPositionFromMouse(event.position);
        if (position) {
          this.currentPositions.push(position);
          this.addVertexMarker(position);
        }

        // 验证顶点数量,至少需要3个顶点才能形成有效的折线
        if (this.currentPositions.length < 3) {
          this.showToast('折线至少需要3个顶点,请继续添加');
          return;
        }

        // 保存最终折线、清理临时对象、重置状态、恢复地图交互
        this.savePolyline();
        this.clearTempLine();
        this.resetDrawingState();
        this.enableMapInteraction();
      }, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);

      // 绑定右键单击事件 - 取消绘制
      this.handler.setInputAction(() => {
        if (this.isDrawing) {
          this.clearTempLine(); // 清除临时折线
          this.resetDrawingState(); // 重置绘制状态
          this.enableMapInteraction(); // 恢复地图交互
          this.showToast('已取消绘制');
        }
      }, Cesium.ScreenSpaceEventType.RIGHT_DOWN);
    },

    /**
     * 将鼠标屏幕坐标转换为地球表面的地理坐标
     * @param {Cesium.Cartesian2} mousePosition - 鼠标屏幕坐标
     * @returns {Cesium.Cartesian3|null} 地球表面的地理坐标,失败时返回null
     */
    getPositionFromMouse(mousePosition) {
      // 创建从相机位置到鼠标位置的射线
      const ray = this.viewer.camera.getPickRay(mousePosition);
      if (!ray || !this.viewer.scene) return null;

      // 计算射线与地球表面的交点(获取地理坐标)
      const position = this.viewer.scene.globe.pick(ray, this.viewer.scene);
      if (!position) {
        this.showToast('请在地球表面点击'); // 如果点击了天空盒等非地球表面位置
      }
      return position;
    },

    /**
     * 绘制临时折线(随鼠标移动更新)
     * @param {Cesium.Cartesian3} currentMousePosition - 当前鼠标位置的地理坐标
     */
    drawTempLine(currentMousePosition) {
      this.clearTempLine(); // 先清除已有的临时折线

      // 如果没有顶点或鼠标位置无效,不绘制
      if (this.currentPositions.length === 0 || !currentMousePosition) return;

      // 创建临时坐标数组:已有顶点 + 当前鼠标位置
      const tempPositions = [...this.currentPositions, currentMousePosition];

      // 创建临时折线Primitive
      this.tempPrimitive = new Cesium.Primitive({
        geometryInstances: new Cesium.GeometryInstance({
          geometry: new Cesium.PolylineGeometry({
            positions: tempPositions, // 折线顶点坐标数组
            width: 5, // 折线宽度(像素)
            vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, // 指定顶点格式
          }),
        }),
        appearance: new Cesium.PolylineMaterialAppearance({
          material: Cesium.Material.fromType('Color', {
            color: Cesium.Color.RED.withAlpha(0.8), // 临时折线为半透明红色
          }),
        }),
      });

      // 将临时折线添加到场景中显示
      this.viewer.scene.primitives.add(this.tempPrimitive);
    },

    /**
     * 清除临时折线
     */
    clearTempLine() {
      if (this.tempPrimitive) {
        // 从场景中移除临时折线并释放资源
        this.viewer.scene.primitives.remove(this.tempPrimitive);
        this.tempPrimitive = null;
      }
    },

    /**
     * 保存最终绘制的折线
     */
    savePolyline() {
      // 创建最终折线Primitive
      const polyline = new Cesium.Primitive({
        geometryInstances: new Cesium.GeometryInstance({
          geometry: new Cesium.PolylineGeometry({
            positions: this.currentPositions, // 使用当前所有顶点坐标
            width: 5,
            vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
          }),
        }),
        appearance: new Cesium.PolylineMaterialAppearance({
          material: Cesium.Material.fromType('Color', {
            color: Cesium.Color.BLUE.withAlpha(0.8), // 最终折线为半透明蓝色
          }),
        }),
      });

      // 添加到场景并保存引用
      this.viewer.scene.primitives.add(polyline);
      this.allPolylines.push(polyline);

      // 计算并显示折线总长度
      const totalLength = this.calculatePolylineLength(this.currentPositions);
      this.showToast(
        `折线绘制完成!顶点数: ${this.currentPositions.length}, 
         总长度: ${totalLength.toFixed(2)}米`
      );
    },

    /**
     * 计算折线总长度
     * @param {Cesium.Cartesian3[]} positions - 折线顶点坐标数组
     * @returns {number} 折线总长度(米)
     */
    calculatePolylineLength(positions) {
      let totalLength = 0;
      // 遍历所有相邻顶点对,累加距离
      for (let i = 0; i < positions.length - 1; i++) {
        totalLength += Cesium.Cartesian3.distance(
          positions[i],
          positions[i + 1]
        );
      }
      return totalLength;
    },

    /**
     * 在地图上添加顶点标记
     * @param {Cesium.Cartesian3} position - 顶点坐标
     */
    addVertexMarker(position) {
      const marker = this.viewer.entities.add({
        position: position,
        point: {
          pixelSize: 8, // 像素大小
          color: Cesium.Color.YELLOW, // 黄色标记
          outlineColor: Cesium.Color.BLACK, // 黑色轮廓
          outlineWidth: 2, // 轮廓宽度
          heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, // 贴地显示
        },
      });
      this.vertexMarkers.push(marker); // 保存标记引用,便于后续清理
    },

    /**
     * 重置绘制状态
     */
    resetDrawingState() {
      this.isDrawing = false; // 退出绘制状态
      this.isFirstClick = true; // 重置首次点击标志
      this.currentPositions = []; // 清空顶点数组

      // 如需保留顶点标记,注释掉下面两行
      // this.vertexMarkers.forEach(marker => this.viewer.entities.remove(marker));
      // this.vertexMarkers = [];
    },

    /**
     * 显示临时提示信息
     * @param {string} message - 提示文本
     */
    showToast(message) {
      const toast = document.createElement('div');
      // 设置提示框样式
      toast.style.cssText = `
        position: absolute;
        bottom: 20px;
        left: 50%;
        transform: translateX(-50%);
        padding: 8px 16px;
        background-color: rgba(0, 0, 0, 0.7);
        color: white;
        border-radius: 4px;
        font-size: 14px;
        z-index: 9999;
      `;
      toast.textContent = message;
      document.body.appendChild(toast);
      // 3秒后自动移除提示框
      setTimeout(() => document.body.removeChild(toast), 3000);
    },

    /**
     * 禁用地图交互(旋转、缩放等)
     */
    disableMapInteraction() {
      const controller = this.viewer.scene.screenSpaceCameraController;
      controller.enableRotate = false; // 禁用旋转
      controller.enableZoom = false; // 禁用缩放
      controller.enableTranslate = false; // 禁用平移
      controller.enableTilt = false; // 禁用倾斜
      controller.enableLook = false; // 禁用环顾
    },

    /**
     * 启用地图交互
     */
    enableMapInteraction() {
      const controller = this.viewer.scene.screenSpaceCameraController;
      controller.enableRotate = true;
      controller.enableZoom = true;
      controller.enableTranslate = true;
      controller.enableTilt = true;
      controller.enableLook = true;
    },
  },
  /**
   * 组件销毁前清理资源
   */
  beforeDestroy() {
    if (this.viewer) this.viewer.destroy(); // 销毁Cesium Viewer实例
    if (this.handler) this.handler.destroy(); // 销毁事件处理器
  },
};
</script>

<style lang="scss" scoped>
#cesiumContainer {
  width: 100%;
  height: 100vh;
  touch-action: none;
}
</style>

点点关注,有需求或问题可以在评论留言

相关推荐
chao_78941 分钟前
frame 与新窗口切换操作【selenium 】
前端·javascript·css·selenium·测试工具·自动化·html
天蓝色的鱼鱼1 小时前
从零实现浏览器摄像头控制与视频录制:基于原生 JavaScript 的完整指南
前端·javascript
三原1 小时前
7000块帮朋友做了2个小程序加一个后台管理系统,值不值?
前端·vue.js·微信小程序
popoxf1 小时前
在新版本的微信开发者工具中使用npm包
前端·npm·node.js
莱茶荼菜1 小时前
虚拟项目[3D物体测量]
数码相机·计算机视觉·3d
白仑色2 小时前
完整 Spring Boot + Vue 登录系统
vue.js·spring boot·后端
爱编程的喵2 小时前
React Router Dom 初步:从传统路由到现代前端导航
前端·react.js
阳火锅2 小时前
Vue 开发者的外挂工具:配置一个 JSON,自动造出一整套页面!
javascript·vue.js·面试
每天吃饭的羊2 小时前
react中为啥使用剪头函数
前端·javascript·react.js
Nicholas683 小时前
Flutter帧定义与60-120FPS机制
前端