Models/Terria.js

"use strict";

/*global require*/
var URI = require("urijs");
import "./i18n.js";
var buildModuleUrl = require("terriajs-cesium/Source/Core/buildModuleUrl")
  .default;
var CesiumEvent = require("terriajs-cesium/Source/Core/Event").default;
var combine = require("terriajs-cesium/Source/Core/combine").default;
var DataSourceCollection = require("terriajs-cesium/Source/DataSources/DataSourceCollection")
  .default;
var defaultValue = require("terriajs-cesium/Source/Core/defaultValue").default;
var defined = require("terriajs-cesium/Source/Core/defined").default;
var DeveloperError = require("terriajs-cesium/Source/Core/DeveloperError")
  .default;
var ImagerySplitDirection = require("terriajs-cesium/Source/Scene/ImagerySplitDirection")
  .default;
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var queryToObject = require("terriajs-cesium/Source/Core/queryToObject")
  .default;
var Rectangle = require("terriajs-cesium/Source/Core/Rectangle").default;
var when = require("terriajs-cesium/Source/ThirdParty/when").default;
var { addMarker } = require("./LocationMarkerUtils.js");

var CameraView = require("./CameraView");
var Catalog = require("./Catalog");
var Clock = require("./Clock");
var ConsoleAnalytics = require("../Core/ConsoleAnalytics");
var CorsProxy = require("../Core/CorsProxy");
var Feature = require("./Feature");
var GoogleAnalytics = require("../Core/GoogleAnalytics");
var hashEntity = require("../Core/hashEntity");
var isCommonMobilePlatform = require("../Core/isCommonMobilePlatform");
var loadJson5 = require("../Core/loadJson5");
var NoViewer = require("./NoViewer");
var NowViewing = require("./NowViewing");
var runLater = require("../Core/runLater");
var ServerConfig = require("../Core/ServerConfig");
var Services = require("./Services");
var TimeSeriesStack = require("./TimeSeriesStack");
var ViewerMode = require("./ViewerMode");
var findIndex = require("../Core/findIndex.js");
var i18next = require("i18next").default;
var overrides = require("../Overrides/defaults.jsx").overrides;

var defaultConfigParameters = {
  defaultMaximumShownFeatureInfos: 100,
  /* These services are not included within Terria, but this is where we expect them to be, by default. */
  regionMappingDefinitionsUrl: "build/TerriaJS/data/regionMapping.json",
  conversionServiceBaseUrl: "convert/",
  proj4ServiceBaseUrl: "proj4/",
  corsProxyBaseUrl: "proxy/",
  proxyableDomainsUrl: "proxyabledomains/",
  shareUrl: "share",
  feedbackUrl: undefined,
  initFragmentPaths: ["init/"],
  storyEnabled: true,
  interceptBrowserPrint: true,
  useCesiumIonTerrain: true,
  useCesiumIonBingImagery: undefined,
  cesiumIonAccessToken: undefined,
  cesiumTerrainUrl: undefined
};

// These properties can be directly passed in as properties of an init source.
var directInitSourceProperties = [
  "baseMapName",
  "fogSettings",
  "splitPosition"
];

/**
 * The overall model for TerriaJS.
 * @alias Terria
 * @constructor
 *
 * @param {Object} options Object with the following properties:
 * @param {String} options.baseUrl The base directory in which TerriaJS can find its static assets.
 * @param {String} [options.cesiumBaseUrl='(options.baseUrl)/build/Cesium/build/'] The base directory in which Cesium can find its static assets.
 * @param {String} [options.appName] The name of the app.
 * @param {String} [options.supportEmail] The support email for the app.
 * @param {AddressGeocoder} [options.batchGeocoder] Geocoder to use for geocoding addresses in CSV files.
 */
