基于百度地图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

相关推荐
醉城夜风~25 分钟前
CSS元素显示模式(display)
前端·css
AI大模型-小华1 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子3 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅4 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni4 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学5 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师6 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc886 小时前
微软EDGE浏览器功能学习
前端·学习·edge
破z晓6 小时前
javascript 导出excel表
开发语言·javascript·excel