fix: use live V2X vehicles only in preview

This commit is contained in:
2026-08-24 14:42:31 +08:00
parent ee273c5cd6
commit b7031f145a
10 changed files with 240 additions and 52 deletions

View File

@@ -307,6 +307,66 @@ npm run build:area -- --config config/areas/<area>.json --stages cesium
--- ---
## 实时 V2X 车辆仅数据源
### 1. Scope / Trigger
适用于开启 `v2xPreview.enabled` 的 Cesium 运营预览。触发:修改
`v2x-cesium-overlay.js`、preview descriptor 的 `routeName`、或实时车辆展示。
### 2. Signatures
```text
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`
### 3. Contracts
- 两条流均为 GCJ-02必须在实体创建前恰好调用一次 `gcj02ToWgs84`
- 本预览的 `routeName``vehicleModelName` 必须为 `null``vehicleModelNames` 必须为空;
原生 preview 不得生成 `trafficSimulation` 描述符。
- 每辆实时车保留最多 24 个已转换的位置作为实际轨迹;轨迹不是推测路径。
### 4. Validation & Error Matrix
| 条件 | 结果 |
|---|---|
| 未登录、令牌失效、REST/WS 不可用 | 静态路口继续显示,车辆层为空,并显示实时数据不可用状态 |
| 消息不是 JSON、心跳、坐标无效 | 忽略该消息,不创建车辆 |
| 收到有效车辆坐标 | 创建或更新真实车辆与实际轨迹 |
### 5. Good / Base / Bad Cases
- GoodOBU 和感知目标连续推送,页面只显示对应车辆的实际行驶轨迹。
- Base服务无数据页面没有车辆或线路。
- Bad将旧路线 JSON 或 `native-preview-traffic-simulation` 用作回退展示。
### 6. Tests Required
- `npm run test:v2x-cesium-preview`:校验两类消息解析与 GCJ-02 转换。
- `npm run test:preview-assets`:断言两条 WebSocket 存在,且 runtime 不调用
`addVehicleCruises`、不显示 simulation 诊断。
- 对目标区域运行 `npm run build:area -- --config config/areas/<area>.json --stages preview`
检查 descriptor 中 route/vehicle 字段为空,且不存在 traffic-simulation 文件。
### 7. Wrong vs Correct
错误:接口不可用时恢复构造路线。
```js
const cruise = addVehicleCruises(viewer, routeData, signalData, start);
```
正确:保持空车辆层,等待真实流。
```js
const cruise = createLiveVehicleState();
```
## 本地预览必须走 HTTP ## 本地预览必须走 HTTP
```bash ```bash

View File

@@ -4,7 +4,9 @@
The native pipeline remains the source of static scene geometry and package The native pipeline remains the source of static scene geometry and package
placement. The existing Cesium preview gains an operations overlay and a compact placement. The existing Cesium preview gains an operations overlay and a compact
V2X sign-in gate. The implementation stays in the current generated static V2X sign-in gate. Vehicle entities and their trace lines exist only after the
authenticated V2X streams provide valid positions; no generated traffic route or
simulation is rendered. The implementation stays in the current generated static
preview architecture and does not import the source dashboard's Vue, AMap, or preview architecture and does not import the source dashboard's Vue, AMap, or
Three dependencies. Three dependencies.
@@ -65,11 +67,11 @@ so preserves its compiler behavior and test coverage.
## Compatibility And Failure Handling ## Compatibility And Failure Handling
- Existing Cesium preview and its static-only workflow remain usable. - Existing Cesium preview and its static-only workflow remain usable.
- The V2X overlay is optional and starts only after successful sign-in. - The V2X overlay starts only after successful sign-in.
- An expired token returns the user to sign-in and removes live entities rather - An expired token returns the user to sign-in and removes live entities rather
than presenting stale data as current. than presenting stale data as current.
- A REST/WS capability failure is shown in diagnostics; native scene, signals, - A REST/WS capability failure is shown in diagnostics; native scene and signals
and traffic simulation remain usable. remain usable, while the vehicle layer stays empty rather than simulating data.
- Removing the optional overlay/support files restores current preview behavior - Removing the optional overlay/support files restores current preview behavior
without changing static package contracts. without changing static package contracts.

