引子

信息弹框种类有很多,今天我们要说的是那种可以钉在地图上的信息框,它具备一个地图坐标,可以跟随地图移动,超出地图范围会被隐藏,让人感觉它是地图场景中的一部分。不过它还不是真正的地图元素,它还只是个网页元素而已,也就是说它始终是朝向屏幕平面的,而不是那种三维广告板的效果,那种效果或许后续会做吧。

预期效果

这个效果其实是动态的,从底部到顶部逐渐显现,不过GIF图比较大就没上传了,看看最终的效果吧。

实现原理

原理真的很简单,一句话可以描述,就是实时同步笛卡尔坐标(地图坐标)和画布(canvas)坐标,让网页元素始终保持在地图坐标的某个点上,其他的操作都是HTML+CSS的基本操作了,来看具体的操作吧。

具体实现

代码不多,我就直接给出完整的封装了,不过要注意一下,我使用的是ES6封装的,而且其中使用了某些新特性,比如私有变量,最好配合eslint转码,或者自行修改变量名称吧。另外Cesium不是全局引用,而是在模块中分别引用的,引用方式不同的小伙伴请自行添加Cesium前缀。

 

// InfoTool.js

// ====================
// 引入模块
// ====================
import Viewer from "cesium/Source/Widgets/Viewer/Viewer.js";
import CesiumMath from "cesium/Source/Core/Math.js";
import Cesium3DTileFeature from "cesium/Source/Scene/Cesium3DTileFeature.js";
import Cartesian2 from "cesium/Source/Core/Cartesian2.js";
import Cartesian3 from "cesium/Source/Core/Cartesian3.js";
import Cartographic from "cesium/Source/Core/Cartographic.js";
import SceneTransforms from "cesium/Source/Scene/SceneTransforms.js";
import defined from "cesium/Source/Core/defined.js";
import './info.css';

// ====================
// 类
// ====================
/**
 * 信息工具。
 *
 * @author Helsing
 * @date 2019/12/22
 * @alias InfoTool
 * @constructor
 * @param {Viewer} viewer Cesium视窗。
 */
class InfoTool {
    /**
     * 创建一个动态实体弹窗。
     *
     * @param {Viewer} viewer Cesium视窗。
     * @param {Number} options 选项。
     * @param {Cartesian3} options.position 弹出位置。
     * @param {HTMLElement} options.element 弹出窗元素容器。
     * @param {Function} callback 回调函数。
     * @ignore
     */
    static #createInfoTool(viewer, options, callback = undefined) {
        const cartographic = Cartographic.fromCartesian(options.position);
        const lon = CesiumMath.toDegrees(cartographic.longitude); //.toFixed(5);
        const lat = CesiumMath.toDegrees(cartographic.latitude); //.toFixed(5);

        // 注意,这里不能使用hide()或者display,会导致元素一直重绘。
        util.setCss(options.element, "opacity", "0");
        util.setCss(options.element.querySelector("div:nth-child(1)"), "height", "0");
        util.setCss(options.element.querySelector("div:nth-child(2)"), "opacity", "0");

        // 回调
        callback();

