terriajs
Version:
Geospatial data visualization platform.
1,496 lines (1,356 loc) • 73.2 kB
text/typescript
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