(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 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 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 routeData = await fetchOptionalJson(config.routeName); 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 cruise = addVehicleCruises(viewer, routeData, config.vehicleModelNames, config.vehicleModelName); const trafficSignals = addTrafficSignals(viewer, signalData); const cameras = createCameraPresets(viewer, metadata, placement, cruise); buildAssetToggles(assets); buildSemanticToggles(viewer, assets, placement); bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals); startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals); 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 }; } async function fetchJson(url) { const response = await fetch(url); 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) { 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 || {}; 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 || 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) { 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); } // 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.category === "semantic") 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"; toggleScene.addEventListener("change", () => { 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", () => { for (const vehicle of cruise.vehicles) vehicle.routeEntity.show = toggleRoutes.checked; }); toggleVehicles.addEventListener("change", () => { for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked; }); if (!trafficSignals.entities.length) { 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); }); 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) { 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); 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, 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 = Cesium.JulianDate.now(); viewer.clock.startTime = start.clone(); viewer.clock.currentTime = start.clone(); viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED; viewer.clock.multiplier = 1; viewer.clock.shouldAnimate = segments.length > 0; const vehicles = segments.map((segment, index) => { const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName)); const option = document.createElement("option"); option.value = String(index); option.textContent = routeLabel(segment, index); vehicleSelect.appendChild(option); return vehicle; }); return { vehicles, baseSpeed: speed, state: { selectedIndex: 0 } }; } function addTrafficSignals(viewer, signalData) { const anchors = (signalData?.signals || []).filter((signal) => Number.isFinite(signal.longitude) && Number.isFinite(signal.latitude)); const entities = []; const start = Cesium.JulianDate.now(); for (const signal of anchors) { const mastReach = Number(signal.mastReachMeters) || 4.5; const countdownOffset = -1.15; const polePosition = signalPosition(signal, 0, 0, 3.35); const headPosition = signalPosition(signal, 0, -mastReach, 6.25); const frame = signalHeadFrame(signal, headPosition); const pole = viewer.entities.add({ position: polePosition, cylinder: { length: 6.7, topRadius: 0.10, bottomRadius: 0.14, material: Cesium.Color.fromCssColorString("#273139") }, }); // The mast arm begins at the curbside pole and reaches above the approach // lanes. A separate mast at the opposite approach controls oncoming cars. const arm = viewer.entities.add({ polyline: { positions: [signalPosition(signal, 0, 0, 6.25), headPosition], width: 9, material: Cesium.Color.fromCssColorString("#273139"), arcType: Cesium.ArcType.NONE, }, }); const head = viewer.entities.add({ position: headPosition, orientation: frame.orientation, box: { dimensions: new Cesium.Cartesian3(0.68, 0.30, 1.62), material: Cesium.Color.fromCssColorString("#182024") }, }); entities.push(pole, arm, head); for (const [index, state] of ["red", "yellow", "green"].entries()) { const bulb = viewer.entities.add({ // The lens sits on the explicit approach-facing normal of the head, // not at an angle inferred from the box's local axes. position: signalLensPosition(headPosition, frame, 0.18, 0.49 - index * 0.50), ellipsoid: { radii: new Cesium.Cartesian3(0.22, 0.22, 0.22), material: new Cesium.ColorMaterialProperty(new Cesium.CallbackProperty((time) => signalColor(signal.phaseGroup, state, time, start), false)), }, }); entities.push(bulb); } // The countdown board mounts on the mast between the head and pole, // rather than protruding beyond the signal on the roadway side. const counterPosition = signalPanelPosition(headPosition, frame, countdownOffset, 0.05, 0); const counter = viewer.entities.add({ position: counterPosition, orientation: frame.orientation, box: { dimensions: new Cesium.Cartesian3(0.82, 0.14, 0.56), material: Cesium.Color.fromCssColorString("#251f1c"), distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220), }, }); const countdown = createSevenSegmentCountdown( viewer, signal, start, headPosition, frame, countdownOffset, ); entities.push(counter, ...countdown); } return { entities, count: anchors.length, set show(value) { for (const entity of entities) entity.show = value; }, }; } function signalPosition(signal, longitudinalMeters, lateralMeters, height) { const heading = Cesium.Math.toRadians(signal.headingDegrees); const latitude = signal.latitude + (longitudinalMeters * Math.cos(heading) - lateralMeters * Math.sin(heading)) / 110540; const longitude = signal.longitude + (longitudinalMeters * Math.sin(heading) + lateralMeters * Math.cos(heading)) / (111320 * Math.cos(Cesium.Math.toRadians(signal.latitude))); return Cesium.Cartesian3.fromDegrees(longitude, latitude, height); } function signalHeadFrame(signal, position) { const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position); // headingDegrees is the approach's travel direction into the junction. // The signal's front must point back toward that approaching traffic. const heading = Cesium.Math.toRadians(signal.headingDegrees); const localFace = new Cesium.Cartesian3(-Math.sin(heading), -Math.cos(heading), 0); const face = Cesium.Matrix4.multiplyByPointAsVector(enu, localFace, new Cesium.Cartesian3()); Cesium.Cartesian3.normalize(face, face); const up = Cesium.Cartesian3.normalize(position, new Cesium.Cartesian3()); const across = Cesium.Cartesian3.cross(face, up, new Cesium.Cartesian3()); Cesium.Cartesian3.normalize(across, across); const rotation = new Cesium.Matrix3( across.x, face.x, up.x, across.y, face.y, up.y, across.z, face.z, up.z, ); return { across, face, up, orientation: Cesium.Quaternion.fromRotationMatrix(rotation, new Cesium.Quaternion()) }; } function signalLensPosition(headPosition, frame, faceOffset, verticalOffset) { const point = Cesium.Cartesian3.multiplyByScalar(frame.face, faceOffset, new Cesium.Cartesian3()); Cesium.Cartesian3.add(headPosition, point, point); const vertical = Cesium.Cartesian3.multiplyByScalar(frame.up, verticalOffset, new Cesium.Cartesian3()); return Cesium.Cartesian3.add(point, vertical, point); } function signalPanelPosition(headPosition, frame, acrossOffset, faceOffset, verticalOffset) { const point = signalLensPosition(headPosition, frame, faceOffset, verticalOffset); const across = Cesium.Cartesian3.multiplyByScalar(frame.across, acrossOffset, new Cesium.Cartesian3()); return Cesium.Cartesian3.add(point, across, point); } function createSevenSegmentCountdown(viewer, signal, start, headPosition, frame, boardAcross) { const digitMap = { "0": "abcedf", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc", "5": "afgcd", "6": "afgecd", "7": "abc", "8": "abcdefg", "9": "abfgcd" }; const shape = { a: [0, 0.18, 0.20, 0.025, 0.035], b: [0.10, 0.085, 0.035, 0.025, 0.15], c: [0.10, -0.085, 0.035, 0.025, 0.15], d: [0, -0.18, 0.20, 0.025, 0.035], e: [-0.10, -0.085, 0.035, 0.025, 0.15], f: [-0.10, 0.085, 0.035, 0.025, 0.15], g: [0, 0, 0.20, 0.025, 0.035], }; const result = []; for (const [digitIndex, digitAcross] of [-0.17, 0.17].entries()) { for (const [name, [x, z, width, depth, height]] of Object.entries(shape)) { result.push(viewer.entities.add({ // The visible panel x-axis is the inverse of the signal frame's // across axis. Mirror the LED layout once here to keep digits normal. position: signalPanelPosition(headPosition, frame, boardAcross - digitAcross - x, 0.18, z), orientation: frame.orientation, box: { dimensions: new Cesium.Cartesian3(width, depth, height), material: new Cesium.ColorMaterialProperty(new Cesium.CallbackProperty((time) => signalActiveColor(signal.phaseGroup, time, start), false)), show: new Cesium.CallbackProperty((time) => { const value = String(signalPhase(signal.phaseGroup, time, start).remaining).padStart(2, "0")[digitIndex]; return (digitMap[value] || "").includes(name); }, false), distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220), }, })); } } return result; } 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) { const second = ((Cesium.JulianDate.secondsDifference(time, start) % 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, vehicleModelName) { const route = prepareRoute(segment); const positions = new Cesium.CallbackProperty((time, result) => { return routePosition(route, start, time, speed, result); }, false); const flat = []; for (const coord of segment.coordinates) { flat.push(coord[0], coord[1], 1.05); } 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: routeOrientation(route, start, 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 } }); return { entity: vehicle, routeEntity, positions, route, segment, label: routeLabel(segment, index), }; } 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 prepareRoute(segment) { 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]) }; } function routePosition(route, start, time, speed, result) { const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start)); const distance = (seconds * speed) % route.length; 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, 1.15, 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 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) { 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 (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(", "), cruise.vehicles.length ? "vehicles " + cruise.vehicles.length : "no drivable route", "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); setStatus("Failed to load Cesium preview: " + error.message); document.body.classList.add("scene-error"); setLoadingMessage("Failed to load scene", error.message); }); }());