var Terria = function(options) {
  if (!defined(options) || !defined(options.baseUrl)) {
    throw new DeveloperError("options.baseUrl is required.");
  }

  options.functionOverrides = defined(options.functionOverrides)
    ? options.functionOverrides
    : {};

  this.overrides = combine(options.functionOverrides, overrides);

  this.baseUrl = defaultValue(options.baseUrl, "build/TerriaJS/");
  if (this.baseUrl.lastIndexOf("/") !== this.baseUrl.length - 1) {
    this.baseUrl += "/";
  }

  var cesiumBaseUrl = defaultValue(
    options.cesiumBaseUrl,
    this.baseUrl + "build/Cesium/build/"
  );
  if (cesiumBaseUrl.lastIndexOf("/") !== cesiumBaseUrl.length - 1) {
    cesiumBaseUrl += "/";
  }
  this.cesiumBaseUrl = cesiumBaseUrl;
  buildModuleUrl.setBaseUrl(cesiumBaseUrl);

  /**
   * 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`.
   * @type {ConsoleAnalytics|GoogleAnalytics}
   */
  this.analytics = options.analytics;
  if (!defined(this.analytics)) {
    if (typeof window !== "undefined" && defined(window.ga)) {
      this.analytics = new GoogleAnalytics();
    } else {
      this.analytics = new ConsoleAnalytics();
    }
  }

  /**
   * The name of the app to be built upon Terria. This will appear in error messages to the user.
   * @type {String}
   * @default "TerriaJS App"
   */
  this.appName = defaultValue(options.appName, "TerriaJS App");

  /**
   * The support email for the app to be built upon Terria. This will appear in error messages to the user.
   * @type {String}
   * @default "support@terria.io"
   */
  this.supportEmail = defaultValue(options.supportEmail, "support@terria.io");

  /**
   * Indicates whether time-dynamic layers should start animating immediately upon load.
   * If false, the user will need to press play manually before the layer starts animating.
   * @type {Boolean}
   * @default true
   */
  this.autoPlay = false;

  /**
   * The geocoder to use for batch geocoding addresses in CSV files.
   * @type {AddressGeocoder}
   */
  this.batchGeocoder = options.batchGeocoder;

  /**
   * An event that is raised when a user-facing error occurs.  This is especially useful for errors that happen asynchronously and so
   * cannot be raised as an exception because no one would be able to catch it.  Subscribers are passed the {@link TerriaError}
   * that occurred as the only function parameter.
   * @type {CesiumEvent}
   */
  this.error = new CesiumEvent();

  /**
   * Gets or sets the map mode.
   * @type {ViewerMode}
   */
  this.viewerMode = defaultValue(options.viewerMode, ViewerMode.CesiumTerrain);

  /**
   * Gets or sets the current base map.
   * @type {ImageryLayerCatalogItem}
   */
  this.baseMap = undefined;

  /**
   * Gets or sets the current fog settings, used in the Cesium Scene/Fog constructor.
   * @type {Object}
   */
  this.fogSettings = undefined;

  /**
   * Gets or sets the name of the base map to use.
   * @type {String}
   */
  this.baseMapName = undefined;

  /**
   * Gets or sets a color that contrasts well with the base map.
   * @type {String}
   */
  this.baseMapContrastColor = "#ffffff";

  /**
   * Gets or sets the event that is raised just before switching between Cesium and Leaflet.
   * @type {Event}
   */
  this.beforeViewerChanged = new CesiumEvent();

  /**
   * Gets or sets the event that is raised just after switching between Cesium and Leaflet.
   * @type {Event}
   */
  this.afterViewerChanged = new CesiumEvent();

  /**
   * Gets or sets the collection of Cesium-style data sources that are currently active on the map.
   * @type {DataSourceCollection}
   */
  this.dataSources = new DataSourceCollection();

  /**
   * Gets or sets the clock that controls how time-varying data items are displayed.
   * @type {Clock}
   */
  this.clock = new Clock({
    shouldAnimate: false
  });

  this.timeSeriesStack = new TimeSeriesStack(this.clock);

  // See the intialView property below.
  this._initialView = undefined;

  /**
   * Gets or sets the camera's home view.  The home view is the one that the application
   * returns to when the user clicks the "Reset View" button in the Navigation widget.  It is also used
   * as the {@link Terria#initialView} if one is not specified.
   * @type {CameraView}
   */
  this.homeView = new CameraView(Rectangle.MAX_VALUE);

  /**
   * Gets or sets a value indicating whether the application should automatically zoom to the new view when
   * the {@link Terria#initialView} (or {@link Terria#homeView} if no initial view is specified).
   * @type {Boolean}
   * @default true
   */
  this.zoomWhenInitialViewChanges = true;

  /**
   * 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}
   */
  this.corsProxy = new CorsProxy();

  /**
   * Gets or sets properties related to the Cesium globe.  If the application is in 2D mode, this property will be
   * undefined and {@link Terria#leaflet} will be set.
   * @type {Cesium}
   */
  this.cesium = undefined;

  /**
   * Gets or sets properties related to the Leaflet map.  If the application is in 3D mode, this property will be
   * undefined and {@link Terria#cesium} will be set.
   * @type {Leaflet}
   */
  this.leaflet = undefined;

  this._noViewer = new NoViewer(this);
  /**
   * Gets or sets a reference to the current viewer, which is a subclass of {@link Terria#globeOrMap} -
   * typically {@link Terria#cesium} or {@link Terria#leaflet}.
   * This property is observable.
   * @type {Cesium|Leaflet|NoViewer}
   */
  this.currentViewer = this._noViewer;

  /**
   * Gets or sets the collection of user properties.  User properties
   * can be set by specifying them in the hash portion of the URL.  For example, if the application URL is
   * `http://localhost:3001/#foo=bar&someproperty=true`, this object will contain a property named 'foo' with the
   * value 'bar' and a property named 'someproperty' with the value 'true'. Currently recognised URL parameters include
   * 'map=[2D,3D]' (choose the Leaflet or Cesium view) and `mode=preview` (suppress warnings, when used as an embedded
   * previewer).
   * @type {Object}
   */
  this.userProperties = {};

  /**
   * Gets or sets the list of sources from which the catalog was populated.  A source may be a string, in which case it
   * is expected to be a URL of an init file (like init_nm.json), or it can be a JSON-style object literal which is
   * the init content itself.
   * @type {Array}
   */
  this.initSources = [];

  /**
   * Gets or sets the data source that represents the location marker.
   * @type {CustomDataSource}
   */
  this.locationMarker = undefined;

  /**
   * Gets or sets the features that are currently picked.
   * @type {PickedFeatures}
   */
  this.pickedFeatures = undefined;

  /**
   * Gets or sets whether to make feature info requests when points are clicked.
   * @type {Boolean}
   */
  this.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
   * @type {MapInteractionMode[]}
   */
  this.mapInteractionModeStack = [];

  /**
   * Gets or sets the catalog of geospatial data.
   * @type {Catalog}
   */
  this.catalog = new Catalog(this);

  /**
   * Gets or sets the add-on services known to the application.
   * @type {Services}
   */
  this.services = new Services(this);

  /**
   * Gets or sets the collection of geospatial data that is currently enabled.
   * @type {NowViewing}
   */
  this.nowViewing = new NowViewing(this);

  /**
   * Gets or sets the currently-selected feature, or undefined if there is no selected feature.  The selected
   * feature is highlighted by drawing a targetting cursor around it.
   * @type {Entity}
   */
  this.selectedFeature = undefined;

  /**
   * Gets or sets the configuration parameters set at startup.
   * Contains:
   * * regionMappingDefinitionsUrl: URL of JSON file containing region mapping definitions
   * * conversionServiceBaseUrl: URL of OGR2OGR conversion service
   * * proj4ServiceBaseUrl: URL of proj4def lookup service
   * * corsProxyBaseUrl: URL of CORS proxy
   * @type {Object}
   */
  this.configParameters = defaultConfigParameters;

  /**
   * Gets or sets the urlShorter to be used with terria.  This is currently set in the start method
   * to allow the urlShortener object to properly initialize.  See the GoogleUrlShortener for an
   * example urlShortener.
   * @type {Object}
   */
  this.urlShortener = undefined;

  /**
   * Gets or sets the shareDataService to be used with Terria, which can save JSON or (in future) other user-provided
   * data somewhere. It can be used to generate short URLs.
   * @type {Object}
   */
  this.shareDataService = undefined;

  /**
   * Gets or sets the ServerConfig object representing server-side configuration.
   * @type {Object}
   */
  this.serverConfig = undefined;

  /**
   * Event that tracks changes to the progress in loading new tiles from either Cesium or Leaflet - events will be
   * raised with the number of tiles that still need to load.
   *
   * @type {CesiumEvent}
   */
  this.tileLoadProgressEvent = new CesiumEvent();

  this.disclaimerListener = function(catalogMember, callback) {
    window.alert(catalogMember.initialMessage.content); /*eslint no-alert: 0*/
    callback();
  };

  /**
   * Gets or sets the selectBox function - set true when user requires a rectangle parameter from analytics.
   * @type {Boolean}
   */
  this.selectBox = false;

  /**
   * Gets or sets a callback function that can modify any "start data" (e.g. a share URL) before it is loaded.
   * The function is passed the start data and may modify it in place or return a new instance.
   * @type {Function}
   */
  this.filterStartDataCallback = undefined;

  /**
   * Gets or sets whether to show a splitter, if possible. Default false. This property is observable.
   * @type {Boolean}
   */
  this.showSplitter = false;

  /**
   * Gets or sets whether to toggle viewState's sharedFromExplorerPanel after loading start data. Default false.
   * @type {Boolean}
   */
  this.sharedFromExplorerPanel = false;

  /**
   * Gets or sets id of previewed item
   * @type {string}
   */
  this.previewedItemId = undefined;

  /**
   * Gets or sets the current position of the splitter (if {@link Terria#showSplitter} is true) as a fraction of the map window.
   * 0.0 is on the left, 0.5 is in the center, and 1.0 is on the right.  This property is observable.
   */
  this.splitPosition = 0.5;
  /**
   * Gets or sets the current vertical position of the splitter (if {@link Terria#showSplitter} is true) as a fraction of the map window.
   * 0.0 is on the top, 0.5 is in the center, and 1.0 is on the bottom.  This property is observable.
   */
  this.splitPositionVertical = 0.5;

  /**
   * Array of stories
   */
  this.stories = [];

  /**
   * Base ratio for maximumScreenSpaceError
   * @type {Integer}
   */
  this.baseMaximumScreenSpaceError = 2;

  /**
   * Gets or sets whether to use the device's native resolution (sets cesium.viewer.resolutionScale to a ratio of devicePixelRatio)
   * @type {Boolean}
   */
  this.useNativeResolution = false;

  /**
   * Gets or sets whether we should initiate the satellite guidance, based on
   * whether a time wms exists in NowViewing
   * @type {Boolean}
   */
  this.shouldStartSatelliteGuidance = false;

  knockout.track(this, [
    "baseMaximumScreenSpaceError",
    "useNativeResolution",
    "viewerMode",
    "baseMap",
    "baseMapName",
    "fogSettings",
    "_initialView",
    "homeView",
    "locationMarker",
    "pickedFeatures",
    "selectedFeature",
    "mapInteractionModeStack",
    "configParameters",
    "catalog",
    "selectBox",
    "currentViewer",
    "showSplitter",
    "splitPosition",
    "splitPositionVertical",
    "baseMapContrastColor",
    "sharedFromExplorerPanel",
    "previewedItemId",
    "stories",
    "shouldStartSatelliteGuidance"
  ]);

  /**
   * Gets or sets the camera's initial view.  This is the view that the application has at startup.  If this property
   * is not explicitly specified, the {@link Terria#homeView} is used.
   * @type {CameraView}
   */
  knockout.defineProperty(this, "initialView", {
    get: function() {
      if (this._initialView) {
        return this._initialView;
      } else {
        return this.homeView;
      }
    },
    set: function(value) {
      this._initialView = value;
    }
  });

  knockout.getObservable(this, "initialView").subscribe(function() {
    if (this.zoomWhenInitialViewChanges && defined(this.currentViewer)) {
      this.currentViewer.zoomTo(this.initialView, 2.0);
    }
  }, this);

  var myEventAdd = window.attachEvent || window.addEventListener;
  var myEventRemove = window.detachEvent || window.removeEventListener;
  var chkevent = window.attachEvent ? "onbeforeunload" : "beforeunload"; // make IE7, IE8 compitable
  this.handleWindowClose = function(e) {
    var confirmationMessage = "Are you sure to leave the page?";
    (e || window.event).returnValue = confirmationMessage;
    return confirmationMessage;
  };

  knockout.getObservable(this, "stories").subscribe(function() {
    if (this.stories.length > 0) {
      myEventAdd(chkevent, this.handleWindowClose);
      // window.addEventListener("beforeunload", this.handleWindowClose);
    } else {
      myEventRemove(chkevent, this.handleWindowClose);
      //window.removeEventListener("beforeunload", this.handleWindowClose);
    }
  }, this);
};

