JS 手撸高德地图导航

前言

由于实时导航功能需要复杂的逻辑和很高的性能要求,再加上PC端没有导航的需求,或者根本就没有GPS模块。

高德并没有像移动端SDK那样直接提供 web 端的实时导航 API。但是前端开发中不可避免的需要用到导航相关的功能,然而我们目前只有一个可怜的路线规划功能。其实想要实现web端实时导航也不是一件不可能的事儿,接下来我将使用高德 路线规划+浏览器高精度定位 实现一个基本可用的导航逻辑。

bash 复制代码
代码已开源在github : <https://github.com/LarryZhu-dev/amap_nav>

起终点设置和路线规划

地名搜索

使用 element-plus 的自动补全输入框的远程搜索功能,事件连接到高德的地名搜索

ini 复制代码
      <div class="searchBox" v-if="!currStep">
        <div class="inputs">
          <ElAutocomplete placeholder="请输入起点" v-model="points[0].keyword" size="large" @select="handleSelectStart"
            :fetch-suggestions="querySearchAsync">
            <template #prefix>
              <ElIcon color="#00b144" size="24">
                <LocationFilled />
              </ElIcon>
            </template>
          </ElAutocomplete>
          <ElAutocomplete placeholder="请输入终点" v-model="points[1].keyword" size="large" @select="handleSelectEnd"
            :fetch-suggestions="querySearchAsync">
            <template #prefix>
              <ElIcon color="#d32f19" size="24">
                <LocationFilled />
              </ElIcon>
            </template>
          </ElAutocomplete>
        </div>
      </div>

querySearchAsync 函数内实现搜索逻辑,使用callback返回即可。

php 复制代码
function querySearchAsync(queryString: string, cb: (arg: any) => void) {
  const placeSearch = new AMap.PlaceSearch({
    city: '北京',
    pageSize: 5,
    pageIndex: 1,
    citylimit: true,
    extensions: 'all',
  });
  placeSearch.search(queryString, function (status: any, result: any) {
    if (status === 'complete') {
      const res = result.poiList.pois.map((item: any) => {
        return {
          value: item.name,
          label: item.name,
          raw: item
        }
      })
      cb(res)
    }
  });
}

PlaceSearch : lbs.amap.com/api/javascr...

可以设置 citylimit 和 city 字段 以开关、切换城市范围。pageSize控制返回条数。

handleSelectStart 和 handleSelectEnd 函数 则检查是否起点、终点都有了,这样可以单独修改其中一个。修改后立即查询路线。

scss 复制代码
​
function handleSelectStart(item: any) {
  points.value[0].keyword = item.raw.name
  points.value[0].lnglat = [item.raw.location.lng, item.raw.location.lat]
  getRoute()
}
function handleSelectEnd(item: any) {
  points.value[1].keyword = item.raw.name
  points.value[1].lnglat = [item.raw.location.lng, item.raw.location.lat]
  getRoute()
}
​

路线规划

在路线规划函数内,检查 points 的两个位置是不是都有了,否则就不执行。对应我们上面的逻辑,起点和终点可以单独修改,修改后立即查询。

ini 复制代码
function getRoute() {
  if (!points.value[0].lnglat.length || !points.value[1].lnglat.length) {
    return
  }
  loading.value = true
  driving.search(points.value[0].lnglat, points.value[1].lnglat, function (status: any, result: any) {
    if (status === 'complete') {
      console.log(result.routes[0])
      currentRoute.value = {
        distance: result.routes[0].distance,
        duration: result.routes[0].time,
        policy: result.routes[0].policy,
        steps: result.routes[0].steps.map((item: any, index: number) => {
          return {
            instruction: item.instruction,
            distance: meters2kilometers(item.distance),
            action: item.action,
            icon: actionIconDict[item.action],
            startPoint: [item.start_location.lng, item.start_location.lat],
            endPoint: [item.end_location.lng, item.end_location.lat],
            time: item.time,
            index: index
          }
        })
      }
    } else {
      console.log('获取驾车数据失败:' + result)
      currentRoute.value = {}
    }
    loading.value = false
  });
}

driving.search 有两种搜索方式,关键字搜索和经纬度搜索,格式分别如下

