mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
* fix(share): restore a share link opened by hash navigation Opening a different share link in an already-open tab (pasting it into the address bar, following a link, or Back/Forward) changed the URL but left the camera, visual style, map, panels and layers on the old view until a reload. The share manager now listens for hashchange and popstate. A new hash that parses as a share and differs from the last hash the app read or wrote runs the start-up restore pipeline: lane authority is reserved, URL writes are held (including a pending debounced write of the old view), the camera restore takes a new navigation generation, and the layer coordinator replaces any in-flight layer restore with the new share's layers through the passive share origin. A newer navigation supersedes an older restore end to end; only the newest one releases URL writes. The app's own replaceState writes never restore. Listeners are removed on destroy. Returning to the Normal style and resetting an already-active preset now also apply when a share is restored after start-up, so the result matches a fresh load of the same link. * docs: changelog for #1416
275 lines
8.0 KiB
JavaScript
275 lines
8.0 KiB
JavaScript
import { cancelCameraArrival } from '../data/cameraArrival.js';
|
|
import { createStateChannel } from '../app/stateChannel.js';
|
|
import * as Cesium from 'cesium';
|
|
import {
|
|
beginDeferredNavigation,
|
|
reassertNavigationHandoff,
|
|
runExplicitNavigation,
|
|
} from '../navigationPolicy.js';
|
|
|
|
/** Own camera authority generations, pending search UI and tracking handoff. */
|
|
export class NavigationController {
|
|
constructor({
|
|
viewer,
|
|
tracking,
|
|
searchInput,
|
|
interruptCameraMotion,
|
|
interruptGlobeRotation = () => {},
|
|
isCockpitActive,
|
|
clearLocation,
|
|
cancelShareSelection,
|
|
getDataManager,
|
|
stopOrbit,
|
|
showToast,
|
|
cancelOrientation = () => {},
|
|
}) {
|
|
Object.assign(this, {
|
|
viewer,
|
|
tracking,
|
|
searchInput,
|
|
interruptCameraMotion,
|
|
interruptGlobeRotation,
|
|
isCockpitActive,
|
|
clearLocation,
|
|
cancelShareSelection,
|
|
getDataManager,
|
|
stopOrbit,
|
|
showToast,
|
|
cancelOrientation,
|
|
});
|
|
this._navigationGeneration = 0;
|
|
this._cameraHandoffs = createStateChannel(() => ({
|
|
generation: this._navigationGeneration,
|
|
}));
|
|
this._activeLocationSearchGeneration = null;
|
|
this._disposed = false;
|
|
}
|
|
_stampNavigation({
|
|
cancelPendingSelection = true,
|
|
clearSearchedLocation = true,
|
|
} = {}) {
|
|
this.interruptGlobeRotation('navigation-intent');
|
|
cancelCameraArrival(this.viewer);
|
|
this.cancelOrientation();
|
|
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
|
this.tracking;
|
|
this._navigationGeneration += 1;
|
|
this._cameraHandoffs?.publish();
|
|
// A newer destination owns the camera, so the last free-text search is no
|
|
// longer where we are. DEFERRED navigation opts out here and clears at the
|
|
// reassert seam instead: a geocode that never resolves moves no camera, and
|
|
// a lookup that fails must not blank a readout that is still true.
|
|
if (clearSearchedLocation) this.clearLocation();
|
|
if (cancelPendingSelection) {
|
|
const passivelyClearedShareSelection = this.cancelShareSelection();
|
|
try {
|
|
flightsLayer.cancelPendingTrackingRestore?.();
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
try {
|
|
militaryFlightsLayer.cancelPendingTrackingRestore?.();
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
try {
|
|
satellitesLayer.cancelPendingTrackingRestore?.();
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
// A deliberate destination supersedes share-selected entities that have
|
|
// not arrived yet. Active owners publish their clear when released.
|
|
if (!passivelyClearedShareSelection && !flightsLayer.getTrackedInfo?.()) {
|
|
this.getDataManager()?.setLayerParams(
|
|
'flights',
|
|
{
|
|
selectedFlightsTrackingId: null,
|
|
},
|
|
{ origin: 'tool' },
|
|
);
|
|
}
|
|
if (
|
|
!passivelyClearedShareSelection &&
|
|
!militaryFlightsLayer.getTrackedInfo?.()
|
|
) {
|
|
this.getDataManager()?.setLayerParams(
|
|
'military',
|
|
{
|
|
selectedMilitaryTrackingId: null,
|
|
},
|
|
{ origin: 'tool' },
|
|
);
|
|
}
|
|
if (
|
|
!passivelyClearedShareSelection &&
|
|
!satellitesLayer.getTrackedInfo?.()
|
|
) {
|
|
this.getDataManager()?.setLayerParams(
|
|
'satellites',
|
|
{
|
|
selectedSatTrackingId: null,
|
|
},
|
|
{ origin: 'tool' },
|
|
);
|
|
}
|
|
}
|
|
if (this._activeLocationSearchGeneration !== null) {
|
|
this._settleLocationSearchUi(this._activeLocationSearchGeneration);
|
|
}
|
|
return this._navigationGeneration;
|
|
}
|
|
|
|
_settleLocationSearchUi(generation) {
|
|
if (this._activeLocationSearchGeneration !== generation) return;
|
|
this._activeLocationSearchGeneration = null;
|
|
this.searchInput?.classList.remove('searching', 'expanded');
|
|
if (this.searchInput) this.searchInput.value = '';
|
|
this.searchInput?.blur();
|
|
}
|
|
|
|
_releaseFollowCamera({
|
|
preserveVesselSelection = true,
|
|
preserveCameraFlight = false,
|
|
trackingOrigin = 'tool',
|
|
} = {}) {
|
|
const {
|
|
flightsLayer,
|
|
militaryFlightsLayer,
|
|
satellitesLayer,
|
|
aisLiveVesselsLayer,
|
|
militaryAwarenessLayer,
|
|
rocketLaunchesLayer,
|
|
} = this.tracking;
|
|
let contactSelected = false;
|
|
try {
|
|
contactSelected = Boolean(
|
|
militaryAwarenessLayer.releaseCameraOwnership?.({
|
|
preserveVesselSelection,
|
|
origin: trackingOrigin,
|
|
}),
|
|
);
|
|
} catch {
|
|
try {
|
|
flightsLayer.stopTracking?.({ origin: trackingOrigin });
|
|
} catch {
|
|
/* best-effort release */
|
|
}
|
|
try {
|
|
militaryFlightsLayer.stopTracking?.({ origin: trackingOrigin });
|
|
} catch {
|
|
/* best-effort release */
|
|
}
|
|
if (!preserveVesselSelection) {
|
|
try {
|
|
aisLiveVesselsLayer.clearSelection?.();
|
|
} catch {
|
|
/* best-effort release */
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
satellitesLayer.stopTracking?.({ origin: trackingOrigin });
|
|
} catch {
|
|
/* best-effort release */
|
|
}
|
|
try {
|
|
rocketLaunchesLayer.releaseCameraOwnership?.();
|
|
} catch {
|
|
/* best-effort release */
|
|
}
|
|
this.viewer.trackedEntity = undefined;
|
|
this.interruptCameraMotion('explicit-navigation');
|
|
this.stopOrbit();
|
|
if (!preserveCameraFlight) this.viewer.camera.cancelFlight();
|
|
try {
|
|
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
|
} catch {
|
|
/* teardown race */
|
|
}
|
|
return contactSelected;
|
|
}
|
|
|
|
_runExplicitNavigation(noun, navigate, releaseOptions = undefined) {
|
|
return runExplicitNavigation({
|
|
disposed: this._disposed,
|
|
cockpitActive: this.isCockpitActive(),
|
|
noun,
|
|
showToast: (text) => this.showToast(text),
|
|
stamp: () => this._stampNavigation(),
|
|
release: () => this._releaseFollowCamera(releaseOptions),
|
|
navigate,
|
|
});
|
|
}
|
|
|
|
/** Change the viewing angle without clearing selection or follow ownership. */
|
|
runOrientation(noun, navigate) {
|
|
return runExplicitNavigation({
|
|
disposed: this._disposed,
|
|
cockpitActive: this.isCockpitActive(),
|
|
noun,
|
|
showToast: (text) => this.showToast(text),
|
|
stamp: () =>
|
|
this._stampNavigation({
|
|
cancelPendingSelection: false,
|
|
clearSearchedLocation: false,
|
|
}),
|
|
release: () => {
|
|
this.interruptCameraMotion('camera-orientation');
|
|
this.stopOrbit();
|
|
this.viewer.camera.cancelFlight();
|
|
},
|
|
navigate,
|
|
});
|
|
}
|
|
|
|
_beginDeferredNavigation(
|
|
noun = 'location',
|
|
{ cancelPendingSelection = true } = {},
|
|
) {
|
|
return beginDeferredNavigation({
|
|
disposed: this._disposed,
|
|
cockpitActive: this.isCockpitActive(),
|
|
noun,
|
|
showToast: (text) => this.showToast(text),
|
|
// The searched-location readout survives the STAMP; only a flight that
|
|
// actually starts invalidates it (see the release hook below).
|
|
stamp: () =>
|
|
this._stampNavigation({
|
|
cancelPendingSelection,
|
|
clearSearchedLocation: false,
|
|
}),
|
|
});
|
|
}
|
|
|
|
_reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {
|
|
return reassertNavigationHandoff({
|
|
generation,
|
|
currentGeneration: this._navigationGeneration,
|
|
cockpitActive: this.isCockpitActive(),
|
|
disposed: this._disposed,
|
|
showToast: (text) => this.showToast(text),
|
|
// Reached only once the handoff is granted, immediately before the
|
|
// deferred flight starts — so a lookup that failed, was superseded, or
|
|
// was refused by the cockpit leaves the old readout standing.
|
|
release: () => {
|
|
this.clearLocation();
|
|
return this._releaseFollowCamera({ trackingOrigin });
|
|
},
|
|
});
|
|
}
|
|
/** Subscribe to ownership changes without claiming the camera or exposing mutable state. */
|
|
subscribeCameraHandoff(listener) {
|
|
return this._cameraHandoffs.subscribe(listener, { emitCurrent: false });
|
|
}
|
|
stop() {
|
|
cancelCameraArrival(this.viewer);
|
|
this._disposed = true;
|
|
this._cameraHandoffs?.publish();
|
|
this._cameraHandoffs?.destroy();
|
|
}
|
|
destroy() {
|
|
this.stop();
|
|
this._stampNavigation();
|
|
}
|
|
}
|