前言
由于实时导航功能需要复杂的逻辑和很高的性能要求,再加上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 有两种搜索方式,关键字搜索和经纬度搜索,格式分别如下
arduinoconst points = [ { keyword: '北京市地震局(公交站)',city:'北京' }, //起始点坐标 { keyword: '亦庄文化园(地铁站)',city:'北京' } //终点坐标 ]
arduinoconst 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... 😉