UNPKG

terriajs

Version:

Geospatial data visualization platform.

1,496 lines (1,356 loc) 73.2 kB
import i18next from "i18next"; import { action, computed, observable, runInAction, toJS, when } from "mobx"; import { createTransformer } from "mobx-utils"; import buildModuleUrl from "terriajs-cesium/Source/Core/buildModuleUrl"; import Clock from "terriajs-cesium/Source/Core/Clock"; import defaultValue from "terriajs-cesium/Source/Core/defaultValue"; import defined from "terriajs-cesium/Source/Core/defined"; import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError"; import CesiumEvent from "terriajs-cesium/Source/Core/Event"; import queryToObject from "terriajs-cesium/Source/Core/queryToObject"; 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 Entity from "terriajs-cesium/Source/DataSources/Entity"; import SplitDirection from "terriajs-cesium/Source/Scene/SplitDirection"; import URI from "urijs"; import { Category, LaunchAction, DataSourceAction } 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 ensureSuffix from "../Core/ensureSuffix"; import filterOutUndefined from "../Core/filterOutUndefined"; import getDereferencedIfExists from "../Core/getDereferencedIfExists"; import getPath from "../Core/getPath"; import GoogleAnalytics from "../Core/GoogleAnalytics"; import hashEntity from "../Core/hashEntity"; import instanceOf from "../Core/instanceOf"; import isDefined from "../Core/isDefined"; import { isJsonBoolean, isJsonNumber, isJsonObject, isJsonString, JsonArray, JsonObject } from "../Core/Json"; import { isLatLonHeight } from "../Core/LatLonHeight"; import loadJson from "../Core/loadJson"; import loadJson5 from "../Core/loadJson5"; import Result from "../Core/Result"; import ServerConfig from "../Core/ServerConfig"; import TerriaError, { TerriaErrorOverrides, TerriaErrorSeverity } from "../Core/TerriaError"; import { Complete } from "../Core/TypeModifiers"; 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 { HelpContentItem } from "../ReactViewModels/defaultHelpContent"; import { defaultTerms, Term } from "../ReactViewModels/defaultTerms"; import NotificationState from "../ReactViewModels/NotificationState"; import { ICredit } from "../ReactViews/Credits"; 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, { MagdaReferenceHeaders } from "./Catalog/CatalogReferences/MagdaReference"; import SplitItemReference from "./Catalog/CatalogReferences/SplitItemReference"; import CommonStrata from "./Definition/CommonStrata"; import hasTraits from "./Definition/hasTraits"; import { BaseModel } from "./Definition/Model"; import updateModelFromJson from "./Definition/updateModelFromJson"; import upsertModelFromJson from "./Definition/upsertModelFromJson"; import { ErrorServiceOptions, ErrorServiceProvider, initializeErrorServiceProvider } 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, isInitFromData, isInitFromDataPromise, isInitFromOptions, isInitFromUrl, ShareInitSourceData, StoryData } from "./InitSource"; import Internationalization, { I18nStartOptions, LanguageConfiguration } from "./Internationalization"; import MapInteractionMode from "./MapInteractionMode"; import NoViewer from "./NoViewer"; import { defaultRelatedMaps, RelatedMap } from "./RelatedMaps"; import CatalogIndex from "./SearchProviders/CatalogIndex"; 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"; // import overrides from "../Overrides/defaults.jsx"; 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; /** * 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; /** * 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; /** 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; } interface StartOptions { configUrl: string; configUrlHeaders?: { [key: string]: string; }; applicationUrl?: Location; shareDataService?: ShareDataService; /** * 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; } interface Analytics { start: ( configParameters: Partial<{ enableConsoleAnalytics: boolean; googleAnalyticsKey: any; googleAnalyticsOptions: any; }> ) => void; logEvent: ( category: string, action: string, label?: string, value?: string ) => void; } interface TerriaOptions { /** * Override detecting base href from document.baseURI. * Used in specs to support routes within Karma 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 = 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 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, interceptBrowserPrint: true, tabbedCatalog: false, useCesiumIonTerrain: true, cesiumTerrainUrl: undefined, cesiumTerrainAssetId: undefined, cesiumIonAccessToken: undefined, useCesiumIonBingImagery: undefined, bingMapsKey: undefined, hideTerriaLogo: false, brandBarElements: undefined, brandBarSmallElements: undefined, displayOneBrand: 0, disableMyLocation: undefined, disableSplitter: undefined, disablePedestrianMode: 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, leafletAttributionPrefix: undefined, extraCreditLinks: [ // Default credit links (shown at the bottom of the Cesium map) { text: "map.extraCreditLinks.dataAttribution", url: "about.html#data-attribution" }, { text: "map.extraCreditLinks.disclaimer", url: "about.html#disclaimer" } ], printDisclaimer: undefined, storyRouteUrlPrefix: undefined, enableConsoleAnalytics: undefined, googleAnalyticsOptions: undefined, relatedMaps: defaultRelatedMaps, aboutButtonHrefUrl: "about.html", plugins: undefined }; @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; @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 configured by setting the * `errorService` config parameter. Here we initialize it to stub provider so * that the `terria.errorService` always exists. */ errorService: ErrorServiceProvider = new StubErrorServiceProvider(); constructor(options: TerriaOptions = {}) { if (options.appBaseHref) { this.appBaseHref = new URL( options.appBaseHref, typeof document !== "undefined" ? document.baseURI : "/" ).toString(); } if (options.baseUrl) { this.baseUrl = ensureSuffix(options.baseUrl, "/"); } this.cesiumBaseUrl = ensureSuffix( options.cesiumBaseUrl ?? `${this.baseUrl}build/Cesium/build/`, "/" ); // 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((<any>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: unknown, overrides?: TerriaErrorOverrides, 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(); } @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(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[]) { 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) { this.removeSelectedFeaturesForModel(model); this.workbench.remove(model); if (model.uniqueId) { this.models.delete(model.uniqueId); } } @action removeSelectedFeaturesForModel(model: BaseModel) { const pickedFeatures = this.pickedFeatures; if (pickedFeatures) { // Remove picked features that belong to the catalog item pickedFeatures.features.forEach((feature, i) => { if (featureBelongsToCatalogItem(<TerriaFeature>feature, 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 { let 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) { if (id === shareKey || this.shareKeysMap.has(shareKey)) return; this.shareKeysMap.set(shareKey, id); this.modelIdShareKeysMap.get(id)?.push(shareKey) ?? this.modelIdShareKeysMap.set(id, [shareKey]); } /** * Initialize errorService from config parameters. */ setupErrorServiceProvider(errorService: ErrorServiceOptions) { initializeErrorServiceProvider(errorService) .then((errorService) => { this.errorService = errorService; }) .catch((e) => { console.error("Failed to initialize error service", e); }); } setupInitializationUrls(baseUri: uri.URI, config: any) { 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) { // 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); } if (this.configParameters.errorService) { this.setupErrorServiceProvider(this.configParameters.errorService); } 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 ); 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") ) ); 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(); } 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 = <string>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); } } @action setUseNativeResolution(useNativeResolution: boolean) { this.useNativeResolution = useNativeResolution; } @action setBaseMaximumScreenSpaceError(baseMaximumScreenSpaceError: number): void { 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) && (<any>baseMapItem.item).name === this.baseMapsModel.defaultBaseMapName ); if ( baseMapSearch?.item && MappableMixin.isMixedInto(baseMapSearch.item) ) { baseMap = baseMapSearch; } } await this.mainViewer.setBaseMap(<MappableMixin.Instance>baseMap.item); } get isLoadingInitSources(): boolean { return this._initSourceLoader.isLoading; } /** * Asynchronously loads init sources */ loadInitSources() { return this._initSourceLoader.load(); } dispose() { 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 ) { try { await interpretStartData(this, startData, name, errorSeverity); } catch (e) { return Result.error(e); } return await this.loadInitSources(); } async updateApplicationUrl(newUrl: string) { const uri = new URI(newUrl); const hash = uri.fragment(); const hashProperties = queryToObject(hash); try { await interpretHash( this, hashProperties, this.userProperties, new URI(newUrl).filename("").query("").hash("") ); if (!this.appBaseHref.endsWith("/")) { console.warn( `Terria expected appBaseHref to end with a "/" but appBaseHref is "${this.appBaseHref}". Routes may not work as intended. To fix this, try setting the "--baseHref" parameter to a URL with a trailing slash while building your map, or constructing the Terria object with an appropriate appBaseHref (with trailing slash).` ); } // /catalog/ and /story/ routes if (newUrl.startsWith(this.appBaseHref)) { 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 ); } 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 (this.configParameters.hasOwnProperty(key)) { (this.configParameters as any)[key] = value; } }); this.appName = defaultValue(this.configParameters.appName, this.appName); this.supportEmail = defaultValue( 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" } } }) ); } }) ); // Sequentially apply all InitSources for (let i = 0; i < loadedInitSources.length; i++) { const initSource = loadedInitSources[i]; if (!isDefined(initSource?.data)) continue; try { await this.applyInitData({ initData: initSource!.data }); } catch (e) { errors.push( TerriaError.from(e, { severity: initSource?.errorSeverity, message: { key: "models.terria.loadingInitSourceError2Message", parameters: { loadSource: initSource!.name ?? "Unknown source" } } }) ); } } // Load basemap 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(); } }); 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 } } }); } } private async loadModelStratum( modelId: string, stratumId: string, allModelStratumData: JsonObject, replaceStratum: boolean ): Promise<Result<BaseModel | undefined>> { 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: TerriaError[] = []; 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: JsonObject | undefined = 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 (dereference