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>
12 KiB
Design — 还原 V2X 实时路口信号灯与车辆展示
1. 现状与根因
scripts/lib/v2x-cesium-overlay.js(418 行,单个 IIFE)承担了登录、REST、三个 WS、坐标转换、
实体渲染的全部职责。REST 路径与登录契约与源项目一致,失真集中在数据处理与渲染层。
逐项根因(对应 PRD 的 R1–R10):
| # | 现象 | 根因位置 |
|---|---|---|
| R1 | 连接几十秒后静默中断 | openSocket() 无心跳、无重连 |
| R2 | 灯永远红 | lampColor() 判 2/3;真实码是 21/22/23 |
| R3 | 原生信号灯模型从不亮 | signalByPhase 回退 signal.id,与 phaseNo 永不相等 |
| R4 | 同相位只亮一条进口道 | state.linkPhases 是 Map<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 面板
L1–L3 全部导出到 .utils,测试只打 L1–L3。
4. 关键设计决策
4.1 灯色状态字典(R2)
直接采用源项目 HologramCross/components/utils.ts 的字典,语义化返回而非直接返回颜色,
使 overlay 与 cesium-preview.js 共用同一套判定:
11 -> "off" 灭灯
21 -> "red"
22 -> "yellow"
23 -> "green"
31 -> "other"
其他 -> "other" // 不再落到 red
lampColorName(status) 放在 overlay 的 L2 并导出。cesium-preview.js 中重复的
lampColorName() 删除,改为消费 overlay 通过 setSignalState 回调传入的已归一化灯态
({nodeKeys, color, countDown}),避免两处字典漂移。
取舍:也可以把字典抽到共享文件让两边 require,但 overlay 必须是浏览器端零依赖 IIFE,
cesium-preview.js同样是浏览器端脚本,两者无模块系统。让数据流单向(overlay → preview) 比共享常量更简单,也消除了双字典。
4.2 相位 → 原生信号灯映射(R3,本任务最难的一处)
traffic-signals.json 的信号灯项只有 phaseGroup: 0,不含 V2X phaseNo,两侧没有公共 ID。
唯一可靠的连接是几何。
源项目的做法给了线索:CrossTrafficLights3D.vue:96-104 对每条 link 取 geom.coordinates 的
最后两点,末点即停止线位置,两点连线的方位角即进口道航向。原生信号灯项恰好也有
stopLongitude/stopLatitude 与 headingDegrees。
因此定义纯函数:
buildPhaseSignalMap(links, nativeSignals, options) -> {
byPhase: Map<phaseNo, string[]>, // nodeKey 列表
bound: number,
unbound: Array<{phaseNo, reason}>,
diagnostics: Array<{linkId, matchedNodeKeys, distanceMeters, headingDeltaDegrees}>
}
算法:
-
对每条 link:解析
geom,取末两点,先 GCJ-02 → WGS84,再算stopPoint与approachHeading = getAngle(prePoint, lastPoint)。 -
对每个原生 signal:取
(stopLongitude, stopLatitude)与行车方向。行车方向的语义由生成侧
scripts/lib/traffic-signals.js定死,不是猜的::54headingDegrees = atan2(axis),而axis = roadAxis(stopLineCenter → intersectionCenter), 即停止线指向路口中心的行车方向。:82mast_heading_deg = heading - 90:83face_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 个信号自洽。
-
候选条件:
haversine(stopPoint, signalStop) <= maxDistanceMeters且angleDelta(approachHeading, travelHeading) <= maxHeadingDeltaDegrees。 -
一条 link 可匹配多个 signal(同一进口道的多个灯头),满足 R4 的一半。
-
link 的
phaseList中每个phase→ 该 link 匹配到的全部 nodeKey,并集累加 (Map<phaseNo, Set<nodeKey>>),满足 R4 的另一半(同相位多进口道)。 -
默认容差:
maxDistanceMeters = 30、maxHeadingDeltaDegrees = 45。 -
未匹配上的相位进入
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,与源项目一致 |