戰(zhàn):坐標(biāo)糾偏、抽稀與進(jìn)度條控制)
簡(jiǎn)介這份資源圍繞高德地圖歷史軌跡回放功能展開(kāi)面向移動(dòng)應(yīng)用開(kāi)發(fā)與GIS方向的初中級(jí)開(kāi)發(fā)者幫助解決軌跡數(shù)據(jù)在地圖上動(dòng)態(tài)還原、交互控制與性能優(yōu)化等實(shí)際問(wèn)題。壓縮包共67個(gè)文件約3.59MB包含21個(gè)png界面素材、13個(gè)class與3個(gè)java源碼、9個(gè)jar依賴庫(kù)、9個(gè)xml配置、3個(gè)so本地庫(kù)及apk安裝包等覆蓋從工程配置到可運(yùn)行示例的完整結(jié)構(gòu)。資源以經(jīng)緯度集合為數(shù)據(jù)基礎(chǔ)演示軌跡繪制、動(dòng)畫(huà)回放、開(kāi)始暫停繼續(xù)等交互控制并涉及分段加載、平滑插值等性能優(yōu)化思路同時(shí)兼顧兼容性測(cè)試與界面布局設(shè)計(jì)。已有361人學(xué)習(xí)下載適合希望快速理解高德地圖API軌跡回放實(shí)現(xiàn)路徑、對(duì)照源碼梳理工程組織方式的讀者參考借鑒。1. 高德歷史軌跡回放從一堆坐標(biāo)點(diǎn)到能拖進(jìn)度條的復(fù)盤工具手里攢了一堆設(shè)備上報(bào)的經(jīng)緯度想在地圖上把某輛車、某個(gè)人、某臺(tái)設(shè)備的當(dāng)天路線原樣放一遍還要能拖進(jìn)度條、能看速度、能停在某個(gè)時(shí)間點(diǎn)查位置——這就是高德歷史軌跡回放要解決的事。它本質(zhì)上是把「帶時(shí)間戳的坐標(biāo)序列」翻譯成「地圖上一條會(huì)動(dòng)的線」核心依賴高德地圖 JS API 的軌跡展示能力配合你自己的后端把歷史點(diǎn)位查出來(lái)喂進(jìn)去。適合做車隊(duì)管理、外勤打卡復(fù)盤、設(shè)備巡檢回看、物流時(shí)效核查的開(kāi)發(fā)者。新手照著能跑通熟手更該關(guān)注的是點(diǎn)位抽稀、時(shí)間對(duì)齊和大量點(diǎn)下的卡頓邊界。下面按「數(shù)據(jù)怎么備、地圖怎么畫(huà)、坑在哪」一路拆下去。2. 軌跡數(shù)據(jù)準(zhǔn)備坐標(biāo)、時(shí)間戳與抽稀策略回放能不能順七成取決于喂進(jìn)去的數(shù)據(jù)長(zhǎng)什么樣。高德地圖用的是 GCJ-02 坐標(biāo)系而很多設(shè)備尤其是 GPS 模塊、部分海外模組吐出來(lái)的是 WGS-84兩者直接混用會(huì)出現(xiàn)幾十到上百米的偏移回放時(shí)車「飄」到馬路對(duì)面就是這么來(lái)的。所以第一步不是寫(xiě)地圖代碼而是把數(shù)據(jù)規(guī)整成統(tǒng)一坐標(biāo)系、統(tǒng)一時(shí)間格式、按時(shí)間排好序的數(shù)組。2.1 坐標(biāo)糾偏與時(shí)間戳歸一常見(jiàn)做法是后端在入庫(kù)前就做一次 WGS-84 轉(zhuǎn) GCJ-02別等到前端渲染再轉(zhuǎn)否則每次回放都要算一遍。轉(zhuǎn)換算法網(wǎng)上有成熟實(shí)現(xiàn)核心是一組基于橢球參數(shù)的偏移公式。時(shí)間戳統(tǒng)一成毫秒級(jí) Unix 時(shí)間別混用「秒」和「毫秒」這是后面進(jìn)度條對(duì)不上的頭號(hào)原因。// WGS-84 轉(zhuǎn) GCJ-02入庫(kù)前調(diào)用一次即可 const PI 3.1415926535897932384626; const A 6378245.0; // 長(zhǎng)半軸 const EE 0.00669342162296594323; // 偏心率平方 function outOfChina(lng, lat) { // 粗略判斷是否在國(guó)內(nèi)范圍國(guó)外坐標(biāo)不做偏移 return !(lng 73.66 lng 135.05 lat 3.86 lat 53.55); } function transformLat(lng, lat) { let ret -100.0 2.0 * lng 3.0 * lat 0.2 * lat * lat 0.1 * lng * lat 0.2 * Math.sqrt(Math.abs(lng)); ret (20.0 * Math.sin(6.0 * lng * PI) 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0; ret (20.0 * Math.sin(lat * PI) 40.0 * Math.sin(lat / 3.0 * PI)) * 2.0 / 3.0; ret (160.0 * Math.sin(lat / 12.0 * PI) 320 * Math.sin(lat * PI / 30.0)) * 2.0 / 3.0; return ret; } function transformLng(lng, lat) { let ret 300.0 lng 2.0 * lat 0.1 * lng * lng 0.1 * lng * lat 0.1 * Math.sqrt(Math.abs(lng)); ret (20.0 * Math.sin(6.0 * lng * PI) 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0; ret (20.0 * Math.sin(lng * PI) 40.0 * Math.sin(lng / 3.0 * PI)) * 2.0 / 3.0; ret (150.0 * Math.sin(lng / 12.0 * PI) 300.0 * Math.sin(lng / 30.0 * PI)) * 2.0 / 3.0; return ret; } function wgs84ToGcj02(lng, lat) { if (outOfChina(lng, lat)) return [lng, lat]; let dLat transformLat(lng - 105.0, lat - 35.0); let dLng transformLng(lng - 105.0, lat - 35.0); const radLat lat / 180.0 * PI; let magic Math.sin(radLat); magic 1 - EE * magic * magic; const sqrtMagic Math.sqrt(magic); dLat (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); dLng (dLng * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI); return [lng dLng, lat dLat]; }這段代碼里outOfChina是個(gè)粗略邊界判斷國(guó)內(nèi)坐標(biāo)才做偏移避免把境外點(diǎn)位算歪。transformLat和transformLng是偏移量的多項(xiàng)式擬合EE是橢球偏心率平方這幾個(gè)常數(shù)別隨手改。轉(zhuǎn)換后得到的是[lng, lat]順序注意高德 API 里L(fēng)ngLat也是經(jīng)度在前別寫(xiě)反。2.2 點(diǎn)位抽稀別把一萬(wàn)個(gè)點(diǎn)直接懟給地圖設(shè)備如果 5 秒上報(bào)一次一天就是一萬(wàn)七千多個(gè)點(diǎn)。全量畫(huà)線瀏覽器直接卡成幻燈片。抽稀不是簡(jiǎn)單隔幾個(gè)點(diǎn)取一個(gè)那樣會(huì)把轉(zhuǎn)彎抹掉。常見(jiàn)做法是 Douglas-Peucker 算法按垂直距離閾值抽稀或者按「距離 時(shí)間」雙閾值相鄰兩點(diǎn)距離小于 10 米且時(shí)間差小于 30 秒就合并。抽稀只影響「畫(huà)線」原始點(diǎn)仍要保留因?yàn)辄c(diǎn)擊查詢、速度計(jì)算還得用全量數(shù)據(jù)。// 按距離閾值抽稀保留首尾點(diǎn) function thinByDistance(points, minDistMeter 10) { if (points.length 2) return points; const result [points[0]]; for (let i 1; i points.length - 1; i) { const prev result[result.length - 1]; const d haversine(prev.lng, prev.lat, points[i].lng, points[i].lat); if (d minDistMeter) result.push(points[i]); } result.push(points[points.length - 1]); // 尾點(diǎn)必留 return result; } // 兩點(diǎn)球面距離單位米 function haversine(lng1, lat1, lng2, lat2) { const R 6371000; const rad Math.PI / 180; const dLat (lat2 - lat1) * rad; const dLng (lng2 - lng1) * rad; const a Math.sin(dLat / 2) ** 2 Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); }minDistMeter是抽稀閾值城市道路場(chǎng)景 10 米夠用高速場(chǎng)景可以放到 30 米。haversine用球面距離而不是平面距離跨緯度時(shí)更準(zhǔn)。抽稀后點(diǎn)數(shù)通常能壓到原來(lái)的三分之一到五分之一畫(huà)線流暢度提升明顯。注意尾點(diǎn)必須保留否則軌跡會(huì)「斷頭」。3. 地圖渲染與回放控制把靜態(tài)線變成會(huì)動(dòng)的軌跡數(shù)據(jù)備好了接下來(lái)是把它畫(huà)到高德地圖上并讓它動(dòng)起來(lái)。高德 JS API 里畫(huà)軌跡線用AMap.Polyline回放時(shí)不斷更新線的path或者用一個(gè)移動(dòng)的AMap.Marker沿路徑走。真正好用的回放不是「線一下子全出來(lái)」而是「線隨進(jìn)度生長(zhǎng) 車標(biāo)跟著走 進(jìn)度條可拖」。3.1 初始化地圖與軌跡線先引入高德 JS API創(chuàng)建地圖實(shí)例再把抽稀后的點(diǎn)轉(zhuǎn)成AMap.LngLat數(shù)組。線的樣式、顏色、粗細(xì)都在這里定。注意map容器要有明確高度否則地圖不顯示這是新手最常見(jiàn)的翻車點(diǎn)。!-- 頁(yè)面里放一個(gè)容器必須有高度 -- div idmapContainer stylewidth:100%;height:600px;/div script srchttps://webapi.amap.com/maps?v2.0key你的Key/script script const map new AMap.Map(mapContainer, { zoom: 14, center: [116.397, 39.908] // 初始中心回放開(kāi)始后會(huì)被覆蓋 }); // 假設(shè) points 是抽稀后的 [{lng, lat, time}, ...] const path points.map(p new AMap.LngLat(p.lng, p.lat)); const polyline new AMap.Polyline({ path: path, strokeColor: #1e90ff, strokeWeight: 6, strokeOpacity: 0.85, lineJoin: round, showDir: true // 顯示方向箭頭 }); map.add(polyline); map.setFitView([polyline]); // 自動(dòng)縮放到軌跡范圍 /scriptshowDir: true會(huì)在線上加方向箭頭復(fù)盤時(shí)一眼看出行駛方向。setFitView讓地圖自動(dòng)縮放到整條軌跡省得手動(dòng)調(diào)。strokeWeight別設(shè)太大超過(guò) 8 在小屏幕上會(huì)糊成一片。3.2 進(jìn)度條驅(qū)動(dòng)回放時(shí)間對(duì)齊是核心回放的本質(zhì)是「用一個(gè) 0 到 1 的進(jìn)度值去索引軌跡點(diǎn)」。進(jìn)度條拖動(dòng)時(shí)根據(jù)進(jìn)度算出當(dāng)前應(yīng)該顯示到第幾個(gè)點(diǎn)然后更新線的path和車標(biāo)位置。這里最容易踩的坑是「按點(diǎn)索引」而不是「按時(shí)間索引」——如果點(diǎn)位疏密不均按點(diǎn)索引會(huì)導(dǎo)致進(jìn)度條勻速但車忽快忽慢。正確做法是按時(shí)間戳插值。let playing false; let progress 0; // 0 ~ 1 let rafId null; const totalDuration 30000; // 整段回放耗時(shí) 30 秒 const startTime points[0].time; const endTime points[points.length - 1].time; const timeSpan endTime - startTime; const marker new AMap.Marker({ position: path[0], icon: new AMap.Icon({ size: new AMap.Size(24, 24), image: car.png }), offset: new AMap.Pixel(-12, -12) }); map.add(marker); // 根據(jù)進(jìn)度找到當(dāng)前應(yīng)顯示的點(diǎn)索引按時(shí)間插值 function getIndexByProgress(p) { const targetTime startTime timeSpan * p; for (let i 0; i points.length - 1; i) { if (points[i].time targetTime points[i 1].time targetTime) { return i; } } return points.length - 1; } function renderAt(p) { const idx getIndexByProgress(p); const subPath path.slice(0, idx 1); polyline.setPath(subPath); marker.setPosition(path[idx]); // 同步進(jìn)度條 UI document.getElementById(slider).value p * 100; } function tick(ts) { if (!playing) return; const elapsed ts - startTs; progress Math.min(elapsed / totalDuration, 1); renderAt(progress); if (progress 1) { rafId requestAnimationFrame(tick); } else { playing false; } } let startTs 0; function play() { if (playing) return; playing true; startTs performance.now() - progress * totalDuration; rafId requestAnimationFrame(tick); } function pause() { playing false; cancelAnimationFrame(rafId); } // 進(jìn)度條拖動(dòng) document.getElementById(slider).addEventListener(input, e { pause(); progress e.target.value / 100; renderAt(progress); }); /scriptgetIndexByProgress是整段邏輯的心臟它把 0~1 的進(jìn)度映射成時(shí)間再找到落在哪個(gè)時(shí)間區(qū)間這樣無(wú)論點(diǎn)位疏密車標(biāo)移動(dòng)速度都和真實(shí)時(shí)間成比例。totalDuration控制回放快慢30 秒放完一天也行放完一小時(shí)也行按業(yè)務(wù)調(diào)。requestAnimationFrame比setInterval更跟手頁(yè)面切到后臺(tái)會(huì)自動(dòng)降頻省性能。拖動(dòng)進(jìn)度條時(shí)先pause避免動(dòng)畫(huà)和手動(dòng)拖拽打架。3.3 速度與停留點(diǎn)從軌跡里讀出業(yè)務(wù)信息光看線還不夠復(fù)盤時(shí)經(jīng)常要問(wèn)「這段為什么停了 20 分鐘」「這段速度是不是超了」。這些都能從原始點(diǎn)位算出來(lái)相鄰兩點(diǎn)距離除以時(shí)間差就是速度連續(xù)多個(gè)點(diǎn)距離接近零就是停留。把停留點(diǎn)用不同顏色的圓點(diǎn)標(biāo)出來(lái)超速段用紅色線段復(fù)盤效率立刻不一樣。// 計(jì)算每段速度單位 km/h function calcSpeeds(points) { const speeds []; for (let i 1; i points.length; i) { const d haversine(points[i-1].lng, points[i-1].lat, points[i].lng, points[i].lat); const dt (points[i].time - points[i-1].time) / 1000; // 秒 speeds.push(dt 0 ? (d / dt) * 3.6 : 0); } return speeds; } // 找出停留段連續(xù)速度低于 1km/h 且持續(xù)超過(guò) 5 分鐘 function findStops(points, speeds, minMinutes 5) { const stops []; let start -1; for (let i 0; i speeds.length; i) { if (speeds[i] 1) { if (start -1) start i; } else { if (start ! -1) { const dur (points[i].time - points[start].time) / 60000; if (dur minMinutes) stops.push({ start, end: i, duration: dur }); start -1; } } } return stops; }calcSpeeds返回的是每?jī)蓚€(gè)相鄰點(diǎn)之間的平均速度不是瞬時(shí)速度點(diǎn)位越密越接近真實(shí)。findStops的minMinutes是停留判定閾值低于這個(gè)時(shí)長(zhǎng)的停車比如等紅燈不算停留避免誤報(bào)。停留點(diǎn)在地圖上用AMap.CircleMarker標(biāo)出來(lái)點(diǎn)一下彈出停留時(shí)長(zhǎng)這就是復(fù)盤工具和「只能看線」的差距。4. 避坑與排查回放做不順時(shí)先查這幾條軌跡回放這東西代碼寫(xiě)對(duì)了不一定跑得順很多問(wèn)題出在數(shù)據(jù)、坐標(biāo)系和性能上。下面幾條是我和同行踩過(guò)的真實(shí)坑按「現(xiàn)象 → 原因 → 解決」列出來(lái)遇到問(wèn)題先對(duì)照排查。4.1 軌跡整體偏移幾十米現(xiàn)象回放時(shí)線整體偏離道路明明設(shè)備就在路上線卻畫(huà)到旁邊小區(qū)里。原因坐標(biāo)系沒(méi)統(tǒng)一WGS-84 的點(diǎn)直接當(dāng) GCJ-02 用了。解決入庫(kù)前統(tǒng)一轉(zhuǎn) GCJ-02轉(zhuǎn)換函數(shù)見(jiàn) 2.1如果數(shù)據(jù)源已經(jīng)是 GCJ-02比如從高德 SDK 直接拿的就不要再轉(zhuǎn)轉(zhuǎn)兩次反而偏。4.2 進(jìn)度條拖到一半車標(biāo)不動(dòng)現(xiàn)象拖動(dòng)進(jìn)度條線在變但車標(biāo)卡在原地。原因renderAt里只更新了polyline.setPath忘了marker.setPosition或者getIndexByProgress返回的索引越界。解決確認(rèn)renderAt里兩個(gè)更新都寫(xiě)了getIndexByProgress的循環(huán)邊界用points.length - 1別用points.length否則最后一段取不到。4.3 點(diǎn)位多了頁(yè)面卡死現(xiàn)象超過(guò)五千個(gè)點(diǎn)后拖動(dòng)進(jìn)度條明顯掉幀地圖縮放也卡。原因每次setPath都傳全量數(shù)組瀏覽器要重繪整條線。解決抽稀后再畫(huà)見(jiàn) 2.2或者用AMap.Polyline的setPath只傳當(dāng)前進(jìn)度之前的點(diǎn)別傳全量。另外showDir在點(diǎn)特別多時(shí)也耗性能可以關(guān)掉。4.4 時(shí)間戳對(duì)不上回放速度忽快忽慢現(xiàn)象進(jìn)度條勻速拖車卻一會(huì)兒快一會(huì)兒慢。原因按點(diǎn)索引而不是按時(shí)間索引點(diǎn)位疏密不均時(shí)就會(huì)這樣。解決用 3.2 里的getIndexByProgress按時(shí)間插值找索引。另外檢查時(shí)間戳單位是否統(tǒng)一秒和毫秒混用會(huì)讓timeSpan算錯(cuò)。4.5 地圖容器不顯示或高度塌陷現(xiàn)象頁(yè)面加載后地圖區(qū)域一片空白或者只有一條細(xì)縫。原因容器div沒(méi)有設(shè)高度或者被父元素overflow: hidden裁掉了。解決給容器明確height比如600px或100vh檢查父級(jí)有沒(méi)有display: none導(dǎo)致初始化時(shí)尺寸為 0這種情況要在地圖顯示后再調(diào)map.resize()。5. 進(jìn)階技巧用插值讓回放絲滑用分段讓復(fù)盤精準(zhǔn)基礎(chǔ)回放跑通后真正拉開(kāi)體驗(yàn)差距的是兩個(gè)細(xì)節(jié)一是車標(biāo)在兩點(diǎn)之間的「補(bǔ)間」二是按業(yè)務(wù)時(shí)段分段回放。先說(shuō)補(bǔ)間。上面的renderAt是「跳到最近的點(diǎn)」如果點(diǎn)位間隔 30 秒車標(biāo)就是每 30 秒跳一下看著像瞬移。解決辦法是在相鄰兩點(diǎn)之間做線性插值根據(jù)當(dāng)前時(shí)間算出精確的經(jīng)緯度。這樣即使原始點(diǎn)位很稀車標(biāo)也能平滑移動(dòng)。// 按時(shí)間在兩點(diǎn)間線性插值返回精確位置 function interpolate(points, targetTime) { for (let i 0; i points.length - 1; i) { const t1 points[i].time, t2 points[i 1].time; if (targetTime t1 targetTime t2) { const ratio (targetTime - t1) / (t2 - t1); return { lng: points[i].lng (points[i 1].lng - points[i].lng) * ratio, lat: points[i].lat (points[i 1].lat - points[i].lat) * ratio }; } } return points[points.length - 1]; }interpolate的ratio是當(dāng)前時(shí)間在兩點(diǎn)時(shí)間區(qū)間里的占比經(jīng)緯度按這個(gè)比例線性過(guò)渡。注意這是線性插值不走道路曲線如果兩點(diǎn)之間隔了一個(gè)彎車標(biāo)會(huì)「切彎」。要更真實(shí)就得用路徑規(guī)劃補(bǔ)點(diǎn)但那是另一個(gè)量級(jí)的成本多數(shù)復(fù)盤場(chǎng)景線性插值夠用。再說(shuō)分段。一天的回放里真正要細(xì)看的可能就早高峰那兩小時(shí)。做法是在進(jìn)度條下面加幾個(gè)「時(shí)段按鈕」點(diǎn)了之后把startTime和endTime重設(shè)成該時(shí)段的起止timeSpan跟著變回放范圍就縮到那一段。這樣復(fù)盤時(shí)不用在整天的進(jìn)度條上反復(fù)微調(diào)。參數(shù)作用常用取值totalDuration整段回放耗時(shí)15000~60000 msminDistMeter抽稀距離閾值城市 10高速 30minMinutes停留判定時(shí)長(zhǎng)3~10 分鐘strokeWeight軌跡線粗細(xì)4~8zoom初始縮放級(jí)別13~16最后說(shuō)個(gè)驗(yàn)證方法拿一段已知的、帶真實(shí)時(shí)間戳的軌跡手動(dòng)算出總里程和總耗時(shí)再和回放里顯示的對(duì)一遍。如果里程對(duì)不上八成是抽稀把彎道抹了或者坐標(biāo)系偏了如果耗時(shí)對(duì)不上查時(shí)間戳單位。我現(xiàn)在的習(xí)慣是每接一個(gè)新數(shù)據(jù)源先拿 100 個(gè)點(diǎn)跑一遍這個(gè)對(duì)賬確認(rèn)無(wú)誤再上全量。從那以后我每次接入新設(shè)備數(shù)據(jù)都強(qiáng)制先跑一遍坐標(biāo)轉(zhuǎn)換和里程對(duì)賬省得后面回放出來(lái)全是玄學(xué)偏移。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取