背景
四轮出行场景中,乘客发单后,司机接单成功。司机接单驶向乘客的过程中,为接驾状态,此时乘客等待过程中,无法知道司机的实时位置,如果想要了解司机的实时动态,需要电话沟通询问,这增加了用户出行成本、沟通成本,带来了不好的用户体验。
为了解决上述问题,让乘客能够看到司机的实时位置,有了司乘同显出行方案,提供地图功能,给用户展示司机相对实时的位置,以及司机到乘客的行驶路线。
用过某商旅app的同学,一定知道他们的司乘同显方案是每隔5秒甚至10秒,司机位置会跳跃闪现到下一个位置,再渲染司机到乘客的行驶路线。
这种方案存在的问题:
1. 司机的位置是从一个位置闪现到另一个位置
2. 车辆车头角度有误差
3. 每隔5s或者10s获取最新位置,司机偏航场景下,用户感知较慢
4. 用户体验差,数据实时性较差
5. 没有实时更新位置信息
理想中的场景是由后端计算并且实时返回剩余路程,和司机当前位置,前端只需要渲染车辆位置和路线即可。
但实际上,这对服务侧来说计算存储量和计算量过大,成本过高。
可以先看下历史司乘同显效果:
设计思路
考虑到:
1. 车辆定位存在一定的不准确性,会偏移
2. 高德返回的规划路线做了抽点算法处理,比较直的路线,点位会比较稀疏,比较曲折的路线,点位会比较密集
3. 每隔3秒更新车辆位置,需要根据移动距离计算一些数据
思路基本如下:
1. 根据已有规划路径,根据点到线段的距离,遍历规划路径中的点位,找到最近的线段
2. 做点映射到路径处理
3. 在两个点位中间做插点算法
4. 根据映射的点位,在插完点的线段中,找到离的最近的点位,作为车辆移动的起点和终点
5. 小车边移动边擦点,将行驶过的路段在地图上擦掉
在开发过程中需要解决的问题有很多,首先要解决的就是校正车辆上报偏移位置和路线点位需要均匀分布。
解决上述两个问题,得到两次车辆映射在规划路径的位置,路线点位分布均匀,就能使用户感觉是在动画移动,行驶顺滑。
解决方案
1. 通过车辆的位置得到它在规划路线上对应的位置
规划路径其实是由很多条小线段组成。首先需要找到与车辆直线距离最短的一个线段,这时候需要用到点到直线的距离来计算,三个点可以看作一个三角形,求点到直线的距离其实就是三角形求高的方法。
/\*\*
\* 计算点到线的直线距离
\*/
function getPointToPathDistance(current, start, end) {
let a = getPointDistance(start, end) // 底边
let b = getPointDistance(end, current)
let c = getPointDistance(start, current)
if (b \* b >= c \* c + a \* a) return c
if (c \* c >= b \* b + a \* a) return b
let l = (a + b + c) / 2 // 周长的一半
let s = Math.sqrt((l \* (l - a) \* (l - b) \* (l - c))) // 海伦公式求面积
return (2 \* s / a)
}
将所有的距离都存储起来,找到距离最短的点位的下标,去重前后的点位,得到一个线段的起点和终点【因为理论上来说,在规划路径上,距离车位置最近的点,大概率存在这个线段上】,下面就是做点映射到线上,利用三角函数原理得出点映射。
/\*\*
\* 点映射到线上
\*/
function pointMappingPath(current, start, end) {
let m = current.latitude
let n = current.longitude
let x1 = start.latitude
let y1 = start.longitude
let x2 = end.latitude
let y2 = end.longitude
if (x1 === x2 && y1 === y2) return {longitude: x1, latitude: y1}
let resultLat = ((m \* (x2 - x1) \* (x2 - x1) + n \* (y2 - y1) \* (x2 - x1) + (x1 \* y2 - x2 \* y1) \* (y2 - y1))
/ ((x2 - x1) \* (x2 - x1) + (y2 - y1) \* (y2 - y1)))
let resultLon = ((m \* (x2 - x1) \* (y2 - y1) + n \* (y2 - y1) \* (y2 - y1) + (x2 \* y1 - x1 \* y2) \* (x2 - x1))
/ ((x2 - x1) \* (x2 - x1) + (y2 - y1) \* (y2 - y1)))
return {latitude: resultLat, longitude: resultLon}
}
按照理论上来说,这个时候我们拿到了映射到规划路线的位置了。
映射点位计算包含这几种情况:
1. 映射点位还在在规划路径上,但是不在当前的线段上
2. 在规划路径之外,当前线段的射线上
3. 在规划路径上,同时也在线段的上
考虑到有可能这个算出来的映射点位,有一定几率在在线段之外。所以该映射点位的有效性不高。
提高点位的有效性,我们可以将映射的位置与计算出的相邻两个点位比较,找到两点中最近的点位再加1 => finalIndex。
将下标为0到finalIndex的线段,做插点,根据线段长度动态决定插点的距离,最大距离为10m,最小为1m,找到和映射最近的点,这就小车在路线上当下的点位。
2. 如何让小车顺滑的从当前位置移动到下一个位置,而不是闪现
正常人眼镜在 FPS 小于 30 的时候就会感到卡顿,最优的帧率是 60,即16.5ms 左右渲染一次。理论上车辆只要每秒移动60次,用户的感知就是流畅的。
得到两个相对准确的点位之后,做插点及抽稀处理。所以我们已知起点就是小车上一次的位置,并且已知现在的位置。
在两个点之前做插点算法,根据两个点位的距离除以刷新次数,得到间隔的最短距离,再去计算需要插入点位的经纬度。
大家可能不明白为啥要插点,画张图:
因为接口返回的路线,点位并不是均匀的,如果每次刷新车辆的距离不一致,无法达到,匀速行驶的效果,可能会一会快一会慢。
需要插点的路径,计算两点路线长度,假设路线长度500m,每秒刷新60次,那么3秒需要刷新180次,400/180就是插点的距离。也就是每隔2.22米就需要插一个点。
// 插点
function insertPoint(path) {
let result = \[\]
let totalDis = computedPath(path)
let minDistance = totalDis / 3 / 60 // 3秒-每秒60个点 1000/60秒【16.5ms】-移除一个点
for (let i = 0; i < path.length; i++) {
let current = path\[i\]
if (i === path.length -1) {
result.push(current)
continue
}
let next = path\[i+1\]
let distance = getPointDistance(current, next)\*1000
// 总距离 ➗ 最小间隔距离
let pCount = distance / minDistance;
result.push(current)
if (distance < minDistance) continue;
for (let j = 0; j < pCount - 1; j++) {
let density = (j /(pCount.toFixed(1))); // 比例
if (density == 0) continue;
// 比例 ✖️ 纬度差 = 实际增加的纬度
let dSegLat = density \* (next.latitude - current.latitude)
// 比例 ✖️ 经度差 = 实际增加的经度
let dSegLng = density \* (next.longitude - current.longitude)
let newNode = {
latitude: current.latitude + dSegLat,
longitude: current.longitude + dSegLng
}
result.push(newNode)
}
}
return result
}
// 计算路径距离
function computedPath(path) {
let sum = 0
if(!path||!path.length) return 0
if (path.length === 2) {
sum = getPointDistance(path\[0\], path\[1\])\*1000
}
for(let i = 0; i < path.length - 1; i++) {
let distance = getPointDistance(path\[i\], path\[i+1\])\*1000
sum += distance
}
return sum;
}
// 计算点与点之间的距离
const getPointDistance = function(current, source) {
const lng1 = current.longitude
const lng2 = source.longitude
const lat1 = current.latitude
const lat2 = source.latitude
let radLat1 = lat1 \* Math.PI / 180.0;
let radLat2 = lat2 \* Math.PI / 180.0;
let a = radLat1 - radLat2;
let b = lng1\*Math.PI / 180.0 - lng2 \* Math.PI / 180.0;
let s = 2 \* Math.asin(Math.sqrt(Math.pow(Math.sin(a/2),2) +
Math.cos(radLat1) \* Math.cos(radLat2) \* Math.pow(Math.sin(b/2),2)));
s = s \* 6378.137 ;// EARTH\_RADIUS;
s = Math.round(s \* 10000) / 10000;
return s;
}
3. 当一条直线,小车行驶,总是匹配到第一个点,导致小车不动
拿到车主位置,映射规划路线,得到映射后的点。因为高德规划路线,做了抽稀处理,越直的线,点越稀疏,越弯的线,点越密集。
所以,将规划路线和车主位置做映射的时候,得出的点,往往不是那么准确。
在这里,需要将得到的点,假如点在路线中的下标是index,那么将index-1和index+1的路线取出,做插点处理。
根据路线的长度来计算插点距离,这样就得到点位相对密集的路线,再根据这段路线与车辆位置比对,得到相对准确的车辆位置。
当匹配到第一个点的时候,取到下一个不相同的点,作插点。找到距离最近的点,作为小车下一个移动的点位。这样车辆映射的位置准确性提高。
4. 移动设备和pc端IDE显示不一样,移动设备总是闪现
当每秒更新频率过快,移动设备无法支持,找到一个平衡点,每秒更新12个点。当有的移动路线点位确实本身过多,可以做抽点算法。【后期待优化】
目前的解决方案是:小程序地图性能与体验的平衡点在每秒12次刷新。将更新路线的长度除以36(12个点*3s),计算出最小间隔距离,进行插点。
对于本身路线的长度就多于36个点(存在部分点密集的路段),路线点数组的长度/36,计算出间隔x,每隔x个点,取一个点,第36个点总是为车辆当前行驶动画的终点。
5. 小车车头角度计算问题
移动计算角度,每次移动都去计算当前经纬度和下一个经纬度。
// 计算角度
function computedRotate(start, end, defaultAngle) {
const {longitude:lng_a, latitude:lat_a} = start
const {longitude:lng_b, latitude:lat_b} = end
let y = lat_a - lat_b
let x = lng_a - lng_b
let brng = Math.atan2(y, x);
let angle= (180 * brng) / Math.PI;
return 180 - angle - defaultAngle;
}
6. ios设备includePoints特定情况下会失效
如果两次includePoints所需要自适应地图的点位是一样的,地图小程序就会默认不变化,提升性能。
解决方案:在点位中生成一个随机点。
/\*\*
\* object with lat and lng attributes. {Object} center A JS
\* in meters. {number} radius Radius
\* as JS object with lat and lng attributes. {Object} The generated random points
\*/
const randomPoint = ({ latitude: x0, longitude: y0 }, radius) => {
// Convert Radius from meters to degrees.
const rd = radius / 111300;
const u = Math.random();
const v = Math.random();
const w = rd \* Math.sqrt(u);
const t = 2 \* Math.PI \* v;
const x = w \* Math.cos(t);
const y = w \* Math.sin(t);
const xp = x / Math.cos(y0);
// Resulting point.
return {
longitude: y + y0,
latitude: xp + x0
};
};
7. 场景优化
考虑因为当前的方案是模拟动画,那么如果车辆一次刷新的距离过长,用户还是能够感知到是非动画,所以当动画路线长度大于600m时,就做闪现处理,非动画处理。
车辆位置上报的点和规划路线,总能映射出点位,在偏航场景下,该点位其实是无效点位。所以获得映射点位时,判断该点位和最后一次记录点位是否超过800m,如果超过800m时,则做偏航处理,车辆停止不动,直到返回新的导航路线,重新渲染。
The End
如果你觉得这篇内容对你挺有启发,请你轻轻点下小手指,帮我两个小忙呗:
1、点亮「在看」,让更多的人看到这篇满满干货的内容;
2、关注公众号「哈啰技术」,可第一时间收到最新技术推文。
如果喜欢就点个👍喔,有您的喜欢⛽,我们会更有动力输出有价值的技术分享滴;