OpenLayers:封装一个自定义罗盘控件

我一直希望的我OpenLayers项目中可以有一个罗盘控件,用来指示地图的旋转角度。OpenLayers内置有一个罗盘控件,可它实在是过于简陋了😂😅。

我在网络上查阅了一些资料后,发现有不少仿照百度地图的罗盘功能的文章,我也尝试着实现了一个,效果如下:

一、实现过程

这个罗盘控件的功能大概有三点:

  1. 罗盘的指针随着地图视图的旋转而旋转
  2. 点击罗盘两侧的箭头按钮可以让地图沿顺时针/逆时针方向旋转固定的角度
  3. 点击罗盘的指针可以让地图的旋转角度归零

功能1只需要侦听地图的旋转事件(map#moveend事件或view#change:rotation事件),在地图旋转后以同样的角度旋转指针元素(使用csstransform:rotate())。

功能2和功能3只需要在点击按钮后调用view.animate()方法即可实现。

1.控件样式

JavaScript 复制代码
.ol-custom-compass {
  position: absolute;
  z-index: 5;
  bottom: 34px;
  right: 7px;
  width: 52px;
  height: 54px;
  background: url(./img/earth-navi-control-pc4.png) 0% 0% / 266px no-repeat;
}

.ol-custom-compass .left {
  position: absolute;
  outline: none;
  border: none;
  background: url(./img/earth-navi-control-pc4.png) -75px -5px / 266px no-repeat;
  cursor: pointer;
  left: 2px;
  top: 5px;
  z-index: 1;
  width: 15px;
  height: 42px;
  opacity: 1;
}

.ol-custom-compass .left:hover {
  background: url(./img/earth-navi-control-pc4.png) -89px -5px / 266px no-repeat;
}

.ol-custom-compass .center {
  position: absolute;
  outline: none;
  border: none;
  background: url(./img/earth-navi-control-pc4.png) -56px -4px / 266px no-repeat;
  cursor: pointer;
  left: 19px;
  top: 4px;
  width: 14px;
  height: 44px;
  transform: rotate(0deg);
  opacity: 1;
  transition: all 0.5s;
}

.ol-custom-compass .right {
  position: absolute;
  outline: none;
  border: none;
  background: url(./img/earth-navi-control-pc4.png) -75px -5px / 266px no-repeat;
  cursor: pointer;
  right: 2px;
  top: 5px;
  z-index: 1;
  width: 15px;
  height: 42px;
  transform: scaleX(-1);
  opacity: 1;
}

.ol-custom-compass .right:hover {
  background: url(./img/earth-navi-control-pc4.png) -89px -5px / 266px no-repeat;
}

其中所用到的图片素材如下,它源自百度地图。

2.控件类封装

JavaScript 复制代码
import { Control } from "ol/control";
import { listen } from "ol/events.js";

/**
 * @typedef {Object} Options
 * @property {function(import("../MapEvent.js").default):void} [render] Function called when the
 * control should be re-rendered. This is called in a `requestAnimationFrame`callback.
 * @property {import("../coordinate.js").CoordinateFormat} [coordinateFormat] Coordinate format.
 * @property {import("../proj.js").ProjectionLike} [projection] Projection. Default is the view projection.
 * @property {string} [className='ol-custom-compass'] CSS class name.
 * @property {HTMLElement|string} [target] Specify a target if you want the
 * control to be rendered outside of the map's viewport.
 * @property {number} [angleDelta=Math.PI/2] Angle delta.
 */

class Compass extends Control {
  constructor(options = {}) {
    const element = document.createElement("div");
    element.className = options.className || "ol-custom-compass";

    element.innerHTML = `
        <button class="left"></button>
        <button class="center"></button>
        <button class="right"></button>
    `;

    super({
      element: element,
      render: options.render,
      target: options.target,
    });

    element
      .querySelector(".left")
      .addEventListener("click", this.reverseClick.bind(this));
    element
      .querySelector(".center")
      .addEventListener("click", this.recoveryClick.bind(this));
    element
      .querySelector(".right")
      .addEventListener("click", this.forwardClick.bind(this));

    this.element_ = element;

    this.angleDelta = options.angleDelta || Math.PI / 2;
  }

  /**
   * @override
   */
  setMap(map) {
    super.setMap(map);
    if (map) {
      this.listenerKeys.push(
        listen(map, "moveend", this.handleRotationChange, this)
      );
    }
  }

  /**
   * @private
   */
  handleRotationChange(event) {
    const currentRotation =
      (this.getMap()?.getView().getRotation() * 180) / Math.PI;

    this.element_.querySelector(
      ".center"
    ).style.transform = `rotate(${currentRotation}deg)`;
  }



  /**
   * 逆时针旋转
   * @private
   */
  reverseClick() {
    const view = this.getMap()?.getView();

    if (view) {
      const center = view.getCenter();
      const rotation = view.getRotation();

      view.animate({
        center,
        duration: 200,
        rotation: rotation - this.angleDelta,
      });

    }
  }

  /**
   * 顺时针旋转
   * @private
   */
  forwardClick() {
    const view = this.getMap()?.getView();

    if (view) {

      const center = view.getCenter();
      const rotation = view.getRotation();

      view.animate({
        center,
        duration: 200,
        rotation: rotation + this.angleDelta,
      });
    }
  }

  /**
   * 恢复到0度
   * @private
   */
  recoveryClick() {
    const view = this.getMap()?.getView();

    if (view) {
      const center = view.getCenter();
      view.animate({
        center,
        duration: 200,
        rotation: 0,
      });

    }
  }
}

export default Compass;

二、解决罗盘指针旋转问题

1.发现问题

在我满心欢喜的去试用封装好的罗盘时,我就发现罗盘指针旋转的时候常常就会出现异常的情况。例如下面这样:

我不断的点击按钮让地图向逆时针方向转动90°,当从-180°继续逆时针旋转90°的时候,地图的旋转角度居然莫名其妙的变为90°(本来应该是-270°)。这导致指针变成顺时针旋转了270°,这不符合我的期望。

又比如当我拖动旋转地图到-689°,然后点击指针让旋转角度归零,可以看到此时指针转了好几圈才会到0位置,这也是不符合我的期望的,我希望指针可以按照最短的路径回归到零位置(正北反向)。

2.问题分析

据我观察之所以会出现第一个问题(旋转到-270°变为旋转到90°)是因为OpenLayers的view.animate()方法会将传入的角度进行处理,以保证地图的旋转角度处于 -180° ~ 180° 之间 ,例如 -270° 将会被转换为 90°,270°将会被转换为-90°。与此同时如果我是使用地图交互的方式旋转地图(shift+alt + 鼠标左键旋转地图)则又是完全正常的旋转到-270°就是-270°不会被转换为90°。

当然对于OpenLayers的视图而言无论是用哪种方式进行旋转都不会出现问题(它的内部应该是进行过处理的),但是对于我的指针元素来说则则不是这样,它是基于css实现旋转的,就会受到影响。所以其实我也要对指针的旋转做专门的处理。

至于"最短路径归零"的问题,它实际上是符合css旋转的规则的,只是不符合我的需求,我也需要进行一些处理打破css旋转的默认规则。

3.解决方案

我的解决方案是对三种可能的旋转操作(地图交互、旋转按钮、归零按钮)分别进行处理。目前的情况是这三种旋转操作高度耦合,例如都会执行旋转事件处理函数,都直接采用地图的旋转角度设置指针的旋转角度。这会造成各种冲突,使我无法很好的针对三种操作的特点进行专门的处理。所以当务之急是进行解耦。

首先我不能再直接用地图的rotation来作为罗盘指针的旋转角度了,我新增了一个transformRotation属性来专门存储罗盘指针的旋转角度,这样就可以规避view.animate()方法带来的rotation的突变。

其次handleRotationChange旋转事件处理函数,只在地图交互后执行,点击旋转和归零按钮时就不要执行其中的逻辑了,以免造成重复设置指针旋转角度的情况。为此新增了一个isUseHandleRotationChange属性来控制。

JavaScript 复制代码
class Compass extends Control {
  constructor(options = {}) {
    ......
    /**
     * 当前罗盘指针的旋转角度
     * @private
     */
    this.transformRotation = 0;

    /**
     * 是否使用handleRotationChange事件
     * @private
     */
    this.isUseHandleRotationChange = true;
  }

  /**
   * @private
   */
  handleRotationChange(event) {
    const currentRotation =
      (this.getMap()?.getView().getRotation() * 180) / Math.PI;

    if (this.isUseHandleRotationChange) {
      // 只在用户手动选择地图时触发,点击罗盘上的按钮不会触发
      this.setPointerRotation(currentRotation);
    }

    this.isUseHandleRotationChange = true;
  }

  /**
   * 设置指针旋转
   * @param {number} rotation 旋转角度
   * @param {boolean} isUseTransition 是否使用过渡
   */
  setPointerRotation(rotation, isUseTransition = true) {
    ......
  }

  /**
   * 模仿view.animate的格式化旋转角度,将旋转角度限制在-180到180之间
   * @param {number} rotation 旋转角度
   * @returns {number} 格式化后的旋转角度
   */
  formatRotation(rotation) {
    if (rotation < 180 && rotation >= -180) {
      return rotation;
    } else {
      return this.formatRotation(
        rotation > 0 ? rotation - 360 : rotation + 360
      );
    }
  }

  /**
   * 逆时针旋转
   * @private
   */
  reverseClick() {
    const view = this.getMap()?.getView();

    if (view) {
      this.isUseHandleRotationChange = false;

      const center = view.getCenter();
      const rotation = view.getRotation();

      view.animate({
        center,
        duration: 200,
        rotation: rotation - this.angleDelta,
      });

      this.setPointerRotation(
        this.transformRotation - (this.angleDelta * 180) / Math.PI
      );
    }
  }

  /**
   * 顺时针旋转
   * @private
   */
  forwardClick() {
    const view = this.getMap()?.getView();

    if (view) {
      this.isUseHandleRotationChange = false;

      const center = view.getCenter();
      const rotation = view.getRotation();

      view.animate({
        center,
        duration: 200,
        rotation: rotation + this.angleDelta,
      });

      this.setPointerRotation(
        this.transformRotation + (this.angleDelta * 180) / Math.PI
      );
    }
  }

  /**
   * 恢复到0度
   * @private
   */
  recoveryClick() {
    ......
  }
}

export default Compass;

至于归零按钮的问题,我观察了百度地图中罗盘指针的样式在归零过程中的变化,发现它是这样做的,假设我要将-270°的指针旋转到0°,只需要先将指针旋转到90°(注意关闭过渡样式),然后再旋转到0°。

最后经过优化的代码如下:

JavaScript 复制代码
import { Control } from "ol/control";
import { listen } from "ol/events.js";

/**
 * @typedef {Object} Options
 * @property {function(import("../MapEvent.js").default):void} [render] Function called when the
 * control should be re-rendered. This is called in a `requestAnimationFrame`callback.
 * @property {import("../coordinate.js").CoordinateFormat} [coordinateFormat] Coordinate format.
 * @property {import("../proj.js").ProjectionLike} [projection] Projection. Default is the view projection.
 * @property {string} [className='ol-custom-compass'] CSS class name.
 * @property {HTMLElement|string} [target] Specify a target if you want the
 * control to be rendered outside of the map's viewport.
 * @property {number} [angleDelta=Math.PI/2] Angle delta.
 */

class Compass extends Control {
  constructor(options = {}) {
    const element = document.createElement("div");
    element.className = options.className || "ol-custom-compass";

    element.innerHTML = `
        <button class="left"></button>
        <button class="center"></button>
        <button class="right"></button>
    `;

    super({
      element: element,
      render: options.render,
      target: options.target,
    });

    element
      .querySelector(".left")
      .addEventListener("click", this.reverseClick.bind(this));
    element
      .querySelector(".center")
      .addEventListener("click", this.recoveryClick.bind(this));
    element
      .querySelector(".right")
      .addEventListener("click", this.forwardClick.bind(this));

    this.element_ = element;

    this.angleDelta = options.angleDelta || Math.PI / 2;

    /**
     * 当前罗盘指针的旋转角度
     * @private
     */
    this.transformRotation = 0;

    /**
     * 是否使用handleRotationChange事件
     * @private
     */
    this.isUseHandleRotationChange = true;
  }

  /**
   * @override
   */
  setMap(map) {
    super.setMap(map);
    if (map) {
      this.listenerKeys.push(
        listen(map, "moveend", this.handleRotationChange, this)
      );
    }
  }

  /**
   * @private
   */
  handleRotationChange(event) {
    const currentRotation =
      (this.getMap()?.getView().getRotation() * 180) / Math.PI;

    if (this.isUseHandleRotationChange) {
      this.setPointerRotation(currentRotation);
    }

    this.isUseHandleRotationChange = true;
  }

  /**
   * 设置指针旋转
   * @param {number} rotation 旋转角度
   * @param {boolean} isUseTransition 是否使用过渡
   */
  setPointerRotation(rotation, isUseTransition = true) {
    const pointer = this.element_.querySelector(".center");

    pointer.style.transform = `rotate(${rotation}deg)`;
    pointer.style.transition = isUseTransition ? "all 0.5s" : "none";

    this.transformRotation = rotation;
  }

  /**
   * 模仿view.animate的格式化旋转角度,将旋转角度限制在-180到180之间
   * @param {number} rotation 旋转角度
   * @returns {number} 格式化后的旋转角度
   */
  formatRotation(rotation) {
    if (rotation < 180 && rotation >= -180) {
      return rotation;
    } else {
      return this.formatRotation(
        rotation > 0 ? rotation - 360 : rotation + 360
      );
    }
  }

  /**
   * 逆时针旋转
   * @private
   */
  reverseClick() {
    const view = this.getMap()?.getView();

    if (view) {
      this.isUseHandleRotationChange = false;

      const center = view.getCenter();
      const rotation = view.getRotation();

      view.animate({
        center,
        duration: 200,
        rotation: rotation - this.angleDelta,
      });

      this.setPointerRotation(
        this.transformRotation - (this.angleDelta * 180) / Math.PI
      );
    }
  }

  /**
   * 顺时针旋转
   * @private
   */
  forwardClick() {
    const view = this.getMap()?.getView();

    if (view) {
      this.isUseHandleRotationChange = false;

      const center = view.getCenter();
      const rotation = view.getRotation();

      view.animate({
        center,
        duration: 200,
        rotation: rotation + this.angleDelta,
      });

      this.setPointerRotation(
        this.transformRotation + (this.angleDelta * 180) / Math.PI
      );
    }
  }

  /**
   * 恢复到0度
   * @private
   */
  recoveryClick() {
    const view = this.getMap()?.getView();

    if (view) {
      this.isUseHandleRotationChange = false;

      const center = view.getCenter();
      view.animate({
        center,
        duration: 200,
        rotation: 0,
      });

      this.setPointerRotation(
        this.formatRotation(this.transformRotation),
        false
      );

      //因为上一次的动画没有结束,所以需要等待动画结束后再设置为0
      requestAnimationFrame(() => {
        this.setPointerRotation(0);
      });
    }
  }
}

export default Compass;

参考资料

  1. openlayers+vue 仿百度罗盘功能(指北针)_openlayers 指北针-CSDN博客
  2. openlayers 封装指南针控件
相关推荐
小小小小宇2 小时前
TS泛型笔记
前端
小小小小宇2 小时前
前端canvas手动实现复杂动画示例
前端
codingandsleeping2 小时前
重读《你不知道的JavaScript》(上)- 作用域和闭包
前端·javascript
小小小小宇2 小时前
前端PerformanceObserver使用
前端
zhangxingchao3 小时前
Flutter中的页面跳转
前端
前端风云志4 小时前
TypeScript实用类型之Omit
javascript
烛阴4 小时前
Puppeteer入门指南:掌控浏览器,开启自动化新时代
前端·javascript
全宝5 小时前
🖲️一行代码实现鼠标换肤
前端·css·html
小小小小宇5 小时前
前端模拟一个setTimeout
前端
萌萌哒草头将军5 小时前
🚀🚀🚀 不要只知道 Vite 了,可以看看 Farm ,Rust 编写的快速且一致的打包工具
前端·vue.js·react.js