Files
osmWorkflow/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/design.md
que01 bc845444bb fix: restore live V2X signal and vehicle fidelity in Cesium preview
The ported overlay used the correct REST paths but lost the data handling
from the source dashboard's live-intersection view (HologramCross), so
signals rendered permanently red and vehicles often never appeared.

- Lamp status codes now follow the dashboard dictionary (11/21/22/23/31).
  The previous 2/3 reading made every real push fall through to red. The
  dictionary lives only in the overlay; the preview consumes normalized
  {nodeKeys, color, countDown} entries so the two copies cannot drift.
- Bind V2X phases to native signal heads geometrically. The runtime
  document has no phaseNo, so the old lookup fell back to signal.id and
  never matched, leaving the dynamic assembly dark. Travel heading is
  recovered as faceHeadingDegrees + 180, per the generator's
  mast = travel - 90 / face = travel + 180. Verified 7/7 exact matches
  against the fengshu-er-road runtime document.
- A phase now lights every approach it drives; the phase -> single entity
  map silently overwrote all but the last.
- Drive the countdown assets from the push's countDown field.
- All three sockets heartbeat every 30s and reconnect with backoff,
  replaying their subscription frame. Without this the service dropped
  the connection and the scene emptied after about a minute.
- The OBU socket sends its bounds frame on connect and on camera move;
  it previously sent nothing at all.
- Vehicles are swept when a push goes stale and their slots reused, so
  they no longer accumulate as ghosts. Models follow the dashboard's
  car_obu.glb / ${type}${subType}.glb naming.
- Parse vehicle pushes leniently, since the dashboard uses saferEval and
  the payload is not guaranteed to be strict JSON. Failures are counted
  and surfaced rather than dropped; no eval is introduced.
- Drop FlowTravelRatio/queryListWeek, which is not part of this view.

Also corrects a stale spec rule that required vehicleModelNames to be
empty. Live V2X vehicles need packaged models; the real invariant is no
generated routes or traffic simulation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 09:20:50 +08:00

12 KiB
Raw Blame History

Design — 还原 V2X 实时路口信号灯与车辆展示

1. 现状与根因

scripts/lib/v2x-cesium-overlay.js418 行,单个 IIFE承担了登录、REST、三个 WS、坐标转换、 实体渲染的全部职责。REST 路径与登录契约与源项目一致,失真集中在数据处理与渲染层。

