Cesium+Vue3实现可跟踪的点位详情弹窗

4,703 阅读7分钟

本文过时了!欢迎阅读我的另一篇文章《全网最全的Cesium跟随弹窗的全框架实现剖析》

导读

我们在cesium里经常碰到点位查询这样的一个需求,点击点位弹出一个弹窗。但当我们移动地图时,点位已经不再屏幕点击时的位置,而弹窗还继续在该点位上并不随着地图的拖拽而移动。我们该怎么办呢?

期望的效果最终是这样的:

跟踪对话框.gif

基本实现

更新弹窗位置

通常,要实现上述的跟踪效果,我们通常配合Cesium中的Scene.postRender事件实现的,其含义就是场景渲染之后执行的事件。如要深入了解,请参考我之前的文章cesium的场景渲染事件。在Cesium每次场景渲染后都顺带更新弹窗(DOM)的位置,也就是说我们必须实时地拿到点位随着地图拖拽在屏幕中的新位置,相应地重新计算弹窗的位置。其核心代码如下:

const cart3 =Cesium.Cartesian3.fromDegrees(
     popPoint.x,
     popPoint.y,
     popPoint.height??0
 )
​
    let screenPoint = popPoint;
    viewer.movePositionEventListener=function() {
        //wgs64 coordinate to screen coordinate(Cartesian2)
        let screen =Cesium.SceneTransforms.wgs84ToWindowCoordinates(
            viewer.scene,
            cart3
        );
        if(screenPoint){
            //todo:if the position change, set the popup position!
            if(screenPoint.x !== screen.x || screenPoint.y !== screen.y){
                setPopupPosition(screen,el);
            }
        }
    }
    viewer.scene.postRender.addEventListener(viewer.movePositionEventListener)

其中 popPoint为初始获取点位的经纬度、高程坐标,在每次场景重新渲染后我们首先使用Cesium.SceneTransforms.wgs84ToWindowCoordinates拿到这个点位对应的新的屏幕坐标,然后重新更新弹窗DOM的位置,setPopupPosition(screen,el)就是传入新的位置信息和弹窗的Root DOM。

获取点位坐标信息

而要获取点位点击,并获取对应的经纬度我们可以通过CesiumScreenSpaceEventHandler设置句柄添加setInputAction设置ScreenSpaceEventType.LEFT_CLICK鼠标单击事件来获取。参考代码如下:

export function clickInit(viewer:Cesium.Viewer,callback:(...args:any[])=>void){
    handler= new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
​
    handler.setInputAction((click:any)=>{
        //click.position 获取的是屏幕坐标
        // console.log('click',click)
        let cartesian = viewer.scene.pickPosition(click.position);
        let pickingEntity = viewer.scene.pick(click.position);   //获取三维坐标和点击的实体对象
        // console.log('entity===>',pickingEntity)
        let coord:any;
        //转经纬度坐标
        if (pickingEntity && pickingEntity.id && pickingEntity.id.position) {
            cartesian = pickingEntity.id.position._value;
            let cartographic = Cesium.Cartographic.fromCartesian(cartesian);
            let lontable = Number(
                Cesium.Math.toDegrees(cartographic.longitude).toFixed(7)
            );
            let lattable = Number(
                Cesium.Math.toDegrees(cartographic.latitude).toFixed(7)
            );
            let height = cartographic.height;
            coord = { lon: lontable, lat: lattable, height: height };
        } else {
            let cartographic = Cesium.Cartographic.fromCartesian(cartesian);
            let lontable =
                Cesium.Math.toDegrees(cartographic.longitude).toFixed(5) * 1;
            let lattable =
                Cesium.Math.toDegrees(cartographic.latitude).toFixed(5) * 1;
            let height = cartographic.height;
            coord = { lon: lontable, lat: lattable, height: height };
        }
        if (callback != undefined && typeof callback === "function") {
            callback(pickingEntity, coord);
        }
    },Cesium.ScreenSpaceEventType.LEFT_CLICK)
}

click.position接受的回调中的click为单击后选中的对象,其click.position属性为点击时屏幕坐标,通过viewer.scene.pickPosition方法可以转为三维坐标,再通过viewer.scene.pickPosition转为WGS84坐标(弧度制),并接受一个回调处理接下来的逻辑。

结合Vue实现

上节以及简要描述了如何实现弹窗的跟踪效果,根据步骤可以轻易地在纯js和css中简单实现,这里就不再赘述了,而是重点讲解如何在Vue中实现。

需求分析

