mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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]>
433 lines
13 KiB
JavaScript
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();
|
|
}
|
|
}
|