cesium 实现 场景编辑(物料区+编辑区+属性配置区)

🚀 个人简介:某大型测绘遥感企业资深Webgis开发工程师,软件设计师(中级)、优快云优质创作者
💟 作 者:柳晓黑胡椒❣️
📝 专 栏:cesium实践(原生)
🌈 若有帮助,还请关注点赞收藏,不行的话我再努努力💪💪💪

需求背景

应粉丝要求,需要实现地图实例组态拖拽,地图可配置地图实例编辑的需求,每个实例需要有一个气泡,气泡内可自由编辑,且可放置视频与图片

解决思路

  • 地图实例组态:参考低代码的思路,把地图实例分为三个区块,物料区(可操作的实例),编辑区(管理已添加的实例),属性配置区(单个实例的属性编辑)
  • 气泡可自由编辑:采用divPoint的思路,监听地图渲染事件变化div标签在屏幕位置,div内容供粉丝随意编辑添加
  • 物料区:预想添加之前cesium目前已了解的实例和效果,后续持续更新
  • 属性配置区:包含cesium实例所有可配置参数,以及实时更新

实现效果

index.vue

/**
* @author: liuk
* @date: 2024-11-26
* @describe: 场景编辑(物料区+编辑区+属性配置区)
*/
<template>
  <div class="sceneEdit-wrap">
    <div class="material-area">
      <div class="setting-tabs">物料区</div>
      <ul class="operate-area">
        <li v-for="info in infos" :key="info.name">
          <div class="model-title">
            <span></span>
            {{ info.name }}
          </div>
          <div class="model-item" v-if="info.types[0]?.img">
            <div class="son-item" v-for="item in info.types" :key="item.label" @click="drawFn(info.name,item)">
              <img :src="item.img" alt=""/>
              <div>{{ item.label }}</div>
            </div>
          </div>
          <div class="model-item-btn" v-else>
            <el-button v-for="item in info.types" :key="item.label" @click="drawFn(info.name,item)" size="small">
              {{ item.label }}
            </el-button>
          </div>
        </li>
      </ul>
      <div class="operate">
        <el-button type="primary" plain @click="cutImg">截取封面</el-button>
        <el-button type="primary" plain @click="mapResetCamera">默认视角</el-button>
        <el-button type="primary" class="preserve" @click="saveConfig">保存</el-button>
      </div>
    </div>
    <div class="edit-area">
      <div class="head_title_arrow">编辑区</div>
      <div class="content">
        <el-tree ref="treeRef" :data="treeData" :default-expanded-keys="defaultKeys" node-key="id">
          <template #default="{data}">
            <span>{{ data.label }}</span>
            <span>{{ data.children ? `&nbsp;(${data.children.length})` : '' }}</span>
            <el-icon style="margin-left: 5px" v-if="!data.children" @click="flyToGraphic(data.id)">
              <Promotion/>
            </el-icon>
            <el-icon style="margin-left: 5px" @click="removeNode(data.id)" v-if="!data.children">
              <Delete/>
            </el-icon>
          </template>
        </el-tree>
      </div>
    </div>
    <div class="property-editing-area" v-show="propertyEditingShow">
      <div class="top-area">
        <span class="head_title_arrow">属性配置区</span>
        <el-icon :size="16" @click="closeEdit">
          <Close/>
        </el-icon>
      </div>
      <div>
        <el-icon :size="18" style="margin-right: 6px" @click="flyToGraphic()">
          <Promotion/>
        </el-icon>
        <el-icon :size="18" style="margin-right: 6px" @click="removeNode()">
          <Delete/>
        </el-icon>
        <el-icon :size="18" style="margin-right: 6px" @click="getGeoJson">
          <DocumentCopy/>
        </el-icon>
      </div>
      <el-form :model="formData" ref="formRef" label-width="98" label-position="right">
        <el-form-item v-for="(item,index) in curStyles" :key="index" :label="item.label" :prop="item.name"
                      :name="item.name">
          <template #label>
            <span :title="item.name" @click="copyUrl(item.name)">{{ item.label }}</span>
          </template>
          <component :is="components[item.type]" size="small" v-model="formData[item.name]"
                     :min="item.min || item.min === 0 ? item.min : -Infinity"
                     :max="item.max || item.max === 0 ? item.max : Infinity" :step="item.step || 0.1"
                     :style="{width: item.type ==='slider'?'88%' :'100%'}" :disabled="item.disabled"
                     @change="styleChange(item.name,$event)">
            <template v-if="item.type ==='combobox'">
              <el-option v-for="(x,index) in item.data" :key="index" :label="x.label" :value="x.value"/>
            </template>
          </component>
        </el-form-item>
      </el-form>
    </div>
    <Tdt_img_d/>
    <!-- 气泡-->
    <ul v-for="(item,index) in popupList" :key="index">
      <li :class="['surveyStation-popup',item.class]" :style="{ transform: `translate(${item.x }px, ${item.y}px)`}">
        {{ item.remark }}
        <img src="@/assets/images/no-select.png"/>
      </li>
    </ul>
  </div>
