(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 toggleFps = document.getElementById("toggleFps"); const toggleDiagnostics = document.getElementById("toggleDiagnostics"); const assetToggles = document.getElementById("assetToggles"); const vehicleSelect = document.getElementById("vehicleSelect"); const speedControl = document.getElementById("speedControl"); const speedLabel = document.getElementById("speedLabel"); const cameraButtons = Array.from(document.querySelectorAll("[data-camera]")); // 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 placement = scenePlacement(metadata); const viewer = createViewer(); setLoadingMessage("Loading model", config.glbName || ""); const assets = await loadSceneAssets(viewer, metadata, placement); const cruise = addVehicleCruises(viewer, routeData, config.vehicleModelName); const cameras = createCameraPresets(viewer, metadata, placement, cruise); buildAssetToggles(assets); bindRuntimeControls(viewer, assets, cruise, cameras); startDiagnostics(viewer, metadata, assets, cruise, placement); 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, 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; try { const model = await Cesium.Model.fromGltfAsync({ url: asset.url, modelMatrix: placement.modelMatrix, scale: Number(asset.scale || 1.0) }); model.show = asset.enabled !== false; viewer.scene.primitives.add(model); loaded.push({ id, label, url: asset.url, model, error: null }); } catch (error) { console.error(error); loaded.push({ id, label, url: asset.url, model: null, error }); } } if (!loaded.some((asset) => asset.model)) { throw new Error("No scene model could be loaded (" + loaded.length + " declared)"); } return loaded; } function liveAssets(assets) { return assets.filter((asset) => asset.model); } // 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 bindRuntimeControls(viewer, assets, cruise, cameras) { const hasVehicles = cruise.vehicles.length > 0; 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; }); toggleFps.addEventListener("change", () => { viewer.scene.debugShowFramesPerSecond = toggleFps.checked; }); toggleDiagnostics.addEventListener("change", () => { diagnosticsEl.classList.toggle("hidden", !toggleDiagnostics.checked); }); 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, vehicleModelName) { const segments = ((routeData && 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, vehicleModelName); const option = document.createElement("option"); option.value = String(index); option.textContent = "#" + (index + 1) + " " + segment.name + " " + Math.round(segment.lengthMeters) + "m"; vehicleSelect.appendChild(option); return vehicle; }); return { vehicles, baseSpeed: speed, state: { selectedIndex: 0 } }; } 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, scale: 1.0, minimumPixelSize: 24, maximumScale: 80 } }); return { entity: vehicle, routeEntity, positions, route, segment, label: "Vehicle #" + (index + 1) + " | route " + segment.id + " | " + Math.round(segment.lengthMeters) + "m" }; } 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) { 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, "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); }); }());