fix: use live V2X vehicles only in preview
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
- Good:OBU 和感知目标连续推送,页面只显示对应车辆的实际行驶轨迹。
|
||||||
|
- 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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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(" | ");
|
||||||
|
|||||||
@@ -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,25 +126,55 @@
|
|||||||
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();
|
||||||
|
connectObuSocket();
|
||||||
|
connectTargetSocket(deviceConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSocket(path, onOpen, onMessage, unavailable) {
|
||||||
|
const socketUrl = toWebSocketUrl(joinUrl(settings.wsBaseUrl || "/websocket", path), state.token);
|
||||||
try {
|
try {
|
||||||
state.socket = new WebSocket(socketUrl);
|
const socket = new WebSocket(socketUrl);
|
||||||
state.socket.onopen = () => state.socket?.send(JSON.stringify({ junctionId: state.crossCode }));
|
state.sockets.push(socket);
|
||||||
state.socket.onmessage = (event) => updateSignalPhases(event.data, state);
|
socket.onopen = () => onOpen?.(socket);
|
||||||
state.socket.onerror = () => setStatus(`${state.status} Signal WebSocket unavailable.`);
|
socket.onmessage = (event) => onMessage(event.data);
|
||||||
|
socket.onerror = () => setStatus(`${state.status} ${unavailable}.`);
|
||||||
|
return socket;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
setStatus(`${state.status} Signal WebSocket unavailable.`);
|
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) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -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;
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -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" \}\)/);
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
Reference in New Issue
Block a user