fix: restore live V2X signal and vehicle fidelity in Cesium preview

The ported overlay used the correct REST paths but lost the data handling
from the source dashboard's live-intersection view (HologramCross), so
signals rendered permanently red and vehicles often never appeared.

- Lamp status codes now follow the dashboard dictionary (11/21/22/23/31).
  The previous 2/3 reading made every real push fall through to red. The
  dictionary lives only in the overlay; the preview consumes normalized
  {nodeKeys, color, countDown} entries so the two copies cannot drift.
- Bind V2X phases to native signal heads geometrically. The runtime
  document has no phaseNo, so the old lookup fell back to signal.id and
  never matched, leaving the dynamic assembly dark. Travel heading is
  recovered as faceHeadingDegrees + 180, per the generator's
  mast = travel - 90 / face = travel + 180. Verified 7/7 exact matches
  against the fengshu-er-road runtime document.
- A phase now lights every approach it drives; the phase -> single entity
  map silently overwrote all but the last.
- Drive the countdown assets from the push's countDown field.
- All three sockets heartbeat every 30s and reconnect with backoff,
  replaying their subscription frame. Without this the service dropped
  the connection and the scene emptied after about a minute.
- The OBU socket sends its bounds frame on connect and on camera move;
  it previously sent nothing at all.
- Vehicles are swept when a push goes stale and their slots reused, so
  they no longer accumulate as ghosts. Models follow the dashboard's
  car_obu.glb / ${type}${subType}.glb naming.
- Parse vehicle pushes leniently, since the dashboard uses saferEval and
  the payload is not guaranteed to be strict JSON. Failures are counted
  and surfaced rather than dropped; no eval is introduced.
- Drop FlowTravelRatio/queryListWeek, which is not part of this view.

Also corrects a stale spec rule that required vehicleModelNames to be
empty. Live V2X vehicles need packaged models; the real invariant is no
generated routes or traffic simulation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 09:20:50 +08:00
parent bb2a1449ac
commit bc845444bb
17 changed files with 1900 additions and 110 deletions

View File

@@ -317,27 +317,56 @@ npm run build:area -- --config config/areas/<area>.json --stages cesium
### 2. Signatures
```text
WS /network/ws/network/signal?authorization=<token>
WS /network/ws/network/obuPosition?authorization=<token>
WS /network/ws/network/targetPosition?authorization=<token>
```
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 发 boundstarget 发 `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/<area>.json --stages preview`
检查 descriptor 中 route/vehicle 字段为空,且不存在 traffic-simulation 文件
检查 descriptor 中 `routeName` 为空、不存在 traffic-simulation 文件
`vehicleModelNames` 已写出(实时车辆渲染需要)。
### 7. Wrong vs Correct

View File

@@ -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": "核对灯色字典、相位映射算法、心跳与订阅帧是否与源项目逐项对齐。"}

View File

