leaflet-trackline基于leaflet历史轨迹、实时轨迹播放

824 阅读5分钟

leaflet-trackline


一个基于leaflet开发轨迹实时、历史插件,帮助你快速构建出精美的轨迹回放功能,之前使用的是leaflet-trackplayer,但由于他没有实时轨迹和数据点超过2万个时会出现卡顿,于是便开发这个插件,由于水平和时间有限,代码命名和写法不是很规范,后续会持续迭代更新,还请留下宝贵的意见。


  • 历史轨迹

01.png

  • 实时轨迹

02.png

安装方法

  • 使用npm安装:npm i leaflet-trackline
  • 或者直接下载仓库

仓库地址

leaflet-trackline: https://gitee.com/yufuhuang/leaflet-trackline

leaflet-trackline-demo: https://gitee.com/yufuhuang/leaflet-trackline-demo

使用方式

const TrackLine = new L.TrackLine(trackList,option).addTo(map);

代码示例

<template>
  <div id="tiandi-map" class="map" style="width: 100%; height: 100%"></div>
</template><script setup>
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import "@/utils/leaflet.ChineseTmsProviders.js";
import '@/utils/leaflet-trackline';
​
let map,TrackLine;
const emits = defineEmits();
​
function initMap() {
  const basemapLayer0 = L.tileLayer.chinaProvider('TianDiTu.Normal.Map');
  const basemapLayer1 = L.tileLayer.chinaProvider('TianDiTu.Normal.Annotion');
  const basemapLayer2 = L.tileLayer.chinaProvider('TianDiTu.Satellite.Map');
  const basemapLayer3 = L.tileLayer.chinaProvider('TianDiTu.Satellite.Annotion');
​
  const basemap1 = L.layerGroup([basemapLayer0, basemapLayer1]);
  const basemap2 = L.layerGroup([basemapLayer2,basemapLayer3]);
  map = L.map('tiandi-map', {
    preferCanvas: true,
    zoomControl: false,
    zoomAnimation: true,
    layers: [basemap2],
    doubleClickZoom: true,
    attributionControl: false,
    minZoom: 5,
    maxZoom: 18
  }).setView([31.815908, 117.185687], 8);         
}
​
function initTrackLine(trackList,option){
  TrackLine = new L.TrackLine(trackList,option).addTo(map);
}
​
function updateTrack(trackList,location){
  TrackLine.updateTrack(trackList,location)
}
​
function start(index){
  TrackLine.start(index); 
  TrackLine.eventEmitter.addEventListener('progress', eventListener); 
}
​
function pause(){
  TrackLine.pause();
}
​
function setProgress(index){
  TrackLine.setProgress(index); 
}
​
function setSpeed(index,speed){  
  TrackLine.setSpeed(index,speed)
}
​
function eventListener(event){
  emits('changeSliding',event.detail);
}
​
defineExpose({
    initTrackLine,
    start,
    pause,
    setProgress,
    setSpeed,
    updateTrack
})
​
onMounted(() => {
  initMap();  
});
​
onBeforeUnmount(() => {
  TrackLine.eventEmitter.removeEventListener('progress', eventListener);
  TrackLine.remove();
});
</script>

示例demo

历史轨迹和实时轨迹切换修改52行代码

历史轨迹:const isDisabled = ref(false);

实时轨迹:const isDisabled = ref(true);

<template>
  <div class="home">
    <div id="tiandi-map" class="map" style="width: 100%; height: 100%"></div>
​
    <div class="track-control" :class="isDesc?'track-control-20':'track-control-410'">
      <img v-show="isPlay" src="@/assets/images/work/bofang.png" alt="播放" @click="handlePlay">
      <img v-show="!isPlay" src="@/assets/images/work/zanting.png" alt="暂停" @click="handlePlay">
      <img class="ml10" src="@/assets/images/work/tingzhi.png" alt="停止" @click="handlePause">
      <div class="time-text ml10 mr20">{{ formattedTimes[sliding] || '00:00:00' }}</div>  
      <el-slider :disabled="isDisabled" @change="handleSliding" style="width:210px;" v-model="sliding" :min="0" :max="formattedTimes.length>0?formattedTimes.length-1:0" :show-tooltip="false"></el-slider>    
      <div class="time-text ml10">{{ formattedTimes[formattedTimes.length-1] || '00:00:00' }}</div>
      <el-select :disabled="isDisabled" class="ml10" size="small" style="width:160px;" v-model="speed" @change="handleSpeed" placeholder="请选择">
        <el-option
          v-for="item in speedOptions"
          :key="item.value"
          :label="item.label"
          :value="item.value">
        </el-option>
      </el-select>
      <el-checkbox v-if="isDisabled" class="ml20" v-model="location" @change="handleLocation">实时定位</el-checkbox>   
      <div class="desc">
        <img v-show="isDesc" src="@/assets/images/work/desc.png" @click="handleDesc">
        <img v-show="!isDesc" src="@/assets/images/work/desc-active.png" @click="handleDesc">
      </div>     
    </div>
​
    <div class="track-detail-dom" v-if="!isDesc">
      <el-table v-loading="tableLoading" :data="tablePointList" :height="330">
        <el-table-column label="序号" align="center" type="index" width="100" />
        <el-table-column label="定位时间" align="center" prop="createTime"/>
        <el-table-column label="定位经度" align="center" prop="lng"/>
        <el-table-column label="定位纬度" align="center" prop="lat"/>
      </el-table>
    </div>
  </div>
</template>
​
<script setup name="Index">
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import 'leaflet.chinatmsproviders';
import 'leaflet-trackline';
import { trackArr } from './track.js';
const { proxy } = getCurrentInstance();
import startIconUrl from '@/assets/images/work/start.png';
import endIconUrl from '@/assets/images/work/end.png';
import markerIconUrl from '@/assets/images/work/jq.png';
​
let map,TrackLine;
const emits = defineEmits();
​
const isDisabled = ref(false);
const startTime = ref('00:00:00');
const endTime = ref('00:00:00');
const sliding = ref(0);
const speed = ref(500);
const point = ref(false);
const location = ref(true);
const isPlay = ref(true);
const isDesc = ref(true);
const formattedTimes = ref([]);
const tableLoading = ref(false);
const tablePointList = ref([]);
let timing = null;
const distance = 10;
const earthRadius = 6371000;
​
const speedOptions = ref([
  {
    label:'播放速度(16x)',
    value: 31
  },
  {
    label:'播放速度(8x)',
    value: 62
  },
  {
    label:'播放速度(4x)',
    value: 125
  },
  {
    label:'播放速度(2x)',
    value: 250
  },
  {
    label:'播放速度(正常)',
    value: 500
  }
]);
​
function initMap() {
  const basemapLayer0 = L.tileLayer.chinaProvider('TianDiTu.Normal.Map');
  const basemapLayer1 = L.tileLayer.chinaProvider('TianDiTu.Normal.Annotion');
  const basemapLayer2 = L.tileLayer.chinaProvider('TianDiTu.Satellite.Map');
  const basemapLayer3 = L.tileLayer.chinaProvider('TianDiTu.Satellite.Annotion');
​
  const basemap1 = L.layerGroup([basemapLayer0, basemapLayer1]);
  const basemap2 = L.layerGroup([basemapLayer2,basemapLayer3]);
  map = L.map('tiandi-map', {
    preferCanvas: true,
    zoomControl: false,
    zoomAnimation: true,
    layers: [basemap2],
    doubleClickZoom: true,
    attributionControl: false,
    minZoom: 5,
    maxZoom: 18
  }).setView([31.815908, 117.185687], 18);         
}
​
function getList(){
  tableLoading.value = true;
  formattedTimes.value = splitTimesIntoStopwatchFormat(trackArr); 
  const option = {
    startIconOptions: {iconUrl:startIconUrl,iconSize:[25, 32]},
    endIconOptions: {iconUrl:endIconUrl,iconSize:[25, 32]},
    markerIconOptions: {iconUrl:markerIconUrl,iconSize:[25, 32]},
    weight: 4,
    location: isDisabled.value,
    speed: 500,
    notPassedLineOptions: { weight:4, color: "#FB7B01", opacity: 1 },
    passedLineOptions: { weight:4, color: "#0FE217", opacity: 1 }
  }
  tablePointList.value = trackArr;
  TrackLine = new L.TrackLine(trackArr,option).addTo(map);
  if (isDisabled.value) {      
    getRealPoint();       
  }   
  tableLoading.value = false;  
}
​
function getRealPoint(){
  timing = setInterval(() => {
    const { lng,lat } = trackArr[trackArr.length-1];
    const newPosition = calculateNewPosition(lat, lng, distance, randomInt(0, 90));
    tablePointList.value.push(newPosition);
    formattedTimes.value = splitTimesIntoStopwatchFormat(tablePointList.value);
    sliding.value = formattedTimes.value.length - 1;
    TrackLine.updateTrack(tablePointList.value,location.value)
  }, 1000);
}
​
function handleLocation(flag){
  location.value = flag;
  if (isDisabled.value && location.value) {
    if (timing) {
      clearInterval(timing);
      timing = null;
    }    
    getRealPoint();
  }
}
​
function parseTime(time, pattern) {
  if (arguments.length === 0 || !time) {
    return null
  }
  const format = pattern || '{y}-{m}-{d} {h}:{i}:{s}'
  let date
  if (typeof time === 'object') {
    date = time
  } else {
    if ((typeof time === 'string') && (/^[0-9]+$/.test(time))) {
      time = parseInt(time)
    } else if (typeof time === 'string') {
      time = time.replace(new RegExp(/-/gm), '/').replace('T', ' ').replace(new RegExp(/.[\d]{3}/gm), '');
    }
    if ((typeof time === 'number') && (time.toString().length === 10)) {
      time = time * 1000
    }
    date = new Date(time)
  }
  const formatObj = {
    y: date.getFullYear(),
    m: date.getMonth() + 1,
    d: date.getDate(),
    h: date.getHours(),
    i: date.getMinutes(),
    s: date.getSeconds(),
    a: date.getDay()
  }
  const time_str = format.replace(/{(y|m|d|h|i|s|a)+}/g, (result, key) => {
    let value = formatObj[key]
    if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value] }
    if (result.length > 0 && value < 10) {
      value = '0' + value
    }
    return value || 0
  })
  return time_str
}
​
const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
​
function calculateNewPosition(lat, lon, distance, bearing) {
  const lat1 = (lat * Math.PI) / 180;
  const lon1 = (lon * Math.PI) / 180;
  const brng = (bearing * Math.PI) / 180;
  const d = distance / earthRadius;
  const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) +
                         Math.cos(lat1) * Math.sin(d) * Math.cos(brng));
  const lon2 = lon1 + Math.atan2(Math.sin(brng) * Math.sin(d) * Math.cos(lat1),
                                 Math.cos(d) - Math.sin(lat1) * Math.sin(lat2));
