281 lines
14 KiB
JavaScript
281 lines
14 KiB
JavaScript
(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: [],
|
|
linkPhases: new Map(),
|
|
socket: null,
|
|
status: "Sign in to load live V2X data.",
|
|
crossCode: settings.crossCode || "",
|
|
metrics: null,
|
|
};
|
|
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.linkPhases.clear();
|
|
}
|
|
|
|
function disconnect() {
|
|
if (state.socket) state.socket.close();
|
|
state.socket = null;
|
|
}
|
|
|
|
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.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();
|
|
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");
|
|
connectSignalSocket();
|
|
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 connectSignalSocket() {
|
|
disconnect();
|
|
const socketUrl = toWebSocketUrl(joinUrl(settings.wsBaseUrl || "/websocket", "/network/ws/network/signal"), state.token);
|
|
try {
|
|
state.socket = new WebSocket(socketUrl);
|
|
state.socket.onopen = () => state.socket?.send(JSON.stringify({ junctionId: state.crossCode }));
|
|
state.socket.onmessage = (event) => updateSignalPhases(event.data, state);
|
|
state.socket.onerror = () => setStatus(`${state.status} Signal WebSocket unavailable.`);
|
|
} catch (_) {
|
|
setStatus(`${state.status} Signal 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(); ui.root.remove(); } };
|
|
}
|
|
|
|
function buildUi(state, settings) {
|
|
const root = document.createElement("aside");
|
|
root.id = "v2xPanel";
|
|
root.innerHTML = `<div class="v2x-heading"><strong>Live V2X Overlay</strong><span>Native: WGS84/ENU | V2X: GCJ-02</span></div>
|
|
<form id="v2xLoginForm"><label>Username<input name="userName" autocomplete="username" required></label><label>Password<input name="password" type="password" autocomplete="current-password" required></label><button type="submit">Sign in</button></form>
|
|
<div id="v2xLiveControls" hidden><label>Intersection code<input name="crossCode" value="${escapeAttribute(settings.crossCode || "")}" required></label><div><button type="button" data-action="reload">Refresh</button><button type="button" data-action="signout">Sign out</button></div></div>
|
|
<output id="v2xStatus"></output>`;
|
|
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 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 };
|
|
window.createV2xCesiumOverlay = createV2xCesiumOverlay;
|
|
}());
|