/**
 * Starts up Terria.
 *
 * @param {Object} options Object with the following properties:
 * @param {String} [options.applicationUrl] The URL of the application.  Typically this is obtained from window.location.  This URL, if
 *                                          supplied, is parsed for startup parameters.
 * @param {String} [options.configUrl='config.json'] The URL of the file containing configuration information, such as the list of domains to proxy.
 * @param {UrlShortener} [options.urlShortener] The URL shortener to use to expand short URLs.  If this property is undefined, short URLs will not be expanded.
 * @param {Boolean} [options.persistViewerMode] Whether to use the ViewerMode stored in localStorage if avaliable (this takes priority over other ViewerMode options). If not specified the stored ViewerMode will be used.
 */
Terria.prototype.start = function(options) {
  function slashify(url) {
    return url && url[url.length - 1] !== "/" ? url + "/" : url;
  }

  this.catalog.isLoading = true;

  var applicationUrl = defaultValue(options.applicationUrl, "");
  this.urlShortener = options.urlShortener;
  this.shareDataService = options.shareDataService;

  var that = this;
  return loadJson5(options.configUrl).then(function(config) {
    if (defined(config.parameters)) {
      // allow config file to provide TerriaJS-Server URLs to facilitate purely static deployments relying on external services
      that.configParameters = combine(config.parameters, that.configParameters);
    }
    var cp = that.configParameters;
    cp.conversionServiceBaseUrl = slashify(cp.conversionServiceBaseUrl);
    cp.proj4ServiceBaseUrl = slashify(cp.proj4ServiceBaseUrl);
    cp.corsProxyBaseUrl = slashify(cp.corsProxyBaseUrl);

    that.appName = defaultValue(
      cp.appName,
      defaultValue(options.appName, that.appName)
    );
    that.supportEmail = defaultValue(
      cp.supportEmail,
      defaultValue(options.supportEmail, that.supportEmail)
    );

    if (defined(cp.autoPlay)) that.autoPlay = cp.autoPlay;

    that.analytics.start(that.configParameters);
    that.analytics.logEvent(
      "launch",
      "url",
      defined(applicationUrl.href) ? applicationUrl.href : "empty"
    );

    var initializationUrls = config.initializationUrls;

    if (defined(initializationUrls)) {
      for (var i = 0; i < initializationUrls.length; i++) {
        that.initSources.push(generateInitializationUrl(initializationUrls[i]));
      }
    }

    showDisclaimer(
      that,
      options.globalDisclaimerHtml,
      options.developmentDisclaimerPreambleHtml
    );

    that.serverConfig = new ServerConfig();
    let serverConfig;
    return that.serverConfig
      .init(cp.serverConfigUrl)
      .then(function() {
        // All the "proxyableDomains" bits here are due to a pre-serverConfig mechanism for whitelisting domains.
        // We should deprecate it.
        var pdu = that.configParameters.proxyableDomainsUrl;
        if (pdu) {
          return loadJson5(pdu);
        }
      })
      .then(function(proxyableDomains) {
        if (proxyableDomains) {
          // format of proxyableDomains JSON file slightly differs from serverConfig format.
          proxyableDomains.allowProxyFor =
            proxyableDomains.allowProxyFor || proxyableDomains.proxyableDomains;
        }
        if (typeof that.serverConfig === "object") {
          serverConfig = that.serverConfig.config; // if server config is unavailable, this remains undefined.
        }
        if (that.shareDataService) {
          that.shareDataService.init(serverConfig);
        }
        that.corsProxy.init(
          proxyableDomains || serverConfig,
          cp.corsProxyBaseUrl,
          config.proxyDomains
        );
      })
      .otherwise(function(e) {
        console.error(e);
        // There's no particular reason an error should be thrown here.
        that.error.raiseEvent({
          title: i18next.t("models.terria.initErrorTitle"),
          message: i18next.t("models.terria.initErrorMessage")
        });
      })
      .then(function() {
        return that.updateApplicationUrl(applicationUrl, that.urlShortener);
      })
      .then(function() {
        var persistViewerMode = defaultValue(options.persistViewerMode, true);

        if (persistViewerMode && defined(that.getLocalProperty("viewermode"))) {
          that.viewerMode = parseInt(that.getLocalProperty("viewermode"), 10);
        } else {
          // If we are running on a mobile platform set the viewerMode to the config specified default mobile viewer mode.
          if (isCommonMobilePlatform() && !defined(that.userProperties.map)) {
            // This is the default viewerMode to use if the configuration parameter is not set or is not set correctly.
            that.viewerMode = ViewerMode.Leaflet;

            if (
              defined(that.configParameters.mobileDefaultViewerMode) &&
              typeof that.configParameters.mobileDefaultViewerMode === "string"
            ) {
              const mobileDefault = that.configParameters.mobileDefaultViewerMode.toLowerCase();
              if (mobileDefault === "3dterrain") {
                that.viewerMode = ViewerMode.CesiumTerrain;
              } else if (mobileDefault === "3dsmooth") {
                that.viewerMode = ViewerMode.CesiumEllipsoid;
              } else if (mobileDefault === "2d") {
                that.viewerMode = ViewerMode.Leaflet;
              }
            }
          }

          if (options.defaultTo2D && !defined(that.userProperties.map)) {
            that.viewerMode = ViewerMode.Leaflet;
          }
        }

        that.catalog.isLoading = false;
      })
      .otherwise(function(e) {
        console.error("Error from updateApplicationUrl: ", e);
        that.error.raiseEvent({
          title: i18next.t("models.terria.urlLoadErrorTitle"),
          message: i18next.t("models.terria.urlLoadErrorMessage")
        });
      });
  });
};

