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 封装指南针控件
相关推荐
崔庆才丨静觅1 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了2 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅2 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅3 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment3 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅3 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊4 小时前
jwt介绍
前端
爱敲代码的小鱼4 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax