Atlaslog
Atlaslog / Examples / Leaflet / Track Playback
Leaflet · Runnable Example

Track Playback

Runnable Leaflet example: Track Playback. Open the live demo, read the complete source, and copy the file into your project. No build step or API key.

Open standalone → Chinese notes All Leaflet examples
/demos/leaflet-track-playback.htmlLIVE
single HTML file · CDN dependencies onlyinterface may contain Chinese labels

Source

demos/leaflet-track-playback.html
<!--
  示例:轨迹回放
  要点:预计算累计距离、按时间推进插值、滑动窗口拖尾、进度条拖动、跟随视角
  依赖:Leaflet 1.9.4 · jsDelivr CDN + Esri 瓦片
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="robots" content="noindex,follow">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>轨迹回放</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css">
<style>
  html,body{height:100%;margin:0}
  body{display:flex;flex-direction:column;overflow:hidden;
       font:13px/1.6 -apple-system,"Segoe UI","PingFang SC",sans-serif}
  .bar{flex:0 0 auto;padding:8px 12px;background:#f6f7f9;border-bottom:1px solid #e3e6ea;
       display:flex;gap:8px;align-items:center;font-size:12px;color:#58606e;flex-wrap:wrap}
  .bar button{font:inherit;font-size:12px;padding:4px 10px;border-radius:4px;cursor:pointer;
       border:1px solid #ccd1d9;background:#fff;color:#16181d}
  .bar button:hover{background:#eef0f3}
  .bar button.is-on{background:#1f5fd0;border-color:#1f5fd0;color:#fff}
  .bar input[type=range]{width:150px}
  .bar .spacer{flex:1 1 auto}
  .bar #status{font-variant-numeric:tabular-nums;color:#16181d}
  #map{flex:1 1 auto;min-height:0}
  .car{width:12px;height:12px;border-radius:50%;background:#c2410c;border:2px solid #fff;
       box-shadow:0 0 0 2px rgba(194,65,12,.35)}
</style>
</head>
<body>
<div class="bar">
  <button id="play" type="button">播放</button>
  <button id="reset" type="button">重置</button>
  <input id="seek" type="range" min="0" max="1000" value="0">
  <button class="speed is-on" type="button" data-speed="600">1×</button>
  <button class="speed" type="button" data-speed="1200">2×</button>
  <button class="speed" type="button" data-speed="2400">4×</button>
  <label><input id="follow" type="checkbox"> 跟随</label>
  <span class="spacer"></span>
  <span id="status">0.00 km / 0%</span>
</div>
<div id="map"></div>
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
(function () {
  'use strict';

  var route = [
    [39.9060, 116.3200], [39.9065, 116.3260], [39.9072, 116.3320],
    [39.9086, 116.3380], [39.9102, 116.3440], [39.9112, 116.3500],
    [39.9118, 116.3560], [39.9110, 116.3620], [39.9096, 116.3680],
    [39.9082, 116.3740], [39.9074, 116.3800], [39.9070, 116.3860],
    [39.9073, 116.3920], [39.9084, 116.3980], [39.9098, 116.4040],
    [39.9110, 116.4100], [39.9122, 116.4160], [39.9130, 116.4220],
    [39.9126, 116.4280], [39.9114, 116.4340], [39.9100, 116.4400],
    [39.9092, 116.4460], [39.9090, 116.4520], [39.9098, 116.4580],
    [39.9112, 116.4640], [39.9126, 116.4700]
  ];

  var latlngs = route.map(function (p) { return L.latLng(p[0], p[1]); });

  /* 累计距离:cum[i] 表示从起点走到第 i 个点的米数 */
  var cum = [0];
  var total = 0;
  for (var i = 1; i < latlngs.length; i++) {
    total += latlngs[i - 1].distanceTo(latlngs[i]);
    cum.push(total);
  }

  function pointAt(dist) {
    if (dist <= 0) return latlngs[0];
    if (dist >= total) return latlngs[latlngs.length - 1];
    var lo = 0, hi = cum.length - 1;
    while (lo + 1 < hi) {
      var mid = (lo + hi) >> 1;
      if (cum[mid] <= dist) lo = mid; else hi = mid;
    }
    var seg = cum[hi] - cum[lo];
    var t = seg === 0 ? 0 : (dist - cum[lo]) / seg;
    var a = latlngs[lo], b = latlngs[hi];
    return L.latLng(
      a.lat + (b.lat - a.lat) * t,
      a.lng + (b.lng - a.lng) * t
    );
  }

  var map = L.map('map', { zoomControl: true });
  L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', {
    maxZoom: 19,
    attribution: 'Tiles © Esri'
  }).addTo(map);
  map.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] });

  /* 完整路线作为背景 */
  L.polyline(latlngs, {
    color: '#94a3b8',
    weight: 4,
    opacity: 0.7,
    dashArray: '2 8',
    interactive: false
  }).addTo(map);

  var trail = L.polyline([], {
    color: '#c2410c',
    weight: 4,
    opacity: 0.95,
    interactive: false
  }).addTo(map);

  var car = L.marker(latlngs[0], {
    icon: L.divIcon({
      className: '',
      html: '<div class="car"></div>',
      iconSize: [12, 12],
      iconAnchor: [6, 6]
    }),
    interactive: false
  }).addTo(map);

  var pos = 0;
  var speed = 600;
  var playing = false;
  var raf = null;
  var lastTs = 0;

  var playBtn = document.getElementById('play');
  var resetBtn = document.getElementById('reset');
  var seek = document.getElementById('seek');
  var follow = document.getElementById('follow');
  var status = document.getElementById('status');
  var speedBtns = document.querySelectorAll('.speed');

  function render() {
    var here = pointAt(pos);
    car.setLatLng(here);

    /* 拖尾只保留最近 40 个采样点,避免路径无限增长 */
    var windowStart = Math.max(0, pos - 1400);
    var pts = [pointAt(windowStart)];
    for (var i = 0; i < cum.length; i++) {
      if (cum[i] > windowStart && cum[i] <= pos) pts.push(latlngs[i]);
    }
    pts.push(here);
    trail.setLatLngs(pts);

    seek.value = Math.round(pos / total * 1000);
    status.textContent = (pos / 1000).toFixed(2) + ' km / ' +
      Math.round(pos / total * 100) + '%';

    if (follow.checked) map.panTo(here, { animate: false });
  }

  function tick(ts) {
    if (!playing) return;
    if (!lastTs) lastTs = ts;
    var dt = (ts - lastTs) / 1000;
    lastTs = ts;
    pos = Math.min(total, pos + speed * dt);
    render();

    if (pos >= total) {
      playing = false;
      lastTs = 0;
      playBtn.textContent = '播放';
      return;
    }
    raf = requestAnimationFrame(tick);
  }

  playBtn.addEventListener('click', function () {
    if (playing) {
      playing = false;
      if (raf) cancelAnimationFrame(raf);
      playBtn.textContent = '播放';
      return;
    }
    if (pos >= total) pos = 0;
    playing = true;
    lastTs = 0;
    playBtn.textContent = '暂停';
    raf = requestAnimationFrame(tick);
  });

  resetBtn.addEventListener('click', function () {
    playing = false;
    if (raf) cancelAnimationFrame(raf);
    pos = 0;
    lastTs = 0;
    playBtn.textContent = '播放';
    render();
    map.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] });
  });

  seek.addEventListener('input', function () {
    pos = total * Number(seek.value) / 1000;
    render();
  });

  for (var i = 0; i < speedBtns.length; i++) {
    speedBtns[i].addEventListener('click', function () {
      speed = Number(this.getAttribute('data-speed'));
      for (var j = 0; j < speedBtns.length; j++) {
        speedBtns[j].classList.toggle('is-on', speedBtns[j] === this);
      }
    });
  }

  render();
})();
</script>
</body>
</html>
Language: the code and this page are in English; the full tutorial and API notes for this example are in Chinese. The demo itself is the authoritative version — it runs exactly as shown.

More Leaflet examples

Leaflet · EXAMPLEVideo Overlay Leaflet · EXAMPLEWMS Leaflet · EXAMPLEZoom Slider