UNPKG

terriajs

Version:

Geospatial data visualization platform.

1,092 lines 72.5 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import i18next from "i18next"; import { action, computed, makeObservable, observable, runInAction, toJS, when } from "mobx"; import { createTransformer } from "mobx-utils"; import Clock from "terriajs-cesium/Source/Core/Clock"; import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError"; import CesiumEvent from "terriajs-cesium/Source/Core/Event"; import RequestScheduler from "terriajs-cesium/Source/Core/RequestScheduler"; import RuntimeError from "terriajs-cesium/Source/Core/RuntimeError"; import buildModuleUrl from "terriajs-cesium/Source/Core/buildModuleUrl"; import defined from "terriajs-cesium/Source/Core/defined"; import queryToObject from "terriajs-cesium/Source/Core/queryToObject"; import SplitDirection from "terriajs-cesium/Source/Scene/SplitDirection"; import URI from "urijs"; import { Category, DataSourceAction, LaunchAction } from "../Core/AnalyticEvents/analyticEvents"; import AsyncLoader from "../Core/AsyncLoader"; import ConsoleAnalytics from "../Core/ConsoleAnalytics"; import CorsProxy from "../Core/CorsProxy"; import GoogleAnalytics from "../Core/GoogleAnalytics"; import { isJsonBoolean, isJsonNumber, isJsonObject, isJsonString } from "../Core/Json"; import { isLatLonHeight } from "../Core/LatLonHeight"; import Result from "../Core/Result"; import ServerConfig from "../Core/ServerConfig"; import TerriaError, { TerriaErrorSeverity } from "../Core/TerriaError"; import ensureSuffix from "../Core/ensureSuffix"; import filterOutUndefined from "../Core/filterOutUndefined"; import getDereferencedIfExists from "../Core/getDereferencedIfExists"; import getPath from "../Core/getPath"; import hashEntity from "../Core/hashEntity"; import instanceOf from "../Core/instanceOf"; import isDefined from "../Core/isDefined"; import loadJson from "../Core/loadJson"; import loadJson5 from "../Core/loadJson5"; import { getUriWithoutPath } from "../Core/uriHelpers"; import { featureBelongsToCatalogItem, isProviderCoordsMap } from "../Map/PickedFeatures/PickedFeatures"; import CatalogMemberMixin, { getName } from "../ModelMixins/CatalogMemberMixin"; import GroupMixin from "../ModelMixins/GroupMixin"; import MappableMixin, { isDataSource } from "../ModelMixins/MappableMixin"; import ReferenceMixin from "../ModelMixins/ReferenceMixin"; import NotificationState from "../ReactViewModels/NotificationState"; import { defaultTerms } from "../ReactViewModels/defaultTerms"; import { SHARE_VERSION } from "../ReactViews/Map/Panels/SharePanel/BuildShareLink"; import { shareConvertNotification } from "../ReactViews/Notification/shareConvertNotification"; import MappableTraits from "../Traits/TraitsClasses/MappableTraits"; import MapNavigationModel from "../ViewModels/MapNavigation/MapNavigationModel"; import TerriaViewer from "../ViewModels/TerriaViewer"; import { BaseMapsModel } from "./BaseMaps/BaseMapsModel"; import CameraView from "./CameraView"; import Catalog from "./Catalog/Catalog"; import CatalogGroup from "./Catalog/CatalogGroup"; import CatalogMemberFactory from "./Catalog/CatalogMemberFactory"; import MagdaReference from "./Catalog/CatalogReferences/MagdaReference"; import SplitItemReference from "./Catalog/CatalogReferences/SplitItemReference"; import CommonStrata from "./Definition/CommonStrata"; import { BaseModel } from "./Definition/Model"; import hasTraits from "./Definition/hasTraits"; import updateModelFromJson from "./Definition/updateModelFromJson"; import upsertModelFromJson from "./Definition/upsertModelFromJson"; import StubErrorServiceProvider from "./ErrorServiceProviders/StubErrorServiceProvider"; import TerriaFeature from "./Feature/Feature"; import { isInitFromData, isInitFromDataPromise, isInitFromOptions, isInitFromUrl } from "./InitSource"; import Internationalization from "./Internationalization"; import NoViewer from "./NoViewer"; import CatalogIndex from "./SearchProviders/CatalogIndex"; import { SearchBarModel } from "./SearchProviders/SearchBarModel"; import TimelineStack from "./TimelineStack"; import { isViewerMode, setViewerMode } from "./ViewerMode"; import Workbench from "./Workbench"; export default class Terria { models = observable.map(); /** Map from share key -> id */ shareKeysMap = observable.map(); /** Map from id -> share keys */ modelIdShareKeysMap = observable.map(); /** Base URL for the Terria app. Used for SPA routes */ appBaseHref = ensureSuffix(typeof document !== "undefined" ? document.baseURI : "/", "/"); /** Base URL to Terria resources */ baseUrl = "build/TerriaJS/"; /** * Base URL used by Cesium to link to images and other static assets. * This can be customized by passing `options.cesiumBaseUrl` * Default value is constructed relative to `Terria.baseUrl`. */ cesiumBaseUrl; tileLoadProgressEvent = new CesiumEvent(); indeterminateTileLoadProgressEvent = new CesiumEvent(); workbench = new Workbench(); overlays = new Workbench(); catalog = new Catalog(this); baseMapsModel = new BaseMapsModel("basemaps", this); searchBarModel = new SearchBarModel(this); timelineClock = new Clock({ shouldAnimate: false }); // readonly overrides: any = overrides; // TODO: add options.functionOverrides like in master catalogIndex; elements = observable.map(); mainViewer = new TerriaViewer(this, computed(() => filterOutUndefined(this.overlays.items .map((item) => (MappableMixin.isMixedInto(item) ? item : undefined)) .concat(this.workbench.items.map((item) => MappableMixin.isMixedInto(item) ? item : undefined))))); appName = "TerriaJS App"; supportEmail = "info@terria.io"; /** * Gets or sets the {@link this.corsProxy} used to determine if a URL needs to be proxied and to proxy it if necessary. * @type {CorsProxy} */ corsProxy = new CorsProxy(); /** * Gets or sets the instance to which to report Google Analytics-style log events. * If a global `ga` function is defined, this defaults to `GoogleAnalytics`. Otherwise, it defaults * to `ConsoleAnalytics`. */ analytics; /** * Gets the stack of layers active on the timeline. */ timelineStack = new TimelineStack(this, this.timelineClock); configParameters = { appName: "TerriaJS App", supportEmail: "info@terria.io", defaultMaximumShownFeatureInfos: 100, catalogIndexUrl: undefined, regionMappingDefinitionsUrl: undefined, regionMappingDefinitionsUrls: ["build/TerriaJS/data/regionMapping.json"], proj4ServiceBaseUrl: "proj4def/", corsProxyBaseUrl: "proxy/", proxyableDomainsUrl: "proxyabledomains/", // deprecated, will be determined from serverconfig serverConfigUrl: "serverconfig/", shareUrl: "share", feedbackUrl: undefined, initFragmentPaths: ["init/"], storyEnabled: true, showStorySaveInstructions: false, interceptBrowserPrint: true, tabbedCatalog: false, useCesiumIonTerrain: true, cesiumTerrainUrl: undefined, cesiumTerrainAssetId: undefined, cesiumIonAccessToken: undefined, useCesiumIonBingImagery: undefined, cesiumIonOAuth2ApplicationID: undefined, cesiumIonLoginTokenPersistence: "page", cesiumIonAllowSharingAddedAssets: false, bingMapsKey: undefined, hideTerriaLogo: false, brandBarElements: undefined, brandBarSmallElements: undefined, displayOneBrand: 0, disableMyLocation: undefined, disableSplitter: undefined, disablePedestrianMode: false, keepCatalogOpen: false, experimentalFeatures: undefined, magdaReferenceHeaders: undefined, locationSearchBoundingBox: undefined, googleAnalyticsKey: undefined, errorService: undefined, globalDisclaimer: undefined, theme: {}, showWelcomeMessage: false, welcomeMessageVideo: { videoTitle: "Getting started with the map", videoUrl: "https://www.youtube-nocookie.com/embed/FjSxaviSLhc", placeholderImage: "https://img.youtube.com/vi/FjSxaviSLhc/maxresdefault.jpg" }, storyVideo: { videoUrl: "https://www.youtube-nocookie.com/embed/fbiQawV8IYY" }, showInAppGuides: false, helpContent: [], helpContentTerms: defaultTerms, languageConfiguration: undefined, customRequestSchedulerLimits: undefined, persistViewerMode: true, openAddData: false, feedbackPreamble: "translate#feedback.feedbackPreamble", feedbackPostamble: undefined, feedbackMinLength: 0, leafletMaxZoom: 18, leafletAttributionPrefix: undefined, extraCreditLinks: [ // Default credit links (shown at the bottom of the Cesium map) { text: "map.extraCreditLinks.dataAttribution", url: "https://terria.io/attributions" }, { text: "map.extraCreditLinks.termsOfUse", url: "https://terria.io/demo-terms" } ], printDisclaimer: undefined, storyRouteUrlPrefix: undefined, enableConsoleAnalytics: undefined, googleAnalyticsOptions: undefined, relatedMaps: [], aboutButtonHrefUrl: "about.html", plugins: undefined, searchBarConfig: undefined, searchProviders: [] }; pickedFeatures; selectedFeature; allowFeatureInfoRequests = true; /** * Gets or sets the stack of map interactions modes. The mode at the top of the stack * (highest index) handles click interactions with the map */ mapInteractionModeStack = []; isWorkflowPanelActive = false; /** Gets or sets the active SelectableDimensionWorkflow, if defined, then the workflow will be displayed using `WorkflowPanel` */ selectableDimensionWorkflow; /** * Flag for zooming to workbench items after all init sources have been loaded. * * This is automatically enabled when your init file has the following settings: * ``` * {"initialCamera": {"focusWorkbenchItems": true}} * ``` */ focusWorkbenchItemsAfterLoadingInitSources = false; _loadPersistedSettings = { baseMapPromise: undefined }; get baseMapContrastColor() { return (this.baseMapsModel.baseMapItems.find((basemap) => isDefined(basemap.item?.uniqueId) && basemap.item?.uniqueId === this.mainViewer.baseMap?.uniqueId)?.contrastColor ?? "#ffffff"); } userProperties = new Map(); initSources = []; _initSourceLoader = new AsyncLoader(this.forceLoadInitSources.bind(this)); serverConfig; // TODO shareDataService; /* Splitter controls */ showSplitter = false; splitPosition = 0.5; splitPositionVertical = 0.5; terrainSplitDirection = SplitDirection.NONE; depthTestAgainstTerrainEnabled = false; stories = []; storyPromptShown = 0; // Story Prompt modal will be rendered when this property changes. See StandardUserInterface, section regarding sui.notifications. Ideally move this to ViewState. /** * Gets or sets the ID of the catalog member that is currently being * previewed. This is observed in ViewState. It is used to open "Add data" if a catalog member is open in a share link. * This should stay private - use viewState.viewCatalogMember() instead */ _previewedItemId; get previewedItemId() { return this._previewedItemId; } /** * Base ratio for maximumScreenSpaceError * @type {number} */ baseMaximumScreenSpaceError = 2; /** * Model to use for map navigation */ mapNavigationModel = new MapNavigationModel(this); /** * Gets or sets whether to use the device's native resolution (sets cesium.viewer.resolutionScale to a ratio of devicePixelRatio) * @type {boolean} */ useNativeResolution = false; /** * Whether we think all references in the catalog have been loaded * @type {boolean} */ catalogReferencesLoaded = false; augmentedVirtuality; notificationState = new NotificationState(); developmentEnv = process.env.NODE_ENV === "development"; /** * An error service instance. The instance can be provided via the * `errorService` startOption. Here we initialize it to stub provider so * that the `terria.errorService` always exists. */ errorService = new StubErrorServiceProvider(); /** * @experimental */ catalogProvider; constructor(options = {}) { makeObservable(this); if (options.appBaseHref) { this.appBaseHref = ensureSuffix(typeof document !== "undefined" ? new URI(options.appBaseHref).absoluteTo(document.baseURI).toString() : options.appBaseHref, "/"); } if (options.baseUrl) { this.baseUrl = ensureSuffix(options.baseUrl, "/"); } // Try to construct an absolute URL to send to Cesium, as otherwise it resolves relative // to document.location instead of the correct document.baseURI // This URL can still be relative if Terria is running in an environment without `document` // (e.g. Node.js) and no absolute URL is passed as an option for `appBaseHref`. In this case, // send a relative URL to cesium const cesiumBaseUrlRelative = options.cesiumBaseUrl ?? `${this.baseUrl}build/Cesium/build/`; this.cesiumBaseUrl = ensureSuffix(new URI(cesiumBaseUrlRelative).absoluteTo(this.appBaseHref).toString(), "/"); // Casting to `any` as `setBaseUrl` method is not part of the Cesiums' type definitions buildModuleUrl.setBaseUrl(this.cesiumBaseUrl); this.analytics = options.analytics; if (!defined(this.analytics)) { if (typeof window !== "undefined" && defined(window.ga)) { this.analytics = new GoogleAnalytics(); } else { this.analytics = new ConsoleAnalytics(); } } } /** Raise error to user. * * This accepts same arguments as `TerriaError.from` - but also has: * * @param forceRaiseToUser - which can be used to force raise the error */ raiseErrorToUser(error, overrides, forceRaiseToUser = false) { const terriaError = TerriaError.from(error, overrides); // Set shouldRaiseToUser true if forceRaiseToUser agrument is true if (forceRaiseToUser) terriaError.overrideRaiseToUser = true; // Log error to error service this.errorService.error(terriaError); // Only show error to user if `ignoreError` flag hasn't been set to "1" // Note: this will take precedence over forceRaiseToUser/overrideRaiseToUser if (this.userProperties.get("ignoreErrors") !== "1") this.notificationState.addNotificationToQueue(terriaError.toNotification()); terriaError.log(); } get currentViewer() { return this.mainViewer.currentViewer; } get cesium() { if (isDefined(this.mainViewer) && this.mainViewer.currentViewer.type === "Cesium") { return this.mainViewer.currentViewer; } } /** * @returns The currently active `TerrainProvider` or `undefined`. */ get terrainProvider() { return this.cesium?.terrainProvider; } get leaflet() { if (isDefined(this.mainViewer) && this.mainViewer.currentViewer.type === "Leaflet") { return this.mainViewer.currentViewer; } } get modelValues() { return Array.from(this.models.values()); } get modelIds() { return Array.from(this.models.keys()); } getModelById(type, id) { const model = this.models.get(id); if (instanceOf(type, model)) { return model; } // Model does not have the requested type. return undefined; } addModel(model, shareKeys) { if (model.uniqueId === undefined) { throw new DeveloperError("A model without a `uniqueId` cannot be added."); } if (this.models.has(model.uniqueId)) { throw new RuntimeError(`A model with the specified ID already exists: \`${model.uniqueId}\``); } this.models.set(model.uniqueId, model); shareKeys?.forEach((shareKey) => this.addShareKey(model.uniqueId, shareKey)); } /** * Remove references to a model from Terria. */ removeModelReferences(model) { this.removeSelectedFeaturesForModel(model); this.workbench.remove(model); if (model.uniqueId) { this.models.delete(model.uniqueId); } } removeSelectedFeaturesForModel(model) { const pickedFeatures = this.pickedFeatures; if (pickedFeatures) { // Remove picked features that belong to the catalog item pickedFeatures.features.forEach((feature, i) => { if (featureBelongsToCatalogItem(feature, model)) { pickedFeatures?.features.splice(i, 1); if (this.selectedFeature === feature) this.selectedFeature = undefined; } }); } } getModelIdByShareKey(shareKey) { return this.shareKeysMap.get(shareKey); } getModelByIdOrShareKey(type, id) { const model = this.getModelById(type, id); if (model) { return model; } else { const idFromShareKey = this.getModelIdByShareKey(id); return idFromShareKey !== undefined ? this.getModelById(type, idFromShareKey) : undefined; } } async getModelByIdShareKeyOrCatalogIndex(id) { try { // See if model exists by ID of sharekey const model = this.getModelByIdOrShareKey(BaseModel, id); // If no model exists, try to find it through Terria model sharekeys or CatalogIndex sharekeys if (model?.uniqueId !== undefined) { return new Result(model); } else if (this.catalogIndex) { try { await this.catalogIndex.load(); } catch (e) { throw TerriaError.from(e, `Failed to load CatalogIndex while trying to load model \`${id}\``); } const indexModel = this.catalogIndex.getModelByIdOrShareKey(id); if (indexModel) { (await indexModel.loadReference()).throwIfError(); return new Result(indexModel.target); } } } catch (e) { return Result.error(e); } return new Result(undefined); } addShareKey(id, shareKey) { if (id === shareKey || this.shareKeysMap.has(shareKey)) return; this.shareKeysMap.set(shareKey, id); // eslint-disable-next-line @typescript-eslint/no-unused-expressions this.modelIdShareKeysMap.get(id)?.push(shareKey) ?? this.modelIdShareKeysMap.set(id, [shareKey]); } setupInitializationUrls(baseUri, config) { const initializationUrls = config?.initializationUrls || []; const initSources = initializationUrls.map((url) => ({ name: `Init URL from config ${url}`, errorSeverity: TerriaErrorSeverity.Error, ...generateInitializationUrl(baseUri, this.configParameters.initFragmentPaths, url) })); // look for v7 catalogs -> push v7-v8 conversion to initSources if (Array.isArray(config?.v7initializationUrls)) { initSources.push(...config.v7initializationUrls .filter(isJsonString) .map((v7initUrl) => ({ name: `V7 Init URL from config ${v7initUrl}`, errorSeverity: TerriaErrorSeverity.Error, data: (async () => { try { const [{ convertCatalog }, catalog] = await Promise.all([ import("catalog-converter"), loadJson5(v7initUrl) ]); const convert = convertCatalog(catalog, { generateIds: false }); console.log(`WARNING: ${v7initUrl} is a v7 catalog - it has been upgraded to v8\nMessages:\n`); convert.messages.forEach((message) => console.log(`- ${message.path.join(".")}: ${message.message}`)); return new Result({ data: convert.result || {} }); } catch (error) { return Result.error(error, { title: { key: "models.catalog.convertErrorTitle" }, message: { key: "models.catalog.convertErrorMessage", parameters: { url: v7initUrl } } }); } })() }))); } this.initSources.push(...initSources); } async start(options) { // Some hashProperties need to be set before anything else happens const hashProperties = queryToObject(new URI(window.location).fragment()); if (isDefined(hashProperties["ignoreErrors"])) { this.userProperties.set("ignoreErrors", hashProperties["ignoreErrors"]); } this.shareDataService = options.shareDataService; // If in development environment, allow usage of #configUrl to set Terria config URL if (this.developmentEnv) { if (isDefined(hashProperties["configUrl"]) && hashProperties["configUrl"] !== "") options.configUrl = hashProperties["configUrl"]; } const baseUri = new URI(options.configUrl).filename(""); const launchUrlForAnalytics = options.applicationUrl?.href || getUriWithoutPath(baseUri); try { const config = await loadJson5(options.configUrl, options.configUrlHeaders); // If it's a magda config, we only load magda config and parameters should never be a property on the direct // config aspect (it would be under the `terria-config` aspect) if (isJsonObject(config) && config.aspects) { await this.loadMagdaConfig(options.configUrl, config, baseUri); } runInAction(() => { if (isJsonObject(config) && isJsonObject(config.parameters)) { this.updateParameters(config.parameters); } this.setupInitializationUrls(baseUri, config); }); } catch (error) { this.raiseErrorToUser(error, { sender: this, title: { key: "models.terria.loadConfigErrorTitle" }, message: `Couldn't load ${options.configUrl}`, severity: TerriaErrorSeverity.Error }); } finally { if (!options.i18nOptions?.skipInit) { await Internationalization.initLanguage(this.configParameters.languageConfiguration, options.i18nOptions, this.baseUrl); } } setCustomRequestSchedulerDomainLimits(this.configParameters.customRequestSchedulerLimits); if (options.errorService) { try { this.errorService = options.errorService; this.errorService.init(this.configParameters); } catch (e) { console.error(`Failed to initialize error service: ${this.configParameters.errorService?.provider}`, e); } } this.analytics?.start(this.configParameters); this.analytics?.logEvent(Category.launch, LaunchAction.url, launchUrlForAnalytics); this.serverConfig = new ServerConfig(); const serverConfig = await this.serverConfig.init(this.configParameters.serverConfigUrl); await this.initCorsProxy(this.configParameters, serverConfig); if (this.shareDataService && this.serverConfig.config) { this.shareDataService.init(this.serverConfig.config); } // Create catalog index if catalogIndexUrl is set // Note: this isn't loaded now, it is loaded in first CatalogSearchProvider.doSearch() if (this.configParameters.catalogIndexUrl && !this.catalogIndex) { this.catalogIndex = new CatalogIndex(this, this.configParameters.catalogIndexUrl); } this.baseMapsModel .initializeDefaultBaseMaps() .catchError((error) => this.raiseErrorToUser(TerriaError.from(error, "Failed to load default basemaps"))); this.searchBarModel .updateModelConfig(this.configParameters.searchBarConfig) .initializeSearchProviders(this.configParameters.searchProviders) .catchError((error) => this.raiseErrorToUser(TerriaError.from(error, "Failed to initialize searchProviders"))); if (typeof options.beforeRestoreAppState === "function") { try { await options.beforeRestoreAppState(); } catch (error) { console.log(error); } } await this.restoreAppState(options); } async restoreAppState(options) { if (options.applicationUrl) { (await this.updateApplicationUrl(options.applicationUrl.href)).raiseError(this); } this.loadPersistedMapSettings(); } /** * Zoom to workbench items if `focusWorkbenchItemsAfterLoadingInitSources` is `true`. * * Note that the current behaviour is to zoom to the first item of the * workbench, however in the future we should modify it to zoom to a view * which shows all the workbench items. * * If a Cesium or Leaflet viewer is not available, * we wait for it to load before triggering the zoom. */ async doZoomToWorkbenchItems() { if (!this.focusWorkbenchItemsAfterLoadingInitSources) { return; } // TODO: modify this to zoom to a view that shows all workbench items // instead of just zooming to the first workbench item! const firstMappableItem = this.workbench.items.find((item) => MappableMixin.isMixedInto(item)); if (firstMappableItem) { // When the app loads, Cesium/Leaflet viewers are loaded // asynchronously. Until they become available, a stub viewer called // `NoViewer` is used. `NoViewer` does not implement zooming to mappable // items. So here wait for a valid viewer to become available before // attempting to zoom to the mappable item. const isViewerAvailable = () => this.currentViewer.type !== NoViewer.type; // Note: In some situations the following use of when() can result in // a hanging promise if a valid viewer never becomes available, // for eg: when react is not rendered - `currentViewer` will always be `NoViewer`. await when(isViewerAvailable); await this.currentViewer.zoomTo(firstMappableItem, 0.0); } } loadPersistedMapSettings() { const persistViewerMode = this.configParameters.persistViewerMode; const hashViewerMode = this.userProperties.get("map"); if (hashViewerMode && isViewerMode(hashViewerMode)) { setViewerMode(hashViewerMode, this.mainViewer); } else if (persistViewerMode) { const viewerMode = this.getLocalProperty("viewermode"); if (isDefined(viewerMode) && isViewerMode(viewerMode)) { setViewerMode(viewerMode, this.mainViewer); } } const useNativeResolution = this.getLocalProperty("useNativeResolution"); if (typeof useNativeResolution === "boolean") { this.setUseNativeResolution(useNativeResolution); } const baseMaximumScreenSpaceError = parseFloat(this.getLocalProperty("baseMaximumScreenSpaceError")?.toString() || ""); if (!isNaN(baseMaximumScreenSpaceError)) { this.setBaseMaximumScreenSpaceError(baseMaximumScreenSpaceError); } } setUseNativeResolution(useNativeResolution) { this.useNativeResolution = useNativeResolution; } setBaseMaximumScreenSpaceError(baseMaximumScreenSpaceError) { this.baseMaximumScreenSpaceError = baseMaximumScreenSpaceError; } async loadPersistedOrInitBaseMap() { const baseMapItems = this.baseMapsModel.baseMapItems; // Set baseMap fallback to first option let baseMap = baseMapItems[0]; const persistedBaseMapId = this.getLocalProperty("basemap"); const baseMapSearch = baseMapItems.find((baseMapItem) => baseMapItem.item?.uniqueId === persistedBaseMapId); if (baseMapSearch?.item && MappableMixin.isMixedInto(baseMapSearch.item)) { baseMap = baseMapSearch; } else { // Try to find basemap using defaultBaseMapId and defaultBaseMapName const baseMapSearch = baseMapItems.find((baseMapItem) => baseMapItem.item?.uniqueId === this.baseMapsModel.defaultBaseMapId) ?? baseMapItems.find((baseMapItem) => CatalogMemberMixin.isMixedInto(baseMapItem) && baseMapItem.item.name === this.baseMapsModel.defaultBaseMapName); if (baseMapSearch?.item && MappableMixin.isMixedInto(baseMapSearch.item)) { baseMap = baseMapSearch; } } if (baseMap?.item) await this.mainViewer.setBaseMap(baseMap.item); } get isLoadingInitSources() { return this._initSourceLoader.isLoading; } /** * Asynchronously loads init sources */ loadInitSources() { return this._initSourceLoader.load(); } dispose() { this._initSourceLoader.dispose(); } async updateFromStartData(startData, /** Name for startData initSources - this is only used for debugging purposes */ name = "Application start data", /** Error severity to use for loading startData init sources - default will be `TerriaErrorSeverity.Error` */ errorSeverity) { try { await interpretStartData(this, startData, name, errorSeverity); } catch (e) { return Result.error(e); } return await this.loadInitSources(); } async updateApplicationUrl(newUrl) { const uri = new URI(newUrl); const hash = uri.fragment(); const hashProperties = queryToObject(hash); function checkSegments(urlSegments, customRoute) { // Accept /${customRoute}/:some-id/ or /${customRoute}/:some-id return (((urlSegments.length === 3 && urlSegments[2] === "") || urlSegments.length === 2) && urlSegments[0] === customRoute && urlSegments[1].length > 0); } try { await interpretHash(this, hashProperties, this.userProperties, new URI(newUrl).filename("").query("").hash("")); // /catalog/ and /story/ routes if (newUrl.startsWith(this.appBaseHref)) { const pageUrl = new URL(newUrl); // Find relative path from baseURI to documentURI excluding query and hash // then split into url segments // e.g. "http://ci.terria.io/main/story/1#map=2d" -> ["story", "1"] const segments = (pageUrl.origin + pageUrl.pathname) .slice(this.appBaseHref.length) .split("/"); if (checkSegments(segments, "catalog")) { this.initSources.push({ name: `Go to ${pageUrl.pathname}`, errorSeverity: TerriaErrorSeverity.Error, data: { previewedItemId: decodeURIComponent(segments[1]) } }); const replaceUrl = new URL(newUrl); replaceUrl.pathname = new URL(this.appBaseHref).pathname; history.replaceState({}, "", replaceUrl.href); } else if (checkSegments(segments, "story") && isDefined(this.configParameters.storyRouteUrlPrefix)) { let storyJson; try { storyJson = await loadJson(`${this.configParameters.storyRouteUrlPrefix}${segments[1]}`); } catch (e) { throw TerriaError.from(e, { message: `Failed to fetch story \`"${this.appName}/${segments[1]}"\`` }); } await interpretStartData(this, storyJson, `Start data from story \`"${this.appName}/${segments[1]}"\``); runInAction(() => this.userProperties.set("playStory", "1")); } } } catch (e) { this.raiseErrorToUser(e); } return await this.loadInitSources(); } updateParameters(parameters) { Object.entries(parameters).forEach(([key, value]) => { if (Object.hasOwnProperty.call(this.configParameters, key)) { this.configParameters[key] = value; } }); this.appName = this.configParameters.appName ?? this.appName; this.supportEmail = this.configParameters.supportEmail ?? this.supportEmail; } async forceLoadInitSources() { const loadInitSource = createTransformer(async (initSource) => { let initSourceData; if (isInitFromUrl(initSource)) { try { const json = await loadJson5(initSource.initUrl); if (isJsonObject(json, false)) { initSourceData = json; } } catch (e) { throw TerriaError.from(e, { message: { key: "models.terria.loadingInitJsonMessage", parameters: { url: initSource.initUrl } } }); } } else if (isInitFromOptions(initSource)) { let error; for (const option of initSource.options) { try { initSourceData = await loadInitSource(option); if (initSourceData !== undefined) break; } catch (err) { error = err; } } if (initSourceData === undefined && error !== undefined) throw error; } else if (isInitFromData(initSource)) { initSourceData = initSource.data; } else if (isInitFromDataPromise(initSource)) { initSourceData = (await initSource.data).throwIfError()?.data; } return initSourceData; }); const errors = []; // Load all init sources // Converts them to InitSourceFromData const loadedInitSources = await Promise.all(this.initSources.map(async (initSource) => { try { return { name: initSource.name, errorSeverity: initSource.errorSeverity, data: await loadInitSource(initSource) }; } catch (e) { errors.push(TerriaError.from(e, { severity: initSource.errorSeverity, message: { key: "models.terria.loadingInitSourceError2Message", parameters: { loadSource: initSource.name ?? "Unknown source" } } })); } })); let baseMapPromise; // Sequentially apply all InitSources for (let i = 0; i < loadedInitSources.length; i++) { const initSource = loadedInitSources[i]; if (!isDefined(initSource?.data)) continue; try { const result = await this._applyInitData({ initData: initSource.data }); if (result.baseMapPromise) { baseMapPromise = result.baseMapPromise; } } catch (e) { errors.push(TerriaError.from(e, { severity: initSource?.errorSeverity, message: { key: "models.terria.loadingInitSourceError2Message", parameters: { loadSource: initSource.name ?? "Unknown source" } } })); } } // Wait for any basemap loaded from applyInitData to finish // loading before we restore from user preference. Promise.resolve(baseMapPromise).finally(() => { runInAction(() => { if (!this.mainViewer.baseMap) { // Note: there is no "await" here - as basemaps can take a while // to load and there is no need to wait for them to load before // rendering Terria this.loadPersistedOrInitBaseMap(); } }); }); // Zoom to workbench items if any of the init sources specifically requested it if (this.focusWorkbenchItemsAfterLoadingInitSources) { this.doZoomToWorkbenchItems(); } if (errors.length > 0) { // Note - this will get wrapped up in a Result object because it is called in AsyncLoader throw TerriaError.combine(errors, { title: { key: "models.terria.loadingInitSourcesErrorTitle" }, message: { key: "models.terria.loadingInitSourcesErrorMessage", parameters: { appName: this.appName, email: this.supportEmail } } }); } } async loadModelStratum(modelId, stratumId, allModelStratumData, replaceStratum) { const thisModelStratumData = allModelStratumData[modelId] || {}; if (!isJsonObject(thisModelStratumData)) { throw new TerriaError({ sender: this, title: "Invalid model traits", message: "The traits of a model must be a JSON object." }); } const cleanStratumData = { ...thisModelStratumData }; delete cleanStratumData.dereferenced; delete cleanStratumData.knownContainerUniqueIds; const errors = []; const containerIds = thisModelStratumData.knownContainerUniqueIds; if (Array.isArray(containerIds)) { // Groups that contain this item must be loaded before this item. await Promise.all(containerIds.map(async (containerId) => { if (typeof containerId !== "string") { return; } const container = (await this.loadModelStratum(containerId, stratumId, allModelStratumData, replaceStratum)).pushErrorTo(errors, `Failed to load container ${containerId}`); if (container) { const dereferenced = ReferenceMixin.isMixedInto(container) ? container.target : container; if (GroupMixin.isMixedInto(dereferenced)) { (await dereferenced.loadMembers()).pushErrorTo(errors, `Failed to load group ${dereferenced.uniqueId}`); } } })); } const model = (await this.getModelByIdShareKeyOrCatalogIndex(modelId)).pushErrorTo(errors); if (model?.uniqueId !== undefined) { // Update modelId from model sharekeys or CatalogIndex sharekeys modelId = model.uniqueId; } // If this model is a `SplitItemReference` we must load the source item first const splitSourceId = cleanStratumData.splitSourceItemId; if (cleanStratumData.type === SplitItemReference.type && typeof splitSourceId === "string") { (await this.loadModelStratum(splitSourceId, stratumId, allModelStratumData, replaceStratum)).pushErrorTo(errors, `Failed to load SplitItemReference ${splitSourceId}`); } const loadedModel = upsertModelFromJson(CatalogMemberFactory, this, "/", stratumId, { ...cleanStratumData, id: modelId }, { replaceStratum }).pushErrorTo(errors); if (loadedModel && Array.isArray(containerIds)) { containerIds.forEach((containerId) => { if (typeof containerId === "string" && loadedModel.knownContainerUniqueIds.indexOf(containerId) < 0) { loadedModel.knownContainerUniqueIds.push(containerId); } }); } // If we're replacing the stratum and the existing model is already // dereferenced, we need to replace the dereferenced stratum, too, // even if there's no trace of it in the load data. let dereferenced = isJsonObject(thisModelStratumData.dereferenced) ? thisModelStratumData.dereferenced : undefined; if (loadedModel && replaceStratum && dereferenced === undefined && ReferenceMixin.isMixedInto(loadedModel) && loadedModel.target !== undefined) { dereferenced = {}; } if (loadedModel && ReferenceMixin.isMixedInto(loadedModel)) { (await loadedModel.loadReference()).pushErrorTo(errors, `Failed to load reference ${loadedModel.uniqueId}`); if (isDefined(loadedModel.target)) { updateModelFromJson(loadedModel.target, stratumId, dereferenced || {}, replaceStratum).pushErrorTo(errors, `Failed to update model from JSON: ${loadedModel.target.uniqueId}`); } } else if (dereferenced) { throw new TerriaError({ sender: this, title: "Model cannot be dereferenced", message: `Model ${getName(loadedModel)} has a \`dereferenced\` property, but the model cannot be dereferenced.` }); } if (loadedModel) { const dereferencedGroup = getDereferencedIfExists(loadedModel); if (GroupMixin.isMixedInto(dereferencedGroup)) { if (dereferencedGroup.isOpen) { (await dereferencedGroup.loadMembers()).pushErrorTo(errors, `Failed to open group ${dereferencedGroup.uniqueId}`); } } } return new Result(loadedModel, TerriaError.combine(errors, { // This will set TerriaErrorSeverity to Error if the model which FAILED to load is in the workbench. severity: () => this.workbench.items.find((workbenchItem) => workbenchItem.uniqueId === modelId) ? TerriaErrorSeverity.Error : TerriaErrorSeverity.Warning, message: { key: "models.terria.loadModelErrorMessage", parameters: { model: modelId } } })); } async pushAndLoadMapItems(model, newItems, errors) { if (ReferenceMixin.isMixedInto(model)) { (await model.loadReference()).pushErrorTo(errors); if (model.target !== undefined) { await this.pushAndLoadMapItems(model.target, newItems, errors); } else { errors.push(TerriaError.from("Reference model has no target. Model Id: " + model.uniqueId)); } } else if (GroupMixin.isMixedInto(model)) { (await model.loadMembers()).pushErrorTo(errors); model.memberModels.map(async (m) => { await this.pushAndLoadMapItems(m, newItems, errors); }); } else if (MappableMixin.isMixedInto(model)) { newItems.push(model); (await model.loadMapItems()).pushErrorTo(errors); } else { errors.push(TerriaError.from("Can not load an un-mappable item to the map. Item Id: " + model.uniqueId)); } } async applyInitData(params) { await this._applyInitData(params); } /** * @private */ async _applyInitData({ initData, replaceStratum = false, canUnsetFeaturePickingState = false }) { const errors = []; initData = toJS(initData); let baseMapPromise; const stratumId = typeof initData.stratum === "string" ? initData.stratum : CommonStrata.definition; // Extract the list of CORS-ready domains. if (Array.isArray(initData.corsDomains)) { this.corsProxy.corsDomains.push(...initData.corsDomains); } // Add catalog members if (initData.catalog !== undefined) { this.catalog.group .addMembersFromJson(stratumId, initData.catalog) .pushErrorTo(errors); } // Show/hide elements in mapNavigationModel if (isJsonObject(initData.elements)) { this.elements.merge(initData.elements); // we don't want to go through all elements unless they are added. if (this.mapNavigationModel.items.length > 0) { this.elements.forEach((element, key) => { if (isDefined(element.visible)) { if (element.visible) { this.mapNavigationModel.show(key); } else { this.mapNavigationModel.hide(key); } } }); } } // Add stories if (Array.isArray(initData.stories)) { this.stories = initData.stories; this.storyPromptShown++; } // Add map settings if (isJsonString(initData.viewerMode)) { const viewerMode = initData.viewerMode.toLowerCase(); if (isViewerMode(viewerMode)) { setViewerMode(viewerMode, this.mainViewer); } } if (isJsonObject(initData.baseMaps)) { this.baseMapsModel .loadFromJson(CommonStrata.definition, initData.baseMaps) .pushErrorTo(errors, "Failed to load basemaps"); } if (isJsonObject(initData.homeCamera)) { this.loadHomeCamera(initData.homeCamera); } if (isJsonObject(initData.initialCamera)) { // When initialCamera is set: // - try to construct a CameraView and zoom to it // - otherwise, if `initialCamera.focusWorkbenchItems` is `true` flag it // so that we can zoom after the workbench items are loaded. // - If there are multiple initSources, the setting from the last source takes effect try { const initialCamera = CameraView.fromJson(initData.initialCamera); this.currentViewer.zoomTo(initialCamera, 2.0); // reset in case this was enabled by a previous initSource this.focusWorkbenchItemsAfterLoadingInitSources = false; } catch (error) { // Not a CameraView but does it specify focusWorkbenchItems? if (typeof initData.initialCamera.focusWorkbenchItems === "boolean") { this.focusWorkbenchItemsAfterLoadingInitSources = initData.initialCamera.focusWorkbenchItems; } else { throw error; } } } if (isJsonBoolean(initData.showSplitter)) { this.showSplitter = initData.showSplitter; } if (isJsonNumber(initData.splitPosition)) { this.splitPosition = initData.splitPosition; } if (isJsonObject(initData.settings)) { if (isJsonNumber(initData.settings.baseMaximumScreenSpaceError)) { this.setBaseMaximumScreenSpaceError(initData.settings.baseMaximumScreenSpaceError); } if (isJsonBoolean(initData.settings.useNativeResolution)) { this.setUseNativeResolution(initData.settings.useNativeResolution); } if (isJsonBoolean(initData.settings.alwaysShowTimeline)) { this.timelineStack.setAlwaysShowTimeline(initData.settings.alwaysShowTimeline); } if (isJsonString(initData.settings.baseMapId))