(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"; function createV2xCesiumOverlay(context) { const settings = context.config.v2xPreview || {}; if (!settings.enabled) return null; const state = { token: sessionStorage.getItem(TOKEN_KEY) || "", entities: [], vehicles: new Map(), linkPhases: new Map(), sockets: [], status: "Sign in to load live V2X data.", crossCode: settings.crossCode || "", metrics: null, vehicleSource: "waiting", }; const ui = buildUi(state, settings); function setStatus(message) { state.status = message; ui.status.textContent = message; } 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); }); state.vehicles.clear(); state.linkPhases.clear(); } function disconnect() { state.sockets.forEach((socket) => socket.close()); state.sockets = []; } 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; sessionStorage.setItem(TOKEN_KEY, token); ui.form.hidden = true; ui.live.hidden = false; await loadLiveData(); } function signOut(message) { disconnect(); clearEntities(); state.vehicleSource = "waiting"; state.token = ""; sessionStorage.removeItem(TOKEN_KEY); ui.form.hidden = false; ui.live.hidden = true; if (message) setStatus(message); } async function loadLiveData() { const crossCode = ui.crossCode.value.trim(); if (!crossCode) { setStatus("Enter a V2X intersection code."); return; } state.crossCode = crossCode; 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)}`), request(`/facilities/api/FlowTravelRatio/queryListWeek?code=${encodeURIComponent(crossCode)}`), ]); const [links, poles, devices, deviceConfig, flow] = results; const warnings = []; 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"); 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(", ")}.` : ""}`); } function connectLiveSockets(deviceConfig) { disconnect(); connectSignalSocket(); connectObuSocket(); connectTargetSocket(deviceConfig); } 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; } } function connectSignalSocket() { openSocket("/network/ws/network/signal", (socket) => socket.send(JSON.stringify({ junctionId: state.crossCode })), (value) => updateSignalPhases(value, state), "Signal WebSocket unavailable"); } function connectObuSocket() { openSocket("/network/ws/network/obuPosition", null, (value) => { const vehicle = normalizeObuVehicle(value); if (vehicle) updateLiveVehicle(context.viewer, vehicle, state); }, "OBU vehicle WebSocket unavailable"); } function connectTargetSocket(deviceConfig) { openSocket("/network/ws/network/targetPosition", (socket) => { const targetIds = arrayValue(deviceConfig?.deviceConfig?.target); socket.send(JSON.stringify({ deviceId: targetIds.length ? targetIds.join(",") : null })); }, (value) => { normalizeTargetVehicles(value).forEach((vehicle) => updateLiveVehicle(context.viewer, vehicle, state)); }, "Target vehicle WebSocket unavailable"); } 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.")); 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(); } }; } function buildUi(state, settings) { 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]"), crossCode: root.querySelector("[name=crossCode]"), 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); arrayValue(link.phaseList).forEach((phase) => state.linkPhases.set(String(phase.phase), entity)); }); } 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); }); } function updateLiveVehicle(viewer, vehicle, state) { const [longitude, latitude] = gcj02ToWgs84([vehicle.longitude, vehicle.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) { 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); } else { record.entity.position = position; record.tracePositions.push(position); if (record.tracePositions.length > 24) record.tracePositions.shift(); } } function parseSocketJson(value) { if (typeof value !== "string" || value.includes('"heartBeat":"pong"')) return null; try { return JSON.parse(value); } catch (_) { return null; } } function normalizeObuVehicle(value) { const source = parseSocketJson(value); 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 normalizeTargetVehicles(value) { const source = parseSocketJson(value); 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; 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 }; }).filter(Boolean)); } 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); }); } 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; } 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 escapeAttribute(value) { return String(value).replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<"); } 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)]; } // 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, joinUrl, md5, toWebSocketUrl, normalizeObuVehicle, normalizeTargetVehicles }; window.createV2xCesiumOverlay = createV2xCesiumOverlay; }());