1. 程式人生 > 實用技巧 >Cesium深入淺出之資訊彈框

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 }

另外給出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來吧,這裡是沒有任何商業氣息的純技術分享群,隊伍不斷壯大中,期待你的加入。