fix: restore live V2X signal and vehicle fidelity in Cesium preview
The ported overlay used the correct REST paths but lost the data handling
from the source dashboard's live-intersection view (HologramCross), so
signals rendered permanently red and vehicles often never appeared.
- Lamp status codes now follow the dashboard dictionary (11/21/22/23/31).
The previous 2/3 reading made every real push fall through to red. The
dictionary lives only in the overlay; the preview consumes normalized
{nodeKeys, color, countDown} entries so the two copies cannot drift.
- Bind V2X phases to native signal heads geometrically. The runtime
document has no phaseNo, so the old lookup fell back to signal.id and
never matched, leaving the dynamic assembly dark. Travel heading is
recovered as faceHeadingDegrees + 180, per the generator's
mast = travel - 90 / face = travel + 180. Verified 7/7 exact matches
against the fengshu-er-road runtime document.
- A phase now lights every approach it drives; the phase -> single entity
map silently overwrote all but the last.
- Drive the countdown assets from the push's countDown field.
- All three sockets heartbeat every 30s and reconnect with backoff,
replaying their subscription frame. Without this the service dropped
the connection and the scene emptied after about a minute.
- The OBU socket sends its bounds frame on connect and on camera move;
it previously sent nothing at all.
- Vehicles are swept when a push goes stale and their slots reused, so
they no longer accumulate as ghosts. Models follow the dashboard's
car_obu.glb / ${type}${subType}.glb naming.
- Parse vehicle pushes leniently, since the dashboard uses saferEval and
the payload is not guaranteed to be strict JSON. Failures are counted
and surfaced rather than dropped; no eval is introduced.
- Drop FlowTravelRatio/queryListWeek, which is not part of this view.
Also corrects a stale spec rule that required vehicleModelNames to be
empty. Live V2X vehicles need packaged models; the real invariant is no
generated routes or traffic simulation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -589,18 +589,18 @@ function writeCesiumPreview(area, roadProvider) {
|
||||
// Do not let a route from an earlier legacy preview survive into native output.
|
||||
fs.rmSync(area.outputs.vehicleRoute, { force: true });
|
||||
}
|
||||
const vehicleModelNames = [];
|
||||
const vehicleModelNames = writeVehicleModel(area);
|
||||
writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
|
||||
const glbName = "package/manifest.json";
|
||||
const metadataName = "package/manifest.json";
|
||||
const routeName = vehicleRoute || routeArtifact
|
||||
? previewRelativePath(area.outputs.areaDir, vehicleRoute ? area.outputs.vehicleRoute : routeArtifact)
|
||||
: null;
|
||||
const vehicleModelName = null;
|
||||
const descriptor = { routeName, vehicleModelName, vehicleModelNames, trafficSignalsName: "package/runtime/traffic-signals.json", assets: [] };
|
||||
const vehicleModelName = vehicleModelNames[0] ? `_preview/${vehicleModelNames[0]}` : null;
|
||||
const descriptor = { routeName, vehicleModelName, 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, "package/runtime/traffic-signals.json", "_preview/descriptor.json", area.v2xPreview));
|
||||
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, {
|
||||
|
||||
@@ -37,8 +37,8 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(areaId)} Cesium Preview</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Cesium.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Widgets/widgets.css" rel="stylesheet" onerror="this.onerror=null;this.href='https://unpkg.com/cesium@1.121.1/Build/Cesium/Widgets/widgets.css'">
|
||||
<script src="https://cdn.jsdelivr.net/npm/cesium@1.121.1/Build/Cesium/Cesium.js" onerror="this.onerror=null;this.src='https://unpkg.com/cesium@1.121.1/Build/Cesium/Cesium.js';"></script>
|
||||
<link href="cesium-preview.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -54,11 +54,19 @@
|
||||
setLoadingMessage("Loading model", config.glbName || "");
|
||||
const assets = await loadSceneAssets(viewer, metadata, placement);
|
||||
const trafficStart = Cesium.JulianDate.now();
|
||||
const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart, assets);
|
||||
const trafficSignals = createLiveTrafficSignals(signalData, assets);
|
||||
hideUnconfirmedSignalAssets(assets);
|
||||
const cruise = createLiveVehicleState();
|
||||
const cameras = createCameraPresets(viewer, metadata, placement, cruise);
|
||||
const v2xOverlay = typeof window.createV2xCesiumOverlay === "function"
|
||||
? window.createV2xCesiumOverlay({ viewer, metadata, placement, config })
|
||||
? window.createV2xCesiumOverlay({
|
||||
viewer,
|
||||
metadata,
|
||||
placement,
|
||||
config,
|
||||
nativeSignals: (signalData && signalData.signals) || [],
|
||||
setSignalState: (entries) => trafficSignals.update(entries),
|
||||
})
|
||||
: null;
|
||||
|
||||
buildAssetToggles(assets);
|
||||
@@ -611,6 +619,78 @@
|
||||
};
|
||||
}
|
||||
|
||||
function hideUnconfirmedSignalAssets(assets) {
|
||||
assets.filter((asset) => asset.category === "dynamic" || asset.category === "countdown")
|
||||
.forEach((asset) => { if (asset.model) asset.model.show = false; });
|
||||
}
|
||||
|
||||
// Driven entirely by live V2X lamp state. The overlay has already resolved
|
||||
// phaseNo -> native node keys and normalized the lamp status, so this only
|
||||
// paints; it never re-interprets status codes.
|
||||
function createLiveTrafficSignals(signalData, assets) {
|
||||
const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model);
|
||||
const countdownModels = new Map(assets
|
||||
.filter((asset) => asset.category === "countdown" && asset.model)
|
||||
.map((asset) => [Number(asset.phaseGroup), asset.model]));
|
||||
const signals = (signalData?.signals || []).filter((signal) => signal && signal.id);
|
||||
const phaseGroupByNodeKey = new Map(signals.map((signal) => [String(signal.nodeKey || signal.id), Number(signal.phaseGroup)]));
|
||||
const state = { live: false };
|
||||
|
||||
function paintLamp(nodeKey, color) {
|
||||
let changed = false;
|
||||
["red", "yellow", "green"].forEach((lamp) => {
|
||||
let node = null;
|
||||
try { node = dynamic.model.getNode(`TrafficSignalDynamic_${nodeKey}_${lamp}`); } catch (_) { return; }
|
||||
if (node && node.show !== (lamp === color)) { node.show = lamp === color; changed = true; }
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function paintCountdown(nodeKey, countDown, color) {
|
||||
const model = countdownModels.get(phaseGroupByNodeKey.get(String(nodeKey)));
|
||||
if (!model) return false;
|
||||
// Only two digits are modelled; anything outside 00-19 shows nothing.
|
||||
const visible = Number.isFinite(countDown) && countDown >= 0 && countDown < 20
|
||||
? String(countDown).padStart(2, "0")
|
||||
: null;
|
||||
let changed = false;
|
||||
for (let value = 0; value < 20; value += 1) {
|
||||
const label = String(value).padStart(2, "0");
|
||||
let node = null;
|
||||
try { node = model.getNode(`TrafficSignalDynamic_${nodeKey}_countdown_${label}`); } catch (_) { continue; }
|
||||
if (node && node.show !== (label === visible)) { node.show = label === visible; changed = true; }
|
||||
}
|
||||
if (visible !== null && typeof signalBaseColor === "function") {
|
||||
model.color = signalBaseColor(color);
|
||||
model.colorBlendMode = Cesium.ColorBlendMode.REPLACE;
|
||||
model.colorBlendAmount = 1.0;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function update(entries) {
|
||||
if (!dynamic?.model) return;
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
let changed = false;
|
||||
let painted = 0;
|
||||
list.forEach((entry) => {
|
||||
const nodeKeys = Array.isArray(entry?.nodeKeys) ? entry.nodeKeys : [];
|
||||
nodeKeys.forEach((nodeKey) => {
|
||||
painted += 1;
|
||||
if (paintLamp(nodeKey, entry.color)) changed = true;
|
||||
if (paintCountdown(nodeKey, entry.countDown, entry.color)) changed = true;
|
||||
});
|
||||
});
|
||||
// Only show the dynamic assembly once a lamp actually resolved to a head.
|
||||
state.live = painted > 0;
|
||||
dynamic.model.show = state.live;
|
||||
countdownModels.forEach((model) => { model.show = state.live; });
|
||||
if (changed) dynamic.model.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
return { count: signals.length, state, update, set show(value) { if (dynamic?.model) dynamic.model.show = Boolean(value && state.live); } };
|
||||
}
|
||||
|
||||
function addTrafficSignals(viewer, signalData, start, assets) {
|
||||
const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model);
|
||||
const countdownModels = new Map(assets
|
||||
@@ -1416,8 +1496,9 @@
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
setStatus("Failed to load Cesium preview: " + error.message);
|
||||
const detail = error && error.message ? error.message : String(error);
|
||||
setStatus("Failed to load Cesium preview: " + detail);
|
||||
document.body.classList.add("scene-error");
|
||||
setLoadingMessage("Failed to load scene", error.message);
|
||||
setLoadingMessage("Failed to load scene", detail + " (see Safari Develop > Show JavaScript Console)");
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -11,37 +11,66 @@
|
||||
if (!settings.enabled) return null;
|
||||
|
||||
const state = {
|
||||
token: sessionStorage.getItem(TOKEN_KEY) || "",
|
||||
token: "",
|
||||
entities: [],
|
||||
vehicles: new Map(),
|
||||
linkPhases: new Map(),
|
||||
linkEntitiesByPhase: new Map(),
|
||||
phaseSignals: new Map(),
|
||||
phaseBinding: { bound: 0, unbound: [], diagnostics: [] },
|
||||
sockets: [],
|
||||
status: "Sign in to load live V2X data.",
|
||||
crossCode: settings.crossCode || "",
|
||||
metrics: null,
|
||||
vehicleSource: "waiting",
|
||||
parseFailures: 0,
|
||||
missingModels: new Set(),
|
||||
};
|
||||
// This preview intentionally starts at the login gate. Do not revive a
|
||||
// previous tab's token and begin REST/WebSocket traffic before sign-in.
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
const ui = buildUi(state);
|
||||
state.vehicleModels = Array.isArray(context.config.vehicleModelNames) ? context.config.vehicleModelNames : [];
|
||||
state.setSignalState = context.setSignalState;
|
||||
state.nativeSignals = arrayValue(context.nativeSignals);
|
||||
const registry = createVehicleRegistry({});
|
||||
state.registry = registry;
|
||||
let sweepTimer = null;
|
||||
let boundsTimer = null;
|
||||
let obuSend = null;
|
||||
|
||||
function setStatus(message) {
|
||||
state.status = message;
|
||||
ui.status.textContent = message;
|
||||
}
|
||||
|
||||
function statusDetail() {
|
||||
const parts = [];
|
||||
parts.push(`${state.phaseBinding.bound} phases bound`);
|
||||
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`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function clearEntities() {
|
||||
state.entities.forEach((entity) => context.viewer.entities.remove(entity));
|
||||
state.entities = [];
|
||||
state.vehicles.forEach((vehicle) => {
|
||||
context.viewer.entities.remove(vehicle.entity);
|
||||
context.viewer.entities.remove(vehicle.trace);
|
||||
registry.list().forEach((record) => {
|
||||
if (record.entity) context.viewer.entities.remove(record.entity);
|
||||
if (record.trace) context.viewer.entities.remove(record.trace);
|
||||
});
|
||||
state.vehicles.clear();
|
||||
state.linkPhases.clear();
|
||||
registry.clear();
|
||||
state.linkEntitiesByPhase.clear();
|
||||
state.phaseSignals.clear();
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
state.sockets.forEach((socket) => socket.close());
|
||||
state.sockets.forEach((socket) => socket.dispose());
|
||||
state.sockets = [];
|
||||
if (sweepTimer !== null) { clearInterval(sweepTimer); sweepTimer = null; }
|
||||
if (boundsTimer !== null) { clearTimeout(boundsTimer); boundsTimer = null; }
|
||||
obuSend = null;
|
||||
if (context.viewer?.camera?.moveEnd) {
|
||||
try { context.viewer.camera.moveEnd.removeEventListener(onCameraMoveEnd); } catch (_) { /* not attached */ }
|
||||
}
|
||||
}
|
||||
|
||||
function useLiveVehicles() {
|
||||
@@ -81,7 +110,6 @@
|
||||
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();
|
||||
@@ -92,6 +120,9 @@
|
||||
clearEntities();
|
||||
state.vehicleSource = "waiting";
|
||||
state.token = "";
|
||||
state.parseFailures = 0;
|
||||
state.missingModels.clear();
|
||||
state.phaseBinding = { bound: 0, unbound: [], diagnostics: [] };
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
ui.form.hidden = false;
|
||||
ui.live.hidden = true;
|
||||
@@ -99,9 +130,9 @@
|
||||
}
|
||||
|
||||
async function loadLiveData() {
|
||||
const crossCode = state.crossCode;
|
||||
const crossCode = await resolveCrossCode();
|
||||
if (!crossCode) {
|
||||
setStatus("V2X intersection code is not configured.");
|
||||
setStatus("No V2X intersection is configured for this account.");
|
||||
return;
|
||||
}
|
||||
clearEntities();
|
||||
@@ -112,23 +143,44 @@
|
||||
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 [links, poles, devices, deviceConfig] = results;
|
||||
const warnings = [];
|
||||
const linkList = links.status === "fulfilled" ? arrayValue(links.value?.inLinkList) : [];
|
||||
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");
|
||||
|
||||
state.phaseBinding = buildPhaseSignalMap(linkList, state.nativeSignals, {
|
||||
phaseSignalMap: settings.phaseSignalMap,
|
||||
maxDistanceMeters: settings.maxDistanceMeters,
|
||||
maxHeadingDeltaDegrees: settings.maxHeadingDeltaDegrees,
|
||||
});
|
||||
state.phaseSignals = state.phaseBinding.byPhase;
|
||||
if (!state.phaseBinding.bound && linkList.length) {
|
||||
warnings.push("no phase bound to a native signal head; set v2xPreview.phaseSignalMap");
|
||||
}
|
||||
|
||||
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");
|
||||
connectLiveSockets(deviceConfig.status === "fulfilled" ? deviceConfig.value : null);
|
||||
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(", ")}.` : ""}`);
|
||||
setStatus(`Live V2X: ${linkList.length} links, ${deviceCount} devices, ${poleCount} poles, ${targetCount} configured targets. ${statusDetail()}. GCJ-02 -> WGS84 once.${warnings.length ? ` ${warnings.join(", ")}.` : ""}`);
|
||||
}
|
||||
|
||||
async function resolveCrossCode() {
|
||||
if (state.crossCode) return state.crossCode;
|
||||
setStatus("Finding the configured V2X intersection...");
|
||||
const [crosses, configurations] = await Promise.allSettled([
|
||||
request("/cloud-display/api/search/crossList?hdMap=true"),
|
||||
request("/facilities/api/crossDeviceConfig/queryList"),
|
||||
]);
|
||||
state.crossCode = selectCrossCode(
|
||||
crosses.status === "fulfilled" ? crosses.value : null,
|
||||
configurations.status === "fulfilled" ? configurations.value : null,
|
||||
);
|
||||
return state.crossCode;
|
||||
}
|
||||
|
||||
function connectLiveSockets(deviceConfig) {
|
||||
@@ -136,44 +188,95 @@
|
||||
connectSignalSocket();
|
||||
connectObuSocket();
|
||||
connectTargetSocket(deviceConfig);
|
||||
// Stale vehicles must disappear even while pushes keep arriving for
|
||||
// others, so sweep on a timer as well as on each push.
|
||||
sweepTimer = setInterval(() => {
|
||||
if (registry.sweep().length) renderVehicles();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function openSocket(path, onOpen, onMessage, unavailable) {
|
||||
const socketUrl = toWebSocketUrl(joinUrl(settings.wsBaseUrl || "/websocket", path), state.token);
|
||||
try {
|
||||
const socket = new WebSocket(socketUrl);
|
||||
state.sockets.push(socket);
|
||||
socket.onopen = () => onOpen?.(socket);
|
||||
socket.onmessage = (event) => onMessage(event.data);
|
||||
socket.onerror = () => setStatus(`${state.status} ${unavailable}.`);
|
||||
return socket;
|
||||
} catch (_) {
|
||||
setStatus(`${state.status} ${unavailable}.`);
|
||||
return null;
|
||||
}
|
||||
const socket = createSocket({
|
||||
url: () => toWebSocketUrl(joinUrl(settings.wsBaseUrl || "/websocket", path), state.token),
|
||||
onOpen,
|
||||
onMessage,
|
||||
onError: () => setStatus(`${state.status} ${unavailable}.`),
|
||||
});
|
||||
state.sockets.push(socket);
|
||||
socket.open();
|
||||
return socket;
|
||||
}
|
||||
|
||||
function connectSignalSocket() {
|
||||
openSocket("/network/ws/network/signal", (socket) => socket.send(JSON.stringify({ junctionId: state.crossCode })),
|
||||
(value) => updateSignalPhases(value, state), "Signal WebSocket unavailable");
|
||||
openSocket("/network/ws/network/signal",
|
||||
({ send }) => send(JSON.stringify({ junctionId: String(state.crossCode) })),
|
||||
(value) => updateSignalPhases(value, state),
|
||||
"Signal WebSocket unavailable");
|
||||
}
|
||||
|
||||
function onCameraMoveEnd() {
|
||||
if (boundsTimer !== null) clearTimeout(boundsTimer);
|
||||
boundsTimer = setTimeout(() => {
|
||||
boundsTimer = null;
|
||||
const bounds = cameraBoundsMessage();
|
||||
if (obuSend && bounds) obuSend(JSON.stringify({ bounds }));
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function cameraBoundsMessage() {
|
||||
try {
|
||||
const rectangle = context.viewer?.camera?.computeViewRectangle?.();
|
||||
if (!rectangle) return "";
|
||||
return buildBoundsMessage({
|
||||
west: Cesium.Math.toDegrees(rectangle.west),
|
||||
south: Cesium.Math.toDegrees(rectangle.south),
|
||||
east: Cesium.Math.toDegrees(rectangle.east),
|
||||
north: Cesium.Math.toDegrees(rectangle.north),
|
||||
});
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function connectObuSocket() {
|
||||
openSocket("/network/ws/network/obuPosition", null, (value) => {
|
||||
const vehicle = normalizeObuVehicle(value);
|
||||
if (vehicle) updateLiveVehicle(context.viewer, vehicle, state);
|
||||
const socket = openSocket("/network/ws/network/obuPosition", ({ send }) => {
|
||||
obuSend = send;
|
||||
// The dashboard sends `bounds || ''` on connect; an empty frame means
|
||||
// "no viewport filter", which is the safe default for a tilted camera.
|
||||
const bounds = cameraBoundsMessage();
|
||||
send(bounds ? JSON.stringify({ bounds }) : "");
|
||||
}, (value) => {
|
||||
const push = normalizeObuPush(value);
|
||||
if (!push.vehicle) { if (!isHeartbeat(value)) state.parseFailures += 1; return; }
|
||||
useLiveVehicles();
|
||||
registry.ingest([push.vehicle], push.interval);
|
||||
registry.sweep();
|
||||
renderVehicles();
|
||||
}, "OBU vehicle WebSocket unavailable");
|
||||
if (context.viewer?.camera?.moveEnd) {
|
||||
try { context.viewer.camera.moveEnd.addEventListener(onCameraMoveEnd); } catch (_) { /* no camera events */ }
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
function connectTargetSocket(deviceConfig) {
|
||||
openSocket("/network/ws/network/targetPosition", (socket) => {
|
||||
openSocket("/network/ws/network/targetPosition", ({ send }) => {
|
||||
const targetIds = arrayValue(deviceConfig?.deviceConfig?.target);
|
||||
socket.send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null }));
|
||||
send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null }));
|
||||
}, (value) => {
|
||||
normalizeTargetVehicles(value).forEach((vehicle) => updateLiveVehicle(context.viewer, vehicle, state));
|
||||
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();
|
||||
}, "Target vehicle WebSocket unavailable");
|
||||
}
|
||||
|
||||
function renderVehicles() {
|
||||
registry.list().forEach((record) => syncVehicleEntity(context.viewer, record, state));
|
||||
}
|
||||
|
||||
ui.form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
@@ -186,12 +289,12 @@
|
||||
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(); state.vehicleSource = "waiting"; ui.root.remove(); } };
|
||||
return {
|
||||
state,
|
||||
load: loadLiveData,
|
||||
signOut,
|
||||
dispose: () => { disconnect(); clearEntities(); state.vehicleSource = "waiting"; ui.root.remove(); },
|
||||
};
|
||||
}
|
||||
|
||||
function buildUi(state) {
|
||||
@@ -225,7 +328,14 @@
|
||||
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));
|
||||
// A phase can drive several approaches, so collect entities per phase
|
||||
// instead of overwriting a single one.
|
||||
arrayValue(link.phaseList).forEach((phase) => {
|
||||
const key = String(phase.phase);
|
||||
const existing = state.linkEntitiesByPhase.get(key) || [];
|
||||
existing.push(entity);
|
||||
state.linkEntitiesByPhase.set(key, existing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -245,43 +355,318 @@
|
||||
});
|
||||
}
|
||||
|
||||
function updateLiveVehicle(viewer, vehicle, state) {
|
||||
const [longitude, latitude] = gcj02ToWgs84([vehicle.longitude, vehicle.latitude]);
|
||||
// Pick the packaged model whose file name matches the dashboard's naming
|
||||
// (car_obu.glb / `${type}${subType}.glb`), falling back to the first model so
|
||||
// a missing asset degrades to a visible vehicle rather than an invisible one.
|
||||
function vehicleModelUri(record, state) {
|
||||
const models = arrayValue(state.vehicleModels);
|
||||
if (!models.length) return "";
|
||||
const wanted = record.model;
|
||||
const match = models.find((name) => String(name).split("/").pop() === wanted);
|
||||
if (!match) state.missingModels.add(wanted);
|
||||
return new URL(match || models[0], window.location.href).href;
|
||||
}
|
||||
|
||||
function syncVehicleEntity(viewer, record, state) {
|
||||
const [longitude, latitude] = gcj02ToWgs84([record.longitude, record.latitude]);
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
|
||||
state.activateLiveVehicles?.();
|
||||
const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, .65);
|
||||
let record = state.vehicles.get(vehicle.id);
|
||||
if (!record) {
|
||||
if (!record.entity) {
|
||||
const tracePositions = [position];
|
||||
record = {
|
||||
tracePositions,
|
||||
entity: viewer.entities.add({
|
||||
name: vehicle.label,
|
||||
position,
|
||||
point: { pixelSize: 10, color: vehicle.kind === "obu" ? Cesium.Color.LIME : Cesium.Color.ORANGE, outlineColor: Cesium.Color.BLACK, outlineWidth: 1 },
|
||||
label: { text: vehicle.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) },
|
||||
}),
|
||||
trace: viewer.entities.add({
|
||||
name: `${vehicle.label} live trace`,
|
||||
polyline: { positions: new Cesium.CallbackProperty(() => tracePositions, false), width: 3, material: vehicle.kind === "obu" ? Cesium.Color.LIME : Cesium.Color.ORANGE, arcType: Cesium.ArcType.NONE },
|
||||
}),
|
||||
};
|
||||
state.vehicles.set(vehicle.id, record);
|
||||
record.tracePositions = tracePositions;
|
||||
record.renderedPosition = position;
|
||||
record.targetPosition = position;
|
||||
record.sampleStart = Date.now();
|
||||
// Interpolate between pushes so vehicles glide instead of teleporting.
|
||||
const animated = new Cesium.CallbackProperty(() => {
|
||||
const duration = Math.max(1, Number(record.duration) || 500);
|
||||
const ratio = Math.min(1, (Date.now() - record.sampleStart) / duration);
|
||||
return Cesium.Cartesian3.lerp(record.renderedPosition, record.targetPosition, ratio, new Cesium.Cartesian3());
|
||||
}, false);
|
||||
record.entity = viewer.entities.add({
|
||||
name: record.label,
|
||||
position: animated,
|
||||
orientation: new Cesium.CallbackProperty(
|
||||
() => vehicleOrientation(record.targetPosition, record.angle), false),
|
||||
model: {
|
||||
uri: vehicleModelUri(record, state),
|
||||
scale: 0.9,
|
||||
minimumPixelSize: 14,
|
||||
maximumScale: 2.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.entity.position = position;
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
function selectCrossCode(crosses, configurations) {
|
||||
const crossList = Array.isArray(crosses) ? crosses : Object.values(crosses || {}).flatMap(arrayValue);
|
||||
const configuredCodes = new Set(arrayValue(configurations).map((item) => String(item.crossCode || "")).filter(Boolean));
|
||||
const configuredCross = crossList.find((cross) => configuredCodes.has(String(cross?.code || cross?.crossCode || "")));
|
||||
const selected = configuredCross || crossList[0];
|
||||
return String(selected?.code || selected?.crossCode || arrayValue(configurations)[0]?.crossCode || "");
|
||||
}
|
||||
|
||||
|
||||
function isHeartbeat(value) {
|
||||
return typeof value === "string" && value.includes('"heartBeat":"pong"');
|
||||
}
|
||||
|
||||
// The dashboard parses vehicle pushes with saferEval, so the payload is not
|
||||
// guaranteed to be strict JSON. Retry once through a narrow relaxation
|
||||
// instead of eval, which would open arbitrary code execution in the preview.
|
||||
function relaxJson(text) {
|
||||
return text
|
||||
.replace(/'/g, "\"")
|
||||
.replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, "$1\"$2\":")
|
||||
.replace(/,\s*([}\]])/g, "$1")
|
||||
.replace(/:\s*(NaN|-?Infinity)\s*([,}\]])/g, ": null$2");
|
||||
}
|
||||
|
||||
function parseLoosePayload(value) {
|
||||
if (typeof value !== "string" || isHeartbeat(value)) return null;
|
||||
try { return JSON.parse(value); } catch (_) { /* retry relaxed */ }
|
||||
try { return JSON.parse(relaxJson(value)); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
// traffic-signals.json carries no V2X phaseNo (only phaseGroup), so the two
|
||||
// sides share no identifier. The only reliable join is geometry: the V2X link
|
||||
// ends at the stop line (CrossTrafficLights3D.vue takes the last two points),
|
||||
// and each native signal records its own stop-line point and heading.
|
||||
//
|
||||
// Heading semantics are fixed by the generator in scripts/lib/traffic-signals.js:
|
||||
// the road axis points from the stop line into the intersection (the travel
|
||||
// direction), and it emits mast = travel - 90 and face = travel + 180. The
|
||||
// runtime document stores the mast value in both headingDegrees and
|
||||
// mastHeadingDegrees, so the approach travel direction is recovered as
|
||||
// face + 180, or equivalently heading + 90.
|
||||
function signalTravelHeading(signal) {
|
||||
const face = Number(signal.faceHeadingDegrees);
|
||||
if (Number.isFinite(face)) return (face + 180) % 360;
|
||||
const mast = Number(signal.mastHeadingDegrees ?? signal.headingDegrees);
|
||||
if (Number.isFinite(mast)) return (mast + 90) % 360;
|
||||
return NaN;
|
||||
}
|
||||
|
||||
function linkStopGeometry(link) {
|
||||
let geometry;
|
||||
try { geometry = typeof link.geom === "string" ? JSON.parse(link.geom) : link.geom; } catch (_) { return null; }
|
||||
const coordinates = arrayValue(geometry?.coordinates);
|
||||
if (coordinates.length < 2) return null;
|
||||
// Convert before measuring: native signals are WGS84, V2X links are GCJ-02.
|
||||
const last = gcj02ToWgs84(coordinates[coordinates.length - 1]);
|
||||
const previous = gcj02ToWgs84(coordinates[coordinates.length - 2]);
|
||||
if (!Number.isFinite(last[0]) || !Number.isFinite(previous[0])) return null;
|
||||
return { stopPoint: last, approachHeading: getAngle(previous, last) };
|
||||
}
|
||||
|
||||
function buildPhaseSignalMap(links, nativeSignals, options) {
|
||||
const settings = options || {};
|
||||
const maxDistanceMeters = Number.isFinite(settings.maxDistanceMeters) ? settings.maxDistanceMeters : 30;
|
||||
const maxHeadingDeltaDegrees = Number.isFinite(settings.maxHeadingDeltaDegrees) ? settings.maxHeadingDeltaDegrees : 45;
|
||||
const override = settings.phaseSignalMap || null;
|
||||
const signals = arrayValue(nativeSignals).filter((signal) => signal && signal.nodeKey);
|
||||
const byPhase = new Map();
|
||||
const diagnostics = [];
|
||||
const unbound = [];
|
||||
|
||||
function addPhase(phaseNo, nodeKeys) {
|
||||
const key = String(phaseNo);
|
||||
const existing = byPhase.get(key) || new Set();
|
||||
nodeKeys.forEach((nodeKey) => existing.add(nodeKey));
|
||||
byPhase.set(key, existing);
|
||||
}
|
||||
|
||||
arrayValue(links).forEach((link) => {
|
||||
const phases = arrayValue(link.phaseList)
|
||||
.map((entry) => entry?.phase)
|
||||
.filter((phase) => phase !== undefined && phase !== null);
|
||||
if (!phases.length) return;
|
||||
const geometry = linkStopGeometry(link);
|
||||
if (!geometry) {
|
||||
phases.forEach((phase) => unbound.push({ phaseNo: String(phase), linkId: link.id, reason: "link geometry unusable" }));
|
||||
return;
|
||||
}
|
||||
const matches = [];
|
||||
signals.forEach((signal) => {
|
||||
const signalPoint = [Number(signal.stopLongitude ?? signal.longitude), Number(signal.stopLatitude ?? signal.latitude)];
|
||||
if (!Number.isFinite(signalPoint[0]) || !Number.isFinite(signalPoint[1])) return;
|
||||
const distanceMeters = haversineMeters(geometry.stopPoint, signalPoint);
|
||||
if (distanceMeters > maxDistanceMeters) return;
|
||||
const travelHeading = signalTravelHeading(signal);
|
||||
const headingDeltaDegrees = angleDeltaDegrees(geometry.approachHeading, travelHeading);
|
||||
if (!(headingDeltaDegrees <= maxHeadingDeltaDegrees)) return;
|
||||
matches.push({ nodeKey: signal.nodeKey, distanceMeters, headingDeltaDegrees, travelHeading });
|
||||
});
|
||||
diagnostics.push({
|
||||
linkId: link.id,
|
||||
approachHeading: geometry.approachHeading,
|
||||
matchedNodeKeys: matches.map((match) => match.nodeKey),
|
||||
nearestDistanceMeters: matches.length ? Math.min(...matches.map((match) => match.distanceMeters)) : null,
|
||||
matches,
|
||||
});
|
||||
// One link may light several heads on the same approach, and several
|
||||
// links may share one phase; both unions are required.
|
||||
if (matches.length) phases.forEach((phase) => addPhase(phase, matches.map((match) => match.nodeKey)));
|
||||
else phases.forEach((phase) => unbound.push({ phaseNo: String(phase), linkId: link.id, reason: "no native signal within tolerance" }));
|
||||
});
|
||||
|
||||
if (override && typeof override === "object") {
|
||||
Object.keys(override).forEach((phaseNo) => {
|
||||
const nodeKeys = arrayValue(override[phaseNo]).filter(Boolean).map(String);
|
||||
if (!nodeKeys.length) return;
|
||||
byPhase.set(String(phaseNo), new Set(nodeKeys));
|
||||
});
|
||||
}
|
||||
|
||||
const overridden = override ? new Set(Object.keys(override).map(String)) : new Set();
|
||||
const resolved = new Map();
|
||||
byPhase.forEach((nodeKeys, phaseNo) => resolved.set(phaseNo, Array.from(nodeKeys)));
|
||||
return {
|
||||
byPhase: resolved,
|
||||
bound: resolved.size,
|
||||
unbound: unbound.filter((entry) => !resolved.has(entry.phaseNo) && !overridden.has(entry.phaseNo)),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSocketJson(value) {
|
||||
if (typeof value !== "string" || value.includes('"heartBeat":"pong"')) return null;
|
||||
try { return JSON.parse(value); } catch (_) { return null; }
|
||||
return parseLoosePayload(value);
|
||||
}
|
||||
|
||||
const HEARTBEAT_MESSAGE = JSON.stringify({ heartBeat: "ping" });
|
||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||
|
||||
// The dashboard uses useWebSocket with a 30s {"heartBeat":"ping"} and
|
||||
// autoReconnect on all three sockets. Without both, the service drops the
|
||||
// connection and the scene silently goes empty after a minute.
|
||||
function createSocket(options) {
|
||||
const settings = options || {};
|
||||
const resolveUrl = typeof settings.url === "function" ? settings.url : () => settings.url;
|
||||
const factory = settings.socketFactory || ((url) => new WebSocket(url));
|
||||
const setIntervalFn = settings.setIntervalFn || ((fn, ms) => setInterval(fn, ms));
|
||||
const clearIntervalFn = settings.clearIntervalFn || ((handle) => clearInterval(handle));
|
||||
const setTimeoutFn = settings.setTimeoutFn || ((fn, ms) => setTimeout(fn, ms));
|
||||
const clearTimeoutFn = settings.clearTimeoutFn || ((handle) => clearTimeout(handle));
|
||||
const heartbeatMs = Number.isFinite(settings.heartbeatMs) ? settings.heartbeatMs : HEARTBEAT_INTERVAL_MS;
|
||||
const maxBackoffMs = Number.isFinite(settings.maxBackoffMs) ? settings.maxBackoffMs : 8000;
|
||||
const reconnect = settings.reconnect !== false;
|
||||
|
||||
const state = { socket: null, heartbeat: null, retry: null, attempt: 0, disposed: false, closedByUs: false };
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (state.heartbeat === null) return;
|
||||
clearIntervalFn(state.heartbeat);
|
||||
state.heartbeat = null;
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat();
|
||||
if (!heartbeatMs) return;
|
||||
state.heartbeat = setIntervalFn(() => send(HEARTBEAT_MESSAGE), heartbeatMs);
|
||||
}
|
||||
|
||||
function send(data) {
|
||||
const socket = state.socket;
|
||||
if (!socket) return false;
|
||||
if (typeof socket.readyState === "number" && socket.readyState !== 1) return false;
|
||||
try { socket.send(data); return true; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (state.disposed || state.closedByUs || !reconnect) return;
|
||||
const delay = Math.min(maxBackoffMs, 1000 * (2 ** state.attempt));
|
||||
state.attempt += 1;
|
||||
state.retry = setTimeoutFn(() => { state.retry = null; open(); }, delay);
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (state.disposed) return null;
|
||||
state.closedByUs = false;
|
||||
let socket;
|
||||
try { socket = factory(resolveUrl()); } catch (error) {
|
||||
settings.onError?.(error);
|
||||
scheduleReconnect();
|
||||
return null;
|
||||
}
|
||||
state.socket = socket;
|
||||
socket.onopen = () => {
|
||||
state.attempt = 0;
|
||||
startHeartbeat();
|
||||
// Re-send the subscription frame on every (re)connect, matching the
|
||||
// dashboard's onConnected. A reconnect without this receives nothing.
|
||||
settings.onOpen?.({ send });
|
||||
};
|
||||
socket.onmessage = (event) => settings.onMessage?.(event?.data);
|
||||
socket.onerror = (event) => settings.onError?.(event);
|
||||
socket.onclose = (event) => {
|
||||
stopHeartbeat();
|
||||
settings.onClose?.(event);
|
||||
scheduleReconnect();
|
||||
};
|
||||
return socket;
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.closedByUs = true;
|
||||
stopHeartbeat();
|
||||
if (state.retry !== null) { clearTimeoutFn(state.retry); state.retry = null; }
|
||||
try { state.socket?.close(); } catch (_) { /* already closed */ }
|
||||
state.socket = null;
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
state.disposed = true;
|
||||
close();
|
||||
}
|
||||
|
||||
return { open, close, dispose, send, get attempt() { return state.attempt; }, get socket() { return state.socket; } };
|
||||
}
|
||||
|
||||
// Matches getMapBounds in CrossCars/index.vue: NW, NE, SE, SW joined by ';'.
|
||||
// The camera reports WGS84 but the service filters GCJ-02, so convert.
|
||||
function buildBoundsMessage(rectangle) {
|
||||
if (!rectangle) return "";
|
||||
const west = Number(rectangle.west);
|
||||
const south = Number(rectangle.south);
|
||||
const east = Number(rectangle.east);
|
||||
const north = Number(rectangle.north);
|
||||
if (![west, south, east, north].every(Number.isFinite)) return "";
|
||||
const corners = [[west, north], [east, north], [east, south], [west, south]];
|
||||
return corners
|
||||
.map((corner) => wgs84ToGcj02(corner).map((value) => Number(value.toFixed(8))).join(","))
|
||||
.join(";");
|
||||
}
|
||||
|
||||
function normalizeObuVehicle(value) {
|
||||
const source = parseSocketJson(value);
|
||||
return obuVehicleFrom(source);
|
||||
}
|
||||
|
||||
function obuVehicleFrom(source) {
|
||||
const longitude = Number(source?.lon);
|
||||
const latitude = Number(source?.lat);
|
||||
const code = source?.carCode || source?.obuCode;
|
||||
@@ -289,34 +674,161 @@
|
||||
return { id: `obu-${code}`, label: source.plateNumber || String(code), kind: "obu", longitude, latitude, angle: Number(source.angle) || 0, speed: Number(source.speed) || 0 };
|
||||
}
|
||||
|
||||
function normalizeTargetVehicles(value) {
|
||||
function normalizeObuPush(value) {
|
||||
const source = parseSocketJson(value);
|
||||
return { interval: source?.interval, vehicle: obuVehicleFrom(source) };
|
||||
}
|
||||
|
||||
function normalizeTargetVehicles(value) {
|
||||
return targetVehiclesFrom(parseSocketJson(value));
|
||||
}
|
||||
|
||||
function normalizeTargetPush(value) {
|
||||
const source = parseSocketJson(value);
|
||||
return { interval: source?.interval, vehicles: targetVehiclesFrom(source) };
|
||||
}
|
||||
|
||||
function targetVehiclesFrom(source) {
|
||||
const devices = source?.data;
|
||||
if (!devices || typeof devices !== "object" || Array.isArray(devices)) return [];
|
||||
return Object.keys(devices).flatMap((deviceId) => arrayValue(devices[deviceId]).map((target) => {
|
||||
const longitude = Number(target?.longitude);
|
||||
const latitude = Number(target?.latitude);
|
||||
if (!target?.id || !Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
|
||||
// Mirrors useTargetCars.ts: a type 1 target with no subType is subType 1.
|
||||
const type = target.type || 1;
|
||||
const subType = target.subType || 1;
|
||||
return { id: `${deviceId}-${target.id}-${type}${subType}`, label: target.plate || `${deviceId}-${target.id}`, kind: "target", longitude, latitude, angle: Number(target.angle) || 0, speed: Number(target.speed) || 0 };
|
||||
return { id: `${deviceId}-${target.id}-${type}${subType}`, label: target.plate || `${deviceId}-${target.id}`, kind: "target", longitude, latitude, angle: Number(target.angle) || 0, speed: Number(target.speed) || 0, type, subType };
|
||||
}).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`).
|
||||
function modelNameFor(vehicle) {
|
||||
if (!vehicle) return "";
|
||||
if (vehicle.kind === "obu") return "car_obu.glb";
|
||||
return `${vehicle.type || 1}${vehicle.subType || 1}.glb`;
|
||||
}
|
||||
|
||||
function resolvePushInterval(value) {
|
||||
const parsed = Number(value);
|
||||
return !Number.isFinite(parsed) || parsed === 0 ? 500 : parsed;
|
||||
}
|
||||
|
||||
// Vehicles are hidden rather than removed once a push goes stale, and hidden
|
||||
// slots are reused, matching hideTimeoutObuCars / hideTimeoutTargetCars.
|
||||
function createVehicleRegistry(options) {
|
||||
const settings = options || {};
|
||||
const now = typeof settings.now === "function" ? settings.now : () => Date.now();
|
||||
const records = [];
|
||||
const index = new Map();
|
||||
let interval = 1000;
|
||||
|
||||
function ingest(vehicles, pushInterval) {
|
||||
interval = resolvePushInterval(pushInterval);
|
||||
const timeStamp = now();
|
||||
const summary = { added: 0, updated: 0, reused: 0 };
|
||||
arrayValue(vehicles).forEach((vehicle) => {
|
||||
if (!vehicle || !vehicle.id) return;
|
||||
const model = modelNameFor(vehicle);
|
||||
const next = { ...vehicle, model, visible: true, timeStamp, duration: interval };
|
||||
const existing = index.get(vehicle.id);
|
||||
if (existing) {
|
||||
Object.assign(existing, next);
|
||||
summary.updated += 1;
|
||||
return;
|
||||
}
|
||||
const slot = records.find((record) => !record.visible && record.model === model);
|
||||
if (slot) {
|
||||
index.delete(slot.id);
|
||||
Object.assign(slot, next);
|
||||
index.set(vehicle.id, slot);
|
||||
summary.reused += 1;
|
||||
return;
|
||||
}
|
||||
const created = { ...next };
|
||||
records.push(created);
|
||||
index.set(vehicle.id, created);
|
||||
summary.added += 1;
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
function sweep(atMs) {
|
||||
const timeStamp = Number.isFinite(atMs) ? atMs : now();
|
||||
const hidden = [];
|
||||
records.forEach((record) => {
|
||||
if (!record.visible) return;
|
||||
if (Math.abs(timeStamp - record.timeStamp) >= interval * 1.5) {
|
||||
record.visible = false;
|
||||
hidden.push(record.id);
|
||||
}
|
||||
});
|
||||
return hidden;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
records.length = 0;
|
||||
index.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
ingest, sweep, clear,
|
||||
list: () => records.slice(),
|
||||
get interval() { return interval; },
|
||||
get size() { return records.length; },
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize the push into {nodeKeys, color, countDown} before handing it to
|
||||
// the preview, so the lamp dictionary lives in exactly one place.
|
||||
function signalEntriesFrom(lamps, phaseSignals) {
|
||||
return arrayValue(lamps).map((lamp) => {
|
||||
const phaseNo = String(lamp?.phaseNo ?? lamp?.phase ?? "");
|
||||
return {
|
||||
phaseNo,
|
||||
nodeKeys: arrayValue(phaseSignals?.get?.(phaseNo)),
|
||||
color: lampColorName(lamp?.status),
|
||||
countDown: Number(lamp?.countDown),
|
||||
};
|
||||
}).filter((entry) => entry.phaseNo);
|
||||
}
|
||||
|
||||
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);
|
||||
const payload = parseLoosePayload(value);
|
||||
if (!payload) {
|
||||
if (!isHeartbeat(value)) state.parseFailures += 1;
|
||||
return;
|
||||
}
|
||||
const lamps = arrayValue(payload.lamps);
|
||||
if (!lamps.length) return;
|
||||
const entries = signalEntriesFrom(lamps, state.phaseSignals);
|
||||
state.setSignalState?.(entries);
|
||||
entries.forEach((entry) => {
|
||||
// Every approach on this phase, not just the last one registered.
|
||||
arrayValue(state.linkEntitiesByPhase.get(entry.phaseNo)).forEach((entity) => {
|
||||
if (!entity?.polyline) return;
|
||||
entity.polyline.material = lampCesiumColor(entry.color).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;
|
||||
// Lamp status codes come from the V2X dashboard's lightStatusColorDict
|
||||
// (HologramCross/components/utils.ts). Do not reinterpret them as 1/2/3.
|
||||
const LAMP_STATUS = { 11: "off", 21: "red", 22: "yellow", 23: "green", 31: "other" };
|
||||
|
||||
function lampColorName(status) {
|
||||
return LAMP_STATUS[Number(status)] || "other";
|
||||
}
|
||||
|
||||
function lampCesiumColor(color) {
|
||||
switch (color) {
|
||||
case "red": return Cesium.Color.fromCssColorString("#f45f5f");
|
||||
case "yellow": return Cesium.Color.fromCssColorString("rgb(238, 166, 12)");
|
||||
case "green": return Cesium.Color.fromCssColorString("rgb(8, 244, 8)");
|
||||
case "off": return Cesium.Color.fromCssColorString("#eeeeee");
|
||||
default: return Cesium.Color.GRAY;
|
||||
}
|
||||
}
|
||||
|
||||
function arrayValue(value) { return Array.isArray(value) ? value : []; }
|
||||
@@ -332,6 +844,51 @@
|
||||
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)]; }
|
||||
|
||||
// Forward transform, needed for the OBU bounds message: the service filters
|
||||
// by GCJ-02, but the Cesium camera reports WGS84.
|
||||
function wgs84ToGcj02(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),
|
||||
];
|
||||
}
|
||||
|
||||
// Bearing from north, 0-360. Mirrors HologramCross/components/utils.ts getAngle.
|
||||
function getAngle(start, end) {
|
||||
const rad = PI / 180;
|
||||
const lat1 = Number(start[1]) * rad;
|
||||
const lat2 = Number(end[1]) * rad;
|
||||
const lon1 = Number(start[0]) * rad;
|
||||
const lon2 = Number(end[0]) * rad;
|
||||
const a = Math.sin(lon2 - lon1) * Math.cos(lat2);
|
||||
const b = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1);
|
||||
const degrees = (Math.atan2(a, b) % (2 * PI)) * 180 / PI;
|
||||
return degrees > 0 ? degrees : degrees + 360;
|
||||
}
|
||||
|
||||
function angleDeltaDegrees(a, b) {
|
||||
const delta = Math.abs(((Number(a) - Number(b)) % 360 + 540) % 360 - 180);
|
||||
return Number.isFinite(delta) ? delta : NaN;
|
||||
}
|
||||
|
||||
function haversineMeters(a, b) {
|
||||
const rad = PI / 180;
|
||||
const lat1 = Number(a[1]) * rad;
|
||||
const lat2 = Number(b[1]) * rad;
|
||||
const dLat = lat2 - lat1;
|
||||
const dLon = (Number(b[0]) - Number(a[0])) * rad;
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371008.8 * 2 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -375,6 +932,16 @@
|
||||
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, normalizeObuVehicle, normalizeTargetVehicles };
|
||||
createV2xCesiumOverlay.utils = {
|
||||
gcj02ToWgs84, wgs84ToGcj02, joinUrl, md5, toWebSocketUrl,
|
||||
normalizeObuVehicle, normalizeObuPush, normalizeTargetVehicles, normalizeTargetPush,
|
||||
selectCrossCode,
|
||||
lampColorName, parseLoosePayload, isHeartbeat, signalEntriesFrom,
|
||||
getAngle, angleDeltaDegrees, haversineMeters,
|
||||
buildPhaseSignalMap, linkStopGeometry, signalTravelHeading,
|
||||
createVehicleRegistry, modelNameFor, resolvePushInterval,
|
||||
createSocket, buildBoundsMessage,
|
||||
HEARTBEAT_MESSAGE, HEARTBEAT_INTERVAL_MS,
|
||||
};
|
||||
window.createV2xCesiumOverlay = createV2xCesiumOverlay;
|
||||
}());
|
||||
|
||||
@@ -267,7 +267,7 @@ 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, /const vehicleModelNames = \[\];/);
|
||||
assert.match(buildAreaSource, /const vehicleModelNames = writeVehicleModel\(area\);/);
|
||||
assert.doesNotMatch(buildAreaSource, /buildNativeTrafficSimulation/);
|
||||
const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf");
|
||||
assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned with the project");
|
||||
@@ -281,8 +281,40 @@ assert.match(v2xRuntime, /GCJ-02 -> WGS84 once/);
|
||||
assert.match(v2xRuntime, /\/network\/ws\/network\/signal/);
|
||||
assert.match(v2xRuntime, /\/network\/ws\/network\/obuPosition/);
|
||||
assert.match(v2xRuntime, /\/network\/ws\/network\/targetPosition/);
|
||||
assert.match(v2xRuntime, /headingPitchRollQuaternion/);
|
||||
assert.match(v2xRuntime, /model:/);
|
||||
assert.match(previewRuntime, /createLiveTrafficSignals\(/);
|
||||
assert.doesNotMatch(previewRuntime, /const trafficSignals = addTrafficSignals\(/);
|
||||
|
||||
// The lamp dictionary lives only in the overlay, which hands the preview
|
||||
// already-normalized {nodeKeys, color, countDown} entries. Two copies of the
|
||||
// dictionary drifted apart once and made every phase render red.
|
||||
assert.doesNotMatch(previewRuntime, /function lampColorName/,
|
||||
"the preview must not keep its own lamp status dictionary");
|
||||
assert.doesNotMatch(previewRuntime, /Number\(status\) === 3/,
|
||||
"status 3 is not green; the real codes are 21/22/23");
|
||||
assert.match(previewRuntime, /entry\.nodeKeys/,
|
||||
"live signals are addressed by resolved native node keys");
|
||||
assert.match(previewRuntime, /paintCountdown\(/, "countDown from the push drives the countdown assets");
|
||||
assert.match(previewRuntime, /nativeSignals: \(signalData && signalData\.signals\) \|\| \[\]/,
|
||||
"the overlay needs the native signal list to bind phases");
|
||||
|
||||
// Overlay-side regressions: lamp codes, heartbeat, subscription frames,
|
||||
// vehicle lifecycle and the removed out-of-scope request.
|
||||
assert.match(v2xRuntime, /LAMP_STATUS = \{ 11: "off", 21: "red", 22: "yellow", 23: "green", 31: "other" \}/);
|
||||
assert.doesNotMatch(v2xRuntime, /Number\(status\) === 3/);
|
||||
assert.match(v2xRuntime, /heartBeat: "ping"/, "all sockets must heartbeat like the dashboard");
|
||||
assert.match(v2xRuntime, /HEARTBEAT_INTERVAL_MS = 30000/);
|
||||
assert.match(v2xRuntime, /scheduleReconnect/, "a dropped socket must reconnect");
|
||||
assert.match(v2xRuntime, /buildBoundsMessage/, "the OBU socket must send a bounds frame");
|
||||
assert.match(v2xRuntime, /createVehicleRegistry/, "stale vehicles must be swept");
|
||||
assert.match(v2xRuntime, /linkEntitiesByPhase/, "one phase may light several approaches");
|
||||
assert.doesNotMatch(v2xRuntime, /linkPhases/, "the phase -> single entity map overwrote approaches");
|
||||
assert.doesNotMatch(v2xRuntime, /FlowTravelRatio/,
|
||||
"weekly flow ratio is not part of the live intersection view");
|
||||
assert.doesNotMatch(v2xRuntime, /Intersection code<input/);
|
||||
assert.match(v2xRuntime, /V2X intersection code is not configured/);
|
||||
assert.match(v2xRuntime, /Finding the configured V2X intersection/);
|
||||
assert.match(v2xRuntime, /crossDeviceConfig\/queryList/);
|
||||
assert.match(v2xRuntime, /gcj02ToWgs84/);
|
||||
assert.match(previewRuntime, /createLiveVehicleState\(\)/);
|
||||
assert.doesNotMatch(previewRuntime, /const cruise = addVehicleCruises\(/);
|
||||
|
||||
@@ -15,7 +15,17 @@ const context = {
|
||||
};
|
||||
vm.runInNewContext(source, context, { filename: "v2x-cesium-overlay.js" });
|
||||
|
||||
const { gcj02ToWgs84, joinUrl, md5, toWebSocketUrl, normalizeObuVehicle, normalizeTargetVehicles } = context.window.createV2xCesiumOverlay.utils;
|
||||
const {
|
||||
gcj02ToWgs84, wgs84ToGcj02, joinUrl, md5, toWebSocketUrl,
|
||||
normalizeObuVehicle, normalizeObuPush, normalizeTargetVehicles, normalizeTargetPush,
|
||||
selectCrossCode, lampColorName, parseLoosePayload, isHeartbeat, signalEntriesFrom,
|
||||
getAngle, angleDeltaDegrees, haversineMeters,
|
||||
buildPhaseSignalMap, signalTravelHeading,
|
||||
createVehicleRegistry, modelNameFor, resolvePushInterval,
|
||||
createSocket, buildBoundsMessage, HEARTBEAT_MESSAGE, HEARTBEAT_INTERVAL_MS,
|
||||
} = context.window.createV2xCesiumOverlay.utils;
|
||||
|
||||
// --- existing contracts -----------------------------------------------------
|
||||
assert.equal(md5(""), "d41d8cd98f00b204e9800998ecf8427e");
|
||||
assert.equal(md5("password"), "5f4dcc3b5aa765d61d8327deb882cf99");
|
||||
assert.equal(joinUrl("/api/", "/facilities/api/sys/login"), "/api/facilities/api/sys/login");
|
||||
@@ -27,12 +37,240 @@ 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]);
|
||||
|
||||
// wgs84ToGcj02 is the forward transform used for the OBU bounds frame. Both
|
||||
// directions are single-step approximations, so the round trip is not exact:
|
||||
// it closes to about 1.5 m. That is irrelevant for a viewport filter, and
|
||||
// vehicle positions never use it -- they only ever go GCJ-02 -> WGS84 once.
|
||||
const roundTrip = gcj02ToWgs84(wgs84ToGcj02([114.12864875054062, 30.460485279762146]));
|
||||
assert.ok(haversineMeters([114.12864875054062, 30.460485279762146], roundTrip) < 2);
|
||||
assert.deepEqual(wgs84ToGcj02([NaN, 30]), [NaN, NaN]);
|
||||
|
||||
assert.equal(selectCrossCode({ west: [{ code: "first" }, { code: "configured" }] }, [{ crossCode: "configured" }]), "configured");
|
||||
assert.equal(selectCrossCode([{ code: "first" }], []), "first");
|
||||
|
||||
// --- AC2: lamp status dictionary -------------------------------------------
|
||||
// Codes come from HologramCross/components/utils.ts lightStatusColorDict.
|
||||
assert.equal(lampColorName(11), "off");
|
||||
assert.equal(lampColorName(21), "red");
|
||||
assert.equal(lampColorName(22), "yellow");
|
||||
assert.equal(lampColorName(23), "green");
|
||||
assert.equal(lampColorName(31), "other");
|
||||
assert.equal(lampColorName("23"), "green", "string codes resolve too");
|
||||
// Regression guard: the previous port read 2/3 as yellow/green, which made
|
||||
// every real 21/22/23 push fall through to red.
|
||||
assert.notEqual(lampColorName(2), "yellow");
|
||||
assert.notEqual(lampColorName(3), "green");
|
||||
assert.equal(lampColorName(2), "other");
|
||||
assert.equal(lampColorName(3), "other");
|
||||
assert.equal(lampColorName(undefined), "other", "unknown codes never default to red");
|
||||
assert.equal(lampColorName(999), "other");
|
||||
|
||||
// --- AC7: loose payload parsing --------------------------------------------
|
||||
assert.deepEqual(parseLoosePayload('{"a":1}'), { a: 1 });
|
||||
assert.deepEqual(parseLoosePayload("{a:1,b:'x',}"), { a: 1, b: "x" });
|
||||
assert.deepEqual(parseLoosePayload('{"a":NaN}'), { a: null });
|
||||
assert.equal(parseLoosePayload("{bad"), null);
|
||||
assert.equal(parseLoosePayload(null), null);
|
||||
assert.ok(isHeartbeat('{"heartBeat":"pong"}'));
|
||||
assert.equal(parseLoosePayload('{"heartBeat":"pong"}'), null, "heartbeats are not payloads");
|
||||
|
||||
// --- geometry helpers -------------------------------------------------------
|
||||
assert.ok(Math.abs(angleDeltaDegrees(350, 10) - 20) < 1e-9);
|
||||
assert.ok(Math.abs(angleDeltaDegrees(10, 350) - 20) < 1e-9);
|
||||
assert.ok(Math.abs(angleDeltaDegrees(0, 180) - 180) < 1e-9);
|
||||
assert.ok(Math.abs(getAngle([0, 0], [1, 0]) - 90) < 1e-6, "due east is 90");
|
||||
assert.ok(Math.abs(haversineMeters([114, 30], [114, 30.001]) - 111.2) < 0.5);
|
||||
assert.equal(haversineMeters([114, 30], [114, 30]), 0);
|
||||
|
||||
// --- AC3/AC4: phase to native signal mapping --------------------------------
|
||||
const nativeSignals = JSON.parse(fs.readFileSync(
|
||||
path.join(__dirname, "..", "outputs", "fengshu-er-road", "package", "runtime", "traffic-signals.json"), "utf8")).signals;
|
||||
assert.ok(nativeSignals.length >= 4, "fixture needs several native signals");
|
||||
|
||||
// The generator emits mast = travel - 90 and face = travel + 180
|
||||
// (scripts/lib/traffic-signals.js), so travel is recoverable from either.
|
||||
nativeSignals.forEach((signal) => {
|
||||
const fromFace = signalTravelHeading(signal);
|
||||
const fromMast = signalTravelHeading({ mastHeadingDegrees: signal.mastHeadingDegrees });
|
||||
assert.ok(angleDeltaDegrees(fromFace, fromMast) < 1e-6, "face and mast agree on travel heading");
|
||||
});
|
||||
|
||||
// Build a V2X link whose last segment approaches the given native signal.
|
||||
function linkForSignal(signal, id, phase) {
|
||||
const stop = wgs84ToGcj02([signal.stopLongitude, signal.stopLatitude]);
|
||||
const heading = signalTravelHeading(signal) * Math.PI / 180;
|
||||
const step = 0.0002;
|
||||
const previous = [stop[0] - Math.sin(heading) * step, stop[1] - Math.cos(heading) * step];
|
||||
return { id, geom: JSON.stringify({ type: "LineString", coordinates: [previous, stop] }), phaseList: [{ phase }] };
|
||||
}
|
||||
|
||||
const oneToOne = buildPhaseSignalMap(nativeSignals.map((signal, index) => linkForSignal(signal, `L${index}`, index + 1)), nativeSignals, {});
|
||||
assert.equal(oneToOne.bound, nativeSignals.length, "every phase binds");
|
||||
assert.equal(oneToOne.unbound.length, 0);
|
||||
oneToOne.diagnostics.forEach((entry, index) => {
|
||||
assert.deepEqual(entry.matchedNodeKeys, [nativeSignals[index].nodeKey],
|
||||
`link ${entry.linkId} must bind only its own approach, got ${entry.matchedNodeKeys.join()}`);
|
||||
assert.ok(entry.nearestDistanceMeters < 5, "stop points are metres apart, not kilometres");
|
||||
});
|
||||
|
||||
// AC4: one phase shared by two approaches lights both.
|
||||
const shared = buildPhaseSignalMap([linkForSignal(nativeSignals[0], "A", 7), linkForSignal(nativeSignals[3], "B", 7)], nativeSignals, {});
|
||||
assert.deepEqual(shared.byPhase.get("7").slice().sort(), [nativeSignals[0].nodeKey, nativeSignals[3].nodeKey].sort());
|
||||
|
||||
// A link nowhere near the intersection is reported, not silently dropped.
|
||||
const stray = { id: "far", geom: JSON.stringify({ type: "LineString", coordinates: [[113, 29], [113.001, 29.001]] }), phaseList: [{ phase: 9 }] };
|
||||
const strayResult = buildPhaseSignalMap([stray], nativeSignals, {});
|
||||
assert.equal(strayResult.bound, 0);
|
||||
assert.equal(strayResult.unbound.length, 1);
|
||||
assert.equal(strayResult.unbound[0].phaseNo, "9");
|
||||
assert.ok(strayResult.unbound[0].reason);
|
||||
|
||||
// Config override is the escape hatch when geometry cannot bind.
|
||||
const overridden = buildPhaseSignalMap([stray], nativeSignals, { phaseSignalMap: { 9: ["ts_forced"] } });
|
||||
assert.deepEqual(overridden.byPhase.get("9"), ["ts_forced"]);
|
||||
assert.equal(overridden.unbound.length, 0);
|
||||
|
||||
// Unusable geometry is reported rather than throwing.
|
||||
const brokenGeom = buildPhaseSignalMap([{ id: "x", geom: "{not json", phaseList: [{ phase: 5 }] }], nativeSignals, {});
|
||||
assert.equal(brokenGeom.unbound[0].reason, "link geometry unusable");
|
||||
|
||||
// --- signal entries handed to the preview -----------------------------------
|
||||
const entries = signalEntriesFrom(
|
||||
[{ phaseNo: 1, status: 23, countDown: 12 }, { phaseNo: 2, status: 21, countDown: 4 }],
|
||||
new Map([["1", ["nodeA", "nodeB"]], ["2", ["nodeC"]]]),
|
||||
);
|
||||
assert.deepEqual(entries, [
|
||||
{ phaseNo: "1", nodeKeys: ["nodeA", "nodeB"], color: "green", countDown: 12 },
|
||||
{ phaseNo: "2", nodeKeys: ["nodeC"], color: "red", countDown: 4 },
|
||||
]);
|
||||
assert.deepEqual(signalEntriesFrom([{ phaseNo: 8, status: 22 }], new Map()),
|
||||
[{ phaseNo: "8", nodeKeys: [], color: "yellow", countDown: NaN }],
|
||||
"an unbound phase still reports its colour");
|
||||
|
||||
// --- vehicle normalization --------------------------------------------------
|
||||
assert.deepEqual(normalizeObuVehicle(JSON.stringify({ carCode: "car-7", plateNumber: "A12345", lon: 114.12865, lat: 30.46049, angle: 90, speed: 12 })), {
|
||||
id: "obu-car-7", label: "A12345", kind: "obu", longitude: 114.12865, latitude: 30.46049, angle: 90, speed: 12,
|
||||
});
|
||||
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,
|
||||
assert.equal(normalizeObuVehicle("{bad json"), null);
|
||||
const obuPush = normalizeObuPush(JSON.stringify({ carCode: "c1", lon: 114.1, lat: 30.4, interval: 800 }));
|
||||
assert.equal(obuPush.interval, 800);
|
||||
assert.equal(obuPush.vehicle.id, "obu-c1");
|
||||
|
||||
// type/subType survive normalization because the model name is built from them.
|
||||
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,
|
||||
}]);
|
||||
assert.equal(normalizeObuVehicle('{bad json'), null);
|
||||
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(resolvePushInterval(targetPush.interval), 500, "interval 0 falls back to 500");
|
||||
assert.equal(resolvePushInterval(undefined), 500);
|
||||
assert.equal(resolvePushInterval(900), 900);
|
||||
|
||||
// --- AC8: model naming matches the dashboard --------------------------------
|
||||
assert.equal(modelNameFor({ kind: "obu" }), "car_obu.glb");
|
||||
assert.equal(modelNameFor({ kind: "target", type: 1, subType: 2 }), "12.glb");
|
||||
assert.equal(modelNameFor({ kind: "target" }), "11.glb");
|
||||
|
||||
// --- AC5: vehicle lifecycle -------------------------------------------------
|
||||
let clock = 1000;
|
||||
const registry = createVehicleRegistry({ now: () => clock });
|
||||
registry.ingest([{ id: "v1", kind: "target", type: 1, subType: 1 }], 1000);
|
||||
assert.equal(registry.list().length, 1);
|
||||
assert.equal(registry.list()[0].visible, true);
|
||||
assert.equal(registry.list()[0].duration, 1000);
|
||||
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.equal(registry.list()[0].visible, false);
|
||||
assert.equal(registry.list().length, 1, "hidden vehicles are kept as reusable slots, not removed");
|
||||
|
||||
clock = 5000;
|
||||
registry.ingest([{ id: "v2", kind: "target", type: 1, subType: 1 }], 1000);
|
||||
assert.equal(registry.list().length, 1, "a hidden slot of the same model is reused");
|
||||
assert.equal(registry.list()[0].id, "v2");
|
||||
assert.equal(registry.list()[0].visible, true);
|
||||
|
||||
registry.ingest([{ id: "v3", kind: "obu" }], 1000);
|
||||
assert.equal(registry.list().length, 2, "a different model never steals another model's slot");
|
||||
registry.ingest([{ id: "v2", kind: "target", type: 1, subType: 1 }], 1000);
|
||||
assert.equal(registry.list().length, 2, "re-ingesting a known id updates in place");
|
||||
registry.clear();
|
||||
assert.equal(registry.list().length, 0);
|
||||
|
||||
// --- AC1/AC6: socket heartbeat, subscription frames, reconnect --------------
|
||||
assert.equal(HEARTBEAT_MESSAGE, '{"heartBeat":"ping"}');
|
||||
assert.equal(HEARTBEAT_INTERVAL_MS, 30000);
|
||||
|
||||
function fakeClock() {
|
||||
const intervals = [];
|
||||
const timeouts = [];
|
||||
return {
|
||||
intervals, timeouts,
|
||||
setIntervalFn: (fn, ms) => intervals.push({ fn, ms }) - 1,
|
||||
clearIntervalFn: (handle) => { intervals[handle] = null; },
|
||||
setTimeoutFn: (fn, ms) => timeouts.push({ fn, ms }) - 1,
|
||||
clearTimeoutFn: (handle) => { timeouts[handle] = null; },
|
||||
pendingIntervals: () => intervals.filter(Boolean),
|
||||
pendingTimeouts: () => timeouts.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
const clockStub = fakeClock();
|
||||
const sent = [];
|
||||
let built = 0;
|
||||
let live = null;
|
||||
const socket = createSocket({
|
||||
url: () => "wss://preview.example.test/websocket/network/ws/network/signal",
|
||||
setIntervalFn: clockStub.setIntervalFn,
|
||||
clearIntervalFn: clockStub.clearIntervalFn,
|
||||
setTimeoutFn: clockStub.setTimeoutFn,
|
||||
clearTimeoutFn: clockStub.clearTimeoutFn,
|
||||
socketFactory: () => { built += 1; live = { readyState: 1, send: (data) => sent.push(data), close() {} }; return live; },
|
||||
onOpen: ({ send }) => send(JSON.stringify({ junctionId: "420100023333" })),
|
||||
});
|
||||
socket.open();
|
||||
live.onopen();
|
||||
assert.deepEqual(sent, ['{"junctionId":"420100023333"}'], "subscription frame is sent on connect");
|
||||
assert.equal(clockStub.pendingIntervals()[0].ms, 30000, "heartbeat runs at the dashboard's 30s");
|
||||
clockStub.pendingIntervals()[0].fn();
|
||||
clockStub.pendingIntervals()[0].fn();
|
||||
assert.deepEqual(sent.slice(1), [HEARTBEAT_MESSAGE, HEARTBEAT_MESSAGE]);
|
||||
|
||||
live.onclose({});
|
||||
assert.equal(clockStub.pendingTimeouts().length, 1, "a drop schedules a reconnect");
|
||||
assert.equal(clockStub.pendingTimeouts()[0].ms, 1000, "first backoff is 1s");
|
||||
sent.length = 0;
|
||||
clockStub.pendingTimeouts()[0].fn();
|
||||
live.onopen();
|
||||
assert.equal(built, 2, "the socket reconnected");
|
||||
assert.deepEqual(sent, ['{"junctionId":"420100023333"}'], "the subscription is replayed after reconnect");
|
||||
|
||||
socket.dispose();
|
||||
const timeoutsBeforeClose = clockStub.pendingTimeouts().length;
|
||||
live.onclose({});
|
||||
assert.equal(clockStub.pendingTimeouts().length, timeoutsBeforeClose, "a disposed socket never reconnects");
|
||||
|
||||
// Target subscription frames match useTargetCars.ts.
|
||||
function subscriptionFrame(onOpen) {
|
||||
const frames = [];
|
||||
onOpen({ send: (data) => frames.push(data) });
|
||||
return frames;
|
||||
}
|
||||
assert.deepEqual(subscriptionFrame(({ send }) => send(JSON.stringify({ deviceId: null }))), ['{"deviceId":null}']);
|
||||
assert.deepEqual(subscriptionFrame(({ send }) => send(JSON.stringify({ deviceId: [1, 2].join(",") }))), ['{"deviceId":"1,2"}']);
|
||||
|
||||
// --- OBU bounds frame -------------------------------------------------------
|
||||
const bounds = buildBoundsMessage({ west: 114.12, south: 30.46, east: 114.14, north: 30.47 });
|
||||
const corners = bounds.split(";");
|
||||
assert.equal(corners.length, 4, "NW, NE, SE, SW");
|
||||
corners.forEach((corner) => assert.equal(corner.split(",").length, 2));
|
||||
// Corners are converted to GCJ-02 because the service filters in that frame.
|
||||
assert.notEqual(Number(corners[0].split(",")[0]), 114.12);
|
||||
assert.equal(buildBoundsMessage(null), "");
|
||||
assert.equal(buildBoundsMessage({ west: NaN, south: 30.46, east: 114.14, north: 30.47 }), "");
|
||||
|
||||
// --- the overlay stays opt-in ----------------------------------------------
|
||||
assert.equal(context.window.createV2xCesiumOverlay({ config: {} }), null, "disabled by default");
|
||||
assert.equal(context.window.createV2xCesiumOverlay({ config: { v2xPreview: { enabled: false } } }), null);
|
||||
|
||||
console.log("V2X Cesium overlay tests passed.");
|
||||
|
||||
66
scripts/test-v2x-preview-server.js
Normal file
66
scripts/test-v2x-preview-server.js
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const http = require("http");
|
||||
const path = require("path");
|
||||
const { createV2xPreviewServer, isProxyPath, upstreamPath } = require("./v2x-preview-server");
|
||||
|
||||
async function listen(server) {
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
return server.address().port;
|
||||
}
|
||||
|
||||
async function request(port, pathName, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request({ hostname: "127.0.0.1", port, path: pathName, method: options.method || "GET", headers: options.headers }, (response) => {
|
||||
let body = "";
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => { body += chunk; });
|
||||
response.on("end", () => resolve({ status: response.statusCode, body, headers: response.headers }));
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end(options.body);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
assert.equal(isProxyPath("/api/facilities/api/sys/login"), true);
|
||||
assert.equal(isProxyPath("/websocket/network/ws/network/obuPosition"), true);
|
||||
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("/websocket/network/ws/network/signal"), "/network/ws/network/signal");
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ method: req.method, path: req.url, body }));
|
||||
});
|
||||
});
|
||||
const upstreamPort = await listen(upstream);
|
||||
const preview = createV2xPreviewServer({
|
||||
root: path.join(__dirname, "..", "outputs", "fengshu-er-road"),
|
||||
upstream: `http://127.0.0.1:${upstreamPort}`,
|
||||
});
|
||||
const previewPort = await listen(preview);
|
||||
try {
|
||||
const proxied = await request(previewPort, "/api/facilities/api/sys/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: '{"userName":"test"}',
|
||||
});
|
||||
assert.equal(proxied.status, 200);
|
||||
assert.deepEqual(JSON.parse(proxied.body), { method: "POST", path: "/facilities/api/sys/login", body: '{"userName":"test"}' });
|
||||
const staticPage = await request(previewPort, "/fengshu-er-road-cesium-preview.html");
|
||||
assert.equal(staticPage.status, 200);
|
||||
assert.match(staticPage.body, /Cesium Preview/);
|
||||
} finally {
|
||||
await Promise.all([new Promise((resolve) => preview.close(resolve)), new Promise((resolve) => upstream.close(resolve))]);
|
||||
}
|
||||
console.log("V2X preview server tests passed.");
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
166
scripts/v2x-preview-server.js
Normal file
166
scripts/v2x-preview-server.js
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
|
||||
const MIME_TYPES = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".glb": "model/gltf-binary",
|
||||
".gltf": "model/gltf+json",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".ttf": "font/ttf",
|
||||
".wasm": "application/wasm",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const values = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (!argument.startsWith("--")) continue;
|
||||
const key = argument.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${argument}`);
|
||||
values[key] = value;
|
||||
index += 1;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function createV2xPreviewServer({ root, upstream }) {
|
||||
const staticRoot = path.resolve(root);
|
||||
const upstreamUrl = new URL(upstream);
|
||||
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");
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
if (isProxyPath(request.url)) {
|
||||
proxyHttpRequest(request, response, upstreamUrl);
|
||||
return;
|
||||
}
|
||||
serveStaticFile(request, response, staticRoot);
|
||||
});
|
||||
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (!isProxyPath(request.url)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
proxyWebSocket(request, socket, head, upstreamUrl);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
function isProxyPath(url) {
|
||||
const pathname = new URL(url, "http://preview.local").pathname;
|
||||
return pathname === "/api" || pathname.startsWith("/api/") ||
|
||||
pathname === "/websocket" || pathname.startsWith("/websocket/");
|
||||
}
|
||||
|
||||
function upstreamPath(url) {
|
||||
const parsed = new URL(url, "http://preview.local");
|
||||
const pathname = parsed.pathname.replace(/^\/(api|websocket)(?=\/|$)/, "") || "/";
|
||||
return `${pathname}${parsed.search}`;
|
||||
}
|
||||
|
||||
function upstreamRequestOptions(request, upstreamUrl) {
|
||||
return {
|
||||
protocol: upstreamUrl.protocol,
|
||||
hostname: upstreamUrl.hostname,
|
||||
port: upstreamUrl.port || undefined,
|
||||
method: request.method,
|
||||
path: upstreamPath(request.url),
|
||||
headers: { ...request.headers, host: upstreamUrl.host },
|
||||
};
|
||||
}
|
||||
|
||||
function proxyHttpRequest(request, response, upstreamUrl) {
|
||||
const client = upstreamUrl.protocol === "https:" ? https : http;
|
||||
const proxyRequest = client.request(upstreamRequestOptions(request, upstreamUrl), (proxyResponse) => {
|
||||
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
|
||||
proxyResponse.pipe(response);
|
||||
});
|
||||
proxyRequest.on("error", (error) => {
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({ error: "V2X upstream unavailable", detail: error.message }));
|
||||
} else {
|
||||
response.destroy(error);
|
||||
}
|
||||
});
|
||||
request.pipe(proxyRequest);
|
||||
}
|
||||
|
||||
function proxyWebSocket(request, socket, head, upstreamUrl) {
|
||||
const client = upstreamUrl.protocol === "https:" ? https : http;
|
||||
const options = upstreamRequestOptions(request, upstreamUrl);
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
connection: "Upgrade",
|
||||
upgrade: "websocket",
|
||||
};
|
||||
const proxyRequest = client.request(options);
|
||||
proxyRequest.on("upgrade", (proxyResponse, upstreamSocket, upstreamHead) => {
|
||||
socket.write(`HTTP/${proxyResponse.httpVersion} ${proxyResponse.statusCode} ${proxyResponse.statusMessage}\r\n`);
|
||||
Object.entries(proxyResponse.headers).forEach(([name, value]) => {
|
||||
socket.write(`${name}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`);
|
||||
});
|
||||
socket.write("\r\n");
|
||||
if (upstreamHead.length) socket.write(upstreamHead);
|
||||
if (head.length) upstreamSocket.write(head);
|
||||
socket.pipe(upstreamSocket).pipe(socket);
|
||||
});
|
||||
proxyRequest.on("response", (proxyResponse) => {
|
||||
socket.write(`HTTP/${proxyResponse.httpVersion} ${proxyResponse.statusCode} ${proxyResponse.statusMessage}\r\n\r\n`);
|
||||
socket.destroy();
|
||||
});
|
||||
proxyRequest.on("error", () => socket.destroy());
|
||||
proxyRequest.end();
|
||||
}
|
||||
|
||||
function serveStaticFile(request, response, root) {
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
response.writeHead(405, { Allow: "GET, HEAD" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const pathname = decodeURIComponent(new URL(request.url, "http://preview.local").pathname);
|
||||
const relativePath = pathname === "/" ? "" : pathname.slice(1);
|
||||
const filename = path.resolve(root, relativePath || "fengshu-er-road-cesium-preview.html");
|
||||
if (!filename.startsWith(`${root}${path.sep}`) && filename !== root) {
|
||||
response.writeHead(403);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
fs.stat(filename, (error, stat) => {
|
||||
if (error || !stat.isFile()) {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "Content-Type": MIME_TYPES[path.extname(filename).toLowerCase()] || "application/octet-stream", "Cache-Control": "no-store" });
|
||||
if (request.method === "HEAD") response.end();
|
||||
else fs.createReadStream(filename).pipe(response);
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const root = args.root || process.cwd();
|
||||
const upstream = args.upstream || process.env.V2X_UPSTREAM;
|
||||
const port = Number(args.port || process.env.PORT || 7862);
|
||||
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 (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port must be an integer between 1 and 65535");
|
||||
const server = createV2xPreviewServer({ root, upstream });
|
||||
server.listen(port, host, () => console.log(`V2X preview server: http://${host}:${port} -> ${upstream}`));
|
||||
}
|
||||
|
||||
module.exports = { createV2xPreviewServer, isProxyPath, upstreamPath };
|
||||
Reference in New Issue
Block a user