Bootstrap

基于 Leaflet 和原生 JavaScript 的轨迹回放功能实现

在现代地理信息系统(GIS)开发中,轨迹回放功能是可视化移动对象(如车辆、船只、无人机等)运动路径的关键工具。它不仅能够帮助用户直观地了解对象的运动轨迹,还能通过动态展示增强用户体验。然而,许多开发者在实现这一功能时,往往会依赖于第三方插件,而忽略了纯 JavaScript 实现的灵活性和性能优势。

本文将深入探讨如何使用 Leaflet 和原生 JavaScript 实现轨迹回放功能。我们将从地图初始化、轨迹数据准备、动画实现以及方向计算等多个方面进行详细解析。这种方法虽然代码量稍多,但提供了更高的灵活性和性能优化空间,尤其适合对性能和功能有较高要求的项目。

一、整体设计思路

在实现轨迹回放功能时,我们需要完成以下几个关键步骤:

  1. 地图初始化:构建一个稳定的基础地图环境,加载地图瓦片并设置初始状态。

  2. 轨迹数据准备:对轨迹数据进行格式化和坐标转换,确保其与 Leaflet 兼容。

  3. 轨迹动画实现:通过定时器逐步更新标记的位置,并根据轨迹方向动态调整标记的旋转角度。

  4. 动态方向计算:实现一个算法,动态计算标记的旋转角度,使其始终指向运动方向。

  5. 性能优化:通过合理设置动画参数和减少不必要的 DOM 操作,提升性能和用户体验。

二、具体实现步骤

地图初始化

在实现轨迹回放之前,我们需要构建一个稳定的基础地图环境。Leaflet 是一个轻量级且功能强大的地图库,支持多种地图瓦片源。本文中,我们选择使用天地图(Tianditu)作为地图数据源。以下是初始化地图的核心代码:

import L from "leaflet";
import "leaflet-rotatedmarker";  // 用于图标旋转

export default {
  data() {
    return {
      map: null,
    };
  },
  mounted() {
    this.initMap();
  },
  methods: {
    initMap() {
      this.map = L.map("mapViewRef", {
        center: [23.120555, 113.324553], // 地图中心点
        zoom: 15, // 初始缩放比例
        zoomControl: false, // 禁用默认缩放控件
        doubleClickZoom: false, // 禁用双击放大
        attributionControl: false, // 禁用默认版权信息
        minZoom: 3, // 最小缩放级别
      });

      const tiandiKey = "YOUR_TIANDITU_KEY";  // 替换为你的天地图密钥
      const mapUrl = `http://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandiKey}`;
      const cvaLayer = L.tileLayer(
        `http://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandiKey}`
      );

      L.tileLayer(mapUrl).addTo(this.map);  // 添加矢量地图层
      cvaLayer.addTo(this.map);  // 添加注记层
    },
  },
};

说明:初始化地图时,我们禁用了默认的缩放控件和双击放大功能,以避免与自定义控件冲突。同时,通过天地图的矢量地图层和注记层,我们能够提供更丰富的地图信息。


轨迹数据准备

轨迹数据通常以经纬度数组的形式提供,但在实际应用中,这些数据可能来自不同的坐标系(如 GCJ-02、WGS-84 等)。为了确保数据的准确性,我们需要将这些坐标统一转换为 Leaflet 支持的 WGS-84 坐标系。以下是数据准备的核心代码:

import gcoord from "gcoord";

export default {
  data() {
    return {
      trackList: [
        {
          latitude: "23.124336",
          longitude: "113.315739",
          creatorTime: "2024-07-20 02:40:04"
        },
        {
          latitude: "23.123918",
          longitude: "113.320863",
          creatorTime: "2024-07-21 02:40:04"
        },
        {
          latitude: "23.119572",
          longitude: "113.321034",
          creatorTime: "2024-07-22 02:40:04"
        }
        ....
      ],
    };
  },
  mounted() {
    // 转换轨迹坐标数据
    this.trackList.forEach((item) => {
      // 转换坐标系(将 GCJ-02 坐标转换为 WGS-84 坐标)
      const [longitude, latitude] = gcoord.transform(
        [item.longitude, item.latitude],
        gcoord.GCJ02,
        gcoord.WGS84
      );
      // 更新转换后的坐标
      item.longitude = longitude;
      item.latitude = latitude;
    });
  },
}

