当「实时」数据其实是仿真:把公交位置写成 f(线路, 秒)
手上只有静态快照——线路折线、站点表和班次时刻表,没有任何 GPS 流。于是「车在哪」被定义成一个确定性纯函数。这换来了任意时刻回放与可测试性,代价是画面每天长得一样。
数据现实
一个城市公交可视化项目,最容易被假设的前提是「有一条实时定位数据流」。实际拿到的数据是这样的:
- 线路折线(GeoJSON,含上下行两条)
- 站点表:站名、坐标、站序,以及每条线路引用的站点 ID 列表
- 班次时刻表:每条线路每天有哪些发车时刻
没有 GPS 轨迹,没有到站记录,没有车辆编号。
那么「现在有一辆车正开到某处」这个信息,从哪来?答案只能是:算出来。
把问题写成一个函数
既然要靠计算,那就把它明确写成一个函数,而不是散落在各处的一堆补丁逻辑:
位置 = f(线路, 班次, 秒)
具体展开:
- 预处理阶段,把线路折线转成线性参考系统:累积每个顶点的弧长,并记下每个站点在折线上的弧长位置。
- 给定班次的发车时刻
departSeconds,当前时刻nowSeconds,得到已经过去的时间elapsed = nowSeconds - departSeconds。 - 沿线推进:按设定的行驶速度累积弧长,每经过一个站点就加上一段停站时间。
- 用最终弧长在折线上插值,得到经纬度;用弧长前后微小步长的两点差得到切向,作为航向。
关键参数只有两个:最高行驶速度与每站停靠时长。它们不追求物理真实,只要求「看起来像公交」——车不会跑得比出租车快,也不会在每一站停半分钟。
// 简化后的核心:弧长 → 位置
interface LineGeometry {
/** 每个顶点的累积弧长(米) */
cum: Float64Array;
/** 每个站点的弧长位置(米) */
stopArc: Float64Array;
length: number;
}
function arcAt(geo: LineGeometry, elapsedSec: number, cruise: number, dwell: number): number {
// 逐站推进:每站先跑一段,再停一段
let remain = elapsedSec;
let arc = 0;
for (let i = 1; i < geo.stopArc.length; i += 1) {
const legLength = geo.stopArc[i] - geo.stopArc[i - 1];
const legTime = legLength / cruise;
if (remain <= legTime) {
return arc + remain * cruise;
}
remain -= legTime;
arc += legLength;
if (remain <= dwell) {
// 此刻正在站上停靠 —— 位置就是站点本身
return arc;
}
remain -= dwell;
}
// 跑完全程后继续沿末段外推,避免车在终点"消失"
return Math.min(geo.length, arc + remain * cruise);
}
注意最后那行 Math.min:如果不做限制,超过班次时长的车会一路跑出线路终点。
确定性带来的三个好处
① 任意时刻回放是免费的。 想看看早上 8:30 的路况,不需要录像、不需要日志回放系统,把「秒」换掉重新算一遍就是那一秒的城市。这个能力在传统「保存实时数据再回放」的方案里要一整套存储与查询设施;在这里只是函数参数的差异。
② 可测试。 给定输入必然得到同一输出,于是可以写断言:某条线在某秒的位置必须落在该线路几何的某个容差范围内;跨午夜时不能出现负数或溢出。
③ 可缓存、可预计算。 同一秒只算一次。服务端把整条线路的几何预处理一次,运行期只做查表与一条线段上的插值 —— 这就是为什么单机可以扛住「同一秒全城 8000+ 辆车」。
代价:它不会变
确定性是双刃剑。既然结果只取决于参数,那么今天的画面和明天的画面是一样的。
- 它无法反映晚点、绕行、临时改线。
- 它不能回答「这班车今天准点吗」。
- 它不会因为「今天下雨」而给你任何不同的东西。
所以定位必须诚实:这是可视化,不是实时查询。
有意思的是,这个限制反而让产品的意思更清楚了 —— 它的价值在于「看一座城市在怎么运转」的画面感,而不是「帮我判断现在出门能不能赶上」。后者需要真实数据,而真实数据不在手上。
数据里没有的东西,就不要假装有
这一点在实现中反复出现。列几个真实的例子:
| 想做的功能 | 需要的数据 | 结论 |
|---|---|---|
| 跟随某辆车的一整天 | 车辆编号 | 数据里没有 → 不做 |
| 准点率统计 | 实际到站时间 | 没有 → 不做 |
| 「这站下一班还有几分钟」 | 实时定位 | 只能给「按时刻表的计划到站」→ 必须标为计划 |
| 线路上下行区分 | 方向字段 | 有(成对线路),可以做 |
第三行是边界最微妙的一个:按时刻表推算出来的到站时间,看起来和真实到站时间长得一模一样,但它俩的可信度完全不同。如果界面上不区分,用户很快就会用错误的方式信任它。
原则很简单:能算的标为「推算」,不能算的直接不做。 一个诚实的空白,比一个看起来合理但会误导的数字有用得多。
一个容易忽略的边界:跨午夜
时刻表是「一天」的。而用户可能在凌晨 00:10 查看 23:50 发车的班次 —— 此时 elapsed 算出来是负数。
处理方式不是丢掉这条班次,而是做一次时间折叠:
const DAY = 86_400;
let rel = nowSec - departSec;
if (rel < 0) rel += DAY; // 昨天发车、今天仍在路上
但折叠之后还要用班次的总时长做一次校验:如果 rel 超过了这趟车的总运行时间,说明它已经跑完了,不应该再出现在地图上。少了这一步,凌晨的地图上会挤满「昨天所有班次」的车。
小结
把「位置」从「被观测的事实」变成「被计算的函数」,是在数据受限下最实用的一个转向。它牺牲了一部分真实性,换来的是:
- 时间维度的完全自由(任意时刻回放)
- 可测试、可复现
- 极低的运行成本(查表 + 插值)
代价是内容恒定 —— 而这一点,与其想办法掩盖,不如把它变成定位的一部分:这不是一个告诉你车在哪的工具,这是一扇看城市如何运转的窗。