View File

@@ -9,7 +9,8 @@
3. Implement the V2X browser client: login, session token lifecycle, REST 3. Implement the V2X browser client: login, session token lifecycle, REST
envelope handling, capability errors, and signal WebSocket lifecycle. envelope handling, capability errors, and signal WebSocket lifecycle.
4. Extend the generated Cesium preview with V2X entities and operations controls 4. Extend the generated Cesium preview with V2X entities and operations controls
for links, devices, poles/configuration, metrics, and live signal state. for links, devices, poles/configuration, metrics, live signal state, and the
OBU/target-vehicle streams. Do not load or generate simulated vehicle routes.
5. Add a local development/proxy configuration example and deployment 5. Add a local development/proxy configuration example and deployment
documentation for V2X REST/WS proxying. Do not add an AMap key, SDK, or page. documentation for V2X REST/WS proxying. Do not add an AMap key, SDK, or page.
6. Add focused tests for HTML generation, configuration escaping, login/request 6. Add focused tests for HTML generation, configuration escaping, login/request

View File

@@ -38,6 +38,9 @@ retaining the native road compiler's WGS84/ENU output as the geometry authority.
loaded while leaving the native complex-intersection view usable. loaded while leaving the native complex-intersection view usable.
- R6: Make endpoint origin, selected V2X intersection code, and authorization - R6: Make endpoint origin, selected V2X intersection code, and authorization
mechanism deployment configuration rather than hard-coded source values. mechanism deployment configuration rather than hard-coded source values.
- R7: Vehicle lines and moving vehicles must come only from the authenticated
V2X vehicle streams. Do not render or fall back to generated route/simulation
data when the service is unavailable.
## Candidate Upstream Resources ## Candidate Upstream Resources
@@ -69,6 +72,7 @@ retaining the native road compiler's WGS84/ENU output as the geometry authority.
- Copying the source dashboard's Vue, AMap/Three, or proprietary UI component stack. - Copying the source dashboard's Vue, AMap/Three, or proprietary UI component stack.
- Building a full user/role management service. - Building a full user/role management service.
- Replacing native road geometry or traffic-signal contracts with V2X data. - Replacing native road geometry or traffic-signal contracts with V2X data.
- Generated vehicle-route or traffic-simulation fallback in the operations preview.
## Authentication Decision ## Authentication Decision

View File