</template>

<script lang="ts" setup>
import {reactive, toRefs, ref, onMounted, onUnmounted} from "vue";
import {usemapStore} from "@/store/modules/cesiumMap.ts";
import {ElMessage} from "element-plus";
import {generateUUID, downloadFile, copyUrl, cartesianToWgs84} from "@/utils/dictionary"
import * as mars3d from "mars3d";
import * as turf from '@turf/turf'
import defaultData from "./data"
import tempJSON from "./temp.json"
import styleConfig from "./styleConfig"

// Component
import Tdt_img_d from "@/views/cesium/component/controlPanel/layerManagement/basicMap/tdt_img_d.vue"

const components = {
  number: "el-input-number",
  radio: "el-switch",
  slider: "el-slider",
  color: "el-color-picker",
  combobox: "el-select",
  textarea: "el-input",
  label: "el-input"
}
// Refs
const treeRef = ref()
const formRef = ref()

const mapStore = usemapStore()
const model = reactive({
  treeData: [],
  defaultKeys: [2],
  propertyEditingShow: false,
  curStyles: [],
  formData: {}
})
const {treeData, defaultKeys, propertyEditingShow, curStyles, formData} = toRefs(model)

onMounted(() => {
  popupModel.popupList = tempJSON.features.map(item => {
    const {nodeId, label, customType, remark, longitude, latitude, height} = item.properties
    const temp = defaultData.find(item => item.label === customType)
    temp.children.push({id: nodeId, label})
    return {id: nodeId, longitude, latitude, height, remark: remark || "暂无备注", class: `popup-box-${nodeId}`}
  })
  model.treeData = defaultData
  viewer.addLayer(graphicLayer)
  graphicLayer.loadGeoJSON(tempJSON)
  graphicLayer.on([mars3d.EventType.drawCreated, mars3d.EventType.editStart, mars3d.EventType.editMovePoint, mars3d.EventType.editStyle, mars3d.EventType.editRemovePoint], EditorFn)
  graphicLayer.on([mars3d.EventType.editStop, mars3d.EventType.removeGraphic], editStopFn)
  viewer.scene.postRender.addEventListener(showPopupBox);
})

onUnmounted(() => {
  model.treeData = []
  graphicLayer.off([mars3d.EventType.drawCreated, mars3d.EventType.editStart, mars3d.EventType.editMovePoint, mars3d.EventType.editStyle, mars3d.EventType.editRemovePoint], EditorFn)
  graphicLayer.off([mars3d.EventType.editStop, mars3d.EventType.removeGraphic], editStopFn)
  viewer.scene.postRender.removeEventListener(showPopupBox);
  graphicLayer.clear()
  viewer.removeLayer(graphicLayer)
})

const drawFn = (name, row) => {
  const {label, drawType: type, style} = row
  if (!type) {
    ElMessage.warning("开发中")
    return
  }
  graphicLayer.startDraw({type, style, attr: {label, customType: name}})
}

const editStopFn = () => {
  model.propertyEditingShow = false
}

const flyToGraphic = (id?: string) => {
  const graphic = id ? graphicLayer.getGraphicsByAttr(id, "nodeId")[0] : curGraphic
  graphic.flyTo({duration: 1})
}

