阅读视图
OpenLayers:封装Tooltip
在我之前写的一篇文章 OpenLayers:封装Overlay的方法_openlayer overlay-CSDN博客 中我封装一个工具方法addOverlay
,当时我通过这个方法给Overlay
增加一些额外的功能,如:控制显隐、分组、支持传入DOMString作为element
等。但是之前的封装方式还是太过简陋有种种缺陷,使用起来也不甚方便。
最近我封装了一个Overlay
的子类Tooltip
类 ,在其中集成了之前的那些功能,并且还新增了一些功能。接下来我将在这篇文章中阐述各个功能的实现思路以及实现过程中我的一些收获。
一、Tooltip功能概述
首先我要简单的说明一下我将要封装的Tooltip
类,首先我会让它作为Overlay
的子类,这样就可以继承Overlay
本身的各项功能,并且Tooltip
的实例也可以直接被Map.addOverlay
添加到地图中。
import Overlay from "ol/Overlay";
class Tooltip extends Overlay {
......
}
---------------------------------------
const tooltip = new Tooltip()
map.addOverlay(tooltip) // 直接可以将Tooltip的实例也可以被 `addOverlay` 等地图方法使用
我想要在Tooltip
类中实现的功能主要有以下这些:
1.默认模版样式
我希望我的提示框可以默认有如下的容器样式(有一个蓝色的背景,有一个指向地图位置的 '小箭头' 等)。这样以后再不同项目中复用Tooltip
的时候就不用总是自己写样式了。
2.支持 "上、下、左、右" 四个方位
虽然Overlay
中有positioning
属性可以自由设置Overlay
的相对位置但是我认为这个属性并不好用。因为positioning
属性是用来表示位置点相对与Overlay
的位置,例如将positioning
设置为'bottom-center'
表示位置点位于Overlay
的正下方(即Overlay
位于位置点的正上方)。
我个人还是更习惯以Overlay
为“主体”来描述其与位置点的位置关系,例如'top'
表示Overlay
位于位置点的正上方。所以我希望可以创建一个新的属性来替代positioning
属性的功能。
另外由于我的提示框有一个指向位置点的小箭头,这个小箭头的位置也要随着提示框位置的变化而变化,因此就要做对应的处理。
3.支持以DOMString作为element属性的值
Overlay
的element
属性只支持接收一个 HTMLElement。
如果像下面这样传入一个DOMString作为element`,则会报错。
new Overlay({
element: `<div>我是一个Overlay</div>`
})
在实际使用过程中我感觉这十分不便,很多时候我在创建一些简单的Overlay
时,我希望可以支持直接传入一个DOMString。
4.分组
在实际的开发中常常会向地图中添加几种不同的Overlay
,并需要对同一种类的Overlay
进行统一的操作(例如,全部移除、全部隐藏等)。而由于Overlay
中只有一个id
属性来标识不同的个体,所以导致批量的操作非常不方便,我很难将某一种类的Overlay
筛选出来。因此我希望Overlay
可以有一个groupId
属性来标识它们的种类。
希望可以达到类似于下面这样的效果:
// 添加某一类的Tooltip,类标识为tooltip-test
const positions = [];
positions.forEach((position, index) => {
const tooltip = new Tooltip({
id: `tooltip-${index}`,
groupId: "tooltip-test",
element: `tooltip-${index}`,
position: position,
});
map.addOverlay(tooltip);
});
// 获取某一类的Tooltip,类标识为tooltip-test
const tooltips = map.getOverlays().getArray().filter(overlay => {
let groupId = overlay.getGroupId ? overlay.getGroupId() : overlay.groupId;
return groupId ? groupId === "tooltip-test" : false;
});
5.控制显隐
我在实际的开发常遇到需要暂时隐藏(关闭)Overlay
的需求,但是苦于Overlay
不像Layer
那样有visible
属性来控制显隐。
我只能通过移除Overlay
或者是将position
置空的方式来实现隐藏的效果。但是使用这些方法隐藏容易,重新显示就难了。需要将Overlay
缓存起来以便重新添加到地图中,或者将position
的值缓存起来以便重新设置Overlay
的位置。
// 移除Overlay
map.removeOverlay(tooltip)
// 将Overlay的position置空
tooltip.setPosition(null)
希望可以达到类似于下面这样的效果:
6.限制显示的缩放层级
另一个常见的需求是希望Tooltip只在某些缩放层级中被显示。就像Layer
的minZoom
、maxZoom
那样
希望可以达到类似于下面这样的效果:
7.设置层级
Overlay
本身不具有像zIndex
这样设置层级的属性,因此当多个Overlay
重叠在一起时我无法设置哪个在上哪个在下。
二、实现默认模版样式与支持DOMString
我的基本实现思路是创建一个容器元素来包裹传入的element
属性值,然后给这个默认容器元素设置模版样式。Overlay
的源码中也是遵循这样的思路,会给传入的element
包裹一层.ol-overlay-container
元素。
1.编写一个formatElement方法
这个方法可以格式element
,它接受一个参数element
(可以是 HTMLElement 或 DOMString),然后对其进行处理。会将element
由 DOMString 转换为 HTMLElement 并为其包裹一个.ol-custom-tooltip
元素以应用默认模版样式。
/**
* 格式化元素
* @param {HTMLElement | string} element - 元素
* @returns {HTMLElement} 格式化后的元素
*
*/
formatElement(element) {
let _element = element;
// DOMString 转换为 HTMLElement
if (typeof element === "string") {
const div = document.createElement("div");
div.innerHTML = element;
_element = div.firstElementChild;
}
// 使用自定义的模版
if (this.isUseContainer) {
const container = document.createElement("div");
container.className = `ol-custom-tooltip ${this.getPlacement()}`;
container.appendChild(_element);
_element = container;
}
return _element;
}
2.编写CSS样式
我在一个 Tooltip.css 文件中编写相应的默认样式,这个文件会被导入 Tooltip
类所在的js文件。
/* 基础样式 */
.ol-custom-tooltip {
--bg: rgba(25, 82, 78, 0.8);
--arrowSize: 10px;
position: relative;
z-index: 100;
min-width: 120px;
color: #fff;
padding: 5px;
background-color: var(--bg);
border: none;
border-radius: 4px;
line-height: 1;
font-size: 12px;
text-align: start;
box-sizing: border-box;
user-select: none;
}
.ol-custom-tooltip::after {
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border-width: var(--arrowSize);
}
.ol-custom-tooltip.top::after {
top: 100%;
left: 50%;
transform: translateX(-50%);
border-top-color: var(--bg) !important;
}
.ol-custom-tooltip.bottom::after {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-bottom-color: var(--bg) !important;
}
.ol-custom-tooltip.left::after {
left: 100%;
top: 50%;
transform: translateY(-50%);
border-left-color: var(--bg) !important;
}
.ol-custom-tooltip.right::after {
right: 100%;
top: 50%;
transform: translateY(-50%);
border-right-color: var(--bg) !important;
}
3.重写setElement方法
由于Overlay
是一个可写属性,可以通过overlay.setElement
方法来修改它。因此我的计划是提前拦截传入的element
值,在它被设置之前对其进行 ' 格式化 ' 。
因此我在Tooltip
类中重写setElement
方法,以覆盖它的父类Overlay
类中setElement
方法。在新的setElement
方法中提前对element
进行格式化。
/**
*
* @override
*/
setElement(element) {
let _element = this.formatElement(element);
super.setElement(_element);
}
小技巧:钩子函数
这里我就要讲一个我自己总结的技巧了:
在OpenLayers中开发一个新类的时候,如果需要在父类的某个属性被设置前执行一个钩子函数 beforeHook
,可以通过重写属性的set
方法来实现,在新的set
中先执行钩子函数,再执行父类的set
方法(即super.set
)。
// 子类上的新 set 方法
setProperty(property){
// 要执行的 `设置前钩子函数`
beforeHook()
// 父类上的 set 方法
super.setProperty(property)
}
如果需要在父类的某个属性被设置后执行一个钩子函数afterHook
,可以将钩子函数作为该属性的change
事件(即change:property
事件)的事件处理函数。
overlay.on('change:property' , afterHook)
三、实现对 ' 四方位 ' 的支持
基本思路是我新增一个placement
属性,它部分替代positioning
功能,会设置placement
属性与positioning
属性进行 “双向绑定”(其中一个属性发生改变后另一个也会跟着变化)。另外,在设置placement
属性值的时候也还会同步更改提示框小箭头的方向。
小技巧:普通属性和Property属性
在OpenLayers的类中的属性分为两种:普通属性和Property属性。
可以看下图中是Overlay
类中的两个属性的get
方法,其中element
属性是使用this.get('element')
的方式读取,而id
属性则是通过this.id
的方式读取。element
就是Property属性,而id
则是普通属性。
普通属性和Property属性区别主要有两点:
- 普通属性直接添加到class中通过
this.xxx
进行读写。Property属性会被添加到一个特殊的存储对象value_
中,只能通过get
、set
、setProperties
等方法读写。 - Property属性会自动注册对应的事件
'change:xxx'
,Property属性的改变会触发对应的事件。而普通属性则不具备这样的功能。
因此在添加一个新属性时如果该属性是一个 ' 常量 ' 则应该设置为普通属性,如果该属性是一个' 变量 ' 则应该设置为Property属性。
1.创建placement属性
placement
属性表示Tooltip
相对于position
的位置,它有'top' | 'bottom' | 'left' | 'right'
四个可选的值。placement
属性与positioning
属性有对应关系,它的四个值分别对应了positioning
的'bottom-center' | 'top-center' | 'center-right' | 'center-left'
。
const PLACEMENTS = {
TOP: "top",
BOTTOM: "bottom",
LEFT: "left",
RIGHT: "right",
};
const POSITIONINGS = {
TOP: "bottom-center",
BOTTOM: "top-center",
LEFT: "center-right",
RIGHT: "center-left",
};
我还编写了placement
与positioning
相互转换的方法。
/**
* 根据positioning获取placement
* @param {string} positioning - positioning值
* @returns {string} placement值
*/
const toPlacement = positioning => {
switch (positioning) {
case POSITIONINGS.TOP:
return PLACEMENTS.TOP;
case POSITIONINGS.RIGHT:
return PLACEMENTS.RIGHT;
case POSITIONINGS.BOTTOM:
return PLACEMENTS.BOTTOM;
case POSITIONINGS.LEFT:
return PLACEMENTS.LEFT;
default:
return "";
}
};
/**
* 根据placement获取positioning
* @param {string} placement - placement值
* @returns {string} positioning值
*/
const toPositioning = placement => {
switch (placement) {
case PLACEMENTS.TOP:
return POSITIONINGS.TOP;
case PLACEMENTS.RIGHT:
return POSITIONINGS.RIGHT;
case PLACEMENTS.BOTTOM:
return POSITIONINGS.BOTTOM;
case PLACEMENTS.LEFT:
return POSITIONINGS.LEFT;
default:
return "";
}
};
之后为placement
属性编写了读写的方法。
/**
* 设置Tooltip的位置方位
* @param {string} placement - 位置方位,可选值:'top' | 'bottom' | 'left' | 'right'
* @description 设置Tooltip的位置方位
*/
setPlacement(placement = "top") {
this.set("placement", placement);
}
/**
* 获取Tooltip的位置方位
* @returns {string} 当前位置方位
* @description 返回Tooltip的当前位置方位
*/
getPlacement() {
return this.get("placement");
}
2.实现placement与positioning的双向绑定
实现双向绑定的思路就是使用变量设置后的钩子函数,当placement
的值发生变化的时候调用钩子同步修改positioning
的值,反之亦然。
首先在构造函数中添加两个属性的变化事件。
this.on("change:placement", this.handleTooltipPlacementChanged);
this.on("change:positioning", this.handleTooltipPositioningChanged);
在事件处理函数中修改另一个属性的值。
/**
* @protected
*
*/
handleTooltipPlacementChanged() {
const placement = this.getPlacement();
const positioning = toPositioning(placement);
if (positioning) {
this.setPositioning(positioning);
}
}
/**
* @protected
*/
handleTooltipPositioningChanged() {
const positioning = this.getPositioning();
const placement = toPlacement(positioning);
if (placement) {
this.setPlacement(placement);
}
}
3.修改Tooltip的样式(小箭头的位置)
当placement
变化时我还要同步修改小箭头的位置,方法是给element
添加一个样式类来给小箭头设置不同的样式。另外由于我设置的小箭头本质上是一个伪元素,它不会被计算在tooltip的尺寸内,所以必需根据小箭头的尺寸(我这里是10像素)设置一个偏移,才能让小箭头正好指向position
位置。
/**
* @protected
*
*/
handleTooltipPlacementChanged() {
const placement = this.getPlacement();
const positioning = toPositioning(placement);
if (positioning) {
this.setPositioning(positioning);
}
if (this.isUseContainer) {
// 添加样式类
this.getElement().className = `ol-custom-tooltip ${placement}`;
// 设置偏移
switch (placement) {
case PLACEMENTS.TOP:
this.setOffset([0, -10]);
break;
case PLACEMENTS.BOTTOM:
this.setOffset([0, 10]);
break;
case PLACEMENTS.LEFT:
this.setOffset([-10, 0]);
break;
case PLACEMENTS.RIGHT:
this.setOffset([10, 0]);
break;
default:
this.setOffset([0, 0]);
}
}
}
四、实现分组功能
思路还是给Tooltip
增加一个GroupId
属性。
class Tooltip extends Overlay {
constructor(options){
......
/**
* 提示框分组ID
* @type {string}
* @protected
*/
this.groupId = options.groupId !== undefined ? options.groupId : "default";
}
/**
* 获取Tooltip的组ID
* @returns {string} 当前组ID
* @description 返回Tooltip所属的组ID
*/
getGroupId() {
return this.groupId;
}
}
之后可以采用如下的方式获取某一个组别的Overlay
。
const overlays = map
.getOverlays()
.getArray()
.filter(overlay => {
let groupId = overlay.getGroupId ? overlay.getGroupId() : overlay.groupId;
return groupId ? groupId === "tooltip-test" : false;
});
console.log(overlays);
五、实现控制显隐功能
控制显隐的功能也很简单,我的思路是新增一个Property属性visiblity
,当visiblity
的值变化时就去修改element
的 样式属性display
。
class Tooltip extends Overlay {
constructor(options){
......
this.on("change:visibility", this.handleTooltipVisibilityChanged);
// 设置可见性
this.set(
"visibility",
options.visibility !== undefined ? options.visibility : true
);
}
/**
* @protected
*/
handleTooltipVisibilityChanged() {
const element = this.getElement();
if (element) {
element.style.display = this.getVisibility() ? "flex" : "none";
} else {
this.once("change:element", this.handleTooltipVisibilityChanged);
}
}
/**
* 设置Tooltip的可见性
* @param {boolean} visibility - 是否可见
* @description 设置Tooltip的可见性
*/
setVisibility(visibility) {
this.set("visibility", visibility);
}
/**
* 获取Tooltip的可见性
* @returns {boolean} 当前可见性状态
* @description 返回Tooltip的当前可见性状态
*/
getVisibility() {
return this.get("visibility");
}
}
六、实现显示缩放级别限制功能
这个功能在我实现的过程中难度是最大的。当然基本的思路很简单,首先新增两个属性minZoom
和maxZoom
用于设置tooltip的显示范围,然后侦听map
的moveend
事件,若zoom
在设置的范围内则显示tooltip,反之则隐藏tooltip。
但是有两个问题需要解决:
第一,绑定moveend
事件容易,但是想解绑就比较麻烦了。因为我需要在tooltip被添加到地图中时绑定事件,tooltip被从地图中移除时解绑事件,但是既然tooltip都已经被从地图中移除了,又怎么获取到地图对象呢?我最初使用的方法的用一个oldMap
属性将地图对象缓存起来,但是总感觉这种方法不太好,代码质量太低。
第二,与显隐功能的冲突,原本用户只能通过调用tooltip的setVisibility()
方法才能控制显隐。现在根据地图缩放级别的变化也能够自动调整显隐了,因此有的时候就会有冲突。例如,地图的当前的zoom
已经超出了tooltip的显示范围,tooltip被隐藏了,但是用户又执行了tooltip.setVisibility(true)
,结果tooltip又显示了。这种情况就是错误的,正确的应该是只要地图的缩放级别处于规定的范围之外,无论怎样都无法让tooltip显示出来。
1.新增minZoom与maxZoom属性
minZoom
与maxZoom
分别表示Tooltip的最小缩放级别和最大缩放级别,只要在这个范围内Tooltip就可以正常显示(也可以通过setVisibility()
方法控制显隐),超出这个范围Tooltip就会被隐藏(此时setVisibility()
方法应当失效)。
minZoom
的默认值为0
,maxZoom
的默认值为Infinity
。
class Tooltip extends Overlay {
constructor(options){
......
/**
* 最小缩放级别
* @type {number}
* @protected
*/
this.minZoom = options.minZoom !== undefined ? options.minZoom : 0;
/**
* 最大缩放级别
* @type {number}
* @protected
*/
this.maxZoom = options.maxZoom !== undefined ? options.maxZoom : Infinity;
}
/**
* 获取Tooltip的最大缩放级别
* @returns {number} 当前最大缩放级别
* @description 返回Tooltip的最大缩放级别
*/
getMaxZoom() {
return this.maxZoom;
}
/**
* 获取Tooltip的最小缩放级别
* @returns {number} 当前最小缩放级别
* @description 返回Tooltip的最小缩放级别
*/
getMinZoom() {
return this.minZoom;
}
}
2.实现地图的zoom变化时自动调整tooltip显隐
我的实现方式是侦听tooltip的change:map
事件,当为tooltip设置了一个map
时,给map
绑定moveend
事件。
this.on("change:map", this.handleTooltipMapChanged);
为了方便进行事件管理,我使用OpenLayers中两个事件方法listen
和unlistenByKey
。listen
方法用于绑定事件,它会返回一个EventsKey
对象,其中存储了事件的信息(例如事件对象、事件处理函数)。unlistenByKey
方法则会接收一个EventsKey
对象然后移除该事件的绑定。
/**
* Key to use with {@link module:ol/Observable.unByKey}.
* @typedef {Object} EventsKey
* @property {ListenerFunction} listener Listener.
* @property {import("./events/Target.js").EventTargetLike} target Target.
* @property {string} type Type.
* @api
*/
/**
* @protected
*/
handleTooltipMapChanged() {
const map = this.getMap();
// 移除所有事件监听
this.removeAllListeners();
if (map) {
// 监听地图缩放事件(实现对Tooltip的缩放范围控制)
if (this.minZoom > 0 && this.maxZoom < Infinity) {
this.handleMapZoomChange();
this.listenKeys.push(
listen(map, "moveend", this.handleMapZoomChange, this)
);
}
}
}
/**
* @protected
*/
handleMapZoomChange() {
const zoom = this.getMap().getView().getZoom();
const visibility = zoom >= this.minZoom && zoom <= this.maxZoom;
this.isOnZoomRange = visibility;
this.set("visibility", visibility);
}
/**
* 移除所有事件监听
* @description 移除所有事件监听
*/
removeAllListeners() {
this.listenKeys.forEach(key => {
unlistenByKey(key);
});
this.listenKeys = [];
}
3.解决显隐冲突的问题
为了解决“显隐冲突”的问题,我专门设置了一个isOnZoomRange
属性,它用来表示当前地图的zoom
是否在Tooltip的minZoom
与maxZoom
的范围内。
/**
* 是否在缩放范围内
* @type {boolean}
* @private
*/
this.isOnZoomRange = true;
若isZoomOnRange = false
,则setVisibility()
方法将会被禁用。
/**
* 设置Tooltip的可见性
* @param {boolean} visibility - 是否可见
* @description 设置Tooltip的可见性
*/
setVisibility(visibility) {
if (!this.isOnZoomRange) return;
this.set("visibility", visibility);
}
七、实现层级控制功能
基本思路是新增一个zIndex
属性,将.ol-overlay-container
元素的z-index
样式属性设置为zIndex
的值。具体的原理和实现方式可以参考我之前写的这篇文章:
OpenLayers:如何控制Overlay的层级?_openlayer设置overlay在最上面-CSDN博客
1.新增zIndex属性
zIndex
属性用来设置tooltip在z方向上的层级,我设置zIndex
的取值范围在0
到9999
之间。
const MIN_Z_INDEX = 0;
const MAX_Z_INDEX = 9999;
/**
* 设置Tooltip的z-index
* @param {number} zIndex - z-index值
* @description 设置Tooltip的z-index,同时更新DOM元素的z-index
*/
setZIndex(zIndex) {
let _zIndex = zIndex;
if (zIndex < MIN_Z_INDEX) {
_zIndex = MIN_Z_INDEX;
} else if (zIndex > MAX_Z_INDEX) {
_zIndex = MAX_Z_INDEX;
}
this.set("zIndex", _zIndex);
}
/**
* 获取Tooltip的z-index
* @returns {number} 当前z-index值
* @description 返回Tooltip的当前z-index值
*/
getZIndex() {
return this.get("zIndex");
}
2.为.ol-overlay-container元素设置层级
class Tooltip extends Overlay {
constructor(options){
......
}
/**
* 获取Tooltip的最大缩放级别
* @returns {number} 当前最大缩放级别
* @description 返回Tooltip的最大缩放级别
*/
getMaxZoom() {
return this.maxZoom;
}
/**
* 获取Tooltip的最小缩放级别
* @returns {number} 当前最小缩放级别
* @description 返回Tooltip的最小缩放级别
*/
getMinZoom() {
return this.minZoom;
}
}
完整代码
import Overlay from "ol/Overlay";
import { listen, unlistenByKey } from "ol/events";
import "./Tooltip.css";
const MIN_Z_INDEX = 0;
const MAX_Z_INDEX = 9999;
const PLACEMENTS = {
TOP: "top",
BOTTOM: "bottom",
LEFT: "left",
RIGHT: "right",
};
const POSITIONINGS = {
TOP: "bottom-center",
BOTTOM: "top-center",
LEFT: "center-right",
RIGHT: "center-left",
};
const PLACEMENT_MAP_POSITIONING = [
[PLACEMENTS.TOP, POSITIONINGS.TOP],
[PLACEMENTS.RIGHT, POSITIONINGS.RIGHT],
[PLACEMENTS.BOTTOM, POSITIONINGS.BOTTOM],
[PLACEMENTS.LEFT, POSITIONINGS.LEFT],
];
/**
* 根据positioning获取placement
* @param {string} positioning - positioning值
* @returns {string} placement值
*/
const toPlacement = positioning => {
switch (positioning) {
case POSITIONINGS.TOP:
return PLACEMENTS.TOP;
case POSITIONINGS.RIGHT:
return PLACEMENTS.RIGHT;
case POSITIONINGS.BOTTOM:
return PLACEMENTS.BOTTOM;
case POSITIONINGS.LEFT:
return PLACEMENTS.LEFT;
default:
return "";
}
};
/**
* 根据placement获取positioning
* @param {string} placement - placement值
* @returns {string} positioning值
*/
const toPositioning = placement => {
switch (placement) {
case PLACEMENTS.TOP:
return POSITIONINGS.TOP;
case PLACEMENTS.RIGHT:
return POSITIONINGS.RIGHT;
case PLACEMENTS.BOTTOM:
return POSITIONINGS.BOTTOM;
case PLACEMENTS.LEFT:
return POSITIONINGS.LEFT;
default:
return "";
}
};
/**
* @class Tooltip
* @extends {Overlay}
* @classdesc 自定义的 OpenLayers 提示框覆盖物类,继承自 ol/Overlay
*
* @param {Object} options - 配置选项
* @param {string} [options.groupId='default'] - 提示框分组ID
* @param {boolean} [options.visibility=true] - 是否默认可见
* @param {string} [options.placement='top'] - 提示框位置,可选值:'top' | 'bottom' | 'left' | 'right'
* @param {HTMLElement|string} options.element - 提示框内容元素或HTML字符串
* @param {number} [options.maxZoom=Infinity] - 最大显示缩放级别
* @param {number} [options.minZoom=0] - 最小显示缩放级别
* @param {boolean} [options.isUseContainer=true] - 是否使用自定义的提示框容器
* @param {number} [options.zIndex=0] - 提示框的z-index
*
* @example
* // 创建一个顶部显示的提示框
* const tooltip = new Tooltip({
* element: '<div>提示内容</div>',
* placement: 'top'
* });
*
* @property {boolean} isUseContainer - 是否使用自定义的Tooltip容器
* @property {number} minZoom - 最小缩放级别
* @property {number} maxZoom - 最大缩放级别
* @property {Array<number>} listenKeys - 事件监听器数组
* @property {boolean} isOnZoomRange - 是否在缩放范围内
*/
class Tooltip extends Overlay {
constructor(options) {
const { element, ...rest } = options;
super(rest);
/**
* 提示框分组ID
* @type {string}
* @protected
*/
this.groupId = options.groupId !== undefined ? options.groupId : "default";
/**
* 是否使用自定义的Tooltip容器
* @type {boolean}
* @protected
*/
this.isUseContainer =
options.isUseContainer !== undefined ? options.isUseContainer : true;
/**
* 最小缩放级别
* @type {number}
* @protected
*/
this.minZoom = options.minZoom !== undefined ? options.minZoom : 0;
/**
* 最大缩放级别
* @type {number}
* @protected
*/
this.maxZoom = options.maxZoom !== undefined ? options.maxZoom : Infinity;
/**
* 事件监听器
* @type {Array}
* @private
*/
this.listenKeys = [];
/**
* 是否在缩放范围内
* @type {boolean}
* @private
*/
this.isOnZoomRange = true;
// 注册 `change:visibility` 事件侦听器
this.on("change:visibility", this.handleTooltipVisibilityChanged);
this.on("change:placement", this.handleTooltipPlacementChanged);
this.on("change:positioning", this.handleTooltipPositioningChanged);
this.on("change:map", this.handleTooltipMapChanged);
this.on("change:zIndex", this.handleTooltipZIndexChanged);
// 设置元素
this.setElement(options.element);
// 设置可见性
this.set(
"visibility",
options.visibility !== undefined ? options.visibility : true
);
// 设置位置方位
this.set(
"placement",
options.placement !== undefined ? options.placement : "top"
);
// 设置z-index
this.setZIndex(options.zIndex !== undefined ? options.zIndex : MIN_Z_INDEX);
}
/**
* @protected
*/
handleTooltipVisibilityChanged() {
const element = this.getElement();
if (element) {
element.style.display = this.getVisibility() ? "flex" : "none";
} else {
this.once("change:element", this.handleTooltipVisibilityChanged);
}
}
/**
* @protected
*
*/
handleTooltipPlacementChanged() {
const placement = this.getPlacement();
const positioning = toPositioning(placement);
if (positioning) {
this.setPositioning(positioning);
}
if (this.isUseContainer) {
this.getElement().className = `ol-custom-tooltip ${placement}`;
// 设置偏移
switch (placement) {
case PLACEMENTS.TOP:
this.setOffset([0, -10]);
break;
case PLACEMENTS.BOTTOM:
this.setOffset([0, 10]);
break;
case PLACEMENTS.LEFT:
this.setOffset([-10, 0]);
break;
case PLACEMENTS.RIGHT:
this.setOffset([10, 0]);
break;
default:
this.setOffset([0, 0]);
}
}
}
/**
* @protected
*/
handleTooltipPositioningChanged() {
const positioning = this.getPositioning();
const placement = toPlacement(positioning);
if (placement) {
this.setPlacement(placement);
}
}
/**
* @protected
*/
handleTooltipMapChanged() {
const map = this.getMap();
// 移除所有事件监听
this.removeAllListeners();
if (map) {
// 监听地图缩放事件(实现对Tooltip的缩放范围控制)
if (this.minZoom > 0 && this.maxZoom < Infinity) {
this.handleMapZoomChange();
this.listenKeys.push(
listen(map, "moveend", this.handleMapZoomChange, this)
);
}
}
}
/**
* @protected
*/
handleMapZoomChange() {
const zoom = this.getMap().getView().getZoom();
const visibility = zoom >= this.minZoom && zoom <= this.maxZoom;
this.isOnZoomRange = visibility;
this.set("visibility", visibility);
}
/**
* @protected
*/
handleTooltipZIndexChanged() {
const element = this.getElement();
const zIndex = this.getZIndex();
if (element) {
const parentElement = element.parentElement;
parentElement.style.zIndex = zIndex;
parentElement.addEventListener("mouseenter", function () {
this.style.zIndex = MAX_Z_INDEX;
});
parentElement.addEventListener("mouseleave", function () {
this.style.zIndex = zIndex;
});
} else {
this.once("change:element", this.handleTooltipZIndexChanged);
}
}
/**
*
* @override
*/
setElement(element) {
let _element = this.formatElement(element);
super.setElement(_element);
}
/**
* 格式化元素
* @param {HTMLElement | string} element - 元素
* @returns {HTMLElement} 格式化后的元素
*
*/
formatElement(element) {
let _element = element;
// DOMString 转换为 HTMLElement
if (typeof element === "string") {
const div = document.createElement("div");
div.innerHTML = element;
_element = div.firstElementChild;
}
// 使用自定义的模版
if (this.isUseContainer) {
const container = document.createElement("div");
container.className = `ol-custom-tooltip ${this.getPlacement()}`;
container.appendChild(_element);
_element = container;
}
return _element;
}
/**
* 获取Tooltip的组ID
* @returns {string} 当前组ID
* @description 返回Tooltip所属的组ID
*/
getGroupId() {
return this.groupId;
}
/**
* 获取Tooltip的最大缩放级别
* @returns {number} 当前最大缩放级别
* @description 返回Tooltip的最大缩放级别
*/
getMaxZoom() {
return this.maxZoom;
}
/**
* 获取Tooltip的最小缩放级别
* @returns {number} 当前最小缩放级别
* @description 返回Tooltip的最小缩放级别
*/
getMinZoom() {
return this.minZoom;
}
/**
* 设置Tooltip的可见性
* @param {boolean} visibility - 是否可见
* @description 设置Tooltip的可见性
*/
setVisibility(visibility) {
if (!this.isOnZoomRange) return;
this.set("visibility", visibility);
}
/**
* 获取Tooltip的可见性
* @returns {boolean} 当前可见性状态
* @description 返回Tooltip的当前可见性状态
*/
getVisibility() {
return this.get("visibility");
}
/**
* 设置Tooltip的位置方位
* @param {string} placement - 位置方位,可选值:'top' | 'bottom' | 'left' | 'right'
* @description 设置Tooltip的位置方位
*/
setPlacement(placement = "top") {
this.set("placement", placement);
}
/**
* 获取Tooltip的位置方位
* @returns {string} 当前位置方位
* @description 返回Tooltip的当前位置方位
*/
getPlacement() {
return this.get("placement");
}
/**
* 设置Tooltip的z-index
* @param {number} zIndex - z-index值
* @description 设置Tooltip的z-index,同时更新DOM元素的z-index
*/
setZIndex(zIndex) {
let _zIndex = zIndex;
if (zIndex < MIN_Z_INDEX) {
_zIndex = MIN_Z_INDEX;
} else if (zIndex > MAX_Z_INDEX) {
_zIndex = MAX_Z_INDEX;
}
this.set("zIndex", _zIndex);
}
/**
* 获取Tooltip的z-index
* @returns {number} 当前z-index值
* @description 返回Tooltip的当前z-index值
*/
getZIndex() {
return this.get("zIndex");
}
/**
* 移除所有事件监听
* @description 移除所有事件监听
*/
removeAllListeners() {
this.listenKeys.forEach(key => {
unlistenByKey(key);
});
this.listenKeys = [];
}
/**
* 销毁Tooltip实例
* @description 清理所有资源,包括事件监听器、DOM元素和属性
*/
dispose() {
const map = this.getMap();
map && map.removeOverlay(this);
// 移除所有事件监听
this.removeAllListeners();
// 移除Tooltip自身的事件监听
// 清理DOM元素
const element = this.getElement();
if (element && element.parentNode) {
element.parentNode.removeChild(element);
}
// 清理属性
this.set("groupId", null);
this.set("placement", null);
this.set("visibility", null);
// 检查父类是否有dispose方法
if (typeof super.dispose === "function") {
super.dispose();
}
}
}
export {
PLACEMENTS,
POSITIONINGS,
PLACEMENT_MAP_POSITIONING,
toPlacement,
toPositioning,
};
export default Tooltip;
参考资料
OpenLayers:通过自动布局调整解决Overlay重叠问题
一、解决Overlay重叠问题的尝试
我在最近的开发工作中遇到了一个问题。我开发的项目需要给地图上的站点添加Tooltip提示框(即Overlay),但是由于地图上的部分站点比较密集,导致Tooltip的重叠比较严重,部分Tooltip的内容就会被遮挡。业主对此十分不满,要求我们进行优化。
一些解决的尝试
最初我使用了一些 "治标" 的方式进行优化,例如缩小Tooltip的尺寸、添加折叠功能(Tooltip默认只是小尺寸只展示部分数据,鼠标悬停上去后Tooltip展开显示全部的数据)、将当前聚焦的Tooltip移动到最上层等方法,当然也尝试过做分级展示(在默认的缩放级别下只展示部分重要的Tooltip,当层级增大到一定程度后再展示全部的Tooltip)。
可惜的上述的办法都无法彻底解决Tooltip重叠的问题,后来我参考了其它项目后,找到了一种“治本”的解决方案。
手动调整Tooltip的位置
之前Tooltip都被我放置在站点的上方:
但是实际上也可以将Tooltip放置在下方、左边或右边:
因此通过调整Tooltip的方向,就可以让两个邻近的站点的Tooltip错开,从而解决重叠的问题。
于是基于上面的这种思路,我制定了一个解决方案:只展示部分重要站点的Tooltip,然后手动去调整每个Tooltip的方向,尽可能的避免重叠。
我虽然使用了上面的这种方法暂时了应付了业主的需求,但是我对它并不智能。其实原本在我们的系统中是支持用户可以自主配置展示哪些站点的Tooltip,但是如果这么做地图上Tooltip的数量就是不固定的,这样我是没有办法手动调整方向避免重叠的。因此我只能废弃这一功能,只固定展示部分重要站点的Tooltip。......除非有一套自动调整tooltip布局的机制,于是便有了这篇文章 😊。
二、碰撞检测
之前的“手动布局调整”方案主要的步骤就是先人工识别哪些Tooltip出现了重叠,然后再手动对重叠的Tooltip进行位置的调整。现在想要实现“自动布局调整”我认为也要延续这样的思路,只不过要用程序来代替人工。因此第一步就是要实现自动识别哪些Tooltip出现了重叠,这其实就是要做碰撞检测。
由于我的目标Tooltip,它本质上是个Element元素,而Element都是矩形,因此我决定使用轴对齐边框算法来进行碰撞检测。
轴对齐边界框 (AABB) 算法
轴对齐边界框(Axis-Aligned Bounding Box,简称 AABB)是一种在计算机图形学、物理引擎和碰撞检测中广泛使用的算法。它的核心思想是用一个与坐标轴对齐的矩形(在 2D 中)或长方体(在 3D 中)来包裹一个物体,从而简化碰撞检测的计算。
算法步骤
构建 AABB
- 遍历物体的所有顶点,找到每个维度的最小值和最大值。
- 构建边界框:
-
- 在 2D 中:
minX = min(所有顶点的 x 坐标)
,maxX = max(所有顶点的 x 坐标)
,minY
和maxY
同理。 - 在 3D 中:
minX = min(所有顶点的 x 坐标)
,maxX = max(所有顶点的 x 坐标)
,minY
、maxY
、minZ
、maxZ
同理。
- 在 2D 中:
碰撞检测
- 判断两个 AABB 是否相交:
-
- 在 2D 中:如果
box1.maxX < box2.minX
或box1.minX > box2.maxX
或box1.maxY < box2.minY
或box1.minY > box2.maxY
,则两个边界框不相交。 - 在 3D 中:如果
box1.maxX < box2.minX
或box1.minX > box2.maxX
或box1.maxY < box2.minY
或box1.minY > box2.maxY
或box1.maxZ < box2.minZ
或box1.minZ > box2.maxZ
,则两个边界框不相交。
- 在 2D 中:如果
优缺点
优点
- 计算简单:AABB 的碰撞检测只需要比较边界框的坐标,计算量小,适合快速筛选。
- 内存占用小:只需要存储两个点(最小点和最大点),内存占用低。
- 适合动态物体:如果物体移动或旋转,可以快速更新边界框。
缺点
- 精度较低:AABB 是轴对齐的,对于旋转的物体,边界框会变得很大,导致碰撞检测的精度下降。
- 不适合复杂形状:对于不规则形状的物体,AABB 可能会包含大量空白区域,导致误判。
代码示例
2D AABB 碰撞检测
function isAABBColliding(box1, box2) {
return !(
box1.maxX < box2.minX ||
box1.minX > box2.maxX ||
box1.maxY < box2.minY ||
box1.minY > box2.maxY
);
}
3D AABB 碰撞检测
function isAABBColliding(box1, box2) {
return !(
box1.maxX < box2.minX ||
box1.minX > box2.maxX ||
box1.maxY < box2.minY ||
box1.minY > box2.maxY ||
box1.maxZ < box2.minZ ||
box1.minZ > box2.maxZ
);
}
对Tooltip使用AABB算法
Tooltip的边界框
想要使用AABB算法首先要找到Tooltip的边界框。由于Tooltip本质是Element元素因此就可以借助Element.getBoundingClientRect()
方法。
BoundingClientRect()
方法会返回一个 DOMRect
对象,是包含整个元素的最小矩形(包括 padding
和 border-width
)。
该对象使用 left
、top
、right
、bottom
、x
、y
、width
和 height
这几个以像素为单位的只读属性描述整个矩形的位置和大小。除了 width
和 height
以外的属性是相对于视图窗口的左上角来计算的。
根据DOMRect
对象我们可以计算出Tooltip的边界框,因此在后面我会将DOMRect
对象就视为Tooltip的边界框。
const rect = tooltip.getElement().getBoundingClientRect()
// 最小X
const minX = rect.x
// 最大X
const maxX = rect.x + rect.width
// 最小Y
const minY = rect.y
// 最大Y
const maxY = rect.y + rect.width
但是注意,上面的代码只能获取某一个方位上Tooltip的边界框,而一个站点位置上我设计了上、下、左、右四个方位,在这四个方位上的Tooltip我都有可能去进行碰撞检测,因此我最终写了一个方法实现通过一个Tooltip来计算四个方位上的边界框。
// 计算提示框在上下左右四个位置的Rect
function getRects(tooltip) {
const positioning = tooltip.getPositioning();
const offset = 10;
const rect = tooltip.getElement().getBoundingClientRect();
let cx,
cy,
w = rect.width,
h = rect.height;
switch (positioning) {
case POSITIONINGS.TOP:
cx = rect.x + rect.width / 2;
cy = rect.y + rect.height + offset;
break;
case POSITIONINGS.RIGHT:
cx = rect.x - offset;
cy = rect.y + rect.height / 2;
break;
case POSITIONINGS.BOTTOM:
cx = rect.x + rect.width / 2;
cy = rect.y - offset;
break;
case POSITIONINGS.LEFT:
cx = rect.x + rect.width + offset;
cy = rect.y + rect.height / 2;
break;
}
const result = {
TOP: {
x: cx - w / 2,
y: cy - h - offset,
w,
h,
},
RIGHT: {
x: cx + offset,
y: cy - h / 2,
w,
h,
},
BOTTOM: {
x: cx - w / 2,
y: cy + offset,
w,
h,
},
LEFT: {
x: cx - w - offset,
y: cy - h / 2,
w,
h,
},
};
return result;
}
注意
由于我在四个方位上的Tooltip都有一段10像素的偏移,因此我在计算边界框时将offset
也加入了计算当中。注意要将可能的偏移加入到计算当中来,否则会导致碰撞检测的结果失真。
构建判断方法
之后我们就可以构建一个检测Tooltip是否重叠的方法。
/**
* @abstract 判断两个矩形是否重叠 (碰撞检测- 使用aabb算法)
* @param {{x: number, y: number, w: number, h: number}} a 矩形a
* @param {{x: number, y: number, w: number, h: number}} b 矩形b
* @returns {boolean} 是否重叠
*/
function isRectOverlay_aabb(a, b) {
return !(
a.x + a.w <= b.x ||
b.x + b.w <= a.x ||
a.y + a.h <= b.y ||
b.y + b.h <= a.y
);
}
三、自动布局优化(四个固定位置中选择 + 贪心算法)
在实现了碰撞检测之后,接下来就要进行自动布局优化,也就是我希望可以用程序来自动判断Tooltip放在哪个方位会更好。这本质上其实是一个求最优解的问题,因此我决定使用最简单的贪心算法来实现。
贪心算法(贪心选择)
定义
贪心算法是一种在每一步选择中都力图使局部最优解能够逐步达到全局最优解的算法。与其他算法不同的是,贪心算法在选择过程中不进行回溯,选择的过程往往基于某种局部最优策略。在一些特定的问题中,贪心算法可以通过逐步构建最优解来实现全局最优。
基本思想
贪心算法的核心思想是通过一系列局部最优选择来构建全局最优解。
优缺点
✅ 优点
- 简单直观,易于实现
- 通常时间复杂度较低(O(n log n)常见)
❌ 缺点
- 不能保证所有问题都得到最优解
- 需要严格证明其正确性
使用贪心算法进行布局优化
既然贪心算法的思路是在每次抉择时都追求局部最优解,最终达到全局最优解。因此我指定了如下的使用方式:
遍历Tooltip列表,每个Tooltip的每个位置都去检测其与其它已添加的Tooltip是否重叠,选择最优的位置(局部最优解)。
局部最优解是什么?
这时候我就遇到了一个问题,我如何去判断哪个位置是最优位置?
我进行了一些尝试,在最初我将重叠数最少作为局部最优解。但是我很快就发现这样明显是不对的,因为很可能就会出现一种情况:
在A位置虽然只与一个Tooltip重叠,但是却完全把这个Tooltip给遮挡住了;在B位置虽然与好几个Tooltp,但是都只是擦边。
上述的情形下明显B位置更符合我们的需求,但根据重叠数最少的原则A位置才是最优的😅。
之后我又尝试将中心点距离最大作为局部最优解,我会去计算某个Tooltip中心点与其它Tooltip中心点之间的总距离,最后选择总距离最大的位置。但是最后我发现这样也不准确,因为我Tooltip是个矩形不是圆形,对于矩形来说它的中心点到各个边的距离是不一样的,因此用中心点距离来表示重叠程度是有误差的。
最后我还是选择用重叠面积最小作为局部最优解。这样最能够反应重叠的程度。
/**
* 计算两个矩形之间的重叠面积
* @param {{x: number, y: number, w: number, h: number}} a 矩形a
* @param {{x: number, y: number, w: number, h: number}} b 矩形b
* @returns {number} 重叠面积
*/
function calculateOverlapArea(a, b) {
const dx = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
const dy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
return dx * dy;
}
使用算法
const POSITIONINGS = {
TOP: "bottom-center",
RIGHT: "center-left",
BOTTOM: "top-center",
LEFT: "center-right",
};
// overlay 布局优化
function layoutOptimized(overlays) {
const overlaysRects = []; // 所有overlay的矩形
const chosenPositions = []; // 所有overlay的最终位置
// 遍历所有overlay
for (let i = 0; i < overlays.length; i++) {
const overlay = overlays[i]; // 当前overlay
const rects = getRects(overlay); // 当前overlay的矩形 (上下左右四个位置的矩形)
let maxWeight = -Infinity; // 最大权重
let bestPos = "TOP"; // 最佳位置
// 遍历所有位置
for (let posIdx = 0; posIdx < Object.keys(POSITIONINGS).length; posIdx++) {
const posKey = Object.keys(POSITIONINGS)[posIdx];
const rect = rects[posKey]; // 当前位置的矩形
let overlayArea = 0; // 当前位置下的重叠面积
// 遍历所有已布局的overlay的矩形,计算重叠面积
for (let j = 0; j < overlaysRects.length; j++) {
overlayArea += isRectOverlay_aabb(rect, overlaysRects[j])
? calculateOverlapArea(rect, overlaysRects[j])
: 0;
}
// 根据重叠面积计算权重
const posWeight = -overlayArea;
// 更新最佳位置
if (posWeight > maxWeight) {
maxWeight = posWeight;
bestPos = posKey;
}
if (posWeight === 0) break;
}
overlaysRects.push(rects[bestPos]);
chosenPositions.push(bestPos);
}
overlays.forEach((overlay, idx) => {
overlay.setPositioning(POSITIONINGS[chosenPositions[idx]]);
});
}
基本思路
最后整体呈现出来的效果还可以
优化
我在使用的过程中发现当前的布局调整还存在明显的问题,比如下面的这个例子中 Tooltip3与Tooltip20、Tooltip2与Tooltip17 就明显不是最优解。
造成这种现象主要是因为,我在统计Tooltip的重叠面积的过程中并没有将所有的Tooltip都加入计算,而只将已优化的Tooltip加入了计算。假设一共有20个Tooltip,第一个Tooltip的重叠面积一定为零,因为此时没有其它已优化的Tooltip;在计算第二个Tooltip的重叠面积时,会将第一个Tooltip加入重叠面积的计算;而计算第二十个Tooltip的重叠面积时则会将其它十九个Tooltip加入重叠面积的计算。
了解了问题的根源就可以进行优化了,我准备在每个Tooltip计算重叠面积时都将其它的Tooltip都加入计算,此时的基本流程就是这样:
调整后的代码如下:
const POSITIONINGS = {
TOP: "bottom-center",
RIGHT: "center-left",
BOTTOM: "top-center",
LEFT: "center-right",
};
function layoutOptimized2(overlays) {
const overlaysRects = []; // 所有overlay的矩形
const chosenPositions = []; // 所有overlay的最终位置
for (let i = 0; i < overlays.length; i++) {
const overlay = overlays[i];
const rects = getRects(overlay);
overlaysRects.push(rects);
chosenPositions.push("TOP");
}
// 遍历所有overlay
for (let i = 0; i < overlays.length; i++) {
const overlay = overlays[i];
const rects = overlaysRects[i]; // 当前overlay的矩形 (上下左右四个位置的矩形)
let maxWeight = -Infinity; // 最大权重
let bestPos = "TOP"; // 最佳位置
// 遍历所有位置
for (let posIdx = 0; posIdx < Object.keys(POSITIONINGS).length; posIdx++) {
const posKey = Object.keys(POSITIONINGS)[posIdx];
const rect = rects[posKey]; // 当前位置的矩形
let overlayArea = 0; // 当前位置下的重叠面积
// 遍历所有已布局的overlay的矩形,计算重叠面积
for (let j = 0; j < chosenPositions.length; j++) {
if (i !== j) {
const rect2 = overlaysRects[j][chosenPositions[j]];
overlayArea += isRectOverlay_aabb(rect, rect2)
? calculateOverlapArea(rect, rect2)
: 0;
}
}
// 根据重叠面积计算权重
const posWeight = -overlayArea;
// 更新最佳位置
if (posWeight > maxWeight) {
maxWeight = posWeight;
bestPos = posKey;
}
if (posWeight === 0) break;
}
chosenPositions[i] = bestPos;
}
overlays.forEach((overlay, idx) => {
overlay.setPositioning(POSITIONINGS[chosenPositions[idx]]);
});
}
使用调整后的方法进行布局优化后,可以看到之前例子中 Tooltip3与Tooltip20、Tooltip2与Tooltip17 的布局更加合理了。
计划
1.探索使用“退火算法”追求全局最优解