UNPKG

terriajs

Version:

Geospatial data visualization platform.

1,138 lines (1,027 loc) 37.9 kB
import { GridLayer } from "leaflet"; import { action, autorun, computed, IReactionDisposer, makeObservable, observable, reaction, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; 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 Clock from "terriajs-cesium/Source/Core/Clock"; import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid"; import EventHelper from "terriajs-cesium/Source/Core/EventHelper"; import CesiumMath from "terriajs-cesium/Source/Core/Math"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import DataSource from "terriajs-cesium/Source/DataSources/DataSource"; import DataSourceCollection from "terriajs-cesium/Source/DataSources/DataSourceCollection"; import Entity from "terriajs-cesium/Source/DataSources/Entity"; import ImageryProvider from "terriajs-cesium/Source/Scene/ImageryProvider"; import SplitDirection from "terriajs-cesium/Source/Scene/SplitDirection"; import html2canvas from "terriajs-html2canvas"; import filterOutUndefined from "../Core/filterOutUndefined"; import isDefined from "../Core/isDefined"; import LatLonHeight from "../Core/LatLonHeight"; import runLater from "../Core/runLater"; import ProtomapsImageryProvider from "../Map/ImageryProvider/ProtomapsImageryProvider"; import ImageryProviderLeafletGridLayer, { isImageryProviderGridLayer as supportsImageryProviderGridLayer } from "../Map/Leaflet/ImageryProviderLeafletGridLayer"; import ImageryProviderLeafletTileLayer from "../Map/Leaflet/ImageryProviderLeafletTileLayer"; import LeafletDataSourceDisplay from "../Map/Leaflet/LeafletDataSourceDisplay"; import LeafletScene from "../Map/Leaflet/LeafletScene"; import LeafletSelectionIndicator from "../Map/Leaflet/LeafletSelectionIndicator"; import LeafletVisualizer from "../Map/Leaflet/LeafletVisualizer"; import L from "../Map/LeafletPatched"; import PickedFeatures, { ProviderCoords, ProviderCoordsMap } from "../Map/PickedFeatures/PickedFeatures"; import rectangleToLatLngBounds from "../Map/Vector/rectangleToLatLngBounds"; import FeatureInfoUrlTemplateMixin from "../ModelMixins/FeatureInfoUrlTemplateMixin"; import MappableMixin, { ImageryParts, MapItem } from "../ModelMixins/MappableMixin"; import TileErrorHandlerMixin from "../ModelMixins/TileErrorHandlerMixin"; import ImageryProviderTraits from "../Traits/TraitsClasses/ImageryProviderTraits"; 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 { LeafletAttribution } from "./LeafletAttribution"; import Terria from "./Terria"; // This class is an observer. It probably won't contain any observables itself export default class Leaflet extends GlobeOrMap { readonly type = "Leaflet"; readonly terria: Terria; readonly terriaViewer: TerriaViewer; readonly map: L.Map; readonly scene: LeafletScene; readonly dataSources: DataSourceCollection = new DataSourceCollection(); readonly dataSourceDisplay: LeafletDataSourceDisplay; readonly canShowSplitter = true; private readonly _attributionControl: LeafletAttribution; private readonly _leafletVisualizer: LeafletVisualizer; private readonly _eventHelper: EventHelper; private readonly _selectionIndicator: LeafletSelectionIndicator; private _stopRequestAnimationFrame: boolean = false; private _cesiumReqAnimFrameId: number | undefined; private _pickedFeatures: PickedFeatures | undefined = undefined; private _pauseMapInteractionCount = 0; /* Disposers */ private readonly _disposeWorkbenchMapItemsSubscription: () => void; private readonly _disposeDisableInteractionSubscription: () => void; private _disposeSelectedFeatureSubscription?: () => void; private _disposeSplitterReaction: () => void; // These are used to split CesiumTileLayer and MapboxCanvasVectorTileLayer @observable size: L.Point | undefined; @observable nw: L.Point | undefined; @observable se: L.Point | undefined; /** * Initial view set when the viewer is created */ private _initialView: CameraView | undefined; @action private updateMapObservables() { this.size = this.map.getSize(); this.nw = this.map.containerPointToLayerPoint([0, 0]); this.se = this.map.containerPointToLayerPoint(this.size); } private _createImageryLayer: ( ip: ImageryProvider, clippingRectangle: Rectangle | undefined ) => GridLayer = computedFn((ip, clippingRectangle) => { const layerOptions = { maxZoom: this.terria.configParameters.leafletMaxZoom, bounds: clippingRectangle && rectangleToLatLngBounds(clippingRectangle) }; // We have two different kinds of ImageryProviderLeaflet layers // - Grid layer will use the ImageryProvider in the more traditional way - calling `requestImage` to draw the image on to a canvas // - Tile layer will pass tile URLs to leaflet objects - which is a bit more "Leaflety" than Grid layer // Tile layer is preferred. Grid layer mainly exists for custom Imagery Providers which aren't just a tile of image URLs if (supportsImageryProviderGridLayer(ip)) { return new ImageryProviderLeafletGridLayer(this, ip, layerOptions); } else { return new ImageryProviderLeafletTileLayer(this, ip, layerOptions); } }); private _makeImageryLayerFromParts( parts: ImageryParts, item: MappableMixin.Instance ) { if (parts.imageryProvider === undefined) return undefined; if (TileErrorHandlerMixin.isMixedInto(item)) { // because this code path can run multiple times, make sure we remove the // handler if it is already registered parts.imageryProvider.errorEvent.removeEventListener( item.onTileLoadError, item ); parts.imageryProvider.errorEvent.addEventListener( item.onTileLoadError, item ); } return this._createImageryLayer( parts.imageryProvider, parts.clippingRectangle ); } constructor(terriaViewer: TerriaViewer, container: string | HTMLElement) { super(); makeObservable(this); this.terria = terriaViewer.terria; this.terriaViewer = terriaViewer; this.map = L.map(container, { zoomControl: false, attributionControl: false, zoomSnap: 1, // Change to 0.2 for incremental zoom when Chrome fixes canvas scaling gaps preferCanvas: true, worldCopyJump: false }).setView([-28.5, 135], 5); this.map.on("move", () => this.updateMapObservables()); this.map.on("zoom", () => this.updateMapObservables()); this.scene = new LeafletScene(this.map); this._attributionControl = new LeafletAttribution(this.terria); this.map.addControl(this._attributionControl); this._leafletVisualizer = new LeafletVisualizer(); this._selectionIndicator = new LeafletSelectionIndicator(this); this.dataSourceDisplay = new LeafletDataSourceDisplay({ scene: this.scene, dataSourceCollection: this.dataSources, visualizersCallback: this._leafletVisualizer.visualizersCallback }); this._eventHelper = new EventHelper(); this._eventHelper.add(this.terria.timelineClock.onTick, ((clock: Clock) => { this.dataSourceDisplay.update(clock.currentTime); }) as any); const ticker = () => { if (!this._stopRequestAnimationFrame) { this.terria.timelineClock.tick(); this._cesiumReqAnimFrameId = requestAnimationFrame(ticker); } }; // Start ticker asynchronously to avoid calling an action in the consctructor runLater(ticker); this._disposeWorkbenchMapItemsSubscription = this.observeModelLayer(); this._disposeSplitterReaction = this._reactToSplitterChanges(); this._disposeDisableInteractionSubscription = autorun(() => { const map = this.map; const interactions = filterOutUndefined([ map.touchZoom, map.doubleClickZoom, map.scrollWheelZoom, map.boxZoom, map.keyboard, map.dragging, map.tapHold ]); const pickLocation = this.pickLocation.bind(this); const pickFeature = (entity: Entity, event: L.LeafletMouseEvent) => { this._featurePicked(entity, event); }; // Update mouse coords on mouse move this.map.on("mousemove", (e: L.LeafletEvent) => { const mouseEvent = e as L.LeafletMouseEvent; this.mouseCoords.updateCoordinatesFromLeaflet( this.terria, mouseEvent.originalEvent ); }); if (this.terriaViewer.disableInteraction) { interactions.forEach((handler) => handler.disable()); this.map.off("click", pickLocation); this.scene.featureClicked.removeEventListener(pickFeature); if (this._disposeSelectedFeatureSubscription) { this._disposeSelectedFeatureSubscription(); } } else { interactions.forEach((handler) => handler.enable()); this.map.on("click", pickLocation); this.scene.featureClicked.addEventListener(pickFeature); this._disposeSelectedFeatureSubscription = autorun(() => { const feature = this.terria.selectedFeature; this._selectFeature(feature); }); } }); this._initProgressEvent(); } get attributionPrefix() { return this._attributionControl.prefix; } @computed get attributions() { return this._attributionControl.dataAttributions; } /** * sets up loading listeners */ private _initProgressEvent() { const onTileLoadChange = () => { let tilesLoadingCount = 0; this.map.eachLayer(function (layerOrGridlayer) { // _tiles is protected but our knockout-loading-logic accesses it here anyway const layer = layerOrGridlayer as any; if (layer?._tiles) { // Count all tiles not marked as loaded tilesLoadingCount += Object.keys(layer._tiles).filter( (key) => !layer._tiles[key].loaded ).length; } }); this._updateTilesLoadingCount(tilesLoadingCount); }; this.map.on( "layeradd", function (evt: any) { // This check makes sure we only watch tile layers, and also protects us if this private variable gets changed. if (typeof evt.layer._tiles !== "undefined") { evt.layer.on("tileloadstart tileload load", onTileLoadChange); } }.bind(this) ); this.map.on( "layerremove", function (evt: any) { evt.layer.off("tileloadstart tileload load", onTileLoadChange); }.bind(this) ); } /** * Pick feature from mouse click event. */ private pickLocation(e: L.LeafletEvent) { const mouseEvent = e as L.LeafletMouseEvent; // Handle click events that cross the anti-meridian if (mouseEvent.latlng.lng > 180 || mouseEvent.latlng.lng < -180) { mouseEvent.latlng = mouseEvent.latlng.wrap(); } // if (!this._dragboxcompleted && that.map.dragging.enabled()) { this._pickFeatures(mouseEvent.latlng); // } // this._dragboxcompleted = false; } getContainer(): HTMLElement { return this.map.getContainer(); } pauseMapInteraction(): void { ++this._pauseMapInteractionCount; if (this._pauseMapInteractionCount === 1) { this.map.dragging.disable(); } } resumeMapInteraction(): void { --this._pauseMapInteractionCount; if (this._pauseMapInteractionCount === 0) { setTimeout(() => { if (this._pauseMapInteractionCount === 0) { this.map.dragging.enable(); } }, 0); } } destroy(): void { if (this._disposeSelectedFeatureSubscription) { this._disposeSelectedFeatureSubscription(); } this._disposeSplitterReaction(); this._disposeWorkbenchMapItemsSubscription(); this._eventHelper.removeAll(); this._disposeDisableInteractionSubscription(); // This variable prevents a race condition if destroy() is called // synchronously as a result of timelineClock ticking due to ticker() this._stopRequestAnimationFrame = true; if (isDefined(this._cesiumReqAnimFrameId)) { cancelAnimationFrame(this._cesiumReqAnimFrameId); } this.dataSourceDisplay.destroy(); this.map.off("move"); this.map.off("zoom"); this.map.off("zoomlevelschange"); this.map.remove(); } @computed get availableCatalogItems() { const catalogItems = [ ...this.terriaViewer.items.get(), this.terriaViewer.baseMap ]; return catalogItems; } @computed get availableDataSources() { const catalogItems = this.availableCatalogItems; /* Handle datasources */ const allMapItems = ([] as MapItem[]).concat( ...catalogItems.filter(isDefined).map((item) => item.mapItems) ); const dataSources = allMapItems.filter(isDataSource); return dataSources; } 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 dataSourcesReactionDisposer = reaction( () => this.availableDataSources, () => { syncDataSourcesPromise = syncDataSourcesPromise .then(async () => { await this.syncDataSourceCollection( this.availableDataSources, this.dataSources ); this.notifyRepaintRequired(); }) .catch(console.error); }, { fireImmediately: true } ); // Reaction to sync imagery from model layer with cesium map const imageryReactionDisposer = autorun(() => { const catalogItems = this.availableCatalogItems; // Flatmap const allImageryMapItems = ( [] as { item: MappableMixin.Instance; parts: ImageryParts; }[] ).concat( ...catalogItems .filter(isDefined) .map((item) => item.mapItems .filter(ImageryParts.is) .map((parts: ImageryParts) => ({ item, parts })) ) ); const allImagery = allImageryMapItems.map(({ item, parts }) => { if (hasTraits(item, ImageryProviderTraits, "leafletUpdateInterval")) { (parts.imageryProvider as any)._leafletUpdateInterval = item.leafletUpdateInterval; } return { parts: parts, layer: this._makeImageryLayerFromParts(parts, item) }; }); // Delete imagery layers no longer in the model this.map.eachLayer((mapLayer) => { if ( isImageryLayer(mapLayer) || mapLayer instanceof ImageryProviderLeafletGridLayer ) { const index = allImagery.findIndex((im) => im.layer === mapLayer); if (index === -1) { this.map.removeLayer(mapLayer); } } }); // Add layer and update its zIndex let zIndex = 100; // Start at an arbitrary value allImagery.reverse().forEach(({ parts, layer }) => { if (layer && parts.show) { layer.setOpacity(parts.alpha); layer.setZIndex(zIndex); zIndex++; if (!this.map.hasLayer(layer)) { this.map.addLayer(layer); } } else if (layer) { this.map.removeLayer(layer); } }); }); return () => { dataSourcesReactionDisposer(); imageryReactionDisposer(); }; } /** * Syncs the `dataSources` collection against the latest `availableDataSources`. * */ private async syncDataSourceCollection( availableDataSources: DataSource[], dataSources: DataSourceCollection ) { // 1. Remove deleted data sources // // Iterate backwards because we're removing items. for (let i = dataSources.length - 1; i >= 0; i--) { const ds = dataSources.get(i); if (availableDataSources.indexOf(ds) === -1 || !ds.show) { dataSources.remove(ds); } } // 2. Add new data sources for (const ds of availableDataSources) { if (!dataSources.contains(ds) && ds.show) { 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); } }) ); } doZoomTo( target: CameraView | Rectangle | DataSource | MappableMixin.Instance | any, flightDurationSeconds: number = 3.0 ): Promise<void> { if (!isDefined(target)) { return Promise.resolve(); } let bounds; if (isDefined(target.entities)) { if (isDefined(this.dataSourceDisplay)) { bounds = this.dataSourceDisplay.getLatLngBounds(target); } } else { let extent; if (target instanceof Rectangle) { extent = target; } else if (target instanceof CameraView) { extent = target.rectangle; } else if (MappableMixin.isMixedInto(target)) { if (isDefined(target.cesiumRectangle)) { extent = target.cesiumRectangle; } if (!isDefined(extent)) { return this.doZoomTo(target.mapItems[0], flightDurationSeconds); } } else { extent = target.rectangle; } // Ensure extent is defined before accessing its properties if (isDefined(extent)) { // Account for a bounding box crossing the date line. if (extent.east < extent.west) { extent = Rectangle.clone(extent); extent.east += CesiumMath.TWO_PI; } bounds = rectangleToLatLngBounds(extent); } else { // Handle the case where extent is undefined console.error("Unable to determine bounds for zooming."); return Promise.resolve(); } } if (isDefined(bounds)) { this.map.flyToBounds(bounds, { animate: flightDurationSeconds > 0.0, duration: flightDurationSeconds }); } return Promise.resolve(); } setInitialView(view: CameraView) { this.doZoomTo(view, 0); this._initialView = view; this.map.addOneTimeEventListener("move", () => { this._initialView = undefined; }); } /** * Return the initial view if it hasn't changed. Otherwise return undefined. */ getInitialView(): CameraView | undefined { return this._initialView; } 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. const initialView = this.getInitialView(); if (initialView) { return initialView; } const bounds = this.map.getBounds(); return new CameraView( Rectangle.fromDegrees( bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth() ) ); } notifyRepaintRequired(): void { // No action necessary. } pickFromLocation( latLngHeight: LatLonHeight, providerCoords: ProviderCoordsMap, existingFeatures: TerriaFeature[] ): void { this._pickFeatures( L.latLng({ lat: latLngHeight.latitude, lng: latLngHeight.longitude, alt: latLngHeight.height }), providerCoords, existingFeatures ); } /* * There are two "listeners" for clicks which are set up in our constructor. * - One fires for any click: `map.on('click', ...`. It calls `pickFeatures`. * - One fires only for vector features: `this.scene.featureClicked.addEventListener`. * It calls `featurePicked`, which calls `pickFeatures` and then adds the feature it found, if any. * These events can fire in either order. * Billboards do not fire the first event. * * Note that `pickFeatures` does nothing if `leaflet._pickedFeatures` is already set. * Otherwise, it sets it, runs `runLater` to clear it, and starts the asynchronous raster feature picking. * * So: * If only the first event is received, it triggers the raster-feature picking as desired. * If both are received in the order above, the second adds the vector features to the list of raster features as desired. * If both are received in the reverse order, the vector-feature click kicks off the same behavior as the other click would have; * and when the next click is received, it is ignored - again, as desired. */ @action private async _featurePicked(entity: Entity, event: L.LeafletMouseEvent) { this._pickFeatures(event.latlng); // Ignore clicks on the feature highlight. if (entity.entityCollection && entity.entityCollection.owner) { const owner = entity.entityCollection.owner; if ( owner instanceof DataSource && owner.name === GlobeOrMap._featureHighlightName ) { return; } } const feature = TerriaFeature.fromEntityCollectionOrEntity(entity); const catalogItem = feature._catalogItem; if ( FeatureInfoUrlTemplateMixin.isMixedInto(catalogItem) && typeof catalogItem.getFeaturesFromPickResult === "function" && this.terria.allowFeatureInfoRequests ) { const result = await catalogItem.getFeaturesFromPickResult.bind( catalogItem )( undefined, entity, (this._pickedFeatures?.features.length || 0) < catalogItem.maxRequests ); if (result && isDefined(this._pickedFeatures)) { if (Array.isArray(result)) { this._pickedFeatures.features.push(...result); } else { this._pickedFeatures.features.push(result); } } } else if (isDefined(this._pickedFeatures)) { this._pickedFeatures.features.push(feature); } if ( isDefined(this._pickedFeatures) && isDefined(feature) && feature.position ) { this._pickedFeatures.pickPosition = (feature.position as any)._value; } } private _pickFeatures( latlng: L.LatLng, tileCoordinates?: Record<string, ProviderCoords>, existingFeatures?: TerriaFeature[], ignoreSplitter: boolean = false ) { if (isDefined(this._pickedFeatures)) { // Picking is already in progress. return; } this._pickedFeatures = new PickedFeatures(); if (isDefined(existingFeatures)) { this._pickedFeatures.features = existingFeatures; } // We run this later because vector click events and the map click event can come through in any order, but we can // be reasonably sure that all of them will be processed by the time our runLater func is invoked. const cleanup = runLater(() => { // Set this again just in case a vector pick came through and reset it to the vector's position. const newPickLocation = Ellipsoid.WGS84.cartographicToCartesian(pickedLocation); runInAction(() => { const mapInteractionModeStack = this.terria.mapInteractionModeStack; if ( isDefined(mapInteractionModeStack) && mapInteractionModeStack.length > 0 ) { const pickedFeatures = mapInteractionModeStack[mapInteractionModeStack.length - 1] .pickedFeatures; if (isDefined(pickedFeatures)) { pickedFeatures.pickPosition = newPickLocation; } } else if (isDefined(this.terria.pickedFeatures)) { this.terria.pickedFeatures.pickPosition = newPickLocation; } }); // Unset this so that the next click will start building features from scratch. this._pickedFeatures = undefined; }); const imageryLayers: ImageryProviderLeafletTileLayer[] = []; if (this.terria.allowFeatureInfoRequests) { this.map.eachLayer((layer) => { if (isImageryLayer(layer)) { imageryLayers.push(layer); } }); } // we need items sorted in reverse order by their zIndex to get correct ordering of feature info imageryLayers.sort( ( a: ImageryProviderLeafletTileLayer, b: ImageryProviderLeafletTileLayer ) => { if (!isDefined(a.options.zIndex) || !isDefined(b.options.zIndex)) { return 0; } if (a.options.zIndex < b.options.zIndex) { return 1; } if (a.options.zIndex > b.options.zIndex) { return -1; } return 0; } ); tileCoordinates = tileCoordinates ?? {}; const pickedLocation = Cartographic.fromDegrees(latlng.lng, latlng.lat); this._pickedFeatures.pickPosition = Ellipsoid.WGS84.cartographicToCartesian(pickedLocation); const imageryFeaturePromises = imageryLayers.map(async (imageryLayer) => { const imageryLayerUrl = (imageryLayer.imageryProvider as any).url; const longRadians = CesiumMath.toRadians(latlng.lng); const latRadians = CesiumMath.toRadians(latlng.lat); const coords = tileCoordinates?.[imageryLayerUrl] ?? (await imageryLayer.getFeaturePickingCoords( this.map, longRadians, latRadians )); const features = await imageryLayer.pickFeatures( coords.x, coords.y, coords.level, longRadians, latRadians ); // Make sure ImageryLayerFeatureInfo has imagery layer property features?.forEach((feature) => (feature.imageryLayer = imageryLayer)); return { features: features, imageryLayer: imageryLayer, coords: coords }; }); const pickedFeatures = this._pickedFeatures; // We want the all available promise to return after the cleanup one to // make sure all vector click events have resolved. pickedFeatures.allFeaturesAvailablePromise = Promise.all([ cleanup, Promise.all(imageryFeaturePromises) .then((results) => { runInAction(() => { pickedFeatures.isLoading = false; }); pickedFeatures.providerCoords = {}; const filteredResults = results.filter( (result) => isDefined(result.features) && result.features.length > 0 ); pickedFeatures.providerCoords = filteredResults.reduce(function ( coordsSoFar: ProviderCoordsMap, result ) { const imageryProvider = result.imageryLayer?.imageryProvider; if (imageryProvider) coordsSoFar[(imageryProvider as any).url] = result.coords; return coordsSoFar; }, {}); const features = filteredResults.reduce((allFeatures, result) => { if ( this.terria.showSplitter && ignoreSplitter === false && isDefined(pickedFeatures.pickPosition) ) { // Skip this feature, unless the imagery layer is on the picked side or // belongs to both sides of the splitter const screenPosition = this._computePositionOnScreen( pickedFeatures.pickPosition ); const pickedSide = this._getSplitterSideForScreenPosition(screenPosition); const layerDirection = result.imageryLayer.splitDirection; if ( !( layerDirection === pickedSide || layerDirection === SplitDirection.NONE ) ) { return allFeatures; } } return allFeatures.concat( result.features!.map((feature) => { // For features without a position, use the picked location. if (!isDefined(feature.position)) { feature.position = pickedLocation; } return this._createFeatureFromImageryLayerFeature(feature); }) ); }, pickedFeatures.features); runInAction(() => { pickedFeatures.features = features; }); }) .catch((e) => { runInAction(() => { pickedFeatures.isLoading = false; pickedFeatures.error = "An unknown error occurred while picking features."; }); throw e; }) ]).then(() => undefined); runInAction(() => { const mapInteractionModeStack = this.terria.mapInteractionModeStack; if ( isDefined(mapInteractionModeStack) && mapInteractionModeStack.length > 0 ) { mapInteractionModeStack[ mapInteractionModeStack.length - 1 ].pickedFeatures = this._pickedFeatures; } else { this.terria.pickedFeatures = this._pickedFeatures; } }); } _reactToSplitterChanges(): IReactionDisposer { return autorun(() => { const items = this.terria.mainViewer.items.get(); const showSplitter = this.terria.showSplitter; const splitPosition = this.terria.splitPosition; items.forEach((item) => { if ( MappableMixin.isMixedInto(item) && hasTraits(item, SplitterTraits, "splitDirection") ) { const layers = this.getImageryLayersForItem(item); const splitDirection = item.splitDirection; layers.forEach( action((layer) => { if (showSplitter) { layer.splitDirection = splitDirection; layer.splitPosition = splitPosition; } else { layer.splitDirection = SplitDirection.NONE; layer.splitPosition = splitPosition; } }) ); } }); this.notifyRepaintRequired(); }); } getImageryLayersForItem( item: MappableMixin.Instance ): (ImageryProviderLeafletTileLayer | ImageryProviderLeafletGridLayer)[] { return filterOutUndefined( item.mapItems.map((m) => { if (ImageryParts.is(m)) { const layer = this._makeImageryLayerFromParts(m, item); return layer instanceof ImageryProviderLeafletTileLayer || layer instanceof ImageryProviderLeafletGridLayer ? layer : undefined; } }) ); } /** * Computes the screen position of a given world position. * @param position The world position in Earth-centered Fixed coordinates. * @param [result] The instance to which to copy the result. * @return The screen position, or undefined if the position is not on the screen. */ private _computePositionOnScreen( position: Cartesian3, result?: Cartesian2 ): Cartesian2 { const cartographicScratch = new Cartographic(); const cartographic = Ellipsoid.WGS84.cartesianToCartographic( position, cartographicScratch ); const point = this.map.latLngToContainerPoint( L.latLng( CesiumMath.toDegrees(cartographic.latitude), CesiumMath.toDegrees(cartographic.longitude) ) ); if (isDefined(result)) { result.x = point.x; result.y = point.y; } else { result = new Cartesian2(point.x, point.y); } return result; } private _selectFeature(feature: TerriaFeature | undefined) { this._highlightFeature(feature); if (isDefined(feature) && isDefined(feature.position)) { const cartographicScratch = new Cartographic(); const cartesianPosition = feature.position.getValue( this.terria.timelineClock.currentTime ); if (cartesianPosition === undefined) { this._selectionIndicator.animateSelectionIndicatorDepart(); return; } const cartographic = Ellipsoid.WGS84.cartesianToCartographic( cartesianPosition, cartographicScratch ); this._selectionIndicator.setLatLng( L.latLng([ CesiumMath.toDegrees(cartographic.latitude), CesiumMath.toDegrees(cartographic.longitude) ]) ); this._selectionIndicator.animateSelectionIndicatorAppear(); } else { this._selectionIndicator.animateSelectionIndicatorDepart(); } } getClipsForSplitter(): any { let clipLeft: string = ""; let clipRight: string = ""; let clipPositionWithinMap: number = 0; let clipX: number = 0; if (this.terria.showSplitter) { const map = this.map; const size = map.getSize(); const nw = map.containerPointToLayerPoint([0, 0]); const se = map.containerPointToLayerPoint(size); clipPositionWithinMap = size.x * this.terria.splitPosition; clipX = Math.round(nw.x + clipPositionWithinMap); clipLeft = "rect(" + [nw.y, clipX, se.y, nw.x].join("px,") + "px)"; clipRight = "rect(" + [nw.y, se.x, se.y, clipX].join("px,") + "px)"; } return { left: clipLeft, right: clipRight, clipPositionWithinMap: clipPositionWithinMap, clipX: clipX }; } isSplitterDragThumb(element: HTMLElement): boolean | "" { return ( element.className && element.className.indexOf && element.className.indexOf("tjs-splitter__thumb") >= 0 ); } captureScreenshot(): Promise<string> { // Temporarily hide the map credits. this._attributionControl.remove(); try { // html2canvas can't handle the clip style which is used for the splitter. So if the splitter is active, we render // a left image and a right image and compose them. Also remove the splitter drag thumb. let promise: any; if (this.terria.showSplitter) { const clips = this.getClipsForSplitter(); const clipLeft = clips.left.replace(/ /g, ""); const clipRight = clips.right.replace(/ /g, ""); promise = html2canvas(this.map.getContainer(), { useCORS: true, ignoreElements: (element: HTMLElement) => (element.style.clip !== undefined && element.style.clip !== null && element.style.clip.replace(/ /g, "") === clipRight) || this.isSplitterDragThumb(element) }).then((leftCanvas: HTMLCanvasElement) => { return html2canvas(this.map.getContainer(), { useCORS: true, ignoreElements: (element: HTMLElement) => (element.style.clip !== undefined && element.style.clip !== null && element.style.clip.replace(/ /g, "") === clipLeft) || this.isSplitterDragThumb(element) }).then((rightCanvas: HTMLCanvasElement) => { const combined = document.createElement("canvas"); combined.width = leftCanvas.width; combined.height = leftCanvas.height; const context: CanvasRenderingContext2D | null = combined.getContext("2d"); if (context === undefined || context === null) { // Error return null; } const split = clips.clipPositionWithinMap * window.devicePixelRatio; context.drawImage( leftCanvas, 0, 0, split, combined.height, 0, 0, split, combined.height ); context.drawImage( rightCanvas, split, 0, combined.width - split, combined.height, split, 0, combined.width - split, combined.height ); return combined; }); }); } else { promise = html2canvas(this.map.getContainer(), { useCORS: true }); } return promise .then((canvas: HTMLCanvasElement) => { return canvas.toDataURL("image/png"); }) .finally(() => { this._attributionControl.addTo(this.map); }); } catch (e) { this._attributionControl.addTo(this.map); return Promise.reject(e); } } _addVectorTileHighlight( imageryProvider: ProtomapsImageryProvider, rectangle: Rectangle ): () => void { const map = this.map; const options: any = { opacity: 1, bounds: rectangleToLatLngBounds(rectangle) }; if (isDefined(map.options.maxZoom)) { options.maxZoom = map.options.maxZoom; } const layer = new ImageryProviderLeafletGridLayer( this, imageryProvider, options ); layer.addTo(map); layer.bringToFront(); return function () { map.removeLayer(layer); }; } } function isImageryLayer( someLayer: L.Layer ): someLayer is ImageryProviderLeafletTileLayer { return "imageryProvider" in someLayer; } function isDataSource(object: MapItem): object is DataSource { return "entities" in object; }