/**
 * Updates the state of the application based on the hash portion of a URL.
 * @param {String} newUrl The new URL of the application.
 * @return {Promise} A promise that resolves when any new init sources specified in the URL have been loaded.
 */
Terria.prototype.updateApplicationUrl = function(newUrl) {
  var uri = new URI(newUrl);
  var hash = uri.fragment();
  var hashProperties = queryToObject(hash);

  var initSources = this.initSources.slice();
  var promise = interpretHash(
    this,
    hashProperties,
    this.userProperties,
    this.initSources,
    initSources
  );

  var that = this;

  return when(promise).then(function() {
    var desiredMode = (that.userProperties.map || "").toLowerCase();
    if (desiredMode === "2d") {
      that.viewerMode = ViewerMode.Leaflet;
    } else if (desiredMode === "3d") {
      that.viewerMode = ViewerMode.CesiumTerrain;
    } else if (desiredMode === "3dsmooth") {
      that.viewerMode = ViewerMode.CesiumEllipsoid;
    }
    return loadInitSources(that, initSources);
  });
};

Terria.prototype.updateFromStartData = function(startData) {
  var initSources = this.initSources.slice();
  interpretStartData(this, startData, this.initSources, initSources);
  return loadInitSources(this, initSources);
};

/**
 * Gets the value of a user property.  If the property doesn't exist, it is created as an observable property with the
 * value undefined.  This way, if it becomes defined in the future, anyone depending on the value will be notified.
 * @param {String} propertyName The name of the user property for which to get the value.
 * @return {Object} The value of the property, or undefined if the property does not exist.
 */
