原生Js从0开始实现一个链家网地图画圈找房功能

5,887 阅读14分钟

概述

最近做项目的时候遇到的一个需求:要实现一个链家网地图找房中的画圈找房功能。链家网是采用百度地图实现房源展示,先来看看链家网的画圈找房功能,有木有很炫酷~~,可以到链家上体验一下

链家网画圈找房效果

下面是项目中实现的画圈找房,可以看出效果和链家网很相似

项目中画圈找房效果

下面就来手把手从0开始实现一个画圈找房的demo~~ Js代码一共200行左右,很轻量

为什么写这篇文章

主要是想分享下在完成这个画圈找房功能的过程中,面对没有现成api调用或者方案的问题,自己的思路过程以及遇到的一些问题是怎么解决的

Step 0: 准备工作

此demo未采用框架,用原生js实现,项目里是用react技术栈实现,打开你最喜欢的IDE,新建如下3个文件draw.js, draw.css, draw.html, 我这里是用webstorm编辑代码,demo结构如下图

draw.html,draw.css代码如下,js文件暂时为空,

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>链家网画圈找房demo</title>
    <link href="draw.css" rel="stylesheet" type="text/css"/>
</head>
<body>
<div class="wrapper">
    <div class="map-container" id="container">
    </div>
    <div class="panel">
        <div class="top">
            <button class="btn" id="draw">画圈找房</button>
            <button class="btn" id="exit">退出画圈</button>
        </div>
        <div class="bottom">
            <ul id="data">
            </ul>
        </div>
    </div>
</div>
<script type="text/javascript" src="draw.js"></script>
</body>
</html>
html,body{
    margin:0;
    padding:0;
    height:100%;
    min-width:800px;
}
ul,li{
    margin:0;
    padding:0;
}
.wrapper{
    height:100%;
    padding-right:300px;
}
.map-container{
    height:100%;
    width:100%;
    float:left;
}
.panel{
    float:left;
    margin-left:-300px;
    width:300px;
    height:100%;
    position: relative;
    right:-300px;
    box-shadow: -2px 2px 2px #d9d9d9;
}
.top{
    height:150px;
    padding:10px;
    border-bottom: 1px solid #bfbfbf;
}
.bottom{
    position: absolute;
    top:171px;
    bottom:0;
    width:100%;
}
.btn{
    outline:none;
    border:none;
    display: block;
    margin: 20px auto;
    font-size: 17px;
    color:#fff;
    border-radius: 4px;
    padding:8px;
    background-color: #969696;
    cursor:pointer;
    transition: all .5s;
}
.btn:hover{
    background-color: #b8b8b8;
}

#data li {
    width:100%;
    height:50px;
    border-bottom: 1px dashed #bfbfbf;
    padding:10px 20px;
    list-style-type: none;
    line-height: 50px;
    color: #737373;
}

上述实现了一个左侧自适应右侧固定宽度的布局,左侧容器用于展示百度地图,右侧是操作面板,页面如下图所示,点击画圈找房进入画圈状态,点击退出画圈按钮进入正常操作地图状态, 按钮下面是显示数据列表部分


Step 1:百度地图初始化

本demo需要地图的支持,这里采用百度地图,腾讯地图和高德地图也应该可以实现本demo的效果,首先登录百度地图开放平台进行账号注册,如果已有百度账号可以不用注册
在使用百度地图服务前需要申请密钥(ak), 点击 开发文档 -> JavaScript API进入Javascript指南部分,按照指南注册好自己的密钥(ak)

申请好的密钥如下所示

然后在添加百度地图脚本到draw.html中,然后把ak后面的中文换成刚刚申请的密钥即可

<script type="text/javascript" src="http://api.map.baidu.com/api?v=2.0&ak=您的密钥">

最后在draw.js里写下如下代码,百度地图初始化就完了成了,运行draw.html可以看到地图已经展示出来了(见下图)!第一句代码里的container是地图容器的id,我们选择北京作为展示坐标

window.onload = function(){
    var map = new BMap.Map("container");          // 创建地图实例
    var point = new BMap.Point(116.404, 39.915);  // 创建点坐标
    map.centerAndZoom(point, 15);                 // 初始化地图,设置中心点坐标和地图级别
    map.enableScrollWheelZoom(true);              // 开启鼠标滚轮缩放
}

至此,百度地图初始化完成~~~

Step 2: 事件绑定和点数据放置