@@ -1,9 +1,10 @@
# Live V2X Cesium Preview # Live V2X Cesium Preview
The Cesium preview can overlay live V2X operational data on a compiled native The Cesium preview displays live V2X operational data on a compiled native
intersection. This is an optional verification feature; the package manifest, intersection. The package manifest, native road geometry, and traffic signals
native road geometry, traffic signals, and deterministic traffic simulation do remain independent of the service. Vehicle entities and their trace lines are
not depend on a V2X service being available. created only from authenticated V2X streams; generated routes and traffic
simulation are never rendered as a substitute.
## Configuration ## Configuration
@@ -34,14 +35,16 @@ signal WebSocket. Closing the tab clears the session token.
After sign-in the preview reads the selected intersection's links, pole After sign-in the preview reads the selected intersection's links, pole
configuration, bound devices, device configuration, weekly traffic flow ratio, configuration, bound devices, device configuration, weekly traffic flow ratio,
and `/network/ws/network/signal` phase updates. Failures are shown in the V2X and `/network/ws/network/signal` phase updates. It also subscribes to
panel and do not block the static Cesium scene. `/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.
## Coordinate Contract ## Coordinate Contract
| Data | Coordinate system | Preview handling | | Data | Coordinate system | Preview handling |
| --- | --- | --- | | --- | --- | --- |
| Native package placement, routes, traffic signals | WGS84 and local ENU | Existing Cesium contract; unchanged. | | Native package placement and traffic signals | WGS84 and local ENU | Existing Cesium contract; unchanged. |
| V2X dashboard road/link/device data | GCJ-02 | Converted once to WGS84 immediately before Cesium entity creation. | | V2X dashboard road/link/device data | GCJ-02 | Converted once to WGS84 immediately before Cesium entity creation. |
| High德 reference GeoJSON | GCJ-02 | Compiler calibration input only; never loaded by this preview. | | High德 reference GeoJSON | GCJ-02 | Compiler calibration input only; never loaded by this preview. |

View File

@@ -16,7 +16,6 @@ const {
} = require("./lib/scene-layers"); } = require("./lib/scene-layers");
const { digest: glbDigest } = require("./glb-digest"); const { digest: glbDigest } = require("./glb-digest");
const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route"); const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route");
const { buildNativeTrafficSimulation } = require("./lib/native-preview-traffic-simulation");
const { writePreviewVehicleLibrary } = require("./lib/vehicle-library"); const { writePreviewVehicleLibrary } = require("./lib/vehicle-library");
const { readTrafficSignals } = require("./lib/traffic-signals"); const { readTrafficSignals } = require("./lib/traffic-signals");
const { const {
@@ -576,11 +575,9 @@ function writeCesiumPreview(area, roadProvider) {
}); });
} else { } else {
Object.assign(previewInputs, nativeRoadRecords(area)); Object.assign(previewInputs, nativeRoadRecords(area));
const simulation = buildNativeTrafficSimulation(area); // This operational preview intentionally has no synthetic vehicle source.
fs.mkdirSync(path.dirname(area.outputs.trafficSimulation), { recursive: true }); // A stale descriptor from an older build must not resurrect simulated routes.
fs.writeFileSync(area.outputs.trafficSimulation, `${JSON.stringify(simulation, null, 2)}\n`); fs.rmSync(area.outputs.trafficSimulation, { force: true });
previewInputs.trafficSimulation = fileRecord(area.outputs.trafficSimulation);
routeArtifact = area.outputs.trafficSimulation;
} }
const htmlPath = area.outputs.cesiumPreview; const htmlPath = area.outputs.cesiumPreview;
const started = Date.now(); const started = Date.now();
@@ -592,18 +589,18 @@ function writeCesiumPreview(area, roadProvider) {
// Do not let a route from an earlier legacy preview survive into native output. // Do not let a route from an earlier legacy preview survive into native output.
fs.rmSync(area.outputs.vehicleRoute, { force: true }); fs.rmSync(area.outputs.vehicleRoute, { force: true });
} }
const vehicleModelNames = writeVehicleModel(area); const vehicleModelNames = [];
writeCesiumPreviewSupportFiles(path.dirname(htmlPath)); writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
const glbName = "package/manifest.json"; const glbName = "package/manifest.json";
const metadataName = "package/manifest.json"; const metadataName = "package/manifest.json";
const routeName = vehicleRoute || routeArtifact const routeName = vehicleRoute || routeArtifact
? previewRelativePath(area.outputs.areaDir, vehicleRoute ? area.outputs.vehicleRoute : routeArtifact) ? previewRelativePath(area.outputs.areaDir, vehicleRoute ? area.outputs.vehicleRoute : routeArtifact)
: null; : null;
const vehicleModelName = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel); const vehicleModelName = null;
const descriptor = { routeName, vehicleModelName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel), vehicleModelNames: vehicleModelNames.map((name) => `_preview/${name}`), trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] }; const descriptor = { routeName, vehicleModelName, vehicleModelNames, trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] };
fs.mkdirSync(area.outputs.previewDir, { recursive: true }); fs.mkdirSync(area.outputs.previewDir, { recursive: true });
fs.writeFileSync(area.outputs.previewDescriptor, `${JSON.stringify(descriptor, null, 2)}\n`); fs.writeFileSync(area.outputs.previewDescriptor, `${JSON.stringify(descriptor, null, 2)}\n`);
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)); fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames, "package/runtime/traffic-signals.json", "_preview/descriptor.json", area.v2xPreview));
console.log(`Cesium preview: ${htmlPath}`); console.log(`Cesium preview: ${htmlPath}`);
const finished = Date.now(); const finished = Date.now();
writeStageManifest(area, { writeStageManifest(area, {

View File

@@ -48,7 +48,6 @@
const metadata = await fetchJson(config.metadataName); const metadata = await fetchJson(config.metadataName);
const descriptor = config.previewDescriptorName ? await fetchOptionalJson(config.previewDescriptorName) : null; const descriptor = config.previewDescriptorName ? await fetchOptionalJson(config.previewDescriptorName) : null;
adaptPackageManifest(metadata, descriptor); adaptPackageManifest(metadata, descriptor);
const routeData = await fetchOptionalJson(config.routeName);
const signalData = await fetchOptionalJson(config.trafficSignalsName); const signalData = await fetchOptionalJson(config.trafficSignalsName);
const placement = scenePlacement(metadata); const placement = scenePlacement(metadata);
const viewer = createViewer(); const viewer = createViewer();
@@ -56,7 +55,7 @@
const assets = await loadSceneAssets(viewer, metadata, placement); const assets = await loadSceneAssets(viewer, metadata, placement);
const trafficStart = Cesium.JulianDate.now(); const trafficStart = Cesium.JulianDate.now();
const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart, assets); const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart, assets);
const cruise = addVehicleCruises(viewer, routeData, signalData, trafficStart, config.vehicleModelNames, config.vehicleModelName); const cruise = createLiveVehicleState();
const cameras = createCameraPresets(viewer, metadata, placement, cruise); const cameras = createCameraPresets(viewer, metadata, placement, cruise);
const v2xOverlay = typeof window.createV2xCesiumOverlay === "function" const v2xOverlay = typeof window.createV2xCesiumOverlay === "function"
? window.createV2xCesiumOverlay({ viewer, metadata, placement, config }) ? window.createV2xCesiumOverlay({ viewer, metadata, placement, config })
@@ -66,7 +65,7 @@
buildSemanticToggles(viewer, assets, placement); buildSemanticToggles(viewer, assets, placement);
bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals); bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals);
bindVehicleInfoCard(viewer, cruise); bindVehicleInfoCard(viewer, cruise);
startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals); startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals, v2xOverlay);
cameras.overview(); cameras.overview();
baseStatus = summaryText(metadata, assets, cruise); baseStatus = summaryText(metadata, assets, cruise);
setStatus(baseStatus); setStatus(baseStatus);
@@ -590,6 +589,7 @@
baseSpeed: speed, baseSpeed: speed,
state: { selectedIndex: 0 }, state: { selectedIndex: 0 },
simulation, simulation,
usingLiveData: false,
}; };
syncSelectedRouteVisibility(cruise); syncSelectedRouteVisibility(cruise);
return cruise; return cruise;
@@ -597,10 +597,20 @@
function syncSelectedRouteVisibility(cruise) { function syncSelectedRouteVisibility(cruise) {
for (let index = 0; index < cruise.vehicles.length; index += 1) { for (let index = 0; index < cruise.vehicles.length; index += 1) {
cruise.vehicles[index].routeEntity.show = toggleRoutes.checked && index === cruise.state.selectedIndex; cruise.vehicles[index].routeEntity.show = !cruise.usingLiveData && toggleRoutes.checked && index === cruise.state.selectedIndex;
} }
} }
function createLiveVehicleState() {
return {
vehicles: [],
baseSpeed: 0,
state: { selectedIndex: 0 },
simulation: null,
usingLiveData: true,
};
}
function addTrafficSignals(viewer, signalData, start, assets) { function addTrafficSignals(viewer, signalData, start, assets) {
const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model); const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model);
const countdownModels = new Map(assets const countdownModels = new Map(assets
@@ -1324,7 +1334,7 @@
// Camera-dependent readouts have to track the camera, so refresh off the // Camera-dependent readouts have to track the camera, so refresh off the
// render loop rather than a fixed timer, throttled to stay off the hot path. // render loop rather than a fixed timer, throttled to stay off the hot path.
function startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals) { function startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals, v2xOverlay) {
const center = placement.position; const center = placement.position;
const stats = metadata.scene_stats || {}; const stats = metadata.scene_stats || {};
const failed = assets.filter((asset) => asset.error); const failed = assets.filter((asset) => asset.error);
@@ -1345,11 +1355,9 @@
"Trees: " + Number(stats.trees || 0), "Trees: " + Number(stats.trees || 0),
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback") "Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")
]; ];
if (cruise.simulation) { if (v2xOverlay?.state) {
const simulation = cruise.simulation.diagnostics(); lines.push("Vehicle data: " + (v2xOverlay.state.vehicleSource === "live" ? "live V2X" : "waiting for live V2X"));
lines.push("Simulation: native-preview-traffic-simulation/v1"); lines.push("Live vehicles: " + v2xOverlay.state.vehicles.size);
lines.push("Stopped: " + simulation.stoppedVehicles);
lines.push("Queue: " + simulation.queueLength);
} }
if (failed.length) { if (failed.length) {
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", ")); lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));
@@ -1371,7 +1379,7 @@
return [ return [
config.areaId, config.areaId,
liveAssets(assets).map((asset) => asset.url).join(", "), liveAssets(assets).map((asset) => asset.url).join(", "),
cruise.vehicles.length ? "vehicles " + cruise.vehicles.length : "no drivable route", "live V2X vehicles only",
"buildings " + Number(stats.buildings || 0), "buildings " + Number(stats.buildings || 0),
"trees " + Number(stats.trees || 0) "trees " + Number(stats.trees || 0)
].filter(Boolean).join(" | "); ].filter(Boolean).join(" | ");

View File

@@ -13,11 +13,13 @@
const state = { const state = {
token: sessionStorage.getItem(TOKEN_KEY) || "", token: sessionStorage.getItem(TOKEN_KEY) || "",
entities: [], entities: [],
vehicles: new Map(),
linkPhases: new Map(), linkPhases: new Map(),
socket: null, sockets: [],
status: "Sign in to load live V2X data.", status: "Sign in to load live V2X data.",
crossCode: settings.crossCode || "", crossCode: settings.crossCode || "",
metrics: null, metrics: null,
vehicleSource: "waiting",
}; };
const ui = buildUi(state, settings); const ui = buildUi(state, settings);
@@ -29,14 +31,25 @@
function clearEntities() { function clearEntities() {
state.entities.forEach((entity) => context.viewer.entities.remove(entity)); state.entities.forEach((entity) => context.viewer.entities.remove(entity));
state.entities = []; state.entities = [];
state.vehicles.forEach((vehicle) => {
context.viewer.entities.remove(vehicle.entity);
context.viewer.entities.remove(vehicle.trace);
});
state.vehicles.clear();
state.linkPhases.clear(); state.linkPhases.clear();
} }
function disconnect() { function disconnect() {
if (state.socket) state.socket.close(); state.sockets.forEach((socket) => socket.close());
state.socket = null; state.sockets = [];
} }
function useLiveVehicles() {
if (state.vehicleSource === "live") return;
state.vehicleSource = "live";
}
state.activateLiveVehicles = useLiveVehicles;
async function request(path) { async function request(path) {
const response = await fetch(joinUrl(settings.apiBaseUrl || "/api", path), { const response = await fetch(joinUrl(settings.apiBaseUrl || "/api", path), {
headers: state.token ? { Authorization: state.token } : {}, headers: state.token ? { Authorization: state.token } : {},
@@ -77,6 +90,7 @@
function signOut(message) { function signOut(message) {
disconnect(); disconnect();
clearEntities(); clearEntities();
state.vehicleSource = "waiting";
state.token = ""; state.token = "";
sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(TOKEN_KEY);
ui.form.hidden = false; ui.form.hidden = false;
@@ -92,6 +106,7 @@
} }
state.crossCode = crossCode; state.crossCode = crossCode;
clearEntities(); clearEntities();
state.vehicleSource = "waiting";
setStatus("Loading live V2X intersection data..."); setStatus("Loading live V2X intersection data...");
const results = await Promise.allSettled([ const results = await Promise.allSettled([
request(`/network/api/link/network/queryCrossLinkInfo/${encodeURIComponent(crossCode)}`), request(`/network/api/link/network/queryCrossLinkInfo/${encodeURIComponent(crossCode)}`),
@@ -111,23 +126,53 @@
const targetCount = deviceConfig.status === "fulfilled" ? arrayValue(deviceConfig.value?.deviceConfig?.target).length : 0; const targetCount = deviceConfig.status === "fulfilled" ? arrayValue(deviceConfig.value?.deviceConfig?.target).length : 0;
state.metrics = flow.status === "fulfilled" ? flow.value : null; state.metrics = flow.status === "fulfilled" ? flow.value : null;
if (flow.status !== "fulfilled") warnings.push("traffic metrics unavailable"); if (flow.status !== "fulfilled") warnings.push("traffic metrics unavailable");
connectSignalSocket(); connectLiveSockets(deviceConfig.status === "fulfilled" ? deviceConfig.value : null);
const linkCount = links.status === "fulfilled" ? arrayValue(links.value?.inLinkList).length : 0; const linkCount = links.status === "fulfilled" ? arrayValue(links.value?.inLinkList).length : 0;
const metricCount = arrayValue(state.metrics).length; 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: ${linkCount} links, ${deviceCount} devices, ${poleCount} poles, ${targetCount} configured targets, ${metricCount} flow metrics. GCJ-02 -> WGS84 once.${warnings.length ? ` ${warnings.join(", ")}.` : ""}`);
} }
function connectSignalSocket() { function connectLiveSockets(deviceConfig) {
disconnect(); disconnect();
const socketUrl = toWebSocketUrl(joinUrl(settings.wsBaseUrl || "/websocket", "/network/ws/network/signal"), state.token); connectSignalSocket();
try { connectObuSocket();
state.socket = new WebSocket(socketUrl); connectTargetSocket(deviceConfig);
state.socket.onopen = () => state.socket?.send(JSON.stringify({ junctionId: state.crossCode }));
state.socket.onmessage = (event) => updateSignalPhases(event.data, state);
state.socket.onerror = () => setStatus(`${state.status} Signal WebSocket unavailable.`);
} catch (_) {
setStatus(`${state.status} Signal WebSocket unavailable.`);
} }
function openSocket(path, onOpen, onMessage, unavailable) {
const socketUrl = toWebSocketUrl(joinUrl(settings.wsBaseUrl || "/websocket", path), state.token);
try {
const socket = new WebSocket(socketUrl);
state.sockets.push(socket);
socket.onopen = () => onOpen?.(socket);
socket.onmessage = (event) => onMessage(event.data);
socket.onerror = () => setStatus(`${state.status} ${unavailable}.`);
return socket;
} catch (_) {
setStatus(`${state.status} ${unavailable}.`);
return null;
}
}
function connectSignalSocket() {
openSocket("/network/ws/network/signal", (socket) => socket.send(JSON.stringify({ junctionId: state.crossCode })),
(value) => updateSignalPhases(value, state), "Signal WebSocket unavailable");
}
function connectObuSocket() {
openSocket("/network/ws/network/obuPosition", null, (value) => {
const vehicle = normalizeObuVehicle(value);
if (vehicle) updateLiveVehicle(context.viewer, vehicle, state);
}, "OBU vehicle WebSocket unavailable");
}
function connectTargetSocket(deviceConfig) {
openSocket("/network/ws/network/targetPosition", (socket) => {
const targetIds = arrayValue(deviceConfig?.deviceConfig?.target);
socket.send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null }));
}, (value) => {
normalizeTargetVehicles(value).forEach((vehicle) => updateLiveVehicle(context.viewer, vehicle, state));
}, "Target vehicle WebSocket unavailable");
} }
ui.form.addEventListener("submit", async (event) => { ui.form.addEventListener("submit", async (event) => {
@@ -147,13 +192,13 @@
ui.live.hidden = false; ui.live.hidden = false;
loadLiveData().catch((error) => setStatus(error.message || "Live V2X data unavailable")); loadLiveData().catch((error) => setStatus(error.message || "Live V2X data unavailable"));
} }
return { state, load: loadLiveData, signOut, dispose: () => { disconnect(); clearEntities(); ui.root.remove(); } }; return { state, load: loadLiveData, signOut, dispose: () => { disconnect(); clearEntities(); state.vehicleSource = "waiting"; ui.root.remove(); } };
} }
function buildUi(state, settings) { function buildUi(state, settings) {
const root = document.createElement("aside"); const root = document.createElement("aside");
root.id = "v2xPanel"; root.id = "v2xPanel";
root.innerHTML = `<div class="v2x-heading"><strong>Live V2X Overlay</strong><span>Native: WGS84/ENU | V2X: GCJ-02</span></div> root.innerHTML = `<div class="v2x-heading"><strong>Live V2X Data</strong><span>Native: WGS84/ENU | V2X: GCJ-02</span></div>
<form id="v2xLoginForm"><label>Username<input name="userName" autocomplete="username" required></label><label>Password<input name="password" type="password" autocomplete="current-password" required></label><button type="submit">Sign in</button></form> <form id="v2xLoginForm"><label>Username<input name="userName" autocomplete="username" required></label><label>Password<input name="password" type="password" autocomplete="current-password" required></label><button type="submit">Sign in</button></form>
<div id="v2xLiveControls" hidden><label>Intersection code<input name="crossCode" value="${escapeAttribute(settings.crossCode || "")}" required></label><div><button type="button" data-action="reload">Refresh</button><button type="button" data-action="signout">Sign out</button></div></div> <div id="v2xLiveControls" hidden><label>Intersection code<input name="crossCode" value="${escapeAttribute(settings.crossCode || "")}" required></label><div><button type="button" data-action="reload">Refresh</button><button type="button" data-action="signout">Sign out</button></div></div>
<output id="v2xStatus"></output>`; <output id="v2xStatus"></output>`;
@@ -202,6 +247,64 @@
}); });
} }
function updateLiveVehicle(viewer, vehicle, state) {
const [longitude, latitude] = gcj02ToWgs84([vehicle.longitude, vehicle.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) {
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);
} else {
record.entity.position = position;
record.tracePositions.push(position);
if (record.tracePositions.length > 24) record.tracePositions.shift();
}
}
function parseSocketJson(value) {
if (typeof value !== "string" || value.includes('"heartBeat":"pong"')) return null;
try { return JSON.parse(value); } catch (_) { return null; }
}
function normalizeObuVehicle(value) {
const source = parseSocketJson(value);
const longitude = Number(source?.lon);
const latitude = Number(source?.lat);
const code = source?.carCode || source?.obuCode;
if (!code || !Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
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) {
const source = parseSocketJson(value);
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;
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 };
}).filter(Boolean));
}
function updateSignalPhases(value, state) { function updateSignalPhases(value, state) {
let lamps; let lamps;
try { lamps = JSON.parse(value).lamps; } catch (_) { return; } try { lamps = JSON.parse(value).lamps; } catch (_) { return; }
@@ -275,6 +378,6 @@
function rotate(value, count) { return (value << count) | (value >>> (32 - count)); } 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; } 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 }; createV2xCesiumOverlay.utils = { gcj02ToWgs84, joinUrl, md5, toWebSocketUrl, normalizeObuVehicle, normalizeTargetVehicles };
window.createV2xCesiumOverlay = createV2xCesiumOverlay; window.createV2xCesiumOverlay = createV2xCesiumOverlay;
}()); }());

