Cesium深入浅出之信息弹框

引子

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

预期效果

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

实现原理

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

具体实现

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

  1 // InfoTool.js
  2 
  3 // ====================
  4 // 引入模块
  5 // ====================
  6 import Viewer from "cesium/Source/Widgets/Viewer/Viewer.js";
  7 import CesiumMath from "cesium/Source/Core/Math.js";
  8 import Cesium3DTileFeature from "cesium/Source/Scene/Cesium3DTileFeature.js";
  9 import Cartesian2 from "cesium/Source/Core/Cartesian2.js";
 10 import Cartesian3 from "cesium/Source/Core/Cartesian3.js";
 11 import Cartographic from "cesium/Source/Core/Cartographic.js";
 12 import SceneTransforms from "cesium/Source/Scene/SceneTransforms.js";
 13 import defined from "cesium/Source/Core/defined.js";
 14 import './info.css';
 15 
 16 // ====================
 17 //
 18 // ====================
 19 /**
 20  * 信息工具。
 21  *
 22  * @author Helsing
 23  * @date 2019/12/22
 24  * @alias InfoTool
 25  * @constructor
 26  * @param {Viewer} viewer Cesium视窗。
 27  */
 28 class InfoTool {
 29     /**
 30      * 创建一个动态实体弹窗。
 31      *
 32      * @param {Viewer} viewer Cesium视窗。
 33      * @param {Number} options 选项。
 34      * @param {Cartesian3} options.position 弹出位置。
 35      * @param {HTMLElement} options.element 弹出窗元素容器。
 36      * @param {Function} callback 回调函数。
 37      * @ignore
 38      */
 39     static #createInfoTool(viewer, options, callback = undefined) {
 40         const cartographic = Cartographic.fromCartesian(options.position);
 41         const lon = CesiumMath.toDegrees(cartographic.longitude); //.toFixed(5);
 42         const lat = CesiumMath.toDegrees(cartographic.latitude); //.toFixed(5);
 43 
 44         // 注意,这里不能使用hide()或者display,会导致元素一直重绘。
 45         util.setCss(options.element, "opacity", "0"); 
 46         util.setCss(options.element.querySelector("div:nth-child(1)"), "height", "0");
 47         util.setCss(options.element.querySelector("div:nth-child(2)"), "opacity", "0");
 48 
 49         // 回调
 50         callback();
 51 
 52         // 添加div弹窗
 53         setTimeout(function () {
 54             InfoTool.#popup(viewer, options.element, lon, lat, cartographic.height)
 55         }, 100);
 56     }
 57     /**
 58      * 弹出HTML元素弹窗。
 59      *
 60      * @param {Viewer} viewer Cesium视窗。
 61      * @param {Element|HTMLElement} element 弹窗元素。
 62      * @param {Number} lon 经度。
 63      * @param {Number} lat 纬度。
 64      * @param {Number} height 高度。
 65      * @ignore
 66      */
 67     static #popup(viewer, element, lon, lat, height) {
 68         setTimeout(function () {
 69             // 设置元素效果
 70             util.setCss(element, "opacity", "1");
 71             util.setCss(element.querySelector("div:nth-child(1)"), "transition", "ease 1s");
 72             util.setCss(element.querySelector("div:nth-child(2)"), "transition", "opacity 1s");
 73             util.setCss(element.querySelector("div:nth-child(1)"), "height", "80px");
 74             util.setCss(element.querySelector("div:nth-child(2)"), "pointer-events", "auto");
 75             window.setTimeout(function () {
 76                 util.setCss(element.querySelector("div:nth-child(2)"), "opacity", "1");
 77             }, 500);
 78         }, 100);
 79         const divPosition = Cartesian3.fromDegrees(lon, lat, height);
 80         InfoTool.#hookToGlobe(viewer, element, divPosition, [10, -(parseInt(util.getCss(element, "height")))], true);
 81         viewer.scene.requestRender();
 82     }
 83     /**
 84      * 将HTML弹窗挂接到地球上。
 85      *
 86      * @param {Viewer} viewer Cesium视窗。
 87      * @param {Element} element 弹窗元素。
 88      * @param {Cartesian3} position 地图坐标点。
 89      * @param {Array} offset 偏移。
 90      * @param {Boolean} hideOnBehindGlobe 当元素在地球背面会自动隐藏,以减轻判断计算压力。
 91      * @ignore
 92      */
 93     static #hookToGlobe(viewer, element, position, offset, hideOnBehindGlobe) {
 94         const scene = viewer.scene, camera = viewer.camera;
 95         const cartesian2 = new Cartesian2();
 96         scene.preRender.addEventListener(function () {
 97             const canvasPosition = scene.cartesianToCanvasCoordinates(position, cartesian2); // 笛卡尔坐标到画布坐标
 98             if (defined(canvasPosition)) {
 99                 util.setCss(element, "left", parseInt(canvasPosition.x + offset[0]) + "px");
100                 util.setCss(element, "top", parseInt(canvasPosition.y + offset[1]) + "px");
101 
102                 // 是否在地球背面隐藏
103                 if (hideOnBehindGlobe) {
104                     const cameraPosition = camera.position;
105                     let height = scene.globe.ellipsoid.cartesianToCartographic(cameraPosition).height;
106                     height += scene.globe.ellipsoid.maximumRadius;
107                     if (!(Cartesian3.distance(cameraPosition, position) > height)) {
108                         util.setCss(element, "display", "flex");
109                     } else {
110                         util.setCss(element, "display", "none");
111                     }
112                 }
113             }
114         });
115     }
116 
117     #element;
118     viewer;
119 
120     constructor(viewer) {
121         this.viewer = viewer;
122 
123         // 在Cesium容器中添加元素
124         this.#element = document.createElement("div");
125         this.#element.id = "infoTool_" + util.getGuid(true);
126         this.#element.name = "infoTool";
127         this.#element.classList.add("helsing-three-plugins-infotool");
128         this.#element.appendChild(document.createElement("div"));
129         this.#element.appendChild(document.createElement("div"));
130         viewer.container.appendChild(this.#element);
131     }
132 
133     /**
134      * 添加。
135      *
136      * @author Helsing
137      * @date 2019/12/22
138      * @param {Object} options 选项。
139      * @param {Element} options.element 弹窗元素。
140      * @param {Cartesian2|Cartesian3} options.position 点击位置。
141      * @param {Cesium3DTileFeature} [options.inputFeature] 模型要素。
142      * @param {String} options.type 类型(默认值为default,即任意点击模式;如果设置为info,即信息模式,只有点击Feature才会响应)。
143      * @param {String} options.content 内容(只有类型为default时才起作用)。
144      * @param {Function} callback 回调函数。
145      */
146     add(options, callback = undefined) {
147         // 判断参数为空返回
148         if (!options) {
149             return;
150         }
151         //
152         let position, cartesian2d, cartesian3d, inputFeature;
153         if (options instanceof Cesium3DTileFeature) {
154             inputFeature = options;
155             options = {};
156         } else {
157             if (options instanceof Cartesian2 || options instanceof Cartesian3) {
158                 position = options;
159                 options = {};
160             } else {
161                 position = options.position;
162                 inputFeature = options.inputFeature;
163             }
164             // 判断点位为空返回
165             if (!position) {
166                 return;
167             }
168             if (position instanceof Cartesian2) { // 二维转三维
169                 // 如果支持拾取模型则取模型值
170                 cartesian3d = (this.viewer.scene.pickPositionSupported && defined(this.viewer.scene.pick(options.position))) ?
171                     this.viewer.scene.pickPosition(position) : this.viewer.camera.pickEllipsoid(position, this.viewer.scene.globe.ellipsoid);
172                 cartesian2d = position;
173             } else {
174                 cartesian3d = position;
175                 cartesian2d = SceneTransforms.wgs84ToWindowCoordinates(this.viewer.scene, cartesian3d);
176             }
177             // 判断点位为空返回
178             if (!cartesian3d) {
179                 return;
180             }
181         }
182 
183         const that = this;
184 
185         // 1.组织信息
186         let info = '';
187             if (options.type === "info") {
188             // 拾取要素
189             const feature = inputFeature || this.viewer.scene.pick(cartesian2d);
190             // 判断拾取要素为空返回
191             if (!defined(feature)) {
192                 this.remove();
193                 return;
194             }
195 
196             if (feature instanceof Cesium3DTileFeature) { // 3dtiles
197                 let propertyNames = feature.getPropertyNames();
198                 let length = propertyNames.length;
199                 for (let i = 0; i < length; ++i) {
200                     let propertyName = propertyNames[i];
201                     info += '"' + (propertyName + '": "' + feature.getProperty(propertyName)) + '",\n';
202                 }
203             } else if (feature.id) { // Entity
204                 const properties = feature.id.properties;
205                 if (properties) {
206                     let propertyNames = properties._propertyNames;
207                     let length = propertyNames.length;
208                     for (let i = 0; i < length; ++i) {
209                         let propertyName = propertyNames[i];
210                         //console.log(propertyName + ': ' + properties[propertyName]._value);
211                         info += '"' + (propertyName + '": "' + properties[propertyName]._value) + '",\n';
212                     }
213                 }
214             }
215         } else {
216             options.content && (info = options.content);
217         }
218 
219         // 2.生成特效
220         // 添加之前先移除
221         this.remove();
222 
223         if (!info) {
224             return;
225         }
226 
227         options.position = cartesian3d;
228         options.element = options.element || this.#element;
229 
230         InfoTool.#createInfoTool(this.viewer, options, function () {
231             util.setInnerText(that.#element.querySelector("div:nth-child(2)"), info);
232             typeof callback === "function" && callback();
233         });
234     }
235 
236     /**
237      * 移除。
238      *
239      * @author Helsing
240      * @date 2020/1/18
241      */
242     remove(entityId = undefined) {
243         util.setCss(this.#element, "opacity", "0");
244         util.setCss(this.#element.querySelector("div:nth-child(1)"), "transition", "");
245         util.setCss(this.#element.querySelector("div:nth-child(2)"), "transition", "");
246         util.setCss(this.#element.querySelector("div:nth-child(1)"), "height", "0");
247         util.setCss(this.#element.querySelector("div:nth-child(2)"), "pointer-events", "none");
248     };
249 }
250 
251 export default InfoTool;

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

  1 /**
  2  * 设置CSS。
  3  *
  4  * @author Helsing
  5  * @date 2019/11/12
  6  * @param {Element|HTMLElement|String} srcNodeRef 元素ID、元素或数组。
  7  * @param {String} property 属性。
  8  * @param {String} value 值。
  9  */
 10 setCss: function (srcNodeRef, property, value) {
 11     if (srcNodeRef) {
 12         if (srcNodeRef instanceof Array && srcNodeRef.length > 0) {
 13             for (let i = 0; i < srcNodeRef.length; i++) {
 14                 srcNodeRef[i].style.setProperty(property, value);
 15             }
 16         } else if (typeof (srcNodeRef) === "string") {
 17             if (srcNodeRef.indexOf("#") < 0 && srcNodeRef.indexOf(".") < 0 && srcNodeRef.indexOf(" ") < 0) {
 18                 const element = document.getElementById(srcNodeRef);
 19                 element && (element.style.setProperty(property, value));
 20             } else {
 21                 const elements = document.querySelectorAll(srcNodeRef);
 22                 for (let i = 0; i < elements.length; i++) {
 23                     elements[i].style.setProperty(property, value);
 24                 }
 25             }
 26         } else if (srcNodeRef instanceof HTMLElement) {
 27             srcNodeRef.style.setProperty(property, value);
 28         }
 29     }
 30 },
 31 
 32 /**
 33  * 设置元素的值。
 34  *
 35  * @author Helsing
 36  * @date 2019/11/12
 37  * @param {String|HTMLElement|Array} srcNodeRef 元素ID、元素或数组。
 38  * @param {String} value 值。
 39  */
 40 setInnerText: function (srcNodeRef, value) {
 41     if (srcNodeRef) {
 42         if (srcNodeRef instanceof Array && srcNodeRef.length > 0) {
 43             const that = this;
 44             for (let i = 0; i < srcNodeRef.length; i++) {
 45                 let element = srcNodeRef[i];
 46                 if (that.isElement(element)) {
 47                     element.innerText = value;
 48                 }
 49             }
 50         } else if (typeof (srcNodeRef) === "string") {
 51             if (srcNodeRef.indexOf("#") < 0 && srcNodeRef.indexOf(".") < 0 && srcNodeRef.indexOf(" ") < 0) {
 52                 let element = document.getElementById(srcNodeRef);
 53                 element && (element.innerText = value);
 54             } else {
 55                 const elements = document.querySelectorAll(srcNodeRef);
 56                 for (let i = 0; i < elements.length; i++) {
 57                     elements[i].innerText = value;
 58                 }
 59             }
 60         } else {
 61             if (this.isElement(srcNodeRef)) {
 62                 srcNodeRef.innerText = value;
 63             }
 64         }
 65     }
 66 },
 67 
 68 /**
 69  * 判断对象是否为元素。
 70  *
 71  * @author Helsing
 72  * @date 2019/12/24
 73  * @param {Object} obj 对象。
 74  * @returns {Boolean} 是或否。
 75  */
 76 isElement: function (obj) {
 77     return (typeof HTMLElement === 'object')
 78         ? (obj instanceof HTMLElement)
 79         : !!(obj && typeof obj === 'object' && (obj.nodeType === 1 || obj.nodeType === 9) && typeof obj.nodeName === 'string');
 80 },
 81 
 82 /**
 83  * 获取全球唯一ID。
 84  *
 85  * @author Helsing
 86  * @date 2019/11/21
 87  * @param {Boolean} removeMinus 是否去除“-”号。
 88  * @returns {String} GUID。
 89  */
 90 getGuid: function (removeMinus) {
 91     let d = new Date().getTime();
 92     let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
 93         const r = (d + Math.random() * 16) % 16 | 0;
 94         d = Math.floor(d / 16);
 95         return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
 96     });
 97     if (removeMinus) {
 98         uuid = uuid.replace(/-/g, "");
 99     }
100     return uuid;
101 }

另外给出css样式

1 .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; }
2     .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%; }
3     .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来吧,这里是没有任何商业气息的纯技术分享群,队伍不断壮大中,期待你的加入。

posted on 2020-11-20 13:45  Helsing·Wang  阅读(5625)  评论(5编辑  收藏  举报

导航