普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月4日首页

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

作者 小左OvO
2025年11月4日 13:45

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

QQ20251030-162229-HD-soConvert.webp

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

一般公交车更新实时位置都是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,
            },
            ...
            ]
    }
]


车辆模型加载

image.png

开始,我们用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点 为站点的朝向向量,这样就能让站台正确的朝向马路了。示例图如下

image.png

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

// 创建站点标记
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调整前后对比效果图如下:

image.png


沿路行驶与转向动画

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

核心变量与参数:

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

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

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

  1. 位置插值(沿线段线性插值)
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过渡

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;
}

效果示例:

QQ20251030-164053-HD-soConvert.webp

相机跟随与视角策略

我们需要提供“自由”和“跟随”两种视角模式的切换,在跟随时的时候我们只跟随距离,就像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即可;

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

image.png

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

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

QQ20251030-175611-HD-soConvert.webp

高级参数可视化

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

  1. 运行状态:公交车运行状态机,包含四个状态
    • driving:正常行驶
    • approaching:减速进站(距离站点 < 200米)
    • stopped:站台停靠(距离站点 < 30米,停留3秒)
    • departing:启动离站
  2. 距离计算:使用 Turf.js 计算车辆当前位置到下一站的直线距离(可以用线段截取计算的方式实际剩余距离)
  3. 进度显示:基于距离计算的到站进度
  4. 站点状态管理
    • 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' });
    }
};

效果图:

QQ20251031-142631-HD-soConvert.webp

其实上面的数据在实际业务中是后端不会再前端去计算,这里也只是阐述一下业务逻辑,实现一下效果,还有就是实际业务是要接入实时位置更新的,我们需要实时去更新公交车的位置,简单的阐述一下业务,一般的做法是每辆车需要维护一个信号队列,然后逐个去执行队列,这样车辆的延迟是第一个和第二个信号之间的时间差,画了一个逻辑图:

image.png

而且实际中实时数据是会抖动的,出现长时间没信号、信号批量涌入、gps信号乱跳这些都会出现,若接入真实 GPS,可对点做卡尔曼等滤波处理,减少抖动,让公交车的行进看起来更自然更流畅一些。


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

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

基于百度地图JSAPI Three的城市公交客流可视化(二)——区域客流

作者 小左OvO
2025年11月4日 11:51

基于百度地图JSAPI Three的城市公交客流可视化(二)——区域客流

image.png

前言

在上一篇我们实现了公交线路客流,通过飞线效果展示了站点间的客流流向。现在我们来搞一下区域客流可视化,采用六边形蜂窝网格来展示不同区域的客流热力图,除了保证数据更加直观外,当然也要利用JSAPIThree高灵活自定义的优势来搞点帅的东西。

在公交行业的区域客流可视化主要的是:

  • 哪些区域的公交客流最密集
  • 通过热力图快速识别热点区域
  • 面子攻城(bushi)

与线路客流相比,区域客流更注重空间分布特征这块。我们使用六边形蜂窝网格将城市区域进行规则划分(也支持正方形、三角形),每个六边形代表一个单元,通过统计单元内的公交站点数量和客流数据,生成蜂窝热力图来直观展示每块区域的客流密度分布。

技术实现

数据准备

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

  1. 边界数据 (guzhen.json) - 城市或区域边界数据
  2. 站点数据 (stands.json) - 公交站点位置和客流数据

边界数据采用标准的 GeoJSON 格式(这种数据推荐去阿里的datav中可以直接获取包括省市区)。站点数据包含每个站点的经纬度坐标和客流统计信息。

蜂窝网格生成

六边形相对比矩形和三角形看起来更专业一点。我们使用 Turf.js 的 hexGrid 函数来生成蜂窝网格(truf也支持三角形和矩形)。

网格生成原理:

  1. 边界框计算:使用 bbox 函数计算多边形的包围盒
  2. 网格生成 :- 在边界框内生成指定radius的蜂窝网格
  3. 空间裁剪:使用 booleanIntersects 过滤与目标区域相交的六边形,也就是整个区域内的蜂窝
