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