UNPKG

terriajs

Version:

Geospatial data visualization platform.

768 lines (681 loc) 24 kB
// const mobx = require('mobx'); // const mobxUtils = require('mobx-utils'); // Problems in current architecture: // 1. After loading, can't tell what user actually set versus what came from e.g. GetCapabilities. // Solution: layering // 2. CkanCatalogItem producing a WebMapServiceCatalogItem on load // 3. Observable spaghetti // Solution: think in terms of pipelines with computed observables, document patterns. // 4. All code for all catalog item types needs to be loaded before we can do anything. import i18next from "i18next"; import { computed, runInAction } from "mobx"; import combine from "terriajs-cesium/Source/Core/combine"; import GeographicTilingScheme from "terriajs-cesium/Source/Core/GeographicTilingScheme"; import JulianDate from "terriajs-cesium/Source/Core/JulianDate"; import WebMercatorTilingScheme from "terriajs-cesium/Source/Core/WebMercatorTilingScheme"; import GetFeatureInfoFormat from "terriajs-cesium/Source/Scene/GetFeatureInfoFormat"; import WebMapServiceImageryProvider from "terriajs-cesium/Source/Scene/WebMapServiceImageryProvider"; import URI from "urijs"; import createTransformerAllowUndefined from "../../../Core/createTransformerAllowUndefined"; import filterOutUndefined from "../../../Core/filterOutUndefined"; import isDefined from "../../../Core/isDefined"; import TerriaError from "../../../Core/TerriaError"; import CatalogMemberMixin, { getName } from "../../../ModelMixins/CatalogMemberMixin"; import ChartableMixin from "../../../ModelMixins/ChartableMixin"; import DiffableMixin from "../../../ModelMixins/DiffableMixin"; import ExportWebCoverageServiceMixin from "../../../ModelMixins/ExportWebCoverageServiceMixin"; import GetCapabilitiesMixin from "../../../ModelMixins/GetCapabilitiesMixin"; import { ImageryParts } from "../../../ModelMixins/MappableMixin"; import MinMaxLevelMixin from "../../../ModelMixins/MinMaxLevelMixin"; import TileErrorHandlerMixin from "../../../ModelMixins/TileErrorHandlerMixin"; import UrlMixin from "../../../ModelMixins/UrlMixin"; import WebMapServiceCatalogItemTraits, { SUPPORTED_CRS_3857, SUPPORTED_CRS_4326 } from "../../../Traits/TraitsClasses/WebMapServiceCatalogItemTraits"; import CommonStrata from "../../Definition/CommonStrata"; import CreateModel from "../../Definition/CreateModel"; import LoadableStratum from "../../Definition/LoadableStratum"; import { BaseModel } from "../../Definition/Model"; import StratumOrder from "../../Definition/StratumOrder"; import SelectableDimensions, { SelectableDimensionEnum } from "../../SelectableDimensions/SelectableDimensions"; import Terria from "../../Terria"; import proxyCatalogItemUrl from "../proxyCatalogItemUrl"; import WebMapServiceCapabilities from "./WebMapServiceCapabilities"; import WebMapServiceCapabilitiesStratum from "./WebMapServiceCapabilitiesStratum"; import WebMapServiceCatalogGroup from "./WebMapServiceCatalogGroup"; /** This LoadableStratum is responsible for setting WMS version based on CatalogItem.url */ export class WebMapServiceUrlStratum extends LoadableStratum( WebMapServiceCatalogItemTraits ) { static stratumName = "wms-url-stratum"; constructor(readonly catalogItem: WebMapServiceCatalogItem) { super(); } duplicateLoadableStratum(model: BaseModel): this { return new WebMapServiceUrlStratum( model as WebMapServiceCatalogItem ) as this; } @computed get useWmsVersion130() { if ( this.catalogItem.url?.toLowerCase().includes("version=1.1.0") || this.catalogItem.url?.toLowerCase().includes("version=1.1.1") ) { return false; } } } StratumOrder.addLoadStratum(WebMapServiceUrlStratum.stratumName); class WebMapServiceCatalogItem extends TileErrorHandlerMixin( ExportWebCoverageServiceMixin( DiffableMixin( MinMaxLevelMixin( GetCapabilitiesMixin( UrlMixin( CatalogMemberMixin(CreateModel(WebMapServiceCatalogItemTraits)) ) ) ) ) ) ) implements SelectableDimensions { /** * The collection of strings that indicate an Abstract property should be ignored. If these strings occur anywhere * in the Abstract, the Abstract will not be used. This makes it easy to filter out placeholder data like * Geoserver's "A compliant implementation of WMS..." stock abstract. */ static abstractsToIgnore = ["A compliant implementation of WMS"]; // hide elements in the info section which might show information about the datasource _sourceInfoItemNames = [ i18next.t("models.webMapServiceCatalogItem.getCapabilitiesUrl") ]; _webMapServiceCatalogGroup: undefined | WebMapServiceCatalogGroup = undefined; /** Default WMS parameters for version=1.3.0 */ static defaultParameters130 = { transparent: true, format: "image/png", exceptions: "XML", styles: "", version: "1.3.0" }; static defaultGetFeatureParameters130 = { exceptions: "XML", version: "1.3.0" }; /** Default WMS parameters for version=1.1.1 */ static defaultParameters111 = { transparent: true, format: "image/png", exceptions: "application/vnd.ogc.se_xml", styles: "", tiled: true, version: "1.1.1" }; static defaultGetFeatureParameters111 = { exceptions: "application/vnd.ogc.se_xml", version: "1.1.1" }; static readonly type = "wms"; constructor( id: string | undefined, terria: Terria, sourceReference?: BaseModel | undefined ) { super(id, terria, sourceReference); this.strata.set( WebMapServiceUrlStratum.stratumName, new WebMapServiceUrlStratum(this) ); } get type() { return WebMapServiceCatalogItem.type; } @computed get shortReport(): string | undefined { if ( this.tilingScheme instanceof GeographicTilingScheme && this.terria.currentViewer.type === "Leaflet" ) { return i18next.t("map.cesium.notWebMercatorTilingScheme", this); } return super.shortReport; } @computed get colorScaleRange(): string | undefined { if (this.supportsColorScaleRange) { return `${this.colorScaleMinimum},${this.colorScaleMaximum}`; } return undefined; } async createGetCapabilitiesStratumFromParent( capabilities: WebMapServiceCapabilities ) { const stratum = await WebMapServiceCapabilitiesStratum.load( this, capabilities ); runInAction(() => { this.strata.set(GetCapabilitiesMixin.getCapabilitiesStratumName, stratum); }); } protected async forceLoadMapItems(): Promise<void> { if (this.invalidLayers.length > 0) throw new TerriaError({ sender: this, title: i18next.t("models.webMapServiceCatalogItem.noLayerFoundTitle"), message: i18next.t( "models.webMapServiceCatalogItem.noLayerFoundMessage", { name: getName(this), layers: this.invalidLayers.join(", ") } ) }); } protected async forceLoadMetadata(): Promise<void> { if ( this.strata.get(GetCapabilitiesMixin.getCapabilitiesStratumName) !== undefined ) return; const stratum = await WebMapServiceCapabilitiesStratum.load(this); runInAction(() => { this.strata.set(GetCapabilitiesMixin.getCapabilitiesStratumName, stratum); }); } @computed get cacheDuration(): string { if (isDefined(super.cacheDuration)) { return super.cacheDuration; } return "0d"; } @computed get layersArray(): ReadonlyArray<string> { if (Array.isArray(this.layers)) { return this.layers; } else if (this.layers) { return this.layers.split(","); } else { return []; } } /** LAYERS which are valid (i.e. exist in GetCapabilities). * These can be fetched from the server (eg GetMap request) */ @computed get validLayers() { const gcStratum: WebMapServiceCapabilitiesStratum | undefined = this.strata.get( GetCapabilitiesMixin.getCapabilitiesStratumName ) as WebMapServiceCapabilitiesStratum; if (gcStratum) return this.layersArray .map((layer) => gcStratum.capabilities.findLayer(layer)?.Name) .filter(isDefined); return []; } /** LAYERS which are **INVALID** - they do **not** exist in GetCapabilities * These layers can **not** be fetched the server (eg GetMap request) */ @computed get invalidLayers() { const gcStratum: WebMapServiceCapabilitiesStratum | undefined = this.strata.get( GetCapabilitiesMixin.getCapabilitiesStratumName ) as WebMapServiceCapabilitiesStratum; if (gcStratum) return this.layersArray.filter( (layer) => !isDefined(gcStratum.capabilities.findLayer(layer)?.Name) ); return []; } @computed get stylesArray(): ReadonlyArray<string> { return this.styles?.split(",") ?? []; } @computed get discreteTimes() { const getCapabilitiesStratum: WebMapServiceCapabilitiesStratum | undefined = this.strata.get( GetCapabilitiesMixin.getCapabilitiesStratumName ) as WebMapServiceCapabilitiesStratum; return getCapabilitiesStratum?.discreteTimes; } protected get defaultGetCapabilitiesUrl(): string | undefined { if (this.uri) { return this.uri .clone() .setSearch({ service: "WMS", version: this.useWmsVersion130 ? "1.3.0" : "1.1.1", request: "GetCapabilities" }) .toString(); } else { return undefined; } } @computed get canDiffImages(): boolean { const hasValidDiffStyles = this.availableDiffStyles.some((diffStyle) => this.styleSelectableDimensions?.[0]?.options?.find( (style) => style.id === diffStyle ) ); return hasValidDiffStyles === true; } showDiffImage( firstDate: JulianDate, secondDate: JulianDate, diffStyleId: string ) { if (this.canDiffImages === false) { return; } // A helper to get the diff tag given a date string const firstDateStr = this.getTagForTime(firstDate); const secondDateStr = this.getTagForTime(secondDate); this.setTrait(CommonStrata.user, "firstDiffDate", firstDateStr); this.setTrait(CommonStrata.user, "secondDiffDate", secondDateStr); this.setTrait(CommonStrata.user, "diffStyleId", diffStyleId); this.setTrait(CommonStrata.user, "isShowingDiff", true); } clearDiffImage() { this.setTrait(CommonStrata.user, "firstDiffDate", undefined); this.setTrait(CommonStrata.user, "secondDiffDate", undefined); this.setTrait(CommonStrata.user, "diffStyleId", undefined); this.setTrait(CommonStrata.user, "isShowingDiff", false); } getLegendUrlForStyle( styleId: string, firstDate?: JulianDate, secondDate?: JulianDate ) { const firstTag = firstDate && this.getTagForTime(firstDate); const secondTag = secondDate && this.getTagForTime(secondDate); const time = filterOutUndefined([firstTag, secondTag]).join(","); const layerName = this.availableStyles.find((style) => style.styles.some((s) => s.name === styleId) )?.layerName; const uri = URI( `${this.url}?service=WMS&version=1.1.0&request=GetLegendGraphic&format=image/png&transparent=True` ) .addQuery("layer", encodeURIComponent(layerName || "")) .addQuery("styles", encodeURIComponent(styleId)); if (time) { uri.addQuery("time", time); } return uri.toString(); } @computed get mapItems() { // Don't return anything if there are invalid layers // See forceLoadMapItems for error message if (this.invalidLayers.length > 0) return []; if (this.isShowingDiff === true) { return this._diffImageryParts ? [this._diffImageryParts] : []; } const result = []; const current = this._currentImageryParts; if (current) { result.push(current); } const next = this._nextImageryParts; if (next) { result.push(next); } return result; } @computed get tilingScheme() { if (this.crs) { if (SUPPORTED_CRS_3857.includes(this.crs)) return new WebMercatorTilingScheme(); if (SUPPORTED_CRS_4326.includes(this.crs)) return new GeographicTilingScheme(); } return new WebMercatorTilingScheme(); } @computed private get _currentImageryParts(): ImageryParts | undefined { const imageryProvider = this._createImageryProvider( this.currentDiscreteTimeTag ); if (imageryProvider === undefined) { return undefined; } imageryProvider.enablePickFeatures = true; return { imageryProvider, alpha: this.opacity, show: this.show, clippingRectangle: this.clipToRectangle ? this.cesiumRectangle : undefined }; } @computed private get _nextImageryParts(): ImageryParts | undefined { if ( this.terria.timelineStack.contains(this) && !this.isPaused && this.nextDiscreteTimeTag ) { const imageryProvider = this._createImageryProvider( this.nextDiscreteTimeTag ); if (imageryProvider === undefined) { return undefined; } imageryProvider.enablePickFeatures = false; return { imageryProvider, alpha: 0.0, show: true, clippingRectangle: this.clipToRectangle ? this.cesiumRectangle : undefined }; } else { return undefined; } } @computed private get _diffImageryParts(): ImageryParts | undefined { const diffStyleId = this.diffStyleId; if ( this.firstDiffDate === undefined || this.secondDiffDate === undefined || diffStyleId === undefined ) { return; } const time = `${this.firstDiffDate},${this.secondDiffDate}`; const imageryProvider = this._createImageryProvider(time); if (imageryProvider) { return { imageryProvider, alpha: this.opacity, show: this.show, clippingRectangle: this.clipToRectangle ? this.cesiumRectangle : undefined }; } return undefined; } @computed get diffModeParameters() { return { styles: this.diffStyleId }; } getTagForTime(date: JulianDate): string | undefined { const index = this.getDiscreteTimeIndex(date); return index !== undefined ? this.discreteTimesAsSortedJulianDates?.[index].tag : undefined; } private _createImageryProvider = createTransformerAllowUndefined( (time: string | undefined): WebMapServiceImageryProvider | undefined => { // Don't show anything on the map until GetCapabilities finishes loading. if (this.isLoadingMetadata) { return undefined; } if (this.url === undefined) { return undefined; } console.log(`Creating new ImageryProvider for time ${time}`); // Set dimensionParameters const dimensionParameters = formatDimensionsForOws(this.dimensions); if (time !== undefined) { dimensionParameters.time = time; } // Construct parameters objects // We use slightly different parameters for GetMap and GetFeatureInfo requests const parameters: { [key: string]: any } = { ...(this.useWmsVersion130 ? WebMapServiceCatalogItem.defaultParameters130 : WebMapServiceCatalogItem.defaultParameters111), ...this.parameters, ...dimensionParameters }; const getFeatureInfoParameters: { [key: string]: any } = { ...(this.useWmsVersion130 ? WebMapServiceCatalogItem.defaultGetFeatureParameters130 : WebMapServiceCatalogItem.defaultGetFeatureParameters111), feature_count: 1 + (this.maximumShownFeatureInfos ?? this.terria.configParameters.defaultMaximumShownFeatureInfos), ...this.parameters, ...this.getFeatureInfoParameters, ...dimensionParameters }; const diffModeParameters = this.isShowingDiff ? this.diffModeParameters : {}; if (this.supportsColorScaleRange) { parameters.COLORSCALERANGE = this.colorScaleRange; } if (isDefined(this.styles)) { parameters.styles = this.styles; getFeatureInfoParameters.styles = this.styles; } Object.assign(parameters, diffModeParameters); // Remove problematic query parameters from URL - these are handled by the parameters objects const queryParametersToRemove = [ "request", "service", "x", "y", "width", "height", "bbox", "layers", "styles", "version", "format", "srs", "crs" ]; const baseUrl = queryParametersToRemove.reduce( (url, parameter) => url .removeQuery(parameter) .removeQuery(parameter.toUpperCase()) .removeQuery(parameter.toLowerCase()), new URI(this.url) ); // Set CRS for WMS 1.3.0 // Set SRS for WMS 1.1.1 const crs = this.useWmsVersion130 ? this.crs : undefined; const srs = this.useWmsVersion130 ? undefined : this.crs; const imageryOptions: WebMapServiceImageryProvider.ConstructorOptions = { url: proxyCatalogItemUrl(this, baseUrl.toString()), layers: this.validLayers.length > 0 ? this.validLayers.join(",") : "", parameters, crs, srs, getFeatureInfoParameters, getFeatureInfoUrl: this.getFeatureInfoUrl, tileWidth: this.tileWidth, tileHeight: this.tileHeight, tilingScheme: this.tilingScheme, maximumLevel: this.getMaximumLevel(true) ?? this.maximumLevel, minimumLevel: this.minimumLevel, credit: this.attribution, enablePickFeatures: this.allowFeaturePicking }; if (isDefined(this.getFeatureInfoFormat?.type)) { imageryOptions.getFeatureInfoFormats = [ new GetFeatureInfoFormat( this.getFeatureInfoFormat.type, this.getFeatureInfoFormat.format ) ]; } if ( imageryOptions.maximumLevel !== undefined && this.hideLayerAfterMinScaleDenominator ) { // Make Cesium request one extra level so we can tell the user what's happening and return a blank image. ++imageryOptions.maximumLevel; } const imageryProvider = new WebMapServiceImageryProvider(imageryOptions); return this.updateRequestImage(imageryProvider); } ); @computed get styleSelectableDimensions(): SelectableDimensionEnum[] { return this.availableStyles.map((layer, layerIndex) => { let name = "Styles"; // If multiple layers -> prepend layer name to name if (this.availableStyles.length > 1) { // Attempt to get layer title from GetCapabilitiesStratum const layerTitle = layer.layerName && ( this.strata.get( GetCapabilitiesMixin.getCapabilitiesStratumName ) as WebMapServiceCapabilitiesStratum ).capabilitiesLayers.get(layer.layerName)?.Title; name = `${ layerTitle || layer.layerName || `Layer ${layerIndex + 1}` } styles`; } const options = filterOutUndefined( layer.styles.map(function (s) { if (isDefined(s.name)) { return { name: s.title || s.name || "", id: s.name as string }; } }) ); // Try to set selectedId to value stored in `styles` trait for this `layerIndex` // The `styles` parameter is CSV, a style for each layer let selectedId = this.styles?.split(",")?.[layerIndex]; return { name, id: `${this.uniqueId}-${layer.layerName}-styles`, options, selectedId, setDimensionValue: ( stratumId: string, newStyle: string | undefined ) => { if (!newStyle) return; runInAction(() => { const styles = this.styleSelectableDimensions.map( (style) => style.selectedId || "" ); styles[layerIndex] = newStyle; this.setTrait(stratumId, "styles", styles.join(",")); }); }, // There is no way of finding out default style if no style has been selected :( // To use the default style, we just send empty "styles" to WMS server // But if the server doesn't support GetLegendGraphic, then we can't request the default legend // Therefore - we only add the "Default style" / undefined option if supportsGetLegendGraphic is true allowUndefined: this.supportsGetLegendGraphic && options.length > 1, undefinedLabel: i18next.t( "models.webMapServiceCatalogItem.defaultStyleLabel" ), disable: this.isShowingDiff }; }); } @computed get wmsDimensionSelectableDimensions(): SelectableDimensionEnum[] { const dimensions: SelectableDimensionEnum[] = []; // For each layer -> For each dimension this.availableDimensions.forEach((layer) => { layer.dimensions.forEach((dim) => { // Only add dimensions if hasn't already been added (multiple layers may have the same dimension) if ( !isDefined(dim.name) || dim.values.length < 2 || dimensions.findIndex((findDim) => findDim.name === dim.name) !== -1 ) { return; } dimensions.push({ name: dim.name, id: `${this.uniqueId}-${dim.name}`, options: dim.values.map((value) => { let name = value; // Add units and unitSybol if defined if (typeof dim.units === "string" && dim.units !== "") { if (typeof dim.unitSymbol === "string" && dim.unitSymbol !== "") { name = `${value} (${dim.units} ${dim.unitSymbol})`; } else { name = `${value} (${dim.units})`; } } return { name, id: value }; }), // Set selectedId to value stored in `dimensions` trait, the default value, or the first available value selectedId: this.dimensions?.[dim.name]?.toString() || dim.default || dim.values[0], setDimensionValue: ( stratumId: string, newDimension: string | undefined ) => { let newDimensions: any = {}; newDimensions[dim.name!] = newDimension; if (isDefined(this.dimensions)) { newDimensions = combine(newDimensions, this.dimensions); } runInAction(() => { this.setTrait(stratumId, "dimensions", newDimensions); }); } }); }); }); return dimensions; } @computed get selectableDimensions() { if (this.disableDimensionSelectors) { return super.selectableDimensions; } return filterOutUndefined([ ...super.selectableDimensions, ...this.wmsDimensionSelectableDimensions, ...this.styleSelectableDimensions ]); } } /** * Add `_dim` prefix to dimensions for OWS (WMS, WCS...) excluding time, styles and elevation */ export function formatDimensionsForOws( dimensions: { [key: string]: string } | undefined ) { if (!isDefined(dimensions)) { return {}; } return Object.entries(dimensions).reduce<{ [key: string]: string }>( (formattedDimensions, [key, value]) => // elevation is specified as simply "elevation", styles is specified as "styles" // Other (custom) dimensions are prefixed with 'dim_'. // See WMS 1.3.0 spec section C.3.2 and C.3.3. { formattedDimensions[ ["time", "styles", "elevation"].includes(key?.toLowerCase()) ? key : `dim_${key}` ] = value; return formattedDimensions; }, {} ); } export default WebMapServiceCatalogItem;