mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor: separate application shell state and lifecycle owners
This commit is contained in:
1 parent
81b82da056
commit
bfefa875e1
20 files changed
+3510
-2481
No files matched your search
@@ -0,0 +1,428 @@
|
||||
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,
|
||||
}),
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user