Terria.prototype.getUserProperty = function(propertyName) {
  if (!knockout.getObservable(this.userProperties, propertyName)) {
    this.userProperties[propertyName] = undefined;
    knockout.track(this.userProperties, [propertyName]);
  }
  return this.userProperties[propertyName];
};

Terria.prototype.addInitSource = function(initSource, fromStory = false) {
  var promise = when();
  var that = this;
  var viewerChangeListener;
  function zoomToInitialView() {
    that.currentViewer.zoomTo(that.initialView, 0.0);
    if (defined(viewerChangeListener)) {
      viewerChangeListener();
    }
  }
  // Extract the list of CORS-ready domains.
  if (defined(initSource.corsDomains)) {
    this.corsProxy.corsDomains.push.apply(
      this.corsProxy.corsDomains,
      initSource.corsDomains
    );
  }

  // The last init source to specify an initial/home camera view wins.
  if (defined(initSource.homeCamera)) {
    this.homeView = CameraView.fromJson(initSource.homeCamera);
  }

  if (defined(initSource.initialCamera)) {
    this.initialView = CameraView.fromJson(initSource.initialCamera);
  }

  // Extract the init source properties that require no deserialization.
  directInitSourceProperties.forEach(function(propertyName) {
    // a special case for basemap
    if (propertyName === "baseMapName" && defined(initSource[propertyName])) {
      // if basemap name is not set, we check terria.basemap to see if the
      // same basemap is already active
      if (
        defined(that.baseMap) &&
        that.baseMap.name === initSource[propertyName]
      ) {
        return;
      }
    }
    if (
      defined(initSource[propertyName]) &&
      initSource[propertyName] !== that[propertyName]
    ) {
      that[propertyName] = initSource[propertyName];
    }
  });

  if (defined(initSource.sharedFromExplorerPanel)) {
    if (initSource.sharedFromExplorerPanel) {
      that.sharedFromExplorerPanel = true;
    }
  }

  if (defined(initSource.previewedItemId)) {
    that.previewedItemId = initSource.previewedItemId;
  }

  if (defined(initSource.showSplitter)) {
    // If you try to show the splitter straight away, the browser hangs.
    runLater(function() {
      that.showSplitter = initSource.showSplitter;
    });
  }
  if (fromStory === true) {
    viewerChangeListener = this.afterViewerChanged.addEventListener(
      zoomToInitialView
    );
  }

  if (defined(initSource.viewerMode) && !defined(this.userProperties.map)) {
    var desiredMode = (initSource.viewerMode || "").toLowerCase();

    if (desiredMode === "2d") {
      if (fromStory && this.viewerMode === ViewerMode.Leaflet) {
        return;
      }
      this.viewerMode = ViewerMode.Leaflet;
    } else if (desiredMode === "3d") {
      if (fromStory && this.viewerMode === ViewerMode.CesiumTerrain) {
        return;
      }
      this.viewerMode = ViewerMode.CesiumTerrain;
    } else if (desiredMode === "3dsmooth") {
      if (fromStory && this.viewerMode === ViewerMode.CesiumEllipsoid) {
        return;
      }
      this.viewerMode = ViewerMode.CesiumEllipsoid;
    }
  }

  if (!defined(initSource.timeline) && defined(initSource.currentTime)) {
    // If the time is supplied we want to freeze the display at the specified time and not auto playing.
    this.autoPlay = false;

    const time = initSource.currentTime;
    this.clock.currentTime.dayNumber = parseInt(time.dayNumber, 10);
    this.clock.currentTime.secondsOfDay = parseInt(time.secondsOfDay, 10);
  }

  if (defined(initSource.timeline)) {
    this.clock.shouldAnimate = initSource.timeline.shouldAnimate;
    this.clock.multiplier = initSource.timeline.multiplier;
    const time = initSource.timeline.currentTime;
    this.clock.currentTime.dayNumber = parseInt(time.dayNumber, 10);
    this.clock.currentTime.secondsOfDay = parseInt(time.secondsOfDay, 10);
  }

  // Populate the list of services.
  if (defined(initSource.services)) {
    this.services.services.push.apply(this.services, initSource.services);
  }

  // Populate the catalog
  if (defined(initSource.catalog)) {
    var isUserSupplied = !initSource.isFromExternalFile;

    promise = promise.then(
      this.catalog.updateFromJson.bind(this.catalog, initSource.catalog, {
        isUserSupplied: isUserSupplied
      })
    );
  }

  if (defined(initSource.sharedCatalogMembers)) {
    promise = promise.then(
      this.catalog.updateByShareKeys.bind(
        this.catalog,
        initSource.sharedCatalogMembers
      )
    );
  }

  if (defined(initSource.locationMarker)) {
    var marker = {
      name: initSource.locationMarker.name,
      location: {
        latitude: initSource.locationMarker.latitude,
        longitude: initSource.locationMarker.longitude
      }
    };

    addMarker(this, marker);
  }

  if (defined(initSource.pickedFeatures)) {
    promise.then(function() {
      var removeViewLoadedListener;

      var loadPickedFeatures = function() {
        if (defined(removeViewLoadedListener)) {
          removeViewLoadedListener();
        }

        var vectorFeatures;
        var featureIndex = {};

        var initSourceEntities = initSource.pickedFeatures.entities;
        if (initSourceEntities) {
          // Build index of terria features by a hash of their properties.
          var relevantItems = that.nowViewing.items.filter(function(item) {
            return (
              item.isEnabled &&
              item.isShown &&
              defined(item.dataSource) &&
              defined(item.dataSource.entities)
            );
          });
          relevantItems.forEach(function(item) {
            (item.dataSource.entities.values || []).forEach(function(entity) {
              var hash = hashEntity(entity, that.clock);
              var feature = Feature.fromEntityCollectionOrEntity(entity);
              featureIndex[hash] = featureIndex[hash]
                ? featureIndex[hash].concat([feature])
                : [feature];
            });
          });

          // Go through the features we've got from terria match them up to the id/name info we got from the
          // share link, filtering out any without a match.
          vectorFeatures = initSourceEntities
            .map(function(initSourceEntity) {
              var matches = defaultValue(
                featureIndex[initSourceEntity.hash],
                []
              ).filter(function(match) {
                return match.name === initSourceEntity.name;
              });

              return matches.length && matches[0];
            })
            .filter(function(feature) {
              return defined(feature);
            });
        }

        that.currentViewer.pickFromLocation(
          initSource.pickedFeatures.pickCoords,
          initSource.pickedFeatures.providerCoords,
          vectorFeatures
        );

        that.pickedFeatures.allFeaturesAvailablePromise.then(function() {
          that.pickedFeatures.features.forEach(function(entity) {
            var hash = hashEntity(entity, that.clock);
            var feature = entity;
            featureIndex[hash] = featureIndex[hash]
              ? featureIndex[hash].concat([feature])
              : [feature];
          });

          if (defined(initSource.pickedFeatures.current)) {
            var selectedFeatureMatches = defaultValue(
              featureIndex[initSource.pickedFeatures.current.hash],
              []
            ).filter(function(feature) {
              return feature.name === initSource.pickedFeatures.current.name;
            });

            that.selectedFeature =
              selectedFeatureMatches.length && selectedFeatureMatches[0];
          }
        });
      };

      if (that.currentViewer !== that._noViewer) {
        loadPickedFeatures();
      } else {
        removeViewLoadedListener = that.afterViewerChanged.addEventListener(
          loadPickedFeatures
        );
      }
    });
  }
  return promise;
};

