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.
single HTML file · CDN dependencies onlyinterface may contain Chinese labels
Source
<!--
示例:轨迹回放
要点:预计算累计距离、按时间推进插值、滑动窗口拖尾、进度条拖动、跟随视角
依赖: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.