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

项目中画圈找房效果

下面就来手把手从0开始实现一个画圈找房的demo~~ Js代码一共200行左右,很轻量
为什么写这篇文章
主要是想分享下在完成这个画圈找房功能的过程中,面对没有现成api调用或者方案的问题,自己的思路过程以及遇到的一些问题是怎么解决的
Step 0: 准备工作
此demo未采用框架,用原生js实现,项目里是用react技术栈实现,打开你最喜欢的IDE,新建如下3个文件draw.js, draw.css, draw.html, 我这里是用webstorm编辑代码,demo结构如下图

<!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


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

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个边都有交点,只不过重合了,所以要特殊处理,对于这种特例,可采取如下办法解决

//判定一个点是否包含在多边形内
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()方法,果然!能够获取到文本且没报错,由此可见文档不是准确的,自己要多尝试才能得出准确结果
