我一直希望的我OpenLayers项目中可以有一个罗盘控件,用来指示地图的旋转角度。OpenLayers内置有一个罗盘控件,可它实在是过于简陋了😂😅。
我在网络上查阅了一些资料后,发现有不少仿照百度地图的罗盘功能的文章,我也尝试着实现了一个,效果如下:

一、实现过程
这个罗盘控件的功能大概有三点:
- 罗盘的指针随着地图视图的旋转而旋转
- 点击罗盘两侧的箭头按钮可以让地图沿顺时针/逆时针方向旋转固定的角度
- 点击罗盘的指针可以让地图的旋转角度归零
功能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;