功能:Cesium 地图交互绘制圆形、多边形空域;输出坐标给父组件表单;处理预览、鼠标事件、内存释放 Props:viewer、Cesium、showToolBtn显示按钮、hasDrawn是否已有图形(防重复绘制) Emits:circleDone圆形完成、polygonDone多边形完成、clear清除、changeShapeType修改形状类型 Expose:clearDrawings stopDrawing clearDrawLayer 父组件可调用
html
<template>
<!-- 绘制操作按钮 -->
<div v-if="showToolBtn" class="drawing-buttons">
<el-button type="primary" @click="startDrawCircle">绘制圆形</el-button>
<el-button type="success" @click="startDrawPolygon">绘制多边形</el-button>
<el-button type="danger" @click="clearDrawings">清除绘制</el-button>
</div>
<!-- 鼠标跟随操作提示浮层 -->
<div
v-if="drawHintVisible"
class="draw-hint-tooltip"
:style="{ left: drawHintPosition.x + 'px', top: drawHintPosition.y + 'px' }"
>
{{ drawHintText }}
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
/**
* @component draw‑airspace
* @desc Cesium地图绘制:圆形空域、多边形空域
* 交互:圆形‑左键定圆心,鼠标拖半径,右键完成;多边形‑左键加点,右键结束(≥3点)
* 特点:实时预览线、顶点标记、mousemove渲染节流、组件销毁释放事件防内存泄漏
*/
interface Props {
viewer: any;
Cesium: any;
showToolBtn: boolean;
hasDrawn: boolean; // 是否已经绘制图形,拦截重复绘制
}
const props = defineProps<Props>();
const emit = defineEmits<{
circleDone: [payload: { longitude: string; latitude: string; radius: string }];
polygonDone: [payload: Array<{ longitude: string; latitude: string }>];
clear: [];
changeShapeType: [type: number]; // 1圆形 2多边形
}>();
let drawLayer: any = null; // 绘制专用图层CustomDataSource
let previewEntity: any = null; // 绘制时动态预览线条实体
let livePreviewRadius = 0; // 圆形预览半径
let drawPolygonPreviewPoints: Array<{ longitude: number; latitude: number }> = [];
const PREVIEW_RENDER_INTERVAL_MS = 32; // 预览渲染节流间隔
let lastPreviewRenderAt = 0;
let handler: any = null; // Cesium鼠标事件处理器
let circlePreviewRafId: number | null = null;
let pendingCirclePreviewPosition: any = null;
let lastPreviewRadius = 0;
let drawMode = ref(''); // '' / circle / polygon 当前绘制模式
let circleCenter = ref<{ longitude: number; latitude: number } | null>(null);
let circleRadius = ref(0);
let polygonPoints = ref<Array<{ longitude: number; latitude: number }>>([]);
let polygonCursorPoint: { longitude: number; latitude: number } | null = null;
// tooltip提示相关状态
const drawHintVisible = ref(false);
const drawHintText = ref('');
const drawHintPosition = ref({ x: 0, y: 0 });
// ====================== 工具方法 ======================
/** 获取绘制图层,没有就创建 */
function getDrawLayer() {
if (!props.viewer || !props.Cesium) return null;
if (!drawLayer) {
drawLayer = new props.Cesium.CustomDataSource('airspace_draw');
props.viewer.dataSources.add(drawLayer);
}
return drawLayer;
}
/** 移除预览临时图形,重置预览变量 */
function removePreviewEntity() {
if (previewEntity && drawLayer) drawLayer.entities.remove(previewEntity);
previewEntity = null;
livePreviewRadius = 0;
drawPolygonPreviewPoints = [];
}
/** 清空图层内所有绘制图形 */
function clearDrawLayer() {
removePreviewEntity();
drawLayer?.entities.removeAll();
requestSceneRender();
}
/** 强制刷新cesium场景画面 */
function requestSceneRender() {
props.viewer?.scene?.requestRender();
}
/** 节流:预览画面更新,降低mousemove高频重绘压力 */
function requestPreviewRender() {
const now = performance.now();
if (now - lastPreviewRenderAt < PREVIEW_RENDER_INTERVAL_MS) return;
lastPreviewRenderAt = now;
requestSceneRender();
}
/** 更新鼠标提示浮层位置与文字 */
function updateDrawHintFromScreen(screenPosition: any, text?: string) {
if (!screenPosition) return;
if (text) drawHintText.value = text;
drawHintVisible.value = true;
drawHintPosition.value = { x: screenPosition.x + 14, y: screenPosition.y + 14 };
}
function hideDrawHint() {
drawHintVisible.value = false;
}
/** 添加绘制的顶点黄色标记点 */
function addDrawPointMarker(name: string, longitude: number, latitude: number) {
const layer = getDrawLayer();
if (!layer) return;
layer.entities.add({
name,
position: props.Cesium.Cartesian3.fromDegrees(longitude, latitude),
point: { pixelSize: 10, color: props.Cesium.Color.YELLOW, outlineColor: props.Cesium.Color.BLACK, outlineWidth: 2, heightReference: props.Cesium.HeightReference.CLAMP_TO_GROUND, disableDepthTestDistance: Number.POSITIVE_INFINITY }
});
}
/** 往绘制图层添加实体图形 */
function addDrawEntity(options: Record<string, any>) {
const layer = getDrawLayer();
return layer ? layer.entities.add(options) : null;
}
/** 根据圆心、半径生成圆形折线点位数组 */
function buildCirclePolylinePositions(lon: number, lat: number, radiusMeters: number, segments = 24) {
if (radiusMeters <= 0) return [];
const flat: number[] = [];
const latRad = (lat * Math.PI) / 180;
const meterPerDegLat = 111320;
const meterPerDegLon = 111320 * Math.cos(latRad) || 111320;
for (let i = 0; i <= segments; i++) {
const angle = (i / segments) * Math.PI * 2;
const dLon = (radiusMeters / meterPerDegLon) * Math.sin(angle);
const dLat = (radiusMeters / meterPerDegLat) * Math.cos(angle);
flat.push(lon + dLon, lat + dLat);
}
return props.Cesium.Cartesian3.fromDegreesArray(flat);
}
/** 两点经纬度计算地面距离(米) */
function calculateDistanceFast(lon1: number, lat1: number, lon2: number, lat2: number): number {
const toRad = Math.PI / 180;
const dLat = (lat2 - lat1) * toRad;
const dLon = (lon2 - lon1) * toRad;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/** 取消圆形预览动画帧 */
function cancelCirclePreviewFrame() {
if (circlePreviewRafId != null) cancelAnimationFrame(circlePreviewRafId);
circlePreviewRafId = null;
pendingCirclePreviewPosition = null;
}
/** 鼠标屏幕坐标转笛卡尔坐标;mode terrain拾取地形,fast拾取椭球 */
function getCartesianFromMouse(position: any, mode: 'fast' | 'terrain' = 'fast') {
if (!props.viewer || !props.Cesium) return null;
if (mode === 'terrain') {
const ray = props.viewer.camera.getPickRay(position);
if (ray) {
const globeCartesian = props.viewer.scene.globe.pick(ray, props.viewer.scene);
if (globeCartesian && props.Cesium.defined(globeCartesian)) return globeCartesian;
}
}
const ellipsoidPosition = props.viewer.camera.pickEllipsoid(position, props.viewer.scene.globe.ellipsoid);
return (ellipsoidPosition && props.Cesium.defined(ellipsoidPosition)) ? ellipsoidPosition : null;
}
/** 停止绘制:清空鼠标事件、预览、绘制模式 */
function stopDrawing() {
cancelCirclePreviewFrame();
lastPreviewRadius = 0;
polygonCursorPoint = null;
if (handler) {
handler.removeInputAction(props.Cesium.ScreenSpaceEventType.LEFT_CLICK);
handler.removeInputAction(props.Cesium.ScreenSpaceEventType.RIGHT_CLICK);
handler.removeInputAction(props.Cesium.ScreenSpaceEventType.MOUSE_MOVE);
}
removePreviewEntity();
drawMode.value = '';
hideDrawHint();
}
/** 清除全部绘制图形,重置状态 */
function clearDrawings() {
clearDrawLayer();
circleCenter.value = null;
circleRadius.value = 0;
polygonPoints.value = [];
drawPolygonPreviewPoints = [];
stopDrawing();
emit('clear');
ElMessage.success('已清除绘制');
}
// ====================== 绘制圆形逻辑 ======================
/** 开启圆形绘制模式 */
function startDrawCircle() {
if (!props.viewer || !props.Cesium) return ElMessage.warning('地图未初始化,请稍后再试');
if (props.hasDrawn) return ElMessage.warning('已存在绘制的图形,请先清除后再绘制');
clearDrawLayer();
drawMode.value = 'circle';
lastPreviewRadius = livePreviewRadius = circleRadius.value = 0;
circleCenter.value = null;
emit('changeShapeType', 1);
ElMessage.success('点击地图设置圆心,移动鼠标调整半径,右键完成');
requestAnimationFrame(() => requestAnimationFrame(() => setupCircleDrawHandlers()));
}
/** 注册圆形绘制鼠标事件 */
function setupCircleDrawHandlers() {
if (!props.viewer || !props.Cesium || drawMode.value !== 'circle') return;
if (!handler) handler = new props.Cesium.ScreenSpaceEventHandler(props.viewer.scene.canvas);
stopDrawing();
drawMode.value = 'circle';
drawHintText.value = '左键设置圆心,移动鼠标调整半径,右键完成';
handler.setInputAction((moveEvent: any) => updateDrawHintFromScreen(moveEvent.endPosition), props.Cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction((event: any) => {
if (circleCenter.value) return ElMessage.warning('已设置圆心,请移动鼠标调整半径,右键完成绘制');
const cartesian = getCartesianFromMouse(event.position, 'terrain');
if (!cartesian) return ElMessage.warning('无法获取地图坐标,请尝试点击其他位置');
const cartographic = props.Cesium.Cartographic.fromCartesian(cartesian);
const longitude = props.Cesium.Math.toDegrees(cartographic.longitude);
const latitude = props.Cesium.Math.toDegrees(cartographic.latitude);
if (isNaN(longitude) || isNaN(latitude)) return ElMessage.warning('坐标无效,请尝试点击其他位置');
circleCenter.value = { longitude, latitude };
circleRadius.value = 0;
addDrawPointMarker('draw_circle_center', longitude, latitude);
emit('circleDone', { longitude: String(longitude), latitude: String(latitude), radius: '0' });
handler.setInputAction((moveEvent: any) => {
scheduleCirclePreviewUpdate(moveEvent.endPosition);
updateDrawHintFromScreen(moveEvent.endPosition, '移动鼠标调整半径,右键完成');
}, props.Cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(() => finishCircleDrawing(), props.Cesium.ScreenSpaceEventType.RIGHT_CLICK);
requestSceneRender();
}, props.Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
/** 创建圆形预览动态线实体 */
function ensureCirclePreviewEntity() {
if (previewEntity || !getDrawLayer()) return;
previewEntity = getDrawLayer()!.entities.add({
name: 'draw_circle_preview',
polyline: {
positions: new props.Cesium.CallbackProperty(() => {
if (!circleCenter.value || livePreviewRadius <= 0) return [];
return buildCirclePolylinePositions(circleCenter.value.longitude, circleCenter.value.latitude, livePreviewRadius);
}, false),
width: 2,
material: props.Cesium.Color.GREEN,
clampToGround: true
}
});
}
/** 调度圆形预览更新,用raf防抖 */
function scheduleCirclePreviewUpdate(position: any) {
if (!circleCenter.value) return;
pendingCirclePreviewPosition = position;
if (circlePreviewRafId != null) return;
circlePreviewRafId = requestAnimationFrame(() => {
circlePreviewRafId = null;
updateCirclePreview(pendingCirclePreviewPosition);
pendingCirclePreviewPosition = null;
});
}
/** 实时计算鼠标移动后的预览半径 */
function updateCirclePreview(position: any) {
if (!props.viewer || !props.Cesium || !circleCenter.value) return;
const moveCartesian = getCartesianFromMouse(position, 'fast');
if (!moveCartesian) return;
const moveCartographic = props.Cesium.Cartographic.fromCartesian(moveCartesian);
const moveLongitude = props.Cesium.Math.toDegrees(moveCartographic.longitude);
const moveLatitude = props.Cesium.Math.toDegrees(moveCartographic.latitude);
if (isNaN(moveLongitude) || isNaN(moveLatitude)) return;
const distance = calculateDistanceFast(circleCenter.value.longitude, circleCenter.value.latitude, moveLongitude, moveLatitude);
if (isNaN(distance) || distance <= 0) return;
if (Math.abs(distance - lastPreviewRadius) < 8) return;
lastPreviewRadius = distance;
livePreviewRadius = distance;
ensureCirclePreviewEntity();
requestPreviewRender();
}
/** 右键完成圆形绘制,生成最终椭圆实体,抛出事件 */
function finishCircleDrawing() {
if (!circleCenter.value || livePreviewRadius === 0) return;
circleRadius.value = livePreviewRadius;
if (isNaN(circleCenter.value.longitude) || isNaN(circleCenter.value.latitude)) return ElMessage.warning('圆心坐标无效');
if (isNaN(circleRadius.value) || circleRadius.value <= 0) return ElMessage.warning('半径无效');
cancelCirclePreviewFrame();
removePreviewEntity();
try {
addDrawEntity({
name: 'draw_circle',
position: props.Cesium.Cartesian3.fromDegrees(circleCenter.value.longitude, circleCenter.value.latitude),
ellipse: {
semiMajorAxis: circleRadius.value,
semiMinorAxis: circleRadius.value,
material: props.Cesium.Color.YELLOW.withAlpha(0.4),
outline: true,
outlineColor: props.Cesium.Color.GREEN,
outlineWidth: 2,
clampToGround: true
}
});
requestSceneRender();
emit('circleDone', { longitude: String(circleCenter.value.longitude), latitude: String(circleCenter.value.latitude), radius: String(circleRadius.value.toFixed(2)) });
stopDrawing();
} catch (error) {
console.error('创建圆形失败:', error);
ElMessage.error('创建圆形失败,请重试');
}
}
// ====================== 绘制多边形逻辑 ======================
/** 开启多边形绘制模式 */
function startDrawPolygon() {
if (!props.viewer || !props.Cesium) return ElMessage.warning('地图未初始化,请稍后再试');
if (props.hasDrawn) return ElMessage.warning('已存在绘制的图形,请先清除后再绘制');
clearDrawLayer();
drawMode.value = 'polygon';
polygonPoints.value = [];
drawPolygonPreviewPoints = [];
emit('changeShapeType', 2);
ElMessage.success('点击地图添加顶点,右键完成多边形(至少3个顶点)');
requestAnimationFrame(() => requestAnimationFrame(() => setupPolygonDrawHandlers()));
}
/** 注册多边形绘制鼠标事件 */
function setupPolygonDrawHandlers() {
if (!props.viewer || !props.Cesium || drawMode.value !== 'polygon') return;
if (!handler) handler = new props.Cesium.ScreenSpaceEventHandler(props.viewer.scene.canvas);
stopDrawing();
drawMode.value = 'polygon';
polygonCursorPoint = null;
drawHintText.value = '左键添加顶点,右键完成(至少3个顶点)';
handler.setInputAction((moveEvent: any) => {
const cartesian = getCartesianFromMouse(moveEvent.endPosition, 'fast');
if (!cartesian) return;
const cartographic = props.Cesium.Cartographic.fromCartesian(cartesian);
polygonCursorPoint = { longitude: props.Cesium.Math.toDegrees(cartographic.longitude), latitude: props.Cesium.Math.toDegrees(cartographic.latitude) };
ensurePolygonPreviewEntity();
updateDrawHintFromScreen(moveEvent.endPosition);
requestPreviewRender();
}, props.Cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction((event: any) => {
const cartesian = getCartesianFromMouse(event.position, 'terrain');
if (!cartesian) return ElMessage.warning('无法获取地图坐标,请尝试点击其他位置');
const cartographic = props.Cesium.Cartographic.fromCartesian(cartesian);
const longitude = props.Cesium.Math.toDegrees(cartographic.longitude);
const latitude = props.Cesium.Math.toDegrees(cartographic.latitude);
if (isNaN(longitude) || isNaN(latitude)) return ElMessage.warning('坐标无效,请尝试点击其他位置');
polygonPoints.value.push({ longitude, latitude });
drawPolygonPreviewPoints = polygonPoints.value.slice();
addDrawPointMarker(`draw_point_${polygonPoints.value.length - 1}`, longitude, latitude);
ensurePolygonPreviewEntity();
requestPreviewRender();
}, props.Cesium.ScreenSpaceEventType.LEFT_CLICK);
handler.setInputAction(() => finishPolygonDrawing(), props.Cesium.ScreenSpaceEventType.RIGHT_CLICK);
}
/** 创建多边形预览动态拖尾线实体 */
function ensurePolygonPreviewEntity() {
if (previewEntity || !getDrawLayer()) return;
previewEntity = getDrawLayer()!.entities.add({
name: 'draw_polygon_preview',
polyline: {
positions: new props.Cesium.CallbackProperty(() => {
if (drawPolygonPreviewPoints.length < 1) return [];
const positions = drawPolygonPreviewPoints.map(p => props.Cesium.Cartesian3.fromDegrees(p.longitude, p.latitude));
if (polygonCursorPoint && drawMode.value === 'polygon' && !isNaN(polygonCursorPoint.longitude) && !isNaN(polygonCursorPoint.latitude)) {
positions.push(props.Cesium.Cartesian3.fromDegrees(polygonCursorPoint.longitude, polygonCursorPoint.latitude));
}
return positions.length >= 3 ? [...positions, positions[0]] : positions;
}, false),
width: 2,
material: props.Cesium.Color.GREEN,
clampToGround: true
}
});
}
/** 右键结束多边形绘制,生成面实体抛出顶点数组 */
function finishPolygonDrawing() {
if (polygonPoints.value.length < 3) return ElMessage.warning('至少需要3个顶点才能形成多边形');
const invalidPoint = polygonPoints.value.find(p => isNaN(p.longitude) || isNaN(p.latitude));
if (invalidPoint) return ElMessage.warning('存在无效的顶点坐标');
removePreviewEntity();
try {
const positions = polygonPoints.value.map(p => props.Cesium.Cartesian3.fromDegrees(p.longitude, p.latitude));
addDrawEntity({
name: 'draw_polygon',
polygon: {
hierarchy: new props.Cesium.PolygonHierarchy(positions),
material: props.Cesium.Color.YELLOW.withAlpha(0.4),
outline: true,
outlineColor: props.Cesium.Color.GREEN,
outlineWidth: 2,
clampToGround: true
}
});
requestSceneRender();
const emitPoints = polygonPoints.value.map(p => ({ longitude: String(p.longitude), latitude: String(p.latitude) }));
emit('polygonDone', emitPoints);
stopDrawing();
} catch (error) {
console.error('创建多边形失败:', error);
ElMessage.error('创建多边形失败,请重试');
}
}
// 对外暴露给父组件调用
defineExpose({
clearDrawings,
stopDrawing,
clearDrawLayer,
});
// 组件销毁:释放事件、图层,防止Cesium内存泄漏
onUnmounted(() => {
stopDrawing();
cancelCirclePreviewFrame();
if (drawLayer && props.viewer) props.viewer.dataSources.remove(drawLayer);
drawLayer = null;
if (handler) {
handler.destroy();
handler = null;
}
});
</script>
<style scoped>
.drawing-buttons {
position: absolute;
left: 20px;
top: 20px;
z-index: 999;
display: flex;
gap: 10px;
}
.draw-hint-tooltip {
position: absolute;
z-index: 1000;
pointer-events: none;
padding: 6px 10px;
border-radius: 4px;
background: rgba(14, 70, 81, 0.92);
border: 1px solid #3d8492;
color: #daf9ff;
font-size: 13px;
line-height: 1.4;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
</style>
父组件调用示例
html
<template>
<div class="cesium-box">
<!-- cesium容器 -->
<div ref="viewerRef" class="viewer"></div>
<!-- 空域绘制子组件 -->
<DrawAirspace
ref="drawAirspaceRef"
:viewer="_viewer"
:Cesium="_Cesium"
:show‑tool‑btn="true"
:has‑drawn="formData.shapeType > 0"
@circle‑done="handleCircleDone"
@polygon‑done="handlePolygonDone"
@clear="handleDrawClear"
@change‑shape‑type="formData.shapeType = $event"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// 引入绘制组件
import DrawAirspace from './draw‑airspace.vue';
// cesium实例
const _viewer = ref<any>(null);
const _Cesium = ref<any>(null);
// 子组件ref,可调用内部暴露方法
const drawAirspaceRef = ref<InstanceType<typeof DrawAirspace>>();
// 表单数据
const formData = ref({
shapeType: 0, // 1圆形 2多边形
// 圆形
longitude: '',
latitude: '',
radius: '',
// 多边形顶点
polygonPoints: [] as Array<{ longitude: string; latitude: string }>
});
/**
* 圆形绘制完成回调
*/
function handleCircleDone(payload: { longitude: string; latitude: string; radius: string }) {
formData.value.longitude = payload.longitude;
formData.value.latitude = payload.latitude;
formData.value.radius = payload.radius;
}
/**
* 多边形绘制完成回调
*/
function handlePolygonDone(points: Array<{ longitude: string; latitude: string }>) {
formData.value.polygonPoints = points;
}
/**
* 用户点击清除绘制
*/
function handleDrawClear() {
// 清空表单图形相关字段
formData.value.shapeType = 0;
formData.value.longitude = '';
formData.value.latitude = '';
formData.value.radius = '';
formData.value.polygonPoints = [];
}
// 编辑回填场景示例(打开编辑弹窗时)
function setEditData(row: any) {
formData.value = { ...row };
// 如果需要清空子组件绘制:drawAirspaceRef.value?.clearDrawings()
}
// 提交表单,把formData传给接口
async function submitForm() {
const params = { ...formData.value };
console.log('提交参数', params);
// await api.saveAirspace(params)
}
</script>
<style scoped>
.viewer {
width: 100%;
height: 600px;
}
</style>