在我们的项目,通常都会实现一些前端的框架VueReact中实现,而Cesium的这个跟踪弹窗还使用原生的HTML来写也 very timeout 了!我第一次接到需求时就马上考虑将其封装为一个全局通用的组件或者方法,并满足项目中的绝大多数的使用场景,列如有些点位需要简单地展示一些文字描述,有些甚至需要展示表格。

image.png

因此,我们的弹窗只要一层可以定位的DOM结构,指定一个内容插槽<slot name="content"></>,弹窗的内容由具体的内容组件去完成,我们只需要将内容组件渲染时需要的数据以props的形式传入即可。于是,该组件设计如下(Dialog.vue):

<template>
<!-- 考虑增加弹窗显示和隐藏动画或一些transition钩子 -->
    <transition name="Dialog">
        <div class="trackPopUp" v-if="visible">
            <!--这个div就是控制position的关键DOM -->
            <div ref="contentRef" class="trackPopUpContent">
                <!--右上方的关闭按钮 -->
                <div class="close" @mouseover="closeIcon = overIcon" @mouseleave="closeIcon = leaveIcon" @click="handleClose">
                    <img :src="closeIcon" alt="" />
                </div>
                <!--插槽 -->
                <div class="dialog">
                    <slot></slot>
                </div>
            </div>
        </div>
    </transition>
</template>
​
<script lang="tsx" setup>
import { computed, ref,PropType } from 'vue';
import overIcon from '@/assets/img/popWindow/icon_guanbi_click.png';
import leaveIcon from '@/assets/img/popWindow/icon_guanbi.png';
​
const contentRef=ref();
​
const props = defineProps({
    left: {
        type: Number,
        default: 10
    },
    top: {
        type: Number,
        default: 10
    },
    height: {
        type: Number
    },
    width: {
        type: Number
    },
    remove: {
        type: Function,
        default: () => { }
    },
    list:Object
});
​
const visible = ref(true);
const despData = ref(props.list);
​
const closeIcon = ref(leaveIcon);
​
function handleClose() {
    //关闭弹窗时附带补充一些逻辑,这些逻辑是外部传入的
    props?.remove?.();
    visible.value = false
};
​
function handleOpen() {
    visible.value = true
}
​
function handleUpdate(data) {
    despData.value = data
}
​
function setPosition(top:number,left:number){
    const css= `translate3d();`
}
​
const styleTop = computed(()=>props.top + 'px');
const styleLeft = computed(()=> props.left + 'px');
const styleWidth = computed(() => props.width ? props.width + 'px' : '');
const styleHeight = computed(() => props.height ? props.height + 'px' : '');
defineExpose({
    handleClose,
    handleOpen,
    handleUpdate,
    setPosition,
    visible
})
</script>
​
<style lang="scss" scoped>
.trackPopUp {
  width: 100%;
  height: 100%;
  color: #fff;
  user-select: none;
}
​
.trackPopUpContent {
  position: absolute;
  z-index: 9999;
  background: url("@/assets/img/popWindow/bg_tanchuang.png") no-repeat;
  background-size: cover;
​
  .close {
    position: absolute;
    right: 5px;
    top: 5px;
    cursor: pointer;
  }
​
  .title {
    background: url("@/assets/img/popWindow/title_qipao.png") no-repeat;
    display: flex;
​
    &>div {
      height: 20px;
      line-height: 20px;
      color: white;
      font-size: 18px;
      margin-left: 10px;
    }
  }
​
  top:v-bind(styleTop);
  left:v-bind(styleLeft);
  width:v-bind(styleWidth);
  height: v-bind(styleHeight);
}
​
.trackPopUpContent {
  .content {
    padding-top: 10px
  }
​
  .dialog {
    min-width: 200px;
    min-height: 50px;
    padding: 20px;
  }
}
​
:deep(.el-descriptions) {
  .el-descriptions__body {
    background-color: transparent;
  }
​
  .el-descriptions__label,
  .el-descriptions__content {
    color: #fff;
  }
​
  background-color: transparent;
}</style>
​

代码看着多,但只看template部分确实是外壳DOM(div)用于定位,里面添加插槽。

在JSX中调用vue组件

组件有了,我们是如何使用的呢?或者说,我们是如何做到全局调用的呢?这就不得不拿出大名鼎鼎的JSX了。

如果你还不知道JSX或者不知道如何在Vue中使用JSX,那你可以看看我之前的文章: vue3.0+vite项目中使用JSX语法