逐项根因(对应 PRD 的 R1R10

# 现象 根因位置
R1 连接几十秒后静默中断 openSocket() 无心跳、无重连
R2 灯永远红 lampColor()2/3;真实码是 21/22/23
R3 原生信号灯模型从不亮 signalByPhase 回退 signal.id,与 phaseNo 永不相等
R4 同相位只亮一条进口道 state.linkPhasesMap<phase, entity>,后写覆盖
R5 无倒计时 updateSignalPhases() 丢弃 countDown
R6 OBU 可能一辆车都没有 connectObuSocket()onOpennull,未发订阅帧
R7 幽灵车堆积 state.vehicles 只增不删,未用推送 interval
R8 车型错配 按数组下标取模型,非 ${type}${subType} 语义
R9 推送可能整条被丢 JSON.parse + 静默 catch,源项目用 saferEval
R10 多余请求 FlowTravelRatio/queryListWeek 不属实时路口

2. 边界与不变量

保持不变:

  • overlay 仍是单文件零依赖 IIFE,由 build-area.js:561 原样拷入产物。不新增文件,避免改动 area-preview.js 的 HTML 装配与包契约。
  • 纯逻辑继续通过 createV2xCesiumOverlay.utils 导出,供 scripts/test-v2x-cesium-overlay.jsvm.runInNewContext 沙箱直接测试(无 DOM、无 Cesium
  • 坐标契约不变GCJ-02 → WGS84 只在建实体前转一次。
  • 令牌只进 sessionStorage,页面始终从登录门进入。

新增的可测试纯函数一律不触碰 window / Cesium / document,副作用留在薄薄的适配层。

3. 模块内部分层

文件内按四层组织,自下而上:

L1 纯工具   gcj02ToWgs84 / joinUrl / md5 / toWebSocketUrl / getAngle / haversine
L2 纯解析   lampColorName / parseLoosePayload / normalizeObuVehicle /
            normalizeTargetVehicles / normalizeSignalLamps
L3 纯状态机 createVehicleRegistry / createPhaseBinding / buildPhaseSignalMap
L4 副作用层 createSocket(心跳/重连) / 实体渲染 / UI 面板

L1L3 全部导出到 .utils,测试只打 L1L3。

4. 关键设计决策

4.1 灯色状态字典R2

直接采用源项目 HologramCross/components/utils.ts 的字典,语义化返回而非直接返回颜色, 使 overlay 与 cesium-preview.js 共用同一套判定:

11 -> "off"      灭灯
21 -> "red"
22 -> "yellow"
23 -> "green"
31 -> "other"
其他 -> "other"   // 不再落到 red

lampColorName(status) 放在 overlay 的 L2 并导出。cesium-preview.js 中重复的 lampColorName() 删除,改为消费 overlay 通过 setSignalState 回调传入的已归一化灯态 {nodeKeys, color, countDown}),避免两处字典漂移。

取舍:也可以把字典抽到共享文件让两边 require但 overlay 必须是浏览器端零依赖 IIFE cesium-preview.js 同样是浏览器端脚本两者无模块系统。让数据流单向overlay → preview 比共享常量更简单,也消除了双字典。

4.2 相位 → 原生信号灯映射R3本任务最难的一处

traffic-signals.json 的信号灯项只有 phaseGroup: 0不含 V2X phaseNo,两侧没有公共 ID。 唯一可靠的连接是几何

源项目的做法给了线索:CrossTrafficLights3D.vue:96-104 对每条 link 取 geom.coordinates 的 最后两点,末点即停止线位置,两点连线的方位角即进口道航向。原生信号灯项恰好也有 stopLongitude/stopLatitudeheadingDegrees

因此定义纯函数:

buildPhaseSignalMap(links, nativeSignals, options) -> {
  byPhase: Map<phaseNo, string[]>,   // nodeKey 列表
  bound: number,
  unbound: Array<{phaseNo, reason}>,
  diagnostics: Array<{linkId, matchedNodeKeys, distanceMeters, headingDeltaDegrees}>
}

算法:

  1. 对每条 link解析 geom,取末两点,先 GCJ-02 → WGS84,再算 stopPointapproachHeading = getAngle(prePoint, lastPoint)

  2. 对每个原生 signal(stopLongitude, stopLatitude)行车方向

    行车方向的语义由生成侧 scripts/lib/traffic-signals.js 定死,不是猜的:

    • :54 headingDegrees = atan2(axis),而 axis = roadAxis(stopLineCenter → intersectionCenter)停止线指向路口中心的行车方向
    • :82 mast_heading_deg = heading - 90
    • :83 face_heading_deg = heading + 180

    运行时 traffic-signals.jsonheadingDegrees === mastHeadingDegrees(实测 7/7 相等, 如 150.658),即存的是 mast 值faceHeadingDegrees 比它小 9060.658)。因此:

    travelHeading = (faceHeadingDegrees + 180) % 360     // 首选
                  = (mastHeadingDegrees + 90) % 360      // face 缺失时的等价回退
    

    实测对全部 7 个信号自洽。

  3. 候选条件:haversine(stopPoint, signalStop) <= maxDistanceMetersangleDelta(approachHeading, travelHeading) <= maxHeadingDeltaDegrees

  4. 一条 link 可匹配多个 signal同一进口道的多个灯头满足 R4 的一半。

  5. link 的 phaseList 中每个 phase → 该 link 匹配到的全部 nodeKey并集累加 Map<phaseNo, Set<nodeKey>>),满足 R4 的另一半(同相位多进口道)。

  6. 默认容差:maxDistanceMeters = 30maxHeadingDeltaDegrees = 45

  7. 未匹配上的相位进入 unbound面板显示「N 已绑定 / M 未绑定」。

实现期修正:设计初稿认为朝向语义不可判定,打算用「三字段 ± 180 的候选集,任一命中即可」。 实测该方案在凤树二路路口产生严重误匹配 —— 一条 link 同时命中 3 条不同 approachId 的进口道 (距离 1.57 / 13.01 / 17.56 m角差 3.8 / 7.8 / 5.4°),因为 ±180 × 三字段几乎放行任意 90° 朝向。 改为上述单一确定式后7 条 link 对 7 个信号达成 7/7 精确一对一,误匹配为 0。 候选集方案已废弃。

配置覆盖v2xPreview.phaseSignalMap = { "<phaseNo>": ["<nodeKey>", …] } 存在时, 该 phaseNo 直接用配置值,跳过几何匹配。这是几何匹配失败时的逃生舱,也让映射可人工固化。

取舍:不用 approachId / sourceWayId 匹配,因为那是 OSM 侧标识V2X link 不携带; 不用最近邻唯一指派(匈牙利算法),因为一进口道多灯头是正常情况,强制一对一会漏灯。 容差可配是因为不同路口的停止线标注精度差异较大。

4.3 WebSocket 连接层R1、R6

新增 createSocket({url, onOpen, onMessage, onClose, heartbeatMs, reconnect}) 副作用封装:

  • 心跳:setInterval(() => send('{"heartBeat":"ping"}'), 30000),与源项目一致。
  • 重连:指数退避 1s, 2s, 4s, 8s,上限 8s重连成功后重新执行 onOpen 确保订阅帧(junctionId / deviceId / bounds)被重放。这是源项目 onConnected 的语义。
  • close() 主动关闭时不触发重连;dispose() 清心跳与退避计时器。
  • 计时器通过参数注入(setIntervalFn / setTimeoutFn),使重连与心跳逻辑可在测试中用假时钟驱动。