import { bbox, polygon, hexGrid, booleanIntersects, booleanContains } from '@turf/turf'

// 生成 1.5km 六边形蜂窝并裁剪到目标边界
const hexLinesFC = () => {
  const boundary = guzhen.features[0].geometry
  const wgsPolygon = polygon([boundary.coordinates[0]])
  const box = bbox(wgsPolygon)

  // 生成 1.5公里半径的六边形网格
  const grid = hexGrid(box, 1.5, { units: 'kilometers' })

  // 过滤与边界之外的六边形
  const features = grid.features.filter((cell) => booleanIntersects(cell, wgsPolygon))

  return { type: 'FeatureCollection', features }
}

booleanIntersects 函数是空间相交判断,booleanContains函数是判断否在空间内,我们只保留与目标区域重叠的六边形

站点数据统计

为每个六边形计算站点数量和总客流数据,这是为了生成热力图用的的数值。

统计原理:

  1. 包含判断 - 使用 booleanContains 函数判断站点是否在六边形内
  2. 数据聚合 - 累加六边形内所有站点的客流数据
// 计算每个六边形内的站点数据
const calculateHexagonData = (hexagon) => {
  let totalUp = 0 //六边形内所有站点的上车人数总和
  let stationCount = 0 // 六边形内包含的站点数量

  // 遍历所有站点,检查是否在六边形内
  for (const station of stands) {
    for (const stand of station.stands) {
      const standPoint = point([stand.lon, stand.lat])

      //是否在内部
      if (booleanContains(hexagon, standPoint)) {
        totalUp += stand.up || 0
        stationCount++
      }
    }
  }

  return { totalUp, stationCount }
}

然后我们可以用使用处理好的所有数据使用mapvthree.Polyline进行预览,代码如下:

// 生成六边形蜂窝并裁剪到边界
const hexLinesFC = (): any => {
  const g = (guzhen as any)?.features?.[0]?.geometry
  if (!g) return { type: 'FeatureCollection', features: [] }
  // 使用边界外环构造 turf 多边形
  let wgsOuter: [number, number][] = []
  if (g.type === 'Polygon') {
    wgsOuter = (g.coordinates?.[0] || []) as [number, number][]
  } else if (g.type === 'MultiPolygon') {
    wgsOuter = (g.coordinates?.[0]?.[0] || []) as [number, number][]
  }
  if (!wgsOuter || wgsOuter.length < 3) return { type: 'FeatureCollection', features: [] }

  const wgsPolygon = turfPolygon([wgsOuter])
  const box = turfBbox(wgsPolygon)

  const radius = 1.5
  // 生成 5 公里六边形网格
  const grid = turfHexGrid(box, radius, { units: 'kilometers' } as any)
  // 过滤与多边形相交的六边形
  const features: any[] = []
  for (const cell of grid.features || []) {
    try {
      if (turfBooleanIntersects(cell as any, wgsPolygon as any)) {
        const ring: [number, number][] = (cell.geometry as any)?.coordinates?.[0] || []
        if (Array.isArray(ring) && ring.length > 0) {
          // 计算六边形内的站点数据
          const hexData = calculateHexagonData(cell)

          const bdCoords = ring.map(([lon, lat]) => wgs84tobd09(lon, lat))
          features.push({
            type: 'Feature',
            geometry: { type: 'LineString', coordinates: bdCoords },
            properties: {
              type: 'hex',
              radius_km: radius,
              totalUp: hexData.totalUp,
              stationCount: hexData.stationCount,
              hexagonId: features.length, 
            },
          })
        }
      }
    } catch (_e) {}
  }
  return { type: 'FeatureCollection', features }
}

//传入数据
const hexSource = mapvthree.GeoJSONDataSource.fromGeoJSON(hexLinesFC() as any)
const hexLayer = engine.add(
  new mapvthree.Polyline({
    flat: true,
    lineWidth: 1.5,
    keepSize: true,
    color: '#7A7AFF',
  }),
)
hexLayer.dataSource = hexSource

目前的基础效果就是这个样子:

image.png

蜂窝区块可视化