说明gcoord 是一个常用的地理坐标转换工具,支持 GCJ-02(火星坐标系)、WGS-84(全球定位系统坐标系)等多种坐标系之间的转换。通过 gcoord.transform 方法,我们将原始轨迹数据的坐标转换为 WGS-84 坐标系,以确保与 Leaflet 的兼容性。


轨迹动画实现

轨迹动画的核心是通过定时器逐步更新标记的位置,并根据轨迹方向动态调整标记的旋转角度。以下是动画实现的关键代码:

animateTrack(trackList) {
  const segments = trackList.map((point, index) => ({
    start: L.latLng(point.lat, point.lng),
    end: L.latLng(trackList[index + 1]?.lat, trackList[index + 1]?.lng),
  }));

  const speed = 0.05;  // 动画速度
  const interval = 20;  // 更新间隔

  const animate = () => {
    if (!this.isAnimationRunning) return;

    const segment = segments[this.segmentIndex];
    const latLng = L.latLng(
      segment.start.lat + this.progress * (segment.end.lat - segment.start.lat),
      segment.start.lng + this.progress * (segment.end.lng - segment.start.lng)
    );

    this.shipMarker.setLatLng(latLng);
    this.shipMarker.setRotationAngle(this.calculateBearing(segment.start, segment.end));

    if (this.progress >= 1) {
      this.progress = 0;
      this.segmentIndex++;
    } else {
      this.progress += speed;
    }

    this.animationTimeout = setTimeout(animate, interval);
  };

  animate();
},

说明:动画的核心逻辑是通过 setIntervalsetTimeout 定时器逐步更新标记的位置。我们通过线性插值计算标记在当前线段上的位置,并动态调整标记的旋转角度以匹配轨迹方向。


动态方向计算

轨迹动画中,标记的方向需要根据轨迹的走向动态调整。以下是计算方向的核心算法:

calculateBearing(start, end) {
  const startLat = this.degreesToRadians(start.lat);
  const startLng = this.degreesToRadians(start.lng);
  const endLat = this.degreesToRadians(end.lat);
  const endLng = this.degreesToRadians(end.lng);

  const dLng = endLng - startLng;
  const y = Math.sin(dLng) * Math.cos(endLat);
  const x = Math.cos(startLat) * Math.sin(endLat) - Math.sin(startLat) * Math.cos(endLat) * Math.cos(dLng);

  const bearing = this.radiansToDegrees(Math.atan2(y, x));
  return (bearing + 360) % 360; // 归一化到 0-360 度
},

degreesToRadians(degrees) {
  return degrees * Math.PI / 180;
},

radiansToDegrees(radians) {
  return radians * 180 / Math.PI;
},

说明:方向计算基于地理坐标系中的球面三角学。calculateBearing 函数通过计算两点之间的方位角,动态调整标记的旋转角度,从而实现标记始终指向运动方向的效果。


性能优化

在实现轨迹回放功能时,性能优化和用户体验是两个不可忽视的方面。以下是优化建议:

  1. 合理设置动画速度和更新间隔:通过调整 speedinterval 参数,可以确保动画的流畅性。较低的速度和较短的间隔可以使动画更加平滑,但可能会增加计算负担。

  2. 动态调整标记的旋转角度:通过实时计算方向角,标记始终指向运动方向,提升了视觉效果。

  3. 减少不必要的 DOM 操作:避免频繁地添加或移除地图元素,而是通过更新现有元素的属性来实现动态效果。

三、总结

通过以上步骤,我们成功实现了一个基于 Leaflet 和原生 JavaScript 的轨迹回放功能。这种方法的优点在于高度的灵活性和定制化能力,能够满足复杂业务场景的需求。然而,它也存在一定的缺点,比如代码量较大,且需要手动处理动画的细节。

在后续的文章中,我将分享如何使用 Leaflet 的插件 leaflet-trackplayer 来实现类似的功能,这将大大简化代码并提高开发效率。敬请期待!

四、扩展阅读

  1. Leaflet 官方文档Leaflet - a JavaScript library for interactive maps

  2. gcoord 坐标转换工具https://github.com/allenhwkim/gcoord

  3. Leaflet 插件推荐Plugins - Leaflet - a JavaScript library for interactive maps

;