在任何外卖配送或拼车应用中,追踪地图都是用户停留时间最长的页面,顾客在等待时会一直盯着它。平滑的移动、准确的预计到达时间(ETA)和清晰的路线,决定了应用给人的感觉是专业可靠,还是让人放心不下。
本教程从零开始构建面向顾客的追踪地图:由后端通过 WebSocket 广播 GPS 位置,前端接收位置并平滑移动司机标记而不出现跳动,使用 MapAtlas Routing API 绘制路线,并实时显示 ETA。完整实现只需不到 70 行客户端 JavaScript,可以接入任何能推送 WebSocket 消息的后端。
这套架构适用于外卖配送、生鲜配送、拼车、上门服务,以及任何车辆驶向固定目的地、顾客实时观看的场景。
架构概览
在编写代码之前,先理清数据流:
- 司机端应用(手机或 GPS 硬件)每 3 至 5 秒将经纬度发送到您的后端。
- 后端(Node.js、Python、Go 均可)保存最后已知的位置,并通过 WebSocket 广播给所有订阅该订单的连接。
- 顾客浏览器 接收 WebSocket 消息,并用插值动画在地图上移动标记。
- Routing API 在订单创建时调用一次,获取规划路线。解码后的折线以线图层显示。
- ETA 通过比较剩余距离与平均速度重新计算,或者从司机当前位置再次调用 Routing API 获得。
后端实现不在本教程范围内,但任何发送以下格式消息的 WebSocket 服务器,都可以配合下面的前端代码使用:
{
"type": "position_update",
"orderId": "order-8821",
"lat": 52.3741,
"lng": 4.8952,
"heading": 92,
"speed": 28,
"timestamp": 1738234521000
}
第 1 步:初始化地图
以配送起点为中心创建地图。Routing API 的调用放在第 4 步,现在只需初始化画布。
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const map = new mapmetricsgl.Map({
container: 'tracking-map',
style: 'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/style.json&token=YOUR_API_KEY',
center: [4.9041, 52.3676],
zoom: 14
});
// Destination marker (restaurant or pickup point)
const destination = [4.9001, 52.3791];
new mapmetricsgl.Marker({ color: '#EF4444' })
.setLngLat(destination)
.setPopup(new mapmetricsgl.Popup().setHTML('<strong>Pickup point</strong>'))
.addTo(map);
第 2 步:创建可按行驶方向旋转的司机标记
单独创建司机标记,以便在每次收到 GPS 位置时更新它的位置。使用自定义 HTML 元素可以让标记图标按司机的行驶方向旋转,这个小细节会让追踪效果真实得多。
// Custom element so we can rotate it
const driverEl = document.createElement('div');
driverEl.innerHTML = '🚗';
driverEl.style.cssText = 'font-size:28px;transform-origin:center;transition:transform 0.3s';
const driverMarker = new mapmetricsgl.Marker({ element: driverEl, anchor: 'center' })
.setLngLat([4.9041, 52.3676])
.addTo(map);
function setDriverHeading(heading) {
driverEl.style.transform = `rotate(${heading}deg)`;
}
第 3 步:WebSocket 连接与平滑插值
这是追踪地图的核心。连接 WebSocket 只需一行代码,关键在于在两次 GPS 位置之间对标记位置进行插值,让它平滑滑动,而不是瞬间跳到新位置。
let prevPos = null; // { lat, lng }
let animFrame = null;
function interpolateMarker(fromLat, fromLng, toLat, toLng, durationMs) {
const startTime = performance.now();
function step(now) {
const elapsed = now - startTime;
const t = Math.min(elapsed / durationMs, 1); // 0 → 1
const lat = fromLat + (toLat - fromLat) * t;
const lng = fromLng + (toLng - fromLng) * t;
driverMarker.setLngLat([lng, lat]);
if (t < 1) {
animFrame = requestAnimationFrame(step);
}
}
if (animFrame) cancelAnimationFrame(animFrame);
animFrame = requestAnimationFrame(step);
}
const ws = new WebSocket('wss://your-backend.example.com/track/order-8821');
ws.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type !== 'position_update') return;
const { lat, lng, heading } = msg;
if (prevPos) {
// Smooth animation from previous to new position
interpolateMarker(prevPos.lat, prevPos.lng, lat, lng, 400);
} else {
// First ping, place marker immediately
driverMarker.setLngLat([lng, lat]);
map.flyTo({ center: [lng, lat], zoom: 15 });
}
setDriverHeading(heading);
updateETA(lat, lng);
prevPos = { lat, lng };
});
ws.addEventListener('close', () => {
console.log('Driver has arrived or connection closed.');
});
400 毫秒的插值窗口与 3 至 5 秒的常见 GPS 上报间隔配合得比较好:标记总是略微落后于实际位置,但不会出现明显的跳动。
第 4 步:用 Routing API 绘制规划路线
在订单分配时获取完整路线,保存折线坐标,并将其绘制为 GeoJSON 线图层。多站点场景请参阅 Route Optimization API 教程;对于从 A 到 B 的简单配送,请求非常直接。
async function fetchAndDrawRoute(originLat, originLng, destLat, destLng) {
const url = new URL('https://gateway.mapmetrics-atlas.net/directions/');
url.searchParams.set('token', 'YOUR_API_KEY');
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
locations: [
{ lat: originLat, lon: originLng },
{ lat: destLat, lon: destLng },
],
costing: 'auto',
format: 'osrm',
shape_format: 'geojson',
}),
});
const data = await res.json();
if (!data.routes?.length) return;
const route = data.routes[0];
map.on('load', () => {
map.addSource('route', {
type: 'geojson',
data: {
type: 'Feature',
geometry: route.geometry // GeoJSON LineString
}
});
map.addLayer({
id: 'route-line',
type: 'line',
source: 'route',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': '#3B82F6',
'line-width': 4,
'line-opacity': 0.8
}
});
});
return route.duration; // seconds
}
第 5 步:计算并显示 ETA
通过比较司机当前位置与目的地来计算 ETA。如需更高精度,每 30 秒从司机当前位置重新调用一次 Routing API,获取最新的行程时间估算。
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
let lastRouteFetch = 0;
async function updateETA(driverLat, driverLng) {
const [destLng, destLat] = destination;
const distKm = haversineKm(driverLat, driverLng, destLat, destLng);
// Fast client-side estimate (assume 30 km/h urban average)
const minutesEstimate = Math.ceil((distKm / 30) * 60);
document.getElementById('eta').textContent =
distKm < 0.1
? 'Arriving now'
: `ETA: ${minutesEstimate} min (${distKm.toFixed(1)} km)`;
// Re-fetch from Routing API every 30 seconds for accuracy
const now = Date.now();
if (now - lastRouteFetch > 30_000) {
lastRouteFetch = now;
const url = new URL('https://gateway.mapmetrics-atlas.net/directions/');
url.searchParams.set('token', 'YOUR_API_KEY');
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
locations: [
{ lat: driverLat, lon: driverLng },
{ lat: destLat, lon: destLng },
],
costing: 'auto',
format: 'osrm',
shape_format: 'geojson',
}),
});
const data = await res.json();
if (data.routes?.length) {
const mins = Math.ceil(data.routes[0].duration / 60);
document.getElementById('eta').textContent = `ETA: ${mins} min`;
}
}
}
司机追踪的 GDPR 注意事项
根据 GDPR 第 4(1) 条,司机的 GPS 坐标属于个人数据。这里适用的 GDPR 原则是第 5 条规定的数据最小化和存储限制:
数据最小化:只追踪调度所需的字段,即位置、行驶方向和速度。不要在业务必需范围之外记录原始 GPS 历史。
保留期限:订单完成后,应删除细粒度的行程追踪数据,或对其进行不可逆的匿名化处理。不关联到具体司机的聚合路线数据,可以为网络优化保留更长时间。
法律依据:实时调度追踪通常可以依据第 6(1)(f) 条规定的正当利益(需有书面记录的利益平衡测试支持),或第 6(1)(b) 条规定的合同履行。如果将追踪数据用于任何次要用途(分析、基准比较),需要另行记录法律依据。
对司机透明:在司机入职流程中明确说明追踪情况。必须告知司机收集哪些数据、保留多长时间以及谁可以访问。
数据驻留:MapAtlas 在欧盟境内处理所有 API 请求,因此不存在使用美国地图服务商时会出现的向第三国传输数据的问题。完整的合规说明请参阅 欧盟开发者 GDPR 合规地图 API 指南。
针对 拼车与出行行业 场景,MapAtlas 默认提供 DPA 文件和欧盟服务器保障。物流与配送 行业页面介绍了车队和多司机场景。
上线前的加固工作
在向顾客发布追踪功能之前,请检查以下几项:
- WebSocket 重连:添加
ws.addEventListener('close', reconnect),并使用指数退避。移动网络经常断开连接。 - 处理过期位置:如果 15 秒内没有收到更新,显示「正在定位司机」状态,而不是一直显示最后一个位置。
- 到达检测:当
distKm < 0.1时,触发「已到达」状态,关闭 WebSocket,并显示确认页面。 - 镜头跟随司机:每次位置更新时调用
map.panTo([lng, lat]),让司机保持在地图中心。为用户提供一个「锁定」开关,方便他们关闭跟随模式自由浏览地图。
后续步骤
- 注册免费的 MapAtlas API 密钥,开始构建
- 阅读 Route Optimization API 教程,为您的配送应用添加多站点调度
- 查看 房地产房源地图教程,了解另一个动态、数据驱动的地图图层示例
常见问题
如何让司机位置在地图上平滑移动?
GPS 位置每隔几秒才上报一次,如果直接更新标记位置,就会出现明显的跳动。平滑插值会在很短的时间内(300 至 500 毫秒)让标记从上一个位置动画移动到新位置,通过 requestAnimationFrame 以小步长移动标记。这样即使 GPS 更新不频繁,看起来也是连续移动的。
司机位置数据受 GDPR 约束吗?
是的。根据 GDPR 第 4 条,司机的实时 GPS 坐标属于个人数据。平台只应在必要期限内保留追踪数据,行程结束后将其删除或匿名化是最稳妥的默认做法。处理这些数据需要法律依据,并且必须在司机隐私声明中披露。
可以用 MapAtlas Routing API 在追踪地图上显示规划路线吗?
可以。在行程创建时从 Routing API 获取路线,解码折线,然后将其作为 GeoJSON 线图层添加到地图上。司机移动过程中,您还可以从当前位置重新获取路线,动态重新计算 ETA。
常见问题
如何让司机位置在地图上平滑移动?
GPS 位置每隔几秒才上报一次,如果直接更新标记位置,就会出现明显的跳动。平滑插值会在很短的时间内(300 至 500 毫秒)让标记从上一个位置动画移动到新位置,通过 requestAnimationFrame 以小步长移动标记。这样即使 GPS 更新不频繁,看起来也是连续移动的。
司机位置数据受 GDPR 约束吗?
是的。根据 GDPR 第 4 条,司机的实时 GPS 坐标属于个人数据。平台只应在必要期限内保留追踪数据,行程结束后将其删除或匿名化是最稳妥的默认做法。处理这些数据需要法律依据(调度运营通常基于正当利益或合同履行),并且必须在司机隐私声明中披露。
可以用 MapAtlas Routing API 在追踪地图上显示规划路线吗?
可以。在行程创建时从 Routing API 获取路线,解码折线,然后将其作为 GeoJSON 线图层添加到地图上。司机移动过程中,你还可以从当前位置重新获取路线,动态重新计算 ETA。

