Files
osmWorkflow/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/design.md

12 KiB
Raw Permalink 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不删实体,与槽位复用配套。

模型选择R8OBU 的数据 hook 虽标记 car_obu.glb,但最终的 CrossCars/index.vue 模板固定 传入 11.glb,因此 modelNameFor(vehicle) 对 OBU 返回 11.glb;目标车辆返回 ${type}${subType}.glb。预览阶段把 dashboard 的 11/12/13/14/15/16/17/31/221/222.glb 复制到 _preview/v2x-vehicles/,并在 vehicleModelNames 中按文件名匹配;未知车型回退 11.glb 并累加 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与源项目一致