概述
本文使用高德API
实现位置查询和路径规划,使用MapboxGL
完成地图交互与界面展示,并使用Web Speech API
实现行驶中路线的实时语音播报。
效果
Web Speech API简介
Web Speech API
使你能够将语音数据合并到 Web 应用程序中。Web Speech API
有两个部分:SpeechSynthesis
语音合成(文本到语音 TTS)和 SpeechRecognition
语音识别(异步语音识别)。
-
语音识别通过
SpeechRecognition
接口进行访问,它提供了识别从音频输入(通常是设备默认的语音识别服务)中识别语音情景的能力。一般来说,你将使用该接口的构造函数来构造一个新的SpeechRecognition
对象,该对象包含了一系列有效的对象处理函数来检测识别设备麦克风中的语音输入。SpeechGrammar
接口则表示了你应用中想要识别的特定文法。文法则通过JSpeech Grammar Format (JSGF.)
来定义。 -
语音合成通过
SpeechSynthesis
接口进行访问,它提供了文字到语音(TTS)的能力,这使得程序能够读出它们的文字内容(通常使用设备默认的语音合成器)。不同的声音类类型通过SpeechSynthesisVoice
对象进行表示,不同部分的文字则由SpeechSynthesisUtterance
对象来表示。你可以将它们传递给SpeechSynthesis.speak()
方法来产生语音。
SpeechSynthesisUtterance
是HTML5中新增的API,用于将指定文字合成为对应的语音。它包含一些配置项,可以指定如何去阅读(如语言、音量、音调等)。
简单使用示例如下代码:
// 创建 SpeechSynthesisUtterance 对象
var utterance = new SpeechSynthesisUtterance();
// 可选:设置语言(例如,中文)
utterance.lang = "zh-CN";
// 可选:设置语音
utterance.voice = window.speechSynthesis.getVoices()[0];
// 可选:设置音量(0.0到1.0之间)
utterance.volume = 1.0;
// 可选:设置语速(正常速度为1.0)
utterance.rate = 1.0;
// 可选:设置语调(正常语调为1.0)
utterance.pitch = 1.0;
// 设置要朗读的文本
utterance.text = '设置要朗读的文本';
window.speechSynthesis.speak(utterance);
实现
实现思路
- 地图初始化的时候通过H5的
geolocation
接口获取当前位置; - 调用
rgeo
接口,根据获取到的位置获取位置所在市; - 调用
inputtips
接口完成关键词联想查询; - 调用
/direction/driving
接口完成路径的规划; - 用MapboxGL实现地图交互与路径展示;
- 根据当前位置判断是否进入对应的步骤,提示对应的语音。
实现代码
示例使用Vue作为演示,界面与地图初始化代码如下:
<template>
<div class="container">
<div class="query">
<el-form :model="form" label-width="auto" class="query-form">
<el-form-item label="起点">
<el-autocomplete
v-model="form.startPosition"
:fetch-suggestions="querySearchStart"
clearable
placeholder="请输入起点位置"
@select="handleSelectStart"
>
<template #default="{ item }">
<div class="autocomplete-value">{{ item.value }}</div>
<div class="autocomplete-address">{{ item.address }}</div>
</template></el-autocomplete
>
</el-form-item>
<el-form-item label="终点" style="margin-bottom: 0">
<el-autocomplete
v-model="form.endPosition"
:fetch-suggestions="querySearchEnd"
clearable
placeholder="请输入终点位置"
@select="handleSelectEnd"
>
<template #default="{ item }">
<div class="autocomplete-value">{{ item.value }}</div>
<div class="autocomplete-address">{{ item.address }}</div>
</template></el-autocomplete
>
</el-form-item>
</el-form>
<div class="query-button">
<el-button
:disabled="!form.startPosition || !form.endPosition"
class="query-button-inner"
@click="queryRoute"
>查询</el-button
>
</div>
</div>
<div class="map" id="map">
<div class="map-button">
<el-button
v-show="path"
class="map-button-inner"
type="primary"
@click="toggleAnimate"
>{{ isPlay ? "结束导航" : "开始导航" }}</el-button
>
</div>
</div>
</div>
</template>
<script>
const AK = "你申请的key"; // 高德地图key
const BASE_URL = "https://restapi.amap.com/v3";
let map = null,
animation = null;
import { ElMessage, ElMessageBox } from "element-plus";
import AnimationRoute from "./utils/route";
//封装请求
function request(url, params) {
let fullUrl = `${BASE_URL}${url}?key=${AK}`;
for (const key in params) {
fullUrl += `&${key}=${params[key]}`;
}
return new Promise((resolve, reject) => {
fetch(fullUrl)
.then((res) => res.json())
.then((res) => {
if (res.status === "1") resolve(res);
else {
ElMessage.error("接口请求失败");
reject(res);
}
});
});
}
export default {
data() {
return {
center: [113.94150905808976, 22.523881824251347],
form: {
startPosition: "",
endPosition: "",
startCoord: [],
endCoord: [],
},
cityInfo: {},
path: null,
isPlay: false,
};
},
mounted() {
const that = this;
function successFunc(position) {
const { longitude, latitude } = position.coords;
that.center = [longitude, latitude];
that.initMap(true);
}
function errFunc() {
that.initMap(false);
}
if (navigator.geolocation) {
try {
errFunc();
navigator.geolocation.getCurrentPosition(successFunc, errFunc);
} catch (e) {
errFunc();
}
} else {
errFunc();
}
},
methods: {
toggleAnimate() {
if (!animation) return;
if (this.isPlay) {
animation.pause();
ElMessageBox.confirm("确认取消导航吗?", "提示", {
confirmButtonText: "确认",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
animation.destory();
animation = null;
this.path = null;
this.form = {
startPosition: "",
endPosition: "",
startCoord: [],
endCoord: [],
};
this.isPlay = false;
})
.catch(() => {
animation.play();
this.isPlay = true;
});
} else {
animation.play();
this.isPlay = true;
}
},
initMap(isLocate) {
map = new SFMap.Map({
container: "map",
center: this.center,
zoom: 17.1,
});
map.on("load", (e) => {
new SFMap.SkyLayer({
map: map,
type: "atmosphere",
// 设置天空光源的强度
atmosphereIntensity: 12,
// 设置太阳散射到大气中的颜色
atmosphereColor: "rgba(87, 141, 219, 0.8)",
// 设置太阳光晕颜色
atmosphereHaloColor: "rgba(202, 233, 250, 0.1)",
});
request("/geocode/regeo", {
location: this.center.join(","),
}).then((res) => {
this.cityInfo = res.regeocode.addressComponent;
});
});
},
querySearchStart(str, cb) {
if (str) {
request("/assistant/inputtips", {
keywords: str,
city: this.cityInfo?.city,
}).then((res) => {
cb(
res.tips.map((t) => {
t.value = t.name;
return t;
})
);
});
} else {
cb([]);
}
},
querySearchEnd(str, cb) {
if (str) {
request("/assistant/inputtips", {
keywords: str,
city: this.cityInfo?.city,
}).then((res) => {
cb(
res.tips.map((t) => {
t.value = t.name;
return t;
})
);
});
} else {
cb([]);
}
},
handleSelectStart(item) {
this.form.startCoord = item.location.split(",").map(Number);
},
handleSelectEnd(item) {
this.form.endCoord = item.location.split(",").map(Number);
},
queryRoute() {
const { startCoord, endCoord } = this.form;
request("/direction/driving", {
origin: startCoord.join(","),
destination: endCoord.join(","),
extensions: "all",
}).then((res) => {
const path = res.route.paths[0];
let coordinates = [];
this.path = path;
path.steps.forEach((step) => {
const polyline = step.polyline
.split(";")
.map((c) => c.split(",").map(Number));
coordinates = [...coordinates, ...polyline];
});
const route = {
type: "Feature",
properties: { path },
geometry: {
type: "LineString",
coordinates: coordinates,
},
};
animation = new AnimationRoute(map, route, false);
});
},
},
};
</script>
<style scoped lang="scss">
.container {
width: 100%;
height: 100vh;
overflow: hidden;
}
.query {
padding: 0.8rem;
overflow: hidden;
background: #ccc;
.query-form {
width: calc(100% - 4rem);
float: left;
}
.query-button {
width: 2rem;
height: 4.7rem;
float: right;
display: flex;
justify-content: flex-end;
align-items: center;
.query-button-inner {
width: 3.8rem;
height: 100%;
}
}
}
.map {
height: calc(100% - 6.4rem);
}
.autocomplete-value,
.autocomplete-address {
white-space: nowrap;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.autocomplete-address {
font-size: 0.8rem;
color: #999;
margin-top: -0.8rem;
border-bottom: 1px solid #efefef;
}
.map-button {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
z-index: 99;
padding: 0.8rem;
box-sizing: border-box;
&-inner {
width: 100%;
}
}
:deep .el-form-item {
margin-bottom: 0.5rem;
}
</style>
示例中使用轨迹播放的方式演示了位置的变化,前文mapboxGL轨迹展示与播放已经有过分享,示例在前文的基础上做了一点改动,改动完代码如下:
const icon = "/imgs/car.png";
const arrow = "/imgs/arrow.png";
import * as turf from "@turf/turf";
class AnimationRoute {
constructor(map, route, play = true, fit = true, speed = 60) {
this._map = map;
this._json = route;
this._play = play;
this._speed = speed;
this._path = route.properties.path;
this.init();
if (fit) this._map.fitBounds(turf.bbox(route), { padding: 50 });
}
init() {
const that = this;
// 创建 SpeechSynthesisUtterance 对象
var utterance = new SpeechSynthesisUtterance();
// 可选:设置语言(例如,中文)
utterance.lang = "zh-CN";
// 可选:设置语音
utterance.voice = window.speechSynthesis.getVoices()[0];
// 可选:设置音量(0.0到1.0之间)
utterance.volume = 1.0;
// 可选:设置语速(正常速度为1.0)
utterance.rate = 1.0;
// 可选:设置语调(正常语调为1.0)
utterance.pitch = 1.0;
that.utterance = utterance;
that._index = 0;
const length = turf.length(that._json);
const scale = 60;
that._count = Math.round((length / that._speed) * 60 * 60) * scale;
that._step = length / that._count;
that._stepPlay = -1;
that._flag = 0;
that._playId = "play-" + Date.now();
// 添加路径图层
that._map.addSource(that._playId, {
type: "geojson",
data: that._json,
});
that._map.addLayer({
id: that._playId,
type: "line",
source: that._playId,
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": "#aaaaaa",
"line-width": 10,
},
});
// 添加已播放路径
that._map.addSource(that._playId + "-played", {
type: "geojson",
data: that._json,
});
that._map.addLayer({
id: that._playId + "-played",
type: "line",
source: that._playId + "-played",
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": "#09801a",
"line-width": 10,
},
});
// 添加路径上的箭头
that._map.loadImage(arrow, function (error, image) {
if (error) throw error;
that._map.addImage(that._playId + "-arrow", image);
that._map.addLayer({
id: that._playId + "-arrow",
source: that._playId,
type: "symbol",
layout: {
"symbol-placement": "line",
"symbol-spacing": 50,
"icon-image": that._playId + "-arrow",
"icon-size": 0.6,
"icon-allow-overlap": true,
},
});
});
// 添加动态图标
that._map.loadImage(icon, function (error, image) {
if (error) throw error;
that._map.addImage(that._playId + "-icon", image);
that._map.addSource(that._playId + "-point", {
type: "geojson",
data: that._getDataByCoords(),
});
that._map.addLayer({
id: that._playId + "-point",
source: that._playId + "-point",
type: "symbol",
layout: {
"icon-image": that._playId + "-icon",
"icon-size": 0.75,
"icon-allow-overlap": true,
"icon-rotation-alignment": "map",
"icon-pitch-alignment": "map",
"icon-rotate": 50,
},
});
that._animatePath();
});
}
pause() {
this._play = false;
window.cancelAnimationFrame(this._flag);
}
start() {
this._index = 0;
this.play();
}
play() {
this._play = true;
this._animatePath();
}
_animatePath() {
if (this._index > this._count) {
window.cancelAnimationFrame(this._flag);
} else {
const coords = turf.along(this._json, this._step * this._index).geometry
.coordinates;
// 已播放的线
const start = turf.along(this._json, 0).geometry.coordinates;
this._map
.getSource(this._playId + "-played")
.setData(turf.lineSlice(start, coords, this._json));
// 车的图标位置
this._map
.getSource(this._playId + "-point")
.setData(this._getDataByCoords(coords));
// 计算旋转角度
const nextIndex =
this._index === this._count ? this._count - 1 : this._index + 1;
const coordsNext = turf.along(this._json, this._step * nextIndex).geometry
.coordinates;
let angle = turf.bearing(turf.point(coords), turf.point(coordsNext)) - 90;
if (this._index === this._count) angle += 180;
this._map.setLayoutProperty(
this._playId + "-point",
"icon-rotate",
angle
);
const camera = this._map.getFreeCameraOptions();
camera.position = mapboxgl.MercatorCoordinate.fromLngLat(coords, 100);
camera.lookAtPoint(coordsNext);
this._map.setFreeCameraOptions(camera);
this._map.setPitch(80);
this._index++;
if (this._play) {
this._playInstruction(coords);
this._flag = requestAnimationFrame(() => {
this._animatePath();
});
}
}
}
_playInstruction(coords) {
const { steps } = this._path;
const stepPlay = this._stepPlay;
const start = this._stepPlay !== -1 ? this._stepPlay : 0
for (let i = start; i < steps.length; i++) {
const step = steps[i];
const polyline = step.polyline
.split(";")
.map((v) => v.split(",").map(Number));
const pt = turf.point(coords);
const line = turf.lineString(polyline);
const dis = turf.pointToLineDistance(pt, line) * 1000;
if (i > this._stepPlay && dis < 10) {
this._stepPlay = i;
break;
}
}
if (stepPlay !== this._stepPlay) {
this.utterance.text = steps[this._stepPlay].instruction;
window.speechSynthesis.speak(this.utterance);
}
}
_getDataByCoords(coords) {
if (!coords || coords.length !== 2) return null;
return turf.point(coords, {
label: this._formatDistance(this._step * this._index),
});
}
_formatDistance(dis) {
if (dis < 1) {
dis = dis * 1000;
return dis.toFixed(0) + "米";
} else {
return dis.toFixed(2) + "千米";
}
}
destory() {
window.cancelAnimationFrame(this._flag);
if (this._map.getSource(this._playId + "-point")) {
this._map.removeLayer(this._playId + "-point");
this._map.removeSource(this._playId + "-point");
}
if (this._map.getSource(this._playId + "-played")) {
this._map.removeLayer(this._playId + "-played");
this._map.removeSource(this._playId + "-played");
}
if (this._map.getSource(this._playId)) {
this._map.removeLayer(this._playId);
this._map.removeLayer(this._playId + "-arrow");
this._map.removeSource(this._playId);
}
}
}
export default AnimationRoute;