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:
2026-08-25 09:20:50 +08:00
parent bb2a1449ac
commit bc845444bb
17 changed files with 1900 additions and 110 deletions

View File

@@ -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;
}());