我们需要在地图上放置几个点作为画圈的初始数据,查阅百度地图API,写下在draw.js里写如下函数进行初始化,并在window.onload中调用该方法

//初始化地图坐标点
function initMapMarkers(map){
    //地图上需要标注点的坐标信息(经度,纬度,文本描述)
    var dataList = [
    	[116.351951,39.929543,'北京国宾酒店'],
    	[116.404556,39.92069,'故宫博物院'],
    	[116.479008,39.925781,'呼家楼'],
    	[116.368624,39.870869,'首都医科大学'],
    	[116.4471,39.849601,'宋家庄']
    ];
    //创建marker和label(文字标签)并显示在地图上
    dataList.forEach(function(item){
    	var point = new BMap.Point(item[0],item[1])
    	var marker = new BMap.Marker(point);
    	var label = new BMap.Label(item[2],{offset:new BMap.Size(20,-10)});
    	marker.setLabel(label);
    	markerList.push(marker);
    	map.addOverlay(marker)
    })
}
刷新页面发现地图上已经有了点数据和标注

然后我们需要设置一些全局变量存储画圈逻辑的相关数据,后面会解释各个变量的作用

/*** 界面元素 ***/
//画圈按钮
var drawBtn = document.getElementById('draw')
//退出画圈按钮
var exitBtn = document.getElementById('exit')
//画圈完成的数据展示列表
var ul = document.getElementById('data')

/*** 画圈有关的数据结构 ***/
//是否处于画圈状态下
var isInDrawing = false;
//是否处于鼠标左键按下状态下
var isMouseDown = false;
//存储画出折线点的数组
var polyPointArray = [];
//上次操作画出的折线
var lastPolyLine = null;
//画圈完成后生成的多边形
var polygonAfterDraw = null;
//存储地图上marker的数组
var markerList = [];

整个demo的基本逻辑如下:
点击画圈找房按钮进入画圈状态,在画圈状态下,在地图上按下鼠标左键开始画圈操作,移动鼠标进行画圈操作,然后抬起鼠标左键完成画圈,此时地图上会显示出圈,然后右列表会显示出圈内坐标的文本。最后点击退出画圈按钮退出画圈状态

因此需要为地图以及按钮绑定事件,写下如下函数进行事件绑定:

//开始画圈绑定事件
drawBtn.addEventListener('click',function(e){
	//禁止地图移动点击等操作
	map.disableDragging();
	map.disableScrollWheelZoom();
	map.disableDoubleClickZoom();
	map.disableKeyboard();
	map.setDefaultCursor('crosshair');
	//设置标志位进入画圈状态
	isInDrawing = true;
});
//退出画圈按钮绑定事件
exitBtn.addEventListener('click',function(e){
	//恢复地图移动点击等操作
	map.enableDragging();
	map.enableScrollWheelZoom();
	map.enableDoubleClickZoom();
	map.enableKeyboard();
	map.setDefaultCursor('default');
	//设置标志位退出画圈状态
	isInDrawing = false;
})

Step 3: 如何实现‘画’操作

这是本demo的第一个难点,如何实现链家的这种类似画笔的画操作?我翻看了百度地图关于画图的所有api后,只发现百度地图提供了绘制圆,直线,多边形,矩形的api,官网demo如下所示:

其中最符合需求的就是一个画出矩形或者圆,但是这样也达不到链家那种很连贯随意画图的效果,怎么办?开始也很费解,仔细研究链家网的画圈,放大地图进行画图观察,发现看似很连贯画出的图在放大状态下是由折线段组成,这就说明了这其实是用线段模拟画圈的操作

回去继续查阅百度地图api,发现有在地图上画出折线的api,其参数是一个由Point组成的数组,只要给出这个数组就能画出折线来,点击这里前往百度地图api

因此逐渐有了眉目,那就是需要获取这样一个由不同Point组成的数组然后调用该api就能在地图上画图了,那么如何获取数组呢?因为图时在鼠标按下且移动过程中画出来的,所以肯定是在map的mousemove事件上做文章,我猜想mousemove的回调函数中能够获取鼠标在地图上的坐标点,然后继续查阅相关api,果然!验证了我的猜想

如下代码便能够获取到鼠标移动过程中的每一个点的坐标

map.addEventListener('mousemove',function(e){
    console.log(e.point)
})

