terriajs
Version:
Geospatial data visualization platform.
450 lines (392 loc) • 16.7 kB
text/typescript
import { action, makeObservable, observable, runInAction } from "mobx";
import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2";
import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3";
import Color from "terriajs-cesium/Source/Core/Color";
import createGuid from "terriajs-cesium/Source/Core/createGuid";
import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError";
import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid";
import Rectangle from "terriajs-cesium/Source/Core/Rectangle";
import ColorMaterialProperty from "terriajs-cesium/Source/DataSources/ColorMaterialProperty";
import ConstantPositionProperty from "terriajs-cesium/Source/DataSources/ConstantPositionProperty";
import ConstantProperty from "terriajs-cesium/Source/DataSources/ConstantProperty";
import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo";
import SplitDirection from "terriajs-cesium/Source/Scene/SplitDirection";
import isDefined from "../Core/isDefined";
import LatLonHeight from "../Core/LatLonHeight";
import TerriaError from "../Core/TerriaError";
import ProtomapsImageryProvider from "../Map/ImageryProvider/ProtomapsImageryProvider";
import featureDataToGeoJson from "../Map/PickedFeatures/featureDataToGeoJson";
import { ProviderCoordsMap } from "../Map/PickedFeatures/PickedFeatures";
import MappableMixin from "../ModelMixins/MappableMixin";
import TimeVarying from "../ModelMixins/TimeVarying";
import MouseCoords from "../ReactViewModels/MouseCoords";
import TableColorStyleTraits from "../Traits/TraitsClasses/Table/ColorStyleTraits";
import TableOutlineStyleTraits, {
OutlineSymbolTraits
} from "../Traits/TraitsClasses/Table/OutlineStyleTraits";
import TableStyleTraits from "../Traits/TraitsClasses/Table/StyleTraits";
import CameraView from "./CameraView";
import CommonStrata from "./Definition/CommonStrata";
import createStratumInstance from "./Definition/createStratumInstance";
import TerriaFeature from "./Feature/Feature";
import Terria from "./Terria";
import HighlightColorTraits from "../Traits/TraitsClasses/HighlightColorTraits";
import hasTraits from "./Definition/hasTraits";
import "./Feature/ImageryLayerFeatureInfo"; // overrides Cesium's prototype.configureDescriptionFromProperties
export default abstract class GlobeOrMap {
abstract readonly type: string;
abstract readonly terria: Terria;
abstract readonly canShowSplitter: boolean;
public static featureHighlightID = "___$FeatureHighlight&__";
protected static _featureHighlightName = "TerriaJS Feature Highlight Marker";
private _removeHighlightCallback?: () => Promise<void> | void;
private _highlightPromise: Promise<unknown> | undefined;
private _tilesLoadingCountMax: number = 0;
protected supportsPolylinesOnTerrain?: boolean;
// True if zoomTo() was called and the map is currently zooming to dataset
isMapZooming = false;
// An internal id to track an in progress call to zoomTo()
_currentZoomId?: string;
// This is updated by Leaflet and Cesium objects.
// Avoid duplicate mousemove events. Why would we get duplicate mousemove events? I'm glad you asked:
// http://stackoverflow.com/questions/17818493/mousemove-event-repeating-every-second/17819113
// I (Kevin Ring) see this consistently on my laptop when Windows Media Player is running.
mouseCoords: MouseCoords = new MouseCoords();
abstract destroy(): void;
abstract doZoomTo(
target: CameraView | Rectangle | MappableMixin.Instance,
flightDurationSeconds: number
): Promise<void>;
constructor() {
makeObservable(this);
}
/**
* Zoom map to a dataset or the given bounds.
*
* @param target A bounds item to zoom to
* @param flightDurationSeconds Optional time in seconds for the zoom animation to complete
* @returns A promise that resolves when the zoom animation is complete
*/
zoomTo(
target: CameraView | Rectangle | MappableMixin.Instance,
flightDurationSeconds: number = 3.0
): Promise<void> {
this.isMapZooming = true;
const zoomId = createGuid();
this._currentZoomId = zoomId;
return this.doZoomTo(target, flightDurationSeconds).finally(
action(() => {
// Unset isMapZooming only if the local zoomId matches _currentZoomId.
// If they do not match, it means there was another call to zoomTo which
// could still be in progress and it will handle unsetting isMapZooming.
if (zoomId === this._currentZoomId) {
this.isMapZooming = false;
this._currentZoomId = undefined;
if (MappableMixin.isMixedInto(target) && TimeVarying.is(target)) {
// Set the target as the source for timeline
this.terria.timelineStack.promoteToTop(target);
}
}
})
);
}
/**
* Set initial camera view
*/
abstract setInitialView(cameraView: CameraView): void;
abstract getCurrentCameraView(): CameraView;
/* Gets the current container element.
*/
abstract getContainer(): Element | undefined;
abstract pauseMapInteraction(): void;
abstract resumeMapInteraction(): void;
abstract notifyRepaintRequired(): void;
/**
* List of the attributions (credits) for data currently displayed on map.
*/
get attributions(): string[] {
return [];
}
/**
* Picks features based off a latitude, longitude and (optionally) height.
* @param latLngHeight The position on the earth to pick.
* @param providerCoords A map of imagery provider urls to the coords used to get features for those imagery
* providers - i.e. x, y, level
* @param existingFeatures An optional list of existing features to concatenate the ones found from asynchronous picking to.
*/
abstract pickFromLocation(
latLngHeight: LatLonHeight,
providerCoords: ProviderCoordsMap,
existingFeatures: TerriaFeature[]
): void;
/**
* Creates a {@see Feature} (based on an {@see Entity}) from a {@see ImageryLayerFeatureInfo}.
* @param imageryFeature The imagery layer feature for which to create an entity-based feature.
* @return The created feature.
*/
protected _createFeatureFromImageryLayerFeature(
imageryFeature: ImageryLayerFeatureInfo
): TerriaFeature {
const feature = new TerriaFeature({
id: imageryFeature.name
});
feature.name = imageryFeature.name;
if (imageryFeature.description) {
feature.description = new ConstantProperty(imageryFeature.description); // already defined by the new Entity
}
feature.properties = imageryFeature.properties;
feature.data = imageryFeature.data;
feature.imageryLayer = imageryFeature.imageryLayer;
if (imageryFeature.position) {
feature.position = new ConstantPositionProperty(
Ellipsoid.WGS84.cartographicToCartesian(imageryFeature.position)
);
}
(feature as any).coords = (imageryFeature as any).coords;
return feature;
}
/**
* Adds loading progress for cesium
*/
protected _updateTilesLoadingCount(tilesLoadingCount: number): void {
if (tilesLoadingCount > this._tilesLoadingCountMax) {
this._tilesLoadingCountMax = tilesLoadingCount;
} else if (tilesLoadingCount === 0) {
this._tilesLoadingCountMax = 0;
}
this.terria.tileLoadProgressEvent.raiseEvent(
tilesLoadingCount,
this._tilesLoadingCountMax
);
}
/**
* Adds loading progress (boolean) for 3DTileset layers where total tiles is not known
*/
protected _updateTilesLoadingIndeterminate(loading: boolean): void {
this.terria.indeterminateTileLoadProgressEvent.raiseEvent(loading);
}
/**
* Returns the side of the splitter the `position` lies on.
*
* @param The screen position.
* @return The side of the splitter on which `position` lies.
*/
protected _getSplitterSideForScreenPosition(
position: Cartesian2 | Cartesian3
): SplitDirection | undefined {
const container = this.terria.currentViewer.getContainer();
if (!isDefined(container)) {
return;
}
const splitterX = container.clientWidth * this.terria.splitPosition;
if (position.x <= splitterX) {
return SplitDirection.LEFT;
} else {
return SplitDirection.RIGHT;
}
}
abstract _addVectorTileHighlight(
imageryProvider: ProtomapsImageryProvider,
rectangle: Rectangle
): () => void;
async _highlightFeature(feature: TerriaFeature | undefined): Promise<void> {
if (isDefined(this._removeHighlightCallback)) {
await this._removeHighlightCallback();
this._removeHighlightCallback = undefined;
this._highlightPromise = undefined;
}
// Lazy import here to avoid cyclic dependencies.
const { default: GeoJsonCatalogItem } =
await import("./Catalog/CatalogItems/GeoJsonCatalogItem");
if (isDefined(feature)) {
let hasGeometry = false;
if (isDefined(feature._cesium3DTileFeature)) {
const originalColor = feature._cesium3DTileFeature.color;
const defaultColor = Color.fromCssColorString("#fffffe");
// Get the highlight color from the catalogItem trait or default to baseMapContrastColor
const catalogItem = feature._catalogItem;
let highlightColorString;
if (hasTraits(catalogItem, HighlightColorTraits, "highlightColor")) {
highlightColorString = runInAction(() => catalogItem.highlightColor);
runInAction(() => catalogItem.highlightColor);
} else {
highlightColorString = this.terria.baseMapContrastColor;
}
const highlightColor: Color = isDefined(highlightColorString)
? Color.fromCssColorString(highlightColorString)
: defaultColor;
// highlighting doesn't work if the highlight colour is full white
// so in this case use something close to white instead
feature._cesium3DTileFeature.color = Color.equals(
highlightColor,
Color.WHITE
)
? defaultColor
: highlightColor;
this._removeHighlightCallback = function () {
if (
isDefined(feature._cesium3DTileFeature) &&
feature._cesium3DTileFeature.tileset.isDestroyed() === false
) {
try {
feature._cesium3DTileFeature.color = originalColor;
} catch (err) {
TerriaError.from(err).log();
}
}
};
} else if (isDefined(feature.polygon)) {
hasGeometry = true;
const cesiumPolygon = feature.cesiumEntity || feature;
const polygonOutline = cesiumPolygon.polygon!.outline;
const polygonOutlineColor = cesiumPolygon.polygon!.outlineColor;
const polygonMaterial = cesiumPolygon.polygon!.material;
cesiumPolygon.polygon!.outline = new ConstantProperty(true);
cesiumPolygon.polygon!.outlineColor = new ConstantProperty(
Color.fromCssColorString(this.terria.baseMapContrastColor) ??
Color.GRAY
);
cesiumPolygon.polygon!.material = new ColorMaterialProperty(
new ConstantProperty(
(
Color.fromCssColorString(this.terria.baseMapContrastColor) ??
Color.LIGHTGRAY
).withAlpha(0.75)
)
);
this._removeHighlightCallback = function () {
if (cesiumPolygon.polygon) {
cesiumPolygon.polygon.outline = polygonOutline;
cesiumPolygon.polygon.outlineColor = polygonOutlineColor;
cesiumPolygon.polygon.material = polygonMaterial;
}
};
} else if (isDefined(feature.polyline)) {
hasGeometry = true;
const cesiumPolyline = feature.cesiumEntity || feature;
const polylineMaterial = cesiumPolyline.polyline!.material;
const polylineWidth = cesiumPolyline.polyline!.width;
(cesiumPolyline as any).polyline.material =
Color.fromCssColorString(this.terria.baseMapContrastColor) ??
Color.LIGHTGRAY;
cesiumPolyline.polyline!.width = new ConstantProperty(2);
this._removeHighlightCallback = function () {
if (cesiumPolyline.polyline) {
cesiumPolyline.polyline.material = polylineMaterial;
cesiumPolyline.polyline.width = polylineWidth;
}
};
}
if (!hasGeometry) {
let vectorTileHighlightCreated = false;
// Feature from ProtomapsImageryProvider
if (
feature.imageryLayer?.imageryProvider instanceof
ProtomapsImageryProvider
) {
const highlightImageryProvider =
feature.imageryLayer.imageryProvider.createHighlightImageryProvider(
feature
);
if (highlightImageryProvider)
this._removeHighlightCallback =
this.terria.currentViewer._addVectorTileHighlight(
highlightImageryProvider,
feature.imageryLayer.imageryProvider.rectangle
);
vectorTileHighlightCreated = true;
}
// No vector tile highlight was created so try to convert feature to GeoJSON
// This flag is necessary to check as it is possible for a feature to use ProtomapsImageryProvider and also have GeoJson data - but maybe failed to createHighlightImageryProvider
if (!vectorTileHighlightCreated) {
const geoJson = featureDataToGeoJson(feature.data);
// Don't show points; the targeting cursor is sufficient.
if (geoJson) {
geoJson.features = geoJson.features.filter(
(f) => f.geometry.type !== "Point"
);
let catalogItem = this.terria.getModelById(
GeoJsonCatalogItem,
GlobeOrMap.featureHighlightID
);
if (catalogItem === undefined) {
catalogItem = new GeoJsonCatalogItem(
GlobeOrMap.featureHighlightID,
this.terria
);
catalogItem.setTrait(
CommonStrata.definition,
"name",
GlobeOrMap._featureHighlightName
);
this.terria.addModel(catalogItem);
}
catalogItem.setTrait(
CommonStrata.user,
"geoJsonData",
geoJson as any
);
catalogItem.setTrait(
CommonStrata.user,
"useOutlineColorForLineFeatures",
true
);
catalogItem.setTrait(
CommonStrata.user,
"defaultStyle",
createStratumInstance(TableStyleTraits, {
outline: createStratumInstance(TableOutlineStyleTraits, {
null: createStratumInstance(OutlineSymbolTraits, {
width: 4,
color: this.terria.baseMapContrastColor
})
}),
color: createStratumInstance(TableColorStyleTraits, {
nullColor: "rgba(0,0,0,0)"
})
})
);
this.terria.overlays.add(catalogItem);
this._highlightPromise = catalogItem.loadMapItems();
const removeCallback = (this._removeHighlightCallback = () => {
if (!isDefined(this._highlightPromise)) {
return;
}
return this._highlightPromise
.then(() => {
if (removeCallback !== this._removeHighlightCallback) {
return;
}
if (isDefined(catalogItem)) {
catalogItem.setTrait(CommonStrata.user, "show", false);
}
})
.catch(function () {});
});
(await catalogItem.loadMapItems()).logError(
"Error occurred while loading picked feature"
);
// Check to make sure we don't have a different `catalogItem` after loading
if (removeCallback !== this._removeHighlightCallback) {
return;
}
catalogItem.setTrait(CommonStrata.user, "show", true);
this._highlightPromise = this.terria.overlays
.add(catalogItem)
.then((r) => r.throwIfError());
}
}
}
}
}
/**
* Captures a screenshot of the map.
* @return A promise that resolves to a data URL when the screenshot is ready.
*/
captureScreenshot(): Promise<string> {
throw new DeveloperError(
"captureScreenshot must be implemented in the derived class."
);
}
}