Vue3中,我们通过createApp创建一个Vue应用,其指定了一个根组件,并通过mount方法指定应用挂载的DOM,那么,我们也将我们的弹窗视为一个应用,上述设计的组件为根组件。有:

import { createApp } from "vue"
import Dialog from "./Dialog.vue"
const popupApp =createApp(Dialog)
popupApp.mount(el) //el为挂载的DOM

将其封装为一个通用的方法,我们就可以在上节的获取到点位坐标后,传入坐标信息,在js的代码里就能创建出我们的弹窗了。

示例demo代码是使用TypeScript写的:

function createSlotDialog(viewer:Viewer,options:any,template:Record<string, any>){
    if(popupApp){
        popupApp.unmount();
    }
    if(!template){
        template={}
    }
    const app = createApp({
        render(){
            // @ts-ignore
            return <Dialog
            remove={removeFn}
            left={options.left}
            top={options.top}
            height={options.height}
            width={options.width}
            >{template}</Dialog>
        }
    }) as PopApp;
    // if you want to use UI library like Element-Plus, you must register it like you did in the entry file!
    /**
     * app.use(Element-Plus)
     * // in main.js
     * import "element-plus/dist/index.css"
     */
    if(popupApp){
        popupApp.handleClose()
    }
    function removeFn(){
        app.unmount();
        clearListener(viewer as PopViewer)
    }
    (app as PopApp).handleClose=removeFn
    return app
}

通过createApp,我们将SFC渲染创建为一个Vue应用,并传入我们需要传递的props,而这些props就是要确定的初始弹窗的屏幕位置以及其长和高。对于PopApp,其为全局变量,目前考虑的是整个系统内点位查询只能同时出现一个弹窗,如果发现当前有别的弹窗,会将其直接销毁。而clearListener将从postRender中移除之前添加的监听事件。添加监听事件就一定要在相应的离开或者销毁时移除,这是一个良好地开发习惯。相关代码如下:

function clearListener(viewer:PopViewer){
    if(typeof viewer.movePositionEventListener === 'function'){
        viewer.scene.postRender.removeEventListener(
            viewer.movePositionEventListener
        );
    }
    viewer.movePositionEventListener=null
}

而我们在createSlotDialog中也看到我们的第三个参数template,其就是我们的插槽,我们将通过形如

{
    default:()=><contentCom><contentCom/>
}

的方式传入我们弹窗的内容组件(ps: Vue3插槽每个都是一个返回VNode的函数)。

最终的可用的全局方法如下:

export function createPopup(viewer:Viewer,position:WGS84_POSITION,options:any,template:Record<string, any>,offset:WGS84_POSITION={
    lon:0,
    lat:0
}){
    const pos=
    {
        x:position.lon + (offset.lon ?? 0),
        y:position.lat + (offset.lat ?? 0),
        height:(position.height ?? 0) + (offset.height ?? 0),
    };
    const popDiv = document.createElement("div");
    const widgetEl =viewer.cesiumWidget.container;
    widgetEl.appendChild(popDiv);
    clearListener(viewer as PopViewer)
    popupApp=createSlotDialog(viewer,options,template);
    const unmount = popupApp.unmount;
    popupApp.unmount=()=>{
        unmount();
        //do something else
        if(widgetEl.contains(popDiv)){
            popDiv.remove();
        }
    }
    popupApp.mount(popDiv);
    setListener(viewer as PopViewer,pos,popDiv)
}

可以看出,我们挂载的DOM是在Viewer实例中的cesiumWidget.container下新建的一个DIV,并重写了Vue3App实例的unmount方法,将在销毁应用时同时销毁该创建的DIV,避免出现多个DIV的情况。至此,完成了VueCesium实现的跟踪弹窗,其具体的调用代码如下:

import {createPopup} from "@/components/dialog"
import Content from "@/components/Content.vue"
......
 createPopup(
               viewer as Cesium.Viewer,
               coord,
               {top:0,left:135},
               {
                   default:()=>(<Content/>)
               }
           ) //coord即为获取到的点位坐标信息

封装好了后,是不是之后的使用就很方便简单了呢?只需要传递内容组件,你就能满足各式各样的点位查询的样式需求了,而且也不用再拼接原生的HTML字符串了。

总结

通过CesiumpostRender添加场景渲染后的事件监听,我们可以实现可跟踪的弹窗效果。在通过Vue3createApp和强大的JSX支持,我们可以直接在JS创建弹窗,并将弹窗的内部的内容和样式单独抽离只考虑弹窗的定位效果。

完整的示例代码放在github,详情自取。如有疑问,欢迎评论区讨论。