View File

@@ -267,7 +267,8 @@ assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/);
const previewRuntime = fs.readFileSync(path.join(__dirname, "lib", "cesium-preview.js"), "utf8"); 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 v2xRuntime = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.js"), "utf8");
const buildAreaSource = fs.readFileSync(path.join(__dirname, "build-area.js"), "utf8"); const buildAreaSource = fs.readFileSync(path.join(__dirname, "build-area.js"), "utf8");
assert.match(buildAreaSource, /vehicleModelNames\.map\(\(name\) => `_preview\/\$\{name\}`\)/); assert.match(buildAreaSource, /const vehicleModelNames = \[\];/);
assert.doesNotMatch(buildAreaSource, /buildNativeTrafficSimulation/);
const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf"); const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf");
assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned with the project"); assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned with the project");
assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/); assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/);
@@ -278,7 +279,11 @@ assert.match(v2xRuntime, /\/facilities\/api\/sys\/login/);
assert.match(v2xRuntime, /sessionStorage/); assert.match(v2xRuntime, /sessionStorage/);
assert.match(v2xRuntime, /GCJ-02 -> WGS84 once/); assert.match(v2xRuntime, /GCJ-02 -> WGS84 once/);
assert.match(v2xRuntime, /\/network\/ws\/network\/signal/); assert.match(v2xRuntime, /\/network\/ws\/network\/signal/);
assert.match(v2xRuntime, /\/network\/ws\/network\/obuPosition/);
assert.match(v2xRuntime, /\/network\/ws\/network\/targetPosition/);
assert.match(v2xRuntime, /gcj02ToWgs84/); assert.match(v2xRuntime, /gcj02ToWgs84/);
assert.match(previewRuntime, /createLiveVehicleState\(\)/);
assert.doesNotMatch(previewRuntime, /const cruise = addVehicleCruises\(/);
assert.match(previewRuntime, /new Cesium\.ScreenSpaceEventHandler/); assert.match(previewRuntime, /new Cesium\.ScreenSpaceEventHandler/);
assert.match(previewRuntime, /vehicleId: record\.id/); assert.match(previewRuntime, /vehicleId: record\.id/);
assert.match(previewRuntime, /status === "breakdown"\s+\? "vehicle-breakdown\.png"/); assert.match(previewRuntime, /status === "breakdown"\s+\? "vehicle-breakdown\.png"/);
@@ -289,9 +294,7 @@ assert.match(previewRuntime, /\(\) => record\?\.status === "normal"/);
assert.match(previewRuntime, /Cesium\.SceneTransforms\.worldToWindowCoordinates/); assert.match(previewRuntime, /Cesium\.SceneTransforms\.worldToWindowCoordinates/);
assert.match(previewRuntime, /TrafficSignalDynamic_/); assert.match(previewRuntime, /TrafficSignalDynamic_/);
assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/); assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
assert.match(previewRuntime, /native-preview-traffic-simulation\/v1/); assert.doesNotMatch(previewRuntime, /native-preview-traffic-simulation\/v1/);
assert.match(previewRuntime, /leader-gap/);
assert.match(previewRuntime, /stopReason = "traffic-signal"/);
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/); assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
assert.match(previewRuntime, /setBuildingGhost/); assert.match(previewRuntime, /setBuildingGhost/);
assert.match(previewRuntime, /fetch\(url, \{ cache: "no-store" \}\)/); assert.match(previewRuntime, /fetch\(url, \{ cache: "no-store" \}\)/);

