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. 数据属性定义
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. 坐标转换
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. 临时折线绘制
drawTempLine(currentMousePosition) {this.clearTempLine(); // 清除已有临时折线if (this.currentPositions.length === 0 || !currentMousePosition) return;// 创建包含已有顶点和当前鼠标位置的临时坐标数组const tempPositions = [...this.currentPositions, currentMousePosition];// 创建临时折线Primitivethis.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. 折线保存与长度计算
savePolyline() {// 创建最终折线Primitiveconst 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;
}
性能优化
- 节流处理:使用Lodash的throttle函数限制鼠标移动时的重绘频率
created() {// 节流处理临时绘制,50ms内最多执行一次,优化性能this.throttledDrawTempLine = throttle(this.drawTempLine, 50);
}
-
顶点去重:通过距离检测避免添加过近的重复顶点
-
资源销毁:组件销毁时清理Cesium资源,避免内存泄漏
beforeDestroy() {if (this.viewer) this.viewer.destroy(); // 销毁Viewer实例if (this.handler) this.handler.destroy(); // 销毁事件处理器
}
常见问题与调试
- 地图初始化失败:检查Cesium资源是否正确加载,确保API密钥有效
- 坐标获取不到:确保点击位置在地球表面,而非天空盒
- 折线不显示:检查坐标数组是否为空,材质颜色是否可见
- 性能问题:使用
viewer.scene.debugShowFramesPerSecond = true
监控帧率
扩展功能建议
- 折线编辑:添加顶点拖拽、删除功能
- 样式自定义:允许用户修改折线颜色、宽度、材质
- 数据导出:将折线坐标导出为GeoJSON或其他格式
- 面积计算:对于闭合折线,添加面积计算功能
学习资源汇总
- Cesium官方文档:Index - Cesium Documentation
- Cesium Sandcastle示例:Cesium Sandcastle
- Vue-Cesium组件库:A Vue 3 based component library of CesiumJS for developers | Vue for Cesium
- Lodash文档:Lodash Documentation
- Cesium中文社区:https://cesiumcn.org/
完整代码(带详细备注)
<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];// 创建临时折线Primitivethis.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() {// 创建最终折线Primitiveconst 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>
点点关注,有需求或问题可以在评论留言