const removeNode = (id) => {
  let graphic
  if (id) {
    graphic = graphicLayer.getGraphicsByAttr(id, "nodeId")[0]
  } else {
    graphic = curGraphic
    id = graphic.attr.nodeId
    model.propertyEditingShow = false
  }
  treeRef.value.remove(id)
  const index = popupModel.popupList.findIndex(item => item.id === id)
  index !== -1 && popupModel.popupList.splice(index, 1)
  graphic && graphic.remove()
  reset()
}

const saveConfig = () => {
  if (!graphicLayer.getGraphics().length) {
    ElMessage.warning("当前没有标注任何数据,无需保存!")
    return
  }
  const geojson = graphicLayer.toGeoJSON()
  const jsonString = JSON.stringify(geojson, null, 2);
  downloadFile(`temp.json`, jsonString)
}

const closeEdit = () => {
  model.propertyEditingShow = false
}

const reset = () => {
  model.curStyles = []
  model.formData = {}
}

// 地图逻辑
const viewer = mapStore.getCesiumViewer()
let curGraphic
const graphicLayer = new mars3d.layer.GraphicLayer({
  hasEdit: true,// 开启编辑
  isAutoEditing: true // 绘制完成后是否自动激活编辑
})

const mapResetCamera = () => {
  viewer.camera.flyTo({
    destination: new Cesium.Cartesian3.fromDegrees(113.048936, 25.755645, 77805.77),
    orientation: {
      heading: Cesium.Math.toRadians(0),
      pitch: Cesium.Math.toRadians(-90),
      roll: Cesium.Math.toRadians(0),
    },
    duration: 2,
  })
}

const EditorFn = (e) => {
  curGraphic = e.graphic
  const {customType, label, nodeId, remark} = curGraphic.attr
  // 属性配置区
  formRef.value.scrollToField('name')
  model.propertyEditingShow = true
  model.curStyles = [
    {name: "name", label: "名称", type: "textarea", defval: label},
    {name: "remark", label: "备注", type: "textarea", defval: remark},
    {name: "customType", label: "所属组类", type: "textarea", defval: customType, disabled: true},
    {name: "editType", label: "样式类型", type: "textarea", defval: curGraphic.type, disabled: true},
    ...styleConfig[curGraphic.type]?.style,
  ]
  model.curStyles.forEach(({name, defval}) => model.formData[name] = curGraphic.style[name] || defval)
  // 气泡
  // const features = turf.points(curGraphic.coordinates)
  // const center = turf.center(features);
  const [longitude, latitude, height] = cartesianToWgs84(e.graphic.center)
  curGraphic.attr.longitude = longitude
  curGraphic.attr.latitude = latitude
  curGraphic.attr.height = height
  const index = popupModel.popupList.findIndex(item => item.id === nodeId)
  const obj = {id: nodeId, longitude, latitude, height, class: `popup-box-${nodeId}`, remark: remark || "暂无备注"}
  if (index !== -1) {
    popupModel.popupList[index] = obj
  } else {
    nodeId && popupModel.popupList.push(obj)
  }
  // 编辑区
  if (nodeId) return
  const {id: parentNode, children} = model.treeData.find(item => item.label === customType)
  const count = children.filter(item => item.label.match(/[\u4e00-\u9fa5]+/g).join("") === label.match(/[\u4e00-\u9fa5]+/g).join("")).length
  const data = {id: generateUUID(), label: `${label}${count ? "_" + count : ""}`}
  treeRef.value.append(data, parentNode)
  model.defaultKeys = [parentNode]
  curGraphic.attr.nodeId = data.id
  curGraphic.attr.label = `${label}${count ? "_" + count : ""}`
}

const styleChange = (name, val) => {
  const {nodeId} = curGraphic.attr
  switch (name) {
    case "name":
      const node = treeRef.value.getNode(nodeId)
      node.data.label = val
      curGraphic.attr.label = val
      break
    case "remark":
      curGraphic.attr.remark = val
      const index = popupModel.popupList.findIndex(item => item.id === nodeId)
      popupModel.popupList[index].remark = val
      break
    default:
      curGraphic.setStyle({[name]: val})
  }
}

