用过微信小程序的map组件开发地图的同学,应该知道mapcontext这个对象的api相当少,功能远不如百度、腾讯、高德地图的JavaScript API。如果要实现聚合、热力图等,都必须自己动手实现。
地图开发最常见的一个需求就是展示点要素,如果需要展示大量的点要素,如果全部展示,不仅渲染上存在很大的开销,而且也会存在图标压盖等问题,影响体验,因此就需要有点聚合的功能,并且随地图缩放来自动重算。
这篇教程就交大家如何实现一个通用的点聚合
功能,并在文中附上源码。
先把点聚合的具体需求罗列出来:
其中,1、3和5与聚合算法相关,2、4和6与map组件相关。
据我的调研,地图的JS API的点聚合算法应该是利用四叉树对点建立索引,然后根据地图的缩放等级scale来获取聚合后的数据。这种算法实现起来相对复杂,不适合直接写在前端。所以,我考虑用另一种聚合的算法:格网聚合。
原理很简单,以可视范围的西南角为原点,将可视范围划分为等间隔的网格,然后遍历所有点,对于落入同一个网格的点进行聚合。该算法时间复杂度为O(n)。
参考map组件展示点marker时,传入的是一个marker对象数组,清空同样也是传入空数组,因此,我们的点聚合函数,传入应该是原本的marker对象数组,传出的是聚合后的marker对象数组。marker对象结构如下:
{
id: ,//点的id
latitude: ,//点的纬度
longitude:,//点的经度
iconPath: ,//图标的路径
width: 40,//图标的宽
height: 40,//图标的高
anchor: {//图标的偏移
x: .5,
y: 1
}
}
为了实现一个通用的点聚合方法,我们需要设计好的入参和出参。考虑到上述介绍中的特点,设计如下:
/*
* @param{scale}当前地图的缩放等级
* @param{col}网格的列数
* @param{defaultIcon}聚合点的图标路径
* @param{southwest}可视区域的西南角经纬度
* @param{northeast}可视区域的东北角经纬度
* @param{sourceList}marker对象数组
* @return{res}聚合后的marker对象数组
*/
function markerCluster(scale, col, defaultIcon, southwest, northeast, sourceList){
if (scale == 20) {//微信小程序的地图scale是3-20,20为最大缩放尺度,不聚合
return sourceList.map((item,index)=>{
return {
...item,
type: 'single',
id: new Date().getTime() + index
}
})
}
let map = new Map();
let threshold = (northeast.longitude - southwest.longitude) / col;//网格间距
let x = 0,
y = 0;
let cur = null;
let key = '';
//遍历marker
sourceList.forEach(item => {
x = Math.floor((item.longitude - southwest.longitude) / threshold)//网格列坐标
y = Math.floor((item.latitude - southwest.latitude) / threshold)//网格行坐标
key = [x, y].join('_')//以x_y的形式作为key值
cur = map.get(key)
if (cur) {
map.set(key, cur.concat(item))//key值存在则存入marker数组
} else {
map.set(key, [item])//key值不存在则创建marker数组
}
})
let res = [];
let index = 0
for (let [k, v] of map) {
if (v.length == 1) {//该网格只有一个marker,直接推入res
res.push({
...v[0],
type: 'single',//区分marker是单独点还是聚合点
id: new Date().getTime() + index
})
} else {//多个marker,合并为一个marker
[x, y] = k.split('_').map(item => parseInt(item))//根据key解析出x和y
res.push({
id: new Date().getTime() + index,
latitude: v.reduce((acc,cur)=>{return acc+cur.latitude},0) / v.length,//平均纬度
longitude: v.reduce((acc,cur)=>{return acc+cur.longitude},0) / v.length,//平均经度
type: 'cluster',
label: {//聚合数量用label显示,通用的属性也可以作为对象出入该函数,看具体需求
content: v.length.toString(),//聚合点数量
fontSize: 16,
padding: 0,
color: '#FFFFFF',
textAlign: 'left',
anchorX: -v.length.toString().length*(16+2)/2/2,//为了保证居中,需要往左偏移字体总宽度的一半,而字体宽度大致等于字体高度的一半,但也不是很准确,所以我这边+2
anchorY: -34,
},
iconPath: defaultIcon,
width: 40,
height: 40,
anchor: {
x: .5,
y: 1
}
})
}
index++
}
return res;
}
我们知道markerCluster函数需要传入southwest和northeast参数,很明显需要调用mapContext对象的getScale()函数获取。
当地图被拖动或者缩放,或者通过接口地图更新,会触发regionchange事件。该事件接收一个event参数,event有type、causedBy属性。一次地图变化会触发两次regionchange事件,一次是type为begin,一次是type是end。一般我们只需要在type==end的时候调用markerCluster函数即可。
这里需要注意,有三种causedBy:drag、scale和update。分别表示拖动地图、缩放地图和调用接口更新。
在该案例中,有三种情况:
此时又会触发update,又会调用一次markerCluster
这就会导致几个问题:
手动缩放地图会以地图的中心坐标为原点进行缩放,因此缩放之前需要对地图中心坐标进行修正
为了解决以上问题,提出以下调用逻辑:
const clusterFunc = rebounce(function() {//防抖500ms,避免短时间重复调用
this.mapContext.getRegion({//获取可视区域
success: region => {
this.mapContext.getScale({//获取缩放等级
success: res => {
let cur = [].concat(this.mapInfo.markers)
this.mapInfo.markers.splice(0, this.mapInfo.markers.length);//清空marker
markerCluster(res.scale, 5, '/static/imgs/svg_location_red.png', region.southwest, region.northeast, cur)
.forEach(it => {
this.mapInfo.markers.push(it)
})
}
})
}
})
}, 500)
//防抖函数
function rebounce(fn, timeout){
let st = null;
return function(){
st && clearTimeout(st);
st = setTimeout(()=>{
st = null;
fn.call(this, ...arguments);
},timeout);
}
}
regionchange(e) {
if (e.type == 'end') {
console.log(e.type, e.causedBy)
if (e.causedBy == 'drag') {//这里直接从e参数中获取当前中心点的经纬度,并更新地图的latitude和longitude
this.mapInfo.lat = e.target.centerLocation.latitude.toFixed(6);//经纬度保留6位小数,避免小数点过多导致的地图重复刷新
this.mapInfo.lng = e.target.centerLocation.longitude.toFixed(6);
}
if ((this.lastEvent == 'update' && e.causedBy == 'update') || e.causedBy == 'scale') {//只有当地图缩放或者前一次事件为update且当前事件为update时,才调用markerCluster函数
clusterFunc.call(this);//防抖函数,调用了markerCluster
}
this.lastEvent = e.causedBy;
}
}
说明:
以上,我们就完成了一个通用的点聚合功能。
约球online第一个版本是将所有的用户点都展示到地图上,没有做聚合,所以会发现渲染完全部点,都得花上1s左右,体验上不好。因此我很早就萌生实现地图点聚合功能。
聚合算法很快就写出来,但是在调用聚合算法的选择上遇到了很多问题,改动了很多次。我在文中写的是我最终的方案,也是我认为效果最好的一种。实际上,真实的版本是这样的:
版本1
在end中调用markerCluster
问题
缩放时以地图中心点位原点
版本2
在end中,当为drag时,调用getCenterLocation获取中心点坐标,赋值给map,当为drag、scale或前一次update且这一次也是update时,调用markerCluster
问题
拖动地图由于给地图中心点坐标赋值,导致地图更新,地图发生闪动
版本3
在end中,当为scale时,赋值map中心点坐标,当scale或前一次update且这一次也是update时,调用markerCluster
问题
当手动缩放地图,会调用两次markerCluster。但是闪动问题依然存在,但次数没有版本2多。
版本4
(文中的版本)在end中,当为drag时,赋值map中心点坐标,当scale或前一次update且这一次也是update时,调用markerCluster
说明
闪动问题产生的原因是两次update间时间间隔很短。该方案在拖动时只修改中心点坐标,不聚合;手动缩放或调用接口缩放时,调用markerCluster,只会调用一次。如下图
综上,我认为该方案及算法,可以满足大部分小程序端地图点聚合需求。