Loading [MathJax]/jax/output/CommonHTML/config.js
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >使用高德API和MapboxGL实现路径规划并语音播报

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

原创
作者头像
牛老师讲GIS
发布于 2024-11-11 10:28:02
发布于 2024-11-11 10:28:02
2410
举报

概述

本文使用高德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()方法来产生语音。

SpeechSynthesisUtteranceHTML5中新增的API,用于将指定文字合成为对应的语音。它包含一些配置项,可以指定如何去阅读(如语言、音量、音调等)。

简单使用示例如下代码:

代码语言:js
AI代码解释
复制
// 创建 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作为演示,界面与地图初始化代码如下:

代码语言:vue
AI代码解释
复制
<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轨迹展示与播放已经有过分享,示例在前文的基础上做了一点改动,改动完代码如下:

代码语言:js
AI代码解释
复制
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;

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
来来来,手把手教你做大白!
这个东西也是经常被拿来玩的一个小东西,就是通过border-radius 去自己切一个图形。
疯狂的技术宅
2019/03/28
4360
CSS3实现冰墩墩自由
CSS3代码: body { background: rgba(72, 167, 255, 0.733); overflow: hidden; width: 100%; height: 100%; } .main { width: 100px; margin: 10% auto; perspective: 300px; position: absolute; top: 20%; left: 50%; transfor
用户5997198
2022/03/28
4110
CSS3实现冰墩墩自由
冰墩墩太火了,一墩难求?Bloger用css方式呈现一人一墩
近日来,围绕冬奥的热点层出不穷。观众们眼前不断闪过一个接一个既陌生又新鲜好玩的项目,话题更迭的速度比钢架雪车还快。自然也会衍生出诸多分歧:围绕谷爱凌的国籍、苏翊鸣的分数、短道速滑赛场上几乎每一次的摔倒和判罚……这是每一届体育大赛中,都少不了的争论甚至争吵,只有冰墩墩成为了例外。
李洋博客
2022/02/18
4470
冰墩墩太火了,一墩难求?Bloger用css方式呈现一人一墩
CSS+HTML绘制2022年北京冬奥会吉祥物冰墩墩
既然买不到冰墩墩,就自己做个专属冰墩墩吧!以上代码都是本人原创,转载的话请注明出处
用户9999906
2022/09/26
3220
【Html.js——CSS布局】618 活动(蓝桥杯真题-2325)【合集】
Rossy Yan
2025/02/02
810
【Html.js——CSS布局】618 活动(蓝桥杯真题-2325)【合集】
【Html.js——页面布局】个人博客(蓝桥杯真题-1766)【合集】
通过以上步骤,HTML 和 CSS 代码协同工作,实现了一个具有导航栏、首页 banner、文章列表和右侧栏的个人博客页面布局。
Rossy Yan
2025/01/24
1470
【Html.js——页面布局】个人博客(蓝桥杯真题-1766)【合集】
【Html.js——Bug修复】找回连接的奇幻之旅(蓝桥杯真题-18555)【合集】
请在 js/index.js 文件中补充 resetableOnce 函数,实现在接收相同的函数时只执行一次。
Rossy Yan
2025/02/26
1090
【Html.js——Bug修复】找回连接的奇幻之旅(蓝桥杯真题-18555)【合集】
【Html.js——页面布局】水果摆盘(蓝桥杯真题-1767)【合集】
在需要修改部分的代码有相关提示,请仔细阅读之后,使用 flex 布局中的 align-self 和 order 完善 index.css 中的代码, 把对应的水果放在对应的盘子里面,最终效果如下:
Rossy Yan
2025/01/24
2450
【Html.js——页面布局】水果摆盘(蓝桥杯真题-1767)【合集】
HTML+CSS+JS 实现登录注册界面[通俗易懂]
鉴于小伙伴们没有csdn积分,我把代码压缩成了一个压缩包,放在了gitee上面,需要的请点击下载 点击下载
全栈程序员站长
2022/09/13
26.3K0
HTML+CSS+JS 实现登录注册界面[通俗易懂]
CSS3 Loading加载效果合集
效果1 CSS <style> .loading { width: 300px; height: 300px; position: relative; border: 1px solid gray; } .loading .line { width: 200px; height: 8px; /* margin: 50% 20px; */ position:
无道
2019/11/13
1.3K0
CSS3 Loading加载效果合集
CSS3实现雪容融自由
前几天写了一篇CSS3实现冰墩墩自由的技术文章,很多人问有没有雪容融的,今天就来啦!
用户5997198
2022/03/28
2690
CSS3实现雪容融自由
【Html.js——功能实现】布局切换(蓝桥杯真题-18556)【合集】
Rossy Yan
2025/02/21
2660
【Html.js——功能实现】布局切换(蓝桥杯真题-18556)【合集】
【CSS——功能实现】用户名片(蓝桥杯真题-2321)【合集】
选中 index.html 右键启动 Web Server 服务(Open with Live Server),让项目运行起来。
Rossy Yan
2025/02/02
1590
【CSS——功能实现】用户名片(蓝桥杯真题-2321)【合集】
700行无用 纯 CSS 祝考生 金榜高粽《1_bit 的无用 CSS 代码 》
今天才想起来这回事,没办法就急急忙忙的赶工一下,接下来我就画一下这个海报试试手了:
1_bit
2022/06/06
6440
700行无用 纯 CSS 祝考生 金榜高粽《1_bit 的无用 CSS 代码 》
【Html.js——标签导航栏】卡片化标签页(蓝桥杯真题-1765)【合集】
选中 index.html 右键启动 Web Server 服务(Open with Live Server),让项目运行起来。接着,打开环境右侧的【Web 服务】,就可以在浏览器中看到如下效果,当前显示仅有静态布局,并未实现选项卡切换功能。
Rossy Yan
2025/01/24
3210
【Html.js——标签导航栏】卡片化标签页(蓝桥杯真题-1765)【合集】
【Html.js——功能实现】蓝桥校园一卡通(蓝桥杯真题-2421)【合集】
HTML 部分主要负责构建页面的结构,创建了一个校园一卡通制卡的表单界面,包含了卡片展示区域和用户输入信息的表单区域。
Rossy Yan
2025/02/10
1930
【Html.js——功能实现】蓝桥校园一卡通(蓝桥杯真题-2421)【合集】
【CSS3——页面布局】画一只考拉(蓝桥杯真题-2341)【合集】
这段 HTML 代码构建了页面的基本结构,为绘制考拉提供了元素容器。主要通过嵌套的 <div> 元素来划分不同的部分,每个部分都有对应的类名,方便在 CSS 中进行样式设置。
Rossy Yan
2025/02/06
1850
【CSS3——页面布局】画一只考拉(蓝桥杯真题-2341)【合集】
归档 | 原生JS实现会动代码之哆啦A梦
TL;DR 项目官网:https://doraemon.jirengu.maylove.pub 源码地址:https://github.com/zkeq/Doraemon 实现原理 textDom.innerHTML = cssString.substring(0, textStartIndex); styleDom.innerHTML = cssString.substring(0, textStartIndex); 没啥好说的 完整 js 放一下 const cssString
Zkeq
2022/09/07
6680
HTML简单音乐播放器「建议收藏」
通过JS部分的代码,动态给歌曲信息模块(id为player-content1)的DOM元素添加/移除active类名。 设置CSS3动画过渡属性: transition:top 0.3s ease;来生成过渡时间0.3s,速度逐渐变慢的: 上移动画效果:top:0px; ——>top:-85px; 下移动画效果: top:-85px; ——>top:0px;
全栈程序员站长
2022/09/07
4.6K0
乐高个性化小人生成器(源代码一键复制即可运行 )
大家好,今天我要给大家介绍一个超级有趣的网页项目——乐高个性化小人生成器!这个项目可以让你随心所欲地定制属于自己的乐高小人,让它们拥有独一无二的表情和配色。
前端达人
2024/11/25
1260
乐高个性化小人生成器(源代码一键复制即可运行 )
推荐阅读
相关推荐
来来来,手把手教你做大白!
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档