const getGeoJson = () => {
  const {customType, label} = curGraphic.attr
  const geojson = curGraphic.toGeoJSON() // 文件处理
  const jsonString = JSON.stringify(geojson, null, 2);
  downloadFile(`${customType}-${label}.json`, jsonString)
}

const cutImg = () => {
  viewer.expImage({type: "image/png"})
}

// 地图弹框逻辑
const popupModel = reactive({
  popupList: []
})
const {popupList} = toRefs(popupModel)

const showPopupBox = () => {
  popupModel.popupList.forEach(item => {
    const {longitude, latitude, height: heigtZ} = item
    const dom = document.querySelector("." + item.class)
    if (!dom) return
    const width = parseInt(getComputedStyle(dom).width)
    const height = parseInt(getComputedStyle(dom).height)
    const curPosition = Cesium.Cartesian3.fromDegrees(longitude, latitude, heigtZ);
    try {
      const {x, y} = viewer.scene.cartesianToCanvasCoordinates(curPosition)
      item.x = x - (width / 2)
      item.y = y - height - 50
    } catch (e) {
    }
  })
}

import png1 from "@/assets/images/sceneEdit/sm@2x.jpg"
import png2 from "@/assets/images/sceneEdit/cd@2x.jpg"
import png3 from "@/assets/images/sceneEdit/jz@2x.jpg"
import png4 from "@/assets/images/sceneEdit/dl@2x.jpg"
import png5 from "@/assets/images/sceneEdit/hl@2x.jpg"
import png6 from "@/assets/images/sceneEdit/hb@2x.jpg"
import png7 from "@/assets/images/sceneEdit/js@2x.jpg"
import png8 from "@/assets/images/sceneEdit/dn@2x.jpg"
import png9 from "@/assets/images/sceneEdit/kz@2x.jpg"
import png11 from "@/assets/images/sceneEdit/h@2x.png"
import png22 from "@/assets/images/sceneEdit/y@2x.png"
import png33 from "@/assets/images/sceneEdit/dgy@2x.png"
import png44 from "@/assets/images/sceneEdit/jgy@2x.png"
import png55 from "@/assets/images/sceneEdit/s@2x.png"

const infos = [
  {
    name: "三维模型",
    types: [
      {label: "树木", img: png1, drawType: "model", style: {scale: 10, url: "/tree.glb"}},
      {label: "草地", img: png2, drawType: ""},
      {label: "建筑", img: png3, drawType: ""},
      {label: "道路", img: png4, drawType: ""},
      {label: "河流", img: png5, drawType: ""},
      {label: "湖泊", img: png6, drawType: ""},
      {label: "教室", img: png7, drawType: ""},
      {label: "电脑", img: png8, drawType: ""},
      {label: "课桌", img: png9, drawType: ""},
    ]
  },
  {
    name: "二维标注",
    types: [
      {label: '线', drawType: "polyline", style: {color: "#ffff00", width: 3, clampToGround: true}},
      {
        label: '虚线',
        drawType: "polyline",
        style: {color: "#ffff00", width: 3, materialType: "PolylineDash", clampToGround: true}
      },
      {
        label: '面', drawType: "polygon",
        style: {color: "#ffff00", opacity: 0.6, outlineWidth: 2.0, clampToGround: true}
      },
      {
        label: '矩形', drawType: "rectangle",
        style: {color: "#ffff00", opacity: 0.6, outlineWidth: 2.0, clampToGround: true}
      },
      {
        label: '圆', drawType: "circle",
        style: {color: "#ffff00", opacity: 0.6, outlineWidth: 2.0, clampToGround: true}
      },
      {
        label: '文字', drawType: "label",
        style: {text: "柳晓黑胡椒", color: "#0081c2", font_size: 50, outline: true, outlineColor: "#ffffff", outlineWidth: 2}
      }
    ]
  },
  {
    name: "三维标注",
    types: [
      {
        label: '墙体', drawType: "wall",
        style: {color: "#00ff00", opacity: 0.8, diffHeight: 400, closure: false}
      },
      {label: '动态墙', drawType: ""},
      {label: '箭头', drawType: ""}
    ]
  },
  {
    name: "场景特效",
    types: [
      {label: "火", img: png11, drawType: ""},
      {label: "烟", img: png22, drawType: ""},
      {label: "点光源", img: png33, drawType: ""},
      {label: "聚光源", img: png44, drawType: ""},
      {label: "水", img: png55, drawType: ""}
    ]
  }
]
</script>

