Cesium 地图交互绘制圆形、多边形

功能:Cesium 地图交互绘制圆形、多边形空域;输出坐标给父组件表单;处理预览、鼠标事件、内存释放 Props:viewerCesiumshowToolBtn显示按钮、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>
相关推荐
禅思院2 小时前
流式 Markdown 渲染完全指南【引子】
前端·架构·前端框架
JackSparrow4142 小时前
前端安全之JS混淆+请求加密+请求签名以提升爬虫难度
前端·javascript·后端·爬虫·python·安全
IMPYLH2 小时前
HTML 的 <em> 元素
java·前端·html
名为沙丁鱼的猫7294 小时前
【审计日志组件实践复盘】前端工程化 + 后端DDD架构(AOP切面拦截日志) + MBG
前端·架构
xqqxqxxq5 小时前
AI智能旅游规划系统 - 前端技术笔记
前端·笔记·旅游
陈随易11 小时前
bm2,MoonBit实现的pm2替代品
前端·后端·程序员
a11177613 小时前
农业数字孪生大屏网页 html开源
前端·html
圣光SG13 小时前
web操作题练习(六)
前端
闪亮的路灯14 小时前
威联通QTS使用自带web服务期代理前端(类似nginx)
前端·nginx·威联通