feat: add live V2X Cesium preview overlay
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."}
|
||||
80
.trellis/tasks/08-24-v2x-amap-intersection-data/design.md
Normal file
80
.trellis/tasks/08-24-v2x-amap-intersection-data/design.md
Normal 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.
|
||||
@@ -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."}
|
||||
40
.trellis/tasks/08-24-v2x-amap-intersection-data/implement.md
Normal file
40
.trellis/tasks/08-24-v2x-amap-intersection-data/implement.md
Normal 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.
|
||||
81
.trellis/tasks/08-24-v2x-amap-intersection-data/prd.md
Normal file
81
.trellis/tasks/08-24-v2x-amap-intersection-data/prd.md
Normal 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.
|
||||
26
.trellis/tasks/08-24-v2x-amap-intersection-data/task.json
Normal file
26
.trellis/tasks/08-24-v2x-amap-intersection-data/task.json
Normal 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": {}
|
||||
}
|
||||
@@ -61,6 +61,12 @@
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "native"
|
||||
},
|
||||
"v2xPreview": {
|
||||
"enabled": true,
|
||||
"apiBaseUrl": "/api",
|
||||
"wsBaseUrl": "/websocket",
|
||||
"crossCode": ""
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
"quality": 82,
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "native"
|
||||
},
|
||||
"v2xPreview": {
|
||||
"enabled": false,
|
||||
"apiBaseUrl": "/api",
|
||||
"wsBaseUrl": "/websocket",
|
||||
"crossCode": ""
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
"quality": 82,
|
||||
|
||||
62
docs/v2x-cesium-preview.md
Normal file
62
docs/v2x-cesium-preview.md
Normal 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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
280
scripts/lib/v2x-cesium-overlay.js
Normal file
280
scripts/lib/v2x-cesium-overlay.js
Normal 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("&", "&").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;
|
||||
}());
|
||||
@@ -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" } }),
|
||||
|
||||
@@ -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<&>\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"/);
|
||||
|
||||
31
scripts/test-v2x-cesium-overlay.js
Normal file
31
scripts/test-v2x-cesium-overlay.js
Normal 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.");
|
||||
Reference in New Issue
Block a user