fix: render live V2X vehicles in Cesium preview

This commit is contained in:
2026-08-25 12:57:35 +08:00
parent bc845444bb
commit 25cf82e7c7
23 changed files with 198 additions and 58 deletions

View File

@@ -5,6 +5,8 @@
const EARTH_A = 6378245.0;
const EARTH_EE = 0.00669342162296594323;
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) {
const settings = context.config.v2xPreview || {};
@@ -22,6 +24,13 @@
vehicleSource: "waiting",
parseFailures: 0,
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
// 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.missingModels.size) parts.push(`${state.missingModels.size} models missing`);
if (state.parseFailures) parts.push(`${state.parseFailures} unparsed pushes`);
if (state.targetMessages) parts.push(`${state.targetVehiclesReceived} target vehicles`);
return parts.join(", ");
}
@@ -55,7 +65,6 @@
state.entities = [];
registry.list().forEach((record) => {
if (record.entity) context.viewer.entities.remove(record.entity);
if (record.trace) context.viewer.entities.remove(record.trace);
});
registry.clear();
state.linkEntitiesByPhase.clear();
@@ -264,12 +273,29 @@
const targetIds = arrayValue(deviceConfig?.deviceConfig?.target);
send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null }));
}, (value) => {
const push = normalizeTargetPush(value);
if (!push.vehicles.length) { if (!isHeartbeat(value) && parseLoosePayload(value) === null) state.parseFailures += 1; return; }
useLiveVehicles();
registry.ingest(push.vehicles, push.interval);
registry.sweep();
renderVehicles();
if (isHeartbeat(value)) return;
state.targetMessages += 1;
try {
state.targetPayloadType = Object.prototype.toString.call(value);
state.targetPayloadSample = typeof value === "string" ? value.slice(0, 800) : String(value).slice(0, 800);
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");
}
@@ -370,10 +396,8 @@
function syncVehicleEntity(viewer, record, state) {
const [longitude, latitude] = gcj02ToWgs84([record.longitude, record.latitude]);
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) {
const tracePositions = [position];
record.tracePositions = tracePositions;
record.renderedPosition = position;
record.targetPosition = position;
record.sampleStart = Date.now();
@@ -390,35 +414,27 @@
() => vehicleOrientation(record.targetPosition, record.angle), false),
model: {
uri: vehicleModelUri(record, state),
scale: 0.9,
minimumPixelSize: 14,
maximumScale: 2.0,
// The full-intersection overview needs more than a couple of screen
// pixels for a car to remain distinguishable from road markings.
scale: 1.0,
minimumPixelSize: 28,
maximumScale: 8.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 {
record.renderedPosition = record.targetPosition || position;
record.targetPosition = position;
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.
record.entity.show = record.visible;
record.trace.show = record.visible;
if (!record.visible) record.tracePositions.length = 0;
}
function vehicleOrientation(position, angle) {
return Cesium.Transforms.headingPitchRollQuaternion(
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) {
return text
.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*(NaN|-?Infinity)\s*([,}\]])/g, ": null$2");
}
@@ -620,7 +636,21 @@
// dashboard's onConnected. A reconnect without this receives nothing.
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.onclose = (event) => {
stopHeartbeat();
@@ -702,11 +732,11 @@
}).filter(Boolean));
}
// OBU vehicles use a fixed model; targets are keyed by type/subType. Mirrors
// useObuCars.ts ('car_obu.glb') and useTargetCars.ts (`${type}${subType}.glb`).
// The dashboard's CrossCars template renders OBU with 11.glb, while target
// vehicles retain their `${type}${subType}.glb` model selection.
function modelNameFor(vehicle) {
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`;
}
@@ -777,6 +807,7 @@
list: () => records.slice(),
get interval() { return interval; },
get size() { return records.length; },
get visibleSize() { return records.filter((record) => record.visible).length; },
};
}