Files
osmWorkflow/scripts/lib/cesium-preview.js

1508 lines
62 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?.registry?.visibleSize ?? 0));
lines.push("Target WS: " + (v2xOverlay.state?.targetMessages ?? 0) + " messages / " + (v2xOverlay.state?.targetLastCount ?? 0) + " parsed");
lines.push("Target payload: " + (v2xOverlay.state?.targetPayloadShape || "waiting"));
if (v2xOverlay.state?.targetLastError) lines.push("Target error: " + v2xOverlay.state.targetLastError);
}
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)");
});
}());