既然点能获取到了,那么就用一个数组把这些点保存下来用于后续画线操作。然后整个画线逻辑就很明显了:每次mousemove触发都往数组中push当前鼠标所在点,然后调用api进行画线,同时用一个lastPolyLine变量记录下上次画的线,因为每次mousemove触发都会把从头到尾把整个数组的点画出来,所以需要擦除上次画的线段,然后画上新的线段,否则地图上的线段将会重叠起来越积越多。这样就可以写出如下代码

map.addEventListener('mousemove',function(e){
	//如果处于鼠标按下状态,才能进行画操作
	if(isMouseDown){
		//将鼠标移动过程中采集到的路径点加入数组保存
		polyPointArray.push(e.point);
		//除去上次的画线
		if(lastPolyLine) {
			map.removeOverlay(lastPolyLine)
		}
		//根据已有的路径数组构建画出的折线
		var polylineOverlay = new window.BMap.Polyline(polyPointArray,{
			strokeColor:'#00ae66',
			strokeOpacity:1,
			enableClicking:false
		});
		//添加新的画线到地图上
		map.addOverlay(polylineOverlay);
		//更新上次画线条
		lastPolyLine = polylineOverlay
	}
})

注意一个小细节,需要给Polyline参数设置一个enableClicking为false的属性,否则鼠标移到画出的线段上时会显示可点击图标,注意上述代码并没有处理删除上次画线的逻辑,这是放在map的mousedown事件里处理

继续分析,当鼠标抬起时表明画线完成,此时地图上会显示一个有填充颜色的多边形,这个怎么处理?也很简单,百度地图提供了画多边形的api

其参数恰好也是一个由Point组成的数组,这个数组就是上述画线的数组,一模一样。因此当鼠标抬起时,擦除上次画的线,然后再根据polyPointArray绘制一个多边形不就画出了整个圈了么!map的mouseup代码如下

map.addEventListener('mouseup',function(e){
	//如果处于画圈状态下 且 鼠标是按下状态
	if(isInDrawing && isMouseDown){
		//退出画线状态
		isMouseDown = false;
		//添加多边形覆盖物,设置为禁止点击
		var polygon = new window.BMap.Polygon(polyPointArray,{
			strokeColor:'#00ae66',
			strokeOpacity:1,
			fillColor:'#00ae66',
			fillOpacity:0.3,
			enableClicking:false
		});
		map.addOverlay(polygon);
		//保存多边形,用于后续删除该多边形
		polygonAfterDraw = polygon
		//计算房屋对于多边形的包含情况
		var ret = caculateEstateContainedInPolygon(polygonAfterDraw);
		//更新dom结构
		ul.innerHTML = '';
		var fragment = document.createDocumentFragment();
		for(var i=0;i<ret.length;i++){
			var li = document.createElement('li');
			li.innerText ? li.innerText = ret[i] : li.textContent = ret[i];
			fragment.appendChild(li);
		}
		ul.appendChild(fragment);
	}
});

多边形有各种参数可以设置其样式,画出的多边形可能很奇怪,因为你可以乱画,就像涂鸦一样,下图这种多边形看似不合法其实也没啥问题,中间可以有各种洞,这里面的具体逻辑就是百度api内部的事情了


代码后半部分caculateEstateContainedInPolygon方法会计算出地图上哪些点包含在所画出的圈内,然后会更新右侧列表刷新数据显示,下一节详细介绍

Step 4: 如何判断点在任意多边形内

这是本demo的第二个难点,网上一番查阅,发现一个叫射线法的方法比较好理解,如下图所示

原理就是地图上每个点往右侧发出一条射线,然后计算该射线与多边形边交点的个数
奇数个: 比如c,e,那么点就在多边形内部
偶数个: 比如a,b,那么点就在多边形外部
不过有一种特例,如果点在内部且与多边形的交点恰好在2个线段的交点上,比如X点,该点在多边形内部,但是该点与多边形2个边都有交点,只不过重合了,所以要特殊处理,对于这种特例,可采取如下办法解决

如上图,x,y,z都是特例点,x,z在多边形外,y在多边形内,按之前的思路y是有2个交点,而x也有2个交点,但是实际是一内一外,所以我们需要重新定义交点的含义:我们规定当交点所在线段的2个点都在交点以上,该交点能算一个交点。这样一来,对于y,交点在c,cd都在y点上面,因此算一个交点,而cb的b点在y下面,因此不算交点,所以y点最终只有一个交点。同理x交点为0个,z交点为2个。这个方法落实到代码里也很简单,下面就是上述思路的实现

