979 lines
43 KiB
JavaScript
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;
|
|
}());
|