【约球online】小程序构建系列教程第三话——地图点聚合功能实现

文章目录

  • 前言
  • 需求
  • 聚合算法
    • 具体实现
  • 监听地图范围变化
    • 方案
  • 最终效果
  • 总结

前言

用过微信小程序的map组件开发地图的同学,应该知道mapcontext这个对象的api相当少,功能远不如百度、腾讯、高德地图的JavaScript API。如果要实现聚合、热力图等,都必须自己动手实现。

地图开发最常见的一个需求就是展示点要素,如果需要展示大量的点要素,如果全部展示,不仅渲染上存在很大的开销,而且也会存在图标压盖等问题,影响体验,因此就需要有点聚合的功能,并且随地图缩放来自动重算。

这篇教程就交大家如何实现一个通用的点聚合功能,并在文中附上源码。

需求

先把点聚合的具体需求罗列出来:

  1. 距离相近的点聚合成一个点
  2. 聚合点的label反应聚合的点数量
  3. 效率高
  4. 地图缩放重新聚合
  5. 地图缩放等级达到最大时,不聚合
  6. 点击聚合点,放大地图

其中,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。分别表示拖动地图、缩放地图和调用接口更新。

在该案例中,有三种情况:

  1. 拖动地图,触发drag,调用markerCluster
  2. 手动缩放地图,触发scale,调用markerCluster,此时又会触发update,又会调用一次markerCluster
  3. 点击聚合图标,修改地图的scale,触发update,调用markerCluster

这就会导致几个问题:

  1. 调用markerCluster函数的次数过多
  2. 手动缩放地图调用了两次markerCluster,第二次实际上没有必要调用markerCluster
  3. 手动缩放地图会以地图的中心坐标为原点进行缩放,因此缩放之前需要对地图中心坐标进行修正

方案

为了解决以上问题,提出以下调用逻辑:

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;
	}
}

说明:

  1. 参考了疫小搜小程序,对于地图的拖动不调用聚合函数,只在地图缩放时调用,调用时对所有点做聚合,不仅仅是可视范围内的点聚合。这样可以免去拖动地图更新图标的消耗。
  2. drag时对地图中心坐标做赋值修改
  3. drag后触发的update不做聚合

以上,我们就完成了一个通用的点聚合功能。

最终效果

【约球online】小程序构建系列教程第三话——地图点聚合功能实现_第1张图片
【约球online】小程序构建系列教程第三话——地图点聚合功能实现_第2张图片
具体效果可进入约球online小程序体验。

总结

约球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,只会调用一次。如下图

修改坐标
不满足条件
满足条件
满足条件
drag
update
不会调用markerCluster
scale
markerCluster
接口缩放update

综上,我认为该方案及算法,可以满足大部分小程序端地图点聚合需求。

你可能感兴趣的:(前端,地图,微信小程序,地图,微信小程序,点聚合)