arduino 复制代码
const points = [
  { keyword: '北京市地震局(公交站)',city:'北京' }, //起始点坐标
  { keyword: '亦庄文化园(地铁站)',city:'北京' } //终点坐标
]
arduino 复制代码
const startLngLat = [116.379028, 39.865042] //起始点坐标
const endLngLat = [116.427281, 39.903719] //终点坐标

使用经纬度比较准确,我个人比较推荐。

在👆这段代码中,我将获取到的路线信息组装了一个 易操作的 对象 steps,包含以下参数

  • distance:这段路线的长度
  • instruction:这一段路的行为描述
  • action:这一段结束后的行为(字典将在下文提到)
  • icon:根据行为字典确定方向 icon ,下文将提到
  • startPoint、endPoint :这一段路的第一个点和最后一个点,其实这一段路可能有很多点,但是这里为了方便演示,简化成了两个点
  • time:这一段的路程时间
  • index:这一段的下标值,看似没什么用,但是却是我们接下来配合 turf.js 找到当前 step 的关键。

currentRoute 中有以下对象

  • distance:路线总长度
  • duration:总时间
  • policy:路线方案推荐理由,如速度最快
  • steps:即上面的组装对象

手撸导航

到了本文的重头戏,我们要用 turf.js + 高德的路线规划 亲手把核心功能:导航!给做出来。

由于我目前的开发电脑就没有GPS模块😅,所以我们要先实现一个模拟位置方法,便于在PC端开发测试。

浏览器精确定位

首先说明,高德地图提供了浏览器精确定位 api AMap.Geolocation,如果你的设备有GPS模块,那么完全可以使用真实位置,当然,在本项目的开源代码中,包含了真实位置的代码,如果需要使用真实位置,将下面提到的代码中的 mock变量改为false即可。

初始化地图是,需要将位置以插件的形式加载进来:

javascript 复制代码
 AMap = await AMapLoader.load({
    key: import.meta.env.VITE_AMAP_KEY, // 申请好的Web端开发者Key,首次调用 load 时必填
    version: "2.0", // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
    plugins: ["AMap.GeoJSON", "AMap.PlaceSearch", "AMap.Driving", "AMap.Geolocation", "AMap.Marker"], //需要使用的的插件列表,如比例尺'AMap.Scale',支持添加多个如:['...','...']
    Loca: {                // 是否加载 Loca, 缺省不加载
      "version": '2.0.0'  // Loca 版本,缺省 1.3.2
    },
  })

"AMap.Geolocation" 就是我们需要用的,当然,可以看到包括PlaceSearch、Driving 都是依靠这种方式加载的。

初始化定位插件:

php 复制代码
  location = new AMap.Geolocation({
    enableHighAccuracy: true,
    timeout: 10000,
  });

文档参考:lbs.amap.com/api/javascr...

enableHighAccuracy打开时将会尝试浏览器默认的高精度定位(谷歌默认的一般用不了)

模拟位置

如果你像我一样,电脑没有GPS模块,又想体验一下模拟导航,建议像我一样,根据路线信息,写一个简单的模拟位置。

typescript 复制代码
const mock = true
const mockSpeed = 1000
let mockTimer: any
function getCurrentLocation(cb: (arg: number[]) => void) {
  if (mock) {
    clearInterval(mockTimer)
    mockTimer = setInterval(() => {
      let currentTimestamp = new Date().getTime()
      let runTime = (currentTimestamp - startTimestamp.value) / 1000
      for (let item of currentRoute.value.steps!) {
        if (runTime < item.time) {
          let distance = pointToPointDistance(item.startPoint, item.endPoint)
          let speed = (distance / item.time) * mockSpeed
          let currentDistance = speed * runTime
          try {
            let currentLocation = along({
              type: 'LineString',
              coordinates: [item.startPoint, item.endPoint]
            }, currentDistance, { units: 'meters' })
            let position = nearestPointOnLine({
              type: 'Feature',
              properties: {},
              geometry: {
                type: 'LineString',
                coordinates: [item.startPoint, item.endPoint]
              }
            }, currentLocation.geometry.coordinates)
            cb(position.geometry.coordinates)
          } catch (e) {
            console.log('e::: ', e);
            continue
          }
          break
        }
      }
    }, 100)
    return
  }
  location.getCurrentPosition(function (status: string, result: any) {
    if (status == 'complete') {
      console.log('result::: ', result);
    } else {
      console.log('result::: ', result);
    }
  });
}

这个函数同样兼容了真实定位,正如上面提到的,你只需要把 mock 改为false即可。