        // 添加div弹窗
        setTimeout(function () {
            InfoTool.#popup(viewer, options.element, lon, lat, cartographic.height)
        }, 100);
    }
    /**
     * 弹出HTML元素弹窗。
     *
     * @param {Viewer} viewer Cesium视窗。
     * @param {Element|HTMLElement} element 弹窗元素。
     * @param {Number} lon 经度。
     * @param {Number} lat 纬度。
     * @param {Number} height 高度。
     * @ignore
     */
    static #popup(viewer, element, lon, lat, height) {
        setTimeout(function () {
            // 设置元素效果
            util.setCss(element, "opacity", "1");
            util.setCss(element.querySelector("div:nth-child(1)"), "transition", "ease 1s");
            util.setCss(element.querySelector("div:nth-child(2)"), "transition", "opacity 1s");
            util.setCss(element.querySelector("div:nth-child(1)"), "height", "80px");
            util.setCss(element.querySelector("div:nth-child(2)"), "pointer-events", "auto");
            window.setTimeout(function () {
                util.setCss(element.querySelector("div:nth-child(2)"), "opacity", "1");
            }, 500);
        }, 100);
        const divPosition = Cartesian3.fromDegrees(lon, lat, height);
        InfoTool.#hookToGlobe(viewer, element, divPosition, [10, -(parseInt(util.getCss(element, "height")))], true);
        viewer.scene.requestRender();
    }
    /**
     * 将HTML弹窗挂接到地球上。
     *
     * @param {Viewer} viewer Cesium视窗。
     * @param {Element} element 弹窗元素。
     * @param {Cartesian3} position 地图坐标点。
     * @param {Array} offset 偏移。
     * @param {Boolean} hideOnBehindGlobe 当元素在地球背面会自动隐藏,以减轻判断计算压力。
     * @ignore
     */
    static #hookToGlobe(viewer, element, position, offset, hideOnBehindGlobe) {
        const scene = viewer.scene, camera = viewer.camera;
        const cartesian2 = new Cartesian2();
        scene.preRender.addEventListener(function () {
            const canvasPosition = scene.cartesianToCanvasCoordinates(position, cartesian2); // 笛卡尔坐标到画布坐标
            if (defined(canvasPosition)) {
                util.setCss(element, "left", parseInt(canvasPosition.x + offset[0]) + "px");
                util.setCss(element, "top", parseInt(canvasPosition.y + offset[1]) + "px");

                // 是否在地球背面隐藏
                if (hideOnBehindGlobe) {
                    const cameraPosition = camera.position;
                    let height = scene.globe.ellipsoid.cartesianToCartographic(cameraPosition).height;
                    height += scene.globe.ellipsoid.maximumRadius;
                    if (!(Cartesian3.distance(cameraPosition, position) > height)) {
                        util.setCss(element, "display", "flex");
                    } else {
                        util.setCss(element, "display", "none");
                    }
                }
            }
        });
    }

    #element;
    viewer;

    constructor(viewer) {
        this.viewer = viewer;

        // 在Cesium容器中添加元素
        this.#element = document.createElement("div");
        this.#element.id = "infoTool_" + util.getGuid(true);
        this.#element.name = "infoTool";
        this.#element.classList.add("helsing-three-plugins-infotool");
        this.#element.appendChild(document.createElement("div"));
        this.#element.appendChild(document.createElement("div"));
        viewer.container.appendChild(this.#element);
    }

    /**
     * 添加。
     *
     * @author Helsing
     * @date 2019/12/22
     * @param {Object} options 选项。
     * @param {Element} options.element 弹窗元素。
     * @param {Cartesian2|Cartesian3} options.position 点击位置。
     * @param {Cesium3DTileFeature} [options.inputFeature] 模型要素。
     * @param {String} options.type 类型(默认值为default,即任意点击模式;如果设置为info,即信息模式,只有点击Feature才会响应)。
     * @param {String} options.content 内容(只有类型为default时才起作用)。
     * @param {Function} callback 回调函数。
     */
    add(options, callback = undefined) {
        // 判断参数为空返回
        if (!options) {
            return;
        }
        // 点
        let position, cartesian2d, cartesian3d, inputFeature;
        if (options instanceof Cesium3DTileFeature) {
            inputFeature = options;
            options = {};
        } else {
            if (options instanceof Cartesian2 || options instanceof Cartesian3) {
                position = options;
                options = {};
            } else {
                position = options.position;
                inputFeature = options.inputFeature;
            }
            // 判断点位为空返回
            if (!position) {
                return;
            }
            if (position instanceof Cartesian2) { // 二维转三维
                // 如果支持拾取模型则取模型值
                cartesian3d = (this.viewer.scene.pickPositionSupported && defined(this.viewer.scene.pick(options.position))) ?
                    this.viewer.scene.pickPosition(position) : this.viewer.camera.pickEllipsoid(position, this.viewer.scene.globe.ellipsoid);
                cartesian2d = position;
            } else {
                cartesian3d = position;
                cartesian2d = SceneTransforms.wgs84ToWindowCoordinates(this.viewer.scene, cartesian3d);
            }
            // 判断点位为空返回
            if (!cartesian3d) {
                return;
            }
        }

        const that = this;

        // 1.组织信息
        let info = '';
            if (options.type === "info") {
            // 拾取要素
            const feature = inputFeature || this.viewer.scene.pick(cartesian2d);
            // 判断拾取要素为空返回
            if (!defined(feature)) {
                this.remove();
                return;
            }

            if (feature instanceof Cesium3DTileFeature) { // 3dtiles
                let propertyNames = feature.getPropertyNames();
                let length = propertyNames.length;
                for (let i = 0; i < length; ++i) {
                    let propertyName = propertyNames[i];
                    info += '"' + (propertyName + '": "' + feature.getProperty(propertyName)) + '",\n';
                }
            } else if (feature.id) { // Entity
                const properties = feature.id.properties;
                if (properties) {
                    let propertyNames = properties._propertyNames;
                    let length = propertyNames.length;
                    for (let i = 0; i < length; ++i) {
                        let propertyName = propertyNames[i];
                        //console.log(propertyName + ': ' + properties[propertyName]._value);
                        info += '"' + (propertyName + '": "' + properties[propertyName]._value) + '",\n';
                    }
                }
            }
        } else {
            options.content && (info = options.content);
        }

        // 2.生成特效
        // 添加之前先移除
        this.remove();

        if (!info) {
            return;
        }

        options.position = cartesian3d;
        options.element = options.element || this.#element;

        InfoTool.#createInfoTool(this.viewer, options, function () {
            util.setInnerText(that.#element.querySelector("div:nth-child(2)"), info);
            typeof callback === "function" && callback();
        });
    }

    /**
     * 移除。
     *
     * @author Helsing
     * @date 2020/1/18
     */
    remove(entityId = undefined) {
        util.setCss(this.#element, "opacity", "0");
        util.setCss(this.#element.querySelector("div:nth-child(1)"), "transition", "");
        util.setCss(this.#element.querySelector("div:nth-child(2)"), "transition", "");
        util.setCss(this.#element.querySelector("div:nth-child(1)"), "height", "0");
        util.setCss(this.#element.querySelector("div:nth-child(2)"), "pointer-events", "none");
    };
}

export default InfoTool;

上述代码中用到了util.setCss等函数,是自己封装的,小伙伴们可以自己实现也可以用我的。

/**
 * 设置CSS。
 *
 * @author Helsing
 * @date 2019/11/12
 * @param {Element|HTMLElement|String} srcNodeRef 元素ID、元素或数组。
 * @param {String} property 属性。
 * @param {String} value 值。
 */
setCss: function (srcNodeRef, property, value) {
    if (srcNodeRef) {
        if (srcNodeRef instanceof Array && srcNodeRef.length > 0) {
            for (let i = 0; i < srcNodeRef.length; i++) {
                srcNodeRef[i].style.setProperty(property, value);
            }
        } else if (typeof (srcNodeRef) === "string") {
            if (srcNodeRef.indexOf("#") < 0 && srcNodeRef.indexOf(".") < 0 && srcNodeRef.indexOf(" ") < 0) {
                const element = document.getElementById(srcNodeRef);
                element && (element.style.setProperty(property, value));
            } else {
                const elements = document.querySelectorAll(srcNodeRef);
                for (let i = 0; i < elements.length; i++) {
                    elements[i].style.setProperty(property, value);
                }
            }
        } else if (srcNodeRef instanceof HTMLElement) {
            srcNodeRef.style.setProperty(property, value);
        }
    }
},

/**
 * 设置元素的值。
 *
 * @author Helsing
 * @date 2019/11/12
 * @param {String|HTMLElement|Array} srcNodeRef 元素ID、元素或数组。
 * @param {String} value 值。
 */
setInnerText: function (srcNodeRef, value) {
    if (srcNodeRef) {
        if (srcNodeRef instanceof Array && srcNodeRef.length > 0) {
            const that = this;
            for (let i = 0; i < srcNodeRef.length; i++) {
                let element = srcNodeRef[i];
                if (that.isElement(element)) {
                    element.innerText = value;
                }
            }
        } else if (typeof (srcNodeRef) === "string") {
            if (srcNodeRef.indexOf("#") < 0 && srcNodeRef.indexOf(".") < 0 && srcNodeRef.indexOf(" ") < 0) {
                let element = document.getElementById(srcNodeRef);
                element && (element.innerText = value);
            } else {
                const elements = document.querySelectorAll(srcNodeRef);
                for (let i = 0; i < elements.length; i++) {
                    elements[i].innerText = value;
                }
            }
        } else {
            if (this.isElement(srcNodeRef)) {
                srcNodeRef.innerText = value;
            }
        }
    }
},

/**
 * 判断对象是否为元素。
 *
 * @author Helsing
 * @date 2019/12/24
 * @param {Object} obj 对象。
 * @returns {Boolean} 是或否。
 */
isElement: function (obj) {
    return (typeof HTMLElement === 'object')
        ? (obj instanceof HTMLElement)
        : !!(obj && typeof obj === 'object' && (obj.nodeType === 1 || obj.nodeType === 9) && typeof obj.nodeName === 'string');
},

/**
 * 获取全球唯一ID。
 *
 * @author Helsing
 * @date 2019/11/21
 * @param {Boolean} removeMinus 是否去除“-”号。
 * @returns {String} GUID。
 */
getGuid: function (removeMinus) {
    let d = new Date().getTime();
    let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
        const r = (d + Math.random() * 16) % 16 | 0;
        d = Math.floor(d / 16);
        return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
    });
    if (removeMinus) {
        uuid = uuid.replace(/-/g, "");
    }
    return uuid;
}

另外给出css样式

.helsing-three-plugins-infotool { display: none; flex-direction: column-reverse; position: fixed; top: 0; left: 0;min-width: 100px; height: 250px; user-select: none; pointer-events: none; }
    .helsing-three-plugins-infotool > div:nth-child(1) { left: 0; width: 40px; height: 0; bottom: 0; background: url("popup_line.png") no-repeat center 100%; }
    .helsing-three-plugins-infotool > div:nth-child(2) { opacity: 0; box-shadow: 0 0 8px 0 rgba(0, 170, 255, .6) inset; padding: 20px; user-select: text; pointer-events: auto; }

上述代码很简单,虽然注释不多,但我相信小伙伴们一眼就能懂了,这里只讲两个关键的地方。

第一个地方,hookToGlobe方法,这也是全篇最重要的一个点了。Cesium和网页元素是两个不相干的东西,它们的唯一纽带就是Canvas,因为Canvas也是网页元素,所以同步div和Canvas的坐标位置即可实现弹窗钉在地图上,而且这个同步是要实时的,这就须要不断的刷新,我们使用Cesium的preRender事件来实现。cartesianToCanvasCoordinates将地图笛卡尔坐标转换为画布坐标,然后设置div的top和left样式,即完成了坐标位置实时同步工作。

第二个地方,add方法。现在弹窗已经有了,那么里面的信息如何获取呢,有一点基础的童鞋都知道要使用pick,pick之后会返回一个Feature对象,这个对象里面包含着属性信息,这里要区分一下模型和实体,它们的获取方法不同,模型使用feature.getProperty方法获取,实体使用feature.id.properties[propertyName]._value属性值获取。最后遍历一下字段名称和属性值,组织成json格式的数据呈现,或者可以使用表格控件来呈现。

小结

这是一个没什么难度但很实用的功能,而且样式可以随意定制,只要你懂css就行,比Cesium自带的信息弹框好灵活多了吧。不出意外的话,下一篇会更新模型压平,说实话现在还没开始研究呢,等着我现学现卖吧,希望别打脸。

PS

想要了解更多更好玩的东西就到群854943530来吧,这里是没有任何商业气息的纯技术分享群,队伍不断壮大中,期待你的加入。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