如何在Mapbox GL JS中动画绘制折线时显示已覆盖距离?
高性能实现Mapbox GL JS折线动画+画布内距离显示+视频捕获
1. 预计算折线累计距离
先离线计算折线每个节点到起点的累计距离,避免动画帧内实时计算地理距离,大幅降低CPU开销:
// 用哈维正弦公式计算两点球面距离(单位:米) function calculateDistance(coords1, coords2) { const R = 6371000; // 地球半径 const lat1 = coords1[1] * Math.PI / 180; const lat2 = coords2[1] * Math.PI / 180; const deltaLat = (coords2[1] - coords1[1]) * Math.PI / 180; const deltaLon = (coords2[0] - coords1[0]) * Math.PI / 180; const a = Math.sin(deltaLat/2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon/2) ** 2; const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R * c; } // 生成折线累计距离数组 function precomputeCumulativeDistances(lineCoords) { const cumulativeDistances = [0]; let totalDistance = 0; for (let i = 1; i < lineCoords.length; i++) { totalDistance += calculateDistance(lineCoords[i-1], lineCoords[i]); cumulativeDistances.push(totalDistance); } return { cumulativeDistances, totalDistance }; } // 使用示例 const lineGeoJSON = { /* 你的LineString类型GeoJSON数据 */ }; const { cumulativeDistances, totalDistance } = precomputeCumulativeDistances(lineGeoJSON.geometry.coordinates);
2. 折线动画高效实现
通过增量更新GeoJSON数据源实现动画,避免频繁重建图层:
// 添加折线数据源和图层 map.addSource('animated-line', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } } }); map.addLayer({ id: 'animated-line-layer', type: 'line', source: 'animated-line', paint: { 'line-color': '#007cbf', 'line-width': 4 } }); // 动画参数 const duration = 10000; // 总时长(毫秒) let startTime = null; window.currentDistance = 0; // 全局变量供文本图层读取 function animateLine(timestamp) { if (!startTime) startTime = timestamp; const elapsed = timestamp - startTime; const progress = Math.min(elapsed / duration, 1); window.currentDistance = progress * totalDistance; // 根据进度截取折线坐标 const targetDistance = window.currentDistance; let coordIndex = 0; while (coordIndex < cumulativeDistances.length && cumulativeDistances[coordIndex] < targetDistance) coordIndex++; const displayedCoords = lineGeoJSON.geometry.coordinates.slice(0, coordIndex); // 插值计算当前帧的中间点(如果未到最后一个节点) if (coordIndex < cumulativeDistances.length) { const prevDist = cumulativeDistances[coordIndex - 1]; const segmentRatio = (targetDistance - prevDist) / (cumulativeDistances[coordIndex] - prevDist); const prevCoord = lineGeoJSON.geometry.coordinates[coordIndex - 1]; const nextCoord = lineGeoJSON.geometry.coordinates[coordIndex]; displayedCoords.push([ prevCoord[0] + (nextCoord[0] - prevCoord[0]) * segmentRatio, prevCoord[1] + (nextCoord[1] - prevCoord[1]) * segmentRatio ]); } // 增量更新数据源 map.getSource('animated-line').setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: displayedCoords } }); progress < 1 ? requestAnimationFrame(animateLine) : stopCapture(); }
3. 画布内文本高性能渲染(自定义WebGL图层)
通过Mapbox自定义WebGL图层直接在画布上绘制文本,完全整合到Mapbox渲染流水线,无DOM重排重绘开销:
class DistanceTextLayer { constructor(id) { this.id = id; this.type = 'custom'; this.renderingMode = '2d'; this.canvas = document.createElement('canvas'); this.ctx = this.canvas.getContext('2d'); } onAdd(map, gl) { this.map = map; this.gl = gl; // 创建WebGL纹理 this.texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, this.texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); } render(gl, matrix) { // 适配Mapbox画布大小 if (this.canvas.width !== this.map.getCanvas().width || this.canvas.height !== this.map.getCanvas().height) { this.canvas.width = this.map.getCanvas().width; this.canvas.height = this.map.getCanvas().height; gl.bindTexture(gl.TEXTURE_2D, this.texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.canvas); } // 绘制距离文本 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); const displayText = `已行驶: ${(window.currentDistance / 1000).toFixed(2)} km`; this.ctx.font = '24px sans-serif'; this.ctx.fillStyle = '#fff'; this.ctx.strokeStyle = '#000'; this.ctx.lineWidth = 3; this.ctx.textAlign = 'right'; this.ctx.textBaseline = 'bottom'; const padding = 20; this.ctx.strokeText(displayText, this.canvas.width - padding, this.canvas.height - padding); this.ctx.fillText(displayText, this.canvas.width - padding, this.canvas.height - padding); // 更新纹理并绘制到Mapbox画布 gl.bindTexture(gl.TEXTURE_2D, this.texture); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.canvas); const program = this.map.getProgramManager().getProgram('2d'); gl.useProgram(program.program); gl.uniformMatrix3fv(program.uniforms.u_matrix, false, matrix); gl.uniform1i(program.uniforms.u_texture, 0); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ 0, 0, this.canvas.width, 0, this.canvas.width, this.canvas.height, 0, this.canvas.height ]), gl.STATIC_DRAW); gl.enableVertexAttribArray(program.attributes.a_pos); gl.vertexAttribPointer(program.attributes.a_pos, 2, gl.FLOAT, false, 0, 0); gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); gl.deleteBuffer(buffer); } } // 添加文本图层到地图 map.addLayer(new DistanceTextLayer('distance-text'));
4. 逐帧视频捕获与编码
使用浏览器原生MediaRecorder捕获画布流,性能远高于逐帧转DataURL:
let mediaRecorder; let recordedChunks = []; function startCapture() { const stream = map.getCanvas().captureStream(30); // 30fps mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/mp4' }); mediaRecorder.ondataavailable = (e) => e.data.size > 0 && recordedChunks.push(e.data); mediaRecorder.onstop = () => { const blob = new Blob(recordedChunks, { type: 'video/mp4' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'line-animation.mp4'; a.click(); URL.revokeObjectURL(url); }; mediaRecorder.start(); } function stopCapture() { mediaRecorder.stop(); } // 启动捕获与动画 startCapture(); requestAnimationFrame(animateLine);
核心性能优化点
- 预计算距离:离线完成地理距离计算,避免帧内冗余运算
- 自定义WebGL图层:文本渲染与Mapbox渲染流水线同步,无DOM额外开销
- 增量更新数据源:仅更新折线可见部分,避免图层重建
- 原生MediaRecorder:直接捕获画布流,降低转码延迟
内容的提问来源于stack exchange,提问作者Keith Carter
相关产品推荐
相关产品推荐

