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 };