摆脱枯燥的二维界面,用cesium+vue3动态渲染一个3D弹窗

4,497 阅读7分钟

大家好,我是日拱一卒的攻城师不浪,专注可视化、数字孪生、前端、nodejs、AI学习、GIS等学习沉淀,这是2024年输出的第17/100篇文章;

小散开胃

作为一个7年多的老前端,对开发二维静态页面早已倦怠,每天面对枯燥的增删改查,未来的路不知该何去何从...

然而,一次工作的变换,让我接触到了有意思的3D开发,从此,我的生活里又有了光,充满了希望,每天都是正能量满满!

前言

好了,言归正传,今天我将给大家带来一个3D弹窗从零到一的组件封装教程,使用框架:cesiumjs + vue3,并结合vue3的createApp动态渲染。

3D弹窗.gif

打点BillboardCollection

常见的业务场景,一般是现在三维场景中进行撒点,然后当点击每个点位的时候,再弹一个3D弹窗展示该点位的一些重要信息。

cesium中提供了BillboardCollection大类,官方叫广告牌集合

API一览直达:cesium.xin/cesium/cn/D…

其实就是我们常说的POI点位标志,所以我们先进行打点:

const store = useStore();
// viewer就是cesium实例化之后的场景示例,我把他存在了vuex的store中
const { viewer } = store.state;
// 先把广告牌实例化,然后再添加到场景中
const billboardsCollection = viewer.scene.primitives.add(
  new Cesium.BillboardCollection()
);
// 点位特性信息集合
let pointFeatures = [];
// 先获取点位的json信息
const getJson = () => {
  getGeojson("/json/chuzhong.geojson").then(({ res }) => {
    const { features } = res;
    pointFeatures = features;
    formatData(features);
  });
};

const formatData = (features) => {
  for (let i = 0; i < features.length; i++) {
    const feature = features[i];
    // 每个点位的坐标
    const coordinates = feature.geometry.coordinates;
    // 将坐标处理成3D笛卡尔点
    const position = Cesium.Cartesian3.fromDegrees(
      coordinates[0],
      coordinates[1],
      1000
    );
    const name = feature.properties.name;
    // 带图片的点
    billboardsCollection._id = `mark`;
    // add的是Billboard,将一个个Billboard添加到集合当中
    billboardsCollection.add({
      image: "/images/mark-icon.png",
      width: 32,
      height: 32,
      position,
    });
  }
};
// 执行打点
getJson()

概念解释

Cartesian3: 在Cesium中,Cartesian3是一个用于表示三维空间中点或向量的类。它主要用于处理地理空间应用中的各种计算和操作,每个点有三个坐标值:x、y、z。这些坐标通常用于表示地球表面上的位置或空间中的任意点

OK,这样,点位就在我们的场景中撒好了。

POI点位事件交互

弹窗需要点击点位弹出,所以,我们来给点位添加事件交互,这就用到了Cesium中的ScreenSpaceEventHandler:处理用户输入事件

API一览直达:cesium.xin/cesium/cn/D…

