terriajs
Version:
Geospatial data visualization platform.
1,138 lines (1,027 loc) • 37.9 kB
text/typescript
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
size: L.Point | undefined;
nw: L.Point | undefined;
se: L.Point | undefined;
/**
* Initial view set when the viewer is created
*/
private _initialView: CameraView | undefined;
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;
}
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();
}
get availableCatalogItems() {
const catalogItems = [
...this.terriaViewer.items.get(),
this.terriaViewer.baseMap
];
return catalogItems;
}
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.
*/
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;
}