diff --git a/.trellis/spec/config/index.md b/.trellis/spec/config/index.md index 8c912d4..f4e4366 100644 --- a/.trellis/spec/config/index.md +++ b/.trellis/spec/config/index.md @@ -55,6 +55,7 @@ cp config/examples/template.json config/areas/my-area.json | `osm2streets` | | 见下 | 透传给 osm2streets 的选项 | | `blender` | | 见下 | Blender 侧选项 | | `nativeRoad` | | 见下 | 原生道路编译选项 | +| `v2xPreview` | | 见下 | 可选的 Cesium 预览实时 V2X 叠加设置 | | `compress` | | 见下 | 默认交付压缩阶段的 GLB 压缩选项 | | `budget` | | 见下 | 区域 GLB 性能与体量预算 | | `outputs` | | 从 `id` 推导 | 输出路径覆盖,逃生舱 | @@ -146,6 +147,24 @@ cp config/examples/template.json config/areas/my-area.json | `junctionTemplates.clusters[].cornerRadiusMeters` | `12`(4–25) | 仅 `complex-junction-v1`:相邻进口夹角处路缘圆角的半径。圆角切于两侧最外道路边缘,只补齐夹角处的路面,不改变 connector、信号或停止线。 | | `junctionTemplates.clusters[].outerRadiusExtraMeters` | `18`(18–35) | 仅 `complex-junction-v1`:路口中心到外部进口交接边界的额外半径。用于让圆角包住角部斑马线;未配置时保持原有 18 m。 | +### `v2xPreview` + +这是 Cesium 验证预览的可选实时叠加层,不进入发布的 `package/`。默认值: + +```json +{ + "enabled": false, + "apiBaseUrl": "/api", + "wsBaseUrl": "/websocket", + "crossCode": "" +} +``` + +`apiBaseUrl` 和 `wsBaseUrl` 应使用同源反向代理路径,不能写入私有上游主机、账号或令牌。 +V2X 接口返回的地图数据是 GCJ-02;浏览器预览在创建 Cesium entity 前一次性转为 WGS84, +而 native package 的 WGS84/ENU 契约保持不变。详见 +[`docs/v2x-cesium-preview.md`](../../../docs/v2x-cesium-preview.md)。 + ### `compress` 完整构建和显式 `--stages compress` 都使用此配置。默认压缩链是 texture resize + WebP diff --git a/.trellis/tasks/08-24-v2x-amap-intersection-data/check.jsonl b/.trellis/tasks/08-24-v2x-amap-intersection-data/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-24-v2x-amap-intersection-data/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-24-v2x-amap-intersection-data/design.md b/.trellis/tasks/08-24-v2x-amap-intersection-data/design.md new file mode 100644 index 0000000..0ac4f96 --- /dev/null +++ b/.trellis/tasks/08-24-v2x-amap-intersection-data/design.md @@ -0,0 +1,80 @@ +# Design: Live V2X Cesium Intersection View + +## Architecture + +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 +preview architecture and does not import the source dashboard's Vue, AMap, or +Three dependencies. + +The generated preview runtime is divided into four browser concerns: + +1. `auth`: presents the V2X sign-in form, MD5-hashes the password to match the + source contract, stores a successful token in `sessionStorage`, and clears it + on sign-out or authorization failure. +2. `v2x-client`: owns configured REST/WS origins, adds the raw `Authorization` + request header, normalizes the source API envelope, and reports a capability + status rather than blanking the preview if an optional resource fails. +3. `coordinates`: labels external values as GCJ-02 and native values as WGS84 or + ENU. It converts V2X GCJ-02 positions to WGS84 exactly once before Cesium + entity creation; the existing native package placement contract is unchanged. +4. `cesium-v2x-overlay`: adds V2X links, devices, pole/configuration evidence, + metrics, and real signal phase updates to the existing Cesium viewer, with + independent visibility controls and diagnostics. + +## Data Flow + +```text +V2X sign-in -> session token + | | + | +--> Authorization header / WS query parameter + v +configured V2X REST + WS endpoints -> GCJ-02 V2X geometry, devices, metrics, lamps + | + v + GCJ-02-to-WGS84 adapter + | +native package + route/signal descriptors -> WGS84/ENU -> existing Cesium viewer + ^ + | + Cesium V2X operations overlay +``` + +## Endpoint Contract + +The first delivery consumes `queryCrossLinkInfo`, `queryPoles`, bound-device, +cross-device-config, and weekly flow-ratio APIs. It subscribes to +`/network/ws/network/signal` only after link data establishes usable phases. +Endpoint host/prefixes and selected `crossCode` are deployment configuration. A +development proxy forwards REST and WebSocket traffic so the browser never needs +a hard-coded private origin. + +## Coordinate Contract + +| Producer | Source CRS | Consumer rule | +| --- | --- | --- | +| Native compiler package, traffic signals, route descriptor | WGS84 / local ENU | Preserve the existing Cesium package placement and WGS84 entity contracts. | +| V2X dashboard link, road, device, and pole data | GCJ-02 | Convert to WGS84 once before creating Cesium entities. | +| High德 reference GeoJSON | GCJ-02 | Calibration-only compiler input; no runtime map or SDK dependency. | + +The existing `gaode-junction-reference` inverse conversion is an appropriate +source for the V2X adapter. It will be factored or safely reused only when doing +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. +- 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. +- Removing the optional overlay/support files restores current preview behavior + without changing static package contracts. + +## Security + +`sessionStorage` limits the upstream token to the current tab session. Credentials +are never persisted. Configuration examples use placeholders, and deployment +documentation requires an HTTPS same-origin reverse proxy for V2X API/WS traffic. diff --git a/.trellis/tasks/08-24-v2x-amap-intersection-data/implement.jsonl b/.trellis/tasks/08-24-v2x-amap-intersection-data/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-24-v2x-amap-intersection-data/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-24-v2x-amap-intersection-data/implement.md b/.trellis/tasks/08-24-v2x-amap-intersection-data/implement.md new file mode 100644 index 0000000..b1491a7 --- /dev/null +++ b/.trellis/tasks/08-24-v2x-amap-intersection-data/implement.md @@ -0,0 +1,40 @@ +# Implementation Plan: Live V2X Cesium Intersection View + +1. Read preview and pipeline specifications, then add a focused V2X preview + configuration. Keep secrets and private origins out of defaults and tracked + area configuration. +2. Add browser-safe GCJ-02-to-WGS84 conversion and focused tests for source + coordinate labels and prevention of duplicate conversion. Preserve the current + compiler reference conversion behavior. +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. +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 + construction, coordinate conversion, source-layer diagnostics, and graceful + unavailable/auth states. Run existing preview and native-road regressions. +7. Validate in a browser with a real V2X account and configured proxy: sign in, + inspect aligned native/V2X features, verify REST panels and signal updates, + then verify sign-out/token expiry behavior. + +## Validation Commands + +```bash +node --check scripts/lib/area-preview.js +node --check scripts/lib/cesium-preview.js +npm run test:gaode-junction-reference +npm run test:preview-assets +npm run test:native-preview-traffic +npm run test:traffic-signals +npm run road:compile -- --config config/areas/fengshu-er-road.json +npm run build:area -- --config config/areas/fengshu-er-road.json --stages preview +``` + +## Review Gates + +- Confirm no token, account, or private host is committed. +- Verify V2X GCJ-02 coordinates convert once before Cesium use. +- Verify the existing native WGS84/ENU scene contract remains unchanged. +- Verify the current Cesium preview works without V2X configuration or sign-in. diff --git a/.trellis/tasks/08-24-v2x-amap-intersection-data/prd.md b/.trellis/tasks/08-24-v2x-amap-intersection-data/prd.md new file mode 100644 index 0000000..b9dd020 --- /dev/null +++ b/.trellis/tasks/08-24-v2x-amap-intersection-data/prd.md @@ -0,0 +1,81 @@ +# Integrate live V2X intersection data into Cesium preview + +## Goal + +Provide a Cesium-based operational view of the current complex intersection in +this repository. It must show live data from the existing V2X platform while +retaining the native road compiler's WGS84/ENU output as the geometry authority. + +## Confirmed Facts + +- The source dashboard's holographic-intersection view uses V2X REST resources for + road/link geometry, device bindings, poles, configuration, traffic metrics, and + a token-authenticated signal WebSocket. +- The dashboard development proxy targets `172.16.1.159:50400` under `/api`, + `/websocket`, and `/vectortile`; the application sends the upstream token as the + `Authorization` header. +- The source dashboard uses AMap and therefore its road/link data is GCJ-02. Its + road-network exporter converts that data from GCJ-02 to WGS84 before export. +- This repository's native road, signal, and simulation artifacts are WGS84/ENU. +- The target is the existing Cesium preview, not AMap. High德 data in this + repository is a calibration/reference GeoJSON only, not a target runtime map. +- The requested page has a simple login gate; V2X data must remain real live + interface data, not copied mock data. + +## Requirements + +- R1: Add a Cesium-based complex-intersection operations view to this repository, protected by + a simple application login. +- R2: Integrate the real V2X REST endpoints used by the source dashboard, including + current intersection/link geometry, devices, poles/configuration, traffic metrics, + and signal state where upstream authorization permits it. +- R3: Keep the V2X platform credentials separate from the simple application login; + do not put a reusable upstream credential into browser source or committed files. +- R4: Treat V2X map-facing coordinates as GCJ-02. Treat native compiler artifacts + as WGS84/ENU, and convert V2X GCJ-02 coordinates to WGS84 exactly once before + adding them to Cesium. +- R5: Surface a clear unavailable/authentication state when live data cannot be + 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. + +## Candidate Upstream Resources + +- `GET /network/api/link/network/queryCrossLinkInfo/{crossCode}`: intersection + link geometry and signal phase association. +- `GET /network/api/pole/network/queryPoles/{crossCode}`: pole and light placement + configuration. +- `GET /facilities/api/crossDevice/findDeviceByCrossCode/{crossCode}`: bound devices. +- `GET /facilities/api/crossDeviceConfig/{crossCode}`: target-device configuration. +- `GET /facilities/api/FlowTravelRatio/queryListWeek?code={crossCode}` and related + cross-monitor resources: operational metrics. +- `WS /network/ws/network/signal?authorization={token}`: live signal lamps. + +## Acceptance Criteria + +- [ ] A user can pass the local login gate and open the current complex-intersection + Cesium view. +- [ ] The page retrieves and renders live V2X data for a configurable intersection + code without source-dashboard runtime dependencies. +- [ ] The page identifies the coordinate reference of each source and visually + aligns native WGS84 geometry with V2X data converted from GCJ-02. +- [ ] A missing/expired upstream credential or unavailable service is visible and + does not make the native intersection view unusable. +- [ ] Upstream URL, intersection code, and credentials/tokens are excluded from + tracked application source and documented for deployment. + +## Out Of Scope + +- 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. + +## Authentication Decision + +- The page signs in to the upstream V2X service with the user-entered account, + posting `userName` and MD5-hashed `password` to + `/facilities/api/sys/login` through the configured API prefix. +- The returned token lives only in `sessionStorage`, is sent as the raw + `Authorization` header for REST requests, and is supplied as the + `authorization` WebSocket query parameter for the signal subscription. +- No default credentials, reusable token, or API secret may be committed. diff --git a/.trellis/tasks/08-24-v2x-amap-intersection-data/task.json b/.trellis/tasks/08-24-v2x-amap-intersection-data/task.json new file mode 100644 index 0000000..ee5bcb4 --- /dev/null +++ b/.trellis/tasks/08-24-v2x-amap-intersection-data/task.json @@ -0,0 +1,26 @@ +{ + "id": "v2x-amap-intersection-data", + "name": "v2x-amap-intersection-data", + "title": "Integrate live V2X intersection data into AMap preview", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-24", + "completedAt": null, + "branch": null, + "base_branch": "fengshu-er-road", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/config/areas/fengshu-er-road.json b/config/areas/fengshu-er-road.json index a9defe8..74148c6 100644 --- a/config/areas/fengshu-er-road.json +++ b/config/areas/fengshu-er-road.json @@ -61,6 +61,12 @@ "officeOverrides": "", "roadProvider": "native" }, + "v2xPreview": { + "enabled": true, + "apiBaseUrl": "/api", + "wsBaseUrl": "/websocket", + "crossCode": "" + }, "compress": { "textureSize": 768, "quality": 82, diff --git a/config/examples/template.json b/config/examples/template.json index 3b3f221..bb84a57 100644 --- a/config/examples/template.json +++ b/config/examples/template.json @@ -44,6 +44,12 @@ "officeOverrides": "", "roadProvider": "native" }, + "v2xPreview": { + "enabled": false, + "apiBaseUrl": "/api", + "wsBaseUrl": "/websocket", + "crossCode": "" + }, "compress": { "textureSize": 768, "quality": 82, diff --git a/docs/v2x-cesium-preview.md b/docs/v2x-cesium-preview.md new file mode 100644 index 0000000..c3c0cd8 --- /dev/null +++ b/docs/v2x-cesium-preview.md @@ -0,0 +1,62 @@ +# 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. + +## Configuration + +Add a non-sensitive `v2xPreview` object to the area configuration: + +```json +{ + "v2xPreview": { + "enabled": true, + "apiBaseUrl": "/api", + "wsBaseUrl": "/websocket", + "crossCode": "420100023333" + } +} +``` + +`apiBaseUrl` and `wsBaseUrl` should normally be relative, same-origin paths. +The deployment server proxies them to the V2X REST and WebSocket services. Do +not commit a private upstream address, an account, a token, or an AMap key. + +The browser sends `POST {apiBaseUrl}/facilities/api/sys/login` with `userName` +and an MD5-hashed password, matching the source V2X dashboard. It holds the +returned token in `sessionStorage` only, sends it as the raw `Authorization` +header for REST calls, and uses it as the `authorization` parameter for the +signal WebSocket. Closing the tab clears the session token. + +## Data Sources + +After sign-in the preview reads the selected intersection's links, pole +configuration, bound devices, device configuration, weekly traffic flow ratio, +and `/network/ws/network/signal` phase updates. Failures are shown in the V2X +panel and do not block the static Cesium scene. + +## Coordinate Contract + +| Data | Coordinate system | Preview handling | +| --- | --- | --- | +| Native package placement, routes, 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. | + +Do not convert native WGS84 data again, and do not pass V2X GCJ-02 coordinates +directly to Cesium. Either error produces a visible intersection offset. + +## Proxy Requirements + +Use an HTTPS reverse proxy which forwards the configured REST prefix and supports +WebSocket upgrade for the configured WS prefix. The generated preview is static, +so direct cross-origin calls are likely to fail CORS or expose an internal origin. +The V2X panel reports that condition while leaving native preview controls usable. + +## Rollback + +`v2xPreview.enabled` defaults to `false`; set it to `true` for a selected area. +Set it back to `false` and regenerate the preview. The generated +page omits the V2X panel; published package contents remain unchanged. diff --git a/package.json b/package.json index 4dc0b12..d157e16 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "test:turn-lane-arrows": "node scripts/test-turn-lane-arrows.js", "test:traffic-signals": "node scripts/test-traffic-signals.js", "test:native-preview-traffic": "node scripts/test-native-preview-traffic.js", + "test:v2x-cesium-preview": "node scripts/test-v2x-cesium-overlay.js", "render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js" }, "dependencies": { diff --git a/scripts/build-area.js b/scripts/build-area.js index 328b766..0dbc2ca 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -559,6 +559,7 @@ function writeCesiumPreview(area, roadProvider) { osm: fileRecord(area.input), previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")), previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")), + v2xPreviewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "v2x-cesium-overlay.js")), }; if (roadProvider === "osm2streets") { const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson"); @@ -602,7 +603,7 @@ function writeCesiumPreview(area, roadProvider) { const descriptor = { routeName, vehicleModelName: previewRelativePath(area.outputs.areaDir, area.outputs.vehicleModel), vehicleModelNames: vehicleModelNames.map((name) => `_preview/${name}`), trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] }; fs.mkdirSync(area.outputs.previewDir, { recursive: true }); fs.writeFileSync(area.outputs.previewDescriptor, `${JSON.stringify(descriptor, null, 2)}\n`); - fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames.map((name) => `_preview/${name}`), "package/runtime/traffic-signals.json", "_preview/descriptor.json")); + fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames.map((name) => `_preview/${name}`), "package/runtime/traffic-signals.json", "_preview/descriptor.json", area.v2xPreview)); console.log(`Cesium preview: ${htmlPath}`); const finished = Date.now(); writeStageManifest(area, { diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index 3ba229b..d5b88b3 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -127,12 +127,33 @@ function normalizeAreaConfig(raw, options = {}) { officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "", roadProvider: roadProviderOption(raw.blender?.roadProvider ?? "native"), }, + v2xPreview: normalizeV2xPreviewConfig(raw.v2xPreview), compress, budget, outputs, }; } +function normalizeV2xPreviewConfig(raw) { + if (raw !== undefined && (raw === null || typeof raw !== "object" || Array.isArray(raw))) { + throw new Error("v2xPreview must be an object"); + } + const value = raw || {}; + const text = (name, fallback) => { + const item = value[name] ?? fallback; + if (typeof item !== "string") throw new Error(`v2xPreview.${name} must be a string`); + return item.trim(); + }; + return { + enabled: booleanOption(value.enabled, false, "v2xPreview.enabled"), + // Relative defaults keep credentials and private service origins out of the + // generated preview. Production should reverse-proxy these prefixes. + apiBaseUrl: text("apiBaseUrl", "/api"), + wsBaseUrl: text("wsBaseUrl", "/websocket"), + crossCode: text("crossCode", ""), + }; +} + function normalizeJunctionTemplates(raw, repoRoot) { if (raw === undefined || raw === null) return { enabled: false, references: [] }; if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("nativeRoad.junctionTemplates must be an object"); diff --git a/scripts/lib/area-preview.js b/scripts/lib/area-preview.js index 3d4933b..1e27b63 100644 --- a/scripts/lib/area-preview.js +++ b/scripts/lib/area-preview.js @@ -7,6 +7,7 @@ function writeCesiumPreviewSupportFiles(outDir) { const files = [ [path.join(__dirname, "cesium-preview.css"), "cesium-preview.css"], [path.join(__dirname, "cesium-preview.js"), "cesium-preview.js"], + [path.join(__dirname, "v2x-cesium-overlay.js"), "v2x-cesium-overlay.js"], [path.join(__dirname, "..", "..", "assets", "preview", "vehicle-breakdown.png"), "vehicle-breakdown.png"], [path.join(__dirname, "..", "..", "assets", "preview", "vehicle-accident.png"), "vehicle-accident.png"], ]; @@ -18,7 +19,7 @@ function writeCesiumPreviewSupportFiles(outDir) { } } -function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null, previewDescriptorName = null) { +function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null, previewDescriptorName = null, v2xPreview = null) { const previewConfig = { areaId, glbName, @@ -28,6 +29,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a vehicleModelNames, trafficSignalsName, previewDescriptorName, + v2xPreview, }; return ` @@ -100,6 +102,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a + diff --git a/scripts/lib/cesium-preview.css b/scripts/lib/cesium-preview.css index 9e5b221..12434cf 100644 --- a/scripts/lib/cesium-preview.css +++ b/scripts/lib/cesium-preview.css @@ -161,6 +161,74 @@ body.scene-error .loading-bar span { white-space: pre-line; } +#v2xPanel { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 2; + box-sizing: border-box; + width: min(300px, calc(100vw - 24px)); + padding: 10px 12px; + border-radius: 4px; + background: rgba(20, 24, 28, 0.88); + color: #fff; + font-size: 12px; + line-height: 1.45; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22); +} + +.v2x-heading { + display: grid; + gap: 2px; + margin-bottom: 8px; +} + +.v2x-heading span, +#v2xStatus { + color: rgba(255, 255, 255, 0.68); +} + +#v2xLoginForm, +#v2xLiveControls { + display: grid; + gap: 7px; +} + +#v2xPanel label { + display: grid; + gap: 3px; +} + +#v2xPanel input { + box-sizing: border-box; + width: 100%; + height: 27px; + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 3px; + padding: 0 7px; + background: rgba(255, 255, 255, 0.12); + color: #fff; + font: inherit; +} + +#v2xPanel button { + height: 27px; + margin-right: 6px; + border: 0; + border-radius: 3px; + padding: 0 8px; + background: #e9f3f5; + color: #112326; + font: inherit; + cursor: pointer; +} + +#v2xStatus { + display: block; + margin-top: 8px; + overflow-wrap: anywhere; +} + #vehicleInfoCard { left: 0; top: 0; diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index b78db93..29fa15a 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -58,6 +58,9 @@ const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart, assets); const cruise = addVehicleCruises(viewer, routeData, signalData, trafficStart, config.vehicleModelNames, config.vehicleModelName); const cameras = createCameraPresets(viewer, metadata, placement, cruise); + const v2xOverlay = typeof window.createV2xCesiumOverlay === "function" + ? window.createV2xCesiumOverlay({ viewer, metadata, placement, config }) + : null; buildAssetToggles(assets); buildSemanticToggles(viewer, assets, placement); @@ -72,7 +75,7 @@ document.body.classList.add("scene-ready"); // Handle for the browser console and for headless checks: everything else // in here is closed over by the IIFE and unreachable from outside. - window.osmPreview = { viewer, metadata, placement, assets, cruise, trafficSignals, cameras }; + window.osmPreview = { viewer, metadata, placement, assets, cruise, trafficSignals, cameras, v2xOverlay }; } async function fetchJson(url) { diff --git a/scripts/lib/v2x-cesium-overlay.js b/scripts/lib/v2x-cesium-overlay.js new file mode 100644 index 0000000..43fbda2 --- /dev/null +++ b/scripts/lib/v2x-cesium-overlay.js @@ -0,0 +1,280 @@ +(function () { + "use strict"; + + const PI = Math.PI; + const EARTH_A = 6378245.0; + const EARTH_EE = 0.00669342162296594323; + const TOKEN_KEY = "osm-asset-preview-v2x-token"; + + function createV2xCesiumOverlay(context) { + const settings = context.config.v2xPreview || {}; + if (!settings.enabled) return null; + + const state = { + token: sessionStorage.getItem(TOKEN_KEY) || "", + entities: [], + linkPhases: new Map(), + socket: null, + status: "Sign in to load live V2X data.", + crossCode: settings.crossCode || "", + metrics: null, + }; + const ui = buildUi(state, settings); + + function setStatus(message) { + state.status = message; + ui.status.textContent = message; + } + + function clearEntities() { + state.entities.forEach((entity) => context.viewer.entities.remove(entity)); + state.entities = []; + state.linkPhases.clear(); + } + + function disconnect() { + if (state.socket) state.socket.close(); + state.socket = null; + } + + async function request(path) { + const response = await fetch(joinUrl(settings.apiBaseUrl || "/api", path), { + headers: state.token ? { Authorization: state.token } : {}, + cache: "no-store", + }); + const body = await response.json().catch(() => null); + if (response.status === 401 || response.status === 403 || body?.code === 401 || body?.code === 403) { + signOut("V2X authorization expired. Sign in again."); + throw new Error("V2X authorization expired"); + } + if (!response.ok) throw new Error(`V2X request failed (${response.status})`); + if (body && Object.prototype.hasOwnProperty.call(body, "code") && Number(body.code) !== 200) { + throw new Error(body.msg || body.message || "V2X request failed"); + } + return body && Object.prototype.hasOwnProperty.call(body, "data") ? body.data : body; + } + + async function signIn(userName, password) { + setStatus("Signing in to V2X..."); + const response = await fetch(joinUrl(settings.apiBaseUrl || "/api", "/facilities/api/sys/login"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userName, password: md5(password) }), + }); + const body = await response.json().catch(() => null); + const payload = body?.data || body || {}; + const token = payload.token || payload.accessToken || body?.token; + if (!response.ok || Number(body?.code) !== 200 || typeof token !== "string" || !token) { + throw new Error(body?.msg || body?.message || "V2X sign-in failed"); + } + state.token = token; + sessionStorage.setItem(TOKEN_KEY, token); + ui.form.hidden = true; + ui.live.hidden = false; + await loadLiveData(); + } + + function signOut(message) { + disconnect(); + clearEntities(); + state.token = ""; + sessionStorage.removeItem(TOKEN_KEY); + ui.form.hidden = false; + ui.live.hidden = true; + if (message) setStatus(message); + } + + async function loadLiveData() { + const crossCode = ui.crossCode.value.trim(); + if (!crossCode) { + setStatus("Enter a V2X intersection code."); + return; + } + state.crossCode = crossCode; + clearEntities(); + setStatus("Loading live V2X intersection data..."); + const results = await Promise.allSettled([ + request(`/network/api/link/network/queryCrossLinkInfo/${encodeURIComponent(crossCode)}`), + request(`/network/api/pole/network/queryPoles/${encodeURIComponent(crossCode)}`), + request(`/facilities/api/crossDevice/findDeviceByCrossCode/${encodeURIComponent(crossCode)}`), + request(`/facilities/api/crossDeviceConfig/${encodeURIComponent(crossCode)}`), + request(`/facilities/api/FlowTravelRatio/queryListWeek?code=${encodeURIComponent(crossCode)}`), + ]); + const [links, poles, devices, deviceConfig, flow] = results; + const warnings = []; + if (links.status === "fulfilled") addLinks(context.viewer, links.value, state); + else warnings.push("links unavailable"); + if (devices.status === "fulfilled") addDevices(context.viewer, devices.value, state); + else warnings.push("devices unavailable"); + const poleCount = poles.status === "fulfilled" ? arrayValue(poles.value?.posConfig || poles.value).length : 0; + const deviceCount = devices.status === "fulfilled" ? arrayValue(devices.value).length : 0; + const targetCount = deviceConfig.status === "fulfilled" ? arrayValue(deviceConfig.value?.deviceConfig?.target).length : 0; + state.metrics = flow.status === "fulfilled" ? flow.value : null; + if (flow.status !== "fulfilled") warnings.push("traffic metrics unavailable"); + connectSignalSocket(); + 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() { + disconnect(); + const socketUrl = toWebSocketUrl(joinUrl(settings.wsBaseUrl || "/websocket", "/network/ws/network/signal"), 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.`); + } catch (_) { + setStatus(`${state.status} Signal WebSocket unavailable.`); + } + } + + ui.form.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await signIn(ui.userName.value.trim(), ui.password.value); + ui.password.value = ""; + } catch (error) { + setStatus(error.message || "V2X sign-in failed"); + } + }); + ui.reload.addEventListener("click", () => loadLiveData().catch((error) => setStatus(error.message || "Live V2X data unavailable"))); + ui.signOut.addEventListener("click", () => signOut("Signed out. Native preview remains available.")); + + if (state.token) { + ui.form.hidden = true; + ui.live.hidden = false; + loadLiveData().catch((error) => setStatus(error.message || "Live V2X data unavailable")); + } + return { state, load: loadLiveData, signOut, dispose: () => { disconnect(); clearEntities(); 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
+
+ + `; + document.body.appendChild(root); + return { + root, + form: root.querySelector("#v2xLoginForm"), + live: root.querySelector("#v2xLiveControls"), + userName: root.querySelector("[name=userName]"), + password: root.querySelector("[name=password]"), + crossCode: root.querySelector("[name=crossCode]"), + reload: root.querySelector("[data-action=reload]"), + signOut: root.querySelector("[data-action=signout]"), + status: root.querySelector("#v2xStatus"), + }; + } + + function addLinks(viewer, document, state) { + arrayValue(document?.inLinkList).forEach((link) => { + let geometry; + try { geometry = typeof link.geom === "string" ? JSON.parse(link.geom) : link.geom; } catch (_) { return; } + const points = arrayValue(geometry?.coordinates).map(gcj02ToWgs84).flat(); + if (points.length < 4) return; + const entity = viewer.entities.add({ + name: link.name || `V2X link ${link.id}`, + polyline: { positions: Cesium.Cartesian3.fromDegreesArray(points), width: 5, material: Cesium.Color.CYAN.withAlpha(.78), clampToGround: false }, + }); + state.entities.push(entity); + arrayValue(link.phaseList).forEach((phase) => state.linkPhases.set(String(phase.phase), entity)); + }); + } + + function addDevices(viewer, devices, state) { + arrayValue(devices).forEach((device) => { + const longitude = Number(device.longitude ?? device.lon ?? device.position?.longitude); + const latitude = Number(device.latitude ?? device.lat ?? device.position?.latitude); + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return; + const [wgsLongitude, wgsLatitude] = gcj02ToWgs84([longitude, latitude]); + const entity = viewer.entities.add({ + name: device.name || device.code || "V2X device", + position: Cesium.Cartesian3.fromDegrees(wgsLongitude, wgsLatitude, 3), + point: { pixelSize: 9, color: device.onlineStatus === 1 ? Cesium.Color.LIME : Cesium.Color.RED, outlineColor: Cesium.Color.BLACK, outlineWidth: 1 }, + label: { text: device.code || device.name || "device", 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) }, + }); + state.entities.push(entity); + }); + } + + function updateSignalPhases(value, state) { + let lamps; + try { lamps = JSON.parse(value).lamps; } catch (_) { return; } + arrayValue(lamps).forEach((lamp) => { + const entity = state.linkPhases.get(String(lamp.phaseNo)); + if (!entity?.polyline) return; + entity.polyline.material = lampColor(lamp.status).withAlpha(.9); + }); + } + + function lampColor(status) { + if (String(status).toLowerCase().includes("green") || Number(status) === 3) return Cesium.Color.LIME; + if (String(status).toLowerCase().includes("yellow") || Number(status) === 2) return Cesium.Color.GOLD; + return Cesium.Color.RED; + } + + function arrayValue(value) { return Array.isArray(value) ? value : []; } + function joinUrl(base, path) { return `${String(base || "").replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`; } + function toWebSocketUrl(url, token) { + const resolved = new URL(url, window.location.href); + resolved.protocol = resolved.protocol === "https:" ? "wss:" : "ws:"; + resolved.searchParams.set("authorization", token); + return resolved.href; + } + function escapeAttribute(value) { return String(value).replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<"); } + + function transformLat(x, y) { let value = -100 + 2 * x + 3 * y + .2 * y * y + .1 * x * y + .2 * Math.sqrt(Math.abs(x)); value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3; value += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3; value += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3; return value; } + function transformLon(x, y) { let value = 300 + x + 2 * y + .1 * x * x + .1 * x * y + .1 * Math.sqrt(Math.abs(x)); value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3; value += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3; value += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3; return value; } + function gcj02ToWgs84(coordinate) { const longitude = Number(coordinate[0]); const latitude = Number(coordinate[1]); if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return [NaN, NaN]; const dLat = transformLat(longitude - 105, latitude - 35); const dLon = transformLon(longitude - 105, latitude - 35); const radLat = latitude / 180 * PI; const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2; const sqrtMagic = Math.sqrt(magic); return [longitude - dLon * 180 / (EARTH_A / sqrtMagic * Math.cos(radLat) * PI), latitude - dLat * 180 / (EARTH_A * (1 - EARTH_EE) / (magic * sqrtMagic) * PI)]; } + + // The V2X login contract uses MD5. Keep the implementation local so the + // generated preview stays dependency-free and credentials are never sent plain. + function md5(value) { + const source = unescape(encodeURIComponent(String(value))); + const words = []; + for (let index = 0; index < source.length; index += 1) words[index >> 2] = (words[index >> 2] || 0) | (source.charCodeAt(index) << ((index % 4) * 8)); + const bitLength = source.length * 8; + words[bitLength >> 5] = (words[bitLength >> 5] || 0) | (128 << (bitLength % 32)); + words[(((bitLength + 64) >>> 9) << 4) + 14] = bitLength; + const shifts = [7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21]; + let a0 = 1732584193; + let b0 = -271733879; + let c0 = -1732584194; + let d0 = 271733878; + for (let offset = 0; offset < words.length; offset += 16) { + let a = a0; + let b = b0; + let c = c0; + let d = d0; + for (let index = 0; index < 64; index += 1) { + let f; + let g; + if (index < 16) { f = (b & c) | (~b & d); g = index; } + else if (index < 32) { f = (d & b) | (~d & c); g = (5 * index + 1) % 16; } + else if (index < 48) { f = b ^ c ^ d; g = (3 * index + 5) % 16; } + else { f = c ^ (b | ~d); g = (7 * index) % 16; } + const nextD = d; + d = c; + c = b; + b = add(b, rotate(add(add(a, f), add(words[offset + g] || 0, Math.floor(Math.abs(Math.sin(index + 1)) * 4294967296))), shifts[index])); + a = nextD; + } + a0 = add(a0, a); + b0 = add(b0, b); + c0 = add(c0, c); + d0 = add(d0, d); + } + return [a0, b0, c0, d0].map(hex).join(""); + } + function add(a, b) { return (a + b) & 0xFFFFFFFF; } + 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 }; + window.createV2xCesiumOverlay = createV2xCesiumOverlay; +}()); diff --git a/scripts/test-asset-budgets.js b/scripts/test-asset-budgets.js index 157d67e..f0b8bef 100644 --- a/scripts/test-asset-budgets.js +++ b/scripts/test-asset-budgets.js @@ -64,6 +64,22 @@ assert.equal( assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800); assert.equal(normalizeAreaConfig(base).stages.intermediates, false); assert.equal(normalizeAreaConfig(base).blender.roadProvider, "native"); +assert.deepEqual(normalizeAreaConfig(base).v2xPreview, { + enabled: false, + apiBaseUrl: "/api", + wsBaseUrl: "/websocket", + crossCode: "", +}); +assert.deepEqual(normalizeAreaConfig({ ...base, v2xPreview: { enabled: false, apiBaseUrl: "/v2x-api/", wsBaseUrl: "/v2x-ws", crossCode: "420100023333" } }).v2xPreview, { + enabled: false, + apiBaseUrl: "/v2x-api/", + wsBaseUrl: "/v2x-ws", + crossCode: "420100023333", +}); +assert.throws( + () => normalizeAreaConfig({ ...base, v2xPreview: "enabled" }), + /v2xPreview must be an object/, +); assert.equal(normalizeAreaConfig({ ...base, blender: { roadProvider: "native" } }).blender.roadProvider, "native"); assert.throws( () => normalizeAreaConfig({ ...base, blender: { roadProvider: "other" } }), diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 199945b..1320062 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -243,6 +243,8 @@ const html = cesiumPreviewHtml( "north<&>\u2028valley", ["car-a.gltf", "truck-a.gltf"], "traffic-signals.json", + null, + { enabled: true, apiBaseUrl: "/api", wsBaseUrl: "/websocket", crossCode: "420100023333" }, ); assert.match(html, /north<&>\u2028valley Cesium Preview<\/title>/); assert.match(html, /Loading scene<&>\.glb/); @@ -250,6 +252,8 @@ assert.match(html, /"areaId":"north\\u003c\\u0026\\u003e\\u2028valley"/); assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/); assert.match(html, /"vehicleModelNames":\["car-a\.gltf","truck-a\.gltf"\]/); assert.match(html, /"trafficSignalsName":"traffic-signals\.json"/); +assert.match(html, /"v2xPreview":\{"enabled":true,"apiBaseUrl":"\/api","wsBaseUrl":"\/websocket","crossCode":"420100023333"\}/); +assert.match(html, /<script src="v2x-cesium-overlay\.js"><\/script>/); assert.match(html, /id="toggleSignals"/); assert.match(html, /id="vehicleInfoCard" class="hidden"/); assert.match(html, /id="vehicleIncidentNote"/); @@ -261,6 +265,7 @@ assert.match(html, /data-view-mode="inspect"/); 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\}`\)/); const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf"); @@ -268,6 +273,12 @@ assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned w assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/); assert.doesNotMatch(previewRuntime, /Traffic Signal Housing/); assert.match(previewRuntime, /asset\.category === "dynamic"/); +assert.match(previewRuntime, /createV2xCesiumOverlay/); +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, /gcj02ToWgs84/); assert.match(previewRuntime, /new Cesium\.ScreenSpaceEventHandler/); assert.match(previewRuntime, /vehicleId: record\.id/); assert.match(previewRuntime, /status === "breakdown"\s+\? "vehicle-breakdown\.png"/); diff --git a/scripts/test-v2x-cesium-overlay.js b/scripts/test-v2x-cesium-overlay.js new file mode 100644 index 0000000..659eb8d --- /dev/null +++ b/scripts/test-v2x-cesium-overlay.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const { gcj02ToWgs84: referenceGcj02ToWgs84 } = require("./lib/gaode-junction-reference"); + +const source = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.js"), "utf8"); +const context = { + window: { location: new URL("https://preview.example.test/areas/fengshu/") }, + URL, + console, +}; +vm.runInNewContext(source, context, { filename: "v2x-cesium-overlay.js" }); + +const { gcj02ToWgs84, joinUrl, md5, toWebSocketUrl } = 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"); +assert.equal(joinUrl("/websocket", "network/ws/network/signal"), "/websocket/network/ws/network/signal"); +assert.equal(toWebSocketUrl("/websocket/network/ws/network/signal", "opaque token"), "wss://preview.example.test/websocket/network/ws/network/signal?authorization=opaque+token"); + +const converted = gcj02ToWgs84([114.12864875054062, 30.460485279762146]); +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]); + +console.log("V2X Cesium overlay tests passed.");