(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)"); }); }());