Terria.prototype.getLocalProperty = function(key) {
  try {
    if (!defined(window.localStorage)) {
      return undefined;
    }
  } catch (e) {
    // SecurityError can arise if 3rd party cookies are blocked in Chrome and we're served in an iFrame
    return undefined;
  }
  var v = window.localStorage.getItem(this.appName + "." + key);
  if (v === "true") {
    return true;
  } else if (v === "false") {
    return false;
  }
  return v;
};

Terria.prototype.setLocalProperty = function(key, value) {
  try {
    if (!defined(window.localStorage)) {
      return undefined;
    }
  } catch (e) {
    return undefined;
  }
  window.localStorage.setItem(this.appName + "." + key, value);
  return true;
};

/**
 * Returns the side of the splitter the `position` lies on.
 *
 * @param {(Cartesian2|Cartesian3)} The screen position.
 * @return {ImagerySplitDirection} The side of the splitter on which `position` lies.
 */
Terria.prototype.getSplitterSideForScreenPosition = function(position) {
  var splitterX =
    this.currentViewer.getContainer().clientWidth * this.splitPosition;
  if (position.x <= splitterX) {
    return ImagerySplitDirection.LEFT;
  } else {
    return ImagerySplitDirection.RIGHT;
  }
};

/**
 * Returns the side of the splitter the `position` lies on.
 *
 * @param {(Cartesian2|Cartesian3)} The screen position.
 * @return {ImagerySplitDirection} The side of the splitter on which `position` lies.
 */
