巴士大屏 · 实时公交可视化
把公交线路、站点与班次时刻表快照,做成能在三维地图上任意时刻回放的画面。北京与成都两地,覆盖 3000+ 条线路。

- 技术栈
- CesiumWebGLVue 3TypeScriptViteFastifySSESQLite微信小程序
亮点
- 任意时刻回放:高峰实测同一秒钟有 8000+ 辆车在城市里跑,位置全部由 f(线路, 班次, 秒) 现场算出
- 车辆沿真实道路行驶:稀疏的位置快照经曲线约束插值后贴路平滑运动,弯道不切角
- 3Mbps 窄出口下服务 100 人并发:SSE 分片推送 + 预计算通道 + brotli 预压缩
- 同一套前端同时承载网页与微信小程序两端,无需为小程序重写
这是什么
巴士大屏把一座城市的公交线网搬到三维地图上:线路、站点、班次,以及任意一个时刻城市里所有公交车的分布。
它不是「查某一班车到哪了」的查询工具,而是「看这座城市此刻在怎么运转」的观察窗口。所以在交互上做的是时间轴与视角,而不是线路搜索与到站预测。
目前覆盖北京与成都两地。
数据从哪里来(先说清楚)
这一点必须诚实交代,因为它决定了后面所有设计:
手上没有实时 GPS 数据流。 拿到的是一份静态快照 ——
- 线路折线(GeoJSON,含上下行)
- 站点表(含站序与所属线路)
- 每条线路每个班次的发车时刻表
也就是说,「这辆车现在在哪」并不是被观测到的,而是算出来的。车辆位置是一个确定性函数:给定线路、班次与时刻,结果唯一。
这个前提带来两个后果,一个惊喜一个代价:
- 惊喜:既然位置是算出来的,那就算「今天 08:30」和「明天 08:30」也没有区别 —— 于是任意时刻回放成了顺手的事,不需要任何额外基础设施。
- 代价:画面每天长得一样。它不会因为「今天下雨晚点」而变化。所以定位只能是观察与欣赏,而不是查询。
宁可把它标成「可视化」,也不标成「实时查询」—— 这是这个项目里少数几个一开始就想清楚的事。
几个关键设计
车辆位置是纯函数。 f(线路, 班次, 秒) → 经纬度 + 航向。服务端把线路折线预处理好(累积弧长、站点弧长位置),运行期只做一次查表加插值。这样单机就能扛住「全城同一秒 8000+ 辆车」的计算量。
稀疏快照要贴路跑。 服务端按固定频率推送车辆位置,两次快照之间间隔十几到二十几米。直接线性插值会在弯道上切角、朝向抖动。做法是把快照投影到该车的线路折线上,在弧长维度插值 —— 细节写在《曲线约束插值》里。
窄出口的流量方案。 服务器出口只有 3Mbps,而前端包含 Cesium 引擎(原始体积约 4.3MB)。做法是三件事叠加:静态资源全量预压缩(brotli)、引擎资源与业务代码分离并走 CDN 或强缓存、车辆数据走 SSE 按视口网格分片推送而非全量广播。
一套前端,两端承载。 同样的代码既跑在浏览器里,也被微信小程序通过 web-view 承载,因此移动端的触摸手势、视口性能、生命周期的处理都必须在同一套代码里解决。
边界与不足
- 不支持任意线路点选后看它的班次明细:时间轴是全局的,这是「大屏」定位下的刻意取舍。
- 没有车号维度。原始数据里没有车辆编号,所以做不了「跟随某一辆车的一天」这类叙事。
- 站名需要聚类。同名站点在不同线路上是不同记录,直接按名字合并会把相隔几公里的站合成一个点,必须按坐标聚类。
- 区域聚合的数字是「此刻」的,切时间轴时同步变化,不做累计统计。
相关技术文章
本项目涉及的实现细节散在博客里:曲线约束插值、确定性位置函数、窄带宽下的并发方案。作品页只负责「是什么」,怎么做的在那边。
截图点击可放大 · 共 10 张
本项目的技术文章
曲线约束插值:让稀疏的点位跑成一条顺滑的线
服务端每秒推一次车辆位置,两次快照之间相距十几到二十几米。直接线性插值的结果是弯道切角、朝向抖动。把它们投影到线路折线上按弧长插值,问题就消失了——代价是所有几何都要预处理。
Cesium 贴地渲染的三个坑:地面图元、像素线宽与三维瓦片
想让道路贴住地形,结果连续踩了三个坑:地面图元只接受一种外观、折线宽度单位是屏幕像素而不是米、三维瓦片的高度参考对烘焙模型根本无效。这篇把每个坑的现象、原因和绕法都写清楚。
当「实时」数据其实是仿真:把公交位置写成 f(线路, 秒)
手上只有静态快照——线路折线、站点表和班次时刻表,没有任何 GPS 流。于是「车在哪」被定义成一个确定性纯函数。这换来了任意时刻回放与可测试性,代价是画面每天长得一样。