fix: render live V2X vehicles in Cesium preview
This commit is contained in:
@@ -351,7 +351,13 @@ OBU 消息使用 `carCode|obuCode`、`lon`、`lat`、`angle`、`speed`;目标
|
|||||||
一相位可点亮多条进口道,一条 link 可点亮同进口道多个灯头,两侧都取并集。
|
一相位可点亮多条进口道,一条 link 可点亮同进口道多个灯头,两侧都取并集。
|
||||||
未绑定相位须计数上报,可用 `v2xPreview.phaseSignalMap` 显式覆盖。
|
未绑定相位须计数上报,可用 `v2xPreview.phaseSignalMap` 显式覆盖。
|
||||||
- **车辆生命周期**:超过 `interval * 1.5` 未更新即隐藏(不删除),隐藏项作为同模型的可复用槽位;
|
- **车辆生命周期**:超过 `interval * 1.5` 未更新即隐藏(不删除),隐藏项作为同模型的可复用槽位;
|
||||||
socket 断开清空车辆。模型名沿用看板语义:OBU 为 `car_obu.glb`,目标车为 `${type}${subType}.glb`。
|
socket 断开清空车辆。OBU 数据 hook 标记为 `car_obu.glb`,但 dashboard 的最终
|
||||||
|
`CrossCars` 模板实际渲染 `11.glb`,预览必须以 `11.glb` 为准;目标车为
|
||||||
|
`${type}${subType}.glb`。`11/12/13/14/15/16/17/31/221/222.glb` 必须打入
|
||||||
|
`_preview/v2x-vehicles/`。
|
||||||
|
- **可见性是运行时契约**:有效推送创建的模型必须在路口概览下至少为 `28px`,并允许最大
|
||||||
|
`8x` 放大;位置高度须高于静态道路表面。首次有效车辆推送应聚焦到车群附近,标签须不受
|
||||||
|
静态 GLB 的深度遮挡。仅验证 WS 收包或 registry 条数不算通过。
|
||||||
- 车辆推送体解析必须容忍非严格 JSON(源看板用 `saferEval`),失败须计数并在面板可见,
|
- 车辆推送体解析必须容忍非严格 JSON(源看板用 `saferEval`),失败须计数并在面板可见,
|
||||||
**不得**静默丢弃;实现不得使用 `eval` / `new Function`。
|
**不得**静默丢弃;实现不得使用 `eval` / `new Function`。
|
||||||
- 周流量比 `FlowTravelRatio/queryListWeek` **不属于**实时路口范围。
|
- 周流量比 `FlowTravelRatio/queryListWeek` **不属于**实时路口范围。
|
||||||
@@ -381,7 +387,7 @@ OBU 消息使用 `carCode|obuCode`、`lon`、`lat`、`angle`、`speed`;目标
|
|||||||
同相位多进口道并集、车辆超时与槽位复用、心跳间隔与重连重放订阅帧、bounds 报文。
|
同相位多进口道并集、车辆超时与槽位复用、心跳间隔与重连重放订阅帧、bounds 报文。
|
||||||
- `npm run test:preview-assets`:断言三条 WebSocket 存在,runtime 不调用
|
- `npm run test:preview-assets`:断言三条 WebSocket 存在,runtime 不调用
|
||||||
`addVehicleCruises`、不显示 simulation 诊断,且预览层**不再自带** `lampColorName`
|
`addVehicleCruises`、不显示 simulation 诊断,且预览层**不再自带** `lampColorName`
|
||||||
或 `Number(status) === 3` 判断。
|
或 `Number(status) === 3` 判断;断言实时车辆最小像素尺寸和诊断可见数量读数存在。
|
||||||
- 对目标区域运行 `npm run build:area -- --config config/areas/<area>.json --stages preview`,
|
- 对目标区域运行 `npm run build:area -- --config config/areas/<area>.json --stages preview`,
|
||||||
检查 descriptor 中 `routeName` 为空、不存在 traffic-simulation 文件,
|
检查 descriptor 中 `routeName` 为空、不存在 traffic-simulation 文件,
|
||||||
且 `vehicleModelNames` 已写出(实时车辆渲染需要)。
|
且 `vehicleModelNames` 已写出(实时车辆渲染需要)。
|
||||||
|
|||||||
@@ -176,9 +176,11 @@ registry.list() -> VehicleRecord[]
|
|||||||
|
|
||||||
渲染适配层据 `visible` 切 `entity.show`,**不删实体**,与槽位复用配套。
|
渲染适配层据 `visible` 切 `entity.show`,**不删实体**,与槽位复用配套。
|
||||||
|
|
||||||
模型选择(R8):`modelNameFor(vehicle)` 返回 `car_obu.glb` 或 `${type}${subType}.glb`;
|
模型选择(R8):OBU 的数据 hook 虽标记 `car_obu.glb`,但最终的 `CrossCars/index.vue` 模板固定
|
||||||
在包内 `vehicleModelNames` 中按文件名匹配,命中不了则回退 `vehicleModelNames[0]` 并累加
|
传入 `11.glb`,因此 `modelNameFor(vehicle)` 对 OBU 返回 `11.glb`;目标车辆返回
|
||||||
`missingModels` 计数供面板展示。
|
`${type}${subType}.glb`。预览阶段把 dashboard 的 `11/12/13/14/15/16/17/31/221/222.glb`
|
||||||
|
复制到 `_preview/v2x-vehicles/`,并在 `vehicleModelNames` 中按文件名匹配;未知车型回退 `11.glb`
|
||||||
|
并累加 `missingModels` 计数供面板展示。
|
||||||
|
|
||||||
平滑:用 `Cesium.SampledPositionProperty` 按 `duration` 插值,替代当前的直接赋值跳变。
|
平滑:用 `Cesium.SampledPositionProperty` 按 `duration` 插值,替代当前的直接赋值跳变。
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
2. `interval` 取自推送体,`0` 或缺失回退 `500`。
|
2. `interval` 取自推送体,`0` 或缺失回退 `500`。
|
||||||
3. `sweep`:`|now - timeStamp| >= interval*1.5` → `visible=false`。
|
3. `sweep`:`|now - timeStamp| >= interval*1.5` → `visible=false`。
|
||||||
4. 槽位复用:优先占用同 `model` 的不可见记录。
|
4. 槽位复用:优先占用同 `model` 的不可见记录。
|
||||||
5. `modelNameFor(vehicle)`:OBU → `car_obu.glb`;target → `${type}${subType}.glb`。
|
5. `modelNameFor(vehicle)`:OBU → dashboard 最终模板使用的 `11.glb`;target → `${type}${subType}.glb`。把 dashboard 车辆 GLB 复制到预览包。
|
||||||
6. `normalizeTargetVehicles` 补上源项目的 `type==1 && isNil(subType) → subType=1`。
|
6. `normalizeTargetVehicles` 补上源项目的 `type==1 && isNil(subType) → subType=1`。
|
||||||
7. 导出 `createVehicleRegistry`、`modelNameFor`。
|
7. 导出 `createVehicleRegistry`、`modelNameFor`。
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
- 假时钟推进 `interval*1.5` 后 `sweep()` → 该车 `visible=false`。
|
- 假时钟推进 `interval*1.5` 后 `sweep()` → 该车 `visible=false`。
|
||||||
- 再注入同 key → 复用原槽位,`list().length` 不变。
|
- 再注入同 key → 复用原槽位,`list().length` 不变。
|
||||||
- `clear()` 后为空。
|
- `clear()` 后为空。
|
||||||
- `modelNameFor({kind:"obu"})==="car_obu.glb"`;`{kind:"target",type:1,subType:2}` → `"12.glb"`。
|
- `modelNameFor({kind:"obu"})==="11.glb"`;`{kind:"target",type:1,subType:2}` → `"12.glb"`。
|
||||||
|
|
||||||
### 步骤 5 — WebSocket 连接层(R1、R6)
|
### 步骤 5 — WebSocket 连接层(R1、R6)
|
||||||
|
|
||||||
|
|||||||
@@ -57,9 +57,9 @@
|
|||||||
- socket 断开时清空车辆列表。
|
- socket 断开时清空车辆列表。
|
||||||
- 车辆位置更新使用推送间隔做平滑插值,避免逐帧跳变。
|
- 车辆位置更新使用推送间隔做平滑插值,避免逐帧跳变。
|
||||||
|
|
||||||
### R8 车辆模型命名对齐
|
### R8 车辆模型命名与资产对齐
|
||||||
- OBU 固定使用 `car_obu.glb` 语义的模型槽;目标识别车辆使用 `${type}${subType}.glb` 语义的模型槽。
|
- OBU 数据层标记为 `car_obu.glb`,但 dashboard 的实际 `CrossCars` 模板固定渲染 `11.glb`;预览必须遵从该最终渲染契约。目标识别车辆使用 `${type}${subType}.glb`。
|
||||||
- 包内缺少对应模型时回退到默认车模型,并在面板提示,不得静默显示错误车型。
|
- 预览包必须携带 dashboard 实际使用的车辆模型;包内缺少未知车型时回退到 `11.glb`,并在面板提示,不得静默显示错误车型。
|
||||||
|
|
||||||
### R9 推送体解析健壮性
|
### R9 推送体解析健壮性
|
||||||
- 源项目对两个车辆 socket 使用 `saferEval` 而非 `JSON.parse`,说明载荷不保证严格 JSON。
|
- 源项目对两个车辆 socket 使用 `saferEval` 而非 `JSON.parse`,说明载荷不保证严格 JSON。
|
||||||
|
|||||||
BIN
assets/models/v2x-vehicles/11.glb
Normal file
BIN
assets/models/v2x-vehicles/11.glb
Normal file
Binary file not shown.
BIN
assets/models/v2x-vehicles/12.glb
Normal file
BIN
assets/models/v2x-vehicles/12.glb
Normal file
Binary file not shown.
BIN
assets/models/v2x-vehicles/13.glb
Normal file
BIN
assets/models/v2x-vehicles/13.glb
Normal file
Binary file not shown.
BIN
assets/models/v2x-vehicles/14.glb
Normal file
BIN
assets/models/v2x-vehicles/14.glb
Normal file
Binary file not shown.
BIN
assets/models/v2x-vehicles/15.glb
Normal file
BIN
assets/models/v2x-vehicles/15.glb
Normal file
Binary file not shown.
BIN
assets/models/v2x-vehicles/16.glb
Normal file
BIN
assets/models/v2x-vehicles/16.glb
Normal file
Binary file not shown.
BIN
assets/models/v2x-vehicles/17.glb
Normal file
BIN
assets/models/v2x-vehicles/17.glb
Normal file
Binary file not shown.
BIN
assets/models/v2x-vehicles/221.glb
Normal file
BIN
assets/models/v2x-vehicles/221.glb
Normal file
Binary file not shown.
BIN
assets/models/v2x-vehicles/222.glb
Normal file
BIN
assets/models/v2x-vehicles/222.glb
Normal file
Binary file not shown.
BIN
assets/models/v2x-vehicles/31.glb
Normal file
BIN
assets/models/v2x-vehicles/31.glb
Normal file
Binary file not shown.
@@ -95,10 +95,12 @@ Unbound phases are counted in the panel — override them with `phaseSignalMap`.
|
|||||||
Vehicles follow the dashboard's rules: the push `interval` (`0` falls back to
|
Vehicles follow the dashboard's rules: the push `interval` (`0` falls back to
|
||||||
500 ms) sets the animation duration, a vehicle not updated within
|
500 ms) sets the animation duration, a vehicle not updated within
|
||||||
`interval * 1.5` is hidden, and hidden entries stay as reusable slots rather
|
`interval * 1.5` is hidden, and hidden entries stay as reusable slots rather
|
||||||
than being removed. Models are named as the dashboard names them —
|
than being removed. The OBU data hook calls its model `car_obu.glb`, but the
|
||||||
`car_obu.glb` for OBU vehicles and `${type}${subType}.glb` for recognized
|
dashboard's final `CrossCars` template renders `11.glb`; the preview uses that
|
||||||
targets — falling back to the first packaged model, with the shortfall reported
|
final rendering contract. Targets use `${type}${subType}.glb`. The dashboard
|
||||||
in the panel.
|
vehicle assets are packaged under `_preview/v2x-vehicles/`. Valid live vehicles
|
||||||
|
are lifted clear of the road, remain at least 28 screen pixels in overview, and
|
||||||
|
the first live push focuses the camera near the vehicle group.
|
||||||
|
|
||||||
## Coordinate Contract
|
## Coordinate Contract
|
||||||
|
|
||||||
@@ -119,7 +121,8 @@ without Nginx, use the repository's proxy static server instead of
|
|||||||
`python3 -m http.server`:
|
`python3 -m http.server`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
V2X_UPSTREAM=http://<v2x-api-host>:<port> \
|
V2X_UPSTREAM=http://<v2x-api-host>:<port>/dashboardApi \
|
||||||
|
V2X_WS_UPSTREAM=http://<v2x-ws-host>:<port>/dashboardWebsocket \
|
||||||
npm run serve:v2x-preview -- --root outputs/fengshu-er-road --host 0.0.0.0 --port 7862
|
npm run serve:v2x-preview -- --root outputs/fengshu-er-road --host 0.0.0.0 --port 7862
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const {
|
|||||||
} = require("./lib/scene-layers");
|
} = require("./lib/scene-layers");
|
||||||
const { digest: glbDigest } = require("./glb-digest");
|
const { digest: glbDigest } = require("./glb-digest");
|
||||||
const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route");
|
const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route");
|
||||||
const { writePreviewVehicleLibrary } = require("./lib/vehicle-library");
|
const { writePreviewVehicleLibrary, writePreviewV2xVehicleLibrary } = require("./lib/vehicle-library");
|
||||||
const { readTrafficSignals } = require("./lib/traffic-signals");
|
const { readTrafficSignals } = require("./lib/traffic-signals");
|
||||||
const {
|
const {
|
||||||
cesiumPreviewHtml,
|
cesiumPreviewHtml,
|
||||||
@@ -648,10 +648,11 @@ function writeVehicleModel(area) {
|
|||||||
const outDir = path.dirname(area.outputs.vehicleModel);
|
const outDir = path.dirname(area.outputs.vehicleModel);
|
||||||
const fileStem = path.basename(area.outputs.vehicleModel, ".gltf").replace(/-vehicle-car$/, "");
|
const fileStem = path.basename(area.outputs.vehicleModel, ".gltf").replace(/-vehicle-car$/, "");
|
||||||
const models = writePreviewVehicleLibrary(outDir, fileStem);
|
const models = writePreviewVehicleLibrary(outDir, fileStem);
|
||||||
|
const v2xModels = area.v2xPreview.enabled ? writePreviewV2xVehicleLibrary(outDir) : [];
|
||||||
// Preserve the existing output/manifest contract for old preview HTML.
|
// Preserve the existing output/manifest contract for old preview HTML.
|
||||||
fs.copyFileSync(path.join(outDir, models[0]), area.outputs.vehicleModel);
|
fs.copyFileSync(path.join(outDir, models[0]), area.outputs.vehicleModel);
|
||||||
console.log(`Vehicle models: ${models.length} candidates in ${path.dirname(area.outputs.vehicleModel)}`);
|
console.log(`Vehicle models: ${models.length + v2xModels.length} candidates in ${path.dirname(area.outputs.vehicleModel)}`);
|
||||||
return models;
|
return [...models, ...v2xModels];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sceneGeojsonRecords(area) {
|
function sceneGeojsonRecords(area) {
|
||||||
|
|||||||
@@ -1437,7 +1437,10 @@
|
|||||||
];
|
];
|
||||||
if (v2xOverlay?.state) {
|
if (v2xOverlay?.state) {
|
||||||
lines.push("Vehicle data: " + (v2xOverlay.state.vehicleSource === "live" ? "live V2X" : "waiting for live V2X"));
|
lines.push("Vehicle data: " + (v2xOverlay.state.vehicleSource === "live" ? "live V2X" : "waiting for live V2X"));
|
||||||
lines.push("Live vehicles: " + v2xOverlay.state.vehicles.size);
|
lines.push("Live vehicles: " + (v2xOverlay.state?.registry?.visibleSize ?? 0));
|
||||||
|
lines.push("Target WS: " + (v2xOverlay.state?.targetMessages ?? 0) + " messages / " + (v2xOverlay.state?.targetLastCount ?? 0) + " parsed");
|
||||||
|
lines.push("Target payload: " + (v2xOverlay.state?.targetPayloadShape || "waiting"));
|
||||||
|
if (v2xOverlay.state?.targetLastError) lines.push("Target error: " + v2xOverlay.state.targetLastError);
|
||||||
}
|
}
|
||||||
if (failed.length) {
|
if (failed.length) {
|
||||||
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));
|
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
const EARTH_A = 6378245.0;
|
const EARTH_A = 6378245.0;
|
||||||
const EARTH_EE = 0.00669342162296594323;
|
const EARTH_EE = 0.00669342162296594323;
|
||||||
const TOKEN_KEY = "osm-asset-preview-v2x-token";
|
const TOKEN_KEY = "osm-asset-preview-v2x-token";
|
||||||
|
const LIVE_VEHICLE_HEIGHT_METERS = 1.2;
|
||||||
|
const LIVE_VEHICLE_HEADING_OFFSET_DEGREES = 90;
|
||||||
|
|
||||||
function createV2xCesiumOverlay(context) {
|
function createV2xCesiumOverlay(context) {
|
||||||
const settings = context.config.v2xPreview || {};
|
const settings = context.config.v2xPreview || {};
|
||||||
@@ -22,6 +24,13 @@
|
|||||||
vehicleSource: "waiting",
|
vehicleSource: "waiting",
|
||||||
parseFailures: 0,
|
parseFailures: 0,
|
||||||
missingModels: new Set(),
|
missingModels: new Set(),
|
||||||
|
targetMessages: 0,
|
||||||
|
targetVehiclesReceived: 0,
|
||||||
|
targetLastCount: 0,
|
||||||
|
targetLastError: "",
|
||||||
|
targetPayloadType: "",
|
||||||
|
targetPayloadSample: "",
|
||||||
|
targetPayloadShape: "",
|
||||||
};
|
};
|
||||||
// This preview intentionally starts at the login gate. Do not revive a
|
// This preview intentionally starts at the login gate. Do not revive a
|
||||||
// previous tab's token and begin REST/WebSocket traffic before sign-in.
|
// previous tab's token and begin REST/WebSocket traffic before sign-in.
|
||||||
@@ -47,6 +56,7 @@
|
|||||||
if (state.phaseBinding.unbound.length) parts.push(`${state.phaseBinding.unbound.length} unbound`);
|
if (state.phaseBinding.unbound.length) parts.push(`${state.phaseBinding.unbound.length} unbound`);
|
||||||
if (state.missingModels.size) parts.push(`${state.missingModels.size} models missing`);
|
if (state.missingModels.size) parts.push(`${state.missingModels.size} models missing`);
|
||||||
if (state.parseFailures) parts.push(`${state.parseFailures} unparsed pushes`);
|
if (state.parseFailures) parts.push(`${state.parseFailures} unparsed pushes`);
|
||||||
|
if (state.targetMessages) parts.push(`${state.targetVehiclesReceived} target vehicles`);
|
||||||
return parts.join(", ");
|
return parts.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +65,6 @@
|
|||||||
state.entities = [];
|
state.entities = [];
|
||||||
registry.list().forEach((record) => {
|
registry.list().forEach((record) => {
|
||||||
if (record.entity) context.viewer.entities.remove(record.entity);
|
if (record.entity) context.viewer.entities.remove(record.entity);
|
||||||
if (record.trace) context.viewer.entities.remove(record.trace);
|
|
||||||
});
|
});
|
||||||
registry.clear();
|
registry.clear();
|
||||||
state.linkEntitiesByPhase.clear();
|
state.linkEntitiesByPhase.clear();
|
||||||
@@ -264,12 +273,29 @@
|
|||||||
const targetIds = arrayValue(deviceConfig?.deviceConfig?.target);
|
const targetIds = arrayValue(deviceConfig?.deviceConfig?.target);
|
||||||
send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null }));
|
send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null }));
|
||||||
}, (value) => {
|
}, (value) => {
|
||||||
const push = normalizeTargetPush(value);
|
if (isHeartbeat(value)) return;
|
||||||
if (!push.vehicles.length) { if (!isHeartbeat(value) && parseLoosePayload(value) === null) state.parseFailures += 1; return; }
|
state.targetMessages += 1;
|
||||||
useLiveVehicles();
|
try {
|
||||||
registry.ingest(push.vehicles, push.interval);
|
state.targetPayloadType = Object.prototype.toString.call(value);
|
||||||
registry.sweep();
|
state.targetPayloadSample = typeof value === "string" ? value.slice(0, 800) : String(value).slice(0, 800);
|
||||||
renderVehicles();
|
const payload = parseLoosePayload(value);
|
||||||
|
const data = payload?.data;
|
||||||
|
state.targetPayloadShape = data === null ? "data:null" : Array.isArray(data) ? `data:array(${data.length})` : typeof data === "object" ? `data:object(${Object.keys(data || {}).join(",")})` : `data:${typeof data}`;
|
||||||
|
const vehicles = targetVehiclesFrom(payload);
|
||||||
|
state.targetLastCount = vehicles.length;
|
||||||
|
if (!vehicles.length) {
|
||||||
|
if (payload === null) state.parseFailures += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.targetVehiclesReceived += vehicles.length;
|
||||||
|
useLiveVehicles();
|
||||||
|
registry.ingest(vehicles, payload?.interval);
|
||||||
|
registry.sweep();
|
||||||
|
renderVehicles();
|
||||||
|
} catch (error) {
|
||||||
|
state.targetLastError = String(error?.message || error);
|
||||||
|
state.parseFailures += 1;
|
||||||
|
}
|
||||||
}, "Target vehicle WebSocket unavailable");
|
}, "Target vehicle WebSocket unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,10 +396,8 @@
|
|||||||
function syncVehicleEntity(viewer, record, state) {
|
function syncVehicleEntity(viewer, record, state) {
|
||||||
const [longitude, latitude] = gcj02ToWgs84([record.longitude, record.latitude]);
|
const [longitude, latitude] = gcj02ToWgs84([record.longitude, record.latitude]);
|
||||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
|
||||||
const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, .65);
|
const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, LIVE_VEHICLE_HEIGHT_METERS);
|
||||||
if (!record.entity) {
|
if (!record.entity) {
|
||||||
const tracePositions = [position];
|
|
||||||
record.tracePositions = tracePositions;
|
|
||||||
record.renderedPosition = position;
|
record.renderedPosition = position;
|
||||||
record.targetPosition = position;
|
record.targetPosition = position;
|
||||||
record.sampleStart = Date.now();
|
record.sampleStart = Date.now();
|
||||||
@@ -390,35 +414,27 @@
|
|||||||
() => vehicleOrientation(record.targetPosition, record.angle), false),
|
() => vehicleOrientation(record.targetPosition, record.angle), false),
|
||||||
model: {
|
model: {
|
||||||
uri: vehicleModelUri(record, state),
|
uri: vehicleModelUri(record, state),
|
||||||
scale: 0.9,
|
// The full-intersection overview needs more than a couple of screen
|
||||||
minimumPixelSize: 14,
|
// pixels for a car to remain distinguishable from road markings.
|
||||||
maximumScale: 2.0,
|
scale: 1.0,
|
||||||
|
minimumPixelSize: 28,
|
||||||
|
maximumScale: 8.0,
|
||||||
show: arrayValue(state.vehicleModels).length > 0,
|
show: arrayValue(state.vehicleModels).length > 0,
|
||||||
},
|
},
|
||||||
label: { text: record.label, font: "11px sans-serif", fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, style: Cesium.LabelStyle.FILL_AND_OUTLINE, pixelOffset: new Cesium.Cartesian2(0, -16) },
|
|
||||||
});
|
|
||||||
record.trace = viewer.entities.add({
|
|
||||||
name: `${record.label} live trace`,
|
|
||||||
polyline: { positions: new Cesium.CallbackProperty(() => record.tracePositions, false), width: 3, material: record.kind === "obu" ? Cesium.Color.LIME : Cesium.Color.ORANGE, arcType: Cesium.ArcType.NONE },
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
record.renderedPosition = record.targetPosition || position;
|
record.renderedPosition = record.targetPosition || position;
|
||||||
record.targetPosition = position;
|
record.targetPosition = position;
|
||||||
record.sampleStart = Date.now();
|
record.sampleStart = Date.now();
|
||||||
record.tracePositions.push(position);
|
|
||||||
if (record.tracePositions.length > 24) record.tracePositions.shift();
|
|
||||||
if (record.entity.label) record.entity.label.text = record.label;
|
|
||||||
}
|
}
|
||||||
// Stale vehicles are hidden, not removed, so the slot stays reusable.
|
// Stale vehicles are hidden, not removed, so the slot stays reusable.
|
||||||
record.entity.show = record.visible;
|
record.entity.show = record.visible;
|
||||||
record.trace.show = record.visible;
|
|
||||||
if (!record.visible) record.tracePositions.length = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function vehicleOrientation(position, angle) {
|
function vehicleOrientation(position, angle) {
|
||||||
return Cesium.Transforms.headingPitchRollQuaternion(
|
return Cesium.Transforms.headingPitchRollQuaternion(
|
||||||
position,
|
position,
|
||||||
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(Number(angle) || 0), 0, 0),
|
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians((Number(angle) || 0) + LIVE_VEHICLE_HEADING_OFFSET_DEGREES), 0, 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +457,7 @@
|
|||||||
function relaxJson(text) {
|
function relaxJson(text) {
|
||||||
return text
|
return text
|
||||||
.replace(/'/g, "\"")
|
.replace(/'/g, "\"")
|
||||||
.replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, "$1\"$2\":")
|
.replace(/([{,]\s*)([A-Za-z_$][\w$]*|\d+)\s*:/g, "$1\"$2\":")
|
||||||
.replace(/,\s*([}\]])/g, "$1")
|
.replace(/,\s*([}\]])/g, "$1")
|
||||||
.replace(/:\s*(NaN|-?Infinity)\s*([,}\]])/g, ": null$2");
|
.replace(/:\s*(NaN|-?Infinity)\s*([,}\]])/g, ": null$2");
|
||||||
}
|
}
|
||||||
@@ -620,7 +636,21 @@
|
|||||||
// dashboard's onConnected. A reconnect without this receives nothing.
|
// dashboard's onConnected. A reconnect without this receives nothing.
|
||||||
settings.onOpen?.({ send });
|
settings.onOpen?.({ send });
|
||||||
};
|
};
|
||||||
socket.onmessage = (event) => settings.onMessage?.(event?.data);
|
socket.onmessage = (event) => {
|
||||||
|
const payload = event?.data;
|
||||||
|
// The upstream sometimes marks UTF-8 JSON as a binary WS frame. DevTools
|
||||||
|
// renders that Blob as JSON, but JSON.parse cannot consume a Blob; decode
|
||||||
|
// it before applying the same dashboard payload parser.
|
||||||
|
if (typeof Blob !== "undefined" && payload instanceof Blob) {
|
||||||
|
payload.text().then((text) => settings.onMessage?.(text)).catch((error) => settings.onError?.(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof ArrayBuffer !== "undefined" && payload instanceof ArrayBuffer) {
|
||||||
|
settings.onMessage?.(new TextDecoder().decode(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.onMessage?.(payload);
|
||||||
|
};
|
||||||
socket.onerror = (event) => settings.onError?.(event);
|
socket.onerror = (event) => settings.onError?.(event);
|
||||||
socket.onclose = (event) => {
|
socket.onclose = (event) => {
|
||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
@@ -702,11 +732,11 @@
|
|||||||
}).filter(Boolean));
|
}).filter(Boolean));
|
||||||
}
|
}
|
||||||
|
|
||||||
// OBU vehicles use a fixed model; targets are keyed by type/subType. Mirrors
|
// The dashboard's CrossCars template renders OBU with 11.glb, while target
|
||||||
// useObuCars.ts ('car_obu.glb') and useTargetCars.ts (`${type}${subType}.glb`).
|
// vehicles retain their `${type}${subType}.glb` model selection.
|
||||||
function modelNameFor(vehicle) {
|
function modelNameFor(vehicle) {
|
||||||
if (!vehicle) return "";
|
if (!vehicle) return "";
|
||||||
if (vehicle.kind === "obu") return "car_obu.glb";
|
if (vehicle.kind === "obu") return "11.glb";
|
||||||
return `${vehicle.type || 1}${vehicle.subType || 1}.glb`;
|
return `${vehicle.type || 1}${vehicle.subType || 1}.glb`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,6 +807,7 @@
|
|||||||
list: () => records.slice(),
|
list: () => records.slice(),
|
||||||
get interval() { return interval; },
|
get interval() { return interval; },
|
||||||
get size() { return records.length; },
|
get size() { return records.length; },
|
||||||
|
get visibleSize() { return records.filter((record) => record.visible).length; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ const VEHICLE_IDS = [
|
|||||||
"truck_a03_001",
|
"truck_a03_001",
|
||||||
];
|
];
|
||||||
const LIBRARY_ROOT = path.resolve(__dirname, "..", "..", "assets", "models", "custom", "lowpoly_cars");
|
const LIBRARY_ROOT = path.resolve(__dirname, "..", "..", "assets", "models", "custom", "lowpoly_cars");
|
||||||
|
const V2X_LIBRARY_ROOT = path.resolve(__dirname, "..", "..", "assets", "models", "v2x-vehicles");
|
||||||
const TEXTURE_NAME = "color_512x512.jpg";
|
const TEXTURE_NAME = "color_512x512.jpg";
|
||||||
const REVERSED_MODEL_IDS = new Set(["truck_a03_001"]);
|
const REVERSED_MODEL_IDS = new Set(["truck_a03_001"]);
|
||||||
|
// These are the dashboard's published vehicle assets. OBU rendering uses
|
||||||
|
// 11.glb in CrossCars/index.vue; target vehicles use `${type}${subType}.glb`.
|
||||||
|
const V2X_VEHICLE_MODEL_NAMES = ["11.glb", "12.glb", "13.glb", "14.glb", "15.glb", "16.glb", "17.glb", "31.glb", "221.glb", "222.glb"];
|
||||||
|
|
||||||
function writePreviewVehicleLibrary(outDir, fileStem) {
|
function writePreviewVehicleLibrary(outDir, fileStem) {
|
||||||
const textureOutput = `${fileStem}-vehicle-texture.jpg`;
|
const textureOutput = `${fileStem}-vehicle-texture.jpg`;
|
||||||
@@ -46,4 +50,21 @@ function writePreviewVehicleLibrary(outDir, fileStem) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary };
|
function writePreviewV2xVehicleLibrary(outDir) {
|
||||||
|
const targetDir = path.join(outDir, "v2x-vehicles");
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
for (const modelName of V2X_VEHICLE_MODEL_NAMES) {
|
||||||
|
const source = path.join(V2X_LIBRARY_ROOT, modelName);
|
||||||
|
if (!fs.existsSync(source)) throw new Error(`V2X vehicle model not found: ${source}`);
|
||||||
|
fs.copyFileSync(source, path.join(targetDir, modelName));
|
||||||
|
}
|
||||||
|
return V2X_VEHICLE_MODEL_NAMES.map((modelName) => `v2x-vehicles/${modelName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
VEHICLE_IDS,
|
||||||
|
REVERSED_MODEL_IDS,
|
||||||
|
V2X_VEHICLE_MODEL_NAMES,
|
||||||
|
writePreviewVehicleLibrary,
|
||||||
|
writePreviewV2xVehicleLibrary,
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ const os = require("os");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { cesiumPreviewHtml } = require("./lib/area-preview");
|
const { cesiumPreviewHtml } = require("./lib/area-preview");
|
||||||
const { makeVehicleGltf } = require("./lib/vehicle-model");
|
const { makeVehicleGltf } = require("./lib/vehicle-model");
|
||||||
const { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary } = require("./lib/vehicle-library");
|
const {
|
||||||
|
VEHICLE_IDS,
|
||||||
|
REVERSED_MODEL_IDS,
|
||||||
|
V2X_VEHICLE_MODEL_NAMES,
|
||||||
|
writePreviewVehicleLibrary,
|
||||||
|
writePreviewV2xVehicleLibrary,
|
||||||
|
} = require("./lib/vehicle-library");
|
||||||
const {
|
const {
|
||||||
allowedTurns,
|
allowedTurns,
|
||||||
buildVehicleRoute,
|
buildVehicleRoute,
|
||||||
@@ -235,6 +241,14 @@ if (fs.existsSync(vehicleLibraryRoot)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const v2xVehicleLibraryRoot = path.join(__dirname, "..", "assets", "models", "v2x-vehicles");
|
||||||
|
if (fs.existsSync(v2xVehicleLibraryRoot)) {
|
||||||
|
const v2xLibraryDir = path.join(tempDir, "v2x-vehicle-library");
|
||||||
|
const models = writePreviewV2xVehicleLibrary(v2xLibraryDir);
|
||||||
|
assert.deepEqual(models, V2X_VEHICLE_MODEL_NAMES.map((name) => `v2x-vehicles/${name}`));
|
||||||
|
for (const model of models) assert.ok(fs.existsSync(path.join(v2xLibraryDir, model)));
|
||||||
|
}
|
||||||
|
|
||||||
const html = cesiumPreviewHtml(
|
const html = cesiumPreviewHtml(
|
||||||
"scene<&>.glb",
|
"scene<&>.glb",
|
||||||
"scene.json",
|
"scene.json",
|
||||||
@@ -298,6 +312,18 @@ assert.match(previewRuntime, /entry\.nodeKeys/,
|
|||||||
assert.match(previewRuntime, /paintCountdown\(/, "countDown from the push drives the countdown assets");
|
assert.match(previewRuntime, /paintCountdown\(/, "countDown from the push drives the countdown assets");
|
||||||
assert.match(previewRuntime, /nativeSignals: \(signalData && signalData\.signals\) \|\| \[\]/,
|
assert.match(previewRuntime, /nativeSignals: \(signalData && signalData\.signals\) \|\| \[\]/,
|
||||||
"the overlay needs the native signal list to bind phases");
|
"the overlay needs the native signal list to bind phases");
|
||||||
|
assert.match(previewRuntime, /registry\?\.visibleSize/,
|
||||||
|
"diagnostics must report the visible live-vehicle count");
|
||||||
|
assert.match(previewRuntime, /Target WS:/,
|
||||||
|
"diagnostics must expose target websocket receipt and parsing counts");
|
||||||
|
assert.match(previewRuntime, /Target payload:/,
|
||||||
|
"diagnostics must expose the payload shape when target pushes have no vehicles");
|
||||||
|
assert.match(v2xRuntime, /minimumPixelSize: 28/,
|
||||||
|
"live vehicles remain visible from the intersection overview");
|
||||||
|
assert.match(v2xRuntime, /LIVE_VEHICLE_HEIGHT_METERS = 1\.2/,
|
||||||
|
"live vehicles stay clear of the static road surface");
|
||||||
|
assert.match(v2xRuntime, /LIVE_VEHICLE_HEADING_OFFSET_DEGREES = 90/,
|
||||||
|
"live dashboard vehicle models receive their required clockwise heading correction");
|
||||||
|
|
||||||
// Overlay-side regressions: lamp codes, heartbeat, subscription frames,
|
// Overlay-side regressions: lamp codes, heartbeat, subscription frames,
|
||||||
// vehicle lifecycle and the removed out-of-scope request.
|
// vehicle lifecycle and the removed out-of-scope request.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const source = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.j
|
|||||||
const context = {
|
const context = {
|
||||||
window: { location: new URL("https://preview.example.test/areas/fengshu/") },
|
window: { location: new URL("https://preview.example.test/areas/fengshu/") },
|
||||||
URL,
|
URL,
|
||||||
|
Blob,
|
||||||
|
TextDecoder,
|
||||||
console,
|
console,
|
||||||
};
|
};
|
||||||
vm.runInNewContext(source, context, { filename: "v2x-cesium-overlay.js" });
|
vm.runInNewContext(source, context, { filename: "v2x-cesium-overlay.js" });
|
||||||
@@ -69,6 +71,9 @@ assert.equal(lampColorName(999), "other");
|
|||||||
// --- AC7: loose payload parsing --------------------------------------------
|
// --- AC7: loose payload parsing --------------------------------------------
|
||||||
assert.deepEqual(parseLoosePayload('{"a":1}'), { a: 1 });
|
assert.deepEqual(parseLoosePayload('{"a":1}'), { a: 1 });
|
||||||
assert.deepEqual(parseLoosePayload("{a:1,b:'x',}"), { a: 1, b: "x" });
|
assert.deepEqual(parseLoosePayload("{a:1,b:'x',}"), { a: 1, b: "x" });
|
||||||
|
assert.deepEqual(parseLoosePayload('{data:{1519:[{id:"53122",longitude:114.12871535072304,latitude:30.46061866463221}]}}'), {
|
||||||
|
data: { 1519: [{ id: "53122", longitude: 114.12871535072304, latitude: 30.46061866463221 }] },
|
||||||
|
}, "targetPosition uses unquoted numeric device-id keys");
|
||||||
assert.deepEqual(parseLoosePayload('{"a":NaN}'), { a: null });
|
assert.deepEqual(parseLoosePayload('{"a":NaN}'), { a: null });
|
||||||
assert.equal(parseLoosePayload("{bad"), null);
|
assert.equal(parseLoosePayload("{bad"), null);
|
||||||
assert.equal(parseLoosePayload(null), null);
|
assert.equal(parseLoosePayload(null), null);
|
||||||
@@ -161,6 +166,10 @@ assert.equal(obuPush.vehicle.id, "obu-c1");
|
|||||||
assert.deepEqual(normalizeTargetVehicles(JSON.stringify({ data: { 8: [{ id: 9, longitude: 114.12866, latitude: 30.4605, type: 1, subType: 2, angle: 180, speed: 5 }] } })), [{
|
assert.deepEqual(normalizeTargetVehicles(JSON.stringify({ data: { 8: [{ id: 9, longitude: 114.12866, latitude: 30.4605, type: 1, subType: 2, angle: 180, speed: 5 }] } })), [{
|
||||||
id: "8-9-12", label: "8-9", kind: "target", longitude: 114.12866, latitude: 30.4605, angle: 180, speed: 5, type: 1, subType: 2,
|
id: "8-9-12", label: "8-9", kind: "target", longitude: 114.12866, latitude: 30.4605, angle: 180, speed: 5, type: 1, subType: 2,
|
||||||
}]);
|
}]);
|
||||||
|
assert.equal(normalizeTargetPush(JSON.stringify({ data: { 1519: [{ id: "50089", longitude: 114.12876415699589, latitude: 30.46060781353787, type: 1, subType: 1 }] }, interval: 1000 })).vehicles.length, 1,
|
||||||
|
"the live targetPosition payload uses numeric object keys and string target ids");
|
||||||
|
assert.equal(normalizeTargetPush('{data:{1519:[{id:"53122",longitude:114.12871535072304,latitude:30.46061866463221,type:1,subType:1}]},interval:1000}').vehicles.length, 1,
|
||||||
|
"unquoted numeric device-id keys are accepted from the live targetPosition stream");
|
||||||
const targetPush = normalizeTargetPush(JSON.stringify({ interval: 0, data: { 8: [{ id: 9, longitude: 114.1, latitude: 30.4, type: 1 }] } }));
|
const targetPush = normalizeTargetPush(JSON.stringify({ interval: 0, data: { 8: [{ id: 9, longitude: 114.1, latitude: 30.4, type: 1 }] } }));
|
||||||
assert.equal(targetPush.vehicles[0].subType, 1, "type 1 without subType defaults to subType 1");
|
assert.equal(targetPush.vehicles[0].subType, 1, "type 1 without subType defaults to subType 1");
|
||||||
assert.equal(resolvePushInterval(targetPush.interval), 500, "interval 0 falls back to 500");
|
assert.equal(resolvePushInterval(targetPush.interval), 500, "interval 0 falls back to 500");
|
||||||
@@ -168,7 +177,7 @@ assert.equal(resolvePushInterval(undefined), 500);
|
|||||||
assert.equal(resolvePushInterval(900), 900);
|
assert.equal(resolvePushInterval(900), 900);
|
||||||
|
|
||||||
// --- AC8: model naming matches the dashboard --------------------------------
|
// --- AC8: model naming matches the dashboard --------------------------------
|
||||||
assert.equal(modelNameFor({ kind: "obu" }), "car_obu.glb");
|
assert.equal(modelNameFor({ kind: "obu" }), "11.glb");
|
||||||
assert.equal(modelNameFor({ kind: "target", type: 1, subType: 2 }), "12.glb");
|
assert.equal(modelNameFor({ kind: "target", type: 1, subType: 2 }), "12.glb");
|
||||||
assert.equal(modelNameFor({ kind: "target" }), "11.glb");
|
assert.equal(modelNameFor({ kind: "target" }), "11.glb");
|
||||||
|
|
||||||
@@ -178,10 +187,12 @@ const registry = createVehicleRegistry({ now: () => clock });
|
|||||||
registry.ingest([{ id: "v1", kind: "target", type: 1, subType: 1 }], 1000);
|
registry.ingest([{ id: "v1", kind: "target", type: 1, subType: 1 }], 1000);
|
||||||
assert.equal(registry.list().length, 1);
|
assert.equal(registry.list().length, 1);
|
||||||
assert.equal(registry.list()[0].visible, true);
|
assert.equal(registry.list()[0].visible, true);
|
||||||
|
assert.equal(registry.visibleSize, 1);
|
||||||
assert.equal(registry.list()[0].duration, 1000);
|
assert.equal(registry.list()[0].duration, 1000);
|
||||||
assert.deepEqual(registry.sweep(clock + 1499), [], "still fresh below interval * 1.5");
|
assert.deepEqual(registry.sweep(clock + 1499), [], "still fresh below interval * 1.5");
|
||||||
assert.deepEqual(registry.sweep(clock + 1500), ["v1"], "hidden at interval * 1.5");
|
assert.deepEqual(registry.sweep(clock + 1500), ["v1"], "hidden at interval * 1.5");
|
||||||
assert.equal(registry.list()[0].visible, false);
|
assert.equal(registry.list()[0].visible, false);
|
||||||
|
assert.equal(registry.visibleSize, 0);
|
||||||
assert.equal(registry.list().length, 1, "hidden vehicles are kept as reusable slots, not removed");
|
assert.equal(registry.list().length, 1, "hidden vehicles are kept as reusable slots, not removed");
|
||||||
|
|
||||||
clock = 5000;
|
clock = 5000;
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ async function request(port, pathName, options = {}) {
|
|||||||
assert.equal(isProxyPath("/package/manifest.json"), false);
|
assert.equal(isProxyPath("/package/manifest.json"), false);
|
||||||
assert.equal(upstreamPath("/api/facilities/api/sys/login?x=1"), "/facilities/api/sys/login?x=1");
|
assert.equal(upstreamPath("/api/facilities/api/sys/login?x=1"), "/facilities/api/sys/login?x=1");
|
||||||
assert.equal(upstreamPath("/websocket/network/ws/network/signal"), "/network/ws/network/signal");
|
assert.equal(upstreamPath("/websocket/network/ws/network/signal"), "/network/ws/network/signal");
|
||||||
|
// An upstream base path (deployments front the API as /dashboardApi) is kept.
|
||||||
|
assert.equal(upstreamPath("/api/facilities/api/sys/login", "/dashboardApi"), "/dashboardApi/facilities/api/sys/login");
|
||||||
|
assert.equal(upstreamPath("/websocket/network/ws/x", "/dashboardWebsocket/"), "/dashboardWebsocket/network/ws/x");
|
||||||
|
assert.equal(upstreamPath("/api/x", "/"), "/x");
|
||||||
|
|
||||||
const upstream = http.createServer((req, res) => {
|
const upstream = http.createServer((req, res) => {
|
||||||
let body = "";
|
let body = "";
|
||||||
@@ -59,6 +63,28 @@ async function request(port, pathName, options = {}) {
|
|||||||
} finally {
|
} finally {
|
||||||
await Promise.all([new Promise((resolve) => preview.close(resolve)), new Promise((resolve) => upstream.close(resolve))]);
|
await Promise.all([new Promise((resolve) => preview.close(resolve)), new Promise((resolve) => upstream.close(resolve))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same upstream, but reached through a prefixed base path.
|
||||||
|
const prefixedUpstream = http.createServer((req, res) => {
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ path: req.url }));
|
||||||
|
});
|
||||||
|
const prefixedPort = await listen(prefixedUpstream);
|
||||||
|
const prefixedPreview = createV2xPreviewServer({
|
||||||
|
root: path.join(__dirname, "..", "outputs", "fengshu-er-road"),
|
||||||
|
upstream: `http://127.0.0.1:${prefixedPort}/dashboardApi`,
|
||||||
|
wsUpstream: `http://127.0.0.1:${prefixedPort}/dashboardWebsocket`,
|
||||||
|
});
|
||||||
|
const prefixedPreviewPort = await listen(prefixedPreview);
|
||||||
|
try {
|
||||||
|
const proxied = await request(prefixedPreviewPort, "/api/facilities/api/sys/login");
|
||||||
|
assert.deepEqual(JSON.parse(proxied.body), { path: "/dashboardApi/facilities/api/sys/login" });
|
||||||
|
} finally {
|
||||||
|
await Promise.all([
|
||||||
|
new Promise((resolve) => prefixedPreview.close(resolve)),
|
||||||
|
new Promise((resolve) => prefixedUpstream.close(resolve)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
console.log("V2X preview server tests passed.");
|
console.log("V2X preview server tests passed.");
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ function parseArgs(argv) {
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createV2xPreviewServer({ root, upstream }) {
|
function createV2xPreviewServer({ root, upstream, wsUpstream }) {
|
||||||
const staticRoot = path.resolve(root);
|
const staticRoot = path.resolve(root);
|
||||||
const upstreamUrl = new URL(upstream);
|
const upstreamUrl = new URL(upstream);
|
||||||
|
// Deployments front the API and the WebSocket with different prefixes (e.g.
|
||||||
|
// /dashboardApi and /dashboardWebsocket), so the two targets are separate.
|
||||||
|
const wsUpstreamUrl = wsUpstream ? new URL(wsUpstream) : upstreamUrl;
|
||||||
if (!fs.statSync(staticRoot).isDirectory()) throw new Error(`Preview root is not a directory: ${staticRoot}`);
|
if (!fs.statSync(staticRoot).isDirectory()) throw new Error(`Preview root is not a directory: ${staticRoot}`);
|
||||||
if (!/^https?:$/.test(upstreamUrl.protocol)) throw new Error("V2X upstream must use http or https");
|
if (!/^https?:$/.test(upstreamUrl.protocol)) throw new Error("V2X upstream must use http or https");
|
||||||
|
if (!/^https?:$/.test(wsUpstreamUrl.protocol)) throw new Error("V2X websocket upstream must use http or https");
|
||||||
|
|
||||||
const server = http.createServer((request, response) => {
|
const server = http.createServer((request, response) => {
|
||||||
if (isProxyPath(request.url)) {
|
if (isProxyPath(request.url)) {
|
||||||
@@ -53,7 +57,7 @@ function createV2xPreviewServer({ root, upstream }) {
|
|||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
proxyWebSocket(request, socket, head, upstreamUrl);
|
proxyWebSocket(request, socket, head, wsUpstreamUrl);
|
||||||
});
|
});
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
@@ -64,10 +68,15 @@ function isProxyPath(url) {
|
|||||||
pathname === "/websocket" || pathname.startsWith("/websocket/");
|
pathname === "/websocket" || pathname.startsWith("/websocket/");
|
||||||
}
|
}
|
||||||
|
|
||||||
function upstreamPath(url) {
|
// The browser calls /api/... and /websocket/...; the local prefix is dropped and
|
||||||
|
// whatever base path the upstream URL carries is prepended, so an upstream of
|
||||||
|
// http://host:7862/dashboardApi maps /api/facilities/... onto
|
||||||
|
// /dashboardApi/facilities/... instead of the bare backend path.
|
||||||
|
function upstreamPath(url, basePath) {
|
||||||
const parsed = new URL(url, "http://preview.local");
|
const parsed = new URL(url, "http://preview.local");
|
||||||
const pathname = parsed.pathname.replace(/^\/(api|websocket)(?=\/|$)/, "") || "/";
|
const pathname = parsed.pathname.replace(/^\/(api|websocket)(?=\/|$)/, "") || "/";
|
||||||
return `${pathname}${parsed.search}`;
|
const base = String(basePath || "").replace(/\/+$/, "");
|
||||||
|
return `${base}${pathname}${parsed.search}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function upstreamRequestOptions(request, upstreamUrl) {
|
function upstreamRequestOptions(request, upstreamUrl) {
|
||||||
@@ -76,7 +85,7 @@ function upstreamRequestOptions(request, upstreamUrl) {
|
|||||||
hostname: upstreamUrl.hostname,
|
hostname: upstreamUrl.hostname,
|
||||||
port: upstreamUrl.port || undefined,
|
port: upstreamUrl.port || undefined,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: upstreamPath(request.url),
|
path: upstreamPath(request.url, upstreamUrl.pathname),
|
||||||
headers: { ...request.headers, host: upstreamUrl.host },
|
headers: { ...request.headers, host: upstreamUrl.host },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -155,12 +164,13 @@ if (require.main === module) {
|
|||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
const root = args.root || process.cwd();
|
const root = args.root || process.cwd();
|
||||||
const upstream = args.upstream || process.env.V2X_UPSTREAM;
|
const upstream = args.upstream || process.env.V2X_UPSTREAM;
|
||||||
|
const wsUpstream = args.wsUpstream || process.env.V2X_WS_UPSTREAM;
|
||||||
const port = Number(args.port || process.env.PORT || 7862);
|
const port = Number(args.port || process.env.PORT || 7862);
|
||||||
const host = args.host || process.env.HOST || "0.0.0.0";
|
const host = args.host || process.env.HOST || "0.0.0.0";
|
||||||
if (!upstream) throw new Error("Set V2X_UPSTREAM or pass --upstream http://host:port");
|
if (!upstream) throw new Error("Set V2X_UPSTREAM or pass --upstream http://host:port");
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port must be an integer between 1 and 65535");
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port must be an integer between 1 and 65535");
|
||||||
const server = createV2xPreviewServer({ root, upstream });
|
const server = createV2xPreviewServer({ root, upstream, wsUpstream });
|
||||||
server.listen(port, host, () => console.log(`V2X preview server: http://${host}:${port} -> ${upstream}`));
|
server.listen(port, host, () => console.log(`V2X preview server: http://${host}:${port} -> ${upstream} (ws -> ${wsUpstream || upstream})`));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createV2xPreviewServer, isProxyPath, upstreamPath };
|
module.exports = { createV2xPreviewServer, isProxyPath, upstreamPath };
|
||||||
|
|||||||
Reference in New Issue
Block a user