@@ -0,0 +1,233 @@
# Design — 还原 V2X 实时路口信号灯与车辆展示
## 1. 现状与根因
`scripts/lib/v2x-cesium-overlay.js`418 行,单个 IIFE承担了登录、REST、三个 WS、坐标转换、
实体渲染的全部职责。REST 路径与登录契约与源项目一致,失真集中在数据处理与渲染层。
逐项根因(对应 PRD 的 R1R10
| # | 现象 | 根因位置 |
| --- | --- | --- |
| R1 | 连接几十秒后静默中断 | `openSocket()` 无心跳、无重连 |
| R2 | 灯永远红 | `lampColor()``2`/`3`;真实码是 `21/22/23` |
| R3 | 原生信号灯模型从不亮 | `signalByPhase` 回退 `signal.id`,与 `phaseNo` 永不相等 |
| R4 | 同相位只亮一条进口道 | `state.linkPhases``Map<phase, entity>`,后写覆盖 |
| R5 | 无倒计时 | `updateSignalPhases()` 丢弃 `countDown` |
| R6 | OBU 可能一辆车都没有 | `connectObuSocket()``onOpen``null`,未发订阅帧 |
| R7 | 幽灵车堆积 | `state.vehicles` 只增不删,未用推送 `interval` |
| R8 | 车型错配 | 按数组下标取模型,非 `${type}${subType}` 语义 |
| R9 | 推送可能整条被丢 | `JSON.parse` + 静默 `catch`,源项目用 `saferEval` |
| R10 | 多余请求 | `FlowTravelRatio/queryListWeek` 不属实时路口 |
## 2. 边界与不变量
保持不变:
- overlay 仍是**单文件零依赖 IIFE**,由 `build-area.js:561` 原样拷入产物。不新增文件,避免改动
`area-preview.js` 的 HTML 装配与包契约。
- 纯逻辑继续通过 `createV2xCesiumOverlay.utils` 导出,供 `scripts/test-v2x-cesium-overlay.js`
`vm.runInNewContext` 沙箱直接测试(无 DOM、无 Cesium
- 坐标契约不变GCJ-02 → WGS84 只在建实体前转一次。
- 令牌只进 `sessionStorage`,页面始终从登录门进入。
新增的可测试纯函数一律**不触碰 `window` / `Cesium` / `document`**,副作用留在薄薄的适配层。
## 3. 模块内部分层
文件内按四层组织,自下而上:
```
L1 纯工具 gcj02ToWgs84 / joinUrl / md5 / toWebSocketUrl / getAngle / haversine
L2 纯解析 lampColorName / parseLoosePayload / normalizeObuVehicle /
normalizeTargetVehicles / normalizeSignalLamps
L3 纯状态机 createVehicleRegistry / createPhaseBinding / buildPhaseSignalMap
L4 副作用层 createSocket(心跳/重连) / 实体渲染 / UI 面板
```
L1L3 全部导出到 `.utils`,测试只打 L1L3。
## 4. 关键设计决策
### 4.1 灯色状态字典R2
直接采用源项目 `HologramCross/components/utils.ts` 的字典,语义化返回而非直接返回颜色,
使 overlay 与 `cesium-preview.js` 共用同一套判定:
```
11 -> "off" 灭灯
21 -> "red"
22 -> "yellow"
23 -> "green"
31 -> "other"
其他 -> "other" // 不再落到 red
```
`lampColorName(status)` 放在 overlay 的 L2 并导出。`cesium-preview.js` 中重复的
`lampColorName()` 删除,改为消费 overlay 通过 `setSignalState` 回调传入的**已归一化**灯态
`{nodeKeys, color, countDown}`),避免两处字典漂移。
> 取舍:也可以把字典抽到共享文件让两边 require但 overlay 必须是浏览器端零依赖 IIFE
> `cesium-preview.js` 同样是浏览器端脚本两者无模块系统。让数据流单向overlay → preview
> 比共享常量更简单,也消除了双字典。
### 4.2 相位 → 原生信号灯映射R3本任务最难的一处
`traffic-signals.json` 的信号灯项只有 `phaseGroup: 0`**不含 V2X `phaseNo`**,两侧没有公共 ID。
唯一可靠的连接是**几何**。
源项目的做法给了线索:`CrossTrafficLights3D.vue:96-104` 对每条 link 取 `geom.coordinates`
最后两点,末点即停止线位置,两点连线的方位角即进口道航向。原生信号灯项恰好也有
`stopLongitude/stopLatitude``headingDegrees`
因此定义纯函数:
```
buildPhaseSignalMap(links, nativeSignals, options) -> {
byPhase: Map<phaseNo, string[]>, // nodeKey 列表
bound: number,
unbound: Array<{phaseNo, reason}>,
diagnostics: Array<{linkId, matchedNodeKeys, distanceMeters, headingDeltaDegrees}>
}
```
算法:
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<phaseNo, Set<nodeKey>>`),满足 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>": ["<nodeKey>", …] }` 存在时,
该 phaseNo 直接用配置值,跳过几何匹配。这是几何匹配失败时的逃生舱,也让映射可人工固化。
> 取舍:不用 `approachId` / `sourceWayId` 匹配,因为那是 OSM 侧标识V2X link 不携带;
> 不用最近邻唯一指派(匈牙利算法),因为一进口道多灯头是正常情况,强制一对一会漏灯。
> 容差可配是因为不同路口的停止线标注精度差异较大。
### 4.3 WebSocket 连接层R1、R6
新增 `createSocket({url, onOpen, onMessage, onClose, heartbeatMs, reconnect})` 副作用封装:
- 心跳:`setInterval(() => send('{"heartBeat":"ping"}'), 30000)`,与源项目一致。
- 重连:指数退避 `1s, 2s, 4s, 8s`,上限 `8s`**重连成功后重新执行 `onOpen`**
确保订阅帧(`junctionId` / `deviceId` / `bounds`)被重放。这是源项目 `onConnected` 的语义。
- `close()` 主动关闭时不触发重连;`dispose()` 清心跳与退避计时器。
- 计时器通过参数注入(`setIntervalFn` / `setTimeoutFn`),使重连与心跳逻辑可在测试中用假时钟驱动。
订阅帧内容严格对齐源项目:
| socket | 连接后首帧 | 来源 |
| --- | --- | --- |
| signal | `{"junctionId":"<crossCode>"}` | `CrossTrafficLights3D.vue:129` |
| obu | `bounds \|\| ""`dashboard 传 `null` → 空串) | `useObuCars.ts:31` |
| target | `{"deviceId":"<ids>"}` 或 `{"deviceId":null}` | `useTargetCars.ts:50-53` |
OBU 的 bounds源项目取 AMap 视野四角。本项目用 Cesium `camera.computeViewRectangle()`
换算成同格式 `lng,lat;lng,lat;lng,lat;lng,lat`**WGS84 → GCJ-02 反向转换**,因为服务端按 GCJ-02
过滤),在 `camera.moveEnd` 时节流发送 `{"bounds": "<…>"}`。视野不可用时退回空串,等价于不过滤。
> 取舍Cesium 是倾斜视角,`computeViewRectangle()` 在极端俯仰下可能返回 `undefined`
> 此时退回空串而非跳过发送,保证仍能收到全量推送——宁可多收也不要空场景。
### 4.4 车辆注册表R7、R8
纯状态机 `createVehicleRegistry({now})`
```
registry.ingest(vehicles, interval) -> {added, updated, hidden}
registry.sweep(nowMs) -> hiddenKeys
registry.list() -> VehicleRecord[]
```
- 复刻源项目:`interval = Number(payload.interval) === 0 ? 500 : Number(payload.interval)`。
- `sweep()``Math.abs(now - timeStamp) >= interval * 1.5` 时 `visible = false`。
- 槽位复用:新车优先占用同模型的 `visible === false` 记录(源项目 `useTargetCars.ts:110`
减少 Cesium 实体的增删抖动。
- `now` 注入,测试用假时钟推进。
- socket `onClose` 时 `registry.clear()`,对齐源项目 `onDisconnected: cars.value = []`。
渲染适配层据 `visible` 切 `entity.show`**不删实体**,与槽位复用配套。
模型选择R8`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与源项目一致 |

View File

@@ -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 与新增配置后需同步更新。"}

View File

@@ -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` | 主要改动L1L4 分层重写 |
| `scripts/lib/cesium-preview.js` | 删除重复 `lampColorName`,调整 `setSignalState` 入参 |
| `scripts/test-v2x-cesium-overlay.js` | 扩充单测,覆盖 AC2AC7 |
| `.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` 的实现(返回 0360
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<phaseNo, Set<nodeKey>>` 并集累加。
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":"<code>"}`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<phase, entity>` 整体删除)。
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_<nodeKey>_<color>` 节点,
删除失效的 `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://<host>:<port> \
npm run serve:v2x-preview -- --root outputs/fengshu-er-road --host 0.0.0.0 --port 7862
```
登录后逐项确认:
- [ ] 信号灯随相位变色,颜色与 dashboard 实时路口一致
- [ ] 倒计时数字走动
- [ ] 车辆出现、移动平滑、离开视野后消失(无幽灵车堆积)
- [ ] 连接持续 5 分钟以上不掉线
- [ ] 车辆与路口位置无明显偏移(坐标未二次转换)
- [ ] 面板绑定数、失败数符合预期
## 回滚点
- 步骤 7 独立可 revert只动 `cesium-preview.js`)。
- 步骤 16 全在 overlay 单文件内,`git checkout scripts/lib/v2x-cesium-overlay.js` 即回到当前状态。
- `v2xPreview.enabled=false` 是运行时总开关。
## 不做(留待后续任务)
- AMap 底图 / 矢量瓦片图层
- `CrossDevices/Model3D.vue` 3D 杆件模型(设备维持点位)
- `useV2XEvents` 事件气泡

View File

@@ -0,0 +1,99 @@
# 还原 V2X 实时路口信号灯与车辆展示
## Goal
`v2x_web` dashboard「实时路口」(`HologramCross`) 的实时数据行为完整搬到本项目的 Cesium 预览中,使
`outputs/<area>/…-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<phase, entity>` 的覆盖行为必须消除。
### R5 倒计时
- 使用推送中的 `countDown` 字段(源项目 `phaseValue['time'] = signal.countDown`),驱动预览中已有的
countdown 资产或等效显示。
### R6 车辆订阅正确建立
- OBU socket 连接后必须发送订阅消息(源项目 `send(bounds || '')`),相机视野变化时发送
`{"bounds": "<lng,lat;…>"}`
- target socket 连接后发送 `{"deviceId": "<ids joined by comma>"}`,无配置时发送 `{"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 面板,包产物字节不变。

View File

@@ -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": {}
}

View File

@@ -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 `{"<phaseNo>": ["<nodeKey>", …]}`; 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":"<crossCode>"}` |
| `obuPosition` | `{"bounds":"<four GCJ-02 corners>"}`, or an empty frame when the camera rectangle is unavailable (no viewport filter) |
| `targetPosition` | `{"deviceId":"<ids>"}`, 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://<v2x-api-host>:<port> \
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

View File

@@ -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"
},

