基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交
基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交
![]()
上一篇文章我们实现了六边形蜂窝的区域客流,这一篇我们的目标是实现 **实时公交**。实现实时公交的方式我们需要得数据:实时更新位置的公交车、
当前公交车排班路线,
还有就是线路所经过的站台
一般公交车更新实时位置都是socket,这里为了方便我们使用历史行进轨迹数据traceData.json进行调试,站台我们采用glb模型,并将模型朝向路线保证视觉正确,公交车运行时需要沿路行驶并且需要正确的朝向,开搞。
数据获取
基于上一篇文章的初始化地图代码,我们需要以下数据文件(文件在下方仓库地址):
- 线路数据(routeData)这是公交车司机当天排班的公交车指定行进线路,比如101路线路
- 站点数据(stand2Data)当天排班的公交车指定行进线路所经过的上下行站点,每个站点最少站台
- 轨迹数据(traceData)公交车的实际行进线路数据,一般都在指定行进线路上
资源文件准备:
import traceData from '@/resources/trace/reace1.json';
import routeData from '@/resources/route/route2.json';
import stand2Data from '@/resources/stand/stand2.json';
routeData线路数据是上/下行两组 WGS84 点位:
{
"up": [ { "lat": 33.27627349350771, "lon": 117.32730936865975 }, ... ],
"down": [ ... ]
}
stand2Data站点数据结构与此相近,但包含名称、分组等属性,
{
"up": [
{
"id": 862,
"name": "小学",
"remarks": "北",
"lat": 33.3333833,
"lon": 117.3255,
...
}
],
"down": [ ... ]
}
轨迹数据则提供车辆按时间序列格式的数据。
[
{
"routeName": "101路",
"driverName": "张xx",
"start": "2025-10-13 07:40:02",
"end": "2025-10-13 09:39:50",
"locations": [
{
"lineId": 15,
"driverId": 37,
"posTime": "2025-10-13 07:41:03", //上报时间
"latitude1": 33.33392333984375,
"longitude1": 117.32551574707031,
"speed": 7000, //当前速度
"gpsMileage": 35010000,
},
...
]
}
]
车辆模型加载
![]()
开始,我们用mapvthree.gltfLoader把公交车模型加载,分三步:定位、调整缩放和朝向、加入场景,这里需要注意three默认的加载地址在根目录public。
加载与首帧定位:
//加载模型
mapvthree.gltfLoader.load('/model/bus.glb', (gltf: any) => {
busModel = gltf.scene;
// 取轨迹起点作为起始位置(WGS84 -> BD09 -> 墨卡托)
const firstLocation = locations[0];
if (firstLocation.latitude1 && firstLocation.longitude1) {
const [bdLon, bdLat] = wgs84tobd09(firstLocation.longitude1, firstLocation.latitude1);
const [x, y, z] = bd09ToMercator(bdLon, bdLat);
busModel.position.set(x, y, z);
}
// 方向、大小合适
busModel.rotateX(Math.PI / 2);
busModel.rotateY(Math.PI * 3 / 2);
busModel.scale.setScalar(0.9);
engine.add(busModel);
// 创建车上放的提示框,后续会更随车辆一起移动
const tooltipDom = createBusTooltip();
const firstLocationForTooltip = locations[0];
if (firstLocationForTooltip.latitude1 && firstLocationForTooltip.longitude1) {
const [bdLon, bdLat] = wgs84tobd09(firstLocationForTooltip.longitude1, firstLocationForTooltip.latitude1);
busTooltip = engine.add(new mapvthree.DOMOverlay({
point: [bdLon, bdLat, 50], //抬高50
dom: tooltipDom
}));
}
});
这里的提示框,我们用自带的 DOMOverlay,传入位置和dom即可;它的跟随逻辑会在raf动画段落里和公交车同步更新。
站台模型加载与朝向设置
我们不仅需要加载站台模型,还需要设置站台的朝向设,一般的站台都是面朝马路的,所以这里我们需要首先找到站台和线路中最近的交点A ,然后计算出 站点 和 A点 为站点的朝向向量,这样就能让站台正确的朝向马路了。示例图如下
![]()
清楚怎么设置之后,首先我们处理好我们的站点和线路的经纬度格式:
// 创建站点标记
const createStationMarkers = () => {
const standData = stand2Data as any;
if (!standData || (!Array.isArray(standData.up) && !Array.isArray(standData.down))) {
console.warn('站点数据格式不正确');
return;
}
// 合并上行和下行站点数据
const allStations = [
...(Array.isArray(standData.up) ? standData.up : []),
...(Array.isArray(standData.down) ? standData.down : [])
];
// 转换为 GeoJSON 格式(wgs转 BD09)
const features = allStations
.filter((station: any) => typeof station?.lon === 'number' && typeof station?.lat === 'number')
.map((station: any) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [wgs84tobd09(station.lon, station.lat)[0], wgs84tobd09(station.lon, station.lat)[1]],
},
properties: {
id: station.id,
name: station.name,
remarks: station.remarks,
up: station.up || 0,
down: station.down || 0,
groupId: station.groupId,
groupName: station.groupName || '',
longitude: station.lon,
latitude: station.lat,
},
}));
const geojson = { type: 'FeatureCollection', features } as const;
const dataSource = mapvthree.GeoJSONDataSource.fromGeoJSON(geojson as any);
// 创建公交线路的 Turf LineString
const routeLineString = createRouteLineString();
接着我们再加载站台模型,设置好大小、初始化朝向和位置
// 加载车站模型
let busStopModel: any = null;
const stationModels: any[] = [];
mapvthree.gltfLoader.load('/model/bus_stop.glb', (gltf: any) => {
busStopModel = gltf.scene;
// 初始化朝向
busStopModel.rotateX(Math.PI / 2);
busStopModel.scale.set(7, 7, 7);
// 为每个站点创建模型实例
features.forEach((feature: any) => {
const originalLon = feature.properties.longitude;
const originalLat = feature.properties.latitude;
const [bdLon, bdLat] = wgs84tobd09(originalLon, originalLat);
const [x, y, z] = bd09ToMercator(bdLon, bdLat);
const stationModel = busStopModel.clone();
stationModel.position.set(x, y, z);
engine.scene.add(stationModel);
stationModels.push(stationModel);
});
});
最后我们来设置站台的朝向,朝向马路,代码如下:
// 如果存在公交线路,计算站点到线路的最近点并设置模型朝向
if (routeLineString) {
const stationPoint = turf.point([bdLon, bdLat]);
//找到最近的点
const nearestPoint = turf.nearestPointOnLine(routeLineString, stationPoint);
const nearestCoords = nearestPoint.geometry.coordinates;
// 计算方位角
const bearing = turf.bearing(stationPoint, turf.point(nearestCoords));
// 转换角度(正北为0、顺时针为正) → Three Y轴旋转
const rotationY = (bearing - 180) * Math.PI / 180;
stationModel.rotateY(rotationY);
}
站台model调整前后对比效果图如下:
![]()
沿路行驶与转向动画
因为原数据的行驶速度过慢,我们采用固定速度,让车辆匀速的方式沿线前进,同时在转弯处做平滑的朝向过渡,避免瞬间旋转。
核心变量与参数:
// 动画参数
const speedKmh = 100; // 行驶速度
const speedMs = speedKmh * 1000 / 3600; // m/s
const totalTimeSeconds = totalDistance / speedMs; // 总行驶时间
// 角度过渡
let currentRotationY = 0; // 当前朝向(弧度)
const rotationSpeed = 2; // 最大旋转速度(弧度/秒)
动画主循环负责两件事:位置插值与朝向插值。
- 位置插值(沿线段线性插值)
const elapsed = (Date.now() - startTime) / 1000;
const progress = Math.min(elapsed / totalTimeSeconds, 1);
const currentDistance = progress * totalDistance;
// 通过累计里程数组 `distances` 定位当前所在的线段
let pointIndex = 0;
for (let i = 0; i < distances.length - 1; i++) {
if (currentDistance >= (distances[i] || 0) && currentDistance <= (distances[i + 1] || 0)) {
pointIndex = i; break;
}
}
// 计算线段内比例,并对两端墨卡托坐标做线性插值
const segmentStart = distances[pointIndex] || 0;
const segmentEnd = distances[pointIndex + 1] || 0;
const t = segmentEnd > segmentStart ? (currentDistance - segmentStart) / (segmentEnd - segmentStart) : 0;
// WGS84 转 墨卡托
const [startBdLon, startBdLat] = wgs84tobd09(startLocation.longitude1, startLocation.latitude1);
const [endBdLon, endBdLat] = wgs84tobd09(endLocation.longitude1, endLocation.latitude1);
const [startX, startY] = bd09ToMercator(startBdLon, startBdLat);
const [endX, endY] = bd09ToMercator(endBdLon, endBdLat);
const currentX = startX + (endX - startX) * t;
const currentY = startY + (endY - startY) * t;
busModel.position.set(currentX, currentY, 0);
- 朝向角插值(平滑转向,避免突变)
算法:用当前线段向量 end - start 求出航向角 targetAngle = atan2(dy, dx);再用“夹角归一到 ([-π, π])”与“最大角速度”把 currentRotationY 朝 targetAngle 推进。这样在急转弯处也会过渡自然,当然你也可以直接用gsap过渡
const directionX = endX - startX;
const directionY = endY - startY;
const directionLength = Math.sqrt(directionX * directionX + directionY * directionY);
if (directionLength > 0) {
const targetAngle = Math.atan2(directionY, directionX);
// 夹角归一 [-π, π]
let angleDiff = targetAngle - currentRotationY;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
// 最大角速度限制
const deltaTime = 1 / 60;
const maxRotationChange = rotationSpeed * deltaTime;
if (Math.abs(angleDiff) > maxRotationChange) {
currentRotationY += Math.sign(angleDiff) * maxRotationChange;
} else {
currentRotationY = targetAngle;
}
busModel.rotation.y = currentRotationY;
}
效果示例:
![]()
相机跟随与视角策略
我们需要提供“自由”和“跟随”两种视角模式的切换,在跟随时的时候我们只跟随距离,就像FPS游戏中TPP视角一样,吃鸡PUBG就是这种策略。
跟随实现:每帧在更新车辆位置中用mapthree中的 lookAt 把镜头看向车辆当前位置,设置固定距离 range: 300
//raf动画循环
if (cameraFollowMode) {
const [currentBdLon, currentBdLat] = mercatorToBd09(currentX, currentY);
engine.map.lookAt([currentBdLon, currentBdLat], {
range: 300
} as any);
}
自由视角直接将cameraFollowMode设为false即可;
停之停之!文章写到这里我我发现百度地图的开发者更新了新功能,他们支持了自定义镜头动画,赶紧换上
![]()
现在只需要传入model即可,不需要再每一帧手动更新相机位置,相比于之前的拖动丝滑的很多,lock设置为true即可固定视角
const tracker = engine.add(new mapvthree.ObjectTracker())
tracker.track(busModel, {
range: 300,
pitch: 80,
heading: 10,
lock: false, //不锁定视角
})
![]()
高级参数可视化
以上实现就已经完成实时公交的基本形式的可视化了,但是我们要做就要做的更详细一点,加入类似于电子公交屏的功能:距离下一站距离、当前到达、下一站和运行状态。
-
运行状态:公交车运行状态机,包含四个状态
-
driving:正常行驶 -
approaching:减速进站(距离站点 < 200米) -
stopped:站台停靠(距离站点 < 30米,停留3秒) -
departing:启动离站
-
- 距离计算:使用 Turf.js 计算车辆当前位置到下一站的直线距离(可以用线段截取计算的方式实际剩余距离)
- 进度显示:基于距离计算的到站进度
-
站点状态管理:
-
passedStations:已通过站点数组 -
currentStationIndex:当前最接近的站点 -
nextStationIndex:下一站 - 站点状态分为:已通过、当前站、下一站、未到达
-
通过在 updateBusStatus 函数每帧去更新:计算车辆到所有站点的距离,找到最近站点,更新到站状态,并计算到下一站的距离和进度即可~
// raf更新函数....
// 更新公交车位置和状态
const updateBusStatus = (currentLon: number, currentLat: number) => {
// 计算到所有站点的距离
const currentPoint = turf.point([currentLon, currentLat]);
const stationDistances = currentStations.value.map((station: any, index: number) => {
const [bdLon, bdLat] = wgs84tobd09(station.lon, station.lat);
const stationPoint = turf.point([bdLon, bdLat]);
const distance = turf.distance(currentPoint, stationPoint, { units: 'meters' });
return { index, distance, station };
});
// 找到最近的站点
const nearestStation = stationDistances.reduce((min: any, current: any) =>
current.distance < min.distance ? current : min
);
// 到站判断(距离小于50米认为到站)
const stationThreshold = 50;
const isAtStation = nearestStation.distance < stationThreshold;
// 处理到站状态
handleStationStateMachine(nearestStation.index, nearestStation.distance);
// 计算到下一站的距离
const nextStation = currentStations.value[busStatus.value.nextStationIndex];
if (nextStation) {
const [nextBdLon, nextBdLat] = wgs84tobd09(nextStation.lon, nextStation.lat);
const nextStationPoint = turf.point([nextBdLon, nextBdLat]);
busStatus.value.distanceToNext = turf.distance(currentPoint, nextStationPoint, { units: 'meters' });
}
};
效果图:
![]()
其实上面的数据在实际业务中是后端不会再前端去计算,这里也只是阐述一下业务逻辑,实现一下效果,还有就是实际业务是要接入实时位置更新的,我们需要实时去更新公交车的位置,简单的阐述一下业务,一般的做法是每辆车需要维护一个信号队列,然后逐个去执行队列,这样车辆的延迟是第一个和第二个信号之间的时间差,画了一个逻辑图:
![]()
而且实际中实时数据是会抖动的,出现长时间没信号、信号批量涌入、gps信号乱跳这些都会出现,若接入真实 GPS,可对点做卡尔曼等滤波处理,减少抖动,让公交车的行进看起来更自然更流畅一些。
好了,以上就是线路客流、区域客流和实时公交的所有内容了,本人技术十分有限,如有不合理或者错误的地方还望指出