<style lang="scss" scoped>
.sceneEdit-wrap {
  pointer-events: auto;

  .material-area {
    position: fixed;
    top: 100px;
    right: 20px;
    width: 330px;
    height: calc(100% - 130px);
    border-radius: 5px;
    background: rgba(0, 0, 0, 0.6);
    overflow: hidden;

    .setting-tabs {
      display: flex;
      width: 100%;
      height: 35px;
      margin-bottom: 5px;
      font-size: 20px;
      line-height: 35px;
      text-indent: 15px;
      font-family: YouSheBiaoTiHei;
      background: rgba(22, 161, 255, 1);
    }

    .operate-area {
      height: calc(100% - 90px);
      overflow-y: auto;
      padding: 0px 5px;
      box-sizing: border-box;

      .model-title {
        display: flex;
        align-items: center;
        text-indent: 8px;
        font-weight: 500;
        margin-bottom: 10px;

        span {
          width: 3px;
          height: 14px;
          background: rgba(22, 161, 255, 1);
        }
      }

      .model-item {
        display: grid;
        grid-template-columns: 1fr 1fr 1fr;
        grid-gap: 10px;
        padding-left: 3px;
        margin-bottom: 10px;

        .son-item {
          position: relative;
          cursor: pointer;

          div {
            position: absolute;
            bottom: 0;
            width: 100%;
            height: 20px;
            background: rgba(0, 0, 0, 0.4);
            text-align: center;
            line-height: 20px;
            font-size: 12px;
            color: #fff;
          }

          img {
            display: block;
            max-width: 100%;
          }
        }
      }

      .model-item-btn {
        display: grid;
        grid-template-columns: repeat(5, 1fr);
        grid-gap: 10px;
        padding-left: 5px;
        margin-bottom: 10px;

        button {
          width: 50px;
          margin-left: 0;
        }
      }
    }

    .operate {
      display: flex;
      justify-content: flex-end;
      position: absolute;
      left: 0;
      bottom: 8px;
      width: 100%;

      .el-button {
        width: 70px;
        margin: 0 7px 0 3px;
      }

    }
  }

  .edit-area {
    position: fixed;
    top: 450px;
    left: 20px;
    padding: 10px;
    width: 220px;
    height: 300px;
    border-radius: 4px;
    background: rgba(0, 0, 0, 0.6);
    overflow-y: auto;
    z-index: 2;

    .head_title_arrow {
      font-family: YouSheBiaoTiHei;
      font-size: 16px;
    }

    .content {
      height: calc(100% - 25px);
      overflow-y: auto;
    }
  }

  .property-editing-area {
    position: fixed;
    top: 100px;
    left: 280px;
    padding: 10px;
    width: 260px;
    height: 300px;
    border-radius: 4px;
    background: rgba(0, 0, 0, 0.6);
    overflow-y: auto;
    overflow-x: hidden;
    z-index: 2;

    .top-area {
      display: flex;
      justify-content: space-between;
      align-items: center;

      .head_title_arrow {
        font-family: YouSheBiaoTiHei;
        font-size: 16px;
        margin-bottom: 5px;
      }
    }

  }
}

:deep(.el-form) {
  height: calc(100% - 50px);
  overflow-y: auto;

  .el-form-item--default {
    margin-bottom: 0;
  }

  .el-form-item {
    --el-form-label-font-size: 12px;
  }
}
</style>


致谢

期间收到了粉丝(肆云)的奶茶和KFC❣️,这真是太贴心了,非常感谢支持和鼓励!感谢你们的每一次点击、每一条留言,甚至每一份小小的礼物,都是我不断前行的动力!🍀

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柳晓黑胡椒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值