# Implement — 还原 V2X 实时路口信号灯与车辆展示 ## 参照实现(只读) `/Users/que01/Project/v2x_web/applications/dashboard/src/views/dashboard/components/Content/Map/HologramCross/` 改动前先读对应源文件,不要凭记忆写契约。 ## 改动文件 | 文件 | 性质 | | --- | --- | | `scripts/lib/v2x-cesium-overlay.js` | 主要改动,L1–L4 分层重写 | | `scripts/lib/cesium-preview.js` | 删除重复 `lampColorName`,调整 `setSignalState` 入参 | | `scripts/test-v2x-cesium-overlay.js` | 扩充单测,覆盖 AC2–AC7 | | `.trellis/tasks/.../prd.md` `design.md` | 如实现中发现偏差需回写 | 不改:`build-area.js`、`area-preview.js`、包产物生成逻辑。 ## 执行顺序 每步结束跑一次 `npm run test:v2x-cesium-preview`,保持绿色再进下一步。 ### 步骤 1 — L2 灯色字典 + 宽松解析(R2、R9) 1. 在 overlay 加 `LAMP_STATUS`(`11/21/22/23/31`)与 `lampColorName(status)`,未知返回 `"other"`。 2. 删除旧 `lampColor()` 中 `Number(status)===3 / ===2` 的判断。 3. 加 `parseLoosePayload(text)`:`JSON.parse` → 受限规范化重试 → `null`。 4. 心跳报文 `pong` 短路判定保留(与源项目一致)。 5. 二者导出到 `.utils`。 验证:`node scripts/test-v2x-cesium-overlay.js` - `lampColorName(21)==="red"`, `(22)==="yellow"`, `(23)==="green"`, `(11)==="off"`, `(31)==="other"` - **`lampColorName(2)!=="yellow"` 且 `lampColorName(3)!=="green"`**(防回归,AC2) - `parseLoosePayload("{'a':1,}")` 解析成功;`parseLoosePayload("{bad")===null`(AC7) **审查点 A**:灯色字典必须与 `HologramCross/components/utils.ts` 的 `lightStatusColorDict` 逐项一致,不得增删码值。 ### 步骤 2 — L1 几何工具(支撑 R3) 1. 加 `getAngle(start, end)`:直接对齐源项目 `utils.ts` 的实现(返回 0–360)。 2. 加 `haversineMeters(a, b)`、`angleDeltaDegrees(a, b)`(后者处理 350° vs 10° 的环绕)。 3. 导出到 `.utils`。 验证:正北 `getAngle([0,0],[0,1])≈0`;正东 `≈90`; `angleDeltaDegrees(350,10)===20`;`haversineMeters` 对已知两点误差 < 0.5m。 ### 步骤 3 — 相位映射(R3、R4) 1. 实现 `buildPhaseSignalMap(links, nativeSignals, options)`,返回 `{byPhase, bound, unbound, diagnostics}`,算法见 `design.md` §4.2。 2. link 末两点**先 GCJ-02→WGS84 再算角度与距离**(原生信号是 WGS84)。 3. 一 link 匹配多 signal;`Map>` 并集累加。 4. 支持 `options.phaseSignalMap` 配置覆盖,命中则跳过几何匹配。 5. 默认 `maxDistanceMeters=30`、`maxHeadingDeltaDegrees=45`。 6. 导出到 `.utils`。 验证(AC3、AC4): - 用 `outputs/fengshu-er-road/package/runtime/traffic-signals.json` 的真实 7 条信号做夹具, 配一组构造的 link(末点取自 `stopLongitude/stopLatitude` 反算 GCJ-02、 航向取自 `headingDegrees`),断言 `bound > 0`。 - 构造两条不同 link 共用同一 `phaseNo`,断言 `byPhase.get(phaseNo)` 含**两条 link 的全部 nodeKey**。 - 构造一条远离所有信号的 link,断言其相位进入 `unbound` 且带 `reason`。 - 配置覆盖生效:`phaseSignalMap` 指定的 phaseNo 取配置值。 **审查点 B**:这是全任务最易做错的一步。合入前必须用真实 `traffic-signals.json` 跑出 非零绑定数,若为 0 则说明容差或坐标方向反了,**不得靠放大容差蒙混过关**——先打印 `diagnostics` 的实际距离/角差,确认量级合理(距离应是米级而非千米级)。 ### 步骤 4 — 车辆注册表(R7、R8) 1. 实现 `createVehicleRegistry({now})`,含 `ingest / sweep / list / clear`。 2. `interval` 取自推送体,`0` 或缺失回退 `500`。 3. `sweep`:`|now - timeStamp| >= interval*1.5` → `visible=false`。 4. 槽位复用:优先占用同 `model` 的不可见记录。 5. `modelNameFor(vehicle)`:OBU → `car_obu.glb`;target → `${type}${subType}.glb`。 6. `normalizeTargetVehicles` 补上源项目的 `type==1 && isNil(subType) → subType=1`。 7. 导出 `createVehicleRegistry`、`modelNameFor`。 验证(AC5): - 注入一次推送 → `list()` 一条 `visible=true`。 - 假时钟推进 `interval*1.5` 后 `sweep()` → 该车 `visible=false`。 - 再注入同 key → 复用原槽位,`list().length` 不变。 - `clear()` 后为空。 - `modelNameFor({kind:"obu"})==="car_obu.glb"`;`{kind:"target",type:1,subType:2}` → `"12.glb"`。 ### 步骤 5 — WebSocket 连接层(R1、R6) 1. 实现 `createSocket({url, onOpen, onMessage, onClose, heartbeatMs, reconnect, socketFactory, setIntervalFn, setTimeoutFn})`。 2. 心跳 `{"heartBeat":"ping"}` / 30000ms。 3. 退避重连 `1s→2s→4s→8s` 封顶,**重连成功重放 `onOpen`**。 4. 主动 `close()` 不重连;`dispose()` 清所有计时器。 5. 三个 socket 的订阅帧按 `design.md` §4.3 的表格实现。 6. OBU bounds:`camera.computeViewRectangle()` → WGS84→GCJ-02 → `"lng,lat;…"` 四角, `camera.moveEnd` 节流发送;不可用时发空串。 7. 导出 `createSocket`、`buildBoundsMessage`。 验证(AC1、AC6): - 假 socket + 假时钟:推进 30s 断言收到一帧 `{"heartBeat":"ping"}`;推进 90s 断言三帧。 - 模拟 `onclose` 后推进退避时间,断言重新建连**且订阅帧被重放**。 - signal 首帧 `{"junctionId":""}`;target 无配置首帧 `{"deviceId":null}`, 有配置 `{"deviceId":"1,2"}`;obu 首帧为空串。 - `dispose()` 后推进时钟不再产生任何发送。 **审查点 C**:心跳报文与间隔必须逐字节对齐源项目 (`useObuCars.ts:28-31`、`useTargetCars.ts:27-30`、`CrossTrafficLights3D.vue:122-130`)。 ### 步骤 6 — 接线与渲染适配(R4、R5、R10) 1. `loadLiveData()` 移除 `FlowTravelRatio/queryListWeek`,删 `state.metrics` 与面板 flow 文案。 2. 用 `buildPhaseSignalMap` 结果替换 `state.linkPhases`(`Map` 整体删除)。 3. `updateSignalPhases` → 归一化为 `{nodeKeys, color, countDown}` 列表后调 `context.setSignalState(...)`,同时给 link polyline 上色(同相位多 link 全部上色)。 4. 车辆渲染改为消费 `registry`:`visible` 切 `entity.show`,位置用 `SampledPositionProperty` 按 `duration` 插值。 5. 面板状态行补:已绑定/未绑定相位数、缺失模型数、解析失败数。 6. `dispose()` 清 socket、计时器、实体、registry。 ### 步骤 7 — `cesium-preview.js` 对接(R2、R5) 1. 删除 `cesium-preview.js` 中的 `lampColorName()`(与 overlay 重复且映射错误)。 2. `createLiveTrafficSignals().update(lamps)` 改签名为 `update(entries)`,`entries = [{nodeKeys:string[], color:"red"|"yellow"|"green"|"off"|"other", countDown:number}]`。 3. 按 `nodeKeys` 直接点亮 `TrafficSignalDynamic__` 节点, 删除失效的 `signalByPhase` 回退逻辑。 4. `countDown` 驱动 `category === "countdown"` 资产显示。 5. `color === "off" | "other"` 时三色节点全灭。 验证:`npm run test:preview-assets` ### 步骤 8 — 全量校验 ```bash npm run test:v2x-cesium-preview npm run test:v2x-preview-server npm run test:preview-assets npm run test:traffic-signals npm run test:native-preview-traffic npm run test:package-contract ``` AC10 校验:将区域配置 `v2xPreview.enabled` 置 `false` 重新生成, 确认生成页无 V2X 面板且 `package/` 下产物字节不变。 ## 人工验收(AC9,需真实上游) ```bash V2X_UPSTREAM=http://: \ npm run serve:v2x-preview -- --root outputs/fengshu-er-road --host 0.0.0.0 --port 7862 ``` 登录后逐项确认: - [ ] 信号灯随相位变色,颜色与 dashboard 实时路口一致 - [ ] 倒计时数字走动 - [ ] 车辆出现、移动平滑、离开视野后消失(无幽灵车堆积) - [ ] 连接持续 5 分钟以上不掉线 - [ ] 车辆与路口位置无明显偏移(坐标未二次转换) - [ ] 面板绑定数、失败数符合预期 ## 回滚点 - 步骤 7 独立可 revert(只动 `cesium-preview.js`)。 - 步骤 1–6 全在 overlay 单文件内,`git checkout scripts/lib/v2x-cesium-overlay.js` 即回到当前状态。 - `v2xPreview.enabled=false` 是运行时总开关。 ## 不做(留待后续任务) - AMap 底图 / 矢量瓦片图层 - `CrossDevices/Model3D.vue` 3D 杆件模型(设备维持点位) - `useV2XEvents` 事件气泡