订阅帧内容严格对齐源项目:

socket 连接后首帧 来源
signal {"junctionId":"<crossCode>"} CrossTrafficLights3D.vue:129
obu bounds || ""dashboard 传 null → 空串) useObuCars.ts:31
target {"deviceId":"<ids>"}{"deviceId":null} useTargetCars.ts:50-53

OBU 的 bounds源项目取 AMap 视野四角。本项目用 Cesium camera.computeViewRectangle() 换算成同格式 lng,lat;lng,lat;lng,lat;lng,latWGS84 → GCJ-02 反向转换,因为服务端按 GCJ-02 过滤),在 camera.moveEnd 时节流发送 {"bounds": "<…>"}。视野不可用时退回空串,等价于不过滤。

取舍Cesium 是倾斜视角,computeViewRectangle() 在极端俯仰下可能返回 undefined 此时退回空串而非跳过发送,保证仍能收到全量推送——宁可多收也不要空场景。

4.4 车辆注册表R7、R8

纯状态机 createVehicleRegistry({now})

registry.ingest(vehicles, interval) -> {added, updated, hidden}
registry.sweep(nowMs) -> hiddenKeys
registry.list() -> VehicleRecord[]
  • 复刻源项目:interval = Number(payload.interval) === 0 ? 500 : Number(payload.interval)
  • sweep()Math.abs(now - timeStamp) >= interval * 1.5visible = false
  • 槽位复用:新车优先占用同模型的 visible === false 记录(源项目 useTargetCars.ts:110 减少 Cesium 实体的增删抖动。
  • now 注入,测试用假时钟推进。
  • socket onCloseregistry.clear(),对齐源项目 onDisconnected: cars.value = []

渲染适配层据 visibleentity.show不删实体,与槽位复用配套。

模型选择R8modelNameFor(vehicle) 返回 car_obu.glb${type}${subType}.glb 在包内 vehicleModelNames 中按文件名匹配,命中不了则回退 vehicleModelNames[0] 并累加 missingModels 计数供面板展示。

平滑:用 Cesium.SampledPositionPropertyduration 插值,替代当前的直接赋值跳变。

4.5 宽松解析R9

源项目用 saferEval。浏览器端零依赖不能引入该库,也不应引入 eval。改为 parseLoosePayload(text):先 JSON.parse;失败则做一次受限规范化(单引号→双引号、 裸键补引号、去尾逗号、NaN/Infinitynull)后重试;仍失败返回 nullstate.parseFailures += 1面板显示失败计数R9 要求可见)。

取舍:不用 new Function 还原 saferEval 的完整语义——那等于在预览页开一个任意代码执行面。 受限规范化覆盖实际会遇到的非严格 JSON 形态,且失败可观测,比静默丢弃安全得多。

4.6 移除范围外请求R10

loadLiveData()Promise.allSettled 去掉 FlowTravelRatio/queryListWeek state.metrics 及面板中的 flow metrics 文案一并移除。

5. 数据流

登录 → resolveCrossCode()
     → queryCrossLinkInfo / queryPoles / findDeviceByCrossCode / crossDeviceConfig
     → buildPhaseSignalMap(links, nativeSignals)        [纯]
     → connectLiveSockets()
          signal  → normalizeSignalLamps → lampColorName → setSignalState({nodeKeys,color,countDown})
                                                        → cesium-preview 点亮原生灯头 + 倒计时
          obu     → parseLoosePayload → normalizeObuVehicle    ┐
          target  → parseLoosePayload → normalizeTargetVehicles ┴→ registry.ingest → 渲染适配
                                                                  registry.sweep  → entity.show

6. 兼容性与回滚

  • v2xPreview.enabled=falsecreateV2xCesiumOverlay() 仍返回 null,生成页不含面板, 包产物字节不变AC10
  • 原生信号灯在无 V2X 灯态时的行为保持现状(hideUnconfirmedSignalAssets 语义不变), 只有收到实时灯态才点亮,不会退回模拟相位。
  • 回滚点:本任务对 cesium-preview.js 的改动限于删除重复的 lampColorName 与调整 setSignalState 入参形状,可单独 revert 而不影响 overlay。
  • 新增 phaseSignalMapmaxDistanceMetersmaxHeadingDeltaDegrees 均为可选配置, 缺省即当前默认行为。

7. 风险

风险 影响 缓解
几何匹配在实际路口全部落空 灯仍不亮 面板显性报「0 已绑定」;phaseSignalMap 配置覆盖兜底
computeViewRectangle() 返回 undefined OBU 过滤异常 退回空串=不过滤
宽松解析未覆盖真实非法形态 丢消息 parseFailures 计数可见,便于按真实样本补规则
服务端心跳格式与源项目不符 仍被断开 心跳报文与间隔取自源项目源码AC1 用模拟服务端验证
真实推送 interval 缺失 车辆过早隐藏 缺失时回退 500ms与源项目一致