UNPKG

terriajs

Version:

Geospatial data visualization platform.

1,394 lines (1,239 loc) 64.5 kB
import i18next from "i18next"; import { isEqual } from "lodash-es"; import { autorun, computed, IObservableArray, observable, reaction, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; 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 Clock from "terriajs-cesium/Source/Core/Clock"; import createWorldTerrain from "terriajs-cesium/Source/Core/createWorldTerrain"; import Credit from "terriajs-cesium/Source/Core/Credit"; import defaultValue from "terriajs-cesium/Source/Core/defaultValue"; 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 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 Camera from "terriajs-cesium/Source/Scene/Camera"; import Cesium3DTileset from "terriajs-cesium/Source/Scene/Cesium3DTileset"; import CreditDisplay from "terriajs-cesium/Source/Scene/CreditDisplay"; import ImageryLayer from "terriajs-cesium/Source/Scene/ImageryLayer"; import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo"; import ImageryProvider from "terriajs-cesium/Source/Scene/ImageryProvider"; 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/Widgets/CesiumWidget/CesiumWidget"; import getElement from "terriajs-cesium/Source/Widgets/getElement"; 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 MapboxVectorTileImageryProvider from "../Map/ImageryProvider/MapboxVectorTileImageryProvider"; import ProtomapsImageryProvider from "../Map/ImageryProvider/ProtomapsImageryProvider"; import PickedFeatures, { ProviderCoordsMap } from "../Map/PickedFeatures/PickedFeatures"; import FeatureInfoUrlTemplateMixin from "../ModelMixins/FeatureInfoUrlTemplateMixin"; import MappableMixin, { 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"; // Intermediary var cartesian3Scratch = new Cartesian3(); var enuToFixedScratch = new Matrix4(); var southwestScratch = new Cartesian3(); var southeastScratch = new Cartesian3(); var northeastScratch = new Cartesian3(); var northwestScratch = new Cartesian3(); var southwestCartographicScratch = new Cartographic(); var southeastCartographicScratch = new Cartographic(); var northeastCartographicScratch = new Cartographic(); var 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 dataSources: DataSourceCollection = new DataSourceCollection(); readonly dataSourceDisplay: DataSourceDisplay; 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; private cesiumDataAttributions: 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; constructor(terriaViewer: TerriaViewer, container: string | HTMLElement) { super(); 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 = { dataSources: this.dataSources, clock: this.terria.timelineClock, imageryProvider: new SingleTileImageryProvider({ url: img }), scene3DOnly: true, shadows: true, useBrowserRecommendedResolution: !this.terria.useNativeResolution }; // Workaround for Firefox bug with WebGL and printing: // https://bugzilla.mozilla.org/show_bug.cgi?id=976173 const firefoxBugOptions = (<any>FeatureDetection).isFirefox() ? { contextOptions: { webgl: { preserveDrawingBuffer: true } } } : undefined; try { this.cesiumWidget = new CesiumWidget( container, Object.assign({}, options, firefoxBugOptions) ); this.scene = this.cesiumWidget.scene; } 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.dataSourceDisplay = new DataSourceDisplay({ scene: this.scene, dataSourceCollection: this.dataSources }); this._selectionIndicator = new CesiumSelectionIndicator(this); this.supportsPolylinesOnTerrain = (<any>this.scene).context.depthTexture; this._eventHelper = new EventHelper(); this._eventHelper.add(this.terria.timelineClock.onTick, <any>(( clock: Clock ) => { this.dataSourceDisplay.update(clock.currentTime); })); // Progress this._eventHelper.add(this.scene.globe.tileLoadProgressEvent, <any>( ((currentLoadQueueLength: number) => this._updateTilesLoadingCount(currentLoadQueueLength)) )); // Disable HDR lighting for better performance and to avoid changing imagery colors. (<any>this.scene).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) => { this.mouseCoords.updateCoordinatesFromCesium(this.terria, e.endPosition); }, ScreenSpaceEventType.MOUSE_MOVE); inputHandler.setInputAction( (e) => { this.mouseCoords.updateCoordinatesFromCesium( this.terria, e.endPosition ); }, ScreenSpaceEventType.MOUSE_MOVE, KeyboardEventModifier.SHIFT ); // Handle left click by picking objects from the map. inputHandler.setInputAction((e) => { if (!this.isFeaturePickingPaused) this.pickFromScreenPosition(e.position, false); }, ScreenSpaceEventType.LEFT_CLICK); let zoomUserDrawing: UserDrawing | undefined; // Handle zooming on SHIFT + MOUSE DOWN inputHandler.setInputAction( (e) => { if (!this.isFeaturePickingPaused && !isDefined(zoomUserDrawing)) { this.pauseMapInteraction(); const exitZoom = () => { document.removeEventListener("keyup", onKeyUp); runInAction(() => { this.terria.mapInteractionModeStack.pop(); 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) => { 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.scene.globe.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; }); } /** 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(terrainProvider: TerrainProvider) { // Some network errors are not rejected through readyPromise, so we have to // listen to them using the error event and dispose it later let networkErrorListener: (err: any) => void; const networkErrorPromise = new Promise((_resolve, reject) => { networkErrorListener = reject; terrainProvider.errorEvent.addEventListener(networkErrorListener); }); const isReady = await Promise.race([ networkErrorPromise, terrainProvider.readyPromise ]) .then(() => { /** 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(); } }) .catch((err) => { console.log("Terrain provider error. ", err.message); if (this.scene.terrainProvider instanceof CesiumTerrainProvider) { 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; } } }) .finally(() => terrainProvider.errorEvent.removeEventListener(networkErrorListener) ); } 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; }; } = this.scene.frameState.creditDisplay; const creditDisplayOldDestroy = creditDisplay.destroy; creditDisplay.destroy = () => { try { creditDisplayOldDestroy(); } catch (err) {} }; const creditDisplayOldEndFrame = creditDisplay.endFrame; creditDisplay.endFrame = () => { creditDisplayOldEndFrame.bind(creditDisplay)(); runInAction(() => { const creditDisplayElements: { credit: Credit; count: number; }[] = creditDisplay._currentFrameCredits!.lightboxCredits.values; // sort credits by count (number of times they are added to map) const credits = creditDisplayElements .sort((credit1, credit2) => { return credit2.count - credit1.count; }) .map(({ credit }) => credit.html); if (isEqual(credits, this.cesiumDataAttributions.toJS())) return; // first remove ones that are not on the map anymore // Iterate backwards because we're removing items. for (let i = this.cesiumDataAttributions.length - 1; i >= 0; i--) { const attribution = this.cesiumDataAttributions[i]; if (!credits.includes(attribution)) { this.cesiumDataAttributions.remove(attribution); } } // then go through all credits and add them or update their position for (const [index, credit] of credits.entries()) { const attributionIndex = this.cesiumDataAttributions.indexOf(credit); if (attributionIndex === index) { // it is already on correct position in the list continue; } else if (attributionIndex === -1) { // it is not on the list yet so we add it to the list this.cesiumDataAttributions.splice(index, 0, credit); } else { // it is on the list but not in the right place so we move it this.cesiumDataAttributions.move(attributionIndex, index); } } }); }; } getContainer() { return this.cesiumWidget.container; } pauseMapInteraction() { ++this._pauseMapInteractionCount; if (this._pauseMapInteractionCount === 1) { this.scene.screenSpaceCameraController.enableInputs = false; } } resumeMapInteraction() { --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() { // 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.dataSourceDisplay.destroy(); 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 availableDataSources(): DataSource[] { return this._allMapItems.filter(isDataSource); } /** * 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 (let 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 dataSources.contains(ds) && dataSources.raiseToTop(ds); }) ); } 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 reactionDisposer = reaction( () => this.availableDataSources, () => { syncDataSourcesPromise = syncDataSourcesPromise .then(async () => { await this.syncDataSourceCollection( this.availableDataSources, this.dataSources ); this.notifyRepaintRequired(); }) .catch(console.error); }, { fireImmediately: true } ); let prevMapItems: MapItem[] = []; const autorunDisposer = autorun(() => { // TODO: Look up the type in a map and call the associated function. // That way the supported types of map items is extensible. const allImageryParts = this._allMappables .map((m) => ImageryParts.is(m.mapItem) ? this._makeImageryLayerFromParts(m.mapItem, m.item) : undefined ) .filter(isDefined); // Delete imagery layers that are no longer in the model // Iterate backwards because we're removing items. for (let i = this.scene.imageryLayers.length - 1; i >= 0; i--) { const imageryLayer = this.scene.imageryLayers.get(i); if (allImageryParts.indexOf(imageryLayer) === -1) { this.scene.imageryLayers.remove(imageryLayer); } } // Iterate backwards so that adding multiple layers adds them in increasing cesium index order for ( let modelIndex = allImageryParts.length - 1; modelIndex >= 0; modelIndex-- ) { const mapItem = allImageryParts[modelIndex]; const targetCesiumIndex = allImageryParts.length - modelIndex - 1; const currentCesiumIndex = this.scene.imageryLayers.indexOf(mapItem); if (currentCesiumIndex === -1) { this.scene.imageryLayers.add(mapItem, targetCesiumIndex); } else if (currentCesiumIndex > targetCesiumIndex) { for (let j = currentCesiumIndex; j > targetCesiumIndex; j--) { this.scene.imageryLayers.lower(mapItem); } } else if (currentCesiumIndex < targetCesiumIndex) { for (let j = currentCesiumIndex; j < targetCesiumIndex; j++) { this.scene.imageryLayers.raise(mapItem); } } } const allPrimitives = this._allMapItems.filter(isPrimitive); const prevPrimitives = prevMapItems.filter(isPrimitive); const primitives = this.scene.primitives; // Remove deleted primitives prevPrimitives.forEach((primitive) => { if (!allPrimitives.includes(primitive)) { if (isCesium3DTileset(primitive)) { // Remove all event listeners from any Cesium3DTilesets by running stored remover functions const fnArray = this._3dTilesetEventListeners.get(primitive); try { fnArray?.forEach((fn) => fn()); // Run the remover functions } catch (error) {} this._3dTilesetEventListeners.delete(primitive); // Remove the item for this tileset from our eventListener reference storage array this._updateTilesLoadingIndeterminate(false); // reset progress bar loading state to false. Any new tile loading event will restart it to account for multiple currently loading 3DTilesets. } primitives.remove(primitive); } }); // Add new primitives allPrimitives.forEach((primitive) => { if (!primitives.contains(primitive)) { primitives.add(primitive); if (isCesium3DTileset(primitive)) { 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) ); // Push new item to eventListener reference storage this._3dTilesetEventListeners.set(primitive, [ startingListener, finishedListener ]); } } }); prevMapItems = this._allMapItems; this.notifyRepaintRequired(); }); return () => { reactionDisposer(); autorunDisposer(); }; } stopObserving() { 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 { 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()); } notifyRepaintRequired() { 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(); }; } getCurrentCameraView(): CameraView { 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) : undefined; if (!center) { // TODO: binary search to find the horizon point and use that as the center. return this.terriaViewer.homeCamera; // This is just a random rectangle. Replace it when there's a home view available // return this.terria.homeView.rectangle; } const ellipsoid = this.scene.globe.ellipsoid; const frustrum = scene.camera.frustum as PerspectiveFrustum; const fovy = frustrum.fovy * 0.5; const fovx = Math.atan(Math.tan(fovy) * frustrum.aspectRatio); 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(): { terrain: TerrainProvider; credit?: Credit; } { if (!this.terriaViewer.viewerOptions.useTerrain) { // Terrain mode is off, use the ellipsoidal terrain (aka 3d-smooth) return { terrain: new EllipsoidTerrainProvider() }; } else if (this._firstMapItemTerrainProvider) { // If there's a TerrainProvider in map items/workbench then use it return { terrain: this._firstMapItemTerrainProvider }; } else if ( this.terria.configParameters.cesiumTerrainAssetId !== undefined ) { // Load the terrain provider from Ion return { terrain: 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 { terrain: this.createTerrainProviderFromUrl( this.terria.configParameters.cesiumTerrainUrl ) }; } else if (this.terria.configParameters.useCesiumIonTerrain) { // Use Cesium ION world Terrain const logo = require("terriajs-cesium/Source/Assets/Images/ion-credit.png"); const ionCredit = new Credit( '<a href="https://cesium.com/" target="_blank" rel="noopener noreferrer"><img src="' + logo + '" title="Cesium ion"/></a>', true ); return { terrain: this.createWorldTerrain(), credit: ionCredit }; } else { // Default to ellipsoid/3d-smooth return { terrain: new EllipsoidTerrainProvider() }; } } /** * Returns terrainProvider from `configParameters.cesiumTerrainAssetId` when set or `undefined`. * * Used for spying in specs */ private createTerrainProviderFromIonAssetId( assetId: number, accessToken?: string ): CesiumTerrainProvider { const terrainProvider = new CesiumTerrainProvider({ url: IonResource.fromAssetId(assetId, { accessToken }) }); // Add the event handler to the TerrainProvider this.catchTerrainProviderDown(terrainProvider); return terrainProvider; } /** * Returns terrainProvider from `configParameters.cesiumTerrainAssetId` when set or `undefined`. * * Used for spying in specs */ private createTerrainProviderFromUrl(url: string): CesiumTerrainProvider { const terrainProvider = new CesiumTerrainProvider({ url }); // Add the event handler to the TerrainProvider this.catchTerrainProviderDown(terrainProvider); return terrainProvider; } /** * Creates cesium-world-terrain. * * Used for spying in specs */ private createWorldTerrain(): CesiumTerrainProvider { const terrainProvider = createWorldTerrain({}); // Add the event handler to the TerrainProvider this.catchTerrainProviderDown(terrainProvider); return terrainProvider; } @computed get terrainProvider(): TerrainProvider { return this._terrainWithCredits.terrain; } /** * 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. * */ pickFromScreenPosition(screenPosition: Cartesian2, ignoreSplitter: boolean) { 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 = 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[] ) { 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; } } /** * Return features at a latitude, longitude and (optionally) height for the given imagery layers. * @param latLngHeight The position on the earth to pick * @param tileCoords A map of imagery provider urls to the tile coords used to get features for those imagery * @returns A flat array of all the features for the given tiles that are currently on the map */ async getFeaturesAtLocation( latLngHeight: LatLonHeight, providerCoords: ProviderCoordsMap, existingFeatures: TerriaFeature[] = [] ) { 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 ); await pickedFeatures.allFeaturesAvailablePromise; return pickedFeatures.features; } private pickImageryLayerFeatures( pickPosition: Cartographic, providerCoords: ProviderCoordsMap ) { const promises: (Promise<ImageryLayerFeatureInfo[]> | undefined)[] = []; for (let i = this.scene.imageryLayers.length - 1; i >= 0; i--) { const imageryLayer = this.scene.imageryLayers.get(i); const imageryProvider = imageryLayer.imageryProvider; function hasUrl(o: any): o is { url: string } { return typeof o?.url === "string"; } if (hasUrl(imageryProvider) && providerCoords[imageryProvider.url]) { var coords = providerCoords[imageryProvider.url]; promises.push( imageryProvider .pickFeatures( coords.x, coords.y, coords.level, pickPosition.longitude, pickPosition.latitude ) // Make sure all features have imageryLayer ?.then((features) => features.map((f) => { f.imageryLayer = imageryLayer; return f; }) ) ); } } return filterOutUndefined(promises); } /** * Picks all *vector* features (e.g. GeoJSON) shown at a certain position on the screen, ignoring raster features * (e.g. WMS). Because all vector features are already in memory, this is synchronous. * * @param screenPosition position on the screen to look for features * @returns The features found. */ private pickVectorFeatures(screenPosition: Cartesian2) { // Pick vector features const vectorFeatures = []; const pickedList = this.scene.drillPick(screenPosition); for (let i = 0; i < pickedList.length; ++i)