1038 lines
41 KiB
JavaScript
1038 lines
41 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const config = window.OSM_ASSET_PREVIEW_CONFIG || {};
|
|
const statusEl = document.getElementById("status");
|
|
const diagnosticsEl = document.getElementById("diagnostics");
|
|
const loadingOverlay = document.getElementById("loadingOverlay");
|
|
const toggleCruise = document.getElementById("toggleCruise");
|
|
const toggleFollow = document.getElementById("toggleFollow");
|
|
const toggleScene = document.getElementById("toggleScene");
|
|
const 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");
|
|
// 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 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 trafficStart = Cesium.JulianDate.now();
|
|
const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart, assets);
|
|
const cruise = addVehicleCruises(viewer, routeData, signalData, trafficStart, config.vehicleModelNames, config.vehicleModelName);
|
|
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.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);
|
|
});
|
|
|
|
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, 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;
|
|
|
|
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, signalData,
|
|
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, 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 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 update = (elapsedSeconds) => {
|
|
Cesium.JulianDate.addSeconds(start, elapsedSeconds, phaseTime);
|
|
let changed = false;
|
|
const groupPhases = new Map();
|
|
for (const signal of signals) {
|
|
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_${signal.id}_${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_${signal.id}_countdown_${String(value).padStart(2, "0")}`;
|
|
let countdown = null;
|
|
try { countdown = countdownModel.getNode(name); } catch (error) { /* model node table is still loading */ }
|
|
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();
|
|
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) {
|
|
const route = prepareRoute(segment, signalData);
|
|
const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed);
|
|
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
|
|
}
|
|
});
|
|
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, 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 createTrafficAwarePositions(viewer, route, start, speed) {
|
|
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;
|
|
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) {
|
|
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);
|
|
});
|
|
}());
|