​
  return {
    createTime:parseTime(new Date()),
    lat: (lat2 * 180) / Math.PI,
    lng: (lon2 * 180) / Math.PI
  };
}
​
function convertToStopwatchFormat(ms) {
  const hours = Math.floor(ms / (1000 * 60 * 60));
  const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
  const seconds = Math.floor((ms % (1000 * 60)) / 1000);
  return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
​
function parseDate(dateStr) {
  return new Date(dateStr.replace(' ', 'T') + 'Z');
}
​
function splitTimesIntoStopwatchFormat(data) {
  const times = data.map(item => parseDate(item.createTime));      
  const startTime = new Date(Math.min(...times));
  const endTime = new Date(Math.max(...times));      
  const totalDurationMs = endTime - startTime;
  const segmentDurationMs = totalDurationMs / (times.length - 1);
  const formattedTimes = [];
  for (let i = 0; i < times.length; i++) {
    const segmentTimeMs = startTime.getTime() + (segmentDurationMs * i);
    const formattedTime = convertToStopwatchFormat(segmentTimeMs - startTime.getTime());
    formattedTimes.push(formattedTime);
  }
  return formattedTimes;
}
​
function handlePlay(){      
  if (isDisabled.value) return false;
  if (isPlay.value) {
    TrackLine.start(sliding.value); 
    TrackLine.eventEmitter.addEventListener('progress', eventListener); 
  }else{
    TrackLine.pause();        
  }
  isPlay.value = !isPlay.value;
}
​
function handlePause(){
  if (isDisabled.value) return false;
  TrackLine.pause();      
  TrackLine.setProgress(0); 
  sliding.value = 0;
  isPlay.value = true;
}
​
function handleSpeed(){
 TrackLine.setSpeed(sliding.value,speed.value)        
}
​
function changeSliding(index){  
  sliding.value = index;
  if (index == formattedTimes.value.length-1) {
    isPlay.value = true;
  }
}
​
function handleSliding(){
  TrackLine.setProgress(sliding.value);     
}
​
function handleDesc(){
  isDesc.value = !isDesc.value;
}
​
function eventListener(event){
  changeSliding(event.detail);
}
​
onMounted(() => {
  initMap();
  getList();  
});
​
onBeforeUnmount(() => {
  if (timing) {
    clearInterval(timing);
  }
  TrackLine.eventEmitter.removeEventListener('progress', eventListener);
  TrackLine.remove();
});
</script>
​
<style scoped lang="scss">
.home{
  width: 100vw;
  height: 100vh;
  position: absolute;  
  left: 0;
  top: 0;
}
​
.track-control{
  position: absolute;
  left: 50%;
  z-index: 999;
  width: 650px;
  height: 50px;        
  transform: translate(-50%);
  background: #fff;
  border-radius: 24px;
  padding: 0 20px;
  font-size: 14px;
  color: #424242;
  display: flex;
  align-items: center;
  justify-content: space-between;
  img{
    cursor: pointer;
  }
  .time-text{
    color: #1eac63;
  }
  .desc{
    position: absolute;
    right: -10px;
    transform: translateX(100%);
  }
  .ml10{
    margin-left: 10px;    
  }
  .mr10{
    margin-right: 10px;    
  }
  .ml20{
    margin-left: 20px;
  }
  .mr20{
    margin-right: 20px;
  }
}
​
.track-control-20{
  bottom: 20px;
}
​
.track-control-410{
  bottom: 410px;
}
​
.track-detail-dom{
  position: absolute;
  left: 50%;
  bottom: 20px;
  transform: translate(-50%);
  z-index: 999;
  width: calc(100% - 100px);
  height: 360px;
  background-color: #fff;
  border-radius: 10px;
  padding: 10px 20px;
}
</style>

文档说明

参数说明

选项类型默认值描述
option.startIconOptionsL.icon.options-轨迹开始图标配置
option.endIconOptionsL.icon.options-轨迹结束图标配置
option.markerIconOptionsL.icon.options-小车图标配置
option.speedNumber500轨迹线宽度
option.locationBooleanfalsetrue:实时轨迹,false:历史轨迹
option.notPassedLineOptionsL.polyline.options-未经过轨迹线配置
option.passedLineOptionsL.polyline.options-经过轨迹线配置

方法

方法返回值描述
addTo(map)-加载轨迹
updateTrack(trackList,location)-更新实时轨迹
start(index)-历史轨迹播放开始
pause()-历史轨迹播放停止
setSpeed(index,speed)-设置历史轨迹播放速度
setProgress(index)-设置历史轨迹进度
remove-清除所有

事件

事件描述
progress进度改变事件

更新日志

V2.2.0(2025-02-12)
  • 新增示例demo
  • 优化代码
  • 修复已知问题

V2.1.0(2025-01-16)
  • 新增配置项
  • 优化代码
  • 新增清除方法
  • 修复已知问题

V2.0.0(2025-01-14)
  • 使用类方式重构

V1.0.3(2024-11-28)
  • 修改依赖包未引入问题

V1.0.2(2024-11-28)
  • 修改小车方向方法,解决小车方向不正确问题
  • 修复已知问题

V1.0.1(2024-11-27)
  • 优化文档和一些细节处理

V1.0.0(2024-11-27)
  • leaflet-trackline正式发布

🎉致谢与引用

我对以下开源插件深表感谢,它们为本插件的功能提供了关键支持。

  • leafletjs —— Leaflet 是一个开源并且对移动端友好的交互式地图 JavaScript 库