Cesium深入浅出之信息弹框
引子信息弹框种类有很多,今天我们要说的是那种可以钉在地图上的信息框,它具备一个地图坐标,可以跟随地图移动,超出地图范围会被隐藏,让人感觉它是地图场景中的一部分。不过它还不是真正的地图元素,它还只是个网页元素而已,也就是说它始终是朝向屏幕平面的,而不是那种三维广告板的效果,那种效果或许后续会做吧。预期效果这个效果其实是动态的,从底部到顶部逐渐显现,不过GIF图比较大就没上传了,看看最终的效果吧。实现
引子
信息弹框种类有很多,今天我们要说的是那种可以钉在地图上的信息框,它具备一个地图坐标,可以跟随地图移动,超出地图范围会被隐藏,让人感觉它是地图场景中的一部分。不过它还不是真正的地图元素,它还只是个网页元素而已,也就是说它始终是朝向屏幕平面的,而不是那种三维广告板的效果,那种效果或许后续会做吧。
预期效果
这个效果其实是动态的,从底部到顶部逐渐显现,不过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来吧,这里是没有任何商业气息的纯技术分享群,队伍不断壮大中,期待你的加入。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)