Files
gods-eye-view/src/ui/locationNavigation.js
T
f4fcf267c6 fix(osm): stop using public Overpass by default (#648) (#742)
Traffic roads come from OpenFreeMap vector tiles, or TomTom flow tiles with a
key (Hybrid by default). Military areas use OpenFreeMap polygons with a bundled
worldwide name index; wide views show named points. ALPR reads an hourly
OpenStreetMap extract. Cockpit place context and country, state and US county
outlines use bundled Natural Earth and US Census data. Overpass is used only
when an operator configures their own endpoint.

Also: camera arrivals no longer land below ground, keyless terrain retries on
429, bounded relation geometry for configured Overpass, one shared
OpenStreetMap credit, and separate tile and API transports for embedders.

Co-authored-by: theGaryLarson <[email protected]>
Co-authored-by: Milan Khanal <[email protected]>
2026-09-28 19:07:57 -05:00

433 lines
13 KiB
JavaScript

import * as Cesium from 'cesium';
import { createStateChannel } from '../app/stateChannel.js';
import { LocationControls } from './location.js';
/** Own destination selection, lookup, orbit and world-jump lifetime. */
export class LocationNavigation {
constructor({
viewer,
placeSearch,
services,
elements,
navigation,
readCockpit,
operations,
}) {
Object.assign(
this,
{ viewer, placeSearch, services, navigation, readCockpit },
elements,
operations,
);
this._disposed = false;
this._activeLocationId = null;
this._expandedCityId = null;
this._activePoiIndex = null;
this._currentTarget = null;
this._currentPoi = null;
this._searchedLocationLabel = null;
this._trafficTransitionTimer = null;
this._globeResetPromise = null;
this._cancelGlobeReset = null;
this._worldJumpActive = false;
this.orbitController = new services.OrbitController(viewer);
this._orbitIndicator = null;
this._locationState = createStateChannel(
() => this._locationLookup?.getState() || null,
);
this._locationState.subscribe(
({ state, change }) => this._handleLocationSearchState(state, change),
{ emitCurrent: false },
);
}
get cockpitView() {
return this.readCockpit();
}
get _navigationGeneration() {
return this.navigation._navigationGeneration;
}
get _activeLocationSearchGeneration() {
return this.navigation._activeLocationSearchGeneration;
}
set _activeLocationSearchGeneration(value) {
this.navigation._activeLocationSearchGeneration = value;
}
setOrbit(enabled) {
const active = !!this.orbitController?.active;
if (typeof enabled === 'boolean' && enabled === active) {
return { ok: true, orbiting: active };
}
if (enabled === false) {
this._stopOrbit();
return { ok: true, orbiting: false };
}
if (!this._currentTarget) {
return {
ok: false,
orbiting: false,
error: 'No active landmark to orbit — fly to a landmark first',
};
}
this._toggleOrbit();
return { ok: true, orbiting: !!this.orbitController?.active };
}
subscribeLocationSearch(listener, options) {
return this._locationState.subscribe(listener, options);
}
_handleLocationSearchState(state, change) {
if (this._disposed || !change) return;
if (change.type === 'started')
this._activeLocationSearchGeneration = change.generation;
else if (change.type === 'found') {
this._searchedLocationLabel = state.destination.label || state.query;
this._setActiveLocation(null);
this._currentPoi = null;
this._collapsePOIRow();
this._updateLocationMiniStatus();
} else if (change.type === 'missing') this._showToast('Location not found');
else if (change.type === 'failed') this._showToast('Search failed');
else if (change.type === 'settled')
this._settleLocationSearchUi(change.generation);
else if (
change.type === 'reset' &&
this._activeLocationSearchGeneration !== null
) {
this._settleLocationSearchUi(this._activeLocationSearchGeneration);
}
}
_initLocationBar() {
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
this._locationControls?.destroy();
this._locationLookupUnsubscribe?.();
this._locationLookup?.destroy();
this._locationLookup = new LocationSearch({
input: this._locationSearch,
begin: () => this._beginDeferredNavigation('location'),
isCurrent: (generation) =>
!this._disposed && generation === this._navigationGeneration,
beforeFly: (generation) => this._reassertNavigationHandoff(generation),
search: (query, options) =>
searchAndFlyTo(this.viewer, query, {
placeSearch: this.placeSearch,
...options,
}),
onResult: (destination) => {
if (destination.outlineUnavailable)
this._showToast('Detailed outline unavailable');
},
onError: (error) => console.error('[Search] Geocoding failed:', error),
});
this._locationLookupUnsubscribe = this._locationLookup.subscribe(
({ initial, change }) => {
this._locationState.publish(initial ? { type: 'reset' } : change);
},
);
this._locationControls = new LocationControls({
elements: {
pills: this._locationPills,
poiRow: this._poiRow,
divider: this._locationBarDivider,
search: this._locationSearch,
searchToggle: this._searchToggle,
resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn],
statusCity: this._locationMiniCity,
statusPoi: this._locationMiniPoi,
},
cities: CITY_POIS,
getExpandedCity: () => this._expandedCityId,
onCity: (id) => this._onCityPillClick(id),
onPoi: (id, index) => this._onPoiClick(id, index),
onSearch: (query) => this._locationLookup.run(query),
onReset: () => this.resetToGlobeView(),
});
}
_beginWorldJumpTransition() {
const { suspendDetection, trafficLayer } = this.services;
clearTimeout(this._trafficTransitionTimer);
this._worldJumpActive = true;
trafficLayer.beginWorldJump?.();
suspendDetection('intercity');
}
_endWorldJumpTransition() {
const { resumeDetection, trafficLayer } = this.services;
clearTimeout(this._trafficTransitionTimer);
this._worldJumpActive = false;
this._trafficTransitionTimer = null;
trafficLayer.endWorldJump?.();
resumeDetection();
this._updateTrafficSyncChip(true);
}
_flyWithTransition(cityChanged, flyAction) {
return this._runExplicitNavigation('location', () => {
if (!cityChanged) return flyAction({});
let completed = false;
const finalize = () => {
if (completed || this._disposed) return;
completed = true;
this._endWorldJumpTransition();
};
const result = flyAction({
onStart: () => this._beginWorldJumpTransition(),
onComplete: finalize,
});
this._trafficTransitionTimer = window.setTimeout(finalize, 5200);
return result;
});
}
_onCityPillClick(cityId) {
const { CITY_POIS, flyToPresetLocation } = this.services;
if (this._expandedCityId === cityId) {
// Same city clicked again — toggle collapse
this._collapsePOIRow();
return;
}
const isCityChanged =
this._activeLocationId && this._activeLocationId !== cityId;
const result = this._flyWithTransition(!!isCityChanged, (hooks) =>
flyToPresetLocation(this.viewer, cityId, hooks),
);
if (result === false) return;
this._expandPOIRow(cityId);
this._setActiveLocation(cityId);
this._activePoiIndex = 0;
this._updatePoiHighlight();
// Track current target + POI for orbit
if (result) {
this._currentTarget = result.targetPosition;
this._currentPoi = CITY_POIS[cityId].pois[0];
}
this._updateLocationMiniStatus();
}
_onPoiClick(cityId, poiIndex) {
const { CITY_POIS, flyToPOI } = this.services;
const isCityChanged =
this._activeLocationId && this._activeLocationId !== cityId;
const result = this._flyWithTransition(!!isCityChanged, (hooks) =>
flyToPOI(this.viewer, cityId, poiIndex, hooks),
);
if (result === false) return;
this._setActiveLocation(cityId);
this._activePoiIndex = poiIndex;
this._updatePoiHighlight();
// Track current target + POI for orbit
if (result) {
this._currentTarget = result.targetPosition;
this._currentPoi = CITY_POIS[cityId].pois[poiIndex];
}
this._updateLocationMiniStatus();
}
_expandPOIRow(cityId) {
const { CITY_POIS } = this.services;
if (!CITY_POIS[cityId]) return;
this._expandedCityId = cityId;
this._locationControls.showPois(cityId);
}
_collapsePOIRow() {
this._expandedCityId = null;
this._activePoiIndex = null;
this._locationControls.hidePois();
}
_updatePoiHighlight() {
this._locationControls.highlightPoi(this._activePoiIndex);
}
clearSearchedLocation() {
if (this._searchedLocationLabel === null) return;
this._searchedLocationLabel = null;
this._updateLocationMiniStatus();
}
_setActiveLocation(locationId) {
this._activeLocationId = locationId;
// A preset city is now what the camera is framed on, so any earlier
// free-text destination has been superseded. Clearing only on a real id
// leaves the search path's own _setActiveLocation(null) untouched.
if (locationId) this._searchedLocationLabel = null;
this._locationControls?.highlightCity(locationId);
this._updateLocationMiniStatus();
}
_updateLocationMiniStatus() {
const { CITY_POIS } = this.services;
this._locationControls?.renderStatus({
city: this._activeLocationId ? CITY_POIS[this._activeLocationId] : null,
currentPoi: this._currentPoi,
searchedLabel: this._searchedLocationLabel,
});
}
_initOrbit() {
this._orbitIndicator = this._locationControls.createOrbitIndicator();
}
_toggleOrbit() {
if (!this._currentTarget) {
this._showToast('Fly to a POI first');
return;
}
const isActive = this.orbitController.toggle(this._currentTarget, {
radius: this._currentPoi?.alt || 500,
pitch: this._currentPoi?.pitch || -30,
});
this._orbitIndicator.classList.toggle('active', isActive);
}
_stopOrbit() {
if (this.orbitController.active) {
this.orbitController.stop();
this._orbitIndicator.classList.remove('active');
}
}
resetToGlobeView() {
const {
GLOBE_VIEW,
flyToGlobeView,
interruptCameraMotion,
flightsLayer,
militaryFlightsLayer,
satellitesLayer,
aisLiveVesselsLayer,
militaryAwarenessLayer,
rocketLaunchesLayer,
} = this.services;
if (this._disposed)
return Promise.resolve({
ok: false,
action: 'zoom_to_globe',
cancelled: true,
});
if (this._globeResetPromise) return this._globeResetPromise;
this._stampNavigation();
interruptCameraMotion('reset-globe');
this._stopOrbit();
this.cockpitView?.exit({ restoreTracking: false });
try {
militaryAwarenessLayer.releaseCameraOwnership?.({ origin: 'tool' });
} catch {
// Keep reset available if Context has not initialized completely.
try {
flightsLayer.stopTracking?.({ origin: 'tool' });
} catch {
/* best-effort release */
}
try {
militaryFlightsLayer.stopTracking?.({ origin: 'tool' });
} catch {
/* best-effort release */
}
try {
aisLiveVesselsLayer.clearSelection?.();
} catch {
/* best-effort release */
}
}
try {
satellitesLayer.stopTracking?.({ origin: 'tool' });
} catch {
/* best-effort release */
}
try {
rocketLaunchesLayer.releaseCameraOwnership?.();
} catch {
/* best-effort release */
}
this.viewer.trackedEntity = undefined;
this.viewer.camera.cancelFlight();
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
this._beginWorldJumpTransition();
let resolveReset;
const resetPromise = new Promise((resolve) => {
resolveReset = resolve;
});
this._globeResetPromise = resetPromise;
let settled = false;
let timer = null;
const finish = (cancelled = false) => {
if (settled) return;
settled = true;
clearTimeout(timer);
this._endWorldJumpTransition();
const carto = this.viewer.camera.positionCartographic;
const result = {
ok: !cancelled,
action: 'zoom_to_globe',
cancelled,
heightKm: Math.round(GLOBE_VIEW.heightM / 1000),
centeredOn: {
latitude: Number(Cesium.Math.toDegrees(carto.latitude).toFixed(2)),
longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(2)),
},
};
this._resetGlobeBtn?.setAttribute(
'aria-label',
'Reset to full globe view',
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
'Reset cockpit to full globe view',
);
this._globeResetPromise = null;
this._cancelGlobeReset = null;
resolveReset(result);
};
this._cancelGlobeReset = () => finish(true);
timer = window.setTimeout(() => {
const height = this.viewer.camera.positionCartographic?.height;
finish(
!Number.isFinite(height) ||
Math.abs(height - GLOBE_VIEW.heightM) > 1000,
);
}, 4200);
this._resetGlobeBtn?.setAttribute(
'aria-label',
'Resetting to full globe view',
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
'Resetting cockpit to full globe view',
);
const target = flyToGlobeView(this.viewer, {
onComplete: () => finish(false),
onCancel: () => finish(true),
});
if (!target) finish(true);
return resetPromise;
}
/** Revoke callbacks and settle owned camera work before the viewer is released. */
destroy() {
if (this._disposed) return;
this._disposed = true;
this._locationState.destroy();
this._locationLookupUnsubscribe?.();
this._locationLookupUnsubscribe = null;
this._locationLookup?.destroy();
this._locationControls?.destroy();
this._cancelGlobeReset?.();
this._cancelGlobeReset = null;
if (this._worldJumpActive) this._endWorldJumpTransition();
clearTimeout(this._trafficTransitionTimer);
this._trafficTransitionTimer = null;
this.orbitController?.stop();
}
}