Files
osmWorkflow/scripts/lib/v2x-cesium-overlay.js

979 lines
43 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";
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 = `<div class="v2x-heading"><strong>Live V2X Data</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><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]"),
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;
}());