最近使用高德地图 JavaScript API 开发地图应用,提炼了不少心得,故写点博文,做个系列总结一下,希望能帮助到LBS开发同胞们。
项目客户端使用高德地图 JavaScript API,主要业务为以区县为基础自由划分区域,并将划分好的区域存入数据库,以作后续操作。
开发之初便遇见一个问题,客户可以在城市区县范围内自由划分自己需要的区域,但是高德地图并未提供自定义区域的实现方法,所以只能借助API自造轮子。
经过讨论得出一个实现方法,初始加载城市区县区域后,自定义折线对象,然后在区域内通过鼠标点击画出折线,再将该折线对象和已有区域边界的路径值一起保存进数据库,便能够构成划分后的两个新区域了。
-
研究出实现方法后,遇见了一个难题,如何判断鼠标点击的点是否在折线上:
- 画起点和终点时必须在原有区域线上,否则无法形成新的封闭空间。故需要判断鼠标点击的点是否在原有折线上,在就让其成为起点或终点,不在则让其重新点击。
- 但是用户点击时无法保证完全点击在原有折线上,故需要允许一定的误差,在误差内则判断为点在折线上,误差外让其重新点击。
- 判断为在误差内后,鼠标点终究不在折线上,此时需要在原折线上生成一个新的点(离该鼠标点最近的点)
一开始希望通过判断折线上每两个相邻点与鼠标点三点共线则证明点在折线上,参阅《代码之美》后,发现了两种解决算法:
- 一种是判断斜率相等,但是由于一下问题被《代码之美》否决,并提出了更加优化的方法。
- 判断斜率相等存在多种特殊情况,如两点经度相等或者纬度相等时,代码实现过于繁琐。
- 斜率使用除法计算为浮点数,存在一定误差。
- 更优化的方法为三点可以组成一个三角形,当三角形面积接近于0时,则判断点在线上。
- 具体细节可以参看《代码之美》第33章。
在实际运用中,发现如果只存在三个点时,计算三角形面积毫无疑问是一个优秀的算法。
但是如前文提到的,鼠标点无法精确点击在折线上,故需要允许一定误差,也就是说三角形面积无法等于0,只能遍历折线每两个相邻点,计算鼠标点与两点组成的三角形面积,取出最小的面积,当其小于一个误差值时,点在折线上。
这样就可能会产生缺陷。一个折线对象存在着数以千计的相邻点,当鼠标点与折线上某两个相邻点组成的三角形面积最小时,却无法保证该点一定离这两个相邻点最近。
理想情况下是这样的,三角形面积最小,并且鼠标点离该两点组成的线段最近。而特殊情况下会是这样的,三角形面积同样最小,但鼠标点其实离线段较远。
无奈只能另寻解决方法,然后在百度LBS JavaScript开源库中发现提供了判断线是否在折线上的方法isPointOnPolyline()
,大喜,赶紧研究一番,应用在项目中。
1 /** 2 * 判断点是否在矩形内 3 * @param {Point} point 点对象 4 * @param {Bounds} bounds 矩形边界对象 5 * @returns {Boolean} 点在矩形内返回true,否则返回false 6 */ 7 function isPointInRect(point, bounds) { 8 var sw = bounds.getSouthWest(); //西南脚点 9 var ne = bounds.getNorthEast(); //东北脚点10 return (point.lng >= sw.lng && point.lng <= ne.lng && point.lat >= sw.lat && point.lat <= ne.lat);11 }12 13 /**14 * 判断点是否在折线上15 * @param {Point} point 点对象16 * @param {Polyline} polyline 折线对象17 * @returns {Boolean} 点在折线上返回true,否则返回false18 */19 function isPointOnPolyline(point, polyline){20 //首先判断点是否在线的外包矩形内,如果在,则进一步判断,否则返回false21 var lineBounds = polyline.getBounds();22 if(!this.isPointInRect(point, lineBounds)){23 return false;24 }25 //判断点是否在线段上,设点为Q,线段为P1P2 ,26 //判断点Q在该线段上的依据是:( Q - P1 ) × ( P2 - P1 ) = 0,且 Q 在以 P1,P2为对角顶点的矩形内27 var pts = polyline.getPath();28 for(var i = 0; i < pts.length - 1; i++){29 var curPt = pts[i];30 var nextPt = pts[i + 1];31 //首先判断point是否在curPt和nextPt之间,即:此判断该点是否在该线段的外包矩形内,先判断离point最近的两个相邻点,再进行斜率计算,有效避免干扰32 if (point.lng >= Math.min(curPt.lng, nextPt.lng) && point.lng <= Math.max(curPt.lng, nextPt.lng) &&33 point.lat >= Math.min(curPt.lat, nextPt.lat) && point.lat <= Math.max(curPt.lat, nextPt.lat)){34 //判断点是否在直线上公式,此处使用减法计算两个斜率之差,有效地简化了特殊情况的判断35 var precision = (curPt.lng - point.lng) * (nextPt.lat - point.lat) - 36 (nextPt.lng - point.lng) * (curPt.lat - point.lat); 37 if(precision < 2e-10 && precision > -2e-10){ //实质判断是否接近038 return true;39 } 40 }41 }42 43 return false;44 }
测一测项目,哈哈,可行,长舒一口气。正准备好好放松下,OMG!又遇见缺陷了,如下图北京西城区存在的一个情况,
此时折线上相邻两点的经度几乎相等,或者北京丰台区存在的情况, 此时折线上相邻两点的纬度几乎相等。由于方法优先判断鼠标点是否在折线某相邻两点的外包矩形内,但是上述两种情况下,相邻两点的外包矩形几乎为0,则鼠标点只有在精确点击到折线的情况下才会判断为true。这与实际开发中要求允许一定误差是相悖的,无奈只能另寻解决方法。
皇天不负有心人,在两次推翻实现算法后,终于又找到一种解决方法。遍历折线对象取出所有相邻点,计算鼠标点到每两个相邻点组成的线段的最短距离,然后排序最短距离,取出其中最小的距离,如果小于误差范围,则判断点在折线上。如果需要闭合区间,则在折线上生成一个离鼠标点最近的折线点(一般取垂足经纬度)。实现代码如下(以下实现代码已分享至):
1 /** 2 *计算折线是否在线上部分:主要实现算法为计算鼠标点到折线上每相邻两点组成的线段的最短距离,如果最小的最短距离小于误差值, 3 *则判断点在折线上。 4 *然后通过该相邻两点取得折线上离鼠标点最近的点。 5 */ 6 isPointOnPloyline = function() { 7 /** 8 * 计算两点之间的距离 9 * @param x1 第一个点的经度 10 * @param y1 第一个点的纬度 11 * @param x2 第二个点的经度 12 * @param y1 第二个点的纬度 13 * @returns lineLength 两点之间的距离 14 */ 15 function lineDis(x1, y1, x2, y2) { 16 var lineLength = 0; 17 lineLength = Math.sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)); 18 return lineLength; 19 } 20 /** 21 * 计算鼠标点到折线上相邻两点组成的线段的最短距离 22 * @param point 鼠标点 23 * @param curPt 折线点 24 * @param nextPt 与curPt相邻的折线点 25 * @returns dis 最短距离 26 */ 27 function countDisPoToLine(point, curPt, nextPt) { 28 var dis = 0; //鼠标点到线段的最短距离 29 var xCur = curPt.lng; //折线点的经纬度,将该点记作P1 30 var yCur = curPt.lat; 31 var xNext = nextPt.lng; //与上一个取点相邻的折线点的经纬度,将该点记作P2 32 var yNext = nextPt.lat; 33 var xPoint = point.lng; //鼠标点的经纬度,将该点记作P 34 var yPoint = point.lat; 35 var lengthCurToPo = lineDis(xCur, yCur, xPoint, yPoint); //P1到P的长度,记作b线段 36 var lengthNextToPo = lineDis(xNext, yNext, xPoint, yPoint); //P2到P的长度,记作c线段 37 var lengthCurToNext = lineDis(xCur, yCur, xNext, yNext); //P1到P2的长度,记作a线段 38 39 if (lengthNextToPo + lengthCurToPo == lengthCurToNext) { 40 //当b+c=a时,P在P1和P2组成的线段上 41 dis = 0; 42 return dis; 43 } else if (lengthNextToPo * lengthNextToPo >= lengthCurToNext * lengthCurToNext + lengthCurToPo * lengthCurToPo) { 44 //当c*c>=a*a+b*b时组成直角三角形或钝角三角形,投影在P1延长线上 45 dis = lengthCurToPo; 46 return dis; 47 } else if (lengthCurToPo * lengthCurToPo >= lengthCurToNext * lengthCurToNext + lengthNextToPo * lengthNextToPo) { 48 //当b*b>c*c+a*a时组成直角三角形或钝角三角形,投影在p2延长线上 49 dis = lengthNextToPo; 50 return dis; 51 } else { 52 //其他情况组成锐角三角形,则求三角形的高 53 var p = (lengthCurToPo + lengthNextToPo + lengthCurToNext) / 2; // 半周长 54 var s = Math.sqrt(p * (p - lengthCurToNext) * (p - lengthCurToPo) * (p - lengthNextToPo)); // 海伦公式求面积 55 dis = 2 * s / lengthCurToNext; // 返回点到线的距离(利用三角形面积公式求高) 56 return dis; 57 } 58 } 59 /** 60 * 判断点是否在矩形内 61 * @param point 点对象 62 * @param bounds 矩形边界对象 63 * @returns 点在矩形内返回true,否则返回false 64 */ 65 function isPointInRect(point, bounds) { 66 var sw = bounds.getSouthWest(); //西南脚点 67 var ne = bounds.getNorthEast(); //东北脚点 68 69 return (point.lng >= sw.lng && point.lng <= ne.lng && point.lat >= sw.lat && point.lat <= ne.lat); 70 71 } 72 73 /** 74 * 判断点是否在折线上,如果需要在折线上生成最近点,则使用下一个方法 75 * @param point 鼠标点 76 * @param polygon 区域多边形对象 77 * @returns 如果判断点不在折线上则返回false,否则返回true 78 */ 79 function isPointOnPloylineTest(point, polygon) { 80 // 首先判断点是否在线的外包矩形内,如果在,则进一步判断,否则返回false 81 var lineBounds = polygon.getBounds(); 82 if (!isPointInRect(point, lineBounds)) { 83 return false; 84 } 85 var disArray = new Array(); //存储最短距离 86 var pts = polygon.getPath(); 87 var curPt = null; //折线的两个相邻点 88 var nextPt = null; 89 for (var i = 0; i < pts.length - 1; i++) { 90 curPt = pts[i]; 91 nextPt = pts[i + 1]; 92 //计算鼠标点到该两个相邻点组成的线段的最短距离 93 var dis = countDisPoToLine(point, curPt, nextPt); 94 //先将存储最短距离的数组排序 95 disArray.push(dis); 96 disArray.sort(); 97 98 } 99 var disMin = disArray[0]; //取得数组中最小的最短距离100 if (disMin < 2e-4 && disMin > -2e-4) { //当最短距离小于误差值时,判断鼠标点在折线上(误差值可根据需要更改)101 return true;102 }103 return false;104 }105 /**106 * 判断点是否在折线上,如果判断为真则在折线上生成离该点最近的点,否则返回鼠标点坐标107 * @param point 鼠标点108 * @param polygon 区域多边形对象109 * @returns 如果判断点不在折线上则返回该点(point),如果判断点在折线上则返回计算出的折线最近点(110 因为鼠标点选很难精确点在折线上,要允许一定误差,故需生成一个折线上的最近点),111 返回该最近点(pointPoly)。112 */113 function isPointOnPloylineTest_02(point, polygon) {114 // 首先判断点是否在线的外包矩形内,如果在,则进一步判断,否则返回false115 var lineBounds = polygon.getBounds();116 if (!isPointInRect(point, lineBounds)) {117 return point;118 }119 var disArray = new Array(); //存储最短距离120 var pointArray = new Array(); //存储折线相邻点121 var pts = polygon.getPath();122 var curPt = null; //折线的两个相邻点123 var nextPt = null;124 for (var i = 0; i < pts.length - 1; i++) {125 curPt = pts[i];126 nextPt = pts[i + 1];127 //计算鼠标点到该两个相邻点组成的线段的最短距离128 var dis = countDisPoToLine(point, curPt, nextPt);129 //先将存储最短距离的数组排序,如果该两个相邻点与鼠标点计算出的最短距离与数组中最小距离相等,则存储该两点130 disArray.push(dis);131 disArray.sort();132 if (dis == disArray[0]) {133 pointArray.push(curPt);134 pointArray.push(nextPt);135 136 }137 }138 139 curPt = pointArray[pointArray.length - 2]; //取得数组最后两项,即为当最短距离最小时鼠标点两侧的折线点140 nextPt = pointArray[pointArray.length - 1];141 var disMin = disArray[0]; //取得数组中最小的最短距离142 143 144 if (disMin < 2e-4 && disMin > -2e-4) { //当最短距离小于误差值时,判断鼠标点在折线上(误差值可根据需要更改)145 var pointPoly = getPointOnPolyline(point, curPt, nextPt); //通过鼠标点和两侧相邻点,在折线上生成一个距离鼠标点最近的点146 return pointPoly;147 }148 return point;149 }150 151 /**152 * 如果点离折线上某两点组成的线段最近,则在折线上生成与鼠标点最近的折线点153 * @param point 鼠标点154 * @param curPt,nextPt 折线上相邻两点155 * @returns pointPoly 生成点156 */157 function getPointOnPolyline(point, curPt, nextPt) {158 var pointLng; // 取得点的经度159 var pointLat; // 取得点的纬度160 var precisionLng = curPt.lng - nextPt.lng;161 var precisionLat = curPt.lat - nextPt.lat;162 163 if (precisionLng < 2e-6 && precisionLng > -2e-6) {164 // 当折线上两点经度几乎相同时(存在一定误差)165 pointLng = curPt.lng;166 pointLat = point.lat;167 //创建生成点对象168 var pointPoly = new AMap.LngLat(curPt.lng, pointLat);169 } else if (precisionLat < 2e-6 && precisionLat > -2e-6) {170 //当折线上两点纬度相同时(存在一定误差)171 pointLat = curPt.lat;172 pointLng = point.lng;173 var pointPoly = new AMap.LngLat(pointLng, curPt.lat);174 } else {175 //其他情况,求得点到折线的垂足坐标176 var k = (nextPt.lat - curPt.lat) / (nextPt.lng - curPt.lng); //折线上两点组成线段的斜率177 //求得该点到线段的垂足坐标178 //设线段的两端点为pt1和pt2,斜率为:k = ( pt2.y - pt1. y ) / (pt2.x - pt1.x );179 //该直线方程为:y = k* ( x - pt1.x) + pt1.y。其垂线的斜率为 - 1 / k,180 //垂线方程为:y = (-1/k) * (x - point.x) + point.y181 var pointLng_02 = (k * k * curPt.lng + k * (point.lat - curPt.lat) + point.lng) / (k * k + 1);182 var pointLat_02 = k * (pointLng_02 - curPt.lng) + curPt.lat;183 var pointPoly = new AMap.LngLat(pointLng_02, pointLat_02);184 }185 return pointPoly;186 }187 return {188 //只需判断调用第一个别名,需要生成点调用第二个别名189 isPointOnPloylineTest: isPointOnPloylineTest;190 isPointOnPloylineTest_02: isPointOnPloylineTest_02;191 }192 }();
测试一番,终于解决了缺陷,能够正常判断点是否在折线上,并生成构建自定义区域及一个闭合区域所需要的最近折线点。
可以发现随着缺陷的不断解决,代码量却越来越多。毫无疑问,保持代码整洁,简化实现逻辑是一名开发人员应有的意识。不过在过度简化实现逻辑的过程中,我们是否会忽略许多用户实际使用时将会遭遇的错误呢。
回顾该功能跌宕的开发流程,就会发现:
- 如果折线不是一个闭合空间,而仅仅是较少点组成的几段线段时,三角形面积的算法遇见的缺陷没有出现的机会,将是最适合的算法。
- 如果折线点较多,但是其中不存在经度或纬度几乎相等的相邻点时,百度提供的算法又将是最适合的算法。
- 如果折线点较多,且情况复杂时,采用最后“较重”的算法,才能避免缺陷,成为一枚正常运转的齿轮。
所以“因地制宜”是一种非常重要的思想,不同的数据结构有不同的优劣势,同样不能因为怕某种框架太“轻”,覆盖面窄就避免使用,也不能因为框架太“重”就回避它。整日争辩哪种技术最好是没有意义的,我们需要做的是了解一种技术的最适使用场景,遇见该场景时使用它,享受技术开发者奉献给使用者的那份便捷。