UNPKG

terriajs

Version:

Geospatial data visualization platform.

1,440 lines (1,306 loc) 80.7 kB
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 TerrainProvider from "terriajs-cesium/Source/Core/TerrainProvider"; 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 Entity from "terriajs-cesium/Source/DataSources/Entity"; 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 Class from "../Core/Class"; import ConsoleAnalytics from "../Core/ConsoleAnalytics"; import CorsProxy from "../Core/CorsProxy"; import GoogleAnalytics from "../Core/GoogleAnalytics"; import { JsonArray, JsonObject, isJsonBoolean, isJsonNumber, isJsonObject, isJsonString } from "../Core/Json"; import { isLatLonHeight } from "../Core/LatLonHeight"; import Result from "../Core/Result"; import ServerConfig from "../Core/ServerConfig"; import TerriaError, { TerriaErrorOverrides, TerriaErrorSeverity } from "../Core/TerriaError"; import { Complete } from "../Core/TypeModifiers"; 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 PickedFeatures, { 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 TimeVarying from "../ModelMixins/TimeVarying"; import NotificationState from "../ReactViewModels/NotificationState"; import { HelpContentItem } from "../ReactViewModels/defaultHelpContent"; import { Term, defaultTerms } from "../ReactViewModels/defaultTerms"; import { ICredit } from "../ReactViews/Map/BottomBar/Credits"; import { SHARE_VERSION } from "../ReactViews/Map/Panels/SharePanel/BuildShareLink"; import { shareConvertNotification } from "../ReactViews/Notification/shareConvertNotification"; import { SearchBarTraits } from "../Traits/SearchProviders/SearchBarTraits"; import SearchProviderTraits from "../Traits/SearchProviders/SearchProviderTraits"; 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 CatalogProvider from "./Catalog/CatalogProvider"; import MagdaReference, { MagdaReferenceHeaders } from "./Catalog/CatalogReferences/MagdaReference"; import SplitItemReference from "./Catalog/CatalogReferences/SplitItemReference"; import CommonStrata from "./Definition/CommonStrata"; import { BaseModel } from "./Definition/Model"; import ModelPropertiesFromTraits from "./Definition/ModelPropertiesFromTraits"; import hasTraits from "./Definition/hasTraits"; import updateModelFromJson from "./Definition/updateModelFromJson"; import upsertModelFromJson from "./Definition/upsertModelFromJson"; import { ErrorServiceOptions, ErrorServiceProvider } from "./ErrorServiceProviders/ErrorService"; import StubErrorServiceProvider from "./ErrorServiceProviders/StubErrorServiceProvider"; import TerriaFeature from "./Feature/Feature"; import GlobeOrMap from "./GlobeOrMap"; import IElementConfig from "./IElementConfig"; import InitSource, { InitSourceData, InitSourceFromData, ShareInitSourceData, StoryData, isInitFromData, isInitFromDataPromise, isInitFromOptions, isInitFromUrl } from "./InitSource"; import Internationalization, { I18nStartOptions, LanguageConfiguration } from "./Internationalization"; import MapInteractionMode from "./MapInteractionMode"; import NoViewer from "./NoViewer"; import { RelatedMap } from "./RelatedMaps"; import CatalogIndex from "./SearchProviders/CatalogIndex"; import { SearchBarModel } from "./SearchProviders/SearchBarModel"; import ShareDataService from "./ShareDataService"; import { StoryVideoSettings } from "./StoryVideoSettings"; import TimelineStack from "./TimelineStack"; import { isViewerMode, setViewerMode } from "./ViewerMode"; import Workbench from "./Workbench"; import SelectableDimensionWorkflow from "./Workflows/SelectableDimensionWorkflow"; export interface ConfigParameters { /** * TerriaJS uses this name whenever it needs to display the name of the application. */ appName?: string; /** * The email address shown when things go wrong. */ supportEmail?: string; /** * The maximum number of "feature info" boxes that can be displayed when clicking a point. */ defaultMaximumShownFeatureInfos: number; /** * URL of the JSON file that contains index of catalog. */ catalogIndexUrl?: string; /** * **Deprecated** - please use regionMappingDefinitionsUrls array instead. If this is defined, it will override `regionMappingDefinitionsUrls` */ regionMappingDefinitionsUrl?: string | undefined; /** * URLs of the JSON file that defines region mapping for CSV files. First matching region will be used (in array order) */ regionMappingDefinitionsUrls: string[]; /** * URL of Proj4 projection lookup service (part of TerriaJS-Server). */ proj4ServiceBaseUrl?: string; /** * URL of CORS proxy service (part of TerriaJS-Server) */ corsProxyBaseUrl?: string; /** * @deprecated */ proxyableDomainsUrl?: string; serverConfigUrl?: string; shareUrl?: string; /** * URL of the service used to send feedback. If not specified, the "Give Feedback" button will not appear. */ feedbackUrl?: string; /** * An array of base paths to use to try to use to resolve init fragments in the URL. For example, if this property is `[ "init/", "http://example.com/init/"]`, then a URL with `#test` will first try to load `init/test.json` and, if that fails, next try to load `http://example.com/init/test.json`. */ initFragmentPaths: string[]; /** * Whether the story is enabled. If false story function button won't be available. */ storyEnabled: boolean; /** * Whether to show the saving instructions message in the story builder panel. Defaults to false. */ showStorySaveInstructions?: boolean; /** * True (the default) to intercept the browser's print feature and use a custom one accessible through the Share panel. */ interceptBrowserPrint?: boolean; /** * True to create a separate explorer panel tab for each top-level catalog group to list its items in. */ tabbedCatalog?: boolean; /** * True to use Cesium World Terrain from Cesium ion. False to use terrain from the URL specified with the `"cesiumTerrainUrl"` property. If this property is false and `"cesiumTerrainUrl"` is not specified, the 3D view will use a smooth ellipsoid instead of a terrain surface. Defaults to true. */ useCesiumIonTerrain?: boolean; /** * The URL to use for Cesium terrain in the 3D Terrain viewer, in quantized mesh format. This property is ignored if "useCesiumIonTerrain" is set to true. */ cesiumTerrainUrl?: string; /** * The Cesium Ion Asset ID to use for Cesium terrain in the 3D Terrain viewer. `cesiumIonAccessToken` will be used to authenticate. This property is ignored if "useCesiumIonTerrain" is set to true. */ cesiumTerrainAssetId?: number; /** * The access token to use with Cesium ion. If `"useCesiumIonTerrain"` is true and this property is not specified, the Cesium default Ion key will be used. It is a violation of the Ion terms of use to use the default key in a deployed application. */ cesiumIonAccessToken?: string; /** * True to use Bing Maps from Cesium ion (Cesium World Imagery). By default, Ion will be used, unless the `bingMapsKey` property is specified, in which case that will be used instead. To disable the Bing Maps layers entirely, set this property to false and set `bingMapsKey` to null. */ useCesiumIonBingImagery?: boolean; /** * The OAuth2 application ID to use to allow login to Cesium ion on the "Add Data" panel. The referenced application must be configured on * Cesium ion with a Redirect URI of `[TerriaMap Base URL]/build/TerriaJS/cesium-ion-oauth2.html`. For example, if users access your TerriaJS * application at `https://example.com/AwesomeMap` then the Redirect URI must be exactly * `https://example.com/AwesomeMap/build/TerriaJS/cesium-ion-oauth2.html`. */ cesiumIonOAuth2ApplicationID?: number; /** * Specifies where to store the Cesium ion login token. Valid values are: * - `page` (default) - The login token is associated with the current page load. Even simply reloading the current page will clear the token. This is the safest option. * - `sessionStorage` - The login token is associated with a browser session, which means it is shared/accessible from any page hosted on the same domain and running in the same browser tab. * - `localStorage` - The login token is shared/accessible from any page hosted on the same domain, even when running in different tabs or after exiting and restarted the web browser. */ cesiumIonLoginTokenPersistence?: string; /** * Whether or not Cesium ion assets added via the "Add Data" panel will be shared with others via share links. If true, users will be asked to select a Cesium ion token when adding assets, * and this choice must be made carefully to avoid exposing more Cesium ion assets than intended. If false (the default), the user's login token will be used, which is safe because this * token will not be shared with others. */ cesiumIonAllowSharingAddedAssets?: boolean; /** * A [Bing Maps API key](https://msdn.microsoft.com/en-us/library/ff428642.aspx) used for requesting Bing Maps base maps and using the Bing Maps geocoder for searching. It is your responsibility to request a key and comply with all terms and conditions. */ bingMapsKey?: string; hideTerriaLogo?: boolean; /** * An array of strings of HTML that fill up the top left logo space (see `brandBarSmallElements` or `displayOneBrand` for small screens). */ brandBarElements?: string[]; /** * An array of strings of HTML that fill up the top left logo space - used for small screens. */ brandBarSmallElements?: string[]; /** * Index of which `brandBarElements` to show for mobile header. This will be used if `this.brandBarSmallElements` is undefined. */ displayOneBrand?: number; /** * True to disable the "Centre map at your current location" button. */ disableMyLocation?: boolean; disableSplitter?: boolean; disablePedestrianMode?: boolean; experimentalFeatures?: boolean; magdaReferenceHeaders?: MagdaReferenceHeaders; locationSearchBoundingBox?: number[]; /** * A Google API key for [Google Analytics](https://analytics.google.com). If specified, TerriaJS will send various events about how it's used to Google Analytics. */ googleAnalyticsKey?: string; /** * Error service provider configuration. */ errorService?: ErrorServiceOptions; globalDisclaimer?: any; /** * True to display welcome message on startup. */ showWelcomeMessage?: boolean; // TODO: make themeing TS /** Theme overrides, this is applied in StandardUserInterface and merged in order of highest priority: * `StandardUserInterface.jsx` `themeOverrides` prop -> `theme` config parameter (this object) -> default `terriaTheme` (see `StandardTheme.jsx`) */ theme?: any; /** * Video to show in welcome message. */ welcomeMessageVideo?: any; /** * Video to show in Story Builder. */ storyVideo?: StoryVideoSettings; /** * True to display in-app guides. */ showInAppGuides?: boolean; /** * The content to be displayed in the help panel. */ helpContent?: HelpContentItem[]; helpContentTerms?: Term[]; /** * */ languageConfiguration?: LanguageConfiguration; /** * Custom concurrent request limits for domains in Cesium's RequestScheduler. Cesium's default is 6 per domain (the maximum allowed by browsers unless the server supports http2). For servers supporting http2 try 12-24 to have more parallel requests. Setting this too high will undermine Cesium's prioritised request scheduling and important data may load slower. Format is {"domain_without_protocol:port": number}. */ customRequestSchedulerLimits?: Record<string, number>; /** * Whether to load persisted viewer mode from local storage. */ persistViewerMode?: boolean; /** * Whether to open the add data explorer panel on load. */ openAddData?: boolean; /** * Text showing at the top of feedback form. */ feedbackPreamble?: string; /** * Text showing at the bottom of feedback form. */ feedbackPostamble?: string; /** * Minimum length of feedback comment. */ feedbackMinLength?: number; /** Maximum zoom level for Leaflet map */ leafletMaxZoom: number; /** If undefined, then Leaflet's default attribution will be used */ leafletAttributionPrefix?: string; /** * Extra links to show in the credit line at the bottom of the map (currently only the Cesium map). */ extraCreditLinks?: ICredit[]; /** * Configurable discalimer that shows up in print view */ printDisclaimer?: { url: string; text: string }; /** * Prefix to which `:story-id` is added to fetch JSON for stories when using /story/:story-id routes. Should end in / */ storyRouteUrlPrefix?: string; /** * For Console Analytics */ enableConsoleAnalytics?: boolean; /** * Options for Google Analytics */ googleAnalyticsOptions?: unknown; relatedMaps?: RelatedMap[]; /** * Optional plugin configuration */ plugins?: Record<string, any>; aboutButtonHrefUrl?: string | null; /** * The search bar allows requesting information from various search services at once. */ searchBarConfig?: ModelPropertiesFromTraits<SearchBarTraits>; searchProviders: ModelPropertiesFromTraits<SearchProviderTraits>[]; /** * Keep catalog open when adding / removing items */ keepCatalogOpen: boolean; } interface StartOptions { configUrl: string; configUrlHeaders?: { [key: string]: string; }; applicationUrl?: Location; shareDataService?: ShareDataService; errorService?: ErrorServiceProvider; /** * i18nOptions is explicitly a separate option from `languageConfiguration`, * as `languageConfiguration` can be serialised, but `i18nOptions` may have * some functions that are passed in from a TerriaMap * */ i18nOptions?: I18nStartOptions; /** * Hook to run before restoring app state from the share URL. This is for * example used in terriamap/index.js for loading plugins before restoring * app state. */ beforeRestoreAppState?: () => Promise<void> | void; } export interface Analytics { start: ( configParameters: Partial<{ enableConsoleAnalytics: boolean; googleAnalyticsKey: any; googleAnalyticsOptions: any; }> ) => void; logEvent: ( category: string, action: string, label?: string, value?: number ) => void; } interface TerriaOptions { /** * Override detecting base href from document.baseURI. * Used in specs to support routes within Browser spec automation framework */ appBaseHref?: string; /** * Base url where TerriaJS resources can be found. * Normally "build/TerriaJS/" in any TerriaMap and "./" in specs */ baseUrl?: string; /** * Base url where Cesium static resources can be found. */ cesiumBaseUrl?: string; analytics?: Analytics; } interface HomeCameraInit { [key: string]: HomeCameraInit[keyof HomeCameraInit]; north: number; east: number; south: number; west: number; } export default class Terria { private readonly models = observable.map<string, BaseModel>(); /** Map from share key -> id */ readonly shareKeysMap = observable.map<string, string>(); /** Map from id -> share keys */ readonly modelIdShareKeysMap = observable.map<string, string[]>(); /** Base URL for the Terria app. Used for SPA routes */ readonly appBaseHref: string = ensureSuffix( typeof document !== "undefined" ? document.baseURI : "/", "/" ); /** Base URL to Terria resources */ readonly baseUrl: string = "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`. */ readonly cesiumBaseUrl: string; readonly tileLoadProgressEvent = new CesiumEvent(); readonly indeterminateTileLoadProgressEvent = new CesiumEvent(); readonly workbench = new Workbench(); readonly overlays = new Workbench(); readonly catalog = new Catalog(this); readonly baseMapsModel = new BaseMapsModel("basemaps", this); readonly searchBarModel = new SearchBarModel(this); readonly timelineClock = new Clock({ shouldAnimate: false }); // readonly overrides: any = overrides; // TODO: add options.functionOverrides like in master catalogIndex: CatalogIndex | undefined; readonly elements = observable.map<string, IElementConfig>(); @observable readonly 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: string = "TerriaJS App"; supportEmail: string = "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: 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`. */ readonly analytics: Analytics | undefined; /** * Gets the stack of layers active on the timeline. */ readonly timelineStack = new TimelineStack(this, this.timelineClock); @observable readonly configParameters: Complete<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: [] }; @observable pickedFeatures: PickedFeatures | undefined; @observable selectedFeature: TerriaFeature | undefined; @observable allowFeatureInfoRequests: boolean = 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 */ @observable mapInteractionModeStack: MapInteractionMode[] = []; @observable isWorkflowPanelActive = false; /** Gets or sets the active SelectableDimensionWorkflow, if defined, then the workflow will be displayed using `WorkflowPanel` */ @observable selectableDimensionWorkflow?: 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}} * ``` */ private focusWorkbenchItemsAfterLoadingInitSources: boolean = false; private _loadPersistedSettings: { baseMapPromise?: Promise<void> } = { baseMapPromise: undefined }; @computed get baseMapContrastColor() { return ( this.baseMapsModel.baseMapItems.find( (basemap) => isDefined(basemap.item?.uniqueId) && basemap.item?.uniqueId === this.mainViewer.baseMap?.uniqueId )?.contrastColor ?? "#ffffff" ); } @observable readonly userProperties = new Map<string, any>(); @observable readonly initSources: InitSource[] = []; private _initSourceLoader = new AsyncLoader( this.forceLoadInitSources.bind(this) ); @observable serverConfig: any; // TODO @observable shareDataService: ShareDataService | undefined; /* Splitter controls */ @observable showSplitter = false; @observable splitPosition = 0.5; @observable splitPositionVertical = 0.5; @observable terrainSplitDirection: SplitDirection = SplitDirection.NONE; @observable depthTestAgainstTerrainEnabled = false; @observable stories: StoryData[] = []; @observable storyPromptShown: number = 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 */ @observable private _previewedItemId: string | undefined; get previewedItemId() { return this._previewedItemId; } /** * Base ratio for maximumScreenSpaceError * @type {number} */ @observable baseMaximumScreenSpaceError = 2; /** * Model to use for map navigation */ @observable mapNavigationModel: 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} */ @observable useNativeResolution = false; /** * Whether we think all references in the catalog have been loaded * @type {boolean} */ @observable catalogReferencesLoaded: boolean = false; augmentedVirtuality?: any; readonly notificationState: NotificationState = new NotificationState(); readonly 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: ErrorServiceProvider = new StubErrorServiceProvider(); /** * @experimental */ catalogProvider?: CatalogProvider; constructor(options: TerriaOptions = {}) { 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 as any).setBaseUrl(this.cesiumBaseUrl); this.analytics = options.analytics; if (!defined(this.analytics)) { if (typeof window !== "undefined" && defined((window as any).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: unknown, overrides?: TerriaErrorOverrides, forceRaiseToUser = false ): void { 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(); } @computed get currentViewer(): GlobeOrMap { return this.mainViewer.currentViewer; } @computed get cesium(): import("./Cesium").default | undefined { if ( isDefined(this.mainViewer) && this.mainViewer.currentViewer.type === "Cesium" ) { return this.mainViewer.currentViewer as import("./Cesium").default; } } /** * @returns The currently active `TerrainProvider` or `undefined`. */ @computed get terrainProvider(): TerrainProvider | undefined { return this.cesium?.terrainProvider; } @computed get leaflet(): import("./Leaflet").default | undefined { if ( isDefined(this.mainViewer) && this.mainViewer.currentViewer.type === "Leaflet" ) { return this.mainViewer.currentViewer as import("./Leaflet").default; } } @computed get modelValues() { return Array.from<BaseModel>(this.models.values()); } @computed get modelIds() { return Array.from(this.models.keys()); } getModelById<T extends BaseModel>(type: Class<T>, id: string): T | undefined { const model = this.models.get(id); if (instanceOf(type, model)) { return model; } // Model does not have the requested type. return undefined; } @action addModel(model: BaseModel, shareKeys?: string[]): void { 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. */ @action removeModelReferences(model: BaseModel): void { this.removeSelectedFeaturesForModel(model); this.workbench.remove(model); if (model.uniqueId) { this.models.delete(model.uniqueId); } } @action removeSelectedFeaturesForModel(model: BaseModel): void { const pickedFeatures = this.pickedFeatures; if (pickedFeatures) { // Remove picked features that belong to the catalog item pickedFeatures.features.forEach((feature, i) => { if (featureBelongsToCatalogItem(feature as TerriaFeature, model)) { pickedFeatures?.features.splice(i, 1); if (this.selectedFeature === feature) this.selectedFeature = undefined; } }); } } getModelIdByShareKey(shareKey: string): string | undefined { return this.shareKeysMap.get(shareKey); } getModelByIdOrShareKey<T extends BaseModel>( type: Class<T>, id: string ): T | undefined { 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: string ): Promise<Result<BaseModel | undefined>> { 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); } @action addShareKey(id: string, shareKey: string): void { 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: URI, config: any): void { const initializationUrls: string[] = config?.initializationUrls || []; const initSources: InitSource[] = 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 as JsonArray) .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 as JsonObject | null) || {} }); } 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: StartOptions): Promise<void> { // 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); } private async restoreAppState(options: StartOptions) { 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. */ private 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) ) as MappableMixin.Instance | undefined; 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(): void { 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") as string; 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); } } @action setUseNativeResolution(useNativeResolution: boolean): void { this.useNativeResolution = useNativeResolution; } @action setBaseMaximumScreenSpaceError(baseMaximumScreenSpaceError: number): void { this.baseMaximumScreenSpaceError = baseMaximumScreenSpaceError; } async loadPersistedOrInitBaseMap(): Promise<void> { 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 as any).name === this.baseMapsModel.defaultBaseMapName ); if ( baseMapSearch?.item && MappableMixin.isMixedInto(baseMapSearch.item) ) { baseMap = baseMapSearch; } } if (baseMap?.item) await this.mainViewer.setBaseMap(baseMap.item as MappableMixin.Instance); } get isLoadingInitSources(): boolean { return this._initSourceLoader.isLoading; } /** * Asynchronously loads init sources */ loadInitSources(): Promise<Result<void>> { return this._initSourceLoader.load(); } dispose(): void { this._initSourceLoader.dispose(); } async updateFromStartData( startData: unknown, /** Name for startData initSources - this is only used for debugging purposes */ name: string = "Application start data", /** Error severity to use for loading startData init sources - default will be `TerriaErrorSeverity.Error` */ errorSeverity?: TerriaErrorSeverity ): Promise<Result<void>> { try { await interpretStartData(this, startData, name, errorSeverity); } catch (e) { return Result.error(e); } return await this.loadInitSources(); } async updateApplicationUrl(newUrl: string): Promise<Result<void>> { const uri = new URI(newUrl); const hash = uri.fragment(); const hashProperties = queryToObject(hash); function checkSegments(urlSegments: string[], customRoute: string) { // 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(); } @action updateParameters(parameters: ConfigParameters | JsonObject): void { Object.entries(parameters).forEach(([key, value]) => { if (Object.hasOwnProperty.call(this.configParameters, key)) { (this.configParameters as any)[key] = value; } }); this.appName = this.configParameters.appName ?? this.appName; this.supportEmail = this.configParameters.supportEmail ?? this.supportEmail; } protected async forceLoadInitSources(): Promise<void> { const loadInitSource = createTransformer( async (initSource: InitSource): Promise<InitSourceData | undefined> => { let initSourceData: InitSourceData | undefined; 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: unknown; 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: TerriaError[] = []; // 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) } as InitSourceFromData; } catch (e) { errors.push( TerriaError.from(e, { severity: initSource.errorSeverity, message: { key: "models.terria.loadingInitSourceError2Message", parameters: { loadSource: initSource.name ?? "Unknown source" } } }) ); } }) ); let baseMapPromise: Promise<void> | undefined; // 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(