View File

@@ -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, {

View File

@@ -37,8 +37,8 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(areaId)} Cesium Preview</title>
<script src="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Cesium.js"></script>
<link href="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Widgets/widgets.css" rel="stylesheet" onerror="this.onerror=null;this.href='https://unpkg.com/cesium@1.121.1/Build/Cesium/Widgets/widgets.css'">
<script src="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Cesium.js" onerror="this.onerror=null;this.src='https://unpkg.com/cesium@1.121.1/Build/Cesium/Cesium.js';"></script>
<link href="cesium-preview.css" rel="stylesheet">
</head>
<body>

View File

@@ -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)");
});
}());

View File

@@ -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);
const socket = createSocket({
url: () => toWebSocketUrl(joinUrl(settings.wsBaseUrl || "/websocket", path), state.token),
onOpen,
onMessage,
onError: () => setStatus(`${state.status} ${unavailable}.`),
});
state.sockets.push(socket);
socket.onopen = () => onOpen?.(socket);
socket.onmessage = (event) => onMessage(event.data);
socket.onerror = () => setStatus(`${state.status} ${unavailable}.`);
socket.open();
return socket;
} catch (_) {
setStatus(`${state.status} ${unavailable}.`);
return null;
}
}
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));
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 = lampColor(lamp.status).withAlpha(.9);
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;
}());

