基于百度地图JSAPI Three的城市公交客流可视化(三)——实时公交

基于百度地图JSAPI Three的城市公交客流可视化(三)------实时公交

上一篇文章我们实现了六边形蜂窝的区域客流,这一篇我们的目标是实现 **实时公交**。实现实时公交的方式我们需要得数据:实时更新位置的公交车、 当前公交车排班路线, 还有就是线路所经过的站台

一般公交车更新实时位置都是socket,这里为了方便我们使用历史行进轨迹数据traceData.json进行调试,站台我们采用glb模型,并将模型朝向路线保证视觉正确,公交车运行时需要沿路行驶并且需要正确的朝向,开搞。


数据获取

基于上一篇文章的初始化地图代码,我们需要以下数据文件(文件在下方仓库地址):

  • 线路数据(routeData)这是公交车司机当天排班的公交车指定行进线路,比如101路线路
  • 站点数据(stand2Data)当天排班的公交车指定行进线路所经过的上下行站点,每个站点最少站台
  • 轨迹数据(traceData)公交车的实际行进线路数据,一般都在指定行进线路上

资源文件准备:

ts 复制代码
import traceData from '@/resources/trace/reace1.json';
import routeData from '@/resources/route/route2.json';
import stand2Data from '@/resources/stand/stand2.json';

routeData线路数据是上/下行两组 WGS84 点位:

1:8:src/resources/route/route2.json 复制代码
{
    "up": [ { "lat": 33.27627349350771, "lon": 117.32730936865975 }, ... ],
    "down": [ ... ]
}

stand2Data站点数据结构与此相近,但包含名称、分组等属性,

json 复制代码
{
    "up": [
            {
                "id": 862,
                "name": "小学",
                "remarks": "北",
                "lat": 33.3333833,
                "lon": 117.3255,
                ...
            }
    	],
    "down": [ ... ]
}

轨迹数据则提供车辆按时间序列格式的数据。

json 复制代码
[
    {

        "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。

加载与首帧定位:

ts 复制代码
//加载模型
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点 为站点的朝向向量,这样就能让站台正确的朝向马路了。示例图如下

清楚怎么设置之后,首先我们处理好我们的站点和线路的经纬度格式:

ts 复制代码
// 创建站点标记
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();

接着我们再加载站台模型,设置好大小、初始化朝向和位置

ts 复制代码
// 加载车站模型
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);
    });
});

最后我们来设置站台的朝向,朝向马路,代码如下:

ts 复制代码
// 如果存在公交线路,计算站点到线路的最近点并设置模型朝向
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调整前后对比效果图如下:


沿路行驶与转向动画

因为原数据的行驶速度过慢,我们采用固定速度,让车辆匀速的方式沿线前进,同时在转弯处做平滑的朝向过渡,避免瞬间旋转。

核心变量与参数:

ts 复制代码
// 动画参数
const speedKmh = 100;                // 行驶速度
const speedMs = speedKmh * 1000 / 3600; // m/s
const totalTimeSeconds = totalDistance / speedMs; // 总行驶时间

// 角度过渡
let currentRotationY = 0;            // 当前朝向(弧度)
const rotationSpeed = 2;             // 最大旋转速度(弧度/秒)

动画主循环负责两件事:位置插值与朝向插值。

  1. 位置插值(沿线段线性插值)
ts 复制代码
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);
  1. 朝向角插值(平滑转向,避免突变)

算法:用当前线段向量 end - start 求出航向角 targetAngle = atan2(dy, dx);再用"夹角归一到 ([-π, π])"与"最大角速度"把 currentRotationYtargetAngle 推进。这样在急转弯处也会过渡自然,当然你也可以直接用gsap过渡

ts 复制代码
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

ts 复制代码
//raf动画循环
if (cameraFollowMode) {
  const [currentBdLon, currentBdLat] = mercatorToBd09(currentX, currentY);
  engine.map.lookAt([currentBdLon, currentBdLat], {
    range: 300
  } as any);
}

自由视角直接将cameraFollowMode设为false即可;

停之停之!文章写到这里我我发现百度地图的开发者更新了新功能,他们支持了自定义镜头动画,赶紧换上

现在只需要传入model即可,不需要再每一帧手动更新相机位置,相比于之前的拖动丝滑的很多,lock设置为true即可固定视角

ts 复制代码
const tracker = engine.add(new mapvthree.ObjectTracker())
tracker.track(busModel, {
  range: 300,
  pitch: 80,
  heading: 10,
  lock: false, //不锁定视角
})

高级参数可视化

以上实现就已经完成实时公交的基本形式的可视化了,但是我们要做就要做的更详细一点,加入类似于电子公交屏的功能:距离下一站距离当前到达下一站运行状态

  1. 运行状态 :公交车运行状态机,包含四个状态
    • driving:正常行驶
    • approaching:减速进站(距离站点 < 200米)
    • stopped:站台停靠(距离站点 < 30米,停留3秒)
    • departing:启动离站
  2. 距离计算:使用 Turf.js 计算车辆当前位置到下一站的直线距离(可以用线段截取计算的方式实际剩余距离)
  3. 进度显示:基于距离计算的到站进度
  4. 站点状态管理
    • passedStations:已通过站点数组
    • currentStationIndex:当前最接近的站点
    • nextStationIndex:下一站
    • 站点状态分为:已通过、当前站、下一站、未到达

通过在 updateBusStatus 函数每帧去更新:计算车辆到所有站点的距离,找到最近站点,更新到站状态,并计算到下一站的距离和进度即可~

ts 复制代码
// 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,可对点做卡尔曼等滤波处理,减少抖动,让公交车的行进看起来更自然更流畅一些。


好了,以上就是线路客流、区域客流和实时公交的所有内容了,本人技术十分有限,如有不合理或者错误的地方还望指出

代码仓库:zuo-wentao/bmap-demo: bmap demp

相关推荐
IT_陈寒6 小时前
Vite 5新特性解析:10个提速技巧让你的开发效率翻倍 🚀
前端·人工智能·后端
焦糖小布丁6 小时前
通配符证书能给几个网站用?
前端
qiao若huan喜6 小时前
6、webgl 基本概念 + 四边形纹理
前端·javascript·信息可视化·webgl
刘一说6 小时前
深入理解 Spring Boot Web 开发中的全局异常统一处理机制
前端·spring boot·后端
啃火龙果的兔子7 小时前
前端导出大量数据到PDF方案
前端·pdf
Lj2_jOker7 小时前
QT 给Qimage数据赋值,显示异常,像素对齐的坑
开发语言·前端·qt
csj507 小时前
前端基础之《React(7)—webpack简介-ESLint集成》
前端·react
Jonathan Star7 小时前
CSS margin 折叠现象的实际代码示例
javascript·css·css3
咚咚咚小柒7 小时前
【前端】Webpack相关(长期更新)
前端·javascript·webpack·前端框架·node.js·vue·scss