警告:干货来袭,请准备好哇哈哈矿泉水
扫码加入诺克萨斯帝国,入住不朽堡垒。
前言
众所周知,在各大图商的 JS API 中都没有提供所谓的实时导航功能,只提供了 "路线规划" 这种看起来没什么用的api。
简单搜索一下现有的微信小程序,也都没有提供实时导航的能力,甚至诸如【腾讯地图】、【高德地图】官方的小程序,也是跳转外部应用实现的导航,那么大家为什么都不用小程序做实时导航呢?
调研
经过调研,为小程序提供了导航(准确的说是路径规划)相关能力的东西,一共有以下几种
腾讯地图小程序插件
地址:mp.weixin.qq.com/wxopen/plug...
优点:一键导入,一键生成路线
缺点:无法获取到格式化信息,只能跳转固定页面,并且只能看。
是否可用:否
腾讯地图小程序 JS SDK
优点:有格式化的导航信息,api功能十分全面
缺点:无实时导航功能,只提供路径规划
是否可用:是
另外有一个 腾讯位置服务 webServer API,提供能力大致相同,地址:lbs.qq.com/service/web...
高德地图 小程序插件
地址:lbs.amap.com/api/wx/summ...
优点:有格式化的导航信息,api功能十分全面,且专门为小程序 map 组件数据格式做了优化
缺点:无实时导航功能,只提供路径规划
是否可用:是
百度地图 小程序 JS API
地址:lbsyun.baidu.com/index.php?t...
优点:优点....呃
缺点:甚至没有提供路径规划能力,且百度使用BD-09坐标系,需要二次解密
是否可用:否
可以看到,提供了路线规划的只有腾讯位置服务
、高德小程序插件
。为了技术栈的统一性,最终选择使用腾讯位置服务的小程序JS SDK
结合 小程序后台持续定位能力
,手动实现一个实时导航功能。
准备
首先,罗列一下需要使用的 API 和 小程序内置能力 以及 第三方库:
- qqmap.direction 路径规划接口,详见 腾讯位置服务 文档:lbs.qq.com/miniProgram...
- qqmap.search 查询周边点位,用于测试路径规划能力。
- wx.getLocation 获取用户起始位置,用于导航起点
- wx.startLocationUpdateBackground 实时获取小程序前后台 用户当前位置,用于导航中刷新位置、计算偏航、重新规划等。
- wx.onLocationChange 接收 wx.startLocationUpdateBackground 返回的位置,用于处理上述逻辑。
- wx.vibrateLong 震动能力,当偏航、到达目的地时将使用震动提醒用户
- wx.openSetting(现已改为open-type按钮启动) 引导用户打开后台定位权限。
- @turf/point-to-line-distance 计算点到线的距离
- @turf/nearest-point-on-line 计算点到线的垂足
- @turf/distance 计算点到点的距离
注意:
- 使用 腾讯位置服务需要注册开发者账号并获取一个key。
- 使用小程序相关接口需要在小程序后台 申请相关接口权限、并更新隐私协议(需审核)
- 使用小程序内置隐私协议引导,需要小程序基础调试库大于 3.0.1,推荐使用目前最新的:3.2.x
- 使用腾讯位置服务,需要在小程序后台配置域名白名单:在小程序管理后台 -> 开发 -> 开发管理 -> 开发设置 -> "服务器域名" 中设置request合法域名,添加 apis.map.qq.com
导航基本流程图
注意:该流程图考虑了,用户距离起点较远的情况、偏航不再提示后再次偏航的情况,为了快速完成简易的导航流程,在下面的实现中,并没有考虑这两点。
开始实现
注1:以下代码经过脱敏处理,可能无法直接运行
注2:使用了 vant 组件库
创建基本框架
首先新建一个 名为 map
的小程序页面
在 map.wxml 中,写入 基本的 map 组件、导航文本提示悬浮窗
xml
<view class="map_container">
<map class="map" id="map" longitude="{{longitude}}" latitude="{{latitude}}" include-points="{{viewPoints}}" scale="14" polyline="{{polyline}}" show-location="true" markers="{{markers}}" bindmarkertap="makertap"></map>
</view>
<view class="map_text">
<text class="h1">{{textData.name}}</text>
<text>{{textData.desc}}</text>
<button class="btn" bindtap="goTo">去这里</button>
<van-dialog use-slot title="定位授权设置不正确,无法使用巡查导航功能" show="{{ isShowGoAuth }}" show-cancel-button confirm-button-open-type="openSetting">
<text>点击确认按钮跳转设置页面,你需要设置 位置信息 > 使用小程序时和离开后</text>
</van-dialog>
</view>
<view class="navTipPanel" wx:if="{{currStep.instruction}}">
<view class="intro">
<text class="instruction">{{currStep.instruction}}</text>
<text class="iconfont icon-{{icons[currStep.act_desc]}}"></text>
</view>
<view class="tips" wx:if="{{isYawed && !noTip}}">
<view class="tip">
<text class="iconfont icon-jinggao"></text>
<text>你已偏离路线</text>
</view>
<view class="btns">
<button class="btn" bindtap="noTipForThisTime">本次不再提醒</button>
<button class="btn" bindtap="reStart">重新规划</button>
</view>
</view>
</view>
以上代码中 map 组件的传值解释:
- longitude、latitude 经度和纬度,用于确定中心点
- include-points 用于自适应视角
- scale 其实就是常规意义的 zoom,小程序总爱整点不一样的
- polyline 多段线
- show-location 是否显示用户位置
- markers 标记点
- bindmarkertap 点位点击事件
php
// map.ts
//qqmap 实例的创建详见腾讯位置服务文档
qqmap.search({
keyword: '超市',
success: function (res: any) {
that.setData({
markers: res.data.map((item: any, index: number) => {
return {
address: item.address,
height: 30,
iconPath: '/img/marker.png',
latitude: item.location.lat,
longitude: item.location.lng,
title: item.title,
width: 18,
id: index,
callout: {
content: item.title,
color: "#000",
borderRadius: 10,
padding: 10,
display: index == 0 ? "ALWAYS" : "BYCLICK", // 气泡窗口只有在用户点击标记时才会显示
},
};
}),
});
if (
that.data.markers[0] &&
that.data.markers[0].latitude &&
that.data.markers[0].longitude
) {
that.setData({
latitude: that.data.markers[0].latitude.toString(),
});
that.setData({
longitude: that.data.markers[0].longitude.toString(),
});
}
}
});
我们通过 qqmap 搜索附近的超市,并将点位信息按照 map 组件要求的格式赋值给 this.data.markers。如果你使用了高德的小程序插件,则不用这么麻烦,高德将返回的 marker 信息封装好了,下面是高德的代码:
php
amap.getPoiAround({
iconPathSelected: "/img/marker_checked.png",
iconPath: "/img/marker.png",
success: function (data: { markers: markerItem[] }) {
markersData = data.markers;
that.setData({
markers: markersData.map((item: markerItem, index: number) => {
return {
...item, //直接将对象放进去就好了
callout: {
content: item.name,
color: "#000",
borderRadius: 10,
padding: 10,
display: index == 0 ? "ALWAYS" : "BYCLICK", // 气泡窗口只有在用户点击标记时才会显示
},
};
}),
});
if (
markersData[0] &&
markersData[0].latitude &&
markersData[0].longitude
) {
that.setData({
latitude: markersData[0].latitude.toString(),
});
that.setData({
longitude: markersData[0].longitude.toString(),
});
}
that.showMarkerInfo(markersData, 0);
}
});
完成上面的一步之后,你应该会看到如下画面(模拟器)
当给 markers 使用 setData 赋值之后,地图上会同步刷新出这些点位。编写上面提到的makertap方法,即可捕获marker的点击事件。
这里我们给marker绑定一个获取路线的事件:
css
goTo() {
wx.getLocation({
type: "gcj02",
success: (res) => {
// 获取到当前位置,模拟器上固定在北京朝阳门外附近区域
let currentLocation = {
latitude: res.latitude,
longitude: res.longitude
}
qqmap.direction({
mode: 'walking',
from: currentLocation,
to: {
latitude: this.data.textData.lnglat.split(',')[1],
longitude: this.data.textData.lnglat.split(',')[0]
},
success: function (res: any) {
for (var i = 2; i < res.result.routes[0].polyline.length; i++) { res.result.routes[0].polyline[i] = res.result.routes[0].polyline[i - 2] + res.result.routes[0].polyline[i] / 1000000 }
let points = []
for (let i = 0; i < res.result.routes[0].polyline.length; i++) {
if (i % 2 == 0) {
points.push({
longitude: res.result.routes[0].polyline[i + 1],
latitude: res.result.routes[0].polyline[i]
})
}
}
// 以上是解压缩小程序返回的路线点位
that.setData({
polyline: [
{
points: points,
color: "#0091ff",
width: 6,
},
],
viewPoints: points,
steps: res.result.routes[0].steps //显示第一步提示信息
});
mapCtx.includePoints({
padding: [10],
points: points,
})
// 使地图显示在最合适的缩放级别,不会因为点的多少而导致显示不全
}
})
}
})
},
此时,我们可以看到,在地图上,勾画出了蓝色的线,标记出了线路。并且自动适应了视角
注意关键来了,这也我们基本已经把路径规划的接口利用完了,接下来,我们将尝试用实时定位接口,来实现真正意义上的实时导航。
使用实时定位
后台实时定位
javascript
wx.startLocationUpdateBackground({
type: "gcj02",
success: (res) => {
wx.onLocationChange((res) => {
console.log("onLocationChange", res);
});
},
});
在 console.log("onLocationChange", res);
处,我们大概可以每秒钟获取到用户的最新位置。经过测试,这个位置虽然比较精确(米级),但是有时会瞬移乱跳,好在不太频发,基本可用。
接下来,我们将在wx.onLocationChange
的回调中,处理所有的导航相关逻辑。
我这里预制了几条用于测试的用户位置
ini
// 真实位置
let userLocation: [number, number] = [res.longitude, res.latitude]
// 模拟走过一段距离后的位置
// let userLocation: [number, number] = [this.data.viewPoints[+(this.data.viewPoints.length / 2)].longitude, this.data.viewPoints[+(this.data.viewPoints.length / 2)].latitude]
// 模拟距离起点有一定距离的位置
// let userLocation: [number, number] = [this.data.viewPoints[0].longitude + 0.0001, this.data.viewPoints[0].latitude + 0.0001]
// 模拟用户偏离路线的位置
// let userLocation: [number, number] = [this.data.viewPoints[+(this.data.viewPoints.length / 2)].longitude + 0.001, this.data.viewPoints[+(this.data.viewPoints.length / 2)].latitude + 0.0001]
上面的路径规划完成后,我们存在了this.data.viewPoints
中,这可以同时方便我们适应视角,所以在这里,我们可以直接用这个值,来算各种参数。
使用turf
turf.js 是目前应用最广泛的geo计算库,包含了几乎全方向的geo计算方法。
首先,我们需要计算出用户距离路线的最短距离,以及以这个最短距离向路线画垂直线的垂足位置
typescript
import pointToLineDistance from '@turf/point-to-line-distance'
import nearestPointOnLine from '@turf/nearest-point-on-line';
import pointToPointDistance from '@turf/distance';
let line: LineString = {
type: "LineString",
coordinates: this.data.viewPoints.map((item: any) => {
return [item.longitude, item.latitude]
})
} // turf 的标准线数据格式
console.log('用户位置::: ', userLocation);
console.log('路线::: ', line);
var distance = pointToLineDistance(userLocation, line, { units: 'kilometers' }) * 1000;
console.log('最短距离为::: ', distance);
let nearestPoint = nearestPointOnLine(line, userLocation, { units: 'kilometers' });
console.log('最近的点(垂足)是::: ', nearestPoint);
通过计算用户位置和路线的最短距离,我们可以得知用户是否偏航
kotlin
// yawLimit == 20
if (distance >= this.data.yawLimit) {
console.log('超出偏离阈值::: ', this.data.yawLimit);
this.setData({
isYawed: true //设置此值后,wxml的偏航提示被显示
})
wx.vibrateLong()
setTimeout(() => {
wx.vibrateLong()
}, 500); //两次震动提示
}
偏航的逻辑处理完了,那么剩下的就是不偏航的,如下:
kotlin
else {
// 导航结束逻辑
let endPoints = this.data.viewPoints[this.data.viewPoints.length - 1]
if (distance <= 10 && pointToPointDistance(userLocation, [endPoints.longitude, endPoints.latitude], { units: 'kilometers' }) * 1000 <= 10) {
wx.stopLocationUpdate({
success: (res) => {
console.log('stopLocationUpdate', res);
},
})
wx.showModal({
title: '提示',
content: '您已到达目的地附近,导航结束',
showCancel: false,
})
this.setData({
polyline: [],
navigationJustStart: true,
isYawed: false,
lastTextData: {} as textData,
currStep: {} as step,
})
// 以上判断用户位置是否距离终点小于10米,清空所有数据,并告知用户导航结束
return
}
if (distance <= this.data.yawLimit && this.data.navigationJustStart) {
this.setData({
navigationJustStart: false,
})
}
this.setData({
isYawed: false
})
let pointIndex = nearestPoint.properties.index!
let currStep = this.data.steps.filter((item: any, index: number) => {
if (item.polyline_idx[0] <= pointIndex * 2 && item.polyline_idx[1] >= pointIndex * 2) {
return item
}
})
this.setData({
currStep: currStep[0] //更新本段路的文本提示
})
// 以下代码是:用户没有走过的线,以及垂足点,画蓝色的线
this.data.polyline[0] = {
points: [{
longitude: nearestPoint.geometry.coordinates[0],
latitude: nearestPoint.geometry.coordinates[1]
}].concat(this.data.viewPoints.filter((item: any, index: number) => {
if (index == this.data.viewPoints.length - 1) return true
return index > pointIndex
})),
color: '#1989fa',
width: 6,
}
// 以下代码是:在此之前的点都是已经走过的点,以及垂足点,衔接上没有走过的线,画一层灰色的线
this.data.polyline[1] = {
points: this.data.viewPoints.filter((item: any, index: number) => {
if (index == 0) return true
return index <= pointIndex
}).concat({
longitude: nearestPoint.geometry.coordinates[0],
latitude: nearestPoint.geometry.coordinates[1]
}),
color: '#999',
width: 6,
}
// 以下代码是:以下代码是用户真实位置和垂足点的连线,这里使用虚线,可以告知用户在路的附近,以及线路位于用户的方向
this.data.polyline[2] = {
points: [
{
longitude: nearestPoint.geometry.coordinates[0],
latitude: nearestPoint.geometry.coordinates[1]
},
{
longitude: userLocation[0],
latitude: userLocation[1]
}
],
color: '#1989fa',
dottedLine: true,
width: 6,
}
this.setData({
polyline: this.data.polyline //更新线
});
}
注意:小程序更新polyline时,会重新渲染整条线,而不是增量更新的,所以在线路较大,或者渲染性能低的时候,会看到线路会闪一下,这属于小程序map组件本身的Bug,我也无能为力。
完成
至此,这个小程序已经基本实现了导航功能,流程为:搜索附近点位 > 点击点选中并导航 > 规划路线 > 使用turf以及后台实时定位计算用户与路径的位置关系,从而实现各种事件。
来看一下最终效果:
真机上 用户位置会有一个带箭头的绿色点位
导航开始
导航开始
模拟起始位置距离起点有一定距离
模拟已经走过一段距离
模拟偏航
到达终点
注:上述"模拟"只是手动改变了用户位置,与真机上的实际显示情况是一致的。
结语
加入诺克萨斯,我德莱厄斯批准你做大将军
扫码加入诺克萨斯帝国,入住不朽堡垒。