跳到正文

当「实时」数据其实是仿真:把公交位置写成 f(线路, 秒)

· 约 4 分钟

手上只有静态快照——线路折线、站点表和班次时刻表,没有任何 GPS 流。于是「车在哪」被定义成一个确定性纯函数。这换来了任意时刻回放与可测试性,代价是画面每天长得一样。

数据现实

一个城市公交可视化项目,最容易被假设的前提是「有一条实时定位数据流」。实际拿到的数据是这样的:

  • 线路折线(GeoJSON,含上下行两条)
  • 站点表:站名、坐标、站序,以及每条线路引用的站点 ID 列表
  • 班次时刻表:每条线路每天有哪些发车时刻

没有 GPS 轨迹,没有到站记录,没有车辆编号。

那么「现在有一辆车正开到某处」这个信息,从哪来?答案只能是:算出来。

把问题写成一个函数

既然要靠计算,那就把它明确写成一个函数,而不是散落在各处的一堆补丁逻辑:

位置 = f(线路, 班次, 秒)

具体展开:

  1. 预处理阶段,把线路折线转成线性参考系统:累积每个顶点的弧长,并记下每个站点在折线上的弧长位置。
  2. 给定班次的发车时刻 departSeconds,当前时刻 nowSeconds,得到已经过去的时间 elapsed = nowSeconds - departSeconds。
  3. 沿线推进:按设定的行驶速度累积弧长,每经过一个站点就加上一段停站时间。
  4. 用最终弧长在折线上插值,得到经纬度;用弧长前后微小步长的两点差得到切向,作为航向。

关键参数只有两个:最高行驶速度与每站停靠时长。它们不追求物理真实,只要求「看起来像公交」——车不会跑得比出租车快,也不会在每一站停半分钟。

// 简化后的核心:弧长 → 位置
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 超过了这趟车的总运行时间,说明它已经跑完了,不应该再出现在地图上。少了这一步,凌晨的地图上会挤满「昨天所有班次」的车。

小结

把「位置」从「被观测的事实」变成「被计算的函数」,是在数据受限下最实用的一个转向。它牺牲了一部分真实性,换来的是:

  • 时间维度的完全自由(任意时刻回放)
  • 可测试、可复现
  • 极低的运行成本(查表 + 插值)

代价是内容恒定 —— 而这一点,与其想办法掩盖,不如把它变成定位的一部分:这不是一个告诉你车在哪的工具,这是一扇看城市如何运转的窗。

这篇文章对应的作品

当「实时」数据其实是仿真:把公交位置写成 f(线路, 秒) · 巴迷科技