terriajs
Version:
Geospatial data visualization platform.
324 lines (282 loc) • 9.57 kB
text/typescript
import { isEqual } from "lodash-es";
import {
action,
computed,
IComputedValue,
IObservableValue,
IReactionDisposer,
observable,
reaction,
runInAction,
untracked,
makeObservable
} from "mobx";
import { fromPromise, FULFILLED, IPromiseBasedObservable } from "mobx-utils";
import CesiumEvent from "terriajs-cesium/Source/Core/Event";
import Rectangle from "terriajs-cesium/Source/Core/Rectangle";
import CatalogMemberMixin from "../ModelMixins/CatalogMemberMixin";
import MappableMixin from "../ModelMixins/MappableMixin";
import CameraView from "../Models/CameraView";
import GlobeOrMap from "../Models/GlobeOrMap";
import NoViewer from "../Models/NoViewer";
import Terria from "../Models/Terria";
import ViewerMode, { getViewerType } from "../Models/ViewerMode";
// Async loading of Leaflet and Cesium
const leafletFromPromise = computed(
() =>
fromPromise(import("../Models/Leaflet").then((Leaflet) => Leaflet.default)),
{ keepAlive: true }
);
const cesiumFromPromise = computed(
() =>
fromPromise(import("../Models/Cesium").then((Cesium) => Cesium.default)),
{ keepAlive: true }
);
// Viewer options. Designed to be easily serialisable
interface ViewerOptions {
useTerrain: boolean;
[key: string]: string | number | boolean;
}
const viewerOptionsDefaults: ViewerOptions = {
useTerrain: true
};
/**
* A class that deals with initialising, destroying and switching between viewers
* Each map-view should have it's own TerriaViewer (main viewer, preview map, etc.)
*/
export default class TerriaViewer {
readonly terria: Terria;
private _baseMap: MappableMixin.Instance | undefined;
/**
* Tracks the basemap that is currently being loaded
*/
private _loadingBaseMap: MappableMixin.Instance | undefined;
get baseMap() {
return this._baseMap;
}
/**
* Returns the basemap that is currently loading
*/
get loadingBaseMap(): MappableMixin.Instance | undefined {
return this._loadingBaseMap;
}
async setBaseMap(baseMap?: MappableMixin.Instance): Promise<void> {
if (!baseMap) return;
runInAction(() => {
this._loadingBaseMap = baseMap;
});
try {
const result = await baseMap.loadMapItems();
if (result.error) {
result.raiseError(this.terria, {
title: {
key: "models.terria.loadingBaseMapErrorTitle",
parameters: {
name:
(CatalogMemberMixin.isMixedInto(baseMap)
? baseMap.name
: baseMap.uniqueId) ?? "Unknown item"
}
}
});
} else {
runInAction(() => {
// Concurrent attempts to load basemap might not complete in the same
// order they were called. Set as current basemap only if this was
// the last call to setBaseMap.
if (this._loadingBaseMap === baseMap) {
// If the basemap specifies a preferred viewer mode, switch to it.
if (baseMap.preferredViewerMode) {
this.viewerMode =
getViewerType(baseMap.preferredViewerMode) ?? this.viewerMode;
}
this._baseMap = baseMap;
}
});
}
} finally {
// Unset loadingBaseMap
if (this._loadingBaseMap === baseMap) {
runInAction(() => {
this._loadingBaseMap = undefined;
});
}
}
}
/**
* Switch to a base map that is compatible with the current viewer
*
* @returns A promise that yields `true` if the switch was made.
*/
async useViewerCompatibleBaseMap(): Promise<boolean> {
const currentViewerType = this.viewerMode;
const baseMapViewerType = this.baseMap?.preferredViewerMode
? getViewerType(this.baseMap.preferredViewerMode)
: undefined;
if (!baseMapViewerType || baseMapViewerType === currentViewerType) {
return false;
}
// Select a base map that either does not require a specific viewer mode or
// specifies a compatible mode.
const compatibleBaseMap = this.terria.baseMapsModel.baseMapItems.find(
(it) =>
!it.item.preferredViewerMode ||
getViewerType(it.item.preferredViewerMode) === currentViewerType
)?.item;
return compatibleBaseMap
? this.setBaseMap(compatibleBaseMap).then(() => true)
: false;
}
// This is a "view" of a workbench/other
readonly items:
| IComputedValue<MappableMixin.Instance[]>
| IObservableValue<MappableMixin.Instance[]>;
viewerMode: ViewerMode | undefined = ViewerMode.Cesium;
// Set by UI
viewerOptions: ViewerOptions = viewerOptionsDefaults;
// Disable all mouse (& keyboard) interaction
disableInteraction: boolean = false;
_homeCamera: CameraView = new CameraView(Rectangle.MAX_VALUE);
get homeCamera() {
return this._homeCamera;
}
set homeCamera(cameraView: CameraView) {
if (isEqual(this._homeCamera.rectangle, Rectangle.MAX_VALUE)) {
this.currentViewer.zoomTo(cameraView, 0.0);
}
this._homeCamera = cameraView;
}
mapContainer: string | HTMLElement | undefined;
/**
* The distance between two pixels at the bottom center of the screen.
* Set in lib/ReactViews/Map/Legend/DistanceLegend.jsx
*/
scale: number = 1;
readonly beforeViewerChanged = new CesiumEvent();
readonly afterViewerChanged = new CesiumEvent();
constructor(terria: Terria, items: IComputedValue<MappableMixin.Instance[]>) {
makeObservable(this);
this.terria = terria;
this.items = items;
if (!this.viewerChangeTracker) {
this.viewerChangeTracker = reaction(
() => this.currentViewer,
() => {
this.afterViewerChanged.raiseEvent();
}
);
}
}
get attached(): boolean {
return this.mapContainer !== undefined;
}
private _lastViewer: GlobeOrMap | undefined;
viewerChangeTracker: IReactionDisposer | undefined = undefined;
/**
* Promise for async loading of current `viewerMode`
* Starts when TerriaViewer is attached to a div and `viewerMode` is set
*/
get viewerLoadPromise(): Promise<void> {
return Promise.resolve(this._currentViewerConstructorPromise).then(
() => {}
);
}
/**
* Get a mobx-utils promise to a constructor for currentViewer. Start loading
* Leaflet or Cesium depending on `viewerMode` if attached to a div
*/
get _currentViewerConstructorPromise() {
let viewerFromPromise: IPromiseBasedObservable<
new (
terriaViewer: TerriaViewer,
container: string | HTMLElement
) => GlobeOrMap
> = fromPromise.resolve(NoViewer) as IPromiseBasedObservable<
typeof NoViewer
>;
if (this.attached && this.viewerMode === ViewerMode.Leaflet) {
viewerFromPromise = leafletFromPromise.get();
} else if (this.attached && this.viewerMode === ViewerMode.Cesium) {
viewerFromPromise = cesiumFromPromise.get();
}
return viewerFromPromise;
}
get currentViewer(): GlobeOrMap {
// Use untracked on everything to ensure the viewer isn't recreated
// except when the viewer is required to change, the currently required
// viewer class finishes loading from an async chunk or the map container
// is changed
const currentView = untracked(() => this.destroyCurrentViewer());
let newViewer: GlobeOrMap;
try {
// If a div is attached and a viewer is ready, use it
if (
this.attached &&
this._currentViewerConstructorPromise.state === FULFILLED
) {
const SomeViewer = this._currentViewerConstructorPromise.value;
newViewer = untracked(() => new SomeViewer(this, this.mapContainer!));
} else {
newViewer = untracked(() => new NoViewer(this));
}
} catch (error) {
// Switch viewerMode inside computed. Could change viewers to
// guarantee no throw in constructor and instead have a `start()`
// method that can throw. Then call that `start()` method inside
// a reaction (reaction would also deal with viewer fallback).
// Using this approach might remove the need for `untracked`
setTimeout(
action(() => {
this.terria.raiseErrorToUser(error);
this.viewerMode =
this.viewerMode === ViewerMode.Cesium
? ViewerMode.Leaflet
: undefined;
}),
0
);
newViewer = untracked(() => new NoViewer(this));
}
this._lastViewer = newViewer;
newViewer.setInitialView(currentView || untracked(() => this.homeCamera));
return newViewer;
}
// Pull out attaching logic into it's own step. This allows constructing a TerriaViewer
// before its UI element is mounted in React to set basemap, items, viewermode
attach(mapContainer?: string | HTMLElement): void {
this.mapContainer = mapContainer;
}
detach(): void {
// Detach from a container
this.mapContainer = undefined;
this.destroyCurrentViewer();
}
destroy(): void {
this.detach();
this.viewerChangeTracker?.();
}
private destroyCurrentViewer() {
let currentView: CameraView | undefined;
if (this._lastViewer !== undefined) {
this.beforeViewerChanged.raiseEvent();
currentView = this._lastViewer.getCurrentCameraView();
this._lastViewer.destroy();
this._lastViewer = undefined;
}
return currentView;
}
}