现在我们要让这些六边形更加的层次分明,要用颜色和透明度来直观展示客流密度分布,让数据更可视化。我们使用 THREE.js 的 LineSegments 来绘制六边形边框,为了实现更吊的热力图效果。上面的 mapvthree 蜂窝可以暂时隐藏,专注于我们自定义效果的实现。

1. 六边形边框着色

接着我们使用 HSL色彩空间实现根据蜂窝内的总下车人数从绿色到红色的自然过渡

const createHexagonLineSegments = () => {
    const hexData = hexLinesFC()
    const vertices = []
    const colors = []

    // 找到客流最大的六边形作为基准
    const maxTotalUp = Math.max(...hexData.features.map((f) => f.properties.totalUp))

    for (const feature of hexData.features) {
        const { totalUp, stationCount } = feature.properties
        const coords = feature.geometry.coordinates

        // 根据客流数据调色
        let heatColor = new THREE.Color()

        if (stationCount > 0) {
            const intensity = totalUp / maxTotalUp
            // 从绿色到红色渐变
            heatColor.setHSL(0.33 - intensity * 0.33, 1.0, 0.5)
        } else {
            // 没有站点的区域保持灰色
            heatColor.setHSL(0, 0, 0.3)
        }

        // 设置颜色
        for (let i = 0; i < coords.length - 1; i++) {
            const [x1, y1] = bd09ToMercator(coords[i][0], coords[i][1])
            const [x2, y2] = bd09ToMercator(coords[i + 1][0], coords[i + 1][1])

            vertices.push(x1, y1, 0, x2, y2, 0)
            colors.push(heatColor.r, heatColor.g, heatColor.b)
            colors.push(heatColor.r, heatColor.g, heatColor.b)
        }
    }

    // 创建几何体,让每条线都有颜色
    const geometry = new THREE.BufferGeometry()
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
    geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))

    const material = new THREE.LineBasicMaterial({
        vertexColors: true,
        transparent: true,
        opacity: 0.8,
    })

    return new THREE.LineSegments(geometry, material)
}

好的,上色之后我们可以很直观的看到哪里的客流多:

image.png

2. 填充六边形

光有边框还不够,我们再来给热力图填充颜色。半透明的填充让整个热力图数据效果看起来更加直观,视觉层次也更丰富。

const createHexagonFillPolygons = () => {
  const hexData = hexLinesFC()
  const polygons = []

  for (const feature of hexData.features) {
    const { totalUp, stationCount } = feature.properties
    const coords = feature.geometry.coordinates

    
    if (stationCount === 0) continue

    // 填充着色
    const intensity = totalUp / maxTotalUp
    const heatColor = new THREE.Color()
    heatColor.setHSL(0.33 - intensity * 0.33, 1.0, 0.5)

    // 创建三角形面片
    const vertices = []
    const center = calculateCenter(coords)

    for (let i = 0; i < coords.length - 1; i++) {
      const [x1, y1] = bd09ToMercator(coords[i][0], coords[i][1])
      const [x2, y2] = bd09ToMercator(coords[i + 1][0], coords[i + 1][1])

      vertices.push(center.x, center.y, 0, x1, y1, 0, x2, y2, 0)
    }

    const geometry = new THREE.BufferGeometry()
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))

    const material = new THREE.MeshBasicMaterial({
      color: heatColor,
      transparent: true,
      opacity: 0.2,
      side: THREE.DoubleSide,
    })

    polygons.push(new THREE.Mesh(geometry, material))
  }

  return polygons
}

这样,我们的热力图就有了底层边框和内部填充:

image.png

看起来蛮吊的,还能不能更唬人一点

3. 添加扫光shader效果

扫描效果还是非常适合这种网格的面,采用从左上到右下的渐变矩形扫光 大致效果如图所示:

image.png