View File

@@ -15,7 +15,7 @@ const context = {
}; };
vm.runInNewContext(source, context, { filename: "v2x-cesium-overlay.js" }); vm.runInNewContext(source, context, { filename: "v2x-cesium-overlay.js" });
const { gcj02ToWgs84, joinUrl, md5, toWebSocketUrl } = context.window.createV2xCesiumOverlay.utils; const { gcj02ToWgs84, joinUrl, md5, toWebSocketUrl, normalizeObuVehicle, normalizeTargetVehicles } = context.window.createV2xCesiumOverlay.utils;
assert.equal(md5(""), "d41d8cd98f00b204e9800998ecf8427e"); assert.equal(md5(""), "d41d8cd98f00b204e9800998ecf8427e");
assert.equal(md5("password"), "5f4dcc3b5aa765d61d8327deb882cf99"); assert.equal(md5("password"), "5f4dcc3b5aa765d61d8327deb882cf99");
assert.equal(joinUrl("/api/", "/facilities/api/sys/login"), "/api/facilities/api/sys/login"); assert.equal(joinUrl("/api/", "/facilities/api/sys/login"), "/api/facilities/api/sys/login");
@@ -27,5 +27,12 @@ const reference = referenceGcj02ToWgs84([114.12864875054062, 30.460485279762146]
assert.ok(Math.abs(converted[0] - reference[0]) < 1e-12); assert.ok(Math.abs(converted[0] - reference[0]) < 1e-12);
assert.ok(Math.abs(converted[1] - reference[1]) < 1e-12); assert.ok(Math.abs(converted[1] - reference[1]) < 1e-12);
assert.deepEqual(gcj02ToWgs84([Infinity, 30]), [NaN, NaN]); assert.deepEqual(gcj02ToWgs84([Infinity, 30]), [NaN, NaN]);
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);
console.log("V2X Cesium overlay tests passed."); console.log("V2X Cesium overlay tests passed.");