# 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`,后写覆盖 | | 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, // nodeKey 列表 bound: number, unbound: Array<{phaseNo, reason}>, diagnostics: Array<{linkId, matchedNodeKeys, distanceMeters, headingDeltaDegrees}> } ``` 算法: 1. 对每条 link:解析 `geom`,取末两点,**先 GCJ-02 → WGS84**,再算 `stopPoint` 与 `approachHeading = 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.json` 中 `headingDegrees === mastHeadingDegrees`(实测 7/7 相等, 如 `150.658`),即存的是 **mast 值**;`faceHeadingDegrees` 比它小 90(`60.658`)。因此: ``` travelHeading = (faceHeadingDegrees + 180) % 360 // 首选 = (mastHeadingDegrees + 90) % 360 // face 缺失时的等价回退 ``` 实测对全部 7 个信号自洽。 3. 候选条件:`haversine(stopPoint, signalStop) <= maxDistanceMeters` 且 `angleDelta(approachHeading, travelHeading) <= maxHeadingDeltaDegrees`。 4. 一条 link **可匹配多个** signal(同一进口道的多个灯头),满足 R4 的一半。 5. link 的 `phaseList` 中每个 `phase` → 该 link 匹配到的全部 nodeKey,**并集累加** (`Map>`),满足 R4 的另一半(同相位多进口道)。 6. 默认容差:`maxDistanceMeters = 30`、`maxHeadingDeltaDegrees = 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 直接用配置值,跳过几何匹配。这是几何匹配失败时的逃生舱,也让映射可人工固化。 > 取舍:不用 `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":""}` | `CrossTrafficLights3D.vue:129` | | obu | `bounds \|\| ""`(dashboard 传 `null` → 空串) | `useObuCars.ts:31` | | target | `{"deviceId":""}` 或 `{"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,与源项目一致 |