前言
上一篇我们是写死经纬度添加 Entity;业务中经常需要鼠标在地图上直接圈选绘制围栏、轨迹 。
Cesium 实现动态绘制核心两个关键点:
ScreenSpaceEventHandler:监听鼠标左击、移动、右击事件CallbackProperty:回调属性,实时返回坐标数组,实现鼠标拖动时图形实时刷新预览(重点!普通属性赋值不会动态更新)稀土掘金
交互规则(和行业标绘工具保持一致)
- ✅ 左键单击:添加顶点
- ✅ 鼠标移动:实时预览橡皮筋线 / 面
- ✅ 右键单击:结束当前绘制,生成最终 Entity
- ✅ 支持贴地形
clampToGround: true - ✅ 绘制过程可见顶点标记点
两种绘制模式:POLYLINE 折线 / POLYGON 多边形
完整组件代码
<template>
<div class="cesium-wrap">
<div class="btn-group">
<button @click="startDraw('POLYLINE')">开始绘制折线</button>
<button @click="startDraw('POLYGON')">开始绘制多边形</button>
<button @click="stopDraw">取消绘制</button>
<button @click="clearAll">清空所有图形</button>
</div>
<div class="cesium-container" ref="cesiumRef"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as Cesium from 'cesium'
import 'cesium/Build/Cesium/Widgets/widgets.css'
const cesiumRef = ref(null)
let viewer = null
let handler = null // 鼠标事件管理器
// 绘制状态
let drawMode = '' // POLYLINE / POLYGON
let drawingPoints = [] // 收集笛卡尔顶点
let tempPreviewEntity = null // 临时预览图形(橡皮筋)
let tempPointEntities = [] // 顶点标记点
// 初始化地图
function initViewer() {
viewer = new Cesium.Viewer(cesiumRef.value, {
animation: false,
timeline: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
fullscreenButton: false,
navigationHelpButton: false
})
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(108.95, 34.27, 80000)
})
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
bindMouseEvent()
}
// 绑定鼠标事件
function bindMouseEvent() {
// 左键点击:添加顶点
handler.setInputAction((e) => {
if (!drawMode) return
// 拾取地面坐标(贴地)
const ray = viewer.camera.getPickRay(e.position)
const cartesian = viewer.scene.globe.pick(ray, viewer.scene)
if (!Cesium.defined(cartesian)) return
drawingPoints.push(cartesian)
// 添加顶点小圆点
const pointEntity = viewer.entities.add({
position: cartesian,
point: {
pixelSize: 8,
color: Cesium.Color.YELLOW,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 1
}
})
tempPointEntities.push(pointEntity)
// 第一次点击,初始化预览图形
if (!tempPreviewEntity) {
createPreviewEntity()
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
// 鼠标移动:实时更新预览
handler.setInputAction((e) => {
if (!drawMode || !tempPreviewEntity || drawingPoints.length === 0) return
const ray = viewer.camera.getPickRay(e.endPosition)
const mouseCartesian = viewer.scene.globe.pick(ray, viewer.scene)
if (!Cesium.defined(mouseCartesian)) return
// 存到临时变量给Callback读取
tempPreviewEntity._mousePos = mouseCartesian
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
// 右键点击:结束绘制
handler.setInputAction(() => {
if (!drawMode || drawingPoints.length < 2) {
cancelDraw()
return
}
finishDraw()
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
}
// 创建预览橡皮筋图形(CallbackProperty动态驱动)
function createPreviewEntity() {
if (tempPreviewEntity) return
tempPreviewEntity = viewer.entities.add({
polyline: {
positions: new Cesium.CallbackProperty(() => {
const pts = [...drawingPoints]
if (tempPreviewEntity._mousePos) {
pts.push(tempPreviewEntity._mousePos)
}
return pts
}, false),
width: 3,
material: Cesium.Color.CYAN,
clampToGround: true
},
polygon: drawMode === 'POLYGON' ? {
hierarchy: new Cesium.CallbackProperty(() => {
const pts = [...drawingPoints]
if (tempPreviewEntity._mousePos) {
pts.push(tempPreviewEntity._mousePos)
}
return new Cesium.PolygonHierarchy(pts)
}, false),
material: Cesium.Color.CYAN.withAlpha(0.25),
outline: true,
outlineColor: Cesium.Color.CYAN,
clampToGround: true
} : undefined
})
}
// 开始绘制
function startDraw(type) {
cancelDraw()
drawMode = type
}
// 结束绘制,生成正式实体
function finishDraw() {
// 移除预览橡皮筋
viewer.entities.remove(tempPreviewEntity)
tempPreviewEntity = null
if (drawMode === 'POLYLINE') {
viewer.entities.add({
name: '绘制折线',
polyline: {
positions: drawingPoints,
width: 3,
material: Cesium.Color.GREEN,
clampToGround: true
}
})
} else if (drawMode === 'POLYGON') {
viewer.entities.add({
name: '绘制多边形',
polygon: {
hierarchy: new Cesium.PolygonHierarchy(drawingPoints),
material: Cesium.Color.GREEN.withAlpha(0.3),
outline: true,
outlineColor: Cesium.Color.GREEN,
clampToGround: true
}
})
}
// 清除顶点标记、重置状态
clearTempPoints()
resetDrawState()
}
// 取消绘制
function stopDraw() {
cancelDraw()
}
function cancelDraw() {
if (tempPreviewEntity) {
viewer.entities.remove(tempPreviewEntity)
tempPreviewEntity = null
}
clearTempPoints()
resetDrawState()
}
function clearTempPoints() {
tempPointEntities.forEach(p => viewer.entities.remove(p))
tempPointEntities = []
}
function resetDrawState() {
drawMode = ''
drawingPoints = []
}
// 清空所有
function clearAll() {
viewer.entities.removeAll()
resetDrawState()
}
onMounted(() => {
initViewer()
})
onUnmounted(() => {
handler?.destroy()
viewer?.destroy()
})
</script>
<style scoped>
.cesium-wrap {
width: 100%;
height: 100vh;
}
.btn-group {
padding: 10px;
background: #fff;
z-index: 9;
position: relative;
}
button {
margin: 0 4px;
padding: 6px 10px;
}
.cesium-container {
width: 100%;
height: calc(100vh - 50px);
}
</style>
核心原理讲解
1. CallbackProperty 为什么必须用?
普通写法 positions: pts 只是赋值一次,后续修改数组图形不会刷新。
CallbackProperty(fn, false):每一帧渲染时都会执行回调函数读取最新坐标,实现鼠标移动实时预览橡皮筋效果
第二个参数
isConstant:false = 动态变化;true = 常量
2. 拾取坐标两种方式区别
// 方式1:globe.pick → 拾取地形表面(贴地绘制,本文用这个)
const ray = viewer.camera.getPickRay(e.position)
const cartesian = viewer.scene.globe.pick(ray, viewer.scene)
// 方式2:viewer.scene.pickPosition(e.position) → 拾取模型、3dtiles、地形都可以,但是依赖深度缓冲
const cartesian = viewer.scene.pickPosition(e.position)
- 标绘围栏、地面轨迹优先
globe.pick,稳定,不容易受 3D 瓦片深度异常影响
3. PolygonHierarchy
多边形面必须包一层 new Cesium.PolygonHierarchy(points),支持挖洞(内环),基础绘制只传外层点位即可。
常见坑(重点)
- 绘制的时候地球无法旋转拖拽
绘制状态下 Cesium 默认相机操作和鼠标事件冲突;如果需要绘制时还能平移地图:可以加一个开关,绘制时关闭相机,或者用Ctrl + 鼠标拖拽方案,自行扩展。 - clampToGround 贴地面不生效
- polygon 贴地:
clampToGround:true - 贴地面不能设置
extrudedHeight拉伸高度,互斥!
- polygon 贴地:
- 右键不生效、没反应
确认ScreenSpaceEventType.RIGHT_CLICK,不要和浏览器原生右键菜单冲突;生产环境可以加e.preventDefault()阻止浏览器右键菜单。 - 页面销毁内存泄漏
onUnmounted一定要销毁handler.destroy()+viewer.destroy(),否则多次进入页面会叠加多个鼠标监听。 - CallbackProperty 不要写复杂逻辑
回调每帧执行,里面不要写请求、循环、大量计算,容易掉帧。
扩展方向(可以继续迭代)
- 绘制矩形、圆形(鼠标按下拖动,松开完成,不是多点点击)
- 绘制完成后编辑顶点:拖拽顶点修改形状
- 绘制带面积、距离测量标签
- 绘制 GeoJSON 导出、导入
- 自定义材质:流动线、虚线、发光围栏
- 增加撤销、回退上一个顶点(回退键)