下面讲解一下它是怎么模拟位置的

首先,它需要传入一个 callback,这个callback将在setInterval中,流式的返回位置,就像真实的变化一样。

大致的逻辑是:在开始导航(startNav函数,下面会提到)时,记录开始导航的时间,根据路线长度、已走的时间来判断路线上的长度,再生成一个新点,这个新点就是我们要的位置。要注意的是,这个方法虽然可以在路线上正确的行进,但是无法模拟偏航的情况。

这里有张图帮助大家理解

ini 复制代码
let runTime = (currentTimestamp - startTimestamp.value) / 1000

算出已走的时间

还记得上面我们在 steps 对象里记录了每一步走到的预估时间吧,在这里,我们遍历 steps,找出我们所在的 step item,注意,这里找到的步不能直接作为真实的动作提醒,因为这是我们用走过的时间和平均速度取的。

使用 turf.pointToPointDistance 算出此段路起点到终点的长度👇

ini 复制代码
let distance = pointToPointDistance(item.startPoint, item.endPoint)

算出平均速度

ini 复制代码
let speed = (distance / item.time) * mockSpeed

算出我们当前应该走到的路程

ini 复制代码
let currentDistance = speed * runTime

尝试计算走过的路程在线上的终点

php 复制代码
          try {
            let currentLocation = along({
              type: 'LineString',
              coordinates: [item.startPoint, item.endPoint]
            }, currentDistance, { units: 'meters' })
            let position = nearestPointOnLine({
              type: 'Feature',
              properties: {},
              geometry: {
                type: 'LineString',
                coordinates: [item.startPoint, item.endPoint]
              }
            }, currentLocation.geometry.coordinates)
            cb(position.geometry.coordinates)
          } catch (e) {
            console.log('e::: ', e);
            continue
          }

因为测试时发现如果速度设置的过高,可能会超出这一段路,turf就会报错,但是为了看更快的效果,我们有时就是需要把速度设置高一点,毕竟是模拟,这里偷个懒,如果过快超出了这段路,导致报错了,就直接进入下一段就好了🤣,所以在我的demo中,你将看到两个不正常而故意为之的状态:1. 模拟点不在路线上(因为简化掉了中间点,只留了每段的两个点)、2. 点不动了或者跳出去了(这里报错了)

ok,到此我们已经完成了位置模拟,这段代码将源源不断的给我们返回可用的位置点。

导航逻辑实现

上面提到的 startNav 函数是这样实现的:

php 复制代码
function startNav() {
  if (navStarted.value) {
    return
  }
  startTimestamp.value = new Date().getTime()
  navStarted.value = true
​
  // 创建一个 icon
  var endIcon = new AMap.Icon({
    size: new AMap.Size(40, 40),
    image: car,
    imageSize: new AMap.Size(40, 40),
  });
​
  // 将 icon 传入 marker
  var endMarker = new AMap.Marker({
    position: new AMap.LngLat(0, 0),
    icon: endIcon,
    offset: new AMap.Pixel(-13, -30)
  });
​
  // 将 markers 添加到地图
  map.add([endMarker]);
  getCurrentLocation((currLocation: number[]) => {
    endMarker.setPosition(currLocation)
    routeNav(currLocation)
  })
}
​

添加了一个方向标作为定位点。

接下来我们看重头戏:routeNav 函数

scss 复制代码
function routeNav(currLocation: number[]) {
  if (checkIsCloseToEndpoint()) {
    console.log('导航结束')
    navStarted.value = false
    currStep.value = undefined
    return
  }
  currStep.value = getCurrStep(currLocation)
  heading.value = -bearing(currStep.value.startPoint, currStep.value.endPoint)
  map.setCenter(currLocation)
  map.setZoom(16)
  map.setRotation(heading.value)
  map.setPitch(45)
}

首先检查 是否距离终点过近了,如果过近,就直接触发导航结束逻辑

php 复制代码
function checkIsCloseToEndpoint() {
  if (!currStep.value?.endPoint) {
    return false
  }
  const distance = pointToPointDistance({
    type: 'Point',
    coordinates: currStep.value!.endPoint
  }, currentRoute.value.steps![currentRoute.value.steps!.length - 1].endPoint, { units: 'meters' })
  return distance < 10
}

