12 KiB
Design — 还原 V2X 实时路口信号灯与车辆展示
1. 现状与根因
scripts/lib/v2x-cesium-overlay.js(418 行,单个 IIFE)承担了登录、REST、三个 WS、坐标转换、
实体渲染的全部职责。REST 路径与登录契约与源项目一致,失真集中在数据处理与渲染层。
逐项根因(对应 PRD 的 R1–R10):
| # | 现象 | 根因位置 |
|---|---|---|
| R1 | 连接几十秒后静默中断 | openSocket() 无心跳、无重连 |
| R2 | 灯永远红 | lampColor() 判 2/3;真实码是 21/22/23 |
| R3 | 原生信号灯模型从不亮 | signalByPhase 回退 signal.id,与 phaseNo 永不相等 |
| R4 | 同相位只亮一条进口道 | state.linkPhases 是 Map<phase, entity>,后写覆盖 |
| R5 | 无倒计时 | updateSignalPhases() 丢弃 countDown |
| R6 | OBU 可能一辆车都没有 | connectObuSocket() 的 onOpen 传 null,未发订阅帧 |
| 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.js的vm.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 面板
L1–L3 全部导出到 .utils,测试只打 L1–L3。
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/stopLatitude 与 headingDegrees。
因此定义纯函数:
buildPhaseSignalMap(links, nativeSignals, options) -> {
byPhase: Map<phaseNo, string[]>, // nodeKey 列表
bound: number,
unbound: Array<{phaseNo, reason}>,
diagnostics: Array<{linkId, matchedNodeKeys, distanceMeters, headingDeltaDegrees}>
}
算法:
-
对每条 link:解析
geom,取末两点,先 GCJ-02 → WGS84,再算stopPoint与approachHeading = getAngle(prePoint, lastPoint)。 -
对每个原生 signal:取
(stopLongitude, stopLatitude)与行车方向。行车方向的语义由生成侧
scripts/lib/traffic-signals.js定死,不是猜的::54headingDegrees = atan2(axis),而axis = roadAxis(stopLineCenter → intersectionCenter), 即停止线指向路口中心的行车方向。:82mast_heading_deg = heading - 90:83face_heading_deg = heading + 180
运行时
traffic-signals.json中headingDegrees === mastHeadingDegrees(实测 7/7 相等, 如150.658),即存的是 mast 值;faceHeadingDegrees比它小 90(60.658)。因此:travelHeading = (faceHeadingDegrees + 180) % 360 // 首选 = (mastHeadingDegrees + 90) % 360 // face 缺失时的等价回退实测对全部 7 个信号自洽。
-
候选条件:
haversine(stopPoint, signalStop) <= maxDistanceMeters且angleDelta(approachHeading, travelHeading) <= maxHeadingDeltaDegrees。 -
一条 link 可匹配多个 signal(同一进口道的多个灯头),满足 R4 的一半。
-
link 的
phaseList中每个phase→ 该 link 匹配到的全部 nodeKey,并集累加 (Map<phaseNo, Set<nodeKey>>),满足 R4 的另一半(同相位多进口道)。 -
默认容差:
maxDistanceMeters = 30、maxHeadingDeltaDegrees = 45。 -
未匹配上的相位进入
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,lat(WGS84 → 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.5时visible = false。- 槽位复用:新车优先占用同模型的
visible === false记录(源项目useTargetCars.ts:110), 减少 Cesium 实体的增删抖动。 now注入,测试用假时钟推进。- socket
onClose时registry.clear(),对齐源项目onDisconnected: cars.value = []。
渲染适配层据 visible 切 entity.show,不删实体,与槽位复用配套。
模型选择(R8):OBU 的数据 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.SampledPositionProperty 按 duration 插值,替代当前的直接赋值跳变。
4.5 宽松解析(R9)
源项目用 saferEval。浏览器端零依赖不能引入该库,也不应引入 eval。改为
parseLoosePayload(text):先 JSON.parse;失败则做一次受限规范化(单引号→双引号、
裸键补引号、去尾逗号、NaN/Infinity → null)后重试;仍失败返回 null 并
state.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=false时createV2xCesiumOverlay()仍返回null,生成页不含面板, 包产物字节不变(AC10)。- 原生信号灯在无 V2X 灯态时的行为保持现状(
hideUnconfirmedSignalAssets语义不变), 只有收到实时灯态才点亮,不会退回模拟相位。 - 回滚点:本任务对
cesium-preview.js的改动限于删除重复的lampColorName与调整setSignalState入参形状,可单独 revert 而不影响 overlay。 - 新增
phaseSignalMap、maxDistanceMeters、maxHeadingDeltaDegrees均为可选配置, 缺省即当前默认行为。
7. 风险
| 风险 | 影响 | 缓解 |
|---|---|---|
| 几何匹配在实际路口全部落空 | 灯仍不亮 | 面板显性报「0 已绑定」;phaseSignalMap 配置覆盖兜底 |
computeViewRectangle() 返回 undefined |
OBU 过滤异常 | 退回空串=不过滤 |
| 宽松解析未覆盖真实非法形态 | 丢消息 | parseFailures 计数可见,便于按真实样本补规则 |
| 服务端心跳格式与源项目不符 | 仍被断开 | 心跳报文与间隔取自源项目源码,AC1 用模拟服务端验证 |
真实推送 interval 缺失 |
车辆过早隐藏 | 缺失时回退 500ms,与源项目一致 |