司乘同显出行解决方案

avatar

本文来自哈啰出行地图平台的前端开发同学戴文飞,主要负责哈啰出行小程序地图相关功能开发与维护。

背景

四轮出行场景中,乘客发单后,司机接单成功。司机接单驶向乘客的过程中,为接驾状态,此时乘客等待过程中,无法知道司机的实时位置,如果想要了解司机的实时动态,需要电话沟通询问,这增加了用户出行成本,沟通成本,带来了不好的用户体验。

为了解决上述问题,让乘客能够看到司机的实时位置,有了司乘同显出行方案,提供地图功能,给用户展示司机相对实时的位置,以及司机到乘客的行驶路线。

用过携程商旅的同学,一定知道他们的司乘同显方案是每隔5秒甚至10秒,司机位置会跳跃闪现到下一个位置,再渲染司机到乘客的行驶路线。

这种方案存在的问题:

  1. 司机的位置是从一个位置闪现到另一个位置

  2. 车辆车头角度有误差

  3. 每隔5s或者10s获取最新位置,司机偏航场景下,用户感知较慢

  4. 用户体验差,数据实时性较差

  5. 没有实时更新位置信息

理想中的场景:由后端计算并且实时返回剩余路程,和司机当前位置,前端只需要渲染车辆位置和路线即可。 但实际上,这对服务侧来说计算存储量和计算量过大,成本过高。

可以先看下历史司乘同显效果: image.png 历史司乘同显效果

现有数据

  • 车主到乘客起点整体规划路线
  • 司机当前位置

设计思路

考虑到:

  1. 车辆定位存在一定的不准确性,会偏移

  2. 高德返回的规划路线做了抽点算法处理,比较直的路线,点位会比较稀疏,比较曲折的路线,点位会比较密集

  3. 每隔3秒更新车辆位置,需要根据移动距离计算一些数据

思路基本如下:

  • 根据已有规划路径,根据点到线段的距离,遍历规划路径中的点位,找到最近的线段

  • 点映射到路径处理

  • 在两个点位中间做插点算法

  • 根据映射的点位,在插完点的线段中,找到离的最近的点位,作为车辆移动的起点和终点

  • 小车边移动边擦点,将行驶过的路段在地图上擦掉

在开发过程中需要解决的问题就有很多 首先要解决的就是

  1. 校正车辆上报偏移位置
  2. 路线点位需要均匀分布

解决上述两个问题,得到两次车辆映射在规划路径的位置,路线点位分布均匀,就能使用户感觉是在动画移动,行驶顺滑

image.png

解决方案

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 => finalIndex

将下标为0到finalIndex的线段,做插点,根据线段长度动态决定插点的距离,最大距离为10m,最小为1m,找到和映射最近的点,这就小车在路线上当下的点位。

2. 如何让小车顺滑的从当前位置移动到下一个位置,而不是闪现

正常人眼镜在 FPS 小于 30 的时候就会感到卡顿,最优的帧率是 60,即16.5ms 左右渲染一次。

理论上车辆只要每秒移动60次,用户的感知就是流畅的

得到两个相对准确的点位之后,做插点及抽稀处理。

所以我们已知起点【就是小车上一次的位置】,并且已知现在的位置。

在两个点之前做插点算法,根据两个点位的距离除以刷新次数,得到间隔的最短距离,再去计算需要插入点位的经纬度。

大家可能不明白为啥要插点,画张图:

image.png 因为接口返回的路线,点位并不是均匀的,如果每次刷新车辆的距离不一致,无法达到,匀速行驶的效果,可能会一会快一会慢。

需要插点的路径,计算两点路线长度,假设路线长度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. 当一条直线,小车行驶,总是匹配到第一个点,导致小车不动

拿到车主位置,映射规划路线,得到映射后的点。因为高德规划路线,做了抽稀处理,越直的线,点越稀疏,越弯的线,点越密集。 image

所以,将规划路线和车主位置做映射的时候,得出的点,往往不是那么准确。
在这里,需要将得到的点,假如点在路线中的下标是index,那么将index-1和index+1的路线取出,做插点处理。
根据路线的长度来计算插点距离,这样就得到点位相对密集的路线,再根据这段路线与车辆位置比对,得到相对准确的车辆位置。

当匹配到第一个点的时候,取到下一个不相同的点,作插点 找到距离最近的点,作为小车下一个移动的点位 这样车辆映射的位置准确性提高。

4. 移动设备和pc端IDE显示不一样,移动设备总是闪现

当每秒更新频率过快,移动设备无法支持,找到一个平衡点,每秒更新12个点

当有的移动路线点位确实本身过多,可以做抽点算法【后期待优化】

目前的解决方案是:【小程序地图性能与体验的平衡点在每秒12次刷新】

  1. 将更新路线的长度除以36(12个点*3s),计算出最小间隔距离,进行插点。

  2. 对于本身路线的长度就多于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所需要自适应地图的点位是一样的,地图小程序就会默认不变化,提升性能

解决方案:在点位中生成一个随机点

/\*\*
\* @param  {Object} center A JS object with lat and lng attributes.
\* @param  {number} radius Radius in meters.
\* @return {Object} The generated random points as JS object with lat and lng attributes.
\*/
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. 场景优化

  1. 考虑因为当前的方案是模拟动画,那么如果车辆一次刷新的距离过长,用户还是能够感知到是非动画,所以当动画路线长度大于600m时,就做闪现处理,非动画处理。
  2. 车辆位置上报的点和规划路线,总能映射出点位,在偏航场景下,该点位其实是无效点位。所以获得映射点位时,判断该点位和最后一次记录点位是否超过800m,如果超过800m时,则做偏航处理,车辆停止不动,直到返回新的导航路线,重新渲染。

image.png

最终车辆行驶效果


image.png 车辆行驶效果