OpenLayers:封装Tooltip

在我之前写的一篇文章 OpenLayers:封装Overlay的方法_openlayer overlay-CSDN博客 中我封装一个工具方法addOverlay,当时我通过这个方法给Overlay增加一些额外的功能,如:控制显隐、分组、支持传入DOMString作为element等。但是之前的封装方式还是太过简陋有种种缺陷,使用起来也不甚方便。

最近我封装了一个Overlay的子类Tooltip类 ,在其中集成了之前的那些功能,并且还新增了一些功能。接下来我将在这篇文章中阐述各个功能的实现思路以及实现过程中我的一些收获。

一、Tooltip功能概述

首先我要简单的说明一下我将要封装的Tooltip类,首先我会让它作为Overlay的子类,这样就可以继承Overlay本身的各项功能,并且Tooltip的实例也可以直接被Map.addOverlay添加到地图中。

JavaScript 复制代码
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属性的值

Overlayelement属性只支持接收一个 HTMLElement。

如果像下面这样传入一个DOMString作为element`,则会报错。

JavaScript 复制代码
new Overlay({
 element: `<div>我是一个Overlay</div>` 
})

在实际使用过程中我感觉这十分不便,很多时候我在创建一些简单的Overlay时,我希望可以支持直接传入一个DOMString。

4.分组

在实际的开发中常常会向地图中添加几种不同的Overlay,并需要对同一种类的Overlay进行统一的操作(例如,全部移除、全部隐藏等)。而由于Overlay中只有一个id属性来标识不同的个体,所以导致批量的操作非常不方便,我很难将某一种类的Overlay筛选出来。因此我希望Overlay可以有一个groupId属性来标识它们的种类。

希望可以达到类似于下面这样的效果:

JavaScript 复制代码
// 添加某一类的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的位置。

scss 复制代码
// 移除Overlay
map.removeOverlay(tooltip)

// 将Overlay的position置空
tooltip.setPosition(null)

希望可以达到类似于下面这样的效果:

6.限制显示的缩放层级

另一个常见的需求是希望Tooltip只在某些缩放层级中被显示。就像LayerminZoommaxZoom那样

希望可以达到类似于下面这样的效果:

7.设置层级

Overlay本身不具有像zIndex这样设置层级的属性,因此当多个Overlay重叠在一起时我无法设置哪个在上哪个在下。

二、实现默认模版样式与支持DOMString

我的基本实现思路是创建一个容器元素来包裹传入的element属性值,然后给这个默认容器元素设置模版样式。Overlay的源码中也是遵循这样的思路,会给传入的element包裹一层.ol-overlay-container 元素。

1.编写一个formatElement方法

这个方法可以格式element,它接受一个参数element(可以是 HTMLElement 或 DOMString),然后对其进行处理。会将element由 DOMString 转换为 HTMLElement 并为其包裹一个.ol-custom-tooltip元素以应用默认模版样式。

JavaScript 复制代码
/**
 * 格式化元素
 * @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文件。

JavaScript 复制代码
/* 基础样式 */
.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进行格式化。

JavaScript 复制代码
/**
*
* @override
*/
setElement(element) {
  let _element = this.formatElement(element);

  super.setElement(_element);
}

小技巧:钩子函数

这里我就要讲一个我自己总结的技巧了:

在OpenLayers中开发一个新类的时候,如果需要在父类的某个属性被设置前执行一个钩子函数 beforeHook,可以通过重写属性的set方法来实现,在新的set中先执行钩子函数,再执行父类的set方法(即super.set)。

JavaScript 复制代码
// 子类上的新 set 方法
setProperty(property){
  // 要执行的 `设置前钩子函数`
  beforeHook()

  // 父类上的 set 方法
  super.setProperty(property)
}

如果需要在父类的某个属性被设置后执行一个钩子函数afterHook,可以将钩子函数作为该属性的change事件(即change:property事件)的事件处理函数。

JavaScript 复制代码
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属性区别主要有两点:

  1. 普通属性直接添加到class中通过this.xxx进行读写。Property属性会被添加到一个特殊的存储对象value_中,只能通过getsetsetProperties等方法读写。
  2. 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'

JavaScript 复制代码
const PLACEMENTS = {
  TOP: "top",
  BOTTOM: "bottom",
  LEFT: "left",
  RIGHT: "right",
};

const POSITIONINGS = {
  TOP: "bottom-center",
  BOTTOM: "top-center",
  LEFT: "center-right",
  RIGHT: "center-left",
};

我还编写了placementpositioning相互转换的方法。

JavaScript 复制代码
/**
 * 根据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属性编写了读写的方法。

JavaScript 复制代码
  /**
   * 设置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的值,反之亦然。

首先在构造函数中添加两个属性的变化事件。

JavaScript 复制代码
this.on("change:placement", this.handleTooltipPlacementChanged);
this.on("change:positioning", this.handleTooltipPositioningChanged);

在事件处理函数中修改另一个属性的值。

JavaScript 复制代码
/**
 * @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位置。

JavaScript 复制代码
  /**
   * @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属性。

JavaScript 复制代码
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

JavaScript 复制代码
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

JavaScript 复制代码
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");
  }
}

六、实现显示缩放级别限制功能

这个功能在我实现的过程中难度是最大的。当然基本的思路很简单,首先新增两个属性minZoommaxZoom用于设置tooltip的显示范围,然后侦听mapmoveend事件,若zoom在设置的范围内则显示tooltip,反之则隐藏tooltip。

但是有两个问题需要解决:

第一,绑定moveend事件容易,但是想解绑就比较麻烦了。因为我需要在tooltip被添加到地图中时绑定事件,tooltip被从地图中移除时解绑事件,但是既然tooltip都已经被从地图中移除了,又怎么获取到地图对象呢?我最初使用的方法的用一个oldMap属性将地图对象缓存起来,但是总感觉这种方法不太好,代码质量太低。

第二,与显隐功能的冲突,原本用户只能通过调用tooltip的setVisibility()方法才能控制显隐。现在根据地图缩放级别的变化也能够自动调整显隐了,因此有的时候就会有冲突。例如,地图的当前的zoom已经超出了tooltip的显示范围,tooltip被隐藏了,但是用户又执行了tooltip.setVisibility(true),结果tooltip又显示了。这种情况就是错误的,正确的应该是只要地图的缩放级别处于规定的范围之外,无论怎样都无法让tooltip显示出来。

1.新增minZoom与maxZoom属性

minZoommaxZoom分别表示Tooltip的最小缩放级别和最大缩放级别,只要在这个范围内Tooltip就可以正常显示(也可以通过setVisibility()方法控制显隐),超出这个范围Tooltip就会被隐藏(此时setVisibility()方法应当失效)。

minZoom的默认值为0maxZoom的默认值为Infinity

JavaScript 复制代码
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事件。

JavaScript 复制代码
this.on("change:map", this.handleTooltipMapChanged);

为了方便进行事件管理,我使用OpenLayers中两个事件方法listenunlistenByKeylisten方法用于绑定事件,它会返回一个EventsKey对象,其中存储了事件的信息(例如事件对象、事件处理函数)。unlistenByKey方法则会接收一个EventsKey对象然后移除该事件的绑定。

JavaScript 复制代码
/**
 * 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
 */
JavaScript 复制代码
  /**
   * @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的minZoommaxZoom的范围内。

JavaScript 复制代码
  /**
   * 是否在缩放范围内
   * @type {boolean}
   * @private
   */
  this.isOnZoomRange = true;

isZoomOnRange = false,则setVisibility()方法将会被禁用。

JavaScript 复制代码
  /**
   * 设置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的取值范围在09999之间。

JavaScript 复制代码
const MIN_Z_INDEX = 0;
const MAX_Z_INDEX = 9999;
JavaScript 复制代码
  /**
   * 设置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元素设置层级

JavaScript 复制代码
class Tooltip extends Overlay {
  constructor(options){
    ......
  
  }

  /**
   * 获取Tooltip的最大缩放级别
   * @returns {number} 当前最大缩放级别
   * @description 返回Tooltip的最大缩放级别
   */
  getMaxZoom() {
    return this.maxZoom;
  }

  /**
   * 获取Tooltip的最小缩放级别
   * @returns {number} 当前最小缩放级别
   * @description 返回Tooltip的最小缩放级别
   */
  getMinZoom() {
    return this.minZoom;
  }
}

完整代码

JavaScript 复制代码
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;

参考资料

  1. OpenLayers v10.5.0 API - Class: Overlay
  2. OpenLayers之 OverLay问题汇总_openlayers overlay zindex-CSDN博客
相关推荐
EndingCoder37 分钟前
React从基础入门到高级实战:React 实战项目 - 项目三:实时聊天应用
前端·react.js·架构·前端框架
阿阳微客2 小时前
Steam 搬砖项目深度拆解:从抵触到真香的转型之路
前端·笔记·学习·游戏
德育处主任Pro2 小时前
『React』Fragment的用法及简写形式
前端·javascript·react.js
CodeBlossom3 小时前
javaweb -html -CSS
前端·javascript·html
CodeCraft Studio3 小时前
【案例分享】如何借助JS UI组件库DHTMLX Suite构建高效物联网IIoT平台
javascript·物联网·ui
打小就很皮...3 小时前
HBuilder 发行Android(apk包)全流程指南
前端·javascript·微信小程序
集成显卡4 小时前
PlayWright | 初识微软出品的 WEB 应用自动化测试框架
前端·chrome·测试工具·microsoft·自动化·edge浏览器
前端小趴菜055 小时前
React - 组件通信
前端·react.js·前端框架
Amy_cx5 小时前
在表单输入框按回车页面刷新的问题
前端·elementui
dancing9995 小时前
cocos3.X的oops框架oops-plugin-excel-to-json改进兼容多表单导出功能
前端·javascript·typescript·游戏程序