Terria.prototype.checkNowViewingForTimeWms = function() {
  const hasTimeWms =
    this.nowViewing &&
    this.nowViewing.items.reduce((acc, item) => {
      if (acc) return true;
      try {
        return (
          item.type === "wms" &&
          defined(item.clock) &&
          defined(item.availableDates) &&
          item.availableDates.length !== 0
        );
      } catch {
        return false;
      }
    }, false);
  if (hasTimeWms) {
    this.shouldStartSatelliteGuidance = true;
  }
  return hasTimeWms;
};

var latestStartVersion = "0.0.05";

function interpretHash(
  terria,
  hashProperties,
  userProperties,
  persistentInitSources,
  temporaryInitSources
) {
  var promise;
  // #share=xyz . Resolve with either share data service or URL shortener.
  if (defined(hashProperties.share)) {
    if (defined(terria.shareDataService)) {
      // get JSON directly
      promise = terria.shareDataService.resolveData(hashProperties.share);
    } else if (defined(terria.urlShortener)) {
      promise = terria.urlShortener
        .expand(hashProperties.share)
        // get URL, and extract the JSON part
        .then(longUrl => longUrl && queryToObject(new URI(longUrl).fragment()));
    }
  }

  return when(promise, function(shareProps) {
    Object.keys(hashProperties).forEach(function(property) {
      var propertyValue = hashProperties[property];

      if (property === "clean") {
        persistentInitSources.length = 0;
        temporaryInitSources.length = 0;
      } else if (property === "start") {
        // a share link that hasn't been shortened: JSON embedded in URL (only works for small quantities of JSON)
        var startData = JSON.parse(propertyValue);
        interpretStartData(
          terria,
          startData,
          persistentInitSources,
          temporaryInitSources
        );
      } else if (defined(propertyValue) && propertyValue.length > 0) {
        userProperties[property] = propertyValue;
        knockout.track(userProperties, [property]);
      } else {
        var initSourceFile = generateInitializationUrl(property);
        persistentInitSources.push(initSourceFile);
        temporaryInitSources.push(initSourceFile);
      }
    });
    if (shareProps) {
      interpretStartData(
        terria,
        shareProps,
        persistentInitSources,
        temporaryInitSources
      );
    }
  });
}

function interpretStartData(
  terria,
  startData,
  persistentInitSources,
  temporaryInitSources
) {
  if (defined(startData.version) && startData.version !== latestStartVersion) {
    adjustForBackwardCompatibility(startData);
  }

  if (defined(terria.filterStartDataCallback)) {
    startData = terria.filterStartDataCallback(startData) || startData;
  }

  // Include any initSources specified in the URL.
  if (defined(startData.initSources)) {
    for (var i = 0; i < startData.initSources.length; ++i) {
      var initSource = startData.initSources[i];
      // avoid loading terria.json twice
      if (
        temporaryInitSources.indexOf(initSource) < 0 &&
        !initFragmentExists(temporaryInitSources, initSource)
      ) {
        temporaryInitSources.push(initSource);
        // Only add external files to the application's list of init sources.
        if (
          typeof initSource === "string" &&
          persistentInitSources.indexOf(initSource) < 0
        ) {
          persistentInitSources.push(initSource);
        }
      }
    }
  }
}

