Source: src/js/views/maps/CesiumWidgetView.js

'use strict';

define(
  [
    'jquery',
    'underscore',
    'backbone',
    'cesium',
    'models/maps/Map',
    'models/maps/assets/MapAsset',
    'models/maps/assets/Cesium3DTileset',
    'models/maps/Feature',
    'text!templates/maps/cesium-widget-view.html'
  ],
  function (
    $,
    _,
    Backbone,
    Cesium,
    Map,
    MapAsset,
    Cesium3DTileset,
    Feature,
    Template
  ) {

    /**
    * @class CesiumWidgetView
    * @classdesc An interactive 2D and/or 3D map/globe rendered using CesiumJS. This view
    * comprises the globe without any of the UI elements like the scalebar, layer list,
    * etc.
    * @classcategory Views/Maps
    * @name CesiumWidgetView
    * @extends Backbone.View
    * @screenshot views/maps/CesiumWidgetView.png
    * @since 2.18.0
    * @constructs
    */
    var CesiumWidgetView = Backbone.View.extend(
      /** @lends CesiumWidgetView.prototype */{

        /**
        * The type of View this is
        * @type {string}
        */
        type: 'CesiumWidgetView',

        /**
        * The HTML classes to use for this view's element. Note that the first child
        * element added to this view by cesium will have the class "cesium-widget".
        * @type {string}
        */
        className: 'cesium-widget-view',

        /**
        * The model that this view uses
        * @type {Map}
        */
        model: null,

        /**
         * The primary HTML template for this view
         * @type {Underscore.template}
         */
        template: _.template(Template),

        /**
         * An array of objects the match a Map Asset's type property to the function in
         * this view that adds and renders that asset on the map, given the Map Asset
         * model. Each object in the array has two properties: 'types' and
         * 'renderFunction'.
         * @type {Object[]}
         * @property {string[]} types The list of types that can be added to the map given
         * the renderFunction
         * @property {string} renderFunction The name of the function in the view that
         * will add the asset to the map and render it, when passed the cesiumModel
         * attribute from the MapAsset model
         */
        mapAssetRenderFunctions: [
          {
            types: ['Cesium3DTileset'],
            renderFunction: 'add3DTileset'
          },
          {
            types: ['GeoJsonDataSource'],
            renderFunction: 'addVectorData'
          },
          {
            types: ['BingMapsImageryProvider', 'IonImageryProvider', 'TileMapServiceImageryProvider', 'WebMapTileServiceImageryProvider', 'WebMapServiceImageryProvider'],
            renderFunction: 'addImagery'
          },
          {
            types: ['CesiumTerrainProvider'],
            renderFunction: 'updateTerrain'
          },
          {
            types: ['CesiumGeohash'],
            renderFunction: 'addGeohashes'
          }
        ],

        /**
         * The border color to use on vector features that a user clicks.
         * See {@link https://cesium.com/learn/cesiumjs/ref-doc/Color.html?classFilter=color}
         * @type {Cesium.Color}
         */
        // TODO - Consider making this color configurable in the Map model
        highlightBorderColor: Cesium.Color.WHITE,

        /**
        * Executed when a new CesiumWidgetView is created
        * @param {Object} [options] - A literal object with options to pass to the view
        */
        initialize: function (options) {
          try {

            // Set the Cesium Ion token (required for some map features)
            Cesium.Ion.defaultAccessToken = MetacatUI.appModel.get('cesiumToken');

            // Get all the options and apply them to this view
            if (typeof options == 'object') {
              for (const [key, value] of Object.entries(options)) {
                this[key] = value;
              }
            }

            // Make sure that there is a Map model and that it has a selectedFeature
            // attribute. The selectedFeature attribute is used to store information about
            // the vector feature, if any, that is currently in focus on the map.
            if (!this.model) {
              this.model = new Map()
            }
            if (!this.model.get('selectedFeatures')) {
              this.model.selectFeatures()
            }


          } catch (e) {
            console.log('Failed to initialize a CesiumWidgetView. Error message: ' + e);
          }

        },

        /**
        * Renders this view
        * @return {CesiumWidgetView} Returns the rendered view element
        */
        render: function () {

          try {

            // If Cesium features are disabled in the AppConfig, then exit without rendering
            // anything.
            if (!MetacatUI.appModel.get('enableCesium')) {
              return;
            }

            // Save a reference to this view
            const view = this;

            // Insert the template into the view
            view.$el.html(view.template({}));

            // Ensure the view's main element has the given class name
            view.el.classList.add(view.className);

            // Clock will be used for the timeline component, and for the clock.ontick
            // event
            view.clock = new Cesium.Clock({ shouldAnimate: false })

            // Create the Cesium Widget and save a reference to it to the view
            view.widget = new Cesium.CesiumWidget(view.el, {
              clock: view.clock,
              // We will add a base imagery layer after initialization
              imageryProvider: false,
              terrain: false,
              // Use explicit rendering to make the widget must faster.
              // See https://cesium.com/blog/2018/01/24/cesium-scene-rendering-performance
              requestRenderMode: true,
              // Need to change the following once we support a time/clock component.
              // See https://cesium.com/blog/2018/01/24/cesium-scene-rendering-performance/#handling-simulation-time-changes.
              maximumRenderTimeChange: Infinity
            });

            // Save references to parts of the widget that the view will access often
            view.scene = view.widget.scene;
            view.camera = view.widget.camera;
            view.inputHandler = view.widget.screenSpaceEventHandler;

            // Decrease the amount the camera must change before the changed event is
            // raised.
            view.camera.percentChanged = 0.1

            // Zoom functions executed after each scene render
            view.scene.postRender.addEventListener(function () {
              view.postRender();
            });

            // Disable HDR lighting for better performance and to avoid changing imagery colors.
            view.scene.highDynamicRange = false;
            view.scene.globe.enableLighting = false;

            // Keep all parts of the globe lit regardless of what time the Cesium clock is
            // set to. This avoids data and imagery appearing too dark.
            view.scene.light = new Cesium.DirectionalLight({
              direction: new Cesium.Cartesian3(1, 0, 0)
            });
            view.scene.preRender.addEventListener(function (scene, time) {
              view.scene.light.direction = Cesium.Cartesian3.clone(
                scene.camera.directionWC, view.scene.light.direction
              );
            });

            // Prepare Cesium to handle vector datasources (e.g. geoJsonDataSources)
            view.dataSourceCollection = new Cesium.DataSourceCollection();
            view.dataSourceDisplay = new Cesium.DataSourceDisplay({
              scene: view.scene,
              dataSourceCollection: view.dataSourceCollection,
            });
            view.clock.onTick.addEventListener(function () {
              view.updateDataSourceDisplay.call(view)
            })

            // Go to the home position, if one is set.
            view.flyHome()

            // If users are allowed to click on features for more details, initialize
            // picking behavior on the map.
            if (view.model.get('showFeatureInfo')) {
              view.initializePicking()
            }

            // Set listeners for when the Cesium camera changes a significant amount.
            view.camera.changed.addEventListener(function () {
              // Update the bounding box for the visible area in the Map model
              view.updateViewExtent()
              // If the scale bar is showing, update the pixel to meter scale on the map
              // model when the camera angle/zoom level changes
              if (view.model.get('showScaleBar')) {
                view.updateCurrentScale()
              }
            })

            // Sets listeners for when the mouse moves, depending on the value of the map
            // model's showScaleBar and showFeatureInfo attributes
            view.setMouseMoveListeners()

            // When the appearance of a layer has been updated, then tell Cesium to
            // re-render the scene. Each layer model triggers the 'appearanceChanged'
            // function whenever the color, opacity, etc. has been updated in the
            // associated Cesium model.
            view.stopListening(view.model.get('layers'), 'appearanceChanged')
            view.listenTo(view.model.get('layers'), 'appearanceChanged', view.requestRender)

            // Other views may trigger an event on the layer/asset model that indicates
            // that the map should navigate to the extent of the data, or on the Map model
            // to navigate to the home position.
            view.stopListening(view.model.get('layers'), 'flyToExtent')
            view.listenTo(view.model.get('layers'), 'flyToExtent', view.flyTo)
            view.stopListening(view.model, 'flyHome')
            view.listenTo(view.model, 'flyHome', view.flyHome)

            // Add each layer from the Map model to the Cesium widget. Render using the
            // function configured in the View's mapAssetRenderFunctions property. Add in
            // reverse order for layers to appear in the correct order on the map.
            const layers = view.model.get('layers')
            _.each(layers.last(layers.length).reverse(), function (mapAsset) {
              view.addAsset(mapAsset)
            });

            // The Cesium Widget will support just one terrain option to start. Later,
            // we'll allow users to switch between terrains if there is more than one.
            var terrains = view.model.get('terrains')
            var terrainModel = terrains ? terrains.first() : false;
            if (terrainModel) {
              view.addAsset(terrainModel)
            }

            return this

          }
          catch (error) {
            console.log(
              'Failed to render a CesiumWidgetView. Error details: ' + error
            );
          }
        },

        /**
         * Because the Cesium widget is configured to use explicit rendering (see
         * {@link https://cesium.com/blog/2018/01/24/cesium-scene-rendering-performance/}),
         * we need to tell Cesium when to render a new frame if it's not one of the cases
         * handle automatically. This function tells the Cesium scene to render, but is
         * limited by the underscore.js debounce function to only happen a maximum of once
         * every 50 ms (see {@link https://underscorejs.org/#debounce}).
         */
        requestRender: _.debounce(function () {
          this.scene.requestRender()
        }, 50),

        /**
         * Functions called after each time the scene renders. If a zoom target has been
         * set by the {@link CesiumWidgetView#flyTo} function, then calls the functions
         * that calculates the bounding sphere and zooms to it (which required to visual
         * elements to be rendered first.)
         */
        postRender: function () {
          try {
            if (this.zoomTarget) {
              this.completeFlight(this.zoomTarget, this.zoomOptions)
              this.zoomTarget = null;
              this.zoomOptions = null;
            }
          }
          catch (error) {
            console.log(
              'There was an error calling post render functions in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Runs on every Cesium clock tick. Updates the display of the CesiumVectorData
         * models in the scene. Similar to Cesium.DataSourceDisplay.update function, in
         * that it runs update() on each DataSource and each DataSource's visualizer,
         * except that it also updates each CesiumVectorData model's 'displayReady'
         * attribute. (Sets to true when the asset is ready to be rendered in the map,
         * false otherwise). Also re-renders the scene when the displayReady attribute
         * changes. 
         */
        updateDataSourceDisplay: function () {
          try {
            const view = this;

            var dataSources = view.dataSourceDisplay.dataSources;
            if (!dataSources || !dataSources.length) {
              return
            }

            let allReady = true;
            const allReadyBefore = view.dataSourceDisplay._ready;

            for (let i = 0, len = dataSources.length; i < len; i++) {

              const time = view.clock.currentTime;
              const dataSource = dataSources.get(i);
              const visualizers = dataSource._visualizers;

              const assetModel = view.model.get('layers').findWhere({
                cesiumModel: dataSource
              })
              const displayReadyBefore = assetModel.get('displayReady')
              let displayReadyNow = dataSource.update(time)

              for (let x = 0; x < visualizers.length; x++) {
                displayReadyNow = visualizers[x].update(time) && displayReadyNow;
              }

              assetModel.set('displayReady', displayReadyNow)

              allReady = displayReadyNow && allReady

            }

            // If any dataSource has switched display states, then re-render the scene.
            if (allReady !== allReadyBefore) {
              view.scene.requestRender()
            }
            // The dataSourceDisplay must be set to 'ready' to get bounding spheres for
            // dataSources
            view.dataSourceDisplay._ready = allReady

          }
          catch (error) {
            console.log(
              'There was an error updating the data source display in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Set up the Cesium scene and set listeners and behavior that enable users to
         * click on vector features on the map to view more information about them.
         */
        initializePicking: function () {
          try {
            // Save a reference to this view the Cesium scene
            var view = this;
            var scene = this.scene

            // To add an outline to 3D tiles in Cesium, we 'silhouette' them. Set up the the
            // scene to support silhouetting.
            view.silhouettes = Cesium.PostProcessStageLibrary.createEdgeDetectionStage();
            view.silhouettes.uniforms.color = view.highlightBorderColor;
            view.silhouettes.uniforms.length = 0.02;
            view.silhouettes.selected = [];
            scene.postProcessStages.add(
              Cesium.PostProcessStageLibrary.createSilhouetteStage([view.silhouettes])
            );
            // When any Feature models in the Map model's selectedFeature collection are
            // changed, added, or removed, update silhouetting of 3D tiles.
            function setSelectedFeaturesListeners() {
              const selectedFeatures = view.model.get('selectedFeatures')
              view.stopListening(selectedFeatures, 'update')
              view.listenTo(selectedFeatures, 'update', function () {
                // Remove highlights from previously selected 3D tiles
                view.silhouettes.selected = []
                // Highlight the newly selected 3D tiles
                selectedFeatures
                  .getFeatureObjects('Cesium3DTileFeature')
                  .forEach(function (featureObject) {
                    view.silhouettes.selected.push(featureObject)
                  })
              })
            }

            setSelectedFeaturesListeners()
            // If the Selected Features collection is ever completely replaced for any
            // reason, make sure to reset the listeners onto the new collection
            view.stopListening(view.model, 'change:selectedFeatures')
            view.listenTo(view.model, 'change:selectedFeatures', setSelectedFeaturesListeners)

            // When a feature is clicked update the Map model's `selectedFeatures`
            // collection with the newly selected features. This will also trigger an
            // event to update styling of map assets with selected features, and tells the
            // parent map view to open the feature details panel.
            view.inputHandler.setInputAction(function (movement) {
              var pickedFeature = scene.pick(movement.position);
              view.updateSelectedFeatures([pickedFeature])
            }, Cesium.ScreenSpaceEventType.LEFT_CLICK);

          }
          catch (error) {
            console.log(
              'There was an error initializing picking in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Given a feature from a vector layer (e.g. a Cesium3DTileFeature), gets any
         * properties that are associated with that feature, the MapAsset model that
         * contains the feature, and the ID that Cesium uses to identify it, and updates
         * the Features collection that is set on the Map's `selectedFeatures` attribute
         * with a new Feature model. NOTE: This currently only works with 3D tile
         * features.
         * @param {Cesium.Cesium3DTileFeature[]} features - An array of Cesium3DTileFeatures to
         * select
        */
        updateSelectedFeatures: function (features) {

          try {
            const view = this
            const layers = view.model.get('layers')

            // Don't update the selected features collection if the newly selected
            // features are identical
            const oldFeatures = view.model.get('selectedFeatures').getFeatureObjects()
            const noChange = _.isEqual(_.sortBy(features), _.sortBy(oldFeatures))
            if (noChange) {
              return;
            }

            // Properties of the selected features to pass to the Map model's
            // selectFeatures function. Passing null will empty the map's selectedFeatures
            // collection
            let featuresAttrs = features ? [] : null
            if (!features || !Array.isArray(features)) {
              features = []
            }

            features.forEach(function (feature) {
              if (feature) {
                // To find corresponding MapAsset model in the layers collection
                let cesiumModel = null
                // Attributes to make a new Feature model
                const attrs = {
                  properties: {},
                  mapAsset: null,
                  featureID: null,
                  featureObject: feature,
                  label: null,
                }
                if (feature instanceof Cesium.Cesium3DTileFeature) {
                  // Cesium.Cesium3DTileFeature.primitive gives the Cesium.Cesium3DTileset
                  cesiumModel = feature.primitive
                  attrs.featureID = feature.pickId ? feature.pickId.key : null
                  // Search for a property to use as a label
                  attrs.label = feature.getProperty('name') || feature.getProperty('label') || null
                } else {
                  // TODO: Test - does feature.id give the entity this work for all datasources ?
                  // A picked feature object's ID gives the Cesium.Entity
                  attrs.featureObject = feature.id
                  // Gives the parent DataSource
                  cesiumModel = attrs.featureObject.entityCollection.owner
                  attrs.featureID = attrs.featureObject.id
                  attrs.label = attrs.featureObject.name
                }

                attrs.mapAsset = layers.findWhere({
                  cesiumModel: cesiumModel
                })

                if (
                  attrs.mapAsset &&
                  typeof attrs.mapAsset.getPropertiesFromFeature === 'function'
                ) {
                  attrs.properties = attrs.mapAsset.getPropertiesFromFeature(attrs.featureObject)
                }

                featuresAttrs.push(attrs)
              }
            })

            // Pass the new information to the Map's selectFeatures function, which will
            // update the selectFeatures collection set on the Map model
            view.model.selectFeatures(featuresAttrs)

          }
          catch (error) {
            console.log(
              'There was an error updating the selected features collection from a ' +
              'CesiumWidgetView. Error details: ' + error
            );
          }
        },

        /**
         * Move the camera position and zoom to the specified target entity or position on
         * the map, using a nice animation. This function starts the flying/zooming
         * action by setting a zoomTarget and zoomOptions on the view and requesting the
         * scene to render. The actual zooming is done by
         * {@link CesiumWidgetView#completeFlight} after the scene has finished rendering.
         * @param {MapAsset|Cesium.BoundingSphere|Object|Feature} target The target asset,
         * bounding sphere, or location to change the camera focus to. If target is a
         * MapAsset, then the bounding sphere from that asset will be used for the target
         * destination. If target is an Object, it may contain any of the properties that
         * are supported by the Cesium camera flyTo options, see
         * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyTo}. If the
         * target is a Feature, then it must be a Feature of a CesiumVectorData layer
         * (currently Cesium3DTileFeatures are not supported). The target can otherwise be
         * a Cesium BoundingSphere, see
         * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html}
         * @param {object} options - For targets that are a bounding sphere or asset,
         * options to pass to Cesium Camera.flyToBoundingSphere(). See
         * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyToBoundingSphere}.
         */
        flyTo: function (target, options) {
          this.zoomTarget = target;
          this.zoomOptions = options;
          this.requestRender();
        },

        /**
         * This function is called by {@link CesiumWidgetView#postRender}; it should only
         * be called once the target has been fully rendered in the scene. This function
         * gets the bounding sphere, if required, and moves the scene to encompass the
         * full extent of the target.
         * @param {MapAsset|Cesium.BoundingSphere|Object|Feature} target The target asset,
         * bounding sphere, or location to change the camera focus to. If target is a
         * MapAsset, then the bounding sphere from that asset will be used for the target
         * destination. If target is an Object, it may contain any of the properties that
         * are supported by the Cesium camera flyTo options, see
         * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyTo}. If the
         * target is a Feature, then it must be a Feature of a CesiumVectorData layer
         * (currently Cesium3DTileFeatures are not supported). The target can otherwise be
         * a Cesium BoundingSphere, see
         * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html}
         * @param {object} options - For targets that are a bounding sphere or asset,
         * options to pass to Cesium Camera.flyToBoundingSphere(). See
         * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyToBoundingSphere}.
         */
        completeFlight: function (target, options) {

          try {

            const view = this;
            if (typeof options !== 'object') {
              options = {}
            }

            // A target is required
            if (!target) {
              return
            }

            // If the target is a Bounding Sphere, use the camera's built-in function
            if (target instanceof Cesium.BoundingSphere) {
              view.camera.flyToBoundingSphere(target, options)
              return
            }

            // If the target is some type of map asset, then get a Bounding Sphere for
            // that asset and call this function again.
            if (target instanceof MapAsset && typeof target.getBoundingSphere === 'function') {
              // Pass the dataSourceDisplay for CesiumVectorData models
              target.getBoundingSphere(view.dataSourceDisplay)
                .then(function (assetBoundingSphere) {
                  // Base value offset required to zoom in close enough to 3D tiles for
                  // them to render.
                  if ((target instanceof Cesium3DTileset) && !Cesium.defined(options.offset)) {
                    options.offset = new Cesium.HeadingPitchRange(
                      0.0, -0.5, assetBoundingSphere.radius
                    )
                  }
                  view.flyTo(assetBoundingSphere, options)
                })
              return
            }

            // Note: This doesn't work yet for Cesium3DTilesetFeatures -
            // Cesium.BoundingSphereState gets stuck in "PENDING" and never resolves.
            // There's no native way of getting the bounding sphere or location from a
            // 3DTileFeature!
            if (target instanceof Feature) {
              // If the target is a Feature, get the Bounding Sphere for the Feature
              // and call this function again.
              const feature = target.get('featureObject')
              let featureBoundingSphere = new Cesium.BoundingSphere();
              view.dataSourceDisplay.getBoundingSphere(
                feature, false, featureBoundingSphere
              )
              setTimeout(() => {
                view.flyTo(featureBoundingSphere, options)
              }, 0);

              return
            }

            // If not a Map Asset or a BoundingSphere, then the target must be an Object.
            // Assume target are options for the Cesium camera flyTo function
            if (typeof target === 'object') {
              view.camera.flyTo(target)
            }

          }
          catch (error) {
            console.log(
              'There was an error navigating to a target position in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Navigate to the homePosition that's set on the Map.
         */
        flyHome: function () {
          try {
            var position = this.model.get('homePosition')

            if (position && Cesium.defined(position.longitude) && Cesium.defined(position.latitude)) {

              // Set a default height (elevation) if there isn't one set
              if (!Cesium.defined(position.height)) {
                position.height = 1000000;
              }

              const target = {}
              target.destination = Cesium.Cartesian3.fromDegrees(
                position.longitude,
                position.latitude,
                position.height
              )

              if (
                Cesium.defined(position.heading) &&
                Cesium.defined(position.pitch) &&
                Cesium.defined(position.roll)
              ) {
                target.orientation = {
                  heading: Cesium.Math.toRadians(position.heading),
                  pitch: Cesium.Math.toRadians(position.pitch),
                  roll: Cesium.Math.toRadians(position.roll)
                }
              }

              this.flyTo(target);
            }
          }
          catch (error) {
            console.log(
              'There was an error navigating to the home position in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Get the current positioning of the camera in the view.
         * @returns {MapConfig#CameraPosition} Returns an object with the longitude, latitude,
         * height, heading, pitch, and roll in the same format that the Map model uses
         * for the homePosition (see {@link Map#defaults})
        */
        getCameraPosition: function () {
          try {
            var camera = this.camera
            var cameraPosition = Cesium.Cartographic.fromCartesian(camera.position)

            return {
              longitude: Cesium.Math.toDegrees(cameraPosition.longitude),
              latitude: Cesium.Math.toDegrees(cameraPosition.latitude),
              height: cameraPosition.height,
              heading: Cesium.Math.toDegrees(camera.heading),
              pitch: Cesium.Math.toDegrees(camera.pitch),
              roll: Cesium.Math.toDegrees(camera.roll)
            }

          }
          catch (error) {
            console.log(
              'There was an error getting the current position in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Update the 'currentViewExtent' attribute in the Map model with the north,
         * south, east, and west-most lat/long that define a bounding box around the
         * currently visible area of the map.
         */
        updateViewExtent: function () {
          try {
            const view = this;
            const camera = view.camera;
            const scene = view.scene;

            // This will be the bounding box of the visible area
            let coords = { north: null, south: null, east: null, west: null }

            // First try getting the visible bounding box using the simple method
            if (!view.scratchRectangle) {
              // Store the rectangle that we use for the calculation (reduces pressure on
              // garbage collector system since this function is called often).
              view.scratchRectangle = new Cesium.Rectangle();
            }
            var rect = camera.computeViewRectangle(
              scene.globe.ellipsoid, view.scratchRectangle
            );
            coords.north = Cesium.Math.toDegrees(rect.north)
            coords.east = Cesium.Math.toDegrees(rect.east)
            coords.south = Cesium.Math.toDegrees(rect.south)
            coords.west = Cesium.Math.toDegrees(rect.west)

            // Check if the resulting coordinates cover the entire globe (happens if some of
            // the sky is visible)

            const fullGlobeCoverage = coords.west === -180 && coords.east === 180 &&
              coords.south === -90 && coords.north === 90

            // See if we can limit the bounding box to a smaller extent
            if (fullGlobeCoverage) {

              // Find points at the top, bottom, right, and left corners of the globe
              const edges = view.findEdges()

              // Get the midPoint between the top and bottom points on the globe. Use this
              // to decide if the northern or southern hemisphere is more in view.
              let midPoint = view.findMidpoint(edges.top, edges.bottom)
              if (midPoint) {

                // Get the latitude of the mid point
                const midPointLat = view.getDegreesFromCartesian(midPoint).latitude

                // Get the latitudes of all the edge points so that we can calculate the
                // southern and northern most coordinate
                const edgeLatitudes = []
                Object.values(edges).forEach(function (point) {
                  if (point) {
                    edgeLatitudes.push(
                      view.getDegreesFromCartesian(point).latitude
                    )
                  }
                })

                if (midPointLat > 0) {
                  // If the midPoint is in the northern hemisphere, limit the southern part
                  // of the bounding box to the southern most edge point latitude
                  coords.south = Math.min(...edgeLatitudes)
                } else {
                  // Vice versa for the southern hemisphere
                  coords.north = Math.max(...edgeLatitudes)
                }
              }

              // If not focused directly on one of the poles, then also limit the east and
              // west sides of the bounding box
              const northPointLat = view.getDegreesFromCartesian(edges.top).latitude
              const southPointLat = view.getDegreesFromCartesian(edges.bottom).latitude

              if (northPointLat > 25 && southPointLat < -25) {
                if (edges.right) {
                  coords.east = view.getDegreesFromCartesian(edges.right).longitude
                }
                if (edges.left) {
                  coords.west = view.getDegreesFromCartesian(edges.left).longitude
                }
              }
            }

            view.model.set('currentViewExtent', coords)

          }
          catch (error) {
            console.log(
              'Failed to update the Map view extent from a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Get longitude and latitude degrees from a cartesian point.
         * @param {Cesium.Cartesian3} cartesian - The point to get degrees for
         * @returns Returns an object with the longitude and latitude in degrees, as well
         * as the height in meters
         */
        getDegreesFromCartesian: function (cartesian) {
          const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
          return {
            longitude: Cesium.Math.toDegrees(cartographic.longitude),
            latitude: Cesium.Math.toDegrees(cartographic.latitude),
            height: cartographic.height
          }
        },

        /**
         * Find four points that exist on the globe that are closest to the top-center,
         * bottom-center, right-middle, and left-middle points of the screen. Note that
         * these are not necessarily the northern, southern, eastern, and western -most
         * points, since the map may be oriented in any direction (e.g. facing the north
         * pole).
         *
         * @returns {Cesium.Cartesian3[]} Returns an object with the top, bottom, left,
         * and right points of the globe.
         */
        findEdges: function () {
          try {
            const view = this;
            const canvas = view.scene.canvas
            const maxX = canvas.clientWidth;
            const maxY = canvas.clientHeight;
            const midX = (maxX / 2) | 0;
            const midY = (maxY / 2) | 0;

            // Points at the extreme edges of the cesium canvas. These may not be points on
            // the globe (i.e. they could be in the sky)
            const topCanvas = new Cesium.Cartesian2(midX, 0)
            const rightCanvas = new Cesium.Cartesian2(maxX, midY)
            const bottomCanvas = new Cesium.Cartesian2(midX, maxY)
            const leftCanvas = new Cesium.Cartesian2(0, midY)

            // Find the real world coordinate that is closest to the canvas edge points
            const points = {
              top: view.findPointOnGlobe(topCanvas, bottomCanvas),
              right: view.findPointOnGlobe(rightCanvas, leftCanvas),
              bottom: view.findPointOnGlobe(bottomCanvas, topCanvas),
              left: view.findPointOnGlobe(leftCanvas, rightCanvas),
            }

            return points
          }
          catch (error) {
            console.log(
              'There was an error finding the edge points in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Given two Cartesian3 points, compute the midpoint.
         * @param {Cesium.Cartesian3} p1  The first point
         * @param {Cesium.Cartesian3} p2  The second point
         * @returns {Cesium.Cartesian3 | null} The midpoint or null if p1 or p2 is not
         * defined.
         */
        findMidpoint: function (p1, p2) {
          try {
            if (!p1 || !p2) {
              return null
            }
            // Compute vector from p1 to p2
            let p1p2 = new Cesium.Cartesian3(0.0, 0.0, 0.0);
            Cesium.Cartesian3.subtract(p2, p1, p1p2);

            // Compute vector to midpoint
            let halfp1p2 = new Cesium.Cartesian3(0.0, 0.0, 0.0);
            Cesium.Cartesian3.multiplyByScalar(p1p2, 0.5, halfp1p2);

            // Compute point half way between p1 and p2
            let p3 = new Cesium.Cartesian3(0.0, 0.0, 0.0);
            p3 = Cesium.Cartesian3.add(p1, halfp1p2, p3);

            // Force point onto surface of ellipsoid
            const midPt = Cesium.Cartographic.fromCartesian(p3);
            const p3a = Cesium.Cartesian3.fromRadians(midPt.longitude, midPt.latitude, 0.0);

            return p3a
          }
          catch (error) {
            console.log(
              'There was an error finding a midpoint in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Find a coordinate that exists on the surface of the globe between two Cartesian
         * points. The points do not need to be withing the bounds of the globe/map (i.e.
         * they can be points in the sky). Uses the Bresenham Algorithm to traverse pixels
         * from the first coordinate to the second, until it finds a valid coordinate.
         * @param {Cesium.Cartesian2} startCoordinates The coordinates to start searching,
         * in pixels
         * @param {Cesium.Cartesian2} endCoordinates The coordinates to stop searching, in
         * pixels
         * @returns {Cesium.Cartesian3 | null} Returns the x, y, z coordinates of the
         * first real point, or null if a valid point was not found.
         *
         * @see {@link https://groups.google.com/g/cesium-dev/c/e2H7EefikAk}
         */
        findPointOnGlobe: function (startCoordinates, endCoordinates) {

          const view = this;
          const camera = view.camera;
          const ellipsoid = view.scene.globe.ellipsoid;

          if (!startCoordinates || !endCoordinates) {
            return null
          }

          let coordinate = camera.pickEllipsoid(startCoordinates, ellipsoid);

          // Translate coordinates
          let x1 = startCoordinates.x;
          let y1 = startCoordinates.y;
          const x2 = endCoordinates.x;
          const y2 = endCoordinates.y;
          // Define differences and error check
          const dx = Math.abs(x2 - x1);
          const dy = Math.abs(y2 - y1);
          const sx = (x1 < x2) ? 1 : -1;
          const sy = (y1 < y2) ? 1 : -1;
          let err = dx - dy;

          coordinate = camera.pickEllipsoid({ x: x1, y: y1 }, ellipsoid);
          if (coordinate) {
            return coordinate
          }

          // Main loop
          while (!((x1 == x2) && (y1 == y2))) {
            const e2 = err << 1;
            if (e2 > -dy) {
              err -= dy;
              x1 += sx;
            }
            if (e2 < dx) {
              err += dx;
              y1 += sy;
            }

            coordinate = camera.pickEllipsoid({ x: x1, y: y1 }, ellipsoid);
            if (coordinate) {
              return coordinate
            }
          }

          return null;
        },

        /**
         * Set a Cesium event handler for when the mouse moves. If the scale bar is
         * enabled, then a updates the Map model's current position attribute whenever the
         * mouse moves. If showFeatureInfo is enabled, then changes the cursor to a
         * pointer when it hovers over a feature.
         */
        setMouseMoveListeners: function () {
          try {

            const view = this;

            // Change the cursor to a pointer when it hovers over a clickable feature
            // (e.g. a 3D tile) if picking is enabled.
            const updateCursor = function (mousePosition) {
              var pickedFeature = view.scene.pick(mousePosition);
              if (Cesium.defined(pickedFeature)) {
                view.el.style.cursor = 'pointer';
              } else {
                view.el.style.cursor = 'default';
              }
            }

            // Slow this function down a little. Picking is quite slow.
            const updateCursorThrottled = _.throttle(updateCursor, 150)

            // Update the model with long and lat when the mouse moves, if the map model
            // is set to show the scale bar
            const setCurrentPosition = function (mousePosition) {
              var pickRay = view.camera.getPickRay(mousePosition);
              var cartesian = view.scene.globe.pick(pickRay, view.scene);
              if (cartesian) {
                // Use globe.ellipsoid.cartesianToCartographic ?
                var cartographic = Cesium.Cartographic.fromCartesian(cartesian);
                view.model.set('currentPosition', {
                  latitude: Cesium.Math.toDegrees(cartographic.latitude),
                  longitude: Cesium.Math.toDegrees(cartographic.longitude),
                  height: cartographic.height,
                })
              }
            }

            // Handle mouse move
            this.inputHandler.setInputAction(function (movement) {
              const mousePosition = movement.endPosition;
              if (view.model.get('showScaleBar')) {
                setCurrentPosition(mousePosition)
              }
              if (view.model.get('showFeatureInfo')) {
                updateCursorThrottled(mousePosition)
              }
            }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);

          }
          catch (error) {
            console.log(
              'There was an error setting the mouse listeners in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Update the map model's currentScale attribute, which is used for the scale bar.
         * Finds the distance between two pixels at the *bottom center* of the screen.
         */
        updateCurrentScale: function () {
          try {
            const view = this;
            let currentScale = {
              pixels: null,
              meters: null
            }
            const onePixelInMeters = view.pixelToMeters()
            if (onePixelInMeters || onePixelInMeters === 0) {
              currentScale = {
                pixels: 1,
                meters: onePixelInMeters
              }
            }
            view.model.set('currentScale', currentScale);
          }
          catch (error) {
            console.log(
              'There was an error updating the scale from a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Finds the geodesic distance (in meters) between two points that are 1 pixel
         * apart at the bottom, center of the Cesium canvas. Adapted from TerriaJS. See
         * {@link https://github.com/TerriaJS/terriajs/blob/main/lib/ReactViews/Map/Legend/DistanceLegend.jsx}
         * @returns {number|boolean} Returns the distance on the globe, in meters, that is
         * equivalent to 1 pixel on the screen at the center bottom point of the current
         * scene. Returns false if there was a problem getting the measurement.
         */
        pixelToMeters: function () {
          try {

            const view = this
            const scene = view.scene
            const globe = scene.globe
            const camera = scene.camera

            // For measuring geodesic distances (shortest route between two points on the
            // Earth's surface)
            if (!view.geodesic) {
              view.geodesic = new Cesium.EllipsoidGeodesic();
            }

            // Find two points that are 1 pixel apart at the bottom center of the cesium
            // canvas.
            const width = scene.canvas.clientWidth;
            const height = scene.canvas.clientHeight;

            const left = camera.getPickRay(
              new Cesium.Cartesian2((width / 2) | 0, height - 1)
            );
            const right = camera.getPickRay(
              new Cesium.Cartesian2((1 + width / 2) | 0, height - 1)
            );

            const leftPosition = globe.pick(left, scene);
            const rightPosition = globe.pick(right, scene);

            // A point must exist at both positions to get the distance
            if (!Cesium.defined(leftPosition) || !Cesium.defined(rightPosition)) {
              return false
            }

            // Find the geodesic distance, in meters, between the two points that are 1
            // pixel apart
            const leftCartographic = globe.ellipsoid.cartesianToCartographic(
              leftPosition
            );
            const rightCartographic = globe.ellipsoid.cartesianToCartographic(
              rightPosition
            );

            view.geodesic.setEndPoints(leftCartographic, rightCartographic);

            const onePixelInMeters = view.geodesic.surfaceDistance;

            return onePixelInMeters

          }
          catch (error) {
            console.log(
              'Failed to get a pixel to meters measurement in a CesiumWidgetView' +
              '. Error details: ' + error
            );
            return false
          }
        },

        /**
         * Finds the function that is configured for the given asset model type in the
         * {@link CesiumWidgetView#mapAssetRenderFunctions} array, then renders the asset
         * in the map. If there is a problem rendering the asset (e.g. it is an
         * unsupported type that is not configured in the mapAssetRenderFunctions), then
         * sets the AssetModel's status to error.
         * @param {MapAsset} mapAsset A MapAsset layer to render in the map, such as a
         * Cesium3DTileset or a CesiumImagery model.
         */
        addAsset: function(mapAsset) {
          try {
            if (!mapAsset) {
              return
            }
            var view = this
            var type = mapAsset.get('type')
            // Find the render option from the options configured in the view, given the
            // asset model type
            const renderOption = _.find(view.mapAssetRenderFunctions, function (option) {
              return option.types.includes(type)
            }) || {};
            // Get the function for this type
            const renderFunction = view[renderOption.renderFunction]

            // If the cesium widget does not have a way to display this error, update the
            // error status in the model (this will be reflected in the LayerListView)
            if (!renderFunction || typeof renderFunction !== 'function') {
              mapAsset.set('statusDetails', 'This type of resource is not supported in the map widget.')
              mapAsset.set('status', 'error')
              return
            }

            // The asset should be visible and the cesium model should be ready before
            // starting to render the asset
            const checkAndRenderAsset = function () {
              let shouldRender = mapAsset.get('visible') && mapAsset.get('status') === 'ready'
              if (shouldRender) {
                renderFunction.call(view, mapAsset.get('cesiumModel'))
                view.stopListening(mapAsset)
              }
            }

            checkAndRenderAsset()

            if (!mapAsset.get('visible')) {
              view.listenToOnce(mapAsset, 'change:visible', checkAndRenderAsset)
            }

            if (mapAsset.get('status') !== 'ready') {
              view.listenTo(mapAsset, 'change:status', checkAndRenderAsset)
            }

          }
          catch (error) {
            console.error(
              'There was an error rendering an asset in a CesiumWidgetView' +
              '. Error details: ' + error
            );
            mapAsset.set('statusDetails', 'There was a problem rendering this resource in the map widget.')
            mapAsset.set('status', 'error')
          }
        },

        /**
         * Renders peaks and valleys in the 3D version of the map, given a terrain model.
         * If a terrain model has already been set on the map, this will replace it.
         * @param {Cesium.TerrainProvider} cesiumModel a Cesium Terrain Provider model to
         * use for the map
        */
        updateTerrain: function (cesiumModel) {
          this.scene.terrainProvider = cesiumModel
          this.requestRender();
        },

        /**
         * Renders a 3D tileset in the map.
         * @param {Cesium.Cesium3DTileset} cesiumModel The Cesium 3D tileset model that
         * contains the information about the 3D tiles to render in the map
        */
        add3DTileset: function (cesiumModel) {
          this.scene.primitives.add(cesiumModel)
        },

        /**
         * Renders vector data (excluding 3D tilesets) in the Map.
         * @param {Cesium.GeoJsonDataSource} cesiumModel - The Cesium data source
         * model to render on the map
         */
        addVectorData: function (cesiumModel) {
          this.dataSourceCollection.add(cesiumModel)
        },

        /**
         * Renders a CesiumGeohash map asset on the map
         * */
        addGeohashes: function () {
            let view = this;
            
            require(["views/maps/CesiumGeohashes"], (CesiumGeohashes)=>{
                //Create a CesiumGeohashes view
                let cg = new CesiumGeohashes();
                cg.cesiumViewer = view;

                //Get the CesiumGeohash MapAsset and save a reference in the view
                let cesiumGeohashAsset = view.model.get('layers').find(mapAsset => mapAsset.get("type") == "CesiumGeohash");
                cg.cesiumGeohash = cesiumGeohashAsset;

                cg.render();
            })
        },

        /**
         * Renders imagery in the Map.
         * @param {Cesium.ImageryLayer} cesiumModel The Cesium imagery model to render
        */
        addImagery: function (cesiumModel) {
          this.scene.imageryLayers.add(cesiumModel)
          this.sortImagery()
        },

        /**
         * Arranges the imagery that is rendered the Map according to the order
         * that the imagery is arranged in the layers collection.
         * @since 2.21.0
         */
        sortImagery() {
          try {
            const imageryInMap = this.scene.imageryLayers
            const imageryModels = this.model.get('layers').getAll('CesiumImagery')

            // If there are no imagery layers, or just one, return
            if (
              !imageryInMap || !imageryModels ||
              imageryInMap.length <= 1 || imageryModels.length <= 1
            ) {
              return
            }

            // If there are more than one imagery layer, arrange them in the order that
            // they were added to the map
            for (let i = 0; i < imageryModels.length; i++) {
              const cesiumModel = imageryModels[i].get('cesiumModel')
              if (cesiumModel) {
                if (imageryInMap.contains(cesiumModel)) {
                  imageryInMap.lowerToBottom(cesiumModel)
                }
              }
            }
          }
          catch (error) {
            console.log(
              'There was an error sorting displayed imagery in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Display a box around every rendered tile in the tiling scheme, and
         * draw a label inside it indicating the X, Y, Level indices of the
         * tile. This is mostly useful for debugging terrain and imagery
         * rendering problems. This function should be called after the other
         * imagery layers have been added to the map, e.g. at the end of the
         * render function.
         * @param {string} [color='#ffffff'] The color of the grid outline and
         * labels. Must be a CSS color string, beginning with a #.
         * @param {'GeographicTilingScheme'|'WebMercatorTilingScheme'}
         *  [tilingScheme='GeographicTilingScheme'] The tiling scheme to use.
         *  Defaults to GeographicTilingScheme.
         */
        showImageryGrid: function (
          color = '#ffffff',
          tilingScheme = 'GeographicTilingScheme'
        ) {
          try {
            const view = this
            // Check the color is valid
            if (!color || typeof color !== 'string' || !color.startsWith('#')) {
              console.log(`${color} is an invalid color for imagery grid. ` +
                `Must be a hex color starting with '#'. ` +
                `Setting color to white: '#ffffff'`)
              color = '#ffffff'
            }

            // Check the tiling scheme is valid
            const availableTS = ['GeographicTilingScheme', 'WebMercatorTilingScheme']
            if (availableTS.indexOf(tilingScheme) == -1) {
              console.log(`${tilingScheme} is not a valid tiling scheme ` +
                `for the imagery grid. Using WebMercatorTilingScheme`)
              tilingScheme = 'WebMercatorTilingScheme'
            }

            // Create the imagery grid
            const gridOpts = {
              tilingScheme: new Cesium[tilingScheme](),
              color: Cesium.Color.fromCssColorString(color)
            }

            const gridOutlines = new Cesium.GridImageryProvider(gridOpts)
            const gridCoords = new Cesium.TileCoordinatesImageryProvider(gridOpts)
            view.scene.imageryLayers.addImageryProvider(gridOutlines)
            view.scene.imageryLayers.addImageryProvider(gridCoords)
          }
          catch (error) {
            console.log(
              'There was an error showing the imagery grid in a CesiumWidgetView' +
              '. Error details: ' + error
            );
          }
        }

      }
    );

    return CesiumWidgetView;

  }
);