这里使用 turf.pointToPointDistance 点到点的距离来计算当前位置和路线最后一个点的距离,如果小于10m,判定为导航结束。

在正常导航时,我们需要判断当前位置处于哪一段 step,并显示相应的动作提示和 icon

这是 驾车 的 action 字典:

c 复制代码
const actionIconDict: { [key: string]: string } = {
  '左转': 'icon-xiangzuozhuan',
  '右转': 'icon-xiangyouzhuan',
  '直行': 'icon-xiangshang',
  '向左前方行驶': 'icon-xiangzuozhuan',
  "向右前方行驶": "icon-xiangyouzhuan",
  "向左后方行驶": "icon-xiangzuozhuan",
  "向右后方行驶": "icon-xiangyouzhuan",
  "左转调头": "icon-xiangzuozhuan",
  "靠左": "icon-xiangzuozhuan",
  "靠右": "icon-xiangyouzhuan",
  "进入环岛": "icon-xiangshang",
  "离开环岛": "icon-xiangshang",
  "减速行驶": "icon-xiangshang"
}

目前我只按照方向加了 向左、向右、向前 三个图标。

接下来获取当前 step

php 复制代码
function getCurrStep(currLocation: number[]) {
  const paths = currentRoute.value.steps!.map((item) => {
    return item.startPoint
  })
  const nearestPoint = nearestPointOnLine({
    type: 'Feature',
    properties: {},
    geometry: {
      type: 'LineString',
      coordinates: paths
    }
  }, currLocation)
  return currentRoute.value.steps![nearestPoint.properties.index]
}

👆 turf.nearestPointOnLine 是计算点到多线段最短间距的点,返回时会自带一个 index 参数,这个 index 就是指的在原数组的 index 值(上面提到的关键),好像最难的一步我们反而解决的最容易,直接返回 currentRoute.value.steps![nearestPoint.properties.index] 我们就拿到了当前路段。

回到 routeNav 函数中

scss 复制代码
function routeNav(currLocation: number[]) {
  if (checkIsCloseToEndpoint()) {
    console.log('导航结束')
    navStarted.value = false
    currStep.value = undefined
    return
  }
  currStep.value = getCurrStep(currLocation)
  heading.value = -bearing(currStep.value.startPoint, currStep.value.endPoint)
  map.setCenter(currLocation)
  map.setZoom(16)
  map.setRotation(heading.value)
}

此段代码无修改,与上文的routeNav相同

使用 turf.bearing 计算出当前位置与此段路下一个点的方位角,但是由于我们每段路只留了两个点,所以这个方位角目前总是指向这段路的最后一个点,在实际应用时,应该把 steps 的所有点都加进来。

图中红色箭头指向了正确方向,而我们的案例指向了此段路的最后一个点,在实际应用时应该要注意这点。

完成(效果图)

到此为止我们已经实现了导航的基本逻辑,并且已经成功跑起来了!

再贴一遍项目地址:github.com/LarryZhu-de... 😉

相关推荐
优雅永不过时·8 分钟前
three.js实现地球 外部扫描的着色器
前端·javascript·webgl·three.js·着色器
peachSoda71 小时前
随手记:鼠标触顶方法
前端·javascript·vue.js
疯狂的沙粒1 小时前
Vue项目开发 formatData 函数有哪些常用的场景?
前端·javascript·vue.js
逆旅行天涯1 小时前
【功能实现】bilibili顶部鼠标跟随效果怎么实现?
前端·javascript·vue
毛毛三由1 小时前
【10分钟学习Vue自定义指令开发】鼠标放置提示指令
前端·javascript·vue.js
一秒美工助手1 小时前
鼠标经过遮罩效果 详情页阿里巴巴国际站外贸跨境电商装修运营 详情页装修无线端装修手机装修设计代码证书滚动特效效果代码,自定义内容代码模板模块设计设置装修
前端·javascript·html·计算机外设
桑榆肖物2 小时前
将 .NET Aspire 添加到现有应用:前端 JavaScript 项目处理
前端·javascript·.net
Wh1teR0se4 小时前
[极客大挑战 2019]Secret File--详细解析
前端·web安全·网络安全
ZhaiMou5 小时前
HTML5拖拽API学习 托拽排序和可托拽课程表
前端·javascript·学习·html5
code_shenbing8 小时前
跨平台WPF框架Avalonia教程 三
前端·microsoft·ui·c#·wpf·跨平台·界面设计