terriajs
Version:
Geospatial data visualization platform.
1,428 lines (1,270 loc) • 69.1 kB
text/typescript
import i18next from "i18next";
import { isEqual } from "lodash-es";
import {
action,
autorun,
computed,
IObservableArray,
makeObservable,
observable,
reaction,
runInAction,
toJS
} from "mobx";
import { computedFn, fromPromise, IPromiseBasedObservable } from "mobx-utils";
import ionCreditLogo from "terriajs-cesium/Source/Assets/Images/ion-credit.png";
import AssociativeArray from "terriajs-cesium/Source/Core/AssociativeArray";
import BoundingSphere from "terriajs-cesium/Source/Core/BoundingSphere";
import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2";
import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3";
import Cartographic from "terriajs-cesium/Source/Core/Cartographic";
import CesiumTerrainProvider from "terriajs-cesium/Source/Core/CesiumTerrainProvider";
import createWorldTerrainAsync from "terriajs-cesium/Source/Core/createWorldTerrainAsync";
import Credit from "terriajs-cesium/Source/Core/Credit";
import defined from "terriajs-cesium/Source/Core/defined";
import destroyObject from "terriajs-cesium/Source/Core/destroyObject";
import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid";
import EllipsoidTerrainProvider from "terriajs-cesium/Source/Core/EllipsoidTerrainProvider";
import Event from "terriajs-cesium/Source/Core/Event";
import EventHelper from "terriajs-cesium/Source/Core/EventHelper";
import FeatureDetection from "terriajs-cesium/Source/Core/FeatureDetection";
import HeadingPitchRange from "terriajs-cesium/Source/Core/HeadingPitchRange";
import Ion from "terriajs-cesium/Source/Core/Ion";
import IonResource from "terriajs-cesium/Source/Core/IonResource";
import KeyboardEventModifier from "terriajs-cesium/Source/Core/KeyboardEventModifier";
import CesiumMath from "terriajs-cesium/Source/Core/Math";
import Matrix4 from "terriajs-cesium/Source/Core/Matrix4";
import PerspectiveFrustum from "terriajs-cesium/Source/Core/PerspectiveFrustum";
import Rectangle from "terriajs-cesium/Source/Core/Rectangle";
import sampleTerrain from "terriajs-cesium/Source/Core/sampleTerrain";
import ScreenSpaceEventHandler from "terriajs-cesium/Source/Core/ScreenSpaceEventHandler";
import ScreenSpaceEventType from "terriajs-cesium/Source/Core/ScreenSpaceEventType";
import TerrainProvider from "terriajs-cesium/Source/Core/TerrainProvider";
import Transforms from "terriajs-cesium/Source/Core/Transforms";
import BoundingSphereState from "terriajs-cesium/Source/DataSources/BoundingSphereState";
import DataSource from "terriajs-cesium/Source/DataSources/DataSource";
import DataSourceCollection from "terriajs-cesium/Source/DataSources/DataSourceCollection";
import DataSourceDisplay from "terriajs-cesium/Source/DataSources/DataSourceDisplay";
import Entity from "terriajs-cesium/Source/DataSources/Entity";
import getElement from "terriajs-cesium/Source/DataSources/getElement";
import Camera from "terriajs-cesium/Source/Scene/Camera";
import Cesium3DTileset from "terriajs-cesium/Source/Scene/Cesium3DTileset";
import CreditDisplay from "terriajs-cesium/Source/Scene/CreditDisplay";
import I3SDataProvider from "terriajs-cesium/Source/Scene/I3SDataProvider";
import ImageryLayer from "terriajs-cesium/Source/Scene/ImageryLayer";
import ImageryLayerCollection from "terriajs-cesium/Source/Scene/ImageryLayerCollection";
import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo";
import ImageryProvider from "terriajs-cesium/Source/Scene/ImageryProvider";
import PrimitiveCollection from "terriajs-cesium/Source/Scene/PrimitiveCollection";
import Scene from "terriajs-cesium/Source/Scene/Scene";
import SceneTransforms from "terriajs-cesium/Source/Scene/SceneTransforms";
import SingleTileImageryProvider from "terriajs-cesium/Source/Scene/SingleTileImageryProvider";
import SplitDirection from "terriajs-cesium/Source/Scene/SplitDirection";
import CesiumWidget from "terriajs-cesium/Source/Widget/CesiumWidget";
import filterOutUndefined from "../Core/filterOutUndefined";
import flatten from "../Core/flatten";
import isDefined from "../Core/isDefined";
import LatLonHeight from "../Core/LatLonHeight";
import pollToPromise from "../Core/pollToPromise";
import TerriaError from "../Core/TerriaError";
import waitForDataSourceToLoad from "../Core/waitForDataSourceToLoad";
import CesiumRenderLoopPauser from "../Map/Cesium/CesiumRenderLoopPauser";
import CesiumSelectionIndicator from "../Map/Cesium/CesiumSelectionIndicator";
import ProtomapsImageryProvider from "../Map/ImageryProvider/ProtomapsImageryProvider";
import PickedFeatures, {
ProviderCoordsMap
} from "../Map/PickedFeatures/PickedFeatures";
import Cesium3dTilesMixin from "../ModelMixins/Cesium3dTilesMixin";
import FeatureInfoUrlTemplateMixin from "../ModelMixins/FeatureInfoUrlTemplateMixin";
import MappableMixin, {
AbstractPrimitive,
ImageryParts,
isCesium3DTileset,
isDataSource,
isPrimitive,
isTerrainProvider,
MapItem
} from "../ModelMixins/MappableMixin";
import TileErrorHandlerMixin from "../ModelMixins/TileErrorHandlerMixin";
import SplitterTraits from "../Traits/TraitsClasses/SplitterTraits";
import TerriaViewer from "../ViewModels/TerriaViewer";
import CameraView from "./CameraView";
import hasTraits from "./Definition/hasTraits";
import TerriaFeature from "./Feature/Feature";
import GlobeOrMap from "./GlobeOrMap";
import Terria from "./Terria";
import UserDrawing from "./UserDrawing";
import { setViewerMode } from "./ViewerMode";
//import Cesium3DTilesInspector from "terriajs-cesium/Source/Widgets/Cesium3DTilesInspector/Cesium3DTilesInspector";
type CreditDisplayElement = {
credit: Credit;
count: number;
};
Ion.defaultTokenMessage =
'<b> \
This application is using Cesium\'s default ion access token. Please set "cesiumIonAccessToken" in config.json \
with an access token from your ion account. \
You can sign up for a free ion account at <a href="https://cesium.com">https://cesium.com</a>.</b>';
// Intermediary
const cartesian3Scratch = new Cartesian3();
const enuToFixedScratch = new Matrix4();
const southwestScratch = new Cartesian3();
const southeastScratch = new Cartesian3();
const northeastScratch = new Cartesian3();
const northwestScratch = new Cartesian3();
const southwestCartographicScratch = new Cartographic();
const southeastCartographicScratch = new Cartographic();
const northeastCartographicScratch = new Cartographic();
const northwestCartographicScratch = new Cartographic();
export default class Cesium extends GlobeOrMap {
readonly type = "Cesium";
readonly terria: Terria;
readonly terriaViewer: TerriaViewer;
readonly cesiumWidget: CesiumWidget;
readonly scene: Scene;
readonly pauser: CesiumRenderLoopPauser;
readonly canShowSplitter = true;
private readonly _eventHelper: EventHelper;
private _3dTilesetEventListeners = new Map<
Cesium3DTileset,
Event.RemoveCallback
>(); // eventListener reference storage
private _pauseMapInteractionCount = 0;
private _lastZoomTarget:
| CameraView
| Rectangle
| DataSource
| MappableMixin.Instance
| /*TODO Cesium.Cesium3DTileset*/ any;
// Lightbox and on screen attributions from CreditDisplay
private cesiumDataAttributions: IObservableArray<string> = observable([]);
// Public because this is accessed from BottomLeftBar.tsx
cesiumScreenDataAttributions: IObservableArray<string> = observable([]);
// When true, feature picking is paused. This is useful for temporarily
// disabling feature picking when some other interaction mode wants to take
// over the LEFT_CLICK behavior.
isFeaturePickingPaused = false;
/* Disposers */
private readonly _selectionIndicator: CesiumSelectionIndicator;
private readonly _disposeSelectedFeatureSubscription: () => void;
private readonly _disposeWorkbenchMapItemsSubscription: () => void;
private readonly _disposeTerrainReaction: () => void;
private readonly _disposeSplitterReaction: () => void;
private readonly _disposeResolutionReaction: () => void;
private _createImageryLayer: (
ip: ImageryProvider,
clippingRectangle: Rectangle | undefined
) => ImageryLayer = computedFn((ip, clippingRectangle) => {
return new ImageryLayer(ip, {
rectangle: clippingRectangle
});
});
private _terrainMessageViewed: boolean = false;
/**
* Initial view set when the viewer is created
*/
private _initialView: CameraView | undefined;
/**
* Collection to add Terria primitives.
*
* We maintain a separate collection to add primitives from Terria to avoid
* accidentally removing primitives that Cesium might add to the scene.
*/
readonly terriaPrimitives = new PrimitiveCollection();
constructor(terriaViewer: TerriaViewer, container: string | HTMLElement) {
super();
makeObservable(this);
this.terriaViewer = terriaViewer;
this.terria = terriaViewer.terria;
if (this.terria.configParameters.cesiumIonAccessToken !== undefined) {
Ion.defaultAccessToken =
this.terria.configParameters.cesiumIonAccessToken;
}
//An arbitrary base64 encoded image used to populate the placeholder SingleTileImageryProvider
const img =
"data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA \
AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO \
9TXL0Y4OHwAAAABJRU5ErkJggg==";
const options = {
clock: this.terria.timelineClock,
baseLayer: ImageryLayer.fromProviderAsync(
SingleTileImageryProvider.fromUrl(img),
{}
),
scene3DOnly: true,
shadows: true,
useBrowserRecommendedResolution: !this.terria.useNativeResolution,
targetFrameRate: 30
};
// Workaround for Firefox bug with WebGL and printing:
// https://bugzilla.mozilla.org/show_bug.cgi?id=976173
const firefoxBugOptions = FeatureDetection.isFirefox()
? {
contextOptions: {
webgl: { preserveDrawingBuffer: true }
}
}
: undefined;
try {
this.cesiumWidget = new CesiumWidget(
container,
Object.assign({}, options, firefoxBugOptions)
);
this.scene = this.cesiumWidget.scene;
this.scene.primitives.add(this.terriaPrimitives);
} catch (error) {
throw TerriaError.from(error, {
message: {
key: "terriaViewer.slowWebGLAvailableMessageII",
parameters: { appName: this.terria.appName, webGL: "WebGL" }
}
});
}
//new Cesium3DTilesInspector(document.getElementsByClassName("cesium-widget").item(0), this.scene);
this._selectionIndicator = new CesiumSelectionIndicator(this);
this.supportsPolylinesOnTerrain = (this.scene as any).context.depthTexture;
this._eventHelper = new EventHelper();
// Progress
this._eventHelper.add(
this.scene.globe.tileLoadProgressEvent,
(currentLoadQueueLength: number) =>
this._updateTilesLoadingCount(currentLoadQueueLength)
);
// Disable HDR lighting for better performance and to avoid changing imagery colors.
(this.scene as any).highDynamicRange = false;
this.scene.imageryLayers.removeAll();
this.updateCredits(container);
this.scene.globe.depthTestAgainstTerrain = false;
this.scene.renderError.addEventListener(this.onRenderError.bind(this));
const inputHandler = this.cesiumWidget.screenSpaceEventHandler;
// // Add double click zoom
// inputHandler.setInputAction(
// function (movement) {
// zoomIn(scene, movement.position);
// },
// ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
// inputHandler.setInputAction(
// function (movement) {
// zoomOut(scene, movement.position);
// },
// ScreenSpaceEventType.LEFT_DOUBLE_CLICK, KeyboardEventModifier.SHIFT);
// Handle mouse move
inputHandler.setInputAction((e: ScreenSpaceEventHandler.MotionEvent) => {
this.mouseCoords.updateCoordinatesFromCesium(this.terria, e.endPosition);
}, ScreenSpaceEventType.MOUSE_MOVE);
inputHandler.setInputAction(
(e: ScreenSpaceEventHandler.MotionEvent) => {
this.mouseCoords.updateCoordinatesFromCesium(
this.terria,
e.endPosition
);
},
ScreenSpaceEventType.MOUSE_MOVE,
KeyboardEventModifier.SHIFT
);
// Handle left click by picking objects from the map.
inputHandler.setInputAction(
(e: ScreenSpaceEventHandler.PositionedEvent) => {
if (!this.isFeaturePickingPaused)
this.pickFromScreenPosition(e.position, false);
},
ScreenSpaceEventType.LEFT_CLICK
);
let zoomUserDrawing: UserDrawing | undefined;
// Handle zooming on SHIFT + MOUSE DOWN
inputHandler.setInputAction(
(e: ScreenSpaceEventHandler.PositionedEvent) => {
if (!this.isFeaturePickingPaused && !isDefined(zoomUserDrawing)) {
this.pauseMapInteraction();
const exitZoom = () => {
document.removeEventListener("keyup", onKeyUp);
runInAction(() => {
this.terria.mapInteractionModeStack.pop();
if (zoomUserDrawing) {
zoomUserDrawing.cleanUp();
}
});
this.resumeMapInteraction();
zoomUserDrawing = undefined;
};
// If the shift key is released -> exit zoom
const onKeyUp = (e: KeyboardEvent) =>
e.key === "Shift" && zoomUserDrawing && exitZoom();
document.addEventListener("keyup", onKeyUp);
let pointClickCount = 0;
zoomUserDrawing = new UserDrawing({
terria: this.terria,
messageHeader: i18next.t("map.drawExtentHelper.drawExtent"),
onPointClicked: () => {
pointClickCount++;
if (
zoomUserDrawing &&
zoomUserDrawing.pointEntities.entities.values.length >= 2
) {
const rectangle = zoomUserDrawing.otherEntities.entities
.getById("rectangle")
?.rectangle?.coordinates?.getValue(
this.terria.timelineClock.currentTime
);
if (rectangle) this.zoomTo(rectangle, 1);
exitZoom();
// If more than two points are clicked but a rectangle hasn't been drawn -> exit zoom
} else if (pointClickCount >= 2) {
exitZoom();
}
},
allowPolygon: false,
drawRectangle: true,
invisible: true
});
zoomUserDrawing.enterDrawMode();
// Pick first point of rectangle on start
this.pickFromScreenPosition(e.position, false);
}
},
ScreenSpaceEventType.LEFT_DOWN,
KeyboardEventModifier.SHIFT
);
// Handle SHIFT + CLICK for zooming
inputHandler.setInputAction(
(e: ScreenSpaceEventHandler.PositionedEvent) => {
if (isDefined(zoomUserDrawing)) {
this.pickFromScreenPosition(e.position, false);
}
},
ScreenSpaceEventType.LEFT_UP,
KeyboardEventModifier.SHIFT
);
this.pauser = new CesiumRenderLoopPauser(this.cesiumWidget, () => {
// Post render, update selection indicator position
const feature = this.terria.selectedFeature;
// If the feature has an associated primitive and that primitive has
// a clamped position, use that instead, because the regular
// position doesn't take terrain clamping into account.
if (isDefined(feature)) {
if (
isDefined(feature.cesiumPrimitive) &&
isDefined(feature.cesiumPrimitive._clampedPosition)
) {
this._selectionIndicator.position =
feature.cesiumPrimitive._clampedPosition;
} else if (
isDefined(feature.cesiumPrimitive) &&
isDefined(feature.cesiumPrimitive._clampedModelMatrix)
) {
this._selectionIndicator.position = Matrix4.getTranslation(
feature.cesiumPrimitive._clampedModelMatrix,
this._selectionIndicator.position || new Cartesian3()
);
} else if (isDefined(feature.position)) {
this._selectionIndicator.position = feature.position.getValue(
this.terria.timelineClock.currentTime
);
}
}
this._selectionIndicator.update();
});
this._disposeSelectedFeatureSubscription = autorun(() => {
this._selectFeature();
});
this._disposeWorkbenchMapItemsSubscription = this.observeModelLayer();
this._disposeTerrainReaction = autorun(() => {
this.cesiumWidget.terrainProvider = this.terrainProvider;
this.scene.globe.splitDirection = this.terria.showSplitter
? this.terria.terrainSplitDirection
: SplitDirection.NONE;
this.scene.globe.depthTestAgainstTerrain =
this.terria.depthTestAgainstTerrainEnabled;
if (this.scene.skyAtmosphere) {
this.scene.skyAtmosphere.splitDirection =
this.scene.globe.splitDirection;
}
});
this._disposeSplitterReaction = this._reactToSplitterChanges();
this._disposeResolutionReaction = autorun(() => {
(this.cesiumWidget as any).useBrowserRecommendedResolution =
!this.terria.useNativeResolution;
this.cesiumWidget.scene.globe.maximumScreenSpaceError =
this.terria.baseMaximumScreenSpaceError;
});
}
get dataSources(): DataSourceCollection {
return this.cesiumWidget.dataSources;
}
get dataSourceDisplay(): DataSourceDisplay {
return this.cesiumWidget.dataSourceDisplay;
}
/** Add an event listener to a TerrainProvider.
* If we get an error when trying to load the terrain, then switch to smooth mode, and notify the user.
* Finally, remove the listener, so failed tiles do not trigger the error as these can be common and are not a problem. */
private async catchTerrainProviderDown(
terrainProviderPromise: Promise<TerrainProvider>
): Promise<TerrainProvider> {
return terrainProviderPromise
.then((terrainProvider: TerrainProvider) => {
/** Need to throw an error if incorrect `cesiumTerrainUrl` has been specified.
The terrainProvider.readyPromise will still be fulfulled, but the map will not load correctly
So we check for terrainProvider.availability */
if (!terrainProvider.availability) {
throw new Error();
}
return terrainProvider;
})
.catch((err) => {
console.log("Terrain provider error. ", err.message);
console.log("Switching to EllipsoidTerrainProvider.");
setViewerMode("3dsmooth", this.terriaViewer);
if (!this._terrainMessageViewed) {
this.terria.raiseErrorToUser(err, {
title: i18next.t("map.cesium.terrainServerErrorTitle"),
message: i18next.t("map.cesium.terrainServerErrorMessage", {
appName: this.terria.appName,
supportEmail: this.terria.supportEmail
})
});
this._terrainMessageViewed = true;
}
return new EllipsoidTerrainProvider();
});
}
private updateCredits(container: string | HTMLElement) {
const containerElement = getElement(container);
const creditsElement =
containerElement &&
(containerElement.getElementsByClassName(
"cesium-widget-credits"
)[0] as HTMLElement);
const logoContainer =
creditsElement &&
(creditsElement.getElementsByClassName(
"cesium-credit-logoContainer"
)[0] as HTMLElement);
const expandLink =
creditsElement &&
creditsElement.getElementsByClassName("cesium-credit-expand-link") &&
(creditsElement.getElementsByClassName(
"cesium-credit-expand-link"
)[0] as HTMLElement);
if (creditsElement) {
if (logoContainer) creditsElement?.removeChild(logoContainer);
if (expandLink) creditsElement?.removeChild(expandLink);
}
const creditDisplay: CreditDisplay & {
_currentFrameCredits?: {
lightboxCredits: AssociativeArray;
screenCredits: AssociativeArray;
};
} = this.scene.frameState.creditDisplay;
const creditDisplayOldDestroy = creditDisplay.destroy;
creditDisplay.destroy = () => {
try {
creditDisplayOldDestroy();
} catch (_err) {
/* TODO: handle Error */
}
};
const creditDisplayOldEndFrame = creditDisplay.endFrame;
creditDisplay.endFrame = () => {
creditDisplayOldEndFrame.bind(creditDisplay)();
runInAction(() => {
syncCesiumCreditsToAttributions(
creditDisplay._currentFrameCredits!.lightboxCredits
.values as CreditDisplayElement[],
this.cesiumDataAttributions
);
syncCesiumCreditsToAttributions(
creditDisplay._currentFrameCredits!.screenCredits
.values as CreditDisplayElement[],
this.cesiumScreenDataAttributions
);
});
};
}
getContainer(): Element {
return this.cesiumWidget.container;
}
pauseMapInteraction(): void {
++this._pauseMapInteractionCount;
if (this._pauseMapInteractionCount === 1) {
this.scene.screenSpaceCameraController.enableInputs = false;
}
}
resumeMapInteraction(): void {
--this._pauseMapInteractionCount;
if (this._pauseMapInteractionCount === 0) {
setTimeout(() => {
if (this._pauseMapInteractionCount === 0) {
this.scene.screenSpaceCameraController.enableInputs = true;
}
}, 0);
}
}
private previousRenderError: string | undefined;
/** Show error message to user if Cesium stops rendering. */
private onRenderError(_scene: Scene, error: unknown) {
// This function can be called many times with the same error
// So we do a rudimentary check to only show the error message once
// - by comparing error.toString() to this.previousRenderError
if (typeof error === "object" && error !== null) {
const newError = error.toString();
if (newError !== this.previousRenderError) {
this.previousRenderError = error.toString();
this.terria.raiseErrorToUser(error, {
title: i18next.t("map.cesium.stoppedRenderingTitle"),
message: i18next.t("map.cesium.stoppedRenderingMessage", {
appName: this.terria.appName
})
});
}
}
}
destroy(): void {
// Port old Cesium.prototype.destroy stuff
// this._enableSelectExtent(cesiumWidget.scene, false);
this.scene.renderError.removeEventListener(this.onRenderError);
const inputHandler = this.cesiumWidget.screenSpaceEventHandler;
inputHandler.removeInputAction(ScreenSpaceEventType.MOUSE_MOVE);
inputHandler.removeInputAction(
ScreenSpaceEventType.MOUSE_MOVE,
KeyboardEventModifier.SHIFT
);
// inputHandler.removeInputAction(ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
// inputHandler.removeInputAction(ScreenSpaceEventType.LEFT_DOUBLE_CLICK, KeyboardEventModifier.SHIFT);
inputHandler.removeInputAction(ScreenSpaceEventType.LEFT_CLICK);
inputHandler.removeInputAction(
ScreenSpaceEventType.LEFT_DOWN,
KeyboardEventModifier.SHIFT
);
inputHandler.removeInputAction(
ScreenSpaceEventType.LEFT_UP,
KeyboardEventModifier.SHIFT
);
// if (defined(this.monitor)) {
// this.monitor.destroy();
// this.monitor = undefined;
// }
if (isDefined(this._selectionIndicator)) {
this._selectionIndicator.destroy();
}
this.pauser.destroy();
this.stopObserving();
this._eventHelper.removeAll();
this._updateTilesLoadingIndeterminate(false); // reset progress bar loading state to false for any data sources with indeterminate progress e.g. 3DTilesets.
this._disposeTerrainReaction();
this._disposeResolutionReaction();
this._disposeSelectedFeatureSubscription();
this._disposeSplitterReaction();
this.cesiumWidget.destroy();
destroyObject(this);
}
@computed
get attributions() {
return this.cesiumDataAttributions;
}
private get _allMappables() {
const catalogItems = [
...this.terriaViewer.items.get(),
this.terriaViewer.baseMap
];
return flatten(
filterOutUndefined(
catalogItems.map((item) => {
if (isDefined(item) && MappableMixin.isMixedInto(item))
return item.mapItems.map((mapItem) => ({ mapItem, item }));
})
)
);
}
@computed
private get _allMapItems(): MapItem[] {
return this._allMappables.map(({ mapItem }) => mapItem);
}
@computed
private get availablePrimitives(): AbstractPrimitive[] {
return this._allMapItems.filter(isPrimitive);
}
@computed
private get availableDataSources(): DataSource[] {
return this._allMapItems.filter(isDataSource);
}
private observeModelLayer() {
// Setup reaction to sync datSources collection with availableDataSources.
// To avoid buggy concurrent syncs, we have to ensure that even when
// multiple sync reactions are triggered, we run them one after the
// other. To do this, we make each run of the reaction wait for the
// previous `syncDataSourcesPromise` to finish before starting itself.
let syncDataSourcesPromise: Promise<void> = Promise.resolve();
const disposeDataSourcesSyncReaction = reaction(
() => this.availableDataSources,
() => {
syncDataSourcesPromise = syncDataSourcesPromise
.then(async () => {
await this.syncDataSourceCollection(
this.availableDataSources,
this.dataSources
);
this.notifyRepaintRequired();
})
.catch(console.error);
},
{ fireImmediately: true }
);
const disposeImagerySyncReaction = autorun(() => {
this.syncImagery();
this.notifyRepaintRequired();
});
const disposePrimitivesSyncReaction = autorun(() => {
this.syncPrimitives();
this.notifyRepaintRequired();
});
return () => {
disposeDataSourcesSyncReaction();
disposeImagerySyncReaction();
disposePrimitivesSyncReaction();
};
}
/**
* Syncs the `dataSources` collection against the latest `availableDataSources`.
*
* @return Promise for finishing the sync
*/
private async syncDataSourceCollection(
availableDataSources: DataSource[],
dataSources: DataSourceCollection
): Promise<void> {
// 1. Remove deleted data sources
// Iterate backwards because we're removing items.
for (let i = dataSources.length - 1; i >= 0; i--) {
const d = dataSources.get(i);
if (availableDataSources.indexOf(d) === -1) {
dataSources.remove(d);
}
}
// 2. Add new data sources
for (const ds of availableDataSources) {
if (!dataSources.contains(ds)) {
await dataSources.add(ds);
}
}
// 3. Ensure stacking order matches order in `availableDataSources` - first item appears on top.
runInAction(() =>
availableDataSources.forEach((ds) => {
// There is a buggy/un-intended side-effect when calling raiseToTop() with
// a source that doesn't exist in the collection. Doing this will replace
// the last entry in the collection with the new one. So we should be
// careful to raiseToTop() only if the DS already exists in the collection.
// Relevant code:
// https://github.com/CesiumGS/cesium/blob/dbd452328a48bfc4e192146862a9f8fa15789dc8/packages/engine/Source/DataSources/DataSourceCollection.js#L298-L299
if (dataSources.contains(ds)) {
dataSources.raiseToTop(ds);
}
})
);
}
/**
* Sync imagery layers added to the viewer with Cesium Scene and Tilesets
*/
private syncImagery() {
const topImageryLayers: ImageryLayer[] = [];
this._allMappables.forEach((m) => {
if (ImageryParts.is(m.mapItem)) {
const imageryLayer = this._makeImageryLayerFromParts(m.mapItem, m.item);
if (imageryLayer) {
topImageryLayers.push(imageryLayer);
}
} else if (
Cesium3dTilesMixin.isMixedInto(m.item) &&
m.mapItem instanceof Cesium3DTileset
) {
// Drape imagery on tilesets that have draping enabled. If draping is
// disabled, we still need to call sync with an empty imagery array to
// force removal of any imagery layers that were previously added.
const tileset = m.mapItem;
syncImageryCollection(
tileset.imageryLayers,
m.item.drapeImagery ? topImageryLayers : [],
false
);
}
});
// Finally add imagery layers to the global scene.
syncImageryCollection(this.scene.imageryLayers, topImageryLayers, true);
}
/**
* Sync primitives added to the viewer with Cesium Scene
*/
private syncPrimitives() {
const mapPrimitives = this.availablePrimitives.filter(isPrimitive);
const primitiveCollection = this.terriaPrimitives;
// Remove primitives that are no longer needed.
// Iterate backwards because we're removing items.
for (let i = primitiveCollection.length - 1; i >= 0; i--) {
const primitive = primitiveCollection.get(i);
if (mapPrimitives.indexOf(primitive) === -1) {
if (isCesium3DTileset(primitive)) {
this.unsubscribeTileLoadEvents(primitive);
}
primitiveCollection.remove(primitive);
}
}
// Add new primitives
mapPrimitives.forEach((primitive) => {
if (!primitiveCollection.contains(primitive)) {
primitiveCollection.add(primitive);
if (isCesium3DTileset(primitive)) {
this.subscribeTileLoadEvents(primitive);
}
}
});
}
private subscribeTileLoadEvents(primitive: Cesium3DTileset) {
const startingListener = this._eventHelper.add(primitive.tileLoad, () =>
this._updateTilesLoadingIndeterminate(true)
);
//Add event listener for when tiles finished loading for current view. Infrequent.
const finishedListener = this._eventHelper.add(
primitive.allTilesLoaded,
() => this._updateTilesLoadingIndeterminate(false)
);
this._3dTilesetEventListeners.set(
primitive,
function unregisterListeners() {
startingListener();
finishedListener();
}
);
}
private unsubscribeTileLoadEvents(primitive: Cesium3DTileset) {
this._3dTilesetEventListeners.get(primitive)?.();
this._3dTilesetEventListeners.delete(primitive);
// reset progress bar loading state to false. Any new tile loading event
// will restart it to account for multiple currently loading 3DTilesets.
this._updateTilesLoadingIndeterminate(false);
}
stopObserving(): void {
if (this._disposeWorkbenchMapItemsSubscription !== undefined) {
this._disposeWorkbenchMapItemsSubscription();
}
}
doZoomTo(target: any, flightDurationSeconds = 3.0): Promise<void> {
this._lastZoomTarget = target;
const _zoom: () => Promise<void> = async () => {
const camera = this.scene.camera;
if (target instanceof Rectangle) {
// target is a Rectangle
// Work out the destination that the camera would naturally fly to
const destinationCartesian =
camera.getRectangleCameraCoordinates(target);
const destination =
Ellipsoid.WGS84.cartesianToCartographic(destinationCartesian);
const terrainProvider = this.scene.globe.terrainProvider;
// A sufficiently coarse tile level that still has approximately accurate height
const level = 6;
const center = Rectangle.center(target);
// Perform an elevation query at the centre of the rectangle
let terrainSample: Cartographic;
try {
[terrainSample] = await sampleTerrain(terrainProvider, level, [
center
]);
} catch {
// if the request fails just use center with height=0
terrainSample = center;
}
if (this._lastZoomTarget !== target) {
return;
}
const finalDestinationCartographic = new Cartographic(
destination.longitude,
destination.latitude,
destination.height + terrainSample.height
);
const finalDestination = Ellipsoid.WGS84.cartographicToCartesian(
finalDestinationCartographic
);
return flyToPromise(camera, {
duration: flightDurationSeconds,
destination: finalDestination
});
} else if (defined(target.entities)) {
// target is some DataSource
return waitForDataSourceToLoad(target).then(() => {
if (this._lastZoomTarget === target) {
return zoomToDataSource(this, target, flightDurationSeconds);
}
});
} else if (
// check for readyPromise first because cesium raises an exception when
// accessing `.boundingSphere` before ready
defined(target.readyPromise) ||
defined(target.boundingSphere)
) {
// target is some object like a Model with boundingSphere and possibly a readyPromise
return Promise.resolve(target.readyPromise).then(() => {
if (this._lastZoomTarget === target) {
return flyToBoundingSpherePromise(camera, target.boundingSphere, {
offset: new HeadingPitchRange(
0,
-0.5,
// To avoid getting too close to models less than 100m radius, let
// cesium calculate an appropriate zoom distance. For the rest
// use the radius as the zoom distance because the offset
// distance cesium calculates for large models is often too far away.
target.boundingSphere.radius < 100
? undefined
: target.boundingSphere.radius
),
duration: flightDurationSeconds
});
}
});
} else if (target.position instanceof Cartesian3) {
// target is a CameraView or an Entity
return flyToPromise(camera, {
duration: flightDurationSeconds,
destination: target.position,
orientation: {
direction: target.direction,
up: target.up
}
});
} else if (MappableMixin.isMixedInto(target)) {
// target is a Mappable
if (isDefined(target.cesiumRectangle)) {
return flyToPromise(camera, {
duration: flightDurationSeconds,
destination: target.cesiumRectangle
});
} else if (target.mapItems.length > 0) {
// Zoom to the first item!
return this.doZoomTo(target.mapItems[0], flightDurationSeconds);
} else {
return Promise.resolve();
}
} else if (defined(target.rectangle)) {
// target has a rectangle
return flyToPromise(camera, {
duration: flightDurationSeconds,
destination: target.rectangle
});
} else if (
defined(target.imageryProvider) &&
defined(target.imageryProvider.rectangle)
) {
return flyToPromise(camera, {
duration: flightDurationSeconds,
destination: target.imageryProvider.rectangle
});
} else {
return Promise.resolve();
}
};
// we call notifyRepaintRequired before and after the zoom
// to wake the cesium render loop which might pause itself after
// some idle time
this.notifyRepaintRequired();
return _zoom().finally(() => this.notifyRepaintRequired());
}
setInitialView(view: CameraView) {
this.doZoomTo(view, 0);
this._initialView = view;
const removeListener = this.scene.camera.changed.addEventListener(() => {
this._initialView = undefined;
removeListener();
});
}
notifyRepaintRequired(): void {
this.pauser.notifyRepaintRequired();
}
_reactToSplitterChanges() {
const disposeSplitPositionChange = autorun(() => {
if (this.scene) {
this.scene.splitPosition = this.terria.splitPosition;
this.notifyRepaintRequired();
}
});
const disposeSplitDirectionChange = autorun(() => {
const items = this.terriaViewer.items.get();
const showSplitter = this.terria.showSplitter;
items.forEach((item) => {
if (
MappableMixin.isMixedInto(item) &&
hasTraits(item, SplitterTraits, "splitDirection")
) {
const splittableItems = this.getSplittableMapItems(item);
const splitDirection = item.splitDirection;
splittableItems.forEach((splittableItem) => {
if (showSplitter) {
splittableItem.splitDirection = splitDirection;
} else {
splittableItem.splitDirection = SplitDirection.NONE;
}
});
}
});
this.notifyRepaintRequired();
});
return function () {
disposeSplitPositionChange();
disposeSplitDirectionChange();
};
}
/**
* Helper method to clone a camera object
* @param camera
* @returns Camera
*/
private cloneCamera(camera: Camera): Camera {
const result = new Camera(this.scene);
Cartesian3.clone(camera.position, result.position);
Cartesian3.clone(camera.direction, result.direction);
Cartesian3.clone(camera.up, result.up);
Cartesian3.clone(camera.right, result.right);
Matrix4.clone(camera.transform, result.transform);
result.frustum = camera.frustum.clone();
return result;
}
getCurrentCameraView(): CameraView {
// Return the initial view if the camera hasn't changed since setting it.
// This ensures that the view remains constant when switching between
// viewer modes.
if (this._initialView) {
return this._initialView;
}
const scene = this.scene;
const camera = scene.camera;
const width = scene.canvas.clientWidth;
const height = scene.canvas.clientHeight;
const centerOfScreen = new Cartesian2(width / 2.0, height / 2.0);
const pickRay = scene.camera.getPickRay(centerOfScreen);
const center = isDefined(pickRay)
? scene.globe.pick(pickRay, scene) // will be undefined if we are facing above the horizon
: undefined;
if (!center) {
/** In cases where the horizon is not visible, we cannot calculate a center using a pick ray,
but we need to return a useful CameraView that works in 3D mode and 2D mode.
In this case we can return the correct definition for the cesium camera, with position, direction, and up,
but we need to calculate a bounding box on the ellipsoid too to be used in 2D mode.
To do this we clone the camera, rotate it to point straight down, and project the camera view from that position onto the ellipsoid.
**/
// Clone the camera
const cameraClone = this.cloneCamera(camera);
// Rotate camera straight down
cameraClone.setView({
orientation: {
heading: 0.0,
pitch: -CesiumMath.PI_OVER_TWO,
roll: 0.0
}
});
// Compute the bounding box on the ellipsoid
const rectangleFor2dView = cameraClone.computeViewRectangle(
this.scene.globe.ellipsoid
);
// Return the combined CameraView object
return new CameraView(
rectangleFor2dView || this.terriaViewer.homeCamera.rectangle, //TODO: Is this fallback appropriate?
camera.positionWC,
camera.directionWC,
camera.upWC
);
}
const ellipsoid = this.scene.globe.ellipsoid;
const frustrum = scene.camera.frustum as PerspectiveFrustum;
const fovy = (frustrum.fovy ?? 0) * 0.5;
const fovx = Math.atan(Math.tan(fovy) * (frustrum.aspectRatio ?? 0));
const cameraOffset = Cartesian3.subtract(
camera.positionWC,
center,
cartesian3Scratch
);
const cameraHeight = Cartesian3.magnitude(cameraOffset);
const xDistance = cameraHeight * Math.tan(fovx);
const yDistance = cameraHeight * Math.tan(fovy);
const southwestEnu = new Cartesian3(-xDistance, -yDistance, 0.0);
const southeastEnu = new Cartesian3(xDistance, -yDistance, 0.0);
const northeastEnu = new Cartesian3(xDistance, yDistance, 0.0);
const northwestEnu = new Cartesian3(-xDistance, yDistance, 0.0);
const enuToFixed = Transforms.eastNorthUpToFixedFrame(
center,
ellipsoid,
enuToFixedScratch
);
const southwest = Matrix4.multiplyByPoint(
enuToFixed,
southwestEnu,
southwestScratch
);
const southeast = Matrix4.multiplyByPoint(
enuToFixed,
southeastEnu,
southeastScratch
);
const northeast = Matrix4.multiplyByPoint(
enuToFixed,
northeastEnu,
northeastScratch
);
const northwest = Matrix4.multiplyByPoint(
enuToFixed,
northwestEnu,
northwestScratch
);
const southwestCartographic = ellipsoid.cartesianToCartographic(
southwest,
southwestCartographicScratch
);
const southeastCartographic = ellipsoid.cartesianToCartographic(
southeast,
southeastCartographicScratch
);
const northeastCartographic = ellipsoid.cartesianToCartographic(
northeast,
northeastCartographicScratch
);
const northwestCartographic = ellipsoid.cartesianToCartographic(
northwest,
northwestCartographicScratch
);
// Account for date-line wrapping
if (southeastCartographic.longitude < southwestCartographic.longitude) {
southeastCartographic.longitude += CesiumMath.TWO_PI;
}
if (northeastCartographic.longitude < northwestCartographic.longitude) {
northeastCartographic.longitude += CesiumMath.TWO_PI;
}
const rect = new Rectangle(
CesiumMath.convertLongitudeRange(
Math.min(
southwestCartographic.longitude,
northwestCartographic.longitude
)
),
Math.min(southwestCartographic.latitude, southeastCartographic.latitude),
CesiumMath.convertLongitudeRange(
Math.max(
northeastCartographic.longitude,
southeastCartographic.longitude
)
),
Math.max(northeastCartographic.latitude, northwestCartographic.latitude)
);
// center isn't a member variable and doesn't seem to be used anywhere else in Terria
// rect.center = center;
return new CameraView(
rect,
camera.positionWC,
camera.directionWC,
camera.upWC
);
}
@computed
private get _firstMapItemTerrainProvider(): TerrainProvider | undefined {
// Get the top map item that is a terrain provider, if any are
return this._allMapItems.find(isTerrainProvider);
}
// It's nice to co-locate creation of Ion TerrainProvider and Credit, but not necessary
@computed
private get _terrainWithCredits(): {
terrainProviderPromise: Promise<TerrainProvider>;
credit?: Credit;
} {
if (!this.terriaViewer.viewerOptions.useTerrain) {
// Terrain mode is off, use the ellipsoidal terrain (aka 3d-smooth)
return {
terrainProviderPromise: Promise.resolve(new EllipsoidTerrainProvider())
};
} else if (this._firstMapItemTerrainProvider) {
// If there's a TerrainProvider in map items/workbench then use it
return {
terrainProviderPromise: Promise.resolve(
this._firstMapItemTerrainProvider
)
};
} else if (
this.terria.configParameters.cesiumTerrainAssetId !== undefined
) {
// Load the terrain provider from Ion
return {
terrainProviderPromise: this.createTerrainProviderFromIonAssetId(
this.terria.configParameters.cesiumTerrainAssetId,
this.terria.configParameters.cesiumIonAccessToken
)
};
} else if (this.terria.configParameters.cesiumTerrainUrl) {
// Load the terrain provider from the given URL
return {
terrainProviderPromise: this.createTerrainProviderFromUrl(
this.terria.configParameters.cesiumTerrainUrl
)
};
} else if (this.terria.configParameters.useCesiumIonTerrain) {
// Use Cesium ION world Terrain
const logo = ionCreditLogo;
const ionCredit = new Credit(
'<a href="https://cesium.com/" target="_blank" rel="noopener noreferrer"><img src="' +
logo +
'" title="Cesium ion"/></a>',
true
);
return {
terrainProviderPromise: this.createWorldTerrain(),
credit: ionCredit
};
} else {
// Default to ellipsoid/3d-smooth
return {
terrainProviderPromise: Promise.resolve(new EllipsoidTerrainProvider())
};
}
}
/**
* Returns terrainProvider from `configParameters.cesiumTerrainAssetId` when set or `undefined`.
*
* Used for spying in specs
*/
private createTerrainProviderFromIonAssetId(
assetId: number,
accessToken?: string
): Promise<TerrainProvider> {
const terrainProvider = CesiumTerrainProvider.fromUrl(
IonResource.fromAssetId(assetId, {
accessToken
})
);
// Add the event handler to the TerrainProvider
return this.catchTerrainProviderDown(terrainProvider);
}
/**
* Returns terrainProvider from `configParameters.cesiumTerrainAssetId` when set or `undefined`.
*
* Used for spying in specs
*/
private createTerrainProviderFromUrl(url: string): Promise<TerrainProvider> {
return this.catchTerrainProviderDown(CesiumTerrainProvider.fromUrl(url));
}
/**
* Creates cesium-world-terrain.
*
* Used for spying in specs
*/
private createWorldTerrain(): Promise<TerrainProvider> {
return this.catchTerrainProviderDown(createWorldTerrainAsync({}));
}
/**
* An observable terrain provider promise
*/
@computed
private get observableTerrainProviderPromise(): IPromiseBasedObservable<TerrainProvider> {
return fromPromise(this._terrainWithCredits.terrainProviderPromise);
}
/**
* Returns the currently active TerrainProvider
*/
@computed
get terrainProvider(): TerrainProvider {
return this.observableTerrainProviderPromise.case({
// Return the current provider from the scene instance if the promise is pending or rejected
pending: () => this.scene.terrainProvider,
rejected: () => this.scene.terrainProvider,
// When promise is fulfilled, return the new terrainProvider
fulfilled: (terrainProvider) => terrainProvider
});
}
/**
* Returns `true` if loading of a new TerrainProvider is in progress
*
* Note that until the loading is fully complete, `this.terrainProvider` will
* return the existing TerrainProvider.
*/
@computed
get isTerrainLoading(): boolean {
return this.observableTerrainProviderPromise.state === "pending";
}
/**
* Picks features based on coordinates relative to the Cesium window. Will draw a ray from the camera through the point
* specified and set terria.pickedFeatures based on this.
*
*/
@action
async pickFromScreenPosition(
screenPosition: Cartesian2,
ignoreSplitter: boolean
): Promise<void> {
const pickRay = this.scene.camera.getPickRay(screenPosition);
const pickPosition = isDefined(pickRay)
? this.scene.globe.pick(pickRay, this.scene)
: undefined;
const pickPositionCartographic =
pickPosition && Ellipsoid.WGS84.cartesianToCartographic(pickPosition);
const vectorFeatures = await this.pickVectorFeatures(screenPosition);
const providerCoords = this._attachProviderCoordHooks();
const pickRasterPromise =
this.terria.allowFeatureInfoRequests && isDefined(pickRay)
? this.scene.imageryLayers.pickImageryLayerFeatures(pickRay, this.scene)
: undefined;
const result = this._buildPickedFeatures(
providerCoords,
pickPosition,
vectorFeatures,
pickRasterPromise ? [pickRasterPromise] : [],
pickPositionCartographic ? pickPositionCartographic.height : 0.0,
ignoreSplitter
);
const mapInteractionModeStack = this.terria.mapInteractionModeStack;
runInAction(() => {
if (
isDefined(mapInteractionModeStack) &&
mapInteractionModeStack.length > 0
) {
mapInteractionModeStack[
mapInteractionModeStack.length - 1
].pickedFeatures = result;
} else {
this.terria.pickedFeatures = result;
}
});
}
pickFromLocation(
latLngHeight: LatLonHeight,
providerCoords: ProviderCoordsMap,
existingFeatures: TerriaFeature[]
): void {
const pickPosition = this.scene.globe.ellipsoid.cartographicToCartesian(
Cartographic.fromDegrees(
latLngHeight.longitude,
latLngHeight.latitude,
latLngHeight.height
)
);
const pickPositionCartographic =
Ellipsoid.WGS84.cartesianToCartographic(pickPosition);
const promises = this.terria.allowFeatureInfoRequests
? this.pickImageryLayerFeatures(pickPositionCartographic, providerCoords)
: [];
const pickedFeatures = this._buildPickedFeatures(
providerCoords,
pickPosition,
existingFeatures,
filterOutUndefined(promises),
pickPositionCartographic.height,
false
);
const mapInteractionModeStack = this.terria.mapInteractionModeStack;
if (
defined(mapInteractionModeStack) &&
mapInteractionModeStack.length > 0
) {
mapInteractionModeStack[
mapInteractionModeStack.length - 1
].pickedFeatures = pickedFeatures;
} else {
this.terria.pickedFeatures = pickedFeatures;
}
}
private pickImageryLayerFeatures(
pickPosition: Cartographic,
providerCoords: ProviderCoordsMap
) {
const promises: (Promise<ImageryLayerFeatureInfo[]> | undefined)[] = [];
function hasUrl