feat: add live V2X Cesium preview overlay

This commit is contained in:
2026-08-24 14:20:16 +08:00
parent 71ba536c7c
commit ee273c5cd6
20 changed files with 760 additions and 3 deletions

View File

@@ -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`425 | 仅 `complex-junction-v1`:相邻进口夹角处路缘圆角的半径。圆角切于两侧最外道路边缘,只补齐夹角处的路面,不改变 connector、信号或停止线。 |
| `junctionTemplates.clusters[].outerRadiusExtraMeters` | `18`1835 | 仅 `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

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}

View File

@@ -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.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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": {}
}

View File

@@ -61,6 +61,12 @@
"officeOverrides": "",
"roadProvider": "native"
},
"v2xPreview": {
"enabled": true,
"apiBaseUrl": "/api",
"wsBaseUrl": "/websocket",
"crossCode": ""
},
"compress": {
"textureSize": 768,
"quality": 82,

View File

@@ -44,6 +44,12 @@
"officeOverrides": "",
"roadProvider": "native"
},
"v2xPreview": {
"enabled": false,
"apiBaseUrl": "/api",
"wsBaseUrl": "/websocket",
"crossCode": ""
},
"compress": {
"textureSize": 768,
"quality": 82,

View File

@@ -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.

View File

@@ -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": {

View File

@@ -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, {

View File

@@ -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");

View File

@@ -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 `<!doctype html>
<html lang="zh-CN">
@@ -100,6 +102,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
</div>
</div>
<script>window.OSM_ASSET_PREVIEW_CONFIG = ${escapeScriptJson(JSON.stringify(previewConfig))};</script>
<script src="v2x-cesium-overlay.js"></script>
<script src="cesium-preview.js"></script>
</body>
</html>

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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 = `<div class="v2x-heading"><strong>Live V2X Overlay</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>
<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>`;
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("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;"); }
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;
}());

View File

@@ -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" } }),

View File

@@ -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, /<title>north&lt;&amp;&gt;\u2028valley Cesium Preview<\/title>/);
assert.match(html, /Loading scene&lt;&amp;&gt;\.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"/);

View File

@@ -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.");