# 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;

      }
    );