UNPKG

terriajs

Version:

Geospatial data visualization platform.

545 lines (482 loc) 17.9 kB
import i18next from "i18next"; import { computed, runInAction, makeObservable } from "mobx"; import CesiumMath from "terriajs-cesium/Source/Core/Math"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import RequestErrorEvent from "terriajs-cesium/Source/Core/RequestErrorEvent"; import URI from "urijs"; import AsyncLoader from "../Core/AsyncLoader"; import Constructor from "../Core/Constructor"; import isDefined from "../Core/isDefined"; import loadBlob from "../Core/loadBlob"; import loadXML from "../Core/loadXML"; import Result from "../Core/Result"; import TerriaError, { networkRequestError } from "../Core/TerriaError"; import proxyCatalogItemUrl from "../Models/Catalog/proxyCatalogItemUrl"; import ResultPendingCatalogItem from "../Models/Catalog/ResultPendingCatalogItem"; import CommonStrata from "../Models/Definition/CommonStrata"; import createStratumInstance from "../Models/Definition/createStratumInstance"; import LoadableStratum from "../Models/Definition/LoadableStratum"; import Model, { BaseModel } from "../Models/Definition/Model"; import StratumOrder from "../Models/Definition/StratumOrder"; import UserDrawing from "../Models/UserDrawing"; import xml2json from "../ThirdParty/xml2json"; import { InfoSectionTraits } from "../Traits/TraitsClasses/CatalogMemberTraits"; import ExportWebCoverageServiceTraits, { WebCoverageServiceParameterTraits } from "../Traits/TraitsClasses/ExportWebCoverageServiceTraits"; import { getName } from "./CatalogMemberMixin"; import ExportableMixin from "./ExportableMixin"; import filterOutUndefined from "../Core/filterOutUndefined"; type Coverage = { CoverageId: string; CoverageSubtype: string; Title: string; WGS84BoundingBox: { LowerCorner: string; UpperCorner: string; dimension: string; }; }; /** Call WCS GetCapabilities to get list of: * - available coverages * - available CRS * - available file formats * * Note: not currently used */ class WebCoverageServiceCapabilitiesStratum extends LoadableStratum( ExportWebCoverageServiceTraits ) { static stratumName = "wcsCapabilitiesStratum"; static async load(catalogItem: ExportWebCoverageServiceMixin.Instance) { if (!catalogItem.linkedWcsUrl) throw "`linkedWcsUrl` is undefined"; const url = new URI(catalogItem.linkedWcsUrl) .query({ service: "WCS", request: "GetCapabilities", version: "2.0.0" }) .toString(); const capabilitiesXml = await loadXML( proxyCatalogItemUrl(catalogItem, url) ); const json = xml2json(capabilitiesXml); if (!isDefined(json.ServiceMetadata)) { throw networkRequestError({ title: "Invalid GetCapabilities", message: `The URL ${url} was retrieved successfully but it does not appear to be a valid Web Coverage Service (WCS) GetCapabilities document.` + `\n\nEither the catalog file has been set up incorrectly, or the server address has changed.` }); } const coverages: Coverage[] = json.Contents?.CoverageSummary ?? []; const formats: string[] = json.ServiceMetadata?.formatSupported ?? []; const crs: string[] = json.ServiceMetadata?.Extension?.CrsMetadata?.crsSupported ?? []; return new WebCoverageServiceCapabilitiesStratum(catalogItem, { coverages, formats, crs }); } constructor( readonly catalogItem: ExportWebCoverageServiceMixin.Instance, readonly capabilities: { coverages: Coverage[]; formats: string[]; crs: string[]; } ) { super(); } duplicateLoadableStratum(model: BaseModel): this { return new WebCoverageServiceCapabilitiesStratum( model as ExportWebCoverageServiceMixin.Instance, this.capabilities ) as this; } } /** Call WCS DescribeCoverage for a specific coverageId to get: * - Native CRS * - Native format */ class WebCoverageServiceDescribeCoverageStratum extends LoadableStratum( ExportWebCoverageServiceTraits ) { static stratumName = "wcsDescribeCoverageStratum"; static async load(catalogItem: ExportWebCoverageServiceMixin.Instance) { if (!catalogItem.linkedWcsUrl) throw "`linkedWcsUrl` is undefined"; if (!catalogItem.linkedWcsCoverage) throw "`linkedWcsCoverage` is undefined"; const url = new URI(catalogItem.linkedWcsUrl) .query({ service: "WCS", request: "DescribeCoverage", version: "2.0.0", coverageId: catalogItem.linkedWcsCoverage }) .toString(); const capabilitiesXml = await loadXML( proxyCatalogItemUrl(catalogItem, url) ); const json = xml2json(capabilitiesXml); if (typeof json.CoverageDescription?.CoverageId !== "string") { throw networkRequestError({ title: "Invalid DescribeCoverage", message: `The URL ${url} was retrieved successfully but it does not appear to be a valid Web Coverage Service (WCS) DescribeCoverage document.` + `\n\nEither the catalog file has been set up incorrectly, or the server address has changed.` }); } const nativeFormat: string | undefined = json.CoverageDescription?.ServiceParameters?.nativeFormat; // Try get native CRS from domainSet and then boundedBy const nativeCrs: string | undefined = json.CoverageDescription?.domainSet?.Grid?.srsName ?? json.CoverageDescription?.boundedBy?.EnvelopeWithTimePeriod?.srsName ?? json.CoverageDescription?.boundedBy?.Envelope?.srsName; return new WebCoverageServiceDescribeCoverageStratum(catalogItem, { nativeFormat, nativeCrs }); } constructor( readonly catalogItem: ExportWebCoverageServiceMixin.Instance, readonly coverage: { nativeFormat: string | undefined; nativeCrs: string | undefined; } ) { super(); makeObservable(this); } @computed get linkedWcsParameters() { return createStratumInstance(WebCoverageServiceParameterTraits, { outputCrs: this.coverage.nativeCrs, outputFormat: this.coverage.nativeFormat }); } duplicateLoadableStratum(model: BaseModel): this { return new WebCoverageServiceDescribeCoverageStratum( model as ExportWebCoverageServiceMixin.Instance, this.coverage ) as this; } } function ExportWebCoverageServiceMixin< T extends Constructor<Model<ExportWebCoverageServiceTraits>> >(Base: T) { abstract class ExportWebCoverageServiceMixin extends ExportableMixin(Base) { private _wcsCapabilitiesLoader = new AsyncLoader( this.loadWcsCapabilities.bind(this) ); private _wcsDescribeCoverageLoader = new AsyncLoader( this.loadWcsDescribeCoverage.bind(this) ); constructor(...args: any[]) { super(...args); makeObservable(this); } @computed get isLoadingWcsMetadata(): boolean { return ( this._wcsCapabilitiesLoader.isLoading || this._wcsDescribeCoverageLoader.isLoading ); } async loadWcsMetadata(force?: boolean) { const results = await Promise.all([ // Disable GetCapabilities loader until we need it // this._wcsCapabilitiesLoader.load(force), this._wcsDescribeCoverageLoader.load(force) ]); return Result.combine(results, { message: `Failed to load \`${getName( this )}\` WebCoverageService metadata`, importance: -1 }); } private async loadWcsCapabilities() { const capabilities = await WebCoverageServiceCapabilitiesStratum.load(this); runInAction(() => this.strata.set( WebCoverageServiceCapabilitiesStratum.stratumName, capabilities ) ); } private async loadWcsDescribeCoverage() { const describeCoverage = await WebCoverageServiceDescribeCoverageStratum.load(this); runInAction(() => this.strata.set( WebCoverageServiceDescribeCoverageStratum.stratumName, describeCoverage ) ); } // ExportableMixin overrides @computed get _canExportData() { return isDefined(this.linkedWcsCoverage) && isDefined(this.linkedWcsUrl); } _exportData(): Promise<undefined | { name: string; file: Blob }> { return new Promise((resolve, reject) => { const terria = this.terria; runInAction(() => (terria.pickedFeatures = undefined)); let rectangle: Rectangle | undefined; const userDrawing = new UserDrawing({ terria: this.terria, messageHeader: "Click two points to draw a retangle extent.", buttonText: "Download Extent", onPointClicked: () => { if (userDrawing.pointEntities.entities.values.length >= 2) { rectangle = userDrawing?.otherEntities?.entities ?.getById("rectangle") ?.rectangle?.coordinates?.getValue( this.terria.timelineClock.currentTime ); } }, onCleanUp: async () => { if (isDefined(rectangle)) { if (!this.linkedWcsUrl || !this.linkedWcsCoverage) return; return this.downloadCoverage(rectangle) .then(resolve) .catch(reject); } else { reject("Invalid drawn extent."); } }, allowPolygon: false, drawRectangle: true }); userDrawing.enterDrawMode(); }); } /** Generate WCS GetCoverage URL */ getCoverageUrl(bbox: Rectangle): Result<string | undefined> { try { let error: TerriaError | undefined = undefined; if ( this.linkedWcsParameters.duplicateSubsetValues && this.linkedWcsParameters.duplicateSubsetValues.length > 0 ) { let message = `WebCoverageService (WCS) only supports one value per dimension.\n\n `; // Add message for each duplicate subset message += this.linkedWcsParameters.duplicateSubsetValues.map( (subset) => `- Multiple dimension values have been set for \`${subset.key}\`. WCS GetCoverage request will use the first value (\`${subset.key} = "${subset.value}"\`).` ); error = new TerriaError({ title: "Warning: export may not reflect displayed data", message, importance: 1 }); } // Make query parameter object const query = { service: "WCS", request: "GetCoverage", version: "2.0.0", coverageId: this.linkedWcsCoverage, format: this.linkedWcsParameters.outputFormat, // Add subsets for bbox, time and dimensions subset: [ `Long(${CesiumMath.toDegrees(bbox.west)},${CesiumMath.toDegrees( bbox.east )})`, `Lat(${CesiumMath.toDegrees(bbox.south)},${CesiumMath.toDegrees( bbox.north )})`, // Turn subsets into `key=(value)` format ...filterOutUndefined( (this.linkedWcsParameters.subsets ?? []).map((subset) => subset.key && subset.value ? `${subset.key}(${ // Wrap string values in double quotes typeof subset.value === "string" ? `"${subset.value}"` : subset.value })` : undefined ) ) ], subsettingCrs: "EPSG:4326", outputCrs: this.linkedWcsParameters.outputCrs }; // Add linkedWcsParameters.additionalParameters ontop of query object Object.assign( query, (this.linkedWcsParameters.additionalParameters ?? []).reduce<{ [key: string]: string | undefined; }>((q, current) => { if (typeof current.key === "string") { q[current.key] = current.value; } return q; }, {}) ); return new Result( new URI(this.linkedWcsUrl).query(query).toString(), error ); } catch (e) { return Result.error(e); } } /** This function downloads WCS coverage for a given bbox (in radians) * It will also create a "pendingWorkbenchItem" with loading indicator and short description. */ async downloadCoverage( bbox: Rectangle ): Promise<{ name: string; file: Blob }> { // Create pending workbench item const now = new Date(); const timestamp = `${now.getFullYear().toString().padStart(4, "0")}-${( now.getMonth() + 1 ) .toString() .padStart(2, "0")}-${now.getDate().toString().padStart(2, "0")}T${now .getHours() .toString() .padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now .getSeconds() .toString() .padStart(2, "0")}`; const pendingWorkbenchItem = new ResultPendingCatalogItem( `WCS: ${getName(this)} ${timestamp}`, this.terria ); try { runInAction(() => { pendingWorkbenchItem.loadPromise = new Promise(() => {}); pendingWorkbenchItem.loadMetadata(); // Add WCS loading metadata message to shortReport pendingWorkbenchItem.setTrait( CommonStrata.user, "shortReport", i18next.t("models.wcs.asyncResultLoadingMetadata", { name: getName(this), timestamp: timestamp }) ); }); pendingWorkbenchItem.terria.workbench.add(pendingWorkbenchItem); // Load WCS metadata (DescribeCoverage request) (await this.loadWcsMetadata()).throwIfError(); // Get WCS URL // This will throw an error if URL is undefined // It will raise an error if URL is defined, but an error has occurred const urlResult = this.getCoverageUrl(bbox); const url = urlResult.throwIfUndefined({ message: "Failed to generate WCS GetCoverage request URL", importance: 2 // Higher importance than error message in `getCoverageUrl()` }); urlResult.raiseError( this.terria, `Error occurred while generating WCS GetCoverage URL` ); runInAction(() => { // Add WCS "pending" message to shortReport pendingWorkbenchItem.setTrait( CommonStrata.user, "shortReport", i18next.t("models.wcs.asyncPendingDescription", { name: getName(this), timestamp: timestamp }) ); // Create info section from URL query parameters const info = createStratumInstance(InfoSectionTraits, { name: "Inputs", content: `<table class="cesium-infoBox-defaultTable">${Object.entries( new URI(url).query(true) ).reduce<string>( (previousValue, [key, value]) => `${previousValue}<tr><td style="vertical-align: middle">${key}</td><td>${value}</td></tr>`, "" )}</table>` }); pendingWorkbenchItem.setTrait(CommonStrata.user, "info", [info]); }); const blob = await loadBlob(proxyCatalogItemUrl(this, url)); runInAction(() => pendingWorkbenchItem.terria.workbench.remove(pendingWorkbenchItem) ); return { name: `${getName(this)} clip.tiff`, file: blob }; } catch (error) { if (error instanceof TerriaError) { throw error; } // Attempt to get error message out of XML response if ( error instanceof RequestErrorEvent && isDefined(error?.response?.type) && error.response.type?.indexOf("xml") !== -1 ) { try { const xml = new DOMParser().parseFromString( await error.response.text(), "text/xml" ); if ( xml.documentElement.localName === "ServiceExceptionReport" || xml.documentElement.localName === "ExceptionReport" ) { const message = xml.getElementsByTagName("ServiceException")?.[0]?.innerHTML ?? xml.getElementsByTagName("ows:ExceptionText")?.[0]?.innerHTML; if (isDefined(message)) { /* eslint-disable-next-line no-ex-assign */ error = message; } } } catch (xmlParseError) { console.log("Failed to parse WCS response"); console.log(xmlParseError); } } throw new TerriaError({ sender: this, title: i18next.t("models.wcs.exportFailedTitle"), message: i18next.t("models.wcs.exportFailedMessageII", { error }) }); } finally { runInAction(() => pendingWorkbenchItem.terria.workbench.remove(pendingWorkbenchItem) ); } } dispose() { super.dispose(); this._wcsCapabilitiesLoader.dispose(); this._wcsDescribeCoverageLoader.dispose(); } } return ExportWebCoverageServiceMixin; } namespace ExportWebCoverageServiceMixin { export interface Instance extends InstanceType< ReturnType<typeof ExportWebCoverageServiceMixin> > {} export function isMixedInto(model: any): model is Instance { return ( model && "loadWcsMetadata" in model && typeof model.loadWcsMetadata === "function" ); } StratumOrder.addLoadStratum( WebCoverageServiceCapabilitiesStratum.stratumName ); StratumOrder.addLoadStratum( WebCoverageServiceDescribeCoverageStratum.stratumName ); } export default ExportWebCoverageServiceMixin;