//判定一个点是否包含在多边形内
function isPointInPolygon(point,bound,pointArray){
	//首先判断该点是否在外包矩形内,如果不在直接返回false
	if(!bound.containsPoint(point)){
		return false;
	}
	//如果在外包矩形内则进一步判断
	//该点往右侧发出的射线和矩形边交点的数量,若为奇数则在多边形内,否则在外
	var crossPointNum = 0;
	for(var i=0;i<pointArray.length;i++){
		//获取2个相邻的点
		var p1 = pointArray[i];
		var p2 = pointArray[(i+1)%pointArray.length];
		//lng是经度,lat是纬度
		//如果点坐标相等直接返回true
		if((p1.lng===point.lng && p1.lat===point.lat)||(p2.lng===point.lng && p2.lat===point.lat)){
			return true
		}
		//如果point在2个点所在直线的下方则continue
		if(point.lat < Math.min(p1.lat,p2.lat)){
			continue;
		}
		//如果point在2个点所在直线的上方则continue
		if(point.lat >= Math.max(p1.lat,p2.lat)){
			continue;
		}
		//有相交情况:2个点一上一下,计算交点
		//特殊情况2个点的横坐标相同
		var crossPointLng;
		//如果线段2个点x相同,则斜率无穷大,特殊处理
		if(p1.lng === p2.lng){
			crossPointLng = p1.lng;
		}else{
			//计算2个点的斜率
			var k = (p2.lat - p1.lat)/(p2.lng - p1.lng);
			//得出水平射线与这2个点形成的直线的交点的横坐标
			crossPointLng = (point.lat - p1.lat)/k + p1.lng;
		}
		//如果crossPointLng的值大于point的横坐标则算交点(因为是右侧相交)
		if(crossPointLng > point.lng){
			crossPointNum++;
		}

	}
	//如果是奇数个交点则点在多边形内
	return crossPointNum%2===1
}

注意一个优化之处bound.containsPoint(point),首先判断该点是否多边形在外包矩形内,如果这个前提都不满足则直接pass, containsPoint是api提供的接口,可以免去自己写方法,由此可见要多读api文档,可以减少工作量

注意这里判断直线位置关系的代码,第一个if没有等于,第二个有等号,这里就实现了上述特例的判断,这里其实任意一个if有等号即可,还有要注意计算交点位置的代码很容易写错,首先线段2个端点可算出斜率,然后交点和其中一个端点又可算出斜率,而交点的y已确定,因此求出交点x值就轻而易举。
那么如何判断是右侧的射线相交呢?很简单,只需判断交点的x值大于坐标点的x值即可

//如果point在2个点所在直线的下方则continue
if(point.lat < Math.min(p1.lat,p2.lat)){
	continue;
}
//如果point在2个点所在直线的上方则continue
if(point.lat >= Math.max(p1.lat,p2.lat)){
	continue;
}

剩下的就是对地图上所有点进行依次判断即可,这里的markerList是一个全局变量,在地图初始化过程中记录了所有点的marker实例,这里面getPath,getBounds等都是api接口,最后我们返回所有marker上的label,即文本数组

//计算地图上点的包含状态
function caculateEstateContainedInPolygon(polygon){
	//得到多边形的点数组
	var pointArray = polygon.getPath();
	//获取多边形的外包矩形
	var bound = polygon.getBounds();
	//在多边形内的点的数组
	var pointInPolygonArray = [];
	//计算每个点是否包含在该多边形内
	for(var i=0;i<markerList.length;i++){
		//该marker的坐标点
		var markerPoint = markerList[i].getPosition();
		if(isPointInPolygon(markerPoint,bound,pointArray)){
			pointInPolygonArray.push(markerList[i])
		}
	}
	var estateListAfterDrawing = pointInPolygonArray.map(function(item){
		return item.getLabel().getContent()
	})
	return estateListAfterDrawing
}



至此,整个demo核心功能全部完成~~右侧显示出了3个被圈住的坐标点



结语

本demo的全部代码放在github上,点这里进入~~

项目过程中遇到的一个坑:百度地图的api不是准确无误的,比如Label的api部分,当时我需要根据一个label实例得到label的文本内容,此文档只有setContent方法获取文档,没有getContent,当时我就震惊了,这怎么办?如果获取不到文本就没法做了,难道是百度地图漏写了?我在代码中尝试getContent()方法,果然!能够获取到文本且没报错,由此可见文档不是准确的,自己要多尝试才能得出准确结果