const createSweepShaderMaterial = () => {
    return new THREE.ShaderMaterial({
        uniforms: {
            time: { value: 0.0 },
            sweepColor: { value: new THREE.Color(0x00ffa8) },
            sweepSpeed: { value: 0.5 },
        },
        vertexShader: `
      attribute vec3 color;
      varying vec3 vColor;
      varying vec2 vUv;

      void main() {
        vColor = color;
        vUv = position.xy;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }
    `,
        fragmentShader: `
      uniform float time;
      uniform vec3 sweepColor;
      uniform float sweepSpeed;
      varying vec3 vColor;
      varying vec2 vUv;

      void main() {
        uniform float time;
                uniform vec3 sweepColor;
                uniform float sweepWidth;
                uniform float sweepSpeed;
                uniform float glowIntensity;

                varying vec2 vUv;
                varying vec3 vColor;
                varying float vOpacity;

                void main() {
                    float sweepPos = mod(time * sweepSpeed, 2.0);
                  float diagonalDist = (vUv.x + (1.0 - vUv.y)) * 0.5;
                  float dist = abs(diagonalDist - sweepPos);

                  // 光衰减和柔尾
                  float gradient = 1.0 - smoothstep(0.0, sweepWidth, dist);
                  float softGlow = exp(-dist / (sweepWidth * 0.3));
                  float sweep = mix(gradient, softGlow, 0.5);

                  // 脉冲
                  sweep *= 0.7 + 0.3 * sin(time * 8.0);
                  sweep = clamp(sweep, 0.0, 1.0);

                  // 混色 和 发光
                  vec3 finalColor = mix(vColor, sweepColor, sweep);
                  finalColor += sweepColor * sweep * glowIntensity;

                  // bloom 触发 
                  finalColor *= 10.0;

                gl_FragColor = vec4(finalColor, vOpacity);
                      }
                    `,
        transparent: true,
        blending: THREE.AdditiveBlending,
    })
}

扫光效果就出来了,看起来很科幻,这领导看不得拍手叫好?

QQ20251029-15237-HD-soConvert.webp

区域掩膜效果

为了突出更加沉浸的显示目标区域,我们创建一个黑色掩膜来遮挡区域外的内容,让观众的注意力集中在目标区域。

实现的步骤:

  1. 世界矩形 - 创建覆盖整个地球的大矩形
  2. 区域掏空 - 将目标区域从世界矩形中挖出
  3. 黑色填充 - 使用黑色填充
// 创建区域掩膜
const buildMaskFC = () => {
  const boundary = guzhen.features[0].geometry

  // 世界矩形
  const worldRect = [
    [-180, -85],
    [180, -85],
    [180, 85],
    [-180, 85],
    [-180, -85],
  ]

  // 目标区域作为洞
  const hole = boundary.coordinates[0].map(([lon, lat]) => wgs84tobd09(lon, lat))

  return {
    type: 'Feature',
    geometry: {
      type: 'Polygon',
      coordinates: [worldRect, hole], // 外环 + 内环
    },
  }
}

const maskLayer = engine.add(
  new mapvthree.Polygon({
    flat: true,
    color: '#0D161C',
    opacity: 1,
  }),
)
maskLayer.dataSource = mapvthree.GeoJSONDataSource.fromGeoJSON(buildMaskFC())

效果如图,会更专注聚焦这个区域

image.png

站点粒子效果

最后为所有公交站点添加发光粒子效果,能够清晰的看到站点分布在蜂窝的情况,我们使用threejs的粒子Points,并让他发光以增强效果,首先将Point位置投影到站点实际的位置 然后使用canvas为粒子创建纹理材质,最后增加亮度触发Bloom即可~


const createStationParticles = () => {
  const positions = []

  // 收集坐标
  for (const station of stands) {
    for (const stand of station.stands) {
      const [x, y] = bd09ToMercator(
        wgs84tobd09(stand.lon, stand.lat)[0],
        wgs84tobd09(stand.lon, stand.lat)[1],
      )
      positions.push(x, y, 0)
    }
  }

  // 创建粒子
  const geometry = new THREE.BufferGeometry()
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))

  // 创建纹理
  const texture = createCircleTexture(64, '#ffffff')

  // 创建发光材质
  const material = new THREE.PointsMaterial({
    size: 5,
    map: texture,
    transparent: true,
    blending: THREE.AdditiveBlending,
  })

  // 触发泛光
  material.color.setRGB(4, 4, 4)

  return new THREE.Points(geometry, material)
}

