(function () { "use strict"; const PI = Math.PI; const EARTH_A = 6378245.0; const EARTH_EE = 0.00669342162296594323; const TOKEN_KEY = "osm-asset-preview-v2x-token"; const LIVE_VEHICLE_HEIGHT_METERS = 1.2; const LIVE_VEHICLE_HEADING_OFFSET_DEGREES = 90; function createV2xCesiumOverlay(context) { const settings = context.config.v2xPreview || {}; if (!settings.enabled) return null; const state = { token: "", entities: [], linkEntitiesByPhase: new Map(), phaseSignals: new Map(), phaseBinding: { bound: 0, unbound: [], diagnostics: [] }, sockets: [], status: "Sign in to load live V2X data.", crossCode: settings.crossCode || "", vehicleSource: "waiting", parseFailures: 0, missingModels: new Set(), targetMessages: 0, targetVehiclesReceived: 0, targetLastCount: 0, targetLastError: "", targetPayloadType: "", targetPayloadSample: "", targetPayloadShape: "", }; // This preview intentionally starts at the login gate. Do not revive a // previous tab's token and begin REST/WebSocket traffic before sign-in. 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`); if (state.targetMessages) parts.push(`${state.targetVehiclesReceived} target vehicles`); return parts.join(", "); } function clearEntities() { state.entities.forEach((entity) => context.viewer.entities.remove(entity)); state.entities = []; registry.list().forEach((record) => { if (record.entity) context.viewer.entities.remove(record.entity); }); registry.clear(); state.linkEntitiesByPhase.clear(); state.phaseSignals.clear(); } function disconnect() { 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() { if (state.vehicleSource === "live") return; state.vehicleSource = "live"; } state.activateLiveVehicles = useLiveVehicles; async function request(path) { const response = await fetch(joinUrl(settings.apiBaseUrl || "/api", path), { headers: state.token ? { Authorization: state.token } : {}, cache: "no-store", }); const body = await response.json().catch(() => null); if (response.status === 401 || response.status === 403 || body?.code === 401 || body?.code === 403) { signOut("V2X authorization expired. Sign in again."); throw new Error("V2X authorization expired"); } if (!response.ok) throw new Error(`V2X request failed (${response.status})`); if (body && Object.prototype.hasOwnProperty.call(body, "code") && Number(body.code) !== 200) { throw new Error(body.msg || body.message || "V2X request failed"); } return body && Object.prototype.hasOwnProperty.call(body, "data") ? body.data : body; } async function signIn(userName, password) { setStatus("Signing in to V2X..."); const response = await fetch(joinUrl(settings.apiBaseUrl || "/api", "/facilities/api/sys/login"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userName, password: md5(password) }), }); const body = await response.json().catch(() => null); const payload = body?.data || body || {}; const token = payload.token || payload.accessToken || body?.token; if (!response.ok || Number(body?.code) !== 200 || typeof token !== "string" || !token) { throw new Error(body?.msg || body?.message || "V2X sign-in failed"); } state.token = token; ui.form.hidden = true; ui.live.hidden = false; await loadLiveData(); } function signOut(message) { disconnect(); 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; if (message) setStatus(message); } async function loadLiveData() { const crossCode = await resolveCrossCode(); if (!crossCode) { setStatus("No V2X intersection is configured for this account."); return; } clearEntities(); state.vehicleSource = "waiting"; setStatus("Loading live V2X intersection data..."); const results = await Promise.allSettled([ request(`/network/api/link/network/queryCrossLinkInfo/${encodeURIComponent(crossCode)}`), request(`/network/api/pole/network/queryPoles/${encodeURIComponent(crossCode)}`), request(`/facilities/api/crossDevice/findDeviceByCrossCode/${encodeURIComponent(crossCode)}`), request(`/facilities/api/crossDeviceConfig/${encodeURIComponent(crossCode)}`), ]); 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; connectLiveSockets(deviceConfig.status === "fulfilled" ? deviceConfig.value : null); 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) { disconnect(); 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 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", ({ 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() { 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", ({ send }) => { const targetIds = arrayValue(deviceConfig?.deviceConfig?.target); send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null })); }, (value) => { if (isHeartbeat(value)) return; state.targetMessages += 1; try { state.targetPayloadType = Object.prototype.toString.call(value); state.targetPayloadSample = typeof value === "string" ? value.slice(0, 800) : String(value).slice(0, 800); const payload = parseLoosePayload(value); const data = payload?.data; state.targetPayloadShape = data === null ? "data:null" : Array.isArray(data) ? `data:array(${data.length})` : typeof data === "object" ? `data:object(${Object.keys(data || {}).join(",")})` : `data:${typeof data}`; const vehicles = targetVehiclesFrom(payload); state.targetLastCount = vehicles.length; if (!vehicles.length) { if (payload === null) state.parseFailures += 1; return; } state.targetVehiclesReceived += vehicles.length; useLiveVehicles(); registry.ingest(vehicles, payload?.interval); registry.sweep(); renderVehicles(); } catch (error) { state.targetLastError = String(error?.message || error); state.parseFailures += 1; } }, "Target vehicle WebSocket unavailable"); } function renderVehicles() { registry.list().forEach((record) => syncVehicleEntity(context.viewer, record, state)); } ui.form.addEventListener("submit", async (event) => { event.preventDefault(); try { await signIn(ui.userName.value.trim(), ui.password.value); ui.password.value = ""; } catch (error) { setStatus(error.message || "V2X sign-in failed"); } }); ui.reload.addEventListener("click", () => loadLiveData().catch((error) => setStatus(error.message || "Live V2X data unavailable"))); ui.signOut.addEventListener("click", () => signOut("Signed out. Native preview remains available.")); return { state, load: loadLiveData, signOut, dispose: () => { disconnect(); clearEntities(); state.vehicleSource = "waiting"; ui.root.remove(); }, }; } function buildUi(state) { const root = document.createElement("aside"); root.id = "v2xPanel"; root.innerHTML = `
Live V2X DataNative: WGS84/ENU | V2X: GCJ-02
`; document.body.appendChild(root); return { root, form: root.querySelector("#v2xLoginForm"), live: root.querySelector("#v2xLiveControls"), userName: root.querySelector("[name=userName]"), password: root.querySelector("[name=password]"), reload: root.querySelector("[data-action=reload]"), signOut: root.querySelector("[data-action=signout]"), status: root.querySelector("#v2xStatus"), }; } function addLinks(viewer, document, state) { arrayValue(document?.inLinkList).forEach((link) => { let geometry; try { geometry = typeof link.geom === "string" ? JSON.parse(link.geom) : link.geom; } catch (_) { return; } const points = arrayValue(geometry?.coordinates).map(gcj02ToWgs84).flat(); if (points.length < 4) return; const entity = viewer.entities.add({ name: link.name || `V2X link ${link.id}`, polyline: { positions: Cesium.Cartesian3.fromDegreesArray(points), width: 5, material: Cesium.Color.CYAN.withAlpha(.78), clampToGround: false }, }); state.entities.push(entity); // 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); }); }); } function addDevices(viewer, devices, state) { arrayValue(devices).forEach((device) => { const longitude = Number(device.longitude ?? device.lon ?? device.position?.longitude); const latitude = Number(device.latitude ?? device.lat ?? device.position?.latitude); if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return; const [wgsLongitude, wgsLatitude] = gcj02ToWgs84([longitude, latitude]); const entity = viewer.entities.add({ name: device.name || device.code || "V2X device", position: Cesium.Cartesian3.fromDegrees(wgsLongitude, wgsLatitude, 3), point: { pixelSize: 9, color: device.onlineStatus === 1 ? Cesium.Color.LIME : Cesium.Color.RED, outlineColor: Cesium.Color.BLACK, outlineWidth: 1 }, label: { text: device.code || device.name || "device", font: "11px sans-serif", fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, style: Cesium.LabelStyle.FILL_AND_OUTLINE, pixelOffset: new Cesium.Cartesian2(0, -16) }, }); state.entities.push(entity); }); } // 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; const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, LIVE_VEHICLE_HEIGHT_METERS); if (!record.entity) { 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), // The full-intersection overview needs more than a couple of screen // pixels for a car to remain distinguishable from road markings. scale: 1.0, minimumPixelSize: 28, maximumScale: 8.0, show: arrayValue(state.vehicleModels).length > 0, }, }); } else { record.renderedPosition = record.targetPosition || position; record.targetPosition = position; record.sampleStart = Date.now(); } // Stale vehicles are hidden, not removed, so the slot stays reusable. record.entity.show = record.visible; } function vehicleOrientation(position, angle) { return Cesium.Transforms.headingPitchRollQuaternion( position, new Cesium.HeadingPitchRoll(Cesium.Math.toRadians((Number(angle) || 0) + LIVE_VEHICLE_HEADING_OFFSET_DEGREES), 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$]*|\d+)\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) { 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) => { const payload = event?.data; // The upstream sometimes marks UTF-8 JSON as a binary WS frame. DevTools // renders that Blob as JSON, but JSON.parse cannot consume a Blob; decode // it before applying the same dashboard payload parser. if (typeof Blob !== "undefined" && payload instanceof Blob) { payload.text().then((text) => settings.onMessage?.(text)).catch((error) => settings.onError?.(error)); return; } if (typeof ArrayBuffer !== "undefined" && payload instanceof ArrayBuffer) { settings.onMessage?.(new TextDecoder().decode(payload)); return; } settings.onMessage?.(payload); }; socket.onerror = (event) => settings.onError?.(event); socket.onclose = (event) => { stopHeartbeat(); 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; if (!code || !Number.isFinite(longitude) || !Number.isFinite(latitude)) return null; return { id: `obu-${code}`, label: source.plateNumber || String(code), kind: "obu", longitude, latitude, angle: Number(source.angle) || 0, speed: Number(source.speed) || 0 }; } 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, type, subType }; }).filter(Boolean)); } // The dashboard's CrossCars template renders OBU with 11.glb, while target // vehicles retain their `${type}${subType}.glb` model selection. function modelNameFor(vehicle) { if (!vehicle) return ""; if (vehicle.kind === "obu") return "11.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; }, get visibleSize() { return records.filter((record) => record.visible).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) { 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); }); }); } // 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 : []; } function joinUrl(base, path) { return `${String(base || "").replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`; } function toWebSocketUrl(url, token) { const resolved = new URL(url, window.location.href); resolved.protocol = resolved.protocol === "https:" ? "wss:" : "ws:"; resolved.searchParams.set("authorization", token); return resolved.href; } function transformLat(x, y) { let value = -100 + 2 * x + 3 * y + .2 * y * y + .1 * x * y + .2 * Math.sqrt(Math.abs(x)); value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3; value += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3; value += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3; return value; } function transformLon(x, y) { let value = 300 + x + 2 * y + .1 * x * x + .1 * x * y + .1 * Math.sqrt(Math.abs(x)); value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3; value += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3; value += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3; return value; } function gcj02ToWgs84(coordinate) { const longitude = Number(coordinate[0]); const latitude = Number(coordinate[1]); if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return [NaN, NaN]; const dLat = transformLat(longitude - 105, latitude - 35); const dLon = transformLon(longitude - 105, latitude - 35); const radLat = latitude / 180 * PI; const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2; const sqrtMagic = Math.sqrt(magic); return [longitude - dLon * 180 / (EARTH_A / sqrtMagic * Math.cos(radLat) * PI), latitude - dLat * 180 / (EARTH_A * (1 - EARTH_EE) / (magic * sqrtMagic) * PI)]; } // 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) { const source = unescape(encodeURIComponent(String(value))); const words = []; for (let index = 0; index < source.length; index += 1) words[index >> 2] = (words[index >> 2] || 0) | (source.charCodeAt(index) << ((index % 4) * 8)); const bitLength = source.length * 8; words[bitLength >> 5] = (words[bitLength >> 5] || 0) | (128 << (bitLength % 32)); words[(((bitLength + 64) >>> 9) << 4) + 14] = bitLength; const shifts = [7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21]; let a0 = 1732584193; let b0 = -271733879; let c0 = -1732584194; let d0 = 271733878; for (let offset = 0; offset < words.length; offset += 16) { let a = a0; let b = b0; let c = c0; let d = d0; for (let index = 0; index < 64; index += 1) { let f; let g; if (index < 16) { f = (b & c) | (~b & d); g = index; } else if (index < 32) { f = (d & b) | (~d & c); g = (5 * index + 1) % 16; } else if (index < 48) { f = b ^ c ^ d; g = (3 * index + 5) % 16; } else { f = c ^ (b | ~d); g = (7 * index) % 16; } const nextD = d; d = c; c = b; b = add(b, rotate(add(add(a, f), add(words[offset + g] || 0, Math.floor(Math.abs(Math.sin(index + 1)) * 4294967296))), shifts[index])); a = nextD; } a0 = add(a0, a); b0 = add(b0, b); c0 = add(c0, c); d0 = add(d0, d); } return [a0, b0, c0, d0].map(hex).join(""); } function add(a, b) { return (a + b) & 0xFFFFFFFF; } function rotate(value, count) { return (value << count) | (value >>> (32 - count)); } function hex(value) { let output = ""; for (let index = 0; index < 4; index += 1) output += (`0${(value >>> (index * 8) & 255).toString(16)}`).slice(-2); return output; } createV2xCesiumOverlay.utils = { gcj02ToWgs84, 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; }());