cesium事件
引言:cesium
中事件系统是其交互能力的核心,用于响应用户的输入(如鼠标、触摸操作)或程序内部状态变化(如相机移动、时间变化)。通过事件系统,开发者可以实现选点、标注、动态更新视图等功能。本篇文章只封装了鼠标事件的三种(左键点击,右键点击,鼠标移动)和相机事件的一种,其余事件大同小异,也可以自行添加扩展。
cesium鼠标相关事件介绍
cesium
通过 ScreenSpaceEventHandler
类管理屏幕空间事件(基于像素坐标的交互)。它是事件系统的核心入口,负责监听、分发事件,并将原始的浏览器事件转换为 Cesium 标准化的事件对象。通常将事件处理器绑定到视图Viewer的 <canvas>
元素上。
核心API: ScreenSpaceEventHandler
,这个是事件监听的核心类,官方的解释是"处理用户输入事件。可以添加自定义函数,以便在用户输入输入时执行。",如同js
的监听函数一样,参数主要是要绑定的元素,主要是Canvas
画布。(API查阅点击跳转)声明使用方式如下:
js
// 初始化cesium视图
const viewer = new Cesium.Viewer('cesiumContainer');
// 创建事件处理器,绑定到 viewer 的 canvas
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
事件的类型: cesium
事件的类型如下(API查阅点击跳转)
事件名称 | Type | 描述 |
---|---|---|
LEFT_DOWN |
number | 表示鼠标左键向下事件。 |
LEFT_UP |
number | 表示鼠标左键向上事件。 |
LEFT_CLICK |
number | 表示鼠标左键点击事件。 |
LEFT_DOUBLE_CLICK |
number | 表示鼠标左键双击事件。 |
RIGHT_DOWN |
number | 表示鼠标右键向下事件。 |
RIGHT_UP |
number | 表示鼠标右键向上事件。 |
RIGHT_CLICK |
number | 表示鼠标右键点击事件。 |
MIDDLE_DOWN |
number | 表示鼠标中键向下事件。 |
MIDDLE_UP |
number | 表示鼠标中键向上事件。 |
MIDDLE_CLICK |
number | 表示鼠标中键点击事件。 |
MOUSE_MOVE |
number | 表示鼠标移动事件。 |
WHEEL |
number | 表示鼠标滚轮事件。 |
PINCH_START |
number | 表示触摸表面上两指事件的开始。 |
PINCH_END |
number | 表示触摸表面上双指事件的结束。 |
PINCH_MOVE |
number | 表示触摸表面上两指事件的更改。 |
cesium事件封装
cesium
事件的简单使用,以鼠标移动和左键点击为例,看完下方代码,可以看出一些简单的相似之处,即事件回调处理函数基本相同,其次,无论是鼠标移动还是鼠标点击,获取的都是当时的经纬度,最后再通过这个经纬度进行逻辑处理,比如获取高程数据,拾取当前的实体等等,所以可以将这部分抽离出来,将各个事件的获取的经纬度放到pinia中去,再到各个功能点去使用。
js
//添加鼠标事件
//1.实例化
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
//2.注册并使用
handler.setInputAction((e) => {
//3.添加鼠标处理事件:
const p = viewer.scene.pickPosition(e.position)
const cartesian = this.viewer.scene.pickPosition(position);
if (!cesium.defined(cartesian)) {
// console.warn('未命中有效地形/模型位置');
return;
}
console.log("left click");
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK) //此处注册为鼠标左键单击事件,其他鼠标事件同此
handler.setInputAction(function(movement) {
var pickedObject = scene.pick(movement.endPosition);
if(Cesium.defined(pickedObject)) {
console.log("mouse move");
}
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
封装函数如下
1、cesium事件封装
js
// 1、cesium事件封装
import * as cesium from "cesium";
import { debounce, getViewBounds } from "@/utils/cesiumUtils";
import { useCesiumEventStoreHook } from "@/store/modules/cesiumEvent.store";
/**
* 定义事件类型常量
*/
const EVENT_TYPES = {
LEFT_CLICK: cesium.ScreenSpaceEventType.LEFT_CLICK,
MOUSE_MOVE: cesium.ScreenSpaceEventType.MOUSE_MOVE,
RIGHT_CLICK: cesium.ScreenSpaceEventType.RIGHT_CLICK,
CAMERA_CHANGED: "changed",
};
/**
* 定义事件处理函数的映射关系(事件类型 -> 处理函数名)
*/
const EVENT_HANDLER_MAP = {
[EVENT_TYPES.LEFT_CLICK]: "leftClick",
[EVENT_TYPES.MOUSE_MOVE]: "mouseMove",
[EVENT_TYPES.RIGHT_CLICK]: "rightClick",
};
/**
* 定义 Store 方法的映射关系(事件类型 -> Store 方法)
*/
const STORE_ACTION_MAP = {
[EVENT_TYPES.LEFT_CLICK]: (pos) => useCesiumEventStoreHook().changeLeftClickPosition(pos),
[EVENT_TYPES.MOUSE_MOVE]: (pos) => useCesiumEventStoreHook().changeMouseMovePosition(pos),
[EVENT_TYPES.RIGHT_CLICK]: (pos) => useCesiumEventStoreHook().changeRightClickPosition(pos),
[EVENT_TYPES.CAMERA_CHANGED]: (bounds) => useCesiumEventStoreHook().changeCameraBounds(bounds),
};
/**
* 定义位置数据结构(JSDoc 说明类型)
* @typedef {Object} CesiumPosition
* @property {number} longitude - 经度(度)
* @property {number} latitude - 纬度(度)
* @property {number} height - 高度(千米)
*/
/**
* Cesium 事件管理类
*/
export class CesiumEvent {
/**
* 构造函数
* @param {cesium.Viewer} viewer - Cesium 视图实例
*/
constructor(viewer) {
// 统一事件声明
this.handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
this.viewer = viewer;
this.position = null; // 存储当前位置信息(CesiumPosition 类型)
this.bounds = null;
this.initEvent();
}
/**
* 初始化事件绑定
*/
initEvent() {
Object.entries(EVENT_HANDLER_MAP).forEach(([eventType, handlerName]) => {
const handler = this[handlerName]; // 获取对应的处理函数
if (typeof handler === "function") {
// 绑定事件,注意保持 this 上下文
this.handler.setInputAction((movement) => handler.call(this, movement), eventType);
}
});
this.viewer.camera[EVENT_TYPES.CAMERA_CHANGED].addEventListener(
debounce(this.handleCameraChanged, 300)
);
}
/**
* 左键点击事件处理
* @param {cesium.ScreenSpaceEventHandlerPositionedEvent} movement - 事件对象
*/
leftClick(movement) {
this.handlePosition(movement.position, EVENT_TYPES.LEFT_CLICK);
}
/**
* 鼠标移动事件处理
* @param {cesium.ScreenSpaceEventHandlerPositionedEvent} movement - 事件对象
*/
mouseMove(movement) {
this.handlePosition(movement.endPosition, EVENT_TYPES.MOUSE_MOVE);
}
/**
* 右键点击事件处理
* @param {cesium.ScreenSpaceEventHandlerPositionedEvent} movement - 事件对象
*/
rightClick(movement) {
this.handlePosition(movement.position, EVENT_TYPES.RIGHT_CLICK);
}
/**
* 统一处理位置计算与存储(核心逻辑)
* @param {cesium.Cartesian2|undefined} position - 屏幕坐标(可能为 undefined)
* @param {string} eventType - 事件类型(来自 EVENT_TYPES)
*/
handlePosition(position, eventType) {
if (!cesium.defined(position)) return; // 屏幕坐标无效时跳过
const cartesian = this.viewer.scene.pickPosition(position);
if (!cesium.defined(cartesian)) {
// console.warn('未命中有效地形/模型位置');
return;
}
// 计算经纬度与高度
const cartographic = cesium.Cartographic.fromCartesian(cartesian);
this.position = {
longitude: cesium.Math.toDegrees(cartographic.longitude),
latitude: cesium.Math.toDegrees(cartographic.latitude),
height: this.viewer.camera.positionCartographic.height / 1000, // 转换为千米
};
// 触发 Store 更新
const action = STORE_ACTION_MAP[eventType];
if (action) action(this.position);
}
handleCameraChanged() {
const bounds = getViewBounds(window.viewer);
// 此处还可以获取相机的方向参数,因此时不需要暂时不写,只获取相机在三维情况下的左上角和右下角的经纬度
if (bounds) {
const action = STORE_ACTION_MAP[EVENT_TYPES.CAMERA_CHANGED];
if (action) action(bounds);
}
}
/**
* 销毁事件处理器(释放资源)
*/
destroy() {
if (this.handler) {
this.handler.destroy();
this.handler = null; // 避免内存泄漏
}
}
}
2、工具类函数
js
// 2、工具类函数如下:
export function debounce(func, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
/**
* 获取当前的图层层级
* @param {cesium.viewer} viewer
* @returns 图层层级
*/
export function getTileLevel(viewer) {
let tiles = new Set();
let tilesToRender = viewer.scene.globe._surface._tilesToRender;
if (cesium.defined(tilesToRender)) {
for (let i = 0; i < tilesToRender.length; i++) {
tiles.add(tilesToRender[i].level);
}
const levels = Array.from(tiles);
return Math.max(...levels);
}
}
/**
* 获取3D模式下cesium的屏幕经纬度
* @param {cesium.viewer} viewer
* @returns 经纬度信息
*/
export function getViewBounds(viewer) {
let bounds = {
topLeft: { lon: null, lat: null },
bottomRight: { lon: null, lat: null },
level: 0,
};
// 获取当前视图矩形范围
const extent = viewer.camera.computeViewRectangle();
// 在3D模式下计算边界
if (extent) {
bounds.topLeft.lon = cesium.Math.toDegrees(extent.west);
bounds.topLeft.lat = cesium.Math.toDegrees(extent.north);
bounds.bottomRight.lon = cesium.Math.toDegrees(extent.east);
bounds.bottomRight.lat = cesium.Math.toDegrees(extent.south);
}
bounds.level = getTileLevel(viewer);
return bounds;
}
3、pinia公共数据池存储
js
// 3、pinia公共数据池存储
import { store } from "@/store";
export const useCesiumEventStore = defineStore("cesiumEvent", () => {
// 鼠标移动的经纬度
const mouseMovePostion = ref({ longitude: 0, latitude: 0, height: 0 });
// 鼠标左键点击的经纬度
const leftClickPosition = ref({ longitude: 0, latitude: 0, height: 0 });
// 鼠标右键点击的经纬度
const rightClickPosition = ref({ longitude: 0, latitude: 0, height: 0 });
// 当前视角所在层级
const bounds = reactive({
topLeft: { lon: null, lat: null },
bottomRight: { lon: null, lat: null },
level: 0,
});
const changeMouseMovePosition = (val) => {
mouseMovePostion.value = { ...val };
};
const changeLeftClickPosition = (val) => {
leftClickPosition.value = { ...val };
};
const changeRightClickPosition = (val) => {
rightClickPosition.value = { ...val };
};
const changeCameraBounds = (val) => {
bounds.topLeft = val.topLeft;
bounds.bottomRight = val.bottomRight;
bounds.level = val.level;
};
const position = computed(() => {
return {
left: { ...leftClickPosition.value },
right: { ...rightClickPosition.value },
mouse: { ...mouseMovePostion.value },
};
});
return {
mouseMovePostion,
leftClickPosition,
rightClickPosition,
position,
bounds,
changeMouseMovePosition,
changeLeftClickPosition,
changeRightClickPosition,
changeCameraBounds,
};
});
// 组件外使用
export function useCesiumEventStoreHook() {
return useCesiumEventStore(store);
}
cesium交互式框选区域工具类
在cesium
开发中会有一些这样的需求,通过鼠标左键点击开始,鼠标移动绘制,鼠标右键结束,框选出来一片区域,或者画出来一个多边形,计算面积等等,在这种需求的主要的实现思路是:
- 需要存储鼠标左键已确认的顶点(左键点击添加的顶点)。
- 鼠标标移动时,通过替换存储的最后一个顶点为当前鼠标位置的坐标,实现"拖拽预览"效果,还要依赖
PolygonPrimitive
类内部的CallbackProperty
来实时动态更新,当存储数组变化时,自动触发多边形重渲染,实现实时预览。 - 右键点击时,创建保存的多边形替换实时更新的多边形,结束绘制。
具体实现如下:
js
import * as cesium from "cesium";
import * as turf from "@turf/turf";
import {
convertCartesiansToDegrees,
ensurePolygonClosed,
formatAreaText,
} from "@/utils/cesiumutils";
// 常量配置
const TEMP_POLYGON_COLOR = cesium.Color.BLUE.withAlpha(0.3);
const FINAL_POLYGON_COLOR = cesium.Color.RED.withAlpha(0.2);
const MIN_VERTEX_COUNT = 3;
/**
* 交互式多边形类
*/
export class interactivePolygon {
constructor(viewer) {
this.viewer = viewer;
this.polygon = null;
this.dynamicPoints = [];
this.polygonPoints = [];
this.entityList = [];
this.isDrawing = false;
this._initPolygon();
}
_initPolygon() {
if (this.polygon) {
window.viewer.entities.remove(this.polygon);
}
this.polygon = this.viewer.entities.add({
polygon: {
hierarchy: new cesium.CallbackProperty(
() => new cesium.PolygonHierarchy(this.dynamicPoints),
false
),
material: TEMP_POLYGON_COLOR, // 绘制过程中使用红色
},
});
this.isDrawing = true;
}
leftClickPosition(position) {
const cartesian = new cesium.Cartesian3.fromDegrees(position.longitude, position.latitude);
if (!cartesian) return;
this.polygonPoints.push(cartesian);
this.dynamicPoints.push(cartesian);
}
mouseMovePosition(position) {
if (this.polygonPoints.length === 0 || !this.isDrawing) return;
const cartesian = new cesium.Cartesian3.fromDegrees(position.longitude, position.latitude);
if (!cartesian) return;
// 更新动态点
if (this.dynamicPoints.length > this.polygonPoints.length) this.dynamicPoints.pop();
this.dynamicPoints.push(cartesian);
}
rightClickPosition() {
if (this.polygonPoints.length < MIN_VERTEX_COUNT) {
ElMessage.error(`至少需要${MIN_VERTEX_COUNT}个顶点才能形成多边形`);
this.clearDrawing();
return;
}
this.dynamicPoints.pop();
this.isDrawing = false;
this._saveFinalPolygon();
}
_saveFinalPolygon() {
this.viewer.entities.remove(this.polygon);
this.polygon = this.viewer.entities.add({
polygon: {
hierarchy: new cesium.PolygonHierarchy(this.polygonPoints),
material: FINAL_POLYGON_COLOR,
},
});
this.entityList.push(this.polygon);
this._calculateAndShowArea();
}
clearDrawing() {
this.isDrawing = false;
this.polygonPoints = [];
this.dynamicPoints = [];
if (this.entityList.length > 0) {
this.entityList.forEach((item) => {
this.viewer.entities.remove(item);
});
}
this.polygon = null;
this.entityList = [];
}
_calculateAndShowArea() {
try {
// 校验顶点数量
if (this.polygonPoints.length < MIN_VERTEX_COUNT) {
ElMessage.error("至少需要3个顶点才能计算面积");
return;
}
// 步骤1:转换为经纬度数组(带有效性校验)
const degreesCoordinates = convertCartesiansToDegrees(this.polygonPoints);
if (!degreesCoordinates) {
ElMessage.error("存在无效坐标点,无法计算面积");
return;
}
// 步骤2:确保多边形闭合(处理浮点精度问题)
const closedCoordinates = ensurePolygonClosed(degreesCoordinates);
// 步骤3:使用Turf.js计算面积(带格式校验)
const areaResult = this._calculateAreaWithTurf(closedCoordinates);
if (!areaResult) {
ElMessage.error("面积计算失败,请检查多边形有效性");
return;
}
// 步骤4:自适应单位显示(平方公里/公顷)
const areaText = formatAreaText(areaResult.squareMeters);
console.log("计算面积:", areaText);
} catch (error) {
console.error("面积计算异常:", error);
ElMessage.error("面积计算异常,请检查控制台日志");
}
}
_calculateAreaWithTurf(closedCoordinates) {
try {
// Turf要求多边形坐标是[[[lng,lat],...]]格式
const turfPolygon = turf.polygon([closedCoordinates]);
return {
squareMeters: turf.area(turfPolygon),
// 可选:添加其他Turf返回的信息(如周长)
perimeter: turf.length(turfPolygon),
};
} catch (turfError) {
console.error("Turf计算异常:", turfError);
return null;
}
}
}
工具类函数如下
js
/**
* 将笛卡尔坐标数组转换为经纬度数组(带有效性校验)
* @param {cesium.Cartesian3[]} cartesians - 笛卡尔坐标数组
* @returns {number[][]|null} 经纬度数组([lng, lat])或null(存在无效坐标)
*/
export function convertCartesiansToDegrees(cartesians) {
const coordinates = [];
for (const cartesian of cartesians) {
const cartographic = cesium.Cartographic.fromCartesian(cartesian);
if (!cartographic) return null; // 无效笛卡尔坐标
// 过滤极值点(纬度超出[-90,90]或经度超出[-180,180])
const lng = cesium.Math.toDegrees(cartographic.longitude);
const lat = cesium.Math.toDegrees(cartographic.latitude);
if (Math.abs(lng) > 180 || Math.abs(lat) > 90) return null;
coordinates.push([lng, lat]);
}
return coordinates;
}
/**
* 确保多边形闭合(处理浮点精度问题)
* @param {number[][]} coordinates - 经纬度数组([lng, lat])
* @returns {number[][]} 闭合后的经纬度数组
*/
export function ensurePolygonClosed(coordinates) {
if (coordinates.length === 0) return coordinates;
// 提取首尾点(转换为数值数组避免引用问题)
const first = coordinates[0];
const last = coordinates[coordinates.length - 1];
// 使用小量epsilon判断是否接近(解决浮点精度问题)
const isClosed = first.every(
(val, idx) => Math.abs(val - last[idx]) < 1e-6 // 1e-6度的精度足够应对大多数场景
);
return isClosed ? coordinates : [...coordinates, first];
}
/**
* 自适应格式化面积文本(平方公里/公顷)
* @param {number} squareMeters - 面积(平方米)
* @returns {string} 格式化后的面积文本
*/
export function formatAreaText(squareMeters) {
if (squareMeters < 1e4) {
// 小于1公顷(1公顷=1e4平方米)
return `${(squareMeters / 1e4).toFixed(4)} 公顷`;
} else if (squareMeters < 1e6) {
// 1公顷到1平方公里之间
return `${(squareMeters / 1e4).toFixed(2)} 公顷(${(squareMeters / 1e6).toFixed(4)} 平方公里)`;
} else {
// 大于等于1平方公里
return `${(squareMeters / 1e6).toFixed(4)} 平方公里`;
}
}
至此,事件函数的封装,交互式框选函数封装已经完成,下面是在页面中使用:
js
<script setup>
// 事件函数使用
import { useCesium } from "@/hooks/useCesium";
import { CesiumEvent } from "../hooks/useCesiumEvent";
import { useCesiumEventStore } from "@/store/modules/cesiumEvent.store";
const cesiumEventStore = useCesiumEventStore();
const position = computed(() => cesiumEventStore.mouseMovePostion);
const bounds = computed(() => cesiumEventStore.bounds);
let viewer = null;
onMounted(() => {
const earth = document.querySelector("#earth");
viewer = useCesium(earth);
new CesiumEvent(viewer);
});
</script>
<template>
<div class="init-view">
<div id="earth" class="init-earth"></div>
<div class="init-content">
<span span style="color: #ffffff">
层级:{{ bounds.level }} 经度:{{ `${position.longitude?.toFixed(4)}°` }} 纬度:{{
`${position.latitude?.toFixed(4)}°`
}}
高度:{{ `${position.height?.toFixed(2)}km` }}
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.init-view {
position: relative;
width: 100%;
height: 100%;
.init-earth {
width: 100%;
height: 100%;
}
.init-content {
position: absolute;
left: 10px;
bottom: 5px;
font-size: 14px;
}
}
</style>
vue
<script setup>
// 交互式框选页面
import { useCesiumEventStore } from "@/store/modules/cesiumEvent.store";
let polygon = null;
const cesiumStore = useCesiumEventStore();
const mouseMovePostion = computed(() => cesiumStore.mouseMovePostion);
const leftClickPosition = computed(() => cesiumStore.leftClickPosition);
const rightClickPosition = computed(() => cesiumStore.rightClickPosition);
watch(
leftClickPosition,
(position) => {
if (!polygon) return;
polygon.leftClickPosition({ ...position });
},
{ deep: true }
);
watch(
rightClickPosition,
() => {
if (!polygon) return;
polygon.rightClickPosition();
},
{ deep: true }
);
watch(
mouseMovePostion,
(position) => {
if (!polygon) return;
polygon.mouseMovePosition({ ...position });
},
{ deep: true }
);
const mapping = () => {
if (polygon) {
polygon.clearDrawing();
polygon = null;
} else {
polygon = new interactivePolygon(window.viewer);
}
};
</script>
<template>
<div class="ribbon">
<div>
<el-button class="mtb10" size="small" type="primary" @click="mapping">面积测量</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.ribbon {
width: 100%;
height: 100%;
}
</style>
实现效果
掘金图片上传总是上传后保存不了,我就放在评论区了,
总结:
这次是事件函数的封装其实是多个需求堆在一起,具体是一方面是要鼠标划过现实经纬度,获取图层当前层级,视角高度,交互式的实体创建,还有一些视角渲染优化等等,发现多次去写事件的注册监听函数,视角的监听函数,代码堆在一起,很是烦人,就抽离出他们的公共之处封装在一起,将获取的数据放在pinia公共数据池中,然后在页面的各个组件监听使用,统一进行管理。有其余的需求还可以通过pinia的计算属性重新派生出来需要的数据。这些后面再说,文章就到这里,有不足之处或者有好的想法欢迎大家下方留言!