function initFragmentExists(temporaryInitSources, initSource) {
  if (initSource.initFragment && typeof initSource.initFragment === "string") {
    if (
      findIndex(
        temporaryInitSources,
        init => init.initFragment === initSource.initFragment
      ) >= 0
    ) {
      return true;
    }
  }
  return false;
}

function generateInitializationUrl(url) {
  if (url.toLowerCase().substring(url.length - 5) !== ".json") {
    return {
      initFragment: url
    };
  }
  return url;
}

function loadInitSources(terria, initSources) {
  return initSources.reduce(function(promiseSoFar, initSource) {
    return promiseSoFar
      .then(loadInitSource.bind(undefined, terria, initSource))
      .then(function(initSource) {
        if (defined(initSource)) {
          return terria.addInitSource(initSource);
        }
      });
  }, when());
}

function loadInitSource(terria, source) {
  if (defined(source.stories)) terria.stories = source.stories;
  let loadSource;
  let loadPromise;
  if (typeof source === "string") {
    loadSource = source;
    loadPromise = loadJson5(terria.corsProxy.getURLProxyIfNecessary(source));
  } else if (
    typeof source === "object" &&
    typeof source.initFragment === "string"
  ) {
    const fragment = source.initFragment;
    const fragmentPaths = terria.configParameters.initFragmentPaths;
    if (fragmentPaths.length === 0) {
      terria.error.raiseEvent({
        title: i18next.t("models.terria.loadingInitSourceErrorTitle"),
        message: i18next.t("models.terria.loadingInitSourceErrorMessage", {
          fragment: fragment
        })
      });
      return undefined;
    }

    loadSource = fragmentPaths
      .map(path => buildInitUrlFromFragment(path, fragment))
      .join(", ");

    loadPromise = loadJson5(
      terria.corsProxy.getURLProxyIfNecessary(
        buildInitUrlFromFragment(fragmentPaths[0], fragment)
      )
    );

    for (let i = 1; i < fragmentPaths.length; ++i) {
      loadPromise = loadPromise.otherwise(function() {
        return loadJson5(
          terria.corsProxy.getURLProxyIfNecessary(
            buildInitUrlFromFragment(fragmentPaths[i], fragment)
          )
        );
      });
    }
  }

  if (loadPromise) {
    return loadPromise
      .then(function(initSource) {
        initSource.isFromExternalFile = true;
        return initSource;
      })
      .otherwise(function() {
        terria.error.raiseEvent({
          title: i18next.t("models.terria.loadingInitSourceErrorTitle"),
          message: i18next.t("models.terria.loadingInitSourceError2Message", {
            loadSource: loadSource
          })
        });
        return undefined;
      });
  } else {
    return source;
  }
}

function buildInitUrlFromFragment(path, fragment) {
  return path + fragment + ".json";
}

function adjustForBackwardCompatibility(startData) {
  if (startData.version === "0.0.03") {
    // In this version, there was just a single 'camera' property instead of a 'homeCamera' and 'initialCamera'.
    // Treat the one property as the initialCamera.
    for (var i = 0; i < startData.initSources.length; ++i) {
      if (defined(startData.initSources[i].camera)) {
        startData.initSources[i].initialCamera =
          startData.initSources[i].camera;
        startData.initSources[i].camera = undefined;
      }
    }
  }
}

function showDisclaimer(
  terria,
  globalDisclaimerHtml,
  developmentDisclaimerPreambleHtml
) {
  // Show a modal disclaimer before user can do anything else.
  if (
    defined(terria.configParameters.globalDisclaimer) &&
    defined(globalDisclaimerHtml)
  ) {
    var globalDisclaimer = terria.configParameters.globalDisclaimer;
    var hostname = window.location.hostname;
    var enabled =
      !defined(globalDisclaimer.enabled) || globalDisclaimer.enabled;
    if (
      enabled &&
      (globalDisclaimer.enableOnLocalhost ||
        hostname.indexOf("localhost") === -1)
    ) {
      var message = "";
      // Sometimes we want to show a preamble if the user is viewing a site other than the official production instance.
      // This can be expressed as a devHostRegex ("any site starting with staging.") or a negative prodHostRegex ("any site not ending in .gov.au")
      if (defined(developmentDisclaimerPreambleHtml)) {
        if (
          (defined(globalDisclaimer.devHostRegex) &&
            hostname.match(globalDisclaimer.devHostRegex)) ||
          (defined(globalDisclaimer.prodHostRegex) &&
            !hostname.match(globalDisclaimer.prodHostRegex))
        ) {
          message += developmentDisclaimerPreambleHtml;
        }
      }

      message += globalDisclaimerHtml;

      var options = {
        title:
          globalDisclaimer.title !== undefined
            ? globalDisclaimer.title
            : i18next.t("models.terria.disclaimer"),
        confirmText:
          globalDisclaimer.buttonTitle ||
          i18next.t("models.terria.buttonTitleConfirm"),
        width: globalDisclaimer.width || 600,
        height: globalDisclaimer.height || 550,
        message: message,
        hideUi: globalDisclaimer.hideUi,
        type: "disclaimer"
      };

      terria.error.raiseEvent(options);
    }
  }
}

module.exports = Terria;