The ported overlay used the correct REST paths but lost the data handling
from the source dashboard's live-intersection view (HologramCross), so
signals rendered permanently red and vehicles often never appeared.
- Lamp status codes now follow the dashboard dictionary (11/21/22/23/31).
The previous 2/3 reading made every real push fall through to red. The
dictionary lives only in the overlay; the preview consumes normalized
{nodeKeys, color, countDown} entries so the two copies cannot drift.
- Bind V2X phases to native signal heads geometrically. The runtime
document has no phaseNo, so the old lookup fell back to signal.id and
never matched, leaving the dynamic assembly dark. Travel heading is
recovered as faceHeadingDegrees + 180, per the generator's
mast = travel - 90 / face = travel + 180. Verified 7/7 exact matches
against the fengshu-er-road runtime document.
- A phase now lights every approach it drives; the phase -> single entity
map silently overwrote all but the last.
- Drive the countdown assets from the push's countDown field.
- All three sockets heartbeat every 30s and reconnect with backoff,
replaying their subscription frame. Without this the service dropped
the connection and the scene emptied after about a minute.
- The OBU socket sends its bounds frame on connect and on camera move;
it previously sent nothing at all.
- Vehicles are swept when a push goes stale and their slots reused, so
they no longer accumulate as ghosts. Models follow the dashboard's
car_obu.glb / ${type}${subType}.glb naming.
- Parse vehicle pushes leniently, since the dashboard uses saferEval and
the payload is not guaranteed to be strict JSON. Failures are counted
and surfaced rather than dropped; no eval is introduced.
- Drop FlowTravelRatio/queryListWeek, which is not part of this view.
Also corrects a stale spec rule that required vehicleModelNames to be
empty. Live V2X vehicles need packaged models; the real invariant is no
generated routes or traffic simulation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1505 lines
61 KiB
JavaScript
1505 lines
61 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const config = window.OSM_ASSET_PREVIEW_CONFIG || {};
|
|
const statusEl = document.getElementById("status");
|
|
const diagnosticsEl = document.getElementById("diagnostics");
|
|
const loadingOverlay = document.getElementById("loadingOverlay");
|
|
const toggleCruise = document.getElementById("toggleCruise");
|
|
const toggleFollow = document.getElementById("toggleFollow");
|
|
const toggleScene = document.getElementById("toggleScene");
|
|
const toggleBuildingGhost = document.getElementById("toggleBuildingGhost");
|
|
const toggleRoutes = document.getElementById("toggleRoutes");
|
|
const toggleVehicles = document.getElementById("toggleVehicles");
|
|
const toggleSignals = document.getElementById("toggleSignals");
|
|
const signalsControl = document.getElementById("signalsControl");
|
|
const toggleFps = document.getElementById("toggleFps");
|
|
const toggleDiagnostics = document.getElementById("toggleDiagnostics");
|
|
const assetToggles = document.getElementById("assetToggles");
|
|
const vehicleInfoCard = document.getElementById("vehicleInfoCard");
|
|
const vehicleInfoTitle = document.getElementById("vehicleInfoTitle");
|
|
const vehicleInfoDetails = document.getElementById("vehicleInfoDetails");
|
|
const vehicleIncidentNote = document.getElementById("vehicleIncidentNote");
|
|
const closeVehicleInfo = document.getElementById("closeVehicleInfo");
|
|
const vehicleStatusButtons = Array.from(document.querySelectorAll("[data-vehicle-status]"));
|
|
// The exported road surface sits at the 0.35m scene anchor plus 0.03m.
|
|
// Vehicle models have their wheels at local Y=0, so keep them just clear
|
|
// of the asphalt instead of using the old visibly floating 1.15m height.
|
|
const VEHICLE_HEIGHT_METERS = 0.40;
|
|
const ROUTE_LINE_HEIGHT_METERS = 0.42;
|
|
const semanticToggles = document.getElementById("semanticToggles");
|
|
const vehicleSelect = document.getElementById("vehicleSelect");
|
|
const speedControl = document.getElementById("speedControl");
|
|
const speedLabel = document.getElementById("speedLabel");
|
|
const cameraButtons = Array.from(document.querySelectorAll("[data-camera]"));
|
|
const viewModeButtons = Array.from(document.querySelectorAll("[data-view-mode]"));
|
|
|
|
// Status carries two kinds of message: the scene summary, which is what the
|
|
// panel should read whenever nothing else is going on, and transient notes
|
|
// from a control the user just touched. Keep the summary so the transient
|
|
// note can be replaced instead of destroying it.
|
|
let baseStatus = "";
|
|
const PREVIEW_BACKGROUND = "#d9e0e2";
|
|
|
|
Cesium.Ion.defaultAccessToken = "";
|
|
|
|
async function main() {
|
|
setLoadingMessage("Loading scene", config.areaId || "");
|
|
const metadata = await fetchJson(config.metadataName);
|
|
const descriptor = config.previewDescriptorName ? await fetchOptionalJson(config.previewDescriptorName) : null;
|
|
adaptPackageManifest(metadata, descriptor);
|
|
const signalData = await fetchOptionalJson(config.trafficSignalsName);
|
|
const placement = scenePlacement(metadata);
|
|
const viewer = createViewer();
|
|
setLoadingMessage("Loading model", config.glbName || "");
|
|
const assets = await loadSceneAssets(viewer, metadata, placement);
|
|
const trafficStart = Cesium.JulianDate.now();
|
|
const trafficSignals = createLiveTrafficSignals(signalData, assets);
|
|
hideUnconfirmedSignalAssets(assets);
|
|
const cruise = createLiveVehicleState();
|
|
const cameras = createCameraPresets(viewer, metadata, placement, cruise);
|
|
const v2xOverlay = typeof window.createV2xCesiumOverlay === "function"
|
|
? window.createV2xCesiumOverlay({
|
|
viewer,
|
|
metadata,
|
|
placement,
|
|
config,
|
|
nativeSignals: (signalData && signalData.signals) || [],
|
|
setSignalState: (entries) => trafficSignals.update(entries),
|
|
})
|
|
: null;
|
|
|
|
buildAssetToggles(assets);
|
|
buildSemanticToggles(viewer, assets, placement);
|
|
bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals);
|
|
bindVehicleInfoCard(viewer, cruise);
|
|
startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals, v2xOverlay);
|
|
cameras.overview();
|
|
baseStatus = summaryText(metadata, assets, cruise);
|
|
setStatus(baseStatus);
|
|
setLoadingMessage("Preparing view", "finalizing materials");
|
|
await waitForStableFrames(viewer);
|
|
document.body.classList.add("scene-ready");
|
|
// Handle for the browser console and for headless checks: everything else
|
|
// in here is closed over by the IIFE and unreachable from outside.
|
|
window.osmPreview = { viewer, metadata, placement, assets, cruise, trafficSignals, cameras, v2xOverlay };
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
// Generated preview JSON keeps a stable filename; bypass browser caches so
|
|
// route regeneration is visible immediately during inspection.
|
|
const response = await fetch(url, { cache: "no-store" });
|
|
if (!response.ok) {
|
|
throw new Error("Could not load " + url + ": " + response.status);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
// The route file is an extra on top of the scene, not a precondition for it.
|
|
// A missing or unreadable route costs the cruise controls, not the preview.
|
|
async function fetchOptionalJson(url) {
|
|
if (!url) return null;
|
|
try {
|
|
return await fetchJson(url);
|
|
} catch (error) {
|
|
console.warn(error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function createViewer() {
|
|
const viewer = new Cesium.Viewer("cesiumContainer", {
|
|
animation: false,
|
|
timeline: false,
|
|
baseLayerPicker: false,
|
|
geocoder: false,
|
|
navigationHelpButton: false,
|
|
sceneModePicker: false,
|
|
homeButton: true,
|
|
fullscreenButton: true,
|
|
infoBox: false,
|
|
selectionIndicator: false,
|
|
baseLayer: false
|
|
});
|
|
viewer.scene.globe.show = false;
|
|
viewer.scene.globe.depthTestAgainstTerrain = false;
|
|
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString(PREVIEW_BACKGROUND);
|
|
viewer.scene.skyAtmosphere.show = false;
|
|
viewer.scene.skyBox.show = false;
|
|
viewer.scene.sun.show = false;
|
|
viewer.scene.moon.show = false;
|
|
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString(PREVIEW_BACKGROUND);
|
|
return viewer;
|
|
}
|
|
|
|
function setLoadingMessage(title, detail) {
|
|
if (!loadingOverlay) return;
|
|
const titleEl = loadingOverlay.querySelector("strong");
|
|
const detailEl = loadingOverlay.querySelector("span");
|
|
if (titleEl) titleEl.textContent = title;
|
|
if (detailEl) detailEl.textContent = detail || "";
|
|
}
|
|
|
|
function waitForStableFrames(viewer) {
|
|
return new Promise((resolve) => {
|
|
const started = performance.now();
|
|
let frames = 0;
|
|
let done = false;
|
|
let remove = function () {};
|
|
function finish() {
|
|
if (done) return;
|
|
done = true;
|
|
remove();
|
|
resolve();
|
|
}
|
|
remove = viewer.scene.postRender.addEventListener(() => {
|
|
frames += 1;
|
|
const elapsed = performance.now() - started;
|
|
if ((frames >= 14 && elapsed >= 650) || elapsed >= 2800) {
|
|
finish();
|
|
}
|
|
});
|
|
window.setTimeout(finish, 3200);
|
|
viewer.scene.requestRender();
|
|
});
|
|
}
|
|
|
|
function scenePlacement(metadata) {
|
|
const anchor = metadata.anchor || metadata.placement || {};
|
|
const longitude = Number(anchor.longitude || 0);
|
|
const latitude = Number(anchor.latitude || 0);
|
|
const height = Number(anchor.height || 0);
|
|
const heading = Number(metadata.heading_correction_degrees ?? metadata.placement?.headingCorrectionDegrees ?? 0);
|
|
const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
|
|
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
|
|
const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(heading));
|
|
return {
|
|
longitude,
|
|
latitude,
|
|
height,
|
|
heading,
|
|
position,
|
|
modelMatrix: Cesium.Matrix4.multiplyByMatrix3(enu, correction, new Cesium.Matrix4())
|
|
};
|
|
}
|
|
|
|
function normalizedAssets(metadata) {
|
|
if (metadata.schema === "osm-asset-package/v1") {
|
|
const staticAssets = metadata.assets.map((asset) => ({
|
|
id: asset.id,
|
|
label: asset.id,
|
|
type: "model",
|
|
// Package URIs are relative to manifest.json, not the preview HTML.
|
|
url: new URL(asset.uri, new URL(config.metadataName, window.location.href)).href,
|
|
enabled: asset.defaultLoad,
|
|
category: asset.category,
|
|
semantic: asset.role === "layer",
|
|
}));
|
|
const runtimeAssets = (metadata.runtime || []).filter((asset) => asset.type !== "traffic-signal-anchors").map((asset) => ({
|
|
id: asset.id,
|
|
label: asset.id,
|
|
type: "model",
|
|
url: new URL(asset.uri, new URL(config.metadataName, window.location.href)).href,
|
|
enabled: true,
|
|
category: asset.type === "traffic-signal-lenses" ? "dynamic" : asset.type === "traffic-signal-countdown" ? "countdown" : asset.type,
|
|
phaseGroup: asset.phaseGroup,
|
|
}));
|
|
return staticAssets.concat(runtimeAssets, metadata.previewAssets || []);
|
|
}
|
|
const assets = Array.isArray(metadata.assets) && metadata.assets.length
|
|
? metadata.assets
|
|
: [{
|
|
id: "main",
|
|
label: "Scene",
|
|
type: "model",
|
|
url: metadata.asset || config.glbName,
|
|
enabled: true
|
|
}];
|
|
return assets.filter((asset) => (asset.type || "model") === "model" && asset.url);
|
|
}
|
|
|
|
function adaptPackageManifest(metadata, descriptor) {
|
|
if (metadata.schema !== "osm-asset-package/v1") return;
|
|
metadata.anchor = metadata.placement;
|
|
metadata.heading_correction_degrees = metadata.placement.headingCorrectionDegrees;
|
|
metadata.scene_stats = metadata.sceneStats || {};
|
|
metadata.bounds = { min_lon: metadata.bounds.minLon, min_lat: metadata.bounds.minLat, max_lon: metadata.bounds.maxLon, max_lat: metadata.bounds.maxLat };
|
|
if (descriptor?.assets) metadata.previewAssets = descriptor.assets;
|
|
}
|
|
|
|
// One broken entry in metadata.assets should not blank the whole preview, so
|
|
// failures are collected and surfaced in the diagnostics panel instead.
|
|
async function loadSceneAssets(viewer, metadata, placement) {
|
|
const loaded = [];
|
|
for (const asset of normalizedAssets(metadata)) {
|
|
const id = asset.id || "asset-" + loaded.length;
|
|
const label = asset.label || asset.id || asset.url;
|
|
const entry = { ...asset, id, label, model: null, error: null };
|
|
loaded.push(entry);
|
|
// Semantic assets are inspection aids. Defer their network and GPU cost
|
|
// until the user explicitly switches out of the normal scene view.
|
|
if (entry.semantic || entry.enabled === false) continue;
|
|
await loadAsset(viewer, entry, placement);
|
|
}
|
|
if (!loaded.some((asset) => asset.model && asset.category !== "semantic")) {
|
|
throw new Error("No scene model could be loaded (" + loaded.length + " declared)");
|
|
}
|
|
return loaded;
|
|
}
|
|
|
|
async function loadAsset(viewer, asset, placement) {
|
|
if (asset.model || asset.error) return asset.model;
|
|
if (asset.loading) return asset.loading;
|
|
asset.loading = Cesium.Model.fromGltfAsync({
|
|
url: asset.url,
|
|
modelMatrix: placement.modelMatrix,
|
|
scale: Number(asset.scale || 1.0)
|
|
}).then((model) => {
|
|
model.show = asset.enabled !== false;
|
|
viewer.scene.primitives.add(model);
|
|
asset.model = model;
|
|
return model;
|
|
}).catch((error) => {
|
|
console.error(error);
|
|
asset.error = error;
|
|
return null;
|
|
}).finally(() => {
|
|
asset.loading = null;
|
|
});
|
|
return asset.loading;
|
|
}
|
|
|
|
function liveAssets(assets) {
|
|
return assets.filter((asset) => asset.model && asset.category !== "semantic");
|
|
}
|
|
|
|
function semanticAssets(assets) {
|
|
return assets.filter((asset) => asset.category === "semantic");
|
|
}
|
|
|
|
// A single-asset scene keeps the plain "Scene" checkbox; a multi-asset one
|
|
// gets a child checkbox per model with "Scene" acting as the master.
|
|
function buildAssetToggles(assets) {
|
|
const live = liveAssets(assets);
|
|
if (live.length < 2) return;
|
|
for (const asset of live) {
|
|
const label = document.createElement("label");
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.checked = asset.model.show;
|
|
input.dataset.assetId = asset.id;
|
|
input.addEventListener("change", () => {
|
|
asset.model.show = input.checked;
|
|
syncSceneMaster(assets);
|
|
setStatus(asset.label + (input.checked ? " visible" : " hidden"));
|
|
});
|
|
label.appendChild(input);
|
|
label.appendChild(document.createTextNode(" " + asset.label));
|
|
assetToggles.appendChild(label);
|
|
asset.toggle = input;
|
|
}
|
|
}
|
|
|
|
function syncSceneMaster(assets) {
|
|
const live = liveAssets(assets);
|
|
const shown = live.filter((asset) => asset.model.show).length;
|
|
toggleScene.checked = shown > 0;
|
|
toggleScene.indeterminate = shown > 0 && shown < live.length;
|
|
}
|
|
|
|
function buildSemanticToggles(viewer, assets, placement) {
|
|
for (const asset of semanticAssets(assets)) {
|
|
const label = document.createElement("label");
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.checked = false;
|
|
input.dataset.assetId = asset.id;
|
|
input.addEventListener("change", async () => {
|
|
if (input.checked) {
|
|
const model = await loadAsset(viewer, asset, placement);
|
|
if (!model) {
|
|
input.checked = false;
|
|
input.disabled = true;
|
|
setStatus(asset.label + " unavailable");
|
|
return;
|
|
}
|
|
model.show = true;
|
|
} else if (asset.model) {
|
|
asset.model.show = false;
|
|
}
|
|
setStatus(asset.label + (input.checked ? " visible" : " hidden"));
|
|
});
|
|
label.appendChild(input);
|
|
label.appendChild(document.createTextNode(" " + asset.label));
|
|
semanticToggles.appendChild(label);
|
|
asset.toggle = input;
|
|
}
|
|
}
|
|
|
|
function bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals) {
|
|
const hasVehicles = cruise.vehicles.length > 0;
|
|
const hasSemanticAssets = semanticAssets(assets).length > 0;
|
|
const sceneLabel = toggleScene.closest("label");
|
|
let viewMode = "scene";
|
|
let buildingGhostActive = false;
|
|
|
|
async function setBuildingGhost(enabled) {
|
|
const roads = assets.find((asset) => asset.id === "roads");
|
|
const buildings = assets.find((asset) => asset.id === "buildings");
|
|
const props = assets.find((asset) => asset.id === "vegetation");
|
|
const main = assets.find((asset) => asset.id === "main");
|
|
if (!roads || !buildings || !props || !main) {
|
|
toggleBuildingGhost.checked = false;
|
|
toggleBuildingGhost.disabled = true;
|
|
return;
|
|
}
|
|
if (enabled) {
|
|
const loaded = await Promise.all([
|
|
loadAsset(viewer, roads, placement),
|
|
loadAsset(viewer, buildings, placement),
|
|
loadAsset(viewer, props, placement),
|
|
]);
|
|
if (!loaded[0] || !loaded[1] || !loaded[2]) {
|
|
toggleBuildingGhost.checked = false;
|
|
setStatus("Building transparency unavailable");
|
|
return;
|
|
}
|
|
main.model.show = false;
|
|
roads.model.show = true;
|
|
buildings.model.show = true;
|
|
// The semantic vegetation asset also owns the static traffic-signal
|
|
// poles/housings from the 05_Props collection.
|
|
props.model.show = true;
|
|
buildings.model.color = Cesium.Color.WHITE.withAlpha(0.22);
|
|
buildings.model.colorBlendMode = Cesium.ColorBlendMode.REPLACE;
|
|
buildings.model.colorBlendAmount = 1.0;
|
|
for (const asset of liveAssets(assets)) {
|
|
if (asset.id !== "main") asset.model.show = asset.category === "dynamic" || asset.category === "countdown"
|
|
? toggleSignals.checked : asset.model.show;
|
|
}
|
|
trafficSignals.show = toggleSignals.checked;
|
|
buildingGhostActive = true;
|
|
setStatus("Buildings transparent");
|
|
} else {
|
|
for (const asset of semanticAssets(assets)) {
|
|
if (asset.model) asset.model.show = false;
|
|
}
|
|
main.model.show = toggleScene.checked;
|
|
for (const asset of liveAssets(assets)) {
|
|
if (asset.id !== "main") asset.model.show = toggleSignals.checked;
|
|
}
|
|
buildingGhostActive = false;
|
|
setStatus("Buildings opaque");
|
|
}
|
|
}
|
|
|
|
toggleScene.addEventListener("change", () => {
|
|
if (buildingGhostActive) return;
|
|
for (const asset of liveAssets(assets)) {
|
|
asset.model.show = toggleScene.checked;
|
|
if (asset.toggle) asset.toggle.checked = toggleScene.checked;
|
|
}
|
|
toggleScene.indeterminate = false;
|
|
setStatus(toggleScene.checked ? "Scene visible" : "Scene hidden");
|
|
});
|
|
toggleRoutes.addEventListener("change", () => {
|
|
syncSelectedRouteVisibility(cruise);
|
|
});
|
|
toggleVehicles.addEventListener("change", () => {
|
|
for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked;
|
|
});
|
|
if (!trafficSignals.count) {
|
|
signalsControl.classList.add("hidden");
|
|
} else {
|
|
toggleSignals.addEventListener("change", () => {
|
|
trafficSignals.show = toggleSignals.checked;
|
|
setStatus(toggleSignals.checked ? "Signals visible" : "Signals hidden");
|
|
});
|
|
}
|
|
toggleFps.addEventListener("change", () => {
|
|
viewer.scene.debugShowFramesPerSecond = toggleFps.checked;
|
|
});
|
|
toggleDiagnostics.addEventListener("change", () => {
|
|
diagnosticsEl.classList.toggle("hidden", !toggleDiagnostics.checked);
|
|
});
|
|
toggleBuildingGhost.addEventListener("change", () => {
|
|
void setBuildingGhost(toggleBuildingGhost.checked);
|
|
});
|
|
if (!semanticAssets(assets).some((asset) => asset.id === "roads") ||
|
|
!semanticAssets(assets).some((asset) => asset.id === "buildings") ||
|
|
!semanticAssets(assets).some((asset) => asset.id === "vegetation")) {
|
|
toggleBuildingGhost.disabled = true;
|
|
}
|
|
|
|
async function setViewMode(nextMode) {
|
|
if (nextMode === viewMode) return;
|
|
viewMode = nextMode;
|
|
const inspecting = viewMode === "inspect";
|
|
for (const button of viewModeButtons) {
|
|
button.setAttribute("aria-pressed", String(button.dataset.viewMode === viewMode));
|
|
}
|
|
semanticToggles.classList.toggle("hidden", !inspecting);
|
|
assetToggles.classList.toggle("hidden", inspecting);
|
|
if (sceneLabel) sceneLabel.classList.toggle("hidden", inspecting);
|
|
|
|
if (!inspecting) {
|
|
if (buildingGhostActive) {
|
|
toggleBuildingGhost.checked = false;
|
|
await setBuildingGhost(false);
|
|
}
|
|
for (const asset of semanticAssets(assets)) {
|
|
if (asset.model) asset.model.show = false;
|
|
}
|
|
for (const asset of liveAssets(assets)) asset.model.show = toggleScene.checked;
|
|
setStatus("Scene view");
|
|
return;
|
|
}
|
|
|
|
for (const asset of liveAssets(assets)) asset.model.show = false;
|
|
await Promise.all(semanticAssets(assets).map(async (asset) => {
|
|
const model = await loadAsset(viewer, asset, placement);
|
|
if (!model) {
|
|
if (asset.toggle) {
|
|
asset.toggle.checked = false;
|
|
asset.toggle.disabled = true;
|
|
}
|
|
return;
|
|
}
|
|
model.show = true;
|
|
if (asset.toggle) asset.toggle.checked = true;
|
|
}));
|
|
if (viewMode !== "inspect") {
|
|
for (const asset of semanticAssets(assets)) {
|
|
if (asset.model) asset.model.show = false;
|
|
}
|
|
return;
|
|
}
|
|
setStatus("Inspection view");
|
|
}
|
|
|
|
for (const button of viewModeButtons) {
|
|
if (button.dataset.viewMode === "inspect" && !hasSemanticAssets) {
|
|
button.disabled = true;
|
|
continue;
|
|
}
|
|
button.addEventListener("click", () => {
|
|
void setViewMode(button.dataset.viewMode);
|
|
});
|
|
}
|
|
|
|
let stopFollow = function () {};
|
|
if (hasVehicles) {
|
|
stopFollow = bindCruiseControls(viewer, cruise);
|
|
} else {
|
|
// Nothing to drive: disable the cruise half of the panel rather than
|
|
// leaving controls that silently do nothing.
|
|
for (const el of [toggleCruise, toggleFollow, vehicleSelect, speedControl, toggleRoutes, toggleVehicles]) {
|
|
el.disabled = true;
|
|
}
|
|
toggleCruise.textContent = "Play";
|
|
}
|
|
|
|
for (const button of cameraButtons) {
|
|
const preset = cameras[button.dataset.camera];
|
|
if (!preset || (button.dataset.camera === "route" && !hasVehicles)) {
|
|
button.disabled = true;
|
|
continue;
|
|
}
|
|
button.addEventListener("click", () => {
|
|
// Chase-follow reclaims the camera on every clock tick, so a preset
|
|
// applied underneath it would be overwritten before the next frame.
|
|
stopFollow();
|
|
preset();
|
|
});
|
|
}
|
|
}
|
|
|
|
function bindCruiseControls(viewer, cruise) {
|
|
// The slider is authored with a static default; the route file decides the
|
|
// real cruise speed, so adopt it before the first input event.
|
|
speedControl.value = String(Cesium.Math.clamp(
|
|
Math.round(cruise.baseSpeed),
|
|
Number(speedControl.min),
|
|
Number(speedControl.max)
|
|
));
|
|
viewer.clock.multiplier = Number(speedControl.value) / cruise.baseSpeed;
|
|
speedLabel.textContent = speedControl.value + " m/s";
|
|
|
|
toggleCruise.addEventListener("click", () => {
|
|
viewer.clock.shouldAnimate = !viewer.clock.shouldAnimate;
|
|
toggleCruise.textContent = viewer.clock.shouldAnimate ? "Pause" : "Play";
|
|
});
|
|
speedControl.addEventListener("input", () => {
|
|
const value = Number(speedControl.value);
|
|
viewer.clock.multiplier = value / cruise.baseSpeed;
|
|
speedLabel.textContent = value + " m/s";
|
|
});
|
|
vehicleSelect.addEventListener("change", () => {
|
|
cruise.state.selectedIndex = Number(vehicleSelect.value || 0);
|
|
syncSelectedRouteVisibility(cruise);
|
|
setStatus(selectedVehicle(cruise).label);
|
|
});
|
|
|
|
const follow = createChaseFollow(viewer, () => selectedVehicle(cruise).positions);
|
|
function stopFollow() {
|
|
if (!follow.enabled) return;
|
|
follow.stop();
|
|
toggleFollow.textContent = "Follow";
|
|
}
|
|
toggleFollow.addEventListener("click", () => {
|
|
if (follow.enabled) {
|
|
stopFollow();
|
|
setStatus(baseStatus);
|
|
} else {
|
|
follow.start();
|
|
toggleFollow.textContent = "Free";
|
|
setStatus("Following " + selectedVehicle(cruise).label);
|
|
}
|
|
});
|
|
return stopFollow;
|
|
}
|
|
|
|
function addVehicleCruises(viewer, routeData, signalData, trafficStart, vehicleModelNames, fallbackVehicleModelName) {
|
|
const segments = ((routeData && (routeData.routes || routeData.segments)) || [])
|
|
.filter((segment) => segment.coordinates && segment.coordinates.length >= 2)
|
|
.slice(0, 5);
|
|
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
|
|
const start = trafficStart;
|
|
const assignments = [];
|
|
segments.forEach((segment, routeIndex) => {
|
|
// Put two vehicles on the first route so the native preview visibly
|
|
// exercises leader following and queue formation.
|
|
const vehicleCount = routeIndex === 0 ? 2 : 1;
|
|
for (let vehicleIndex = 0; vehicleIndex < vehicleCount; vehicleIndex += 1) {
|
|
assignments.push({ segment, routeIndex, vehicleIndex });
|
|
}
|
|
});
|
|
const simulation = createTrafficSimulation(viewer, assignments, start, speed, signalData, routeData?.settings);
|
|
|
|
viewer.clock.startTime = start.clone();
|
|
viewer.clock.currentTime = start.clone();
|
|
viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED;
|
|
viewer.clock.multiplier = 1;
|
|
viewer.clock.shouldAnimate = assignments.length > 0;
|
|
|
|
const vehicles = assignments.map((assignment, index) => {
|
|
const { segment } = assignment;
|
|
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, signalData,
|
|
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName), simulation.motions[index]);
|
|
const option = document.createElement("option");
|
|
option.value = String(index);
|
|
option.textContent = routeLabel(segment, index);
|
|
vehicleSelect.appendChild(option);
|
|
return vehicle;
|
|
});
|
|
const cruise = {
|
|
vehicles,
|
|
baseSpeed: speed,
|
|
state: { selectedIndex: 0 },
|
|
simulation,
|
|
usingLiveData: false,
|
|
};
|
|
syncSelectedRouteVisibility(cruise);
|
|
return cruise;
|
|
}
|
|
|
|
function syncSelectedRouteVisibility(cruise) {
|
|
for (let index = 0; index < cruise.vehicles.length; index += 1) {
|
|
cruise.vehicles[index].routeEntity.show = !cruise.usingLiveData && toggleRoutes.checked && index === cruise.state.selectedIndex;
|
|
}
|
|
}
|
|
|
|
function createLiveVehicleState() {
|
|
return {
|
|
vehicles: [],
|
|
baseSpeed: 0,
|
|
state: { selectedIndex: 0 },
|
|
simulation: null,
|
|
usingLiveData: true,
|
|
};
|
|
}
|
|
|
|
function hideUnconfirmedSignalAssets(assets) {
|
|
assets.filter((asset) => asset.category === "dynamic" || asset.category === "countdown")
|
|
.forEach((asset) => { if (asset.model) asset.model.show = false; });
|
|
}
|
|
|
|
// Driven entirely by live V2X lamp state. The overlay has already resolved
|
|
// phaseNo -> native node keys and normalized the lamp status, so this only
|
|
// paints; it never re-interprets status codes.
|
|
function createLiveTrafficSignals(signalData, assets) {
|
|
const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model);
|
|
const countdownModels = new Map(assets
|
|
.filter((asset) => asset.category === "countdown" && asset.model)
|
|
.map((asset) => [Number(asset.phaseGroup), asset.model]));
|
|
const signals = (signalData?.signals || []).filter((signal) => signal && signal.id);
|
|
const phaseGroupByNodeKey = new Map(signals.map((signal) => [String(signal.nodeKey || signal.id), Number(signal.phaseGroup)]));
|
|
const state = { live: false };
|
|
|
|
function paintLamp(nodeKey, color) {
|
|
let changed = false;
|
|
["red", "yellow", "green"].forEach((lamp) => {
|
|
let node = null;
|
|
try { node = dynamic.model.getNode(`TrafficSignalDynamic_${nodeKey}_${lamp}`); } catch (_) { return; }
|
|
if (node && node.show !== (lamp === color)) { node.show = lamp === color; changed = true; }
|
|
});
|
|
return changed;
|
|
}
|
|
|
|
function paintCountdown(nodeKey, countDown, color) {
|
|
const model = countdownModels.get(phaseGroupByNodeKey.get(String(nodeKey)));
|
|
if (!model) return false;
|
|
// Only two digits are modelled; anything outside 00-19 shows nothing.
|
|
const visible = Number.isFinite(countDown) && countDown >= 0 && countDown < 20
|
|
? String(countDown).padStart(2, "0")
|
|
: null;
|
|
let changed = false;
|
|
for (let value = 0; value < 20; value += 1) {
|
|
const label = String(value).padStart(2, "0");
|
|
let node = null;
|
|
try { node = model.getNode(`TrafficSignalDynamic_${nodeKey}_countdown_${label}`); } catch (_) { continue; }
|
|
if (node && node.show !== (label === visible)) { node.show = label === visible; changed = true; }
|
|
}
|
|
if (visible !== null && typeof signalBaseColor === "function") {
|
|
model.color = signalBaseColor(color);
|
|
model.colorBlendMode = Cesium.ColorBlendMode.REPLACE;
|
|
model.colorBlendAmount = 1.0;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function update(entries) {
|
|
if (!dynamic?.model) return;
|
|
const list = Array.isArray(entries) ? entries : [];
|
|
let changed = false;
|
|
let painted = 0;
|
|
list.forEach((entry) => {
|
|
const nodeKeys = Array.isArray(entry?.nodeKeys) ? entry.nodeKeys : [];
|
|
nodeKeys.forEach((nodeKey) => {
|
|
painted += 1;
|
|
if (paintLamp(nodeKey, entry.color)) changed = true;
|
|
if (paintCountdown(nodeKey, entry.countDown, entry.color)) changed = true;
|
|
});
|
|
});
|
|
// Only show the dynamic assembly once a lamp actually resolved to a head.
|
|
state.live = painted > 0;
|
|
dynamic.model.show = state.live;
|
|
countdownModels.forEach((model) => { model.show = state.live; });
|
|
if (changed) dynamic.model.scene?.requestRender?.();
|
|
}
|
|
|
|
return { count: signals.length, state, update, set show(value) { if (dynamic?.model) dynamic.model.show = Boolean(value && state.live); } };
|
|
}
|
|
|
|
function addTrafficSignals(viewer, signalData, start, assets) {
|
|
const dynamic = assets.find((asset) => asset.category === "dynamic" && asset.model);
|
|
const countdownModels = new Map(assets
|
|
.filter((asset) => asset.category === "countdown" && asset.model)
|
|
.map((asset) => [Number(asset.phaseGroup), asset.model]));
|
|
if (dynamic && countdownModels.size === 2) {
|
|
const signals = (signalData?.signals || []).filter((signal) => signal && signal.id);
|
|
if (signals.length && !viewer.clock.shouldAnimate) viewer.clock.shouldAnimate = true;
|
|
const visualStart = performance.now();
|
|
const phaseTime = new Cesium.JulianDate();
|
|
const state = { elapsedSeconds: 0, phase: "" };
|
|
const entities = [];
|
|
const nodes = new Map();
|
|
const countdownNodes = new WeakMap();
|
|
const node = (name) => {
|
|
if (nodes.has(name)) return nodes.get(name);
|
|
let value = null;
|
|
try {
|
|
value = dynamic.model.getNode(name);
|
|
} catch (error) {
|
|
console.warn("Traffic signal node unavailable:", name, error);
|
|
}
|
|
// Do not cache a miss. Cesium can expose the Model before its node
|
|
// lookup table is populated; a transient miss must be retried on the
|
|
// next clock tick rather than freezing the initial visual state.
|
|
if (value) nodes.set(name, value);
|
|
return value;
|
|
};
|
|
const countdownNode = (model, name) => {
|
|
let modelNodes = countdownNodes.get(model);
|
|
if (!modelNodes) {
|
|
modelNodes = new Map();
|
|
countdownNodes.set(model, modelNodes);
|
|
}
|
|
if (modelNodes.has(name)) return modelNodes.get(name);
|
|
let value = null;
|
|
try { value = model.getNode(name); } catch (error) { /* model node table is still loading */ }
|
|
if (value) modelNodes.set(name, value);
|
|
return value;
|
|
};
|
|
const update = (elapsedSeconds) => {
|
|
Cesium.JulianDate.addSeconds(start, elapsedSeconds, phaseTime);
|
|
let changed = false;
|
|
const groupPhases = new Map();
|
|
for (const signal of signals) {
|
|
const nodeKey = signal.nodeKey || signal.id;
|
|
const phase = signalPhase(signal.phaseGroup, phaseTime, start);
|
|
groupPhases.set(signal.phaseGroup, phase.active);
|
|
if (signal === signals[0]) state.phase = `${phase.active} ${String(phase.remaining).padStart(2, "0")}`;
|
|
for (const state of ["red", "yellow", "green"]) {
|
|
const value = node(`TrafficSignalDynamic_${nodeKey}_${state}`);
|
|
if (value && value.show !== (state === phase.active)) {
|
|
value.show = state === phase.active;
|
|
changed = true;
|
|
}
|
|
}
|
|
const visibleCountdown = String(phase.remaining).padStart(2, "0");
|
|
const countdownModel = countdownModels.get(Number(signal.phaseGroup));
|
|
for (let value = 0; value < 20; value += 1) {
|
|
const name = `TrafficSignalDynamic_${nodeKey}_countdown_${String(value).padStart(2, "0")}`;
|
|
let countdown = null;
|
|
countdown = countdownNode(countdownModel, name);
|
|
if (countdown && countdown.show !== (String(value).padStart(2, "0") === visibleCountdown)) {
|
|
countdown.show = String(value).padStart(2, "0") === visibleCountdown;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
for (const [group, active] of groupPhases) {
|
|
const countdownModel = countdownModels.get(Number(group));
|
|
countdownModel.color = signalBaseColor(active);
|
|
countdownModel.colorBlendMode = Cesium.ColorBlendMode.REPLACE;
|
|
countdownModel.colorBlendAmount = 1.0;
|
|
}
|
|
if (changed && viewer.scene.requestRender) viewer.scene.requestRender();
|
|
};
|
|
let lastSecond = -1;
|
|
const render = () => {
|
|
const elapsedSeconds = Math.floor((performance.now() - visualStart) / 1000);
|
|
if (elapsedSeconds === lastSecond) return;
|
|
lastSecond = elapsedSeconds;
|
|
state.elapsedSeconds = elapsedSeconds;
|
|
update(elapsedSeconds);
|
|
};
|
|
// Keep signal phases independent from the Cesium simulation clock. The
|
|
// clock may be paused while a user inspects the scene, but the lights and
|
|
// countdown must remain visibly periodic.
|
|
const timer = setInterval(render, 250);
|
|
render();
|
|
// Countdown GLBs may expose their node table a few frames after the
|
|
// model object exists. Re-apply the initial state once both models are
|
|
// ready so every hidden digit is explicitly hidden before the first
|
|
// user-visible frame.
|
|
for (const model of countdownModels.values()) {
|
|
if (model.readyPromise) model.readyPromise.then(() => update(0)).catch(() => {});
|
|
}
|
|
return {
|
|
entities, count: signals.length, dynamic, state, timer,
|
|
set show(value) {
|
|
dynamic.model.show = value;
|
|
for (const model of countdownModels.values()) model.show = value;
|
|
for (const entity of entities) entity.show = value;
|
|
}
|
|
};
|
|
}
|
|
return { entities: [], count: 0, set show(value) {} };
|
|
}
|
|
|
|
function signalColor(group, state, time, start) {
|
|
const active = signalPhase(group, time, start).active;
|
|
const color = signalBaseColor(state);
|
|
return state === active ? color : Cesium.Color.multiplyByScalar(color, 0.35, new Cesium.Color());
|
|
}
|
|
|
|
function signalActiveColor(group, time, start) {
|
|
return signalBaseColor(signalPhase(group, time, start).active);
|
|
}
|
|
|
|
function signalBaseColor(state) {
|
|
return Cesium.Color.fromCssColorString({ red: "#ee3f39", yellow: "#f7bf37", green: "#43cf71" }[state]);
|
|
}
|
|
|
|
function signalPhase(group, time, start) {
|
|
return signalPhaseAtElapsed(group, Cesium.JulianDate.secondsDifference(time, start));
|
|
}
|
|
|
|
function signalPhaseAtElapsed(group, elapsed) {
|
|
const second = ((elapsed % 20) + 20) % 20;
|
|
if (group === 0) {
|
|
if (second < 8) return { active: "green", remaining: Math.ceil(8 - second) };
|
|
if (second < 10) return { active: "yellow", remaining: Math.ceil(10 - second) };
|
|
return { active: "red", remaining: Math.ceil(20 - second) };
|
|
}
|
|
if (second < 10) return { active: "red", remaining: Math.ceil(10 - second) };
|
|
if (second < 18) return { active: "green", remaining: Math.ceil(18 - second) };
|
|
return { active: "yellow", remaining: Math.ceil(20 - second) };
|
|
}
|
|
|
|
function selectedVehicleModelName(modelNames, fallbackModelName) {
|
|
const choices = Array.isArray(modelNames) && modelNames.length
|
|
? modelNames
|
|
: [fallbackModelName];
|
|
const usable = choices.filter(Boolean);
|
|
return usable[Math.floor(Math.random() * usable.length)] || "";
|
|
}
|
|
|
|
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName, trafficMotion) {
|
|
const route = trafficMotion.route;
|
|
let record = null;
|
|
trafficMotion.setMoving(() => record?.status === "normal");
|
|
const positions = trafficMotion.positions;
|
|
const flat = [];
|
|
for (const coord of segment.coordinates) {
|
|
flat.push(coord[0], coord[1], ROUTE_LINE_HEIGHT_METERS);
|
|
}
|
|
const routeColor = [
|
|
Cesium.Color.CYAN,
|
|
Cesium.Color.LIME,
|
|
Cesium.Color.YELLOW,
|
|
Cesium.Color.ORANGE,
|
|
Cesium.Color.DEEPSKYBLUE
|
|
][index % 5];
|
|
const routeEntity = viewer.entities.add({
|
|
name: "Cruise route " + (index + 1),
|
|
polyline: {
|
|
positions: Cesium.Cartesian3.fromDegreesArrayHeights(flat),
|
|
width: 2,
|
|
material: routeColor,
|
|
arcType: Cesium.ArcType.NONE,
|
|
clampToGround: false
|
|
}
|
|
});
|
|
const vehicle = viewer.entities.add({
|
|
name: "Cruise vehicle " + (index + 1),
|
|
position: positions,
|
|
orientation: routeOrientationFromState(route, trafficMotion.state, speed, 0.0),
|
|
model: {
|
|
uri: vehicleModelName,
|
|
// Keep the library's world scale visible. A minimum screen size made
|
|
// long trucks balloon in overview and at intersections.
|
|
scale: 0.72,
|
|
minimumPixelSize: 0
|
|
}
|
|
});
|
|
record = {
|
|
id: "vehicle-" + (index + 1),
|
|
entity: vehicle,
|
|
routeEntity,
|
|
positions,
|
|
route,
|
|
segment,
|
|
label: routeLabel(segment, index),
|
|
modelName: vehicleModelLabel(vehicleModelName),
|
|
status: "normal",
|
|
incidentNote: "",
|
|
motion: trafficMotion,
|
|
routeColor,
|
|
markerEntity: null,
|
|
};
|
|
vehicle.properties = new Cesium.PropertyBag({ vehicleId: record.id });
|
|
record.markerEntity = viewer.entities.add({
|
|
name: "Vehicle incident marker " + (index + 1),
|
|
position: positions,
|
|
billboard: {
|
|
image: "",
|
|
show: false,
|
|
width: 36,
|
|
height: 44,
|
|
pixelOffset: new Cesium.Cartesian2(0, -40),
|
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
},
|
|
});
|
|
return record;
|
|
}
|
|
|
|
function vehicleModelLabel(name) {
|
|
const file = String(name || "").split("/").pop().replace(/\.gltf$/i, "");
|
|
return file.replace(/^.*-vehicle-/, "").replaceAll("_", " ");
|
|
}
|
|
|
|
function routeLabel(route, index) {
|
|
const counts = { left: 0, right: 0, through: 0 };
|
|
for (const maneuver of route.maneuvers || []) {
|
|
if (maneuver in counts) counts[maneuver] += 1;
|
|
}
|
|
const actions = [
|
|
counts.left ? "左 " + counts.left : "",
|
|
counts.right ? "右 " + counts.right : "",
|
|
counts.through ? "直 " + counts.through : "",
|
|
].filter(Boolean).join(" / ");
|
|
return "#" + (index + 1) + " · " + Math.round(route.lengthMeters || 0) + " m" +
|
|
(actions ? " · " + actions : "");
|
|
}
|
|
|
|
function selectedVehicle(cruise) {
|
|
return cruise.vehicles[cruise.state.selectedIndex] || cruise.vehicles[0];
|
|
}
|
|
|
|
function vehicleById(cruise, id) {
|
|
return cruise.vehicles.find((vehicle) => vehicle.id === id) || null;
|
|
}
|
|
|
|
function setVehicleStatus(viewer, vehicle, status) {
|
|
if (!vehicle || !["normal", "breakdown", "accident"].includes(status)) return;
|
|
vehicle.status = status;
|
|
const incident = status !== "normal";
|
|
const accident = status === "accident";
|
|
vehicle.markerEntity.billboard.image = status === "breakdown"
|
|
? "vehicle-breakdown.png"
|
|
: accident ? "vehicle-accident.png" : "";
|
|
vehicle.markerEntity.billboard.show = incident;
|
|
vehicle.routeEntity.polyline.material = accident ? Cesium.Color.RED : vehicle.routeColor;
|
|
if (viewer.scene.requestRender) viewer.scene.requestRender();
|
|
}
|
|
|
|
function bindVehicleInfoCard(viewer, cruise) {
|
|
if (!vehicleInfoCard || !cruise.vehicles.length) return;
|
|
let activeVehicle = null;
|
|
const render = () => {
|
|
if (!activeVehicle) return;
|
|
vehicleInfoTitle.textContent = activeVehicle.label;
|
|
vehicleIncidentNote.value = activeVehicle.incidentNote;
|
|
const details = [
|
|
["Vehicle", activeVehicle.id],
|
|
["Model", activeVehicle.modelName],
|
|
["Route", activeVehicle.label],
|
|
["Speed", cruise.baseSpeed + " m/s"],
|
|
["Status", activeVehicle.status],
|
|
];
|
|
vehicleInfoDetails.replaceChildren(...details.flatMap(([term, value]) => {
|
|
const dt = document.createElement("dt");
|
|
const dd = document.createElement("dd");
|
|
dt.textContent = term;
|
|
dd.textContent = value;
|
|
return [dt, dd];
|
|
}));
|
|
for (const button of vehicleStatusButtons) {
|
|
button.disabled = button.dataset.vehicleStatus === activeVehicle.status;
|
|
}
|
|
};
|
|
const open = (vehicle) => {
|
|
activeVehicle = vehicle;
|
|
const index = cruise.vehicles.indexOf(vehicle);
|
|
cruise.state.selectedIndex = index;
|
|
vehicleSelect.value = String(index);
|
|
syncSelectedRouteVisibility(cruise);
|
|
vehicleInfoCard.classList.remove("hidden");
|
|
render();
|
|
};
|
|
const positionCard = () => {
|
|
if (!activeVehicle || vehicleInfoCard.classList.contains("hidden")) return;
|
|
const position = activeVehicle.entity.position?.getValue(viewer.clock.currentTime);
|
|
const windowPosition = position && Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, position);
|
|
if (!windowPosition) {
|
|
vehicleInfoCard.classList.add("hidden");
|
|
return;
|
|
}
|
|
const margin = 12;
|
|
const width = vehicleInfoCard.offsetWidth || 300;
|
|
const x = Cesium.Math.clamp(windowPosition.x, margin + width / 2, window.innerWidth - margin - width / 2);
|
|
vehicleInfoCard.style.left = x + "px";
|
|
vehicleInfoCard.style.top = Math.max(windowPosition.y, margin + 20) + "px";
|
|
};
|
|
closeVehicleInfo.addEventListener("click", () => {
|
|
activeVehicle = null;
|
|
vehicleInfoCard.classList.add("hidden");
|
|
});
|
|
vehicleIncidentNote.addEventListener("input", () => {
|
|
if (activeVehicle) activeVehicle.incidentNote = vehicleIncidentNote.value.trim();
|
|
});
|
|
for (const button of vehicleStatusButtons) {
|
|
button.addEventListener("click", () => {
|
|
if (!activeVehicle) return;
|
|
setVehicleStatus(viewer, activeVehicle, button.dataset.vehicleStatus);
|
|
render();
|
|
setStatus(activeVehicle.label + " " + activeVehicle.status);
|
|
});
|
|
}
|
|
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
|
handler.setInputAction((movement) => {
|
|
const picked = viewer.scene.pick(movement.position);
|
|
const id = picked?.id?.properties?.vehicleId?.getValue?.();
|
|
const vehicle = vehicleById(cruise, id);
|
|
if (vehicle) open(vehicle);
|
|
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
viewer.scene.postRender.addEventListener(positionCard);
|
|
}
|
|
|
|
function prepareRoute(segment, signalData) {
|
|
const distances = [0.0];
|
|
for (let i = 1; i < segment.coordinates.length; i += 1) {
|
|
distances.push(distances[i - 1] + distanceMeters(segment.coordinates[i - 1], segment.coordinates[i]));
|
|
}
|
|
return {
|
|
coordinates: segment.coordinates,
|
|
distances,
|
|
length: Math.max(1.0, distances[distances.length - 1]),
|
|
stops: routeStops(segment.coordinates, distances, signalData),
|
|
};
|
|
}
|
|
|
|
function routeStops(coordinates, distances, signalData) {
|
|
const found = new Map();
|
|
for (const signal of signalData?.signals || []) {
|
|
if (!Number.isFinite(signal.stopLongitude) || !Number.isFinite(signal.stopLatitude)) continue;
|
|
let best = { index: -1, distance: Infinity };
|
|
for (let index = 0; index < coordinates.length; index += 1) {
|
|
const distance = distanceMeters(coordinates[index], [signal.stopLongitude, signal.stopLatitude]);
|
|
if (distance < best.distance) best = { index, distance };
|
|
}
|
|
if (best.index >= 0 && best.distance <= 7) {
|
|
const routeDistance = distances[best.index];
|
|
const existing = found.get(Math.round(routeDistance));
|
|
if (!existing || best.distance < existing.matchDistance) found.set(Math.round(routeDistance), { distance: routeDistance, matchDistance: best.distance, signal });
|
|
}
|
|
}
|
|
return [...found.values()].sort((a, b) => a.distance - b.distance);
|
|
}
|
|
|
|
function createTrafficSimulation(viewer, assignments, start, speed, signalData, settings = {}) {
|
|
const config = {
|
|
desiredSpeedMetersPerSecond: Number(settings.desiredSpeedMetersPerSecond || speed),
|
|
accelerationMetersPerSecondSquared: Number(settings.accelerationMetersPerSecondSquared || 1.8),
|
|
decelerationMetersPerSecondSquared: Number(settings.decelerationMetersPerSecondSquared || 3.5),
|
|
reactionTimeSeconds: Number(settings.reactionTimeSeconds || 0.8),
|
|
vehicleLengthMeters: Number(settings.vehicleLengthMeters || 4.6),
|
|
minimumGapMeters: Number(settings.minimumGapMeters || 7),
|
|
};
|
|
const motions = assignments.map((assignment) => {
|
|
const route = prepareRoute(assignment.segment, signalData);
|
|
const initialGap = config.vehicleLengthMeters + config.minimumGapMeters;
|
|
const state = {
|
|
distance: assignment.vehicleIndex * -initialGap,
|
|
speed: 0,
|
|
targetSpeed: config.desiredSpeedMetersPerSecond,
|
|
stopReason: null,
|
|
queueAhead: 0,
|
|
lastTime: start.clone(),
|
|
};
|
|
state.distance = (state.distance % route.length + route.length) % route.length;
|
|
return {
|
|
route,
|
|
state,
|
|
setMoving(callback) { state.isMoving = callback; },
|
|
positions: new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false),
|
|
};
|
|
});
|
|
const update = (clock) => {
|
|
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, motions[0]?.state.lastTime || start);
|
|
if (!(elapsed > 0) || elapsed > 2) {
|
|
for (const motion of motions) Cesium.JulianDate.clone(clock.currentTime, motion.state.lastTime);
|
|
return;
|
|
}
|
|
const grouped = new Map();
|
|
assignments.forEach((assignment, index) => {
|
|
const key = assignment.routeIndex;
|
|
if (!grouped.has(key)) grouped.set(key, []);
|
|
grouped.get(key).push(index);
|
|
});
|
|
for (const indexes of grouped.values()) {
|
|
indexes.sort((a, b) => motions[a].state.distance - motions[b].state.distance);
|
|
}
|
|
motions.forEach((motion, index) => {
|
|
const state = motion.state;
|
|
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
|
|
if (state.isMoving && !state.isMoving()) {
|
|
state.speed = 0;
|
|
state.stopReason = "incident";
|
|
return;
|
|
}
|
|
const route = motion.route;
|
|
let target = config.desiredSpeedMetersPerSecond;
|
|
let stopReason = null;
|
|
const nextStop = nextRouteStop(route, state.distance);
|
|
if (nextStop) {
|
|
const phase = signalPhase(nextStop.signal.phaseGroup, clock.currentTime, start).active;
|
|
const untilStop = (nextStop.distance - state.distance + route.length) % route.length;
|
|
if (phase !== "green" && untilStop < Math.max(30, state.speed * config.reactionTimeSeconds + 8)) {
|
|
target = Math.min(target, Math.max(0, (untilStop - 1.7) / Math.max(config.reactionTimeSeconds, 0.1)));
|
|
stopReason = "traffic-signal";
|
|
}
|
|
}
|
|
const peers = grouped.get(assignments[index].routeIndex) || [];
|
|
const peerPosition = peers.indexOf(index);
|
|
if (peerPosition >= 0 && peers.length > 1) {
|
|
const leaderIndex = peers[(peerPosition + 1) % peers.length];
|
|
if (leaderIndex !== index) {
|
|
const leader = motions[leaderIndex].state;
|
|
const gap = (leader.distance - state.distance + route.length) % route.length - config.vehicleLengthMeters;
|
|
const safeGap = config.minimumGapMeters + state.speed * config.reactionTimeSeconds;
|
|
if (gap < safeGap + 12) {
|
|
target = Math.min(target, Math.max(0, (gap - config.minimumGapMeters) / Math.max(config.reactionTimeSeconds, 0.1)));
|
|
if (target < 0.2) stopReason = "leader-gap";
|
|
state.queueAhead = leader.stopReason ? 1 : 0;
|
|
} else {
|
|
state.queueAhead = 0;
|
|
}
|
|
}
|
|
}
|
|
state.targetSpeed = target;
|
|
const limit = (target >= state.speed ? config.accelerationMetersPerSecondSquared : config.decelerationMetersPerSecondSquared) * elapsed;
|
|
state.speed += Math.sign(target - state.speed) * Math.min(Math.abs(target - state.speed), limit);
|
|
state.distance = (state.distance + Math.max(0, state.speed) * elapsed) % route.length;
|
|
state.stopReason = state.speed < 0.2 ? stopReason : null;
|
|
});
|
|
};
|
|
viewer.clock.onTick.addEventListener(update);
|
|
return {
|
|
motions,
|
|
settings: config,
|
|
diagnostics() {
|
|
return {
|
|
stoppedVehicles: motions.filter((motion) => motion.state.stopReason).length,
|
|
queueLength: motions.filter((motion) => motion.state.stopReason === "leader-gap").length,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
function createTrafficAwarePositions(viewer, route, start, speed, isMoving = () => true) {
|
|
const state = { distance: 0, lastTime: start.clone() };
|
|
viewer.clock.onTick.addEventListener((clock) => {
|
|
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, state.lastTime);
|
|
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
|
|
if (elapsed <= 0) return;
|
|
if (!isMoving()) return;
|
|
const next = nextRouteStop(route, state.distance);
|
|
const advance = elapsed * speed;
|
|
if (next && signalPhase(next.signal.phaseGroup, clock.currentTime, start).active !== "green") {
|
|
const untilStop = (next.distance - state.distance + route.length) % route.length;
|
|
if (untilStop <= advance + 1.7) {
|
|
state.distance = (next.distance - 1.7 + route.length) % route.length;
|
|
return;
|
|
}
|
|
}
|
|
state.distance = (state.distance + advance) % route.length;
|
|
});
|
|
return {
|
|
state,
|
|
positions: new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false),
|
|
};
|
|
}
|
|
|
|
function nextRouteStop(route, distance) {
|
|
return route.stops.find((stop) => stop.distance > distance + 0.05) || route.stops[0] || null;
|
|
}
|
|
|
|
function routePosition(route, start, time, speed, result) {
|
|
const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start));
|
|
const distance = (seconds * speed) % route.length;
|
|
return routePositionAtDistance(route, distance, result);
|
|
}
|
|
|
|
function routePositionAtDistance(route, distance, result) {
|
|
let index = 1;
|
|
while (index < route.distances.length - 1 && route.distances[index] < distance) {
|
|
index += 1;
|
|
}
|
|
const prevDist = route.distances[index - 1];
|
|
const nextDist = route.distances[index];
|
|
const t = nextDist > prevDist ? (distance - prevDist) / (nextDist - prevDist) : 0;
|
|
const a = route.coordinates[index - 1];
|
|
const b = route.coordinates[index];
|
|
const lon = a[0] + (b[0] - a[0]) * t;
|
|
const lat = a[1] + (b[1] - a[1]) * t;
|
|
return Cesium.Cartesian3.fromDegrees(lon, lat, VEHICLE_HEIGHT_METERS, Cesium.Ellipsoid.WGS84, result);
|
|
}
|
|
|
|
function routeOrientation(route, start, speed, yawDegrees) {
|
|
const correction = Cesium.Quaternion.fromAxisAngle(
|
|
Cesium.Cartesian3.UNIT_Z,
|
|
Cesium.Math.toRadians(yawDegrees)
|
|
);
|
|
const current = new Cesium.Cartesian3();
|
|
const ahead = new Cesium.Cartesian3();
|
|
const direction = new Cesium.Cartesian3();
|
|
const up = new Cesium.Cartesian3();
|
|
const east = new Cesium.Cartesian3();
|
|
const north = new Cesium.Cartesian3();
|
|
const aheadTime = new Cesium.JulianDate();
|
|
const hpr = new Cesium.HeadingPitchRoll(0.0, 0.0, 0.0);
|
|
const base = new Cesium.Quaternion();
|
|
return new Cesium.CallbackProperty((time, result) => {
|
|
routePosition(route, start, time, speed, current);
|
|
Cesium.JulianDate.addSeconds(time, 0.8, aheadTime);
|
|
routePosition(route, start, aheadTime, speed, ahead);
|
|
Cesium.Cartesian3.subtract(ahead, current, direction);
|
|
if (Cesium.Cartesian3.magnitudeSquared(direction) < 0.0001) {
|
|
return result;
|
|
}
|
|
Cesium.Cartesian3.normalize(direction, direction);
|
|
Cesium.Cartesian3.normalize(current, up);
|
|
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, up, east);
|
|
if (Cesium.Cartesian3.magnitudeSquared(east) < 0.0001) {
|
|
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, east);
|
|
} else {
|
|
Cesium.Cartesian3.normalize(east, east);
|
|
}
|
|
Cesium.Cartesian3.cross(up, east, north);
|
|
Cesium.Cartesian3.normalize(north, north);
|
|
const eastComponent = Cesium.Cartesian3.dot(direction, east);
|
|
const northComponent = Cesium.Cartesian3.dot(direction, north);
|
|
hpr.heading = Math.atan2(eastComponent, northComponent);
|
|
Cesium.Transforms.headingPitchRollQuaternion(
|
|
current,
|
|
hpr,
|
|
undefined,
|
|
undefined,
|
|
base
|
|
);
|
|
return Cesium.Quaternion.multiply(base, correction, result || new Cesium.Quaternion());
|
|
}, false);
|
|
}
|
|
|
|
function routeOrientationFromState(route, state, speed, yawDegrees) {
|
|
const correction = Cesium.Quaternion.fromAxisAngle(
|
|
Cesium.Cartesian3.UNIT_Z,
|
|
Cesium.Math.toRadians(yawDegrees)
|
|
);
|
|
const current = new Cesium.Cartesian3();
|
|
const ahead = new Cesium.Cartesian3();
|
|
const direction = new Cesium.Cartesian3();
|
|
const up = new Cesium.Cartesian3();
|
|
const east = new Cesium.Cartesian3();
|
|
const north = new Cesium.Cartesian3();
|
|
const hpr = new Cesium.HeadingPitchRoll(0.0, 0.0, 0.0);
|
|
const base = new Cesium.Quaternion();
|
|
return new Cesium.CallbackProperty((time, result) => {
|
|
routePositionAtDistance(route, state.distance, current);
|
|
// Sample a small distance ahead, rather than using velocity. This keeps
|
|
// the car aligned while stopped and preserves the model's route heading.
|
|
const lookAhead = Math.max(0.8, speed * 0.8);
|
|
routePositionAtDistance(route, (state.distance + lookAhead) % route.length, ahead);
|
|
Cesium.Cartesian3.subtract(ahead, current, direction);
|
|
if (Cesium.Cartesian3.magnitudeSquared(direction) < 0.0001) return result;
|
|
Cesium.Cartesian3.normalize(direction, direction);
|
|
Cesium.Cartesian3.normalize(current, up);
|
|
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, up, east);
|
|
if (Cesium.Cartesian3.magnitudeSquared(east) < 0.0001) {
|
|
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, east);
|
|
} else {
|
|
Cesium.Cartesian3.normalize(east, east);
|
|
}
|
|
Cesium.Cartesian3.cross(up, east, north);
|
|
Cesium.Cartesian3.normalize(north, north);
|
|
hpr.heading = Math.atan2(Cesium.Cartesian3.dot(direction, east), Cesium.Cartesian3.dot(direction, north));
|
|
Cesium.Transforms.headingPitchRollQuaternion(current, hpr, undefined, undefined, base);
|
|
return Cesium.Quaternion.multiply(base, correction, result || new Cesium.Quaternion());
|
|
}, false);
|
|
}
|
|
|
|
function createChaseFollow(viewer, positionsProvider) {
|
|
const scratchPosition = new Cesium.Cartesian3();
|
|
const scratchPrevious = new Cesium.Cartesian3();
|
|
const scratchDirection = new Cesium.Cartesian3();
|
|
const scratchEast = new Cesium.Cartesian3();
|
|
const scratchNorth = new Cesium.Cartesian3();
|
|
const scratchUp = new Cesium.Cartesian3();
|
|
const scratchPreviousTime = new Cesium.JulianDate();
|
|
const scratchHpr = new Cesium.HeadingPitchRoll(0.0, 0.0, 0.0);
|
|
const scratchTransform = new Cesium.Matrix4();
|
|
const offset = new Cesium.Cartesian3();
|
|
const state = { enabled: false, distance: 36.0, height: 18.0 };
|
|
|
|
function update(clock) {
|
|
const time = clock.currentTime;
|
|
const positions = positionsProvider();
|
|
const position = positions.getValue(time, scratchPosition);
|
|
if (!position) return;
|
|
Cesium.JulianDate.addSeconds(time, -0.8, scratchPreviousTime);
|
|
const previous = positions.getValue(scratchPreviousTime, scratchPrevious);
|
|
if (previous) {
|
|
Cesium.Cartesian3.subtract(position, previous, scratchDirection);
|
|
} else {
|
|
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, scratchDirection);
|
|
}
|
|
if (Cesium.Cartesian3.magnitudeSquared(scratchDirection) < 0.0001) {
|
|
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, scratchDirection);
|
|
}
|
|
Cesium.Cartesian3.normalize(scratchDirection, scratchDirection);
|
|
Cesium.Cartesian3.normalize(position, scratchUp);
|
|
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, scratchUp, scratchEast);
|
|
if (Cesium.Cartesian3.magnitudeSquared(scratchEast) < 0.0001) {
|
|
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, scratchEast);
|
|
} else {
|
|
Cesium.Cartesian3.normalize(scratchEast, scratchEast);
|
|
}
|
|
Cesium.Cartesian3.cross(scratchUp, scratchEast, scratchNorth);
|
|
Cesium.Cartesian3.normalize(scratchNorth, scratchNorth);
|
|
|
|
const eastComponent = Cesium.Cartesian3.dot(scratchDirection, scratchEast);
|
|
const northComponent = Cesium.Cartesian3.dot(scratchDirection, scratchNorth);
|
|
scratchHpr.heading = Math.atan2(eastComponent, northComponent);
|
|
Cesium.Cartesian3.fromElements(0.0, -state.distance, state.height, offset);
|
|
Cesium.Transforms.headingPitchRollToFixedFrame(
|
|
position,
|
|
scratchHpr,
|
|
undefined,
|
|
undefined,
|
|
scratchTransform
|
|
);
|
|
viewer.camera.lookAtTransform(scratchTransform, offset);
|
|
}
|
|
|
|
function onWheel(event) {
|
|
if (!state.enabled) return;
|
|
event.preventDefault();
|
|
const zoom = event.deltaY > 0 ? 1.12 : 0.88;
|
|
state.distance = Cesium.Math.clamp(state.distance * zoom, 12.0, 160.0);
|
|
state.height = Cesium.Math.clamp(state.height * zoom, 6.0, 90.0);
|
|
update(viewer.clock);
|
|
}
|
|
|
|
return {
|
|
get enabled() {
|
|
return state.enabled;
|
|
},
|
|
start() {
|
|
if (state.enabled) return;
|
|
state.enabled = true;
|
|
viewer.trackedEntity = undefined;
|
|
viewer.canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
viewer.clock.onTick.addEventListener(update);
|
|
update(viewer.clock);
|
|
},
|
|
stop() {
|
|
if (!state.enabled) return;
|
|
state.enabled = false;
|
|
viewer.clock.onTick.removeEventListener(update);
|
|
viewer.canvas.removeEventListener("wheel", onWheel);
|
|
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
|
}
|
|
};
|
|
}
|
|
|
|
function createCameraPresets(viewer, metadata, placement, cruise) {
|
|
const radius = Math.max(220.0, boundsRadiusMeters(metadata.bounds) || 900.0);
|
|
const center = placement.position;
|
|
function lookAt(range, headingDegrees, pitchDegrees) {
|
|
viewer.camera.lookAt(
|
|
center,
|
|
new Cesium.HeadingPitchRange(
|
|
Cesium.Math.toRadians(headingDegrees),
|
|
Cesium.Math.toRadians(pitchDegrees),
|
|
range
|
|
)
|
|
);
|
|
// lookAt locks the camera into the target's reference frame; release it
|
|
// so orbit and pan keep working from the new vantage point.
|
|
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
|
}
|
|
return {
|
|
overview() {
|
|
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, radius), {
|
|
duration: 0.0
|
|
});
|
|
},
|
|
oblique() {
|
|
lookAt(radius * 0.92, 135.0, -28.0);
|
|
},
|
|
detail() {
|
|
lookAt(radius * 0.32, 115.0, -18.0);
|
|
},
|
|
route() {
|
|
if (!cruise.vehicles.length) return;
|
|
const vehicle = selectedVehicle(cruise);
|
|
const coord = vehicle.segment.coordinates[Math.floor(vehicle.segment.coordinates.length / 2)];
|
|
viewer.camera.flyTo({
|
|
destination: Cesium.Cartesian3.fromDegrees(coord[0], coord[1], 90.0),
|
|
orientation: {
|
|
heading: Cesium.Math.toRadians(0.0),
|
|
pitch: Cesium.Math.toRadians(-62.0),
|
|
roll: 0.0
|
|
},
|
|
duration: 0.0
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
// Camera-dependent readouts have to track the camera, so refresh off the
|
|
// render loop rather than a fixed timer, throttled to stay off the hot path.
|
|
function startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals, v2xOverlay) {
|
|
const center = placement.position;
|
|
const stats = metadata.scene_stats || {};
|
|
const failed = assets.filter((asset) => asset.error);
|
|
let lastUpdate = 0;
|
|
|
|
function render() {
|
|
const cartographic = viewer.camera.positionCartographic;
|
|
const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, center);
|
|
const lines = [
|
|
"Area: " + (config.areaId || "(unknown)"),
|
|
"Anchor: " + placement.longitude.toFixed(7) + ", " + placement.latitude.toFixed(7),
|
|
"Camera height: " + Math.round(cartographic.height) + " m",
|
|
"Camera range: " + Math.round(distance) + " m",
|
|
"Assets: " + liveAssets(assets).length + " model(s)",
|
|
"Vehicles: " + cruise.vehicles.length,
|
|
"Signals: " + trafficSignals.count,
|
|
"Buildings: " + Number(stats.buildings || 0),
|
|
"Trees: " + Number(stats.trees || 0),
|
|
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")
|
|
];
|
|
if (v2xOverlay?.state) {
|
|
lines.push("Vehicle data: " + (v2xOverlay.state.vehicleSource === "live" ? "live V2X" : "waiting for live V2X"));
|
|
lines.push("Live vehicles: " + v2xOverlay.state.vehicles.size);
|
|
}
|
|
if (failed.length) {
|
|
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));
|
|
}
|
|
diagnosticsEl.textContent = lines.join("\n");
|
|
}
|
|
|
|
viewer.scene.postRender.addEventListener(() => {
|
|
const now = performance.now();
|
|
if (now - lastUpdate < 250) return;
|
|
lastUpdate = now;
|
|
render();
|
|
});
|
|
render();
|
|
}
|
|
|
|
function summaryText(metadata, assets, cruise) {
|
|
const stats = metadata.scene_stats || {};
|
|
return [
|
|
config.areaId,
|
|
liveAssets(assets).map((asset) => asset.url).join(", "),
|
|
"live V2X vehicles only",
|
|
"buildings " + Number(stats.buildings || 0),
|
|
"trees " + Number(stats.trees || 0)
|
|
].filter(Boolean).join(" | ");
|
|
}
|
|
|
|
function boundsRadiusMeters(bounds) {
|
|
if (!bounds) return null;
|
|
const minLon = Number(bounds.min_lon);
|
|
const minLat = Number(bounds.min_lat);
|
|
const maxLon = Number(bounds.max_lon);
|
|
const maxLat = Number(bounds.max_lat);
|
|
if (![minLon, minLat, maxLon, maxLat].every(Number.isFinite)) return null;
|
|
return Math.max(
|
|
80.0,
|
|
distanceMeters([minLon, minLat], [maxLon, maxLat]) * 0.58
|
|
);
|
|
}
|
|
|
|
function distanceMeters(a, b) {
|
|
const radius = 6371008.8;
|
|
const lat1 = Cesium.Math.toRadians(a[1]);
|
|
const lat2 = Cesium.Math.toRadians(b[1]);
|
|
const dLat = Cesium.Math.toRadians(b[1] - a[1]);
|
|
const dLon = Cesium.Math.toRadians(b[0] - a[0]);
|
|
const sinLat = Math.sin(dLat / 2);
|
|
const sinLon = Math.sin(dLon / 2);
|
|
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
|
|
return 2 * radius * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
}
|
|
|
|
function setStatus(message) {
|
|
statusEl.textContent = message;
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
const detail = error && error.message ? error.message : String(error);
|
|
setStatus("Failed to load Cesium preview: " + detail);
|
|
document.body.classList.add("scene-error");
|
|
setLoadingMessage("Failed to load scene", detail + " (see Safari Develop > Show JavaScript Console)");
|
|
});
|
|
}());
|