使用高德API和MapboxGL实现路径规划并语音播报

概述

本文使用高德API实现位置查询和路径规划,使用MapboxGL完成地图交互与界面展示,并使用Web Speech API实现行驶中路线的实时语音播报。

效果

image.png
image.png

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);

实现

实现思路

  1. 地图初始化的时候通过H5的geolocation接口获取当前位置;
  2. 调用rgeo接口,根据获取到的位置获取位置所在市;
  3. 调用inputtips接口完成关键词联想查询;
  4. 调用/direction/driving接口完成路径的规划;
  5. 用MapboxGL实现地图交互与路径展示;
  6. 根据当前位置判断是否进入对应的步骤,提示对应的语音。

实现代码

示例使用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;
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容