// 生成纹理
const createCircleTexture = (size, color) => {
  const canvas = document.createElement('canvas')
  canvas.width = canvas.height = size
  const ctx = canvas.getContext('2d')

  ctx.fillStyle = color
  ctx.beginPath()
  ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
  ctx.fill()

  return new THREE.CanvasTexture(canvas)
}

image.png

技术要点

1. 空间计算

主要的实现还是靠turf,turf真是对数学不好的开发的一种福音啊,好用爱用, 拿到边界数据使用bbox计算出边界,然后在这个包围盒通过turfHexGrid生成Hexagon蜂窝,最后用booleanContains裁剪掉地区边界外的蜂窝。

2. 颜色生成

先将客流的数据都维持在0-1之间,这里也叫数据归一化,然后更具数值为设置HSL颜色也就是类似css的rab(255,255,255) 这种写法。

3. Shader

shader glsl不像js那样可以打印调试,完全靠抽象的脑补,这里主要的步骤: 位置计算 → 距离场 → 光脉冲 → 合成

总结

区域客流可视化通过六边形蜂窝网格和热力效果,除了能把复杂的空间数据转化为直观的视效,还结合扫光动画和粒子效果增加视觉体验。

下一篇我们将继续实现实时公交

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

基于百度地图JSAPI Three的城市公交客流可视化(一)——线路客流

作者 小左OvO
2025年11月4日 10:59

基于百度地图JSAPI Three的城市公交客流可视化(一)——线路客流

image.png

前言

目前可视化大屏网站大致分为2/3D两个大类,据我所知3D大屏中的实现方式有客户端渲染和服务端渲染,客户端渲染主流的实现方式大概有threejsbabylonjs或者unity打包html的方式,而服务端则由UE像素流技术实现,当然他们也各有各的优缺点。

今天我们的需求是快速的实现在公交客流的炫酷的可视化展示(ps:一定要高级),我们需要在地图上展示最基础的GEO数据,像点、线等业务数据,目前市面上的有很多可用:

  • cesium:相对复杂

  • 高德地图:loca可视化拓展性低,叠加gl图层复杂

  • mapbox:(不会,嘻嘻)

