diff --git a/.trellis/spec/preview/index.md b/.trellis/spec/preview/index.md index d71faa8..3eeafc9 100644 --- a/.trellis/spec/preview/index.md +++ b/.trellis/spec/preview/index.md @@ -317,27 +317,56 @@ npm run build:area -- --config config/areas/.json --stages cesium ### 2. Signatures ```text +WS /network/ws/network/signal?authorization= WS /network/ws/network/obuPosition?authorization= WS /network/ws/network/targetPosition?authorization= ``` OBU 消息使用 `carCode|obuCode`、`lon`、`lat`、`angle`、`speed`;目标识别消息使用 `data[deviceId][]` 内的 `id`、`longitude`、`latitude`、`type`、`subType`、`angle`、`speed`。 +两类车辆消息的根部还带 `interval`(推送间隔,`0` 视为 `500`)。 +信号灯消息使用 `lamps[]` 内的 `phaseNo`、`status`、`countDown`。 ### 3. Contracts -- 两条流均为 GCJ-02,必须在实体创建前恰好调用一次 `gcj02ToWgs84`。 -- 本预览的 `routeName`、`vehicleModelName` 必须为 `null`,`vehicleModelNames` 必须为空; - 原生 preview 不得生成 `trafficSimulation` 描述符。 +- 两条车辆流均为 GCJ-02,必须在实体创建前恰好调用一次 `gcj02ToWgs84`。 +- 本预览的 `routeName` 必须为 `null`,且不得生成 `trafficSimulation` 描述符。 + 真正的不变量是**不得有生成路线或交通仿真**,而不是「没有车模型」—— + 实时 V2X 车辆需要打包的车模型才能渲染,故 `vehicleModelNames` 由 + `writeVehicleModel(area)` 正常写出(`build-area.js:writeCesiumPreview`), + `vehicleModelName` 取其首项。早期版本靠清空模型列表来阻止仿真车辆, + 该机制已不适用,不要再用它作为约束手段。 - 每辆实时车保留最多 24 个已转换的位置作为实际轨迹;轨迹不是推测路径。 +- **灯色码字典只有一份**,取自源看板 `HologramCross/components/utils.ts`: + `11=灭 21=红 22=黄 23=绿 31=其他`,未知码归入「其他」,**不得**落到红色。 + overlay 归一化为 `{nodeKeys, color, countDown}` 后单向传给 `cesium-preview.js`; + 预览层不得自带第二份字典。 +- **三条 socket 必须发心跳** `{"heartBeat":"ping"}` / 30000ms,并在断线后退避重连, + 且**重连后重放订阅帧**(signal 发 `junctionId`,obu 发 bounds,target 发 `deviceId`)。 + 缺心跳会被服务端断开,画面在一分钟后静默变空。 +- **相位到原生灯头靠几何绑定**:`traffic-signals.json` 无 `phaseNo`,用 link 末段停止线点与 + 航向匹配原生 `stopLongitude/stopLatitude` 与行车方向;行车方向 = + `faceHeadingDegrees + 180`(生成侧 `scripts/lib/traffic-signals.js` 定义 + `mast = travel - 90`、`face = travel + 180`)。 + 一相位可点亮多条进口道,一条 link 可点亮同进口道多个灯头,两侧都取并集。 + 未绑定相位须计数上报,可用 `v2xPreview.phaseSignalMap` 显式覆盖。 +- **车辆生命周期**:超过 `interval * 1.5` 未更新即隐藏(不删除),隐藏项作为同模型的可复用槽位; + socket 断开清空车辆。模型名沿用看板语义:OBU 为 `car_obu.glb`,目标车为 `${type}${subType}.glb`。 +- 车辆推送体解析必须容忍非严格 JSON(源看板用 `saferEval`),失败须计数并在面板可见, + **不得**静默丢弃;实现不得使用 `eval` / `new Function`。 +- 周流量比 `FlowTravelRatio/queryListWeek` **不属于**实时路口范围。 ### 4. Validation & Error Matrix | 条件 | 结果 | |---|---| | 未登录、令牌失效、REST/WS 不可用 | 静态路口继续显示,车辆层为空,并显示实时数据不可用状态 | -| 消息不是 JSON、心跳、坐标无效 | 忽略该消息,不创建车辆 | +| 消息不是 JSON、心跳、坐标无效 | 忽略该消息,不创建车辆;非心跳的解析失败须计数 | | 收到有效车辆坐标 | 创建或更新真实车辆与实际轨迹 | +| 车辆超过 `interval * 1.5` 未更新 | 隐藏该车并保留槽位,不得堆积幽灵车 | +| 相机视野变化 | 节流后向 obu socket 发送 GCJ-02 四角 `bounds` | +| 视野矩形不可用 | 发空帧(等于不过滤),不得跳过发送导致空场景 | +| 相位一个都没绑定 | 面板显性提示并建议配置 `phaseSignalMap` | ### 5. Good / Base / Bad Cases @@ -347,11 +376,15 @@ OBU 消息使用 `carCode|obuCode`、`lon`、`lat`、`angle`、`speed`;目标 ### 6. Tests Required -- `npm run test:v2x-cesium-preview`:校验两类消息解析与 GCJ-02 转换。 -- `npm run test:preview-assets`:断言两条 WebSocket 存在,且 runtime 不调用 - `addVehicleCruises`、不显示 simulation 诊断。 +- `npm run test:v2x-cesium-preview`:校验灯色字典(含 `2`/`3` 不再是黄/绿的回归断言)、 + 宽松解析、几何相位绑定(对真实 `traffic-signals.json` 须达成精确一对一)、 + 同相位多进口道并集、车辆超时与槽位复用、心跳间隔与重连重放订阅帧、bounds 报文。 +- `npm run test:preview-assets`:断言三条 WebSocket 存在,runtime 不调用 + `addVehicleCruises`、不显示 simulation 诊断,且预览层**不再自带** `lampColorName` + 或 `Number(status) === 3` 判断。 - 对目标区域运行 `npm run build:area -- --config config/areas/.json --stages preview`, - 检查 descriptor 中 route/vehicle 字段为空,且不存在 traffic-simulation 文件。 + 检查 descriptor 中 `routeName` 为空、不存在 traffic-simulation 文件, + 且 `vehicleModelNames` 已写出(实时车辆渲染需要)。 ### 7. Wrong vs Correct diff --git a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/check.jsonl b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/check.jsonl new file mode 100644 index 0000000..9330a81 --- /dev/null +++ b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/check.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/preview/index.md", "reason": "校验改动未违反预览层约束:零依赖、无构建步骤、浏览器原生语法、IIFE。"} +{"file": "docs/v2x-cesium-preview.md", "reason": "校验文档与实现一致:接口清单、坐标契约、配置项、回滚说明。"} +{"file": ".trellis/tasks/08-24-v2x-realtime-cross-fidelity/prd.md", "reason": "AC1-AC10 是验收依据,逐条核对。"} +{"file": ".trellis/tasks/08-24-v2x-realtime-cross-fidelity/design.md", "reason": "核对灯色字典、相位映射算法、心跳与订阅帧是否与源项目逐项对齐。"} diff --git a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/design.md b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/design.md new file mode 100644 index 0000000..d982987 --- /dev/null +++ b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/design.md @@ -0,0 +1,233 @@ +# 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):`modelNameFor(vehicle)` 返回 `car_obu.glb` 或 `${type}${subType}.glb`; +在包内 `vehicleModelNames` 中按文件名匹配,命中不了则回退 `vehicleModelNames[0]` 并累加 +`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,与源项目一致 | diff --git a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/implement.jsonl b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/implement.jsonl new file mode 100644 index 0000000..1e2aaed --- /dev/null +++ b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/implement.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/preview/index.md", "reason": "预览层契约:无构建步骤、零依赖 IIFE、参数经 window.OSM_ASSET_PREVIEW_CONFIG 注入。overlay 与 cesium-preview.js 的所有改动都受这些约束。"} +{"file": ".trellis/spec/pipeline/index.md", "reason": "确认 build-area.js 如何把 v2x-cesium-overlay.js 拷入产物,避免误增文件破坏包契约。"} +{"file": ".trellis/spec/config/index.md", "reason": "新增可选配置 v2xPreview.phaseSignalMap / maxDistanceMeters / maxHeadingDeltaDegrees 需符合区域配置约定。"} +{"file": "docs/v2x-cesium-preview.md", "reason": "现有 V2X 预览文档:配置项、坐标契约、代理要求、回滚方式。移除 FlowTravelRatio 与新增配置后需同步更新。"} diff --git a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/implement.md b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/implement.md new file mode 100644 index 0000000..c5b6477 --- /dev/null +++ b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/implement.md @@ -0,0 +1,171 @@ +# 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` 事件气泡 diff --git a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/prd.md b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/prd.md new file mode 100644 index 0000000..a3e1db8 --- /dev/null +++ b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/prd.md @@ -0,0 +1,99 @@ +# 还原 V2X 实时路口信号灯与车辆展示 + +## Goal + +把 `v2x_web` dashboard「实时路口」(`HologramCross`) 的实时数据行为完整搬到本项目的 Cesium 预览中,使 +`outputs//…-cesium-preview.html` 在登录后能持续、正确地显示 **信号灯相位灯色与倒计时** 和 +**OBU / 目标识别车辆**。 + +当前实现(Codex 移植)接口地址是对的,但数据处理与渲染层基本失真,实际效果是:灯永远红、车可能一辆都不出现、 +出现后也不消失、连接几十秒后被服务端断开。本任务修复这条链路。 + +## Source of Truth + +参照实现(只读,不修改): +`/Users/que01/Project/v2x_web/applications/dashboard/src/views/dashboard/components/Content/Map/HologramCross/` + +- `components/CrossTrafficLights3D.vue` — 信号灯:link 相位、WS 订阅、灯色/倒计时 +- `components/utils.ts` — 灯色状态字典、航向角计算 +- `components/CrossCars/hooks/useObuCars.ts` — OBU 车辆 +- `components/CrossCars/hooks/useTargetCars.ts` — 目标识别车辆 +- `components/CrossCars/type.ts` — 车辆数据结构 + +## Requirements + +### R1 WebSocket 连接层对齐 +- 三个 socket(`signal` / `obuPosition` / `targetPosition`)均按源项目发送心跳 + `{"heartBeat":"ping"}`,间隔 30000ms。 +- 断线自动重连,重连后重新发送各自的订阅消息(signal 发 `junctionId`,target 发 `deviceId`,obu 发 bounds)。 +- 页面 `visibilitychange` 回到可见时行为不得导致重复连接堆积;`dispose()` 必须清掉心跳与重连计时器。 + +### R2 信号灯灯色映射修正 +- 采用源项目 `utils.ts` 的状态字典:`11=灭灯 21=红 22=黄 23=绿 31=其他`。 +- 现有 `lampColor()`(overlay)与 `lampColorName()`(`cesium-preview.js`)中基于 `2`/`3` 的判断必须移除。 +- 未知状态码按「其他/灭灯」处理,不得静默落到红色。 + +### R3 相位到原生信号灯的映射 +- `traffic-signals.json` 中不存在 V2X `phaseNo`(只有 `phaseGroup: 0`),当前回退到 `signal.id` 导致永不匹配。 + 必须建立一条真实可用的 V2X `phaseNo` → 原生信号灯头的映射。 +- 映射结果必须可诊断:面板要能显示「N 个相位已绑定 / M 个未绑定」。 +- 允许区域配置提供显式覆盖映射。 + +### R4 一个相位可点亮多条进口道 +- 同一 `phaseNo` 对应的所有 link / 信号灯头都要被点亮,当前 `Map` 的覆盖行为必须消除。 + +### R5 倒计时 +- 使用推送中的 `countDown` 字段(源项目 `phaseValue['time'] = signal.countDown`),驱动预览中已有的 + countdown 资产或等效显示。 + +### R6 车辆订阅正确建立 +- OBU socket 连接后必须发送订阅消息(源项目 `send(bounds || '')`),相机视野变化时发送 + `{"bounds": ""}`。 +- target socket 连接后发送 `{"deviceId": ""}`,无配置时发送 `{"deviceId": null}`。 + +### R7 车辆生命周期 +- 按源项目实现超时隐藏:超过 `interval * 1.5` 未更新的车辆隐藏并可复用槽位;`interval` 取自推送体, + `0` 时回退 `500`。 +- socket 断开时清空车辆列表。 +- 车辆位置更新使用推送间隔做平滑插值,避免逐帧跳变。 + +### R8 车辆模型命名对齐 +- OBU 固定使用 `car_obu.glb` 语义的模型槽;目标识别车辆使用 `${type}${subType}.glb` 语义的模型槽。 +- 包内缺少对应模型时回退到默认车模型,并在面板提示,不得静默显示错误车型。 + +### R9 推送体解析健壮性 +- 源项目对两个车辆 socket 使用 `saferEval` 而非 `JSON.parse`,说明载荷不保证严格 JSON。 +- 解析必须容忍非严格 JSON,且解析失败要计数并在面板可见,不得整条静默丢弃。 + +### R10 范围纠正 +- `/facilities/api/FlowTravelRatio/queryListWeek`(周流量比)不属于「实时路口」,从实时加载链路移除。 + +## Non-Goals + +- 不移植 AMap 底图 / 矢量瓦片图层(`linkVectorHdMapTileUrl`)。 +- 不移植 `CrossDevices/Model3D.vue` 的 3D 杆件模型;设备维持点位显示(可后续独立任务)。 +- 不移植 `useV2XEvents` 的 V2X 事件气泡。 +- 不改变原生包产物(GLB / metadata / traffic-signals.json 的生成逻辑)。 + +## Constraints + +- 不得提交上游地址、账号、令牌、AMap key;`v2xPreview` 配置保持非敏感。 +- 令牌只存 `sessionStorage`,页面始终从登录门进入。 +- 坐标契约不变:V2X 数据 GCJ-02,进 Cesium 前转一次 WGS84;原生数据不得二次转换。 +- `v2xPreview.enabled` 默认 `false`,关闭时生成页不含 V2X 面板。 +- 生成的预览为静态页,无构建步骤,overlay 保持零依赖 IIFE。 + +## Acceptance Criteria + +- [ ] AC1 三个 socket 均可见 30s 心跳;模拟服务端在 60s+ 后连接仍存活。 +- [ ] AC2 单测覆盖灯色映射:`21→红 22→黄 23→绿 11→灭 31→其他`,且 `2`/`3` 不再被当作黄/绿。 +- [ ] AC3 单测覆盖相位映射:给定 V2X link 相位与 `traffic-signals.json`,输出的绑定数 > 0, + 未绑定项被明确列出。 +- [ ] AC4 单测覆盖同相位多进口道:一个 `phaseNo` 对应 2 条 link 时,两条都被点亮。 +- [ ] AC5 单测覆盖车辆超时:注入两次推送并推进时间超过 `interval*1.5`,车辆转为不可见。 +- [ ] AC6 单测覆盖 OBU/target 订阅消息:连接后发出的首帧内容与源项目一致。 +- [ ] AC7 单测覆盖非严格 JSON 推送体能被解析,且失败计数可读。 +- [ ] AC8 `npm run test:v2x-cesium-preview`、`test:v2x-preview-server`、`test:preview-assets` 全绿。 +- [ ] AC9 真机(`npm run serve:v2x-preview` 接真实上游)登录后可见:信号灯随相位变色并显示倒计时、 + 车辆出现并移动、离开后消失。此项由开发者人工确认。 +- [ ] AC10 `v2xPreview.enabled=false` 重新生成后,页面不含 V2X 面板,包产物字节不变。 diff --git a/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/task.json b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/task.json new file mode 100644 index 0000000..b451cfa --- /dev/null +++ b/.trellis/tasks/08-24-v2x-realtime-cross-fidelity/task.json @@ -0,0 +1,26 @@ +{ + "id": "v2x-realtime-cross-fidelity", + "name": "v2x-realtime-cross-fidelity", + "title": "还原 V2X 实时路口信号灯与车辆展示", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/docs/v2x-cesium-preview.md b/docs/v2x-cesium-preview.md index f08c6e7..a8abcc5 100644 --- a/docs/v2x-cesium-preview.md +++ b/docs/v2x-cesium-preview.md @@ -27,6 +27,28 @@ not commit a private upstream address, an account, a token, or an AMap key. `crossCode` is deployment configuration for the selected intersection. It is never shown or editable in the browser login panel. +### Optional phase binding overrides + +`traffic-signals.json` carries no V2X phase number, so live phases are bound to +native signal heads geometrically (see *Phase Binding* below). Three optional +keys tune or replace that binding: + +| Key | Default | Purpose | +| --- | --- | --- | +| `maxDistanceMeters` | `30` | Stop-line proximity tolerance. | +| `maxHeadingDeltaDegrees` | `45` | Approach heading tolerance. | +| `phaseSignalMap` | none | Explicit `{"": ["", …]}`; bypasses geometry for the listed phases. | + +Use `phaseSignalMap` when the V2X link geometry cannot be matched — the V2X +panel reports how many phases bound and how many did not. + +The page always starts at the login gate. After authentication it resolves the +current intersection with the same source-dashboard rule: request the HD-map +cross list and cross-device configuration list, prefer a configured cross, then +use the first available HD-map cross. Only after that resolution succeeds does +it issue the operational REST requests or open signal, OBU, and target-vehicle +WebSockets. + The browser sends `POST {apiBaseUrl}/facilities/api/sys/login` with `userName` and an MD5-hashed password, matching the source V2X dashboard. It holds the returned token in `sessionStorage` only, sends it as the raw `Authorization` @@ -36,12 +58,48 @@ signal WebSocket. Closing the tab clears the session token. ## Data Sources After sign-in the preview reads the selected intersection's links, pole -configuration, bound devices, device configuration, weekly traffic flow ratio, -and `/network/ws/network/signal` phase updates. It also subscribes to +configuration, bound devices, and device configuration, and subscribes to +`/network/ws/network/signal` for phase updates. It also subscribes to `/network/ws/network/obuPosition` and `/network/ws/network/targetPosition` for the only vehicle and vehicle-line sources. Failures are shown in the V2X panel; the static Cesium scene stays available and the vehicle layer stays empty. +All three sockets mirror the source dashboard's connection behaviour: a +`{"heartBeat":"ping"}` frame every 30 s, automatic reconnect with backoff, and +the subscription frame replayed on every reconnect. Without the heartbeat the +service drops the connection and the scene silently empties after a minute. + +Subscription frames match the dashboard exactly: + +| Socket | Frame on every (re)connect | +| --- | --- | +| `signal` | `{"junctionId":""}` | +| `obuPosition` | `{"bounds":""}`, or an empty frame when the camera rectangle is unavailable (no viewport filter) | +| `targetPosition` | `{"deviceId":""}`, or `{"deviceId":null}` when no targets are configured | + +## Phase Binding + +The runtime traffic-signal document has no V2X `phaseNo`, so phases are bound to +native signal heads by geometry: the V2X link's last segment ends at the stop +line, and each native signal records its own stop-line point and heading. The +generator in `scripts/lib/traffic-signals.js` derives `mast = travel - 90` and +`face = travel + 180` from the approach travel direction, so the travel heading +is recovered as `faceHeadingDegrees + 180`. + +One link may bind several heads on the same approach, and several links may +share one phase; both are unions, so a phase lights every approach it drives. +Unbound phases are counted in the panel — override them with `phaseSignalMap`. + +## Vehicle Lifecycle + +Vehicles follow the dashboard's rules: the push `interval` (`0` falls back to +500 ms) sets the animation duration, a vehicle not updated within +`interval * 1.5` is hidden, and hidden entries stay as reusable slots rather +than being removed. Models are named as the dashboard names them — +`car_obu.glb` for OBU vehicles and `${type}${subType}.glb` for recognized +targets — falling back to the first packaged model, with the shortfall reported +in the panel. + ## Coordinate Contract | Data | Coordinate system | Preview handling | @@ -56,9 +114,19 @@ directly to Cesium. Either error produces a visible intersection offset. ## Proxy Requirements Use an HTTPS reverse proxy which forwards the configured REST prefix and supports -WebSocket upgrade for the configured WS prefix. The generated preview is static, +WebSocket upgrade for the configured WS prefix. For development or a simple host +without Nginx, use the repository's proxy static server instead of +`python3 -m http.server`: + +```bash +V2X_UPSTREAM=http://: \ + npm run serve:v2x-preview -- --root outputs/fengshu-er-road --host 0.0.0.0 --port 7862 +``` + +It rewrites `/api/*` and `/websocket/*` before proxying, matching the source +dashboard's Vite proxy. The upstream address stays an environment value, not a +browser configuration or committed area file. The generated preview is static, so direct cross-origin calls are likely to fail CORS or expose an internal origin. -The V2X panel reports that condition while leaving native preview controls usable. ## Rollback diff --git a/package.json b/package.json index d157e16..d879b30 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "reference:junction": "node scripts/inspect-junction-reference.js", "road:check": "node scripts/check-native-roads.js", "road:workbench": "node scripts/road-workbench.js", + "serve:v2x-preview": "node scripts/v2x-preview-server.js", "test:road-workbench": "node scripts/test-road-workbench.js", "test:native-road": "node scripts/test-native-road.js", "test:gaode-junction-reference": "node scripts/test-gaode-junction-reference.js", @@ -28,6 +29,7 @@ "test:turn-lane-arrows": "node scripts/test-turn-lane-arrows.js", "test:traffic-signals": "node scripts/test-traffic-signals.js", "test:native-preview-traffic": "node scripts/test-native-preview-traffic.js", + "test:v2x-preview-server": "node scripts/test-v2x-preview-server.js", "test:v2x-cesium-preview": "node scripts/test-v2x-cesium-overlay.js", "render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js" }, diff --git a/scripts/build-area.js b/scripts/build-area.js index 96952aa..5aa4347 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -589,18 +589,18 @@ function writeCesiumPreview(area, roadProvider) { // Do not let a route from an earlier legacy preview survive into native output. fs.rmSync(area.outputs.vehicleRoute, { force: true }); } - const vehicleModelNames = []; + const vehicleModelNames = writeVehicleModel(area); writeCesiumPreviewSupportFiles(path.dirname(htmlPath)); const glbName = "package/manifest.json"; const metadataName = "package/manifest.json"; const routeName = vehicleRoute || routeArtifact ? previewRelativePath(area.outputs.areaDir, vehicleRoute ? area.outputs.vehicleRoute : routeArtifact) : null; - const vehicleModelName = null; - const descriptor = { routeName, vehicleModelName, vehicleModelNames, trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] }; + const vehicleModelName = vehicleModelNames[0] ? `_preview/${vehicleModelNames[0]}` : null; + const descriptor = { routeName, vehicleModelName, vehicleModelNames: vehicleModelNames.map((name) => `_preview/${name}`), trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] }; fs.mkdirSync(area.outputs.previewDir, { recursive: true }); fs.writeFileSync(area.outputs.previewDescriptor, `${JSON.stringify(descriptor, null, 2)}\n`); - fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames, "package/runtime/traffic-signals.json", "_preview/descriptor.json", area.v2xPreview)); + fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames.map((name) => `_preview/${name}`), "package/runtime/traffic-signals.json", "_preview/descriptor.json", area.v2xPreview)); console.log(`Cesium preview: ${htmlPath}`); const finished = Date.now(); writeStageManifest(area, { diff --git a/scripts/lib/area-preview.js b/scripts/lib/area-preview.js index 1e27b63..49935b2 100644 --- a/scripts/lib/area-preview.js +++ b/scripts/lib/area-preview.js @@ -37,8 +37,8 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a ${escapeHtml(areaId)} Cesium Preview - - + + diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 541e06f..1f8357e 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -54,11 +54,19 @@ setLoadingMessage("Loading model", config.glbName || ""); const assets = await loadSceneAssets(viewer, metadata, placement); const trafficStart = Cesium.JulianDate.now(); - const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart, assets); + const trafficSignals = createLiveTrafficSignals(signalData, assets); + hideUnconfirmedSignalAssets(assets); const cruise = createLiveVehicleState(); const cameras = createCameraPresets(viewer, metadata, placement, cruise); const v2xOverlay = typeof window.createV2xCesiumOverlay === "function" - ? window.createV2xCesiumOverlay({ viewer, metadata, placement, config }) + ? window.createV2xCesiumOverlay({ + viewer, + metadata, + placement, + config, + nativeSignals: (signalData && signalData.signals) || [], + setSignalState: (entries) => trafficSignals.update(entries), + }) : null; buildAssetToggles(assets); @@ -611,6 +619,78 @@ }; } + function hideUnconfirmedSignalAssets(assets) { + assets.filter((asset) => asset.category === "dynamic" || asset.category === "countdown") + .forEach((asset) => { if (asset.model) asset.model.show = false; }); + } + + // Driven entirely by live V2X lamp state. The overlay has already resolved + // phaseNo -> native node keys and normalized the lamp status, so this only + // paints; it never re-interprets status codes. + function createLiveTrafficSignals(signalData, assets) { + const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model); + const countdownModels = new Map(assets + .filter((asset) => asset.category === "countdown" && asset.model) + .map((asset) => [Number(asset.phaseGroup), asset.model])); + const signals = (signalData?.signals || []).filter((signal) => signal && signal.id); + const phaseGroupByNodeKey = new Map(signals.map((signal) => [String(signal.nodeKey || signal.id), Number(signal.phaseGroup)])); + const state = { live: false }; + + function paintLamp(nodeKey, color) { + let changed = false; + ["red", "yellow", "green"].forEach((lamp) => { + let node = null; + try { node = dynamic.model.getNode(`TrafficSignalDynamic_${nodeKey}_${lamp}`); } catch (_) { return; } + if (node && node.show !== (lamp === color)) { node.show = lamp === color; changed = true; } + }); + return changed; + } + + function paintCountdown(nodeKey, countDown, color) { + const model = countdownModels.get(phaseGroupByNodeKey.get(String(nodeKey))); + if (!model) return false; + // Only two digits are modelled; anything outside 00-19 shows nothing. + const visible = Number.isFinite(countDown) && countDown >= 0 && countDown < 20 + ? String(countDown).padStart(2, "0") + : null; + let changed = false; + for (let value = 0; value < 20; value += 1) { + const label = String(value).padStart(2, "0"); + let node = null; + try { node = model.getNode(`TrafficSignalDynamic_${nodeKey}_countdown_${label}`); } catch (_) { continue; } + if (node && node.show !== (label === visible)) { node.show = label === visible; changed = true; } + } + if (visible !== null && typeof signalBaseColor === "function") { + model.color = signalBaseColor(color); + model.colorBlendMode = Cesium.ColorBlendMode.REPLACE; + model.colorBlendAmount = 1.0; + } + return changed; + } + + function update(entries) { + if (!dynamic?.model) return; + const list = Array.isArray(entries) ? entries : []; + let changed = false; + let painted = 0; + list.forEach((entry) => { + const nodeKeys = Array.isArray(entry?.nodeKeys) ? entry.nodeKeys : []; + nodeKeys.forEach((nodeKey) => { + painted += 1; + if (paintLamp(nodeKey, entry.color)) changed = true; + if (paintCountdown(nodeKey, entry.countDown, entry.color)) changed = true; + }); + }); + // Only show the dynamic assembly once a lamp actually resolved to a head. + state.live = painted > 0; + dynamic.model.show = state.live; + countdownModels.forEach((model) => { model.show = state.live; }); + if (changed) dynamic.model.scene?.requestRender?.(); + } + + return { count: signals.length, state, update, set show(value) { if (dynamic?.model) dynamic.model.show = Boolean(value && state.live); } }; + } + function addTrafficSignals(viewer, signalData, start, assets) { const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model); const countdownModels = new Map(assets @@ -1416,8 +1496,9 @@ main().catch((error) => { console.error(error); - setStatus("Failed to load Cesium preview: " + error.message); + const detail = error && error.message ? error.message : String(error); + setStatus("Failed to load Cesium preview: " + detail); document.body.classList.add("scene-error"); - setLoadingMessage("Failed to load scene", error.message); + setLoadingMessage("Failed to load scene", detail + " (see Safari Develop > Show JavaScript Console)"); }); }()); diff --git a/scripts/lib/v2x-cesium-overlay.js b/scripts/lib/v2x-cesium-overlay.js index 63cd2d2..e8ea13d 100644 --- a/scripts/lib/v2x-cesium-overlay.js +++ b/scripts/lib/v2x-cesium-overlay.js @@ -11,37 +11,66 @@ if (!settings.enabled) return null; const state = { - token: sessionStorage.getItem(TOKEN_KEY) || "", + token: "", entities: [], - vehicles: new Map(), - linkPhases: new Map(), + linkEntitiesByPhase: new Map(), + phaseSignals: new Map(), + phaseBinding: { bound: 0, unbound: [], diagnostics: [] }, sockets: [], status: "Sign in to load live V2X data.", crossCode: settings.crossCode || "", - metrics: null, vehicleSource: "waiting", + parseFailures: 0, + missingModels: new Set(), }; + // This preview intentionally starts at the login gate. Do not revive a + // previous tab's token and begin REST/WebSocket traffic before sign-in. + sessionStorage.removeItem(TOKEN_KEY); const ui = buildUi(state); + state.vehicleModels = Array.isArray(context.config.vehicleModelNames) ? context.config.vehicleModelNames : []; + state.setSignalState = context.setSignalState; + state.nativeSignals = arrayValue(context.nativeSignals); + const registry = createVehicleRegistry({}); + state.registry = registry; + let sweepTimer = null; + let boundsTimer = null; + let obuSend = null; function setStatus(message) { state.status = message; ui.status.textContent = message; } + function statusDetail() { + const parts = []; + parts.push(`${state.phaseBinding.bound} phases bound`); + if (state.phaseBinding.unbound.length) parts.push(`${state.phaseBinding.unbound.length} unbound`); + if (state.missingModels.size) parts.push(`${state.missingModels.size} models missing`); + if (state.parseFailures) parts.push(`${state.parseFailures} unparsed pushes`); + return parts.join(", "); + } + function clearEntities() { state.entities.forEach((entity) => context.viewer.entities.remove(entity)); state.entities = []; - state.vehicles.forEach((vehicle) => { - context.viewer.entities.remove(vehicle.entity); - context.viewer.entities.remove(vehicle.trace); + registry.list().forEach((record) => { + if (record.entity) context.viewer.entities.remove(record.entity); + if (record.trace) context.viewer.entities.remove(record.trace); }); - state.vehicles.clear(); - state.linkPhases.clear(); + registry.clear(); + state.linkEntitiesByPhase.clear(); + state.phaseSignals.clear(); } function disconnect() { - state.sockets.forEach((socket) => socket.close()); + state.sockets.forEach((socket) => socket.dispose()); state.sockets = []; + if (sweepTimer !== null) { clearInterval(sweepTimer); sweepTimer = null; } + if (boundsTimer !== null) { clearTimeout(boundsTimer); boundsTimer = null; } + obuSend = null; + if (context.viewer?.camera?.moveEnd) { + try { context.viewer.camera.moveEnd.removeEventListener(onCameraMoveEnd); } catch (_) { /* not attached */ } + } } function useLiveVehicles() { @@ -81,7 +110,6 @@ throw new Error(body?.msg || body?.message || "V2X sign-in failed"); } state.token = token; - sessionStorage.setItem(TOKEN_KEY, token); ui.form.hidden = true; ui.live.hidden = false; await loadLiveData(); @@ -92,6 +120,9 @@ clearEntities(); state.vehicleSource = "waiting"; state.token = ""; + state.parseFailures = 0; + state.missingModels.clear(); + state.phaseBinding = { bound: 0, unbound: [], diagnostics: [] }; sessionStorage.removeItem(TOKEN_KEY); ui.form.hidden = false; ui.live.hidden = true; @@ -99,9 +130,9 @@ } async function loadLiveData() { - const crossCode = state.crossCode; + const crossCode = await resolveCrossCode(); if (!crossCode) { - setStatus("V2X intersection code is not configured."); + setStatus("No V2X intersection is configured for this account."); return; } clearEntities(); @@ -112,23 +143,44 @@ request(`/network/api/pole/network/queryPoles/${encodeURIComponent(crossCode)}`), request(`/facilities/api/crossDevice/findDeviceByCrossCode/${encodeURIComponent(crossCode)}`), request(`/facilities/api/crossDeviceConfig/${encodeURIComponent(crossCode)}`), - request(`/facilities/api/FlowTravelRatio/queryListWeek?code=${encodeURIComponent(crossCode)}`), ]); - const [links, poles, devices, deviceConfig, flow] = results; + const [links, poles, devices, deviceConfig] = results; const warnings = []; + const linkList = links.status === "fulfilled" ? arrayValue(links.value?.inLinkList) : []; if (links.status === "fulfilled") addLinks(context.viewer, links.value, state); else warnings.push("links unavailable"); if (devices.status === "fulfilled") addDevices(context.viewer, devices.value, state); else warnings.push("devices unavailable"); + + state.phaseBinding = buildPhaseSignalMap(linkList, state.nativeSignals, { + phaseSignalMap: settings.phaseSignalMap, + maxDistanceMeters: settings.maxDistanceMeters, + maxHeadingDeltaDegrees: settings.maxHeadingDeltaDegrees, + }); + state.phaseSignals = state.phaseBinding.byPhase; + if (!state.phaseBinding.bound && linkList.length) { + warnings.push("no phase bound to a native signal head; set v2xPreview.phaseSignalMap"); + } + const poleCount = poles.status === "fulfilled" ? arrayValue(poles.value?.posConfig || poles.value).length : 0; const deviceCount = devices.status === "fulfilled" ? arrayValue(devices.value).length : 0; const targetCount = deviceConfig.status === "fulfilled" ? arrayValue(deviceConfig.value?.deviceConfig?.target).length : 0; - state.metrics = flow.status === "fulfilled" ? flow.value : null; - if (flow.status !== "fulfilled") warnings.push("traffic metrics unavailable"); connectLiveSockets(deviceConfig.status === "fulfilled" ? deviceConfig.value : null); - const linkCount = links.status === "fulfilled" ? arrayValue(links.value?.inLinkList).length : 0; - const metricCount = arrayValue(state.metrics).length; - setStatus(`Live V2X: ${linkCount} links, ${deviceCount} devices, ${poleCount} poles, ${targetCount} configured targets, ${metricCount} flow metrics. GCJ-02 -> WGS84 once.${warnings.length ? ` ${warnings.join(", ")}.` : ""}`); + setStatus(`Live V2X: ${linkList.length} links, ${deviceCount} devices, ${poleCount} poles, ${targetCount} configured targets. ${statusDetail()}. GCJ-02 -> WGS84 once.${warnings.length ? ` ${warnings.join(", ")}.` : ""}`); + } + + async function resolveCrossCode() { + if (state.crossCode) return state.crossCode; + setStatus("Finding the configured V2X intersection..."); + const [crosses, configurations] = await Promise.allSettled([ + request("/cloud-display/api/search/crossList?hdMap=true"), + request("/facilities/api/crossDeviceConfig/queryList"), + ]); + state.crossCode = selectCrossCode( + crosses.status === "fulfilled" ? crosses.value : null, + configurations.status === "fulfilled" ? configurations.value : null, + ); + return state.crossCode; } function connectLiveSockets(deviceConfig) { @@ -136,44 +188,95 @@ connectSignalSocket(); connectObuSocket(); connectTargetSocket(deviceConfig); + // Stale vehicles must disappear even while pushes keep arriving for + // others, so sweep on a timer as well as on each push. + sweepTimer = setInterval(() => { + if (registry.sweep().length) renderVehicles(); + }, 500); } function openSocket(path, onOpen, onMessage, unavailable) { - const socketUrl = toWebSocketUrl(joinUrl(settings.wsBaseUrl || "/websocket", path), state.token); - try { - const socket = new WebSocket(socketUrl); - state.sockets.push(socket); - socket.onopen = () => onOpen?.(socket); - socket.onmessage = (event) => onMessage(event.data); - socket.onerror = () => setStatus(`${state.status} ${unavailable}.`); - return socket; - } catch (_) { - setStatus(`${state.status} ${unavailable}.`); - return null; - } + const socket = createSocket({ + url: () => toWebSocketUrl(joinUrl(settings.wsBaseUrl || "/websocket", path), state.token), + onOpen, + onMessage, + onError: () => setStatus(`${state.status} ${unavailable}.`), + }); + state.sockets.push(socket); + socket.open(); + return socket; } function connectSignalSocket() { - openSocket("/network/ws/network/signal", (socket) => socket.send(JSON.stringify({ junctionId: state.crossCode })), - (value) => updateSignalPhases(value, state), "Signal WebSocket unavailable"); + openSocket("/network/ws/network/signal", + ({ send }) => send(JSON.stringify({ junctionId: String(state.crossCode) })), + (value) => updateSignalPhases(value, state), + "Signal WebSocket unavailable"); + } + + function onCameraMoveEnd() { + if (boundsTimer !== null) clearTimeout(boundsTimer); + boundsTimer = setTimeout(() => { + boundsTimer = null; + const bounds = cameraBoundsMessage(); + if (obuSend && bounds) obuSend(JSON.stringify({ bounds })); + }, 300); + } + + function cameraBoundsMessage() { + try { + const rectangle = context.viewer?.camera?.computeViewRectangle?.(); + if (!rectangle) return ""; + return buildBoundsMessage({ + west: Cesium.Math.toDegrees(rectangle.west), + south: Cesium.Math.toDegrees(rectangle.south), + east: Cesium.Math.toDegrees(rectangle.east), + north: Cesium.Math.toDegrees(rectangle.north), + }); + } catch (_) { + return ""; + } } function connectObuSocket() { - openSocket("/network/ws/network/obuPosition", null, (value) => { - const vehicle = normalizeObuVehicle(value); - if (vehicle) updateLiveVehicle(context.viewer, vehicle, state); + const socket = openSocket("/network/ws/network/obuPosition", ({ send }) => { + obuSend = send; + // The dashboard sends `bounds || ''` on connect; an empty frame means + // "no viewport filter", which is the safe default for a tilted camera. + const bounds = cameraBoundsMessage(); + send(bounds ? JSON.stringify({ bounds }) : ""); + }, (value) => { + const push = normalizeObuPush(value); + if (!push.vehicle) { if (!isHeartbeat(value)) state.parseFailures += 1; return; } + useLiveVehicles(); + registry.ingest([push.vehicle], push.interval); + registry.sweep(); + renderVehicles(); }, "OBU vehicle WebSocket unavailable"); + if (context.viewer?.camera?.moveEnd) { + try { context.viewer.camera.moveEnd.addEventListener(onCameraMoveEnd); } catch (_) { /* no camera events */ } + } + return socket; } function connectTargetSocket(deviceConfig) { - openSocket("/network/ws/network/targetPosition", (socket) => { + openSocket("/network/ws/network/targetPosition", ({ send }) => { const targetIds = arrayValue(deviceConfig?.deviceConfig?.target); - socket.send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null })); + send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null })); }, (value) => { - normalizeTargetVehicles(value).forEach((vehicle) => updateLiveVehicle(context.viewer, vehicle, state)); + const push = normalizeTargetPush(value); + if (!push.vehicles.length) { if (!isHeartbeat(value) && parseLoosePayload(value) === null) state.parseFailures += 1; return; } + useLiveVehicles(); + registry.ingest(push.vehicles, push.interval); + registry.sweep(); + renderVehicles(); }, "Target vehicle WebSocket unavailable"); } + function renderVehicles() { + registry.list().forEach((record) => syncVehicleEntity(context.viewer, record, state)); + } + ui.form.addEventListener("submit", async (event) => { event.preventDefault(); try { @@ -186,12 +289,12 @@ ui.reload.addEventListener("click", () => loadLiveData().catch((error) => setStatus(error.message || "Live V2X data unavailable"))); ui.signOut.addEventListener("click", () => signOut("Signed out. Native preview remains available.")); - if (state.token) { - ui.form.hidden = true; - ui.live.hidden = false; - loadLiveData().catch((error) => setStatus(error.message || "Live V2X data unavailable")); - } - return { state, load: loadLiveData, signOut, dispose: () => { disconnect(); clearEntities(); state.vehicleSource = "waiting"; ui.root.remove(); } }; + return { + state, + load: loadLiveData, + signOut, + dispose: () => { disconnect(); clearEntities(); state.vehicleSource = "waiting"; ui.root.remove(); }, + }; } function buildUi(state) { @@ -225,7 +328,14 @@ polyline: { positions: Cesium.Cartesian3.fromDegreesArray(points), width: 5, material: Cesium.Color.CYAN.withAlpha(.78), clampToGround: false }, }); state.entities.push(entity); - arrayValue(link.phaseList).forEach((phase) => state.linkPhases.set(String(phase.phase), entity)); + // A phase can drive several approaches, so collect entities per phase + // instead of overwriting a single one. + arrayValue(link.phaseList).forEach((phase) => { + const key = String(phase.phase); + const existing = state.linkEntitiesByPhase.get(key) || []; + existing.push(entity); + state.linkEntitiesByPhase.set(key, existing); + }); }); } @@ -245,43 +355,318 @@ }); } - function updateLiveVehicle(viewer, vehicle, state) { - const [longitude, latitude] = gcj02ToWgs84([vehicle.longitude, vehicle.latitude]); + // Pick the packaged model whose file name matches the dashboard's naming + // (car_obu.glb / `${type}${subType}.glb`), falling back to the first model so + // a missing asset degrades to a visible vehicle rather than an invisible one. + function vehicleModelUri(record, state) { + const models = arrayValue(state.vehicleModels); + if (!models.length) return ""; + const wanted = record.model; + const match = models.find((name) => String(name).split("/").pop() === wanted); + if (!match) state.missingModels.add(wanted); + return new URL(match || models[0], window.location.href).href; + } + + function syncVehicleEntity(viewer, record, state) { + const [longitude, latitude] = gcj02ToWgs84([record.longitude, record.latitude]); if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return; - state.activateLiveVehicles?.(); const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, .65); - let record = state.vehicles.get(vehicle.id); - if (!record) { + if (!record.entity) { const tracePositions = [position]; - record = { - tracePositions, - entity: viewer.entities.add({ - name: vehicle.label, - position, - point: { pixelSize: 10, color: vehicle.kind === "obu" ? Cesium.Color.LIME : Cesium.Color.ORANGE, outlineColor: Cesium.Color.BLACK, outlineWidth: 1 }, - label: { text: vehicle.label, font: "11px sans-serif", fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, style: Cesium.LabelStyle.FILL_AND_OUTLINE, pixelOffset: new Cesium.Cartesian2(0, -16) }, - }), - trace: viewer.entities.add({ - name: `${vehicle.label} live trace`, - polyline: { positions: new Cesium.CallbackProperty(() => tracePositions, false), width: 3, material: vehicle.kind === "obu" ? Cesium.Color.LIME : Cesium.Color.ORANGE, arcType: Cesium.ArcType.NONE }, - }), - }; - state.vehicles.set(vehicle.id, record); + record.tracePositions = tracePositions; + record.renderedPosition = position; + record.targetPosition = position; + record.sampleStart = Date.now(); + // Interpolate between pushes so vehicles glide instead of teleporting. + const animated = new Cesium.CallbackProperty(() => { + const duration = Math.max(1, Number(record.duration) || 500); + const ratio = Math.min(1, (Date.now() - record.sampleStart) / duration); + return Cesium.Cartesian3.lerp(record.renderedPosition, record.targetPosition, ratio, new Cesium.Cartesian3()); + }, false); + record.entity = viewer.entities.add({ + name: record.label, + position: animated, + orientation: new Cesium.CallbackProperty( + () => vehicleOrientation(record.targetPosition, record.angle), false), + model: { + uri: vehicleModelUri(record, state), + scale: 0.9, + minimumPixelSize: 14, + maximumScale: 2.0, + show: arrayValue(state.vehicleModels).length > 0, + }, + label: { text: record.label, font: "11px sans-serif", fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, style: Cesium.LabelStyle.FILL_AND_OUTLINE, pixelOffset: new Cesium.Cartesian2(0, -16) }, + }); + record.trace = viewer.entities.add({ + name: `${record.label} live trace`, + polyline: { positions: new Cesium.CallbackProperty(() => record.tracePositions, false), width: 3, material: record.kind === "obu" ? Cesium.Color.LIME : Cesium.Color.ORANGE, arcType: Cesium.ArcType.NONE }, + }); } else { - record.entity.position = position; + record.renderedPosition = record.targetPosition || position; + record.targetPosition = position; + record.sampleStart = Date.now(); record.tracePositions.push(position); if (record.tracePositions.length > 24) record.tracePositions.shift(); + if (record.entity.label) record.entity.label.text = record.label; } + // Stale vehicles are hidden, not removed, so the slot stays reusable. + record.entity.show = record.visible; + record.trace.show = record.visible; + if (!record.visible) record.tracePositions.length = 0; + } + + function vehicleOrientation(position, angle) { + return Cesium.Transforms.headingPitchRollQuaternion( + position, + new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(Number(angle) || 0), 0, 0), + ); + } + + function selectCrossCode(crosses, configurations) { + const crossList = Array.isArray(crosses) ? crosses : Object.values(crosses || {}).flatMap(arrayValue); + const configuredCodes = new Set(arrayValue(configurations).map((item) => String(item.crossCode || "")).filter(Boolean)); + const configuredCross = crossList.find((cross) => configuredCodes.has(String(cross?.code || cross?.crossCode || ""))); + const selected = configuredCross || crossList[0]; + return String(selected?.code || selected?.crossCode || arrayValue(configurations)[0]?.crossCode || ""); } + function isHeartbeat(value) { + return typeof value === "string" && value.includes('"heartBeat":"pong"'); + } + + // The dashboard parses vehicle pushes with saferEval, so the payload is not + // guaranteed to be strict JSON. Retry once through a narrow relaxation + // instead of eval, which would open arbitrary code execution in the preview. + function relaxJson(text) { + return text + .replace(/'/g, "\"") + .replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, "$1\"$2\":") + .replace(/,\s*([}\]])/g, "$1") + .replace(/:\s*(NaN|-?Infinity)\s*([,}\]])/g, ": null$2"); + } + + function parseLoosePayload(value) { + if (typeof value !== "string" || isHeartbeat(value)) return null; + try { return JSON.parse(value); } catch (_) { /* retry relaxed */ } + try { return JSON.parse(relaxJson(value)); } catch (_) { return null; } + } + + // traffic-signals.json carries no V2X phaseNo (only phaseGroup), so the two + // sides share no identifier. The only reliable join is geometry: the V2X link + // ends at the stop line (CrossTrafficLights3D.vue takes the last two points), + // and each native signal records its own stop-line point and heading. + // + // Heading semantics are fixed by the generator in scripts/lib/traffic-signals.js: + // the road axis points from the stop line into the intersection (the travel + // direction), and it emits mast = travel - 90 and face = travel + 180. The + // runtime document stores the mast value in both headingDegrees and + // mastHeadingDegrees, so the approach travel direction is recovered as + // face + 180, or equivalently heading + 90. + function signalTravelHeading(signal) { + const face = Number(signal.faceHeadingDegrees); + if (Number.isFinite(face)) return (face + 180) % 360; + const mast = Number(signal.mastHeadingDegrees ?? signal.headingDegrees); + if (Number.isFinite(mast)) return (mast + 90) % 360; + return NaN; + } + + function linkStopGeometry(link) { + let geometry; + try { geometry = typeof link.geom === "string" ? JSON.parse(link.geom) : link.geom; } catch (_) { return null; } + const coordinates = arrayValue(geometry?.coordinates); + if (coordinates.length < 2) return null; + // Convert before measuring: native signals are WGS84, V2X links are GCJ-02. + const last = gcj02ToWgs84(coordinates[coordinates.length - 1]); + const previous = gcj02ToWgs84(coordinates[coordinates.length - 2]); + if (!Number.isFinite(last[0]) || !Number.isFinite(previous[0])) return null; + return { stopPoint: last, approachHeading: getAngle(previous, last) }; + } + + function buildPhaseSignalMap(links, nativeSignals, options) { + const settings = options || {}; + const maxDistanceMeters = Number.isFinite(settings.maxDistanceMeters) ? settings.maxDistanceMeters : 30; + const maxHeadingDeltaDegrees = Number.isFinite(settings.maxHeadingDeltaDegrees) ? settings.maxHeadingDeltaDegrees : 45; + const override = settings.phaseSignalMap || null; + const signals = arrayValue(nativeSignals).filter((signal) => signal && signal.nodeKey); + const byPhase = new Map(); + const diagnostics = []; + const unbound = []; + + function addPhase(phaseNo, nodeKeys) { + const key = String(phaseNo); + const existing = byPhase.get(key) || new Set(); + nodeKeys.forEach((nodeKey) => existing.add(nodeKey)); + byPhase.set(key, existing); + } + + arrayValue(links).forEach((link) => { + const phases = arrayValue(link.phaseList) + .map((entry) => entry?.phase) + .filter((phase) => phase !== undefined && phase !== null); + if (!phases.length) return; + const geometry = linkStopGeometry(link); + if (!geometry) { + phases.forEach((phase) => unbound.push({ phaseNo: String(phase), linkId: link.id, reason: "link geometry unusable" })); + return; + } + const matches = []; + signals.forEach((signal) => { + const signalPoint = [Number(signal.stopLongitude ?? signal.longitude), Number(signal.stopLatitude ?? signal.latitude)]; + if (!Number.isFinite(signalPoint[0]) || !Number.isFinite(signalPoint[1])) return; + const distanceMeters = haversineMeters(geometry.stopPoint, signalPoint); + if (distanceMeters > maxDistanceMeters) return; + const travelHeading = signalTravelHeading(signal); + const headingDeltaDegrees = angleDeltaDegrees(geometry.approachHeading, travelHeading); + if (!(headingDeltaDegrees <= maxHeadingDeltaDegrees)) return; + matches.push({ nodeKey: signal.nodeKey, distanceMeters, headingDeltaDegrees, travelHeading }); + }); + diagnostics.push({ + linkId: link.id, + approachHeading: geometry.approachHeading, + matchedNodeKeys: matches.map((match) => match.nodeKey), + nearestDistanceMeters: matches.length ? Math.min(...matches.map((match) => match.distanceMeters)) : null, + matches, + }); + // One link may light several heads on the same approach, and several + // links may share one phase; both unions are required. + if (matches.length) phases.forEach((phase) => addPhase(phase, matches.map((match) => match.nodeKey))); + else phases.forEach((phase) => unbound.push({ phaseNo: String(phase), linkId: link.id, reason: "no native signal within tolerance" })); + }); + + if (override && typeof override === "object") { + Object.keys(override).forEach((phaseNo) => { + const nodeKeys = arrayValue(override[phaseNo]).filter(Boolean).map(String); + if (!nodeKeys.length) return; + byPhase.set(String(phaseNo), new Set(nodeKeys)); + }); + } + + const overridden = override ? new Set(Object.keys(override).map(String)) : new Set(); + const resolved = new Map(); + byPhase.forEach((nodeKeys, phaseNo) => resolved.set(phaseNo, Array.from(nodeKeys))); + return { + byPhase: resolved, + bound: resolved.size, + unbound: unbound.filter((entry) => !resolved.has(entry.phaseNo) && !overridden.has(entry.phaseNo)), + diagnostics, + }; + } + function parseSocketJson(value) { - if (typeof value !== "string" || value.includes('"heartBeat":"pong"')) return null; - try { return JSON.parse(value); } catch (_) { return null; } + return parseLoosePayload(value); + } + + const HEARTBEAT_MESSAGE = JSON.stringify({ heartBeat: "ping" }); + const HEARTBEAT_INTERVAL_MS = 30000; + + // The dashboard uses useWebSocket with a 30s {"heartBeat":"ping"} and + // autoReconnect on all three sockets. Without both, the service drops the + // connection and the scene silently goes empty after a minute. + function createSocket(options) { + const settings = options || {}; + const resolveUrl = typeof settings.url === "function" ? settings.url : () => settings.url; + const factory = settings.socketFactory || ((url) => new WebSocket(url)); + const setIntervalFn = settings.setIntervalFn || ((fn, ms) => setInterval(fn, ms)); + const clearIntervalFn = settings.clearIntervalFn || ((handle) => clearInterval(handle)); + const setTimeoutFn = settings.setTimeoutFn || ((fn, ms) => setTimeout(fn, ms)); + const clearTimeoutFn = settings.clearTimeoutFn || ((handle) => clearTimeout(handle)); + const heartbeatMs = Number.isFinite(settings.heartbeatMs) ? settings.heartbeatMs : HEARTBEAT_INTERVAL_MS; + const maxBackoffMs = Number.isFinite(settings.maxBackoffMs) ? settings.maxBackoffMs : 8000; + const reconnect = settings.reconnect !== false; + + const state = { socket: null, heartbeat: null, retry: null, attempt: 0, disposed: false, closedByUs: false }; + + function stopHeartbeat() { + if (state.heartbeat === null) return; + clearIntervalFn(state.heartbeat); + state.heartbeat = null; + } + + function startHeartbeat() { + stopHeartbeat(); + if (!heartbeatMs) return; + state.heartbeat = setIntervalFn(() => send(HEARTBEAT_MESSAGE), heartbeatMs); + } + + function send(data) { + const socket = state.socket; + if (!socket) return false; + if (typeof socket.readyState === "number" && socket.readyState !== 1) return false; + try { socket.send(data); return true; } catch (_) { return false; } + } + + function scheduleReconnect() { + if (state.disposed || state.closedByUs || !reconnect) return; + const delay = Math.min(maxBackoffMs, 1000 * (2 ** state.attempt)); + state.attempt += 1; + state.retry = setTimeoutFn(() => { state.retry = null; open(); }, delay); + } + + function open() { + if (state.disposed) return null; + state.closedByUs = false; + let socket; + try { socket = factory(resolveUrl()); } catch (error) { + settings.onError?.(error); + scheduleReconnect(); + return null; + } + state.socket = socket; + socket.onopen = () => { + state.attempt = 0; + startHeartbeat(); + // Re-send the subscription frame on every (re)connect, matching the + // dashboard's onConnected. A reconnect without this receives nothing. + settings.onOpen?.({ send }); + }; + socket.onmessage = (event) => settings.onMessage?.(event?.data); + socket.onerror = (event) => settings.onError?.(event); + socket.onclose = (event) => { + stopHeartbeat(); + settings.onClose?.(event); + scheduleReconnect(); + }; + return socket; + } + + function close() { + state.closedByUs = true; + stopHeartbeat(); + if (state.retry !== null) { clearTimeoutFn(state.retry); state.retry = null; } + try { state.socket?.close(); } catch (_) { /* already closed */ } + state.socket = null; + } + + function dispose() { + state.disposed = true; + close(); + } + + return { open, close, dispose, send, get attempt() { return state.attempt; }, get socket() { return state.socket; } }; + } + + // Matches getMapBounds in CrossCars/index.vue: NW, NE, SE, SW joined by ';'. + // The camera reports WGS84 but the service filters GCJ-02, so convert. + function buildBoundsMessage(rectangle) { + if (!rectangle) return ""; + const west = Number(rectangle.west); + const south = Number(rectangle.south); + const east = Number(rectangle.east); + const north = Number(rectangle.north); + if (![west, south, east, north].every(Number.isFinite)) return ""; + const corners = [[west, north], [east, north], [east, south], [west, south]]; + return corners + .map((corner) => wgs84ToGcj02(corner).map((value) => Number(value.toFixed(8))).join(",")) + .join(";"); } function normalizeObuVehicle(value) { const source = parseSocketJson(value); + return obuVehicleFrom(source); + } + + function obuVehicleFrom(source) { const longitude = Number(source?.lon); const latitude = Number(source?.lat); const code = source?.carCode || source?.obuCode; @@ -289,34 +674,161 @@ return { id: `obu-${code}`, label: source.plateNumber || String(code), kind: "obu", longitude, latitude, angle: Number(source.angle) || 0, speed: Number(source.speed) || 0 }; } - function normalizeTargetVehicles(value) { + function normalizeObuPush(value) { const source = parseSocketJson(value); + return { interval: source?.interval, vehicle: obuVehicleFrom(source) }; + } + + function normalizeTargetVehicles(value) { + return targetVehiclesFrom(parseSocketJson(value)); + } + + function normalizeTargetPush(value) { + const source = parseSocketJson(value); + return { interval: source?.interval, vehicles: targetVehiclesFrom(source) }; + } + + function targetVehiclesFrom(source) { const devices = source?.data; if (!devices || typeof devices !== "object" || Array.isArray(devices)) return []; return Object.keys(devices).flatMap((deviceId) => arrayValue(devices[deviceId]).map((target) => { const longitude = Number(target?.longitude); const latitude = Number(target?.latitude); if (!target?.id || !Number.isFinite(longitude) || !Number.isFinite(latitude)) return null; + // Mirrors useTargetCars.ts: a type 1 target with no subType is subType 1. const type = target.type || 1; const subType = target.subType || 1; - return { id: `${deviceId}-${target.id}-${type}${subType}`, label: target.plate || `${deviceId}-${target.id}`, kind: "target", longitude, latitude, angle: Number(target.angle) || 0, speed: Number(target.speed) || 0 }; + return { id: `${deviceId}-${target.id}-${type}${subType}`, label: target.plate || `${deviceId}-${target.id}`, kind: "target", longitude, latitude, angle: Number(target.angle) || 0, speed: Number(target.speed) || 0, type, subType }; }).filter(Boolean)); } + // OBU vehicles use a fixed model; targets are keyed by type/subType. Mirrors + // useObuCars.ts ('car_obu.glb') and useTargetCars.ts (`${type}${subType}.glb`). + function modelNameFor(vehicle) { + if (!vehicle) return ""; + if (vehicle.kind === "obu") return "car_obu.glb"; + return `${vehicle.type || 1}${vehicle.subType || 1}.glb`; + } + + function resolvePushInterval(value) { + const parsed = Number(value); + return !Number.isFinite(parsed) || parsed === 0 ? 500 : parsed; + } + + // Vehicles are hidden rather than removed once a push goes stale, and hidden + // slots are reused, matching hideTimeoutObuCars / hideTimeoutTargetCars. + function createVehicleRegistry(options) { + const settings = options || {}; + const now = typeof settings.now === "function" ? settings.now : () => Date.now(); + const records = []; + const index = new Map(); + let interval = 1000; + + function ingest(vehicles, pushInterval) { + interval = resolvePushInterval(pushInterval); + const timeStamp = now(); + const summary = { added: 0, updated: 0, reused: 0 }; + arrayValue(vehicles).forEach((vehicle) => { + if (!vehicle || !vehicle.id) return; + const model = modelNameFor(vehicle); + const next = { ...vehicle, model, visible: true, timeStamp, duration: interval }; + const existing = index.get(vehicle.id); + if (existing) { + Object.assign(existing, next); + summary.updated += 1; + return; + } + const slot = records.find((record) => !record.visible && record.model === model); + if (slot) { + index.delete(slot.id); + Object.assign(slot, next); + index.set(vehicle.id, slot); + summary.reused += 1; + return; + } + const created = { ...next }; + records.push(created); + index.set(vehicle.id, created); + summary.added += 1; + }); + return summary; + } + + function sweep(atMs) { + const timeStamp = Number.isFinite(atMs) ? atMs : now(); + const hidden = []; + records.forEach((record) => { + if (!record.visible) return; + if (Math.abs(timeStamp - record.timeStamp) >= interval * 1.5) { + record.visible = false; + hidden.push(record.id); + } + }); + return hidden; + } + + function clear() { + records.length = 0; + index.clear(); + } + + return { + ingest, sweep, clear, + list: () => records.slice(), + get interval() { return interval; }, + get size() { return records.length; }, + }; + } + + // Normalize the push into {nodeKeys, color, countDown} before handing it to + // the preview, so the lamp dictionary lives in exactly one place. + function signalEntriesFrom(lamps, phaseSignals) { + return arrayValue(lamps).map((lamp) => { + const phaseNo = String(lamp?.phaseNo ?? lamp?.phase ?? ""); + return { + phaseNo, + nodeKeys: arrayValue(phaseSignals?.get?.(phaseNo)), + color: lampColorName(lamp?.status), + countDown: Number(lamp?.countDown), + }; + }).filter((entry) => entry.phaseNo); + } + function updateSignalPhases(value, state) { - let lamps; - try { lamps = JSON.parse(value).lamps; } catch (_) { return; } - arrayValue(lamps).forEach((lamp) => { - const entity = state.linkPhases.get(String(lamp.phaseNo)); - if (!entity?.polyline) return; - entity.polyline.material = lampColor(lamp.status).withAlpha(.9); + const payload = parseLoosePayload(value); + if (!payload) { + if (!isHeartbeat(value)) state.parseFailures += 1; + return; + } + const lamps = arrayValue(payload.lamps); + if (!lamps.length) return; + const entries = signalEntriesFrom(lamps, state.phaseSignals); + state.setSignalState?.(entries); + entries.forEach((entry) => { + // Every approach on this phase, not just the last one registered. + arrayValue(state.linkEntitiesByPhase.get(entry.phaseNo)).forEach((entity) => { + if (!entity?.polyline) return; + entity.polyline.material = lampCesiumColor(entry.color).withAlpha(.9); + }); }); } - function lampColor(status) { - if (String(status).toLowerCase().includes("green") || Number(status) === 3) return Cesium.Color.LIME; - if (String(status).toLowerCase().includes("yellow") || Number(status) === 2) return Cesium.Color.GOLD; - return Cesium.Color.RED; + // Lamp status codes come from the V2X dashboard's lightStatusColorDict + // (HologramCross/components/utils.ts). Do not reinterpret them as 1/2/3. + const LAMP_STATUS = { 11: "off", 21: "red", 22: "yellow", 23: "green", 31: "other" }; + + function lampColorName(status) { + return LAMP_STATUS[Number(status)] || "other"; + } + + function lampCesiumColor(color) { + switch (color) { + case "red": return Cesium.Color.fromCssColorString("#f45f5f"); + case "yellow": return Cesium.Color.fromCssColorString("rgb(238, 166, 12)"); + case "green": return Cesium.Color.fromCssColorString("rgb(8, 244, 8)"); + case "off": return Cesium.Color.fromCssColorString("#eeeeee"); + default: return Cesium.Color.GRAY; + } } function arrayValue(value) { return Array.isArray(value) ? value : []; } @@ -332,6 +844,51 @@ function transformLon(x, y) { let value = 300 + x + 2 * y + .1 * x * x + .1 * x * y + .1 * Math.sqrt(Math.abs(x)); value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3; value += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3; value += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3; return value; } function gcj02ToWgs84(coordinate) { const longitude = Number(coordinate[0]); const latitude = Number(coordinate[1]); if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return [NaN, NaN]; const dLat = transformLat(longitude - 105, latitude - 35); const dLon = transformLon(longitude - 105, latitude - 35); const radLat = latitude / 180 * PI; const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2; const sqrtMagic = Math.sqrt(magic); return [longitude - dLon * 180 / (EARTH_A / sqrtMagic * Math.cos(radLat) * PI), latitude - dLat * 180 / (EARTH_A * (1 - EARTH_EE) / (magic * sqrtMagic) * PI)]; } + // Forward transform, needed for the OBU bounds message: the service filters + // by GCJ-02, but the Cesium camera reports WGS84. + function wgs84ToGcj02(coordinate) { + const longitude = Number(coordinate[0]); + const latitude = Number(coordinate[1]); + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return [NaN, NaN]; + const dLat = transformLat(longitude - 105, latitude - 35); + const dLon = transformLon(longitude - 105, latitude - 35); + const radLat = latitude / 180 * PI; + const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2; + const sqrtMagic = Math.sqrt(magic); + return [ + longitude + dLon * 180 / (EARTH_A / sqrtMagic * Math.cos(radLat) * PI), + latitude + dLat * 180 / (EARTH_A * (1 - EARTH_EE) / (magic * sqrtMagic) * PI), + ]; + } + + // Bearing from north, 0-360. Mirrors HologramCross/components/utils.ts getAngle. + function getAngle(start, end) { + const rad = PI / 180; + const lat1 = Number(start[1]) * rad; + const lat2 = Number(end[1]) * rad; + const lon1 = Number(start[0]) * rad; + const lon2 = Number(end[0]) * rad; + const a = Math.sin(lon2 - lon1) * Math.cos(lat2); + const b = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1); + const degrees = (Math.atan2(a, b) % (2 * PI)) * 180 / PI; + return degrees > 0 ? degrees : degrees + 360; + } + + function angleDeltaDegrees(a, b) { + const delta = Math.abs(((Number(a) - Number(b)) % 360 + 540) % 360 - 180); + return Number.isFinite(delta) ? delta : NaN; + } + + function haversineMeters(a, b) { + const rad = PI / 180; + const lat1 = Number(a[1]) * rad; + const lat2 = Number(b[1]) * rad; + const dLat = lat2 - lat1; + const dLon = (Number(b[0]) - Number(a[0])) * rad; + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 6371008.8 * 2 * Math.asin(Math.min(1, Math.sqrt(h))); + } + // The V2X login contract uses MD5. Keep the implementation local so the // generated preview stays dependency-free and credentials are never sent plain. function md5(value) { @@ -375,6 +932,16 @@ function rotate(value, count) { return (value << count) | (value >>> (32 - count)); } function hex(value) { let output = ""; for (let index = 0; index < 4; index += 1) output += (`0${(value >>> (index * 8) & 255).toString(16)}`).slice(-2); return output; } - createV2xCesiumOverlay.utils = { gcj02ToWgs84, joinUrl, md5, toWebSocketUrl, normalizeObuVehicle, normalizeTargetVehicles }; + createV2xCesiumOverlay.utils = { + gcj02ToWgs84, wgs84ToGcj02, joinUrl, md5, toWebSocketUrl, + normalizeObuVehicle, normalizeObuPush, normalizeTargetVehicles, normalizeTargetPush, + selectCrossCode, + lampColorName, parseLoosePayload, isHeartbeat, signalEntriesFrom, + getAngle, angleDeltaDegrees, haversineMeters, + buildPhaseSignalMap, linkStopGeometry, signalTravelHeading, + createVehicleRegistry, modelNameFor, resolvePushInterval, + createSocket, buildBoundsMessage, + HEARTBEAT_MESSAGE, HEARTBEAT_INTERVAL_MS, + }; window.createV2xCesiumOverlay = createV2xCesiumOverlay; }()); diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index e88ae63..252f0f8 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -267,7 +267,7 @@ assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/); const previewRuntime = fs.readFileSync(path.join(__dirname, "lib", "cesium-preview.js"), "utf8"); const v2xRuntime = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.js"), "utf8"); const buildAreaSource = fs.readFileSync(path.join(__dirname, "build-area.js"), "utf8"); -assert.match(buildAreaSource, /const vehicleModelNames = \[\];/); +assert.match(buildAreaSource, /const vehicleModelNames = writeVehicleModel\(area\);/); assert.doesNotMatch(buildAreaSource, /buildNativeTrafficSimulation/); const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf"); assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned with the project"); @@ -281,8 +281,40 @@ assert.match(v2xRuntime, /GCJ-02 -> WGS84 once/); assert.match(v2xRuntime, /\/network\/ws\/network\/signal/); assert.match(v2xRuntime, /\/network\/ws\/network\/obuPosition/); assert.match(v2xRuntime, /\/network\/ws\/network\/targetPosition/); +assert.match(v2xRuntime, /headingPitchRollQuaternion/); +assert.match(v2xRuntime, /model:/); +assert.match(previewRuntime, /createLiveTrafficSignals\(/); +assert.doesNotMatch(previewRuntime, /const trafficSignals = addTrafficSignals\(/); + +// The lamp dictionary lives only in the overlay, which hands the preview +// already-normalized {nodeKeys, color, countDown} entries. Two copies of the +// dictionary drifted apart once and made every phase render red. +assert.doesNotMatch(previewRuntime, /function lampColorName/, + "the preview must not keep its own lamp status dictionary"); +assert.doesNotMatch(previewRuntime, /Number\(status\) === 3/, + "status 3 is not green; the real codes are 21/22/23"); +assert.match(previewRuntime, /entry\.nodeKeys/, + "live signals are addressed by resolved native node keys"); +assert.match(previewRuntime, /paintCountdown\(/, "countDown from the push drives the countdown assets"); +assert.match(previewRuntime, /nativeSignals: \(signalData && signalData\.signals\) \|\| \[\]/, + "the overlay needs the native signal list to bind phases"); + +// Overlay-side regressions: lamp codes, heartbeat, subscription frames, +// vehicle lifecycle and the removed out-of-scope request. +assert.match(v2xRuntime, /LAMP_STATUS = \{ 11: "off", 21: "red", 22: "yellow", 23: "green", 31: "other" \}/); +assert.doesNotMatch(v2xRuntime, /Number\(status\) === 3/); +assert.match(v2xRuntime, /heartBeat: "ping"/, "all sockets must heartbeat like the dashboard"); +assert.match(v2xRuntime, /HEARTBEAT_INTERVAL_MS = 30000/); +assert.match(v2xRuntime, /scheduleReconnect/, "a dropped socket must reconnect"); +assert.match(v2xRuntime, /buildBoundsMessage/, "the OBU socket must send a bounds frame"); +assert.match(v2xRuntime, /createVehicleRegistry/, "stale vehicles must be swept"); +assert.match(v2xRuntime, /linkEntitiesByPhase/, "one phase may light several approaches"); +assert.doesNotMatch(v2xRuntime, /linkPhases/, "the phase -> single entity map overwrote approaches"); +assert.doesNotMatch(v2xRuntime, /FlowTravelRatio/, + "weekly flow ratio is not part of the live intersection view"); assert.doesNotMatch(v2xRuntime, /Intersection code WGS84 once. +const roundTrip = gcj02ToWgs84(wgs84ToGcj02([114.12864875054062, 30.460485279762146])); +assert.ok(haversineMeters([114.12864875054062, 30.460485279762146], roundTrip) < 2); +assert.deepEqual(wgs84ToGcj02([NaN, 30]), [NaN, NaN]); + +assert.equal(selectCrossCode({ west: [{ code: "first" }, { code: "configured" }] }, [{ crossCode: "configured" }]), "configured"); +assert.equal(selectCrossCode([{ code: "first" }], []), "first"); + +// --- AC2: lamp status dictionary ------------------------------------------- +// Codes come from HologramCross/components/utils.ts lightStatusColorDict. +assert.equal(lampColorName(11), "off"); +assert.equal(lampColorName(21), "red"); +assert.equal(lampColorName(22), "yellow"); +assert.equal(lampColorName(23), "green"); +assert.equal(lampColorName(31), "other"); +assert.equal(lampColorName("23"), "green", "string codes resolve too"); +// Regression guard: the previous port read 2/3 as yellow/green, which made +// every real 21/22/23 push fall through to red. +assert.notEqual(lampColorName(2), "yellow"); +assert.notEqual(lampColorName(3), "green"); +assert.equal(lampColorName(2), "other"); +assert.equal(lampColorName(3), "other"); +assert.equal(lampColorName(undefined), "other", "unknown codes never default to red"); +assert.equal(lampColorName(999), "other"); + +// --- AC7: loose payload parsing -------------------------------------------- +assert.deepEqual(parseLoosePayload('{"a":1}'), { a: 1 }); +assert.deepEqual(parseLoosePayload("{a:1,b:'x',}"), { a: 1, b: "x" }); +assert.deepEqual(parseLoosePayload('{"a":NaN}'), { a: null }); +assert.equal(parseLoosePayload("{bad"), null); +assert.equal(parseLoosePayload(null), null); +assert.ok(isHeartbeat('{"heartBeat":"pong"}')); +assert.equal(parseLoosePayload('{"heartBeat":"pong"}'), null, "heartbeats are not payloads"); + +// --- geometry helpers ------------------------------------------------------- +assert.ok(Math.abs(angleDeltaDegrees(350, 10) - 20) < 1e-9); +assert.ok(Math.abs(angleDeltaDegrees(10, 350) - 20) < 1e-9); +assert.ok(Math.abs(angleDeltaDegrees(0, 180) - 180) < 1e-9); +assert.ok(Math.abs(getAngle([0, 0], [1, 0]) - 90) < 1e-6, "due east is 90"); +assert.ok(Math.abs(haversineMeters([114, 30], [114, 30.001]) - 111.2) < 0.5); +assert.equal(haversineMeters([114, 30], [114, 30]), 0); + +// --- AC3/AC4: phase to native signal mapping -------------------------------- +const nativeSignals = JSON.parse(fs.readFileSync( + path.join(__dirname, "..", "outputs", "fengshu-er-road", "package", "runtime", "traffic-signals.json"), "utf8")).signals; +assert.ok(nativeSignals.length >= 4, "fixture needs several native signals"); + +// The generator emits mast = travel - 90 and face = travel + 180 +// (scripts/lib/traffic-signals.js), so travel is recoverable from either. +nativeSignals.forEach((signal) => { + const fromFace = signalTravelHeading(signal); + const fromMast = signalTravelHeading({ mastHeadingDegrees: signal.mastHeadingDegrees }); + assert.ok(angleDeltaDegrees(fromFace, fromMast) < 1e-6, "face and mast agree on travel heading"); +}); + +// Build a V2X link whose last segment approaches the given native signal. +function linkForSignal(signal, id, phase) { + const stop = wgs84ToGcj02([signal.stopLongitude, signal.stopLatitude]); + const heading = signalTravelHeading(signal) * Math.PI / 180; + const step = 0.0002; + const previous = [stop[0] - Math.sin(heading) * step, stop[1] - Math.cos(heading) * step]; + return { id, geom: JSON.stringify({ type: "LineString", coordinates: [previous, stop] }), phaseList: [{ phase }] }; +} + +const oneToOne = buildPhaseSignalMap(nativeSignals.map((signal, index) => linkForSignal(signal, `L${index}`, index + 1)), nativeSignals, {}); +assert.equal(oneToOne.bound, nativeSignals.length, "every phase binds"); +assert.equal(oneToOne.unbound.length, 0); +oneToOne.diagnostics.forEach((entry, index) => { + assert.deepEqual(entry.matchedNodeKeys, [nativeSignals[index].nodeKey], + `link ${entry.linkId} must bind only its own approach, got ${entry.matchedNodeKeys.join()}`); + assert.ok(entry.nearestDistanceMeters < 5, "stop points are metres apart, not kilometres"); +}); + +// AC4: one phase shared by two approaches lights both. +const shared = buildPhaseSignalMap([linkForSignal(nativeSignals[0], "A", 7), linkForSignal(nativeSignals[3], "B", 7)], nativeSignals, {}); +assert.deepEqual(shared.byPhase.get("7").slice().sort(), [nativeSignals[0].nodeKey, nativeSignals[3].nodeKey].sort()); + +// A link nowhere near the intersection is reported, not silently dropped. +const stray = { id: "far", geom: JSON.stringify({ type: "LineString", coordinates: [[113, 29], [113.001, 29.001]] }), phaseList: [{ phase: 9 }] }; +const strayResult = buildPhaseSignalMap([stray], nativeSignals, {}); +assert.equal(strayResult.bound, 0); +assert.equal(strayResult.unbound.length, 1); +assert.equal(strayResult.unbound[0].phaseNo, "9"); +assert.ok(strayResult.unbound[0].reason); + +// Config override is the escape hatch when geometry cannot bind. +const overridden = buildPhaseSignalMap([stray], nativeSignals, { phaseSignalMap: { 9: ["ts_forced"] } }); +assert.deepEqual(overridden.byPhase.get("9"), ["ts_forced"]); +assert.equal(overridden.unbound.length, 0); + +// Unusable geometry is reported rather than throwing. +const brokenGeom = buildPhaseSignalMap([{ id: "x", geom: "{not json", phaseList: [{ phase: 5 }] }], nativeSignals, {}); +assert.equal(brokenGeom.unbound[0].reason, "link geometry unusable"); + +// --- signal entries handed to the preview ----------------------------------- +const entries = signalEntriesFrom( + [{ phaseNo: 1, status: 23, countDown: 12 }, { phaseNo: 2, status: 21, countDown: 4 }], + new Map([["1", ["nodeA", "nodeB"]], ["2", ["nodeC"]]]), +); +assert.deepEqual(entries, [ + { phaseNo: "1", nodeKeys: ["nodeA", "nodeB"], color: "green", countDown: 12 }, + { phaseNo: "2", nodeKeys: ["nodeC"], color: "red", countDown: 4 }, +]); +assert.deepEqual(signalEntriesFrom([{ phaseNo: 8, status: 22 }], new Map()), + [{ phaseNo: "8", nodeKeys: [], color: "yellow", countDown: NaN }], + "an unbound phase still reports its colour"); + +// --- vehicle normalization -------------------------------------------------- assert.deepEqual(normalizeObuVehicle(JSON.stringify({ carCode: "car-7", plateNumber: "A12345", lon: 114.12865, lat: 30.46049, angle: 90, speed: 12 })), { id: "obu-car-7", label: "A12345", kind: "obu", longitude: 114.12865, latitude: 30.46049, angle: 90, speed: 12, }); -assert.deepEqual(normalizeTargetVehicles(JSON.stringify({ data: { "8": [{ id: 9, longitude: 114.12866, latitude: 30.4605, type: 1, subType: 2, angle: 180, speed: 5 }] } })), [{ - id: "8-9-12", label: "8-9", kind: "target", longitude: 114.12866, latitude: 30.4605, angle: 180, speed: 5, +assert.equal(normalizeObuVehicle("{bad json"), null); +const obuPush = normalizeObuPush(JSON.stringify({ carCode: "c1", lon: 114.1, lat: 30.4, interval: 800 })); +assert.equal(obuPush.interval, 800); +assert.equal(obuPush.vehicle.id, "obu-c1"); + +// type/subType survive normalization because the model name is built from them. +assert.deepEqual(normalizeTargetVehicles(JSON.stringify({ data: { 8: [{ id: 9, longitude: 114.12866, latitude: 30.4605, type: 1, subType: 2, angle: 180, speed: 5 }] } })), [{ + id: "8-9-12", label: "8-9", kind: "target", longitude: 114.12866, latitude: 30.4605, angle: 180, speed: 5, type: 1, subType: 2, }]); -assert.equal(normalizeObuVehicle('{bad json'), null); +const targetPush = normalizeTargetPush(JSON.stringify({ interval: 0, data: { 8: [{ id: 9, longitude: 114.1, latitude: 30.4, type: 1 }] } })); +assert.equal(targetPush.vehicles[0].subType, 1, "type 1 without subType defaults to subType 1"); +assert.equal(resolvePushInterval(targetPush.interval), 500, "interval 0 falls back to 500"); +assert.equal(resolvePushInterval(undefined), 500); +assert.equal(resolvePushInterval(900), 900); + +// --- AC8: model naming matches the dashboard -------------------------------- +assert.equal(modelNameFor({ kind: "obu" }), "car_obu.glb"); +assert.equal(modelNameFor({ kind: "target", type: 1, subType: 2 }), "12.glb"); +assert.equal(modelNameFor({ kind: "target" }), "11.glb"); + +// --- AC5: vehicle lifecycle ------------------------------------------------- +let clock = 1000; +const registry = createVehicleRegistry({ now: () => clock }); +registry.ingest([{ id: "v1", kind: "target", type: 1, subType: 1 }], 1000); +assert.equal(registry.list().length, 1); +assert.equal(registry.list()[0].visible, true); +assert.equal(registry.list()[0].duration, 1000); +assert.deepEqual(registry.sweep(clock + 1499), [], "still fresh below interval * 1.5"); +assert.deepEqual(registry.sweep(clock + 1500), ["v1"], "hidden at interval * 1.5"); +assert.equal(registry.list()[0].visible, false); +assert.equal(registry.list().length, 1, "hidden vehicles are kept as reusable slots, not removed"); + +clock = 5000; +registry.ingest([{ id: "v2", kind: "target", type: 1, subType: 1 }], 1000); +assert.equal(registry.list().length, 1, "a hidden slot of the same model is reused"); +assert.equal(registry.list()[0].id, "v2"); +assert.equal(registry.list()[0].visible, true); + +registry.ingest([{ id: "v3", kind: "obu" }], 1000); +assert.equal(registry.list().length, 2, "a different model never steals another model's slot"); +registry.ingest([{ id: "v2", kind: "target", type: 1, subType: 1 }], 1000); +assert.equal(registry.list().length, 2, "re-ingesting a known id updates in place"); +registry.clear(); +assert.equal(registry.list().length, 0); + +// --- AC1/AC6: socket heartbeat, subscription frames, reconnect -------------- +assert.equal(HEARTBEAT_MESSAGE, '{"heartBeat":"ping"}'); +assert.equal(HEARTBEAT_INTERVAL_MS, 30000); + +function fakeClock() { + const intervals = []; + const timeouts = []; + return { + intervals, timeouts, + setIntervalFn: (fn, ms) => intervals.push({ fn, ms }) - 1, + clearIntervalFn: (handle) => { intervals[handle] = null; }, + setTimeoutFn: (fn, ms) => timeouts.push({ fn, ms }) - 1, + clearTimeoutFn: (handle) => { timeouts[handle] = null; }, + pendingIntervals: () => intervals.filter(Boolean), + pendingTimeouts: () => timeouts.filter(Boolean), + }; +} + +const clockStub = fakeClock(); +const sent = []; +let built = 0; +let live = null; +const socket = createSocket({ + url: () => "wss://preview.example.test/websocket/network/ws/network/signal", + setIntervalFn: clockStub.setIntervalFn, + clearIntervalFn: clockStub.clearIntervalFn, + setTimeoutFn: clockStub.setTimeoutFn, + clearTimeoutFn: clockStub.clearTimeoutFn, + socketFactory: () => { built += 1; live = { readyState: 1, send: (data) => sent.push(data), close() {} }; return live; }, + onOpen: ({ send }) => send(JSON.stringify({ junctionId: "420100023333" })), +}); +socket.open(); +live.onopen(); +assert.deepEqual(sent, ['{"junctionId":"420100023333"}'], "subscription frame is sent on connect"); +assert.equal(clockStub.pendingIntervals()[0].ms, 30000, "heartbeat runs at the dashboard's 30s"); +clockStub.pendingIntervals()[0].fn(); +clockStub.pendingIntervals()[0].fn(); +assert.deepEqual(sent.slice(1), [HEARTBEAT_MESSAGE, HEARTBEAT_MESSAGE]); + +live.onclose({}); +assert.equal(clockStub.pendingTimeouts().length, 1, "a drop schedules a reconnect"); +assert.equal(clockStub.pendingTimeouts()[0].ms, 1000, "first backoff is 1s"); +sent.length = 0; +clockStub.pendingTimeouts()[0].fn(); +live.onopen(); +assert.equal(built, 2, "the socket reconnected"); +assert.deepEqual(sent, ['{"junctionId":"420100023333"}'], "the subscription is replayed after reconnect"); + +socket.dispose(); +const timeoutsBeforeClose = clockStub.pendingTimeouts().length; +live.onclose({}); +assert.equal(clockStub.pendingTimeouts().length, timeoutsBeforeClose, "a disposed socket never reconnects"); + +// Target subscription frames match useTargetCars.ts. +function subscriptionFrame(onOpen) { + const frames = []; + onOpen({ send: (data) => frames.push(data) }); + return frames; +} +assert.deepEqual(subscriptionFrame(({ send }) => send(JSON.stringify({ deviceId: null }))), ['{"deviceId":null}']); +assert.deepEqual(subscriptionFrame(({ send }) => send(JSON.stringify({ deviceId: [1, 2].join(",") }))), ['{"deviceId":"1,2"}']); + +// --- OBU bounds frame ------------------------------------------------------- +const bounds = buildBoundsMessage({ west: 114.12, south: 30.46, east: 114.14, north: 30.47 }); +const corners = bounds.split(";"); +assert.equal(corners.length, 4, "NW, NE, SE, SW"); +corners.forEach((corner) => assert.equal(corner.split(",").length, 2)); +// Corners are converted to GCJ-02 because the service filters in that frame. +assert.notEqual(Number(corners[0].split(",")[0]), 114.12); +assert.equal(buildBoundsMessage(null), ""); +assert.equal(buildBoundsMessage({ west: NaN, south: 30.46, east: 114.14, north: 30.47 }), ""); + +// --- the overlay stays opt-in ---------------------------------------------- +assert.equal(context.window.createV2xCesiumOverlay({ config: {} }), null, "disabled by default"); +assert.equal(context.window.createV2xCesiumOverlay({ config: { v2xPreview: { enabled: false } } }), null); console.log("V2X Cesium overlay tests passed."); diff --git a/scripts/test-v2x-preview-server.js b/scripts/test-v2x-preview-server.js new file mode 100644 index 0000000..2937d39 --- /dev/null +++ b/scripts/test-v2x-preview-server.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("assert"); +const http = require("http"); +const path = require("path"); +const { createV2xPreviewServer, isProxyPath, upstreamPath } = require("./v2x-preview-server"); + +async function listen(server) { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return server.address().port; +} + +async function request(port, pathName, options = {}) { + return new Promise((resolve, reject) => { + const request = http.request({ hostname: "127.0.0.1", port, path: pathName, method: options.method || "GET", headers: options.headers }, (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { body += chunk; }); + response.on("end", () => resolve({ status: response.statusCode, body, headers: response.headers })); + }); + request.on("error", reject); + request.end(options.body); + }); +} + +(async () => { + assert.equal(isProxyPath("/api/facilities/api/sys/login"), true); + assert.equal(isProxyPath("/websocket/network/ws/network/obuPosition"), true); + assert.equal(isProxyPath("/package/manifest.json"), false); + assert.equal(upstreamPath("/api/facilities/api/sys/login?x=1"), "/facilities/api/sys/login?x=1"); + assert.equal(upstreamPath("/websocket/network/ws/network/signal"), "/network/ws/network/signal"); + + const upstream = http.createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { body += chunk; }); + req.on("end", () => { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ method: req.method, path: req.url, body })); + }); + }); + const upstreamPort = await listen(upstream); + const preview = createV2xPreviewServer({ + root: path.join(__dirname, "..", "outputs", "fengshu-er-road"), + upstream: `http://127.0.0.1:${upstreamPort}`, + }); + const previewPort = await listen(preview); + try { + const proxied = await request(previewPort, "/api/facilities/api/sys/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: '{"userName":"test"}', + }); + assert.equal(proxied.status, 200); + assert.deepEqual(JSON.parse(proxied.body), { method: "POST", path: "/facilities/api/sys/login", body: '{"userName":"test"}' }); + const staticPage = await request(previewPort, "/fengshu-er-road-cesium-preview.html"); + assert.equal(staticPage.status, 200); + assert.match(staticPage.body, /Cesium Preview/); + } finally { + await Promise.all([new Promise((resolve) => preview.close(resolve)), new Promise((resolve) => upstream.close(resolve))]); + } + console.log("V2X preview server tests passed."); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/v2x-preview-server.js b/scripts/v2x-preview-server.js new file mode 100644 index 0000000..95d9c6a --- /dev/null +++ b/scripts/v2x-preview-server.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const http = require("http"); +const https = require("https"); +const path = require("path"); + +const MIME_TYPES = { + ".css": "text/css; charset=utf-8", + ".glb": "model/gltf-binary", + ".gltf": "model/gltf+json", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".ttf": "font/ttf", + ".wasm": "application/wasm", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", +}; + +function parseArgs(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!argument.startsWith("--")) continue; + const key = argument.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${argument}`); + values[key] = value; + index += 1; + } + return values; +} + +function createV2xPreviewServer({ root, upstream }) { + const staticRoot = path.resolve(root); + const upstreamUrl = new URL(upstream); + if (!fs.statSync(staticRoot).isDirectory()) throw new Error(`Preview root is not a directory: ${staticRoot}`); + if (!/^https?:$/.test(upstreamUrl.protocol)) throw new Error("V2X upstream must use http or https"); + + const server = http.createServer((request, response) => { + if (isProxyPath(request.url)) { + proxyHttpRequest(request, response, upstreamUrl); + return; + } + serveStaticFile(request, response, staticRoot); + }); + + server.on("upgrade", (request, socket, head) => { + if (!isProxyPath(request.url)) { + socket.destroy(); + return; + } + proxyWebSocket(request, socket, head, upstreamUrl); + }); + return server; +} + +function isProxyPath(url) { + const pathname = new URL(url, "http://preview.local").pathname; + return pathname === "/api" || pathname.startsWith("/api/") || + pathname === "/websocket" || pathname.startsWith("/websocket/"); +} + +function upstreamPath(url) { + const parsed = new URL(url, "http://preview.local"); + const pathname = parsed.pathname.replace(/^\/(api|websocket)(?=\/|$)/, "") || "/"; + return `${pathname}${parsed.search}`; +} + +function upstreamRequestOptions(request, upstreamUrl) { + return { + protocol: upstreamUrl.protocol, + hostname: upstreamUrl.hostname, + port: upstreamUrl.port || undefined, + method: request.method, + path: upstreamPath(request.url), + headers: { ...request.headers, host: upstreamUrl.host }, + }; +} + +function proxyHttpRequest(request, response, upstreamUrl) { + const client = upstreamUrl.protocol === "https:" ? https : http; + const proxyRequest = client.request(upstreamRequestOptions(request, upstreamUrl), (proxyResponse) => { + response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers); + proxyResponse.pipe(response); + }); + proxyRequest.on("error", (error) => { + if (!response.headersSent) { + response.writeHead(502, { "Content-Type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ error: "V2X upstream unavailable", detail: error.message })); + } else { + response.destroy(error); + } + }); + request.pipe(proxyRequest); +} + +function proxyWebSocket(request, socket, head, upstreamUrl) { + const client = upstreamUrl.protocol === "https:" ? https : http; + const options = upstreamRequestOptions(request, upstreamUrl); + options.headers = { + ...options.headers, + connection: "Upgrade", + upgrade: "websocket", + }; + const proxyRequest = client.request(options); + proxyRequest.on("upgrade", (proxyResponse, upstreamSocket, upstreamHead) => { + socket.write(`HTTP/${proxyResponse.httpVersion} ${proxyResponse.statusCode} ${proxyResponse.statusMessage}\r\n`); + Object.entries(proxyResponse.headers).forEach(([name, value]) => { + socket.write(`${name}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`); + }); + socket.write("\r\n"); + if (upstreamHead.length) socket.write(upstreamHead); + if (head.length) upstreamSocket.write(head); + socket.pipe(upstreamSocket).pipe(socket); + }); + proxyRequest.on("response", (proxyResponse) => { + socket.write(`HTTP/${proxyResponse.httpVersion} ${proxyResponse.statusCode} ${proxyResponse.statusMessage}\r\n\r\n`); + socket.destroy(); + }); + proxyRequest.on("error", () => socket.destroy()); + proxyRequest.end(); +} + +function serveStaticFile(request, response, root) { + if (request.method !== "GET" && request.method !== "HEAD") { + response.writeHead(405, { Allow: "GET, HEAD" }); + response.end(); + return; + } + const pathname = decodeURIComponent(new URL(request.url, "http://preview.local").pathname); + const relativePath = pathname === "/" ? "" : pathname.slice(1); + const filename = path.resolve(root, relativePath || "fengshu-er-road-cesium-preview.html"); + if (!filename.startsWith(`${root}${path.sep}`) && filename !== root) { + response.writeHead(403); + response.end(); + return; + } + fs.stat(filename, (error, stat) => { + if (error || !stat.isFile()) { + response.writeHead(404); + response.end(); + return; + } + response.writeHead(200, { "Content-Type": MIME_TYPES[path.extname(filename).toLowerCase()] || "application/octet-stream", "Cache-Control": "no-store" }); + if (request.method === "HEAD") response.end(); + else fs.createReadStream(filename).pipe(response); + }); +} + +if (require.main === module) { + const args = parseArgs(process.argv.slice(2)); + const root = args.root || process.cwd(); + const upstream = args.upstream || process.env.V2X_UPSTREAM; + const port = Number(args.port || process.env.PORT || 7862); + const host = args.host || process.env.HOST || "0.0.0.0"; + if (!upstream) throw new Error("Set V2X_UPSTREAM or pass --upstream http://host:port"); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port must be an integer between 1 and 65535"); + const server = createV2xPreviewServer({ root, upstream }); + server.listen(port, host, () => console.log(`V2X preview server: http://${host}:${port} -> ${upstream}`)); +} + +module.exports = { createV2xPreviewServer, isProxyPath, upstreamPath };