diff --git a/.trellis/spec/preview/index.md b/.trellis/spec/preview/index.md index f0c6d25..d71faa8 100644 --- a/.trellis/spec/preview/index.md +++ b/.trellis/spec/preview/index.md @@ -307,6 +307,66 @@ npm run build:area -- --config config/areas/.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= +WS /network/ws/network/targetPosition?authorization= +``` + +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/.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 ```bash diff --git a/.trellis/tasks/08-24-v2x-amap-intersection-data/design.md b/.trellis/tasks/08-24-v2x-amap-intersection-data/design.md index 0ac4f96..fb23ad6 100644 --- a/.trellis/tasks/08-24-v2x-amap-intersection-data/design.md +++ b/.trellis/tasks/08-24-v2x-amap-intersection-data/design.md @@ -4,7 +4,9 @@ The native pipeline remains the source of static scene geometry and package 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 Three dependencies. @@ -65,11 +67,11 @@ so preserves its compiler behavior and test coverage. ## Compatibility And Failure Handling - 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 than presenting stale data as current. -- A REST/WS capability failure is shown in diagnostics; native scene, signals, - and traffic simulation remain usable. +- A REST/WS capability failure is shown in diagnostics; native scene and signals + remain usable, while the vehicle layer stays empty rather than simulating data. - Removing the optional overlay/support files restores current preview behavior without changing static package contracts. diff --git a/.trellis/tasks/08-24-v2x-amap-intersection-data/implement.md b/.trellis/tasks/08-24-v2x-amap-intersection-data/implement.md index b1491a7..4c51706 100644 --- a/.trellis/tasks/08-24-v2x-amap-intersection-data/implement.md +++ b/.trellis/tasks/08-24-v2x-amap-intersection-data/implement.md @@ -9,7 +9,8 @@ 3. Implement the V2X browser client: login, session token lifecycle, REST envelope handling, capability errors, and signal WebSocket lifecycle. 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 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 diff --git a/.trellis/tasks/08-24-v2x-amap-intersection-data/prd.md b/.trellis/tasks/08-24-v2x-amap-intersection-data/prd.md index b9dd020..ca4ed7a 100644 --- a/.trellis/tasks/08-24-v2x-amap-intersection-data/prd.md +++ b/.trellis/tasks/08-24-v2x-amap-intersection-data/prd.md @@ -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. - R6: Make endpoint origin, selected V2X intersection code, and authorization 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 @@ -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. - Building a full user/role management service. - Replacing native road geometry or traffic-signal contracts with V2X data. +- Generated vehicle-route or traffic-simulation fallback in the operations preview. ## Authentication Decision diff --git a/docs/v2x-cesium-preview.md b/docs/v2x-cesium-preview.md index c3c0cd8..b7df994 100644 --- a/docs/v2x-cesium-preview.md +++ b/docs/v2x-cesium-preview.md @@ -1,9 +1,10 @@ # Live V2X Cesium Preview -The Cesium preview can overlay live V2X operational data on a compiled native -intersection. This is an optional verification feature; the package manifest, -native road geometry, traffic signals, and deterministic traffic simulation do -not depend on a V2X service being available. +The Cesium preview displays live V2X operational data on a compiled native +intersection. The package manifest, native road geometry, and traffic signals +remain independent of the service. Vehicle entities and their trace lines are +created only from authenticated V2X streams; generated routes and traffic +simulation are never rendered as a substitute. ## 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 configuration, bound devices, device configuration, weekly traffic flow ratio, -and `/network/ws/network/signal` phase updates. Failures are shown in the V2X -panel and do not block the static Cesium scene. +and `/network/ws/network/signal` 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. ## Coordinate Contract | 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. | | High德 reference GeoJSON | GCJ-02 | Compiler calibration input only; never loaded by this preview. | diff --git a/scripts/build-area.js b/scripts/build-area.js index 0dbc2ca..96952aa 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -16,7 +16,6 @@ const { } = require("./lib/scene-layers"); const { digest: glbDigest } = require("./glb-digest"); const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route"); -const { buildNativeTrafficSimulation } = require("./lib/native-preview-traffic-simulation"); const { writePreviewVehicleLibrary } = require("./lib/vehicle-library"); const { readTrafficSignals } = require("./lib/traffic-signals"); const { @@ -576,11 +575,9 @@ function writeCesiumPreview(area, roadProvider) { }); } else { Object.assign(previewInputs, nativeRoadRecords(area)); - const simulation = buildNativeTrafficSimulation(area); - fs.mkdirSync(path.dirname(area.outputs.trafficSimulation), { recursive: true }); - fs.writeFileSync(area.outputs.trafficSimulation, `${JSON.stringify(simulation, null, 2)}\n`); - previewInputs.trafficSimulation = fileRecord(area.outputs.trafficSimulation); - routeArtifact = area.outputs.trafficSimulation; + // This operational preview intentionally has no synthetic vehicle source. + // A stale descriptor from an older build must not resurrect simulated routes. + fs.rmSync(area.outputs.trafficSimulation, { force: true }); } const htmlPath = area.outputs.cesiumPreview; 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. fs.rmSync(area.outputs.vehicleRoute, { force: true }); } - const vehicleModelNames = writeVehicleModel(area); + const vehicleModelNames = []; 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 = previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel); - 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 vehicleModelName = null; + const descriptor = { routeName, vehicleModelName, vehicleModelNames, 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.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}`); const finished = Date.now(); writeStageManifest(area, { diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 29fa15a..541e06f 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -48,7 +48,6 @@ const metadata = await fetchJson(config.metadataName); const descriptor = config.previewDescriptorName ? await fetchOptionalJson(config.previewDescriptorName) : null; adaptPackageManifest(metadata, descriptor); - const routeData = await fetchOptionalJson(config.routeName); const signalData = await fetchOptionalJson(config.trafficSignalsName); const placement = scenePlacement(metadata); const viewer = createViewer(); @@ -56,7 +55,7 @@ const assets = await loadSceneAssets(viewer, metadata, placement); const trafficStart = Cesium.JulianDate.now(); 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 v2xOverlay = typeof window.createV2xCesiumOverlay === "function" ? window.createV2xCesiumOverlay({ viewer, metadata, placement, config }) @@ -66,7 +65,7 @@ buildSemanticToggles(viewer, assets, placement); bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals); bindVehicleInfoCard(viewer, cruise); - startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals); + startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals, v2xOverlay); cameras.overview(); baseStatus = summaryText(metadata, assets, cruise); setStatus(baseStatus); @@ -590,6 +589,7 @@ baseSpeed: speed, state: { selectedIndex: 0 }, simulation, + usingLiveData: false, }; syncSelectedRouteVisibility(cruise); return cruise; @@ -597,10 +597,20 @@ function syncSelectedRouteVisibility(cruise) { 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) { const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model); const countdownModels = new Map(assets @@ -1324,7 +1334,7 @@ // 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. - function startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals) { + function startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals, v2xOverlay) { const center = placement.position; const stats = metadata.scene_stats || {}; const failed = assets.filter((asset) => asset.error); @@ -1345,11 +1355,9 @@ "Trees: " + Number(stats.trees || 0), "Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback") ]; - if (cruise.simulation) { - const simulation = cruise.simulation.diagnostics(); - lines.push("Simulation: native-preview-traffic-simulation/v1"); - lines.push("Stopped: " + simulation.stoppedVehicles); - lines.push("Queue: " + simulation.queueLength); + if (v2xOverlay?.state) { + lines.push("Vehicle data: " + (v2xOverlay.state.vehicleSource === "live" ? "live V2X" : "waiting for live V2X")); + lines.push("Live vehicles: " + v2xOverlay.state.vehicles.size); } if (failed.length) { lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", ")); @@ -1371,7 +1379,7 @@ return [ config.areaId, 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), "trees " + Number(stats.trees || 0) ].filter(Boolean).join(" | "); diff --git a/scripts/lib/v2x-cesium-overlay.js b/scripts/lib/v2x-cesium-overlay.js index 43fbda2..95bad9a 100644 --- a/scripts/lib/v2x-cesium-overlay.js +++ b/scripts/lib/v2x-cesium-overlay.js @@ -13,11 +13,13 @@ const state = { token: sessionStorage.getItem(TOKEN_KEY) || "", entities: [], + vehicles: new Map(), linkPhases: new Map(), - socket: null, + sockets: [], status: "Sign in to load live V2X data.", crossCode: settings.crossCode || "", metrics: null, + vehicleSource: "waiting", }; const ui = buildUi(state, settings); @@ -29,14 +31,25 @@ 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); + }); + state.vehicles.clear(); state.linkPhases.clear(); } function disconnect() { - if (state.socket) state.socket.close(); - state.socket = null; + state.sockets.forEach((socket) => socket.close()); + state.sockets = []; } + function useLiveVehicles() { + if (state.vehicleSource === "live") return; + state.vehicleSource = "live"; + } + state.activateLiveVehicles = useLiveVehicles; + async function request(path) { const response = await fetch(joinUrl(settings.apiBaseUrl || "/api", path), { headers: state.token ? { Authorization: state.token } : {}, @@ -77,6 +90,7 @@ function signOut(message) { disconnect(); clearEntities(); + state.vehicleSource = "waiting"; state.token = ""; sessionStorage.removeItem(TOKEN_KEY); ui.form.hidden = false; @@ -92,6 +106,7 @@ } state.crossCode = crossCode; clearEntities(); + state.vehicleSource = "waiting"; setStatus("Loading live V2X intersection data..."); const results = await Promise.allSettled([ request(`/network/api/link/network/queryCrossLinkInfo/${encodeURIComponent(crossCode)}`), @@ -111,25 +126,55 @@ 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"); - connectSignalSocket(); + 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(", ")}.` : ""}`); } - function connectSignalSocket() { + function connectLiveSockets(deviceConfig) { 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 { - state.socket = new WebSocket(socketUrl); - 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.`); + 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} 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) => { event.preventDefault(); try { @@ -147,13 +192,13 @@ ui.live.hidden = false; 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) { const root = document.createElement("aside"); root.id = "v2xPanel"; - root.innerHTML = `
Live V2X OverlayNative: WGS84/ENU | V2X: GCJ-02
+ root.innerHTML = `
Live V2X DataNative: WGS84/ENU | V2X: GCJ-02
`; @@ -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) { let lamps; try { lamps = JSON.parse(value).lamps; } catch (_) { return; } @@ -275,6 +378,6 @@ 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 }; + createV2xCesiumOverlay.utils = { gcj02ToWgs84, joinUrl, md5, toWebSocketUrl, normalizeObuVehicle, normalizeTargetVehicles }; window.createV2xCesiumOverlay = createV2xCesiumOverlay; }()); diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 1320062..c4c3145 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -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 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, /vehicleModelNames\.map\(\(name\) => `_preview\/\$\{name\}`\)/); +assert.match(buildAreaSource, /const vehicleModelNames = \[\];/); +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"); 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, /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, /gcj02ToWgs84/); +assert.match(previewRuntime, /createLiveVehicleState\(\)/); +assert.doesNotMatch(previewRuntime, /const cruise = addVehicleCruises\(/); assert.match(previewRuntime, /new Cesium\.ScreenSpaceEventHandler/); assert.match(previewRuntime, /vehicleId: record\.id/); 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, /TrafficSignalDynamic_/); assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/); -assert.match(previewRuntime, /native-preview-traffic-simulation\/v1/); -assert.match(previewRuntime, /leader-gap/); -assert.match(previewRuntime, /stopReason = "traffic-signal"/); +assert.doesNotMatch(previewRuntime, /native-preview-traffic-simulation\/v1/); assert.match(previewRuntime, /ColorBlendMode\.REPLACE/); assert.match(previewRuntime, /setBuildingGhost/); assert.match(previewRuntime, /fetch\(url, \{ cache: "no-store" \}\)/); diff --git a/scripts/test-v2x-cesium-overlay.js b/scripts/test-v2x-cesium-overlay.js index 659eb8d..f3013ac 100644 --- a/scripts/test-v2x-cesium-overlay.js +++ b/scripts/test-v2x-cesium-overlay.js @@ -15,7 +15,7 @@ const context = { }; 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("password"), "5f4dcc3b5aa765d61d8327deb882cf99"); 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[1] - reference[1]) < 1e-12); 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.");