调研了很多,百度地图居然出了基于threejs的版本,大致看了一下,不仅封装了很多模块例如:天气系统、动态天空(还包含大气散射、体积云)、众多投影方式和3D地球,这简直就是百度版cesium(bushi ,不仅能实现地图的基础业务还能拓展超级多的炫酷效果,废话不多说,让我们一块开始实现我们的业务。

需要实现的公交客流效果:线路客流 OD、区域客流 OD和实时公交 ,如图所示

image.png

技术准备和项目初始化

我们需要去百度地图开放平台申请AK(不是你想的那个),🔗:控制台 | 百度地图开放平台 这里跟着官网流程走就行

首先我们使用的技术栈是Vue3,安装我们需要使用的依赖:

  • @baidumap/mapv-three 百度JSAPI three

  • @turf/turf 空间计算工具

  • threejs

  • vite-plugin-static-copy (vite原生插件替代rollup-plugin-copy)

安装vite-plugin-static-copy后将以下主要代码写进vite.config.ts

// vite-plugin-static-copy
import { viteStaticCopy } from 'vite-plugin-static-copy'

export default defineConfig({
  plugins: [
    viteStaticCopy({
      targets: [
        {
          src: 'node_modules/@baidumap/mapv-three/dist/assets/*',
          dest: 'mapvthree/assets',
        },
      ],
    }),

    // 其他插件...
  ],
  // ...其他配置
})

接着我们就去项目的index.html中加入MAPV_BASE_URL 全局变量指向静态资源目录,前提是一定要安装了mapv-three

 <script>
      window.MAPV_BASE_URL = '/node_modules/@baidumap/mapv-three/dist' // 配置为mapv-three包路径的dist目录
  </script>

配置完成后初始化地图我们需要装填我们的AK,然后再开始真正的开发流程。

<script setup lang="ts">
import { onMounted } from 'vue'
// 在项目入口文件中配置
import * as mapvthree from '@baidumap/mapv-three'
import type { Engine } from '@baidumap/mapv-three/dist/types/index.d.ts'

// 配置百度地图 AK
mapvthree.BaiduMapConfig.ak = 'xxxxxxxxxxxx'  //你自己申请的AK

onMounted(() => {
  const div = document.getElementById('engine') as HTMLDivElement | null
  if (!div) return

  const engine: Engine = new mapvthree.Engine(div, {
    map: {
      center: [116.404, 39.915],
      range: 7000,
      projection: 'EPSG:3857',
    },
    rendering: {
      enableAnimationLoop: true,
      sky: new mapvthree.DynamicSky({
        time: 3600 * 6,
      }), // 动态天空
    },
  } as any)
})
</script>
<template>
  <div id="engine" class="engine"></div>
</template>
<style scoped>
.engine {
  width: 100%;
  height: 100%;
}
</style>

技术实现

我们需要展示一条公交线路上不同站点乘客的流动情况,大白话就是乘客做几路公交车从哪里来,到哪里去,他们之间的关系用飞线在适合不过了,就像这样:

image.png

首先我们需要准备几个关键的数据文件:

  1. 站点数据 (stands.json) - 包含所有公交站点的位置和客流信息
  2. 飞线数据 (line.json) - 站点间的客流流向关系
  3. 线路数据 (route.json) - 公交线路的上下行路径

站点数据结构:

{
  "stationId": 200,
  "stationName": "火车站",
  "stands": [
    {
      "id": 799,
      "name": "火车站",
      "lat": 33.3195783,
      "lon": 117.30184,
      "down": 219 // 下车人数
    }
  ]
}

飞线数据定义了客流流向:

{
  "from": [{ "id": 765, "down": 2 }], // 从id:765站点出发
  "to": [{ "id": 561, "down": 2 }] // 到达id:561站点
}

站点可视化 - 热力柱

站点我们用热力柱来展示,高度代表客流强度。这里用到了mapv-three的Pillar热力柱:

// 展平所有站点数据
const allStands = stands.flatMap((s) => s.stands)
const features = allStands.map((s) => ({
    type: 'Feature',
    geometry: {
        type: 'Point',
        coordinates: [wgs84tobd09(s.lon, s.lat)[0], wgs84tobd09(s.lon, s.lat)[1]],
    },
    properties: {
        id: s.id,
        name: s.name,
        down: s.down || 0, // 下车人数作为高度依据
    },
}))

// 创建热力柱
const pillar = new mapvthree.Pillar({
    shape: 'pillar',
    height: 500, // 基础高度
    radius: 10, // 柱子半径
    vertexHeights: true, // 启用顶点高度
    gradient: {
        0.02: '#467ce3', // 蓝色
        0.04: '#25ba3d', // 绿色
        0.06: '#b6cc4f', // 黄绿色
        0.5: '#b04932', // 红色
    },
})

// 设置高度映射
dataSource.defineAttribute('height', (p) => p.down * 2)

wgs84tobd09函数 是将硬件设备上常用的定位格式转为百度地图专用的格式

我们用了从蓝色到红色渐变色来区分不同客流强度,如图所示:

image.png

飞线效果实现

飞线是客流可视化的核心,但是百度地图文档中似乎并没有封装高自定义得飞线,但是好处是百度地图允许我们使用一切threejs中的功能,准备基于Three.js的Line功能,手写自定义的FlyLine类来实现这个类:

export class FlyLine extends Line {
  constructor(startBd09: LngLat, endBd09: LngLat, options?: FlyLineOptions) {
    // 坐标转换
    const start = lngLatToMercator(startBd09)
    const end = lngLatToMercator(endBd09)

    // 动态计算弧线高度
    const distance = start.distanceTo(end)
    const height = this.calculateHeight(distance, options)

    // 构建贝塞尔曲线
    const points = buildSymmetricArc(start, end, height, segments,offset)

    // 创建流光shader材质
    const shaderMaterial = new ShaderMaterial({
      uniforms: {
        uTime: { value: 0 },
        uSpeed: { value: speed },
        uBaseColor: { value: baseColor },
        uGlowColor: { value: glowColor },
      },
      vertexShader,
      fragmentShader,
      transparent: true,
      blending: AdditiveBlending, // 叠加混合产生发光效果
    })

    super(geometry, shaderMaterial)
  }
}

其中飞线主要核心函数是buildSymmetricArc ,主要原理是在两个平面坐标点之间,构建一条光滑的三维贝塞尔弧线,其顶点在连接线的上,从而形成一个拱形的多线段曲线,其中segments为线段平滑程度,offset为贝塞尔曲线偏移量 如图所示:

image.png 关键点在于让曲线动起来,这里采用流光shader的实现,我们在fragment来模拟光点沿着线条流动的效果,如果你是Vscode,推荐安装插件:WebGL GLSL Editor,这样可以抽离成glsl文件并且编写时有语法提示,这里我们暂用模板语法

// 流光shader着色器
const fragmentShader = `
    uniform float uTime;
    uniform float uSpeed;
    uniform vec3 uBaseColor;
    uniform vec3 uGlowColor;

    varying float vU;

    void main() {
        // 计算流光位置
        float flowPos = fract(vU - uTime * uSpeed);

        // 主流光 - 明亮的光点
        float mainFlow = 1.0 - smoothstep(0.0, 0.1, abs(flowPos - 0.5));

        // 流光尾巴
        float trail = smoothstep(flowPos - uTrail, flowPos, vU);

        // 颜色混合,让流光部分超出RGB范围产生泛光
        vec3 color = mix(uBaseColor, uGlowColor, mainFlow + trail);
        color = mix(color, color * 3.0, mainFlow + trail);

        gl_FragColor = vec4(color, intensity * uOpacity);
    }
`;

飞线泛光效果

好了,为了让效果更炫酷,我们需要泛光效果,如果是在threejs中我们需要手动的增加后处理加入Bloom效果,分层、合成会较为繁琐,百度地图为我们考虑好了这点,我们直接在engine.rendering.features.bloom中即可开启场景泛光,只要我们的颜色超过RGB的范围,就会产生泛光

const engine = new mapvthree.Engine(div, {
    rendering: {
        features: {
            bloom: {
                enabled: true,
                strength: 10.5, // 泛光强度
                threshold: 0.1, // 泛光阈值
                radius: 1.4, // 泛光半径
            },
        },
    },
})

//物体泛红光:
const box = engine.add(new Mesh(new IcosahedronGeometry(5, 15), new MeshBasicMaterial({
    color: new Color(10, 0, 0), // 颜色超出RGB范围,会产生泛光效果
})));

这样飞线的流光效果就能产生真正的泛光了,看起来特别酷。

image.png

线路路径绘制

最后我们还需要绘制公交线路的上下行路径,这里直接使用自带的宽线就行,用不同颜色区分:

// 上行线路 - 浅蓝色
const upLine = engine.add(
  new mapvthree.Polyline({
    flat: true,
    lineWidth: 4,
    color: '#87CEFA',
  }),
)

// 下行线路 - 红色
const downLine = engine.add(
  new mapvthree.Polyline({
    flat: true,
    lineWidth: 4,
    color: '#FF6B6B',
  }),
)

// 设置数据源
upLine.dataSource = mapvthree.GeoJSONDataSource.fromGeoJSON(upLineGeoJSON)
downLine.dataSource = mapvthree.GeoJSONDataSource.fromGeoJSON(downLineGeoJSON)

效果图

dmeo.gif

  1. 站点热力柱 - 用高度和渐变色展示客流强度
  2. 飞线效果 - 自定义shader实现流光动画,展示客流流向
  3. 线路路径 - 上下行用不同颜色区分
  4. 泛光效果 - 增强视觉

整个实现过程其实并不复杂,关键是要理解geo数据结构和简单的shader编程。下一期我们继续实现区域客流OD和实时公交功能。

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

❌
❌