View File

@@ -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<input/);
assert.match(v2xRuntime, /V2X intersection code is not configured/);
assert.match(v2xRuntime, /Finding the configured V2X intersection/);
assert.match(v2xRuntime, /crossDeviceConfig\/queryList/);
assert.match(v2xRuntime, /gcj02ToWgs84/);
assert.match(previewRuntime, /createLiveVehicleState\(\)/);
assert.doesNotMatch(previewRuntime, /const cruise = addVehicleCruises\(/);

View File

@@ -15,7 +15,17 @@ const context = {
};
vm.runInNewContext(source, context, { filename: "v2x-cesium-overlay.js" });
const { gcj02ToWgs84, joinUrl, md5, toWebSocketUrl, normalizeObuVehicle, normalizeTargetVehicles } = context.window.createV2xCesiumOverlay.utils;
const {
gcj02ToWgs84, wgs84ToGcj02, joinUrl, md5, toWebSocketUrl,
normalizeObuVehicle, normalizeObuPush, normalizeTargetVehicles, normalizeTargetPush,
selectCrossCode, lampColorName, parseLoosePayload, isHeartbeat, signalEntriesFrom,
getAngle, angleDeltaDegrees, haversineMeters,
buildPhaseSignalMap, signalTravelHeading,
createVehicleRegistry, modelNameFor, resolvePushInterval,
createSocket, buildBoundsMessage, HEARTBEAT_MESSAGE, HEARTBEAT_INTERVAL_MS,
} = context.window.createV2xCesiumOverlay.utils;
// --- existing contracts -----------------------------------------------------
assert.equal(md5(""), "d41d8cd98f00b204e9800998ecf8427e");
assert.equal(md5("password"), "5f4dcc3b5aa765d61d8327deb882cf99");
assert.equal(joinUrl("/api/", "/facilities/api/sys/login"), "/api/facilities/api/sys/login");
@@ -27,12 +37,240 @@ const reference = referenceGcj02ToWgs84([114.12864875054062, 30.460485279762146]
assert.ok(Math.abs(converted[0] - reference[0]) < 1e-12);
assert.ok(Math.abs(converted[1] - reference[1]) < 1e-12);
assert.deepEqual(gcj02ToWgs84([Infinity, 30]), [NaN, NaN]);
// wgs84ToGcj02 is the forward transform used for the OBU bounds frame. Both
// directions are single-step approximations, so the round trip is not exact:
// it closes to about 1.5 m. That is irrelevant for a viewport filter, and
// vehicle positions never use it -- they only ever go GCJ-02 -> 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.");

View File

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

View File

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