const scene = viewer.scene;
// ScreenSpaceEventHandler的参数是要添加事件的元素,直接给整个画布添加
const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas);
handler.setInputAction((e) => {
  // 获取点击的实体
  const pick = scene.pick(e.position);
  // 判断点击的是不是点位
  if (Cesium.defined(pick) && pick.collection._id.indexOf("mark") > -1) {
    // ...
  }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK); // 监听屏幕鼠标左键点击

事件添加好了,还缺弹窗。

封装3D弹窗组件

这里我们把弹窗封装成了一个Dialog大类,方便在cesium的多个场景中共用,并且运用了vue3的元素动态创建能力,用到了vue3的createApph这两个方法,不了解的可以先去了解下。

弹窗js逻辑

代码中的注释已经写的很详细了,需要注意的是这个3D弹窗最终是要和cesium场景结合起来的,因为它需要跟随场景移动而跟随变换位置,所以需要接收viwer场景参数。

import * as Cesium from "cesium";
import { createApp, h } from "vue";
import Popup from "@/components/Dialog/Popup.vue";

export default class Dialog {
  constructor(opts) {
    const { viewer, position, ...rest } = opts;
    this.viewer = viewer;
    // 点位的空间位置信息
    this.position = position._value;
    const { vmInstance } = createDialog({
      ...rest, // 主要是弹窗内容
      closeEvent: this.windowClose.bind(this),
    });
    if (this.vmInstance) {
      this.windowClose.bind(this);
    } else {
      this.vmInstance = vmInstance;
    }
    // 将弹窗元素添加到渲染cesium的容器中
    viewer.cesiumWidget.container.appendChild(vmInstance.$el);
    this.addPostRender();
  }
  //添加场景事件
  addPostRender() {
    this.viewer.scene.postRender.addEventListener(this.postRender, this);
  }
  postRender() {
    if (!this.vmInstance.$el || !this.vmInstance.$el.style) return;
    // 画布高度
    const canvasHeight = this.viewer.scene.canvas.height;
    // 实例化屏幕坐标
    const windowPosition = new Cesium.Cartesian2();
    // 将WGS84 经纬度坐标转换成屏幕坐标,这通常用于将 HTML 元素放置在与场景中的对象相同的屏幕位置。
    Cesium.SceneTransforms.wgs84ToWindowCoordinates(
      this.viewer.scene,
      this.position,
      windowPosition
    );
    // 调整弹窗的位置
    this.vmInstance.$el.style.bottom =
      canvasHeight - windowPosition.y + 260 + "px";
    const elWidth = this.vmInstance.$el.offsetWidth;
    this.vmInstance.$el.style.left =
      windowPosition.x - elWidth / 2 + 110 + "px";

    const camerPosition = this.viewer.camera.position;
    // 控制边界值
    let height =
      this.viewer.scene.globe.ellipsoid.cartesianToCartographic(
        camerPosition
      ).height;
    height += this.viewer.scene.globe.ellipsoid.maximumRadius;
    if (
      !(Cesium.Cartesian3.distance(camerPosition, this.position) > height) &&
      this.viewer.camera.positionCartographic.height < 50000000
    ) {
      this.vmInstance.$el.style.display = "block";
    } else {
      this.vmInstance.$el.style.display = "none";
    }
  }
  //关闭弹窗
  windowClose() {
    if (this.vmInstance) {
      this.vmInstance.$el.remove();
    }
    this.viewer.scene.postRender.removeEventListener(this.postRender, this); //移除事件监听
  }
}

let parentNode = null;
/**
 * @description: 渲染弹窗组件并插入div中
 * @param {*} opts 弹窗内容
 * @return {*}
 */
const createDialog = (opts) => {
  if (parentNode) {
    document.body.removeChild(parentNode);
    parentNode = null;
  }
  const app = createApp({
    render() {
      return h(
        Popup,
        {
          ...opts,
        },
        {
          title: () => opts.slotTitle,
          content: () => opts.slotContent,
        }
      );
    },
  });

  parentNode = document.createElement("div");
  // 将Popup组件挂载到父级div中,生成弹窗实例
  const instance = app.mount(parentNode);
  document.body.appendChild(parentNode);

  return {
    vmInstance: instance,
  };
};

弹窗模板

接下来我们看下Popup.vue的实现,我们把它封装成公共弹窗样式组件,并给它添加一些酷炫的特效。

<script setup>
import { ref } from 'vue'

const props = defineProps({
  title: {
    type: String,
    default: "标题"
  },
  content: {
    type: String,
    default: "内容"
  },
  closeEvent: Function
})
const closeClick = () => {
  props?.closeEvent()
}
</script>
<template>
  <div class="popup-container">
    <div class="pine"></div>
    <div class="box-wrap">
      <div class="close" @click="closeClick">X</div>
      <div class="area">
        <div class="area-title fontColor">
          <template v-if="props.title">{{ props.title }}</template>
          <slot v-else name="title"></slot>
        </div>
      </div>
      <div class="content">
        <div class="data-li">
          <div class="data-label textColor">地址:</div>
          <div class="data-value">
            <span class="label-num yellowColor">
              <template v-if="props.content">{{ props.content }}</template>
              <slot v-else name="content"></slot>
            </span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<style lang='less' scoped>
.popup-container {
  width: 200px;
  position: relative;
  bottom: 0;
  left: 0;
}

.close {
  position: absolute;
  color: #fff;
  top: 1px;
  right: 10px;
  text-shadow: 2px 2px 2px #022122;
  cursor: pointer;
  animation: fontColor 1s;
}

.box-wrap {
  position: absolute;
  left: 21%;
  top: 0;
  width: 100%;
  height: 163px;
  border-radius: 50px 0px 50px 0px;
  border: 1px solid #38e1ff;
  background-color: #38e1ff4a;
  box-shadow: 0 0 10px 2px #29baf1;
  animation: slide 2s;
}

.box-wrap .area {
  position: absolute;
  top: 20px;
  right: 0;
  width: 95%;
  height: 30px;
  background-image: linear-gradient(to left, #4cdef9, #4cdef96b);
  border-radius: 30px 0px 0px 0px;
  animation: area 1s;
}

.pine {
  position: absolute;
  width: 100px;
  height: 100px;
  box-sizing: border-box;
  line-height: 120px;
  text-indent: 5px;
}

.pine::before {
  content: "";
  position: absolute;
  left: 0;
  bottom: -83px;
  width: 40%;
  height: 60px;
  box-sizing: border-box;
  border-bottom: 1px solid #38e1ff;
  transform-origin: bottom center;
  transform: rotateZ(135deg) scale(1.5);
  animation: slash 0.5s;
  filter: drop-shadow(1px 0px 2px #03abb4);
}

.area .area-title {
  text-align: center;
  line-height: 30px;
}

.textColor {
  font-size: 14px;
  font-weight: 600;
  color: #ffffff;
  text-shadow: 1px 1px 5px #002520d2;
  animation: fontColor 1s;
}

.yellowColor {
  font-size: 14px;
  font-weight: 600;
  color: #f09e28;
  text-shadow: 1px 1px 5px #002520d2;
  animation: fontColor 1s;
}

.fontColor {
  font-size: 16px;
  font-weight: 800;
  color: #ffffff;
  text-shadow: 1px 1px 5px #002520d2;
  animation: fontColor 1s;
}

.content {
  padding: 55px 10px 10px 10px;
}

.content .data-li {
  display: flex;
}

@keyframes fontColor {
  0% {
    color: #ffffff00;
    text-shadow: 1px 1px 5px #00252000;
  }

  40% {
    color: #ffffff00;
    text-shadow: 1px 1px 5px #00252000;
  }

  100% {
    color: #ffffff;
    text-shadow: 1px 1px 5px #002520d2;
  }
}

@keyframes slide {
  0% {
    border: 1px solid #38e1ff00;
    background-color: #38e1ff00;
    box-shadow: 0 0 10px 2px #29baf100;
  }

  100% {
    border: 1px solid #38e1ff;
    background-color: #38e1ff4a;
    box-shadow: 0 0 10px 2px #29baf1;
  }
}

@keyframes area {
  0% {
    width: 0%;
  }

  25% {
    width: 0%;
  }

  100% {
    width: 95%;
  }
}

@keyframes slash {
  0% {
    transform: rotateZ(135deg) scale(0);
  }

  100% {
    transform: rotateZ(135deg) scale(1.5);
  }
}
</style>

弹窗使用

OK,弹窗已经封装好了,我们看下如何使用。

刚刚我们添加了handler.setInputAction事件交互方法,我们接着来。

import Dialog from "@/utils/cesiumCtrl/dialog";

// 首先需要定义弹窗实例
const dialogs = ref();

handler.setInputAction((e) => {
  // 省略...
  if (Cesium.defined(pick) && pick.collection._id.indexOf("mark") > -1) {
    // 拿到点位的属性信息
    const property = pointFeatures[pick.primitive._index];
    // 弹窗所需的参数
    const opts = {
      viewer, // cesium的场景
      position: {
        _value: pick.primitive.position,
      },
      title: property.properties.name, // 弹窗标题
      content: property.properties.address, // 弹窗内容
    };
    if (dialogs.value) {
      // 只允许一个弹窗出现
      dialogs.value.windowClose();
    }
    // 实例化弹窗
    dialogs.value = new Dialog(opts);
  }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);

最后

OK,完事了,这样一个栩栩如生的3D弹窗就开发完成了,简单总结下:

  • BillboardCollection打POI点位;
  • 创建cesium中的事件交互ScreenSpaceEventHandler,监听点位点击;
  • 创建Dialog弹窗大类,支持弹窗动态创建,并跟随viewer场景移动,弹窗模板单独创建;

【开源地址】:github.com/tingyuxuan2…

有需要进技术产品开发交流群(可视化&GIS)可以加我:brown_7778,也欢迎数字孪生可视化领域的交流合作。

【往期推荐】

可视化大屏开发,知道了这些经验以及解决方案,效率至少提升2倍!(上)
# 可视化大屏开发,知道这些解决方案,效率至少提升2倍!(中)
# 可视化大屏开发,知道了这些经验以及解决方案,效率至少提升2倍!(完结篇)
前端开发不会写动画?分享4个动画库助你打造视觉盛宴 # threejs实战数字孪生园区开源(threejs+vue3+vite)

最后,如果觉得文章对你有帮助,也希望可以一键三连👏👏👏,你的鼓励是支持我持续分享下去的动力~