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",
  "common/SearchParams",
], (
  $,
  _,
  Backbone,
  Cesium,
  Map,
  MapAsset,
  Cesium3DTileset,
  Feature,
  Template,
  SearchParams,
) => {
  /**
   * @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
   * @augments Backbone.View
   * @screenshot views/maps/CesiumWidgetView.png
   * @since 2.18.0
   * @constructs
   * @fires MapInteraction#moved
   * @fires MapInteraction#moveEnd
   * @fires MapInteraction#moveStart
   */
  const 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
       * @property {string} removeFunction The name of the function in the view
       * that will remove the asset from the map, when passed the cesiumModel
       * attribute from the MapAsset model
       */
      mapAssetRenderFunctions: [
        {
          types: ["Cesium3DTileset"],
          renderFunction: "add3DTileset",
          removeFunction: "remove3DTileset",
        },
        {
          types: ["GeoJsonDataSource", "CzmlDataSource", "CustomDataSource"],
          renderFunction: "addVectorData",
          removeFunction: "removeVectorData",
        },
        {
          types: [
            "BingMapsImageryProvider",
            "IonImageryProvider",
            "TileMapServiceImageryProvider",
            "WebMapTileServiceImageryProvider",
            "WebMapServiceImageryProvider",
            "OpenStreetMapImageryProvider",
          ],
          renderFunction: "addImagery",
          removeFunction: "removeImagery",
        },
        {
          types: ["CesiumTerrainProvider"],
          renderFunction: "updateTerrain",
          removeFunction: null,
        },
      ],

      /**
       * 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 - Make 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(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;
            }
          }

          if (!this.model) {
            this.model = new Map();
          }
          if (!this.model.get("interactions")) {
            this.model.setUpInteractions();
          }
          this.interactions = this.model.get("interactions");
          // The selectedFeature attribute is used to store information about
          // the vector feature, if any, that is currently in focus on the map.
          if (!this.interactions.get("selectedFeatures")) {
            this.interactions.selectFeatures();
          }

          this.debouncedUpdateSearchParams = _.debounce(() => {
            this.updateSearchParams();
          }, 150 /* milliseconds */);

          this.listenTo(
            this.model,
            "change:searchparams",
            this.updateSearchParams,
          );
        } catch (e) {
          console.log("Failed to initialize a CesiumWidgetView. ", e);
        }
      },

      /**
       * Renders this view
       * @return {CesiumWidgetView} Returns the rendered view element
       */
      render() {
        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({}));

          // Create the Cesium Widget
          view.renderWidget();

          // Configure the lighting on the globe
          view.setLighting();

          // Prepare Cesium to handle vector datasources (e.g.
          // geoJsonDataSources)
          view.setUpDataSourceDisplay();

          // Listeners for changes & events to the layers & map
          view.setAssetListeners();
          view.setNavigationListeners();
          // Listen to Cesium screen space events and update Interactions model
          view.setCameraListeners();
          view.setMouseListeners();
          // Listen to Interactions model and react when e.g. something is
          // clicked
          view.setInteractionListeners();

          // Render the layers
          view.addLayers();

          const destination = SearchParams.getDestination();
          if (this.model.get("showShareUrl") && destination) {
            // Go to position specified in query params.
            view.flyTo(destination);
          } else {
            // Go to the home position, if one is set.
            view.flyHome(0);
          }

          // Set the map up so that selected features may be highlighted
          view.setUpSilhouettes();

          return this;
        } catch (e) {
          console.log("Failed to render a CesiumWidgetView,", e);
          // TODO: Render a fallback map or error message
        }
      },

      /**
       * Create the Cesium Widget and save a reference to it to the view
       * @since 2.27.0
       * @returns {Cesium.CesiumWidget} The Cesium Widget
       */
      renderWidget() {
        const view = this;
        // Clock for timeline component & updating data sources
        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,
          useBrowserRecommendedResolution: 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  widget the view will access often
        view.scene = view.widget.scene;
        view.camera = view.widget.camera;

        if (typeof this.model.get("globeBaseColor") === "string") {
          const baseColor = Cesium.Color.fromCssColorString(
            this.model.get("globeBaseColor"),
          );
          if (baseColor) {
            view.scene.globe.baseColor = baseColor;
          }
        }

        return view.widget;
      },

      /**
       * Create a DataSourceDisplay and DataSourceCollection for the Cesium
       * widget. This is required to display vector data (e.g. GeoJSON) on the
       * map.
       * @since 2.27.0
       * @returns {Cesium.DataSourceDisplay} The Cesium DataSourceDisplay
       */
      setUpDataSourceDisplay() {
        const view = this;
        view.dataSourceCollection = new Cesium.DataSourceCollection();
        view.dataSourceDisplay = new Cesium.DataSourceDisplay({
          scene: view.scene,
          dataSourceCollection: view.dataSourceCollection,
        });
        return view.dataSourceDisplay;
      },

      /**
       * 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();
      }, 90),

      /**
       * 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() {
        try {
          const view = this;
          if (view.zoomTarget) {
            view.completeFlight(view.zoomTarget, view.zoomOptions);
          }
        } catch (e) {
          console.log("Error calling post render functions:", e);
        }
      },

      /**
       * Run the update method and all visualizers for each data source.
       * @return {boolean} Returns true if all data sources are ready to be
       * displayed.
       * @since 2.27.0
       */
      updateAllDataSources() {
        const view = this;
        const dataSources = view.dataSourceDisplay.dataSources;
        if (!dataSources || !dataSources.length) {
          return;
        }
        const time = view.clock.currentTime;
        let displayReady = true;
        for (let i = 0; i < dataSources.length; i++) {
          const dataSource = dataSources.get(i);
          dataSource.update(view.clock.currentTime);
          // for each visualizer, update it
          dataSource._visualizers.forEach(function (visualizer) {
            displayReady = displayReady && visualizer.update(time);
          });
        }
        view.dataSourceDisplay._ready = displayReady;
        return displayReady;
      },

      /**
       * Configure the lighting on the globe.
       */
      setLighting() {
        const view = this;
        // Disable HDR lighting for better performance & to keep imagery
        // consistently lit.
        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,
          );
        });
      },

      /**
       * Set up the Cesium scene and set listeners and behavior that enable
       * users to click on vector features on the map to highlight them.
       * @since 2.27.0
       */
      setUpSilhouettes() {
        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,
            ]),
          );
        } catch (e) {
          console.log("Error initializing picking in a CesiumWidgetView", e);
        }
      },

      /**
       * Listen for changes to the assets and update the map accordingly.
       * @since 2.27.0
       */
      setAssetListeners() {
        const view = this;
        const model = view.model;
        const layerGroups = model.getLayerGroups();

        // Listen for addition or removal of layers TODO: Add similar listeners
        // for terrain
        _.each(layerGroups, (layers) => {
          if (layers) {
            view.stopListening(layers);
            view.listenTo(layers, "add", view.addAsset);
            view.listenTo(layers, "remove", view.removeAsset);

            // Each layer fires 'appearanceChanged' whenever the color, opacity,
            // etc. has been updated. Re-render the scene when this happens.
            view.listenTo(layers, "appearanceChanged", view.requestRender);
          }
        });
        // Reset asset listeners if the layers collection is replaced
        view.stopListening(model, "change:layers change:layerCategories");
        view.listenTo(
          model,
          "change:layers change:layerCategories",
          view.setAssetListeners,
        );
      },

      /**
       * Remove listeners for dynamic navigation.
       * @since 2.27.0
       */
      removeNavigationListeners() {
        this.stopListening(this.interactions, "change:zoomTarget", this.flyTo);
        if (this.removePostRenderListener) this.removePostRenderListener();
      },

      /**
       * Set up listeners to allow for dynamic navigation. This includes zooming
       * to the extent of a layer and zooming to the home position. Note that
       * other views may trigger an event on the layer/asset model that
       * indicates that the map should navigate to a given extent.
       * @since 2.27.0
       */
      setNavigationListeners() {
        this.removeNavigationListeners();
        // Zoom functions executed after each scene render
        this.removePostRenderListener = this.scene.postRender.addEventListener(
          this.postRender,
          this,
        );
        this.listenTo(this.interactions, "change:zoomTarget", function () {
          const target = this.interactions.get("zoomTarget");
          if (target) {
            this.flyTo(target);
          }
        });
      },

      /**
       * Remove any previously set camera listeners.
       * @since 2.27.0
       */
      removeCameraListeners() {
        if (!this.cameraListeners) this.cameraListeners = [];
        this.cameraListeners.forEach(function (removeListener) {
          removeListener();
        });
      },

      /**
       * Listen to cesium camera events, and translate them to events on the
       * interactions model. Also update the scale (pixels:meters) and the view
       * extent when the camera has moved.
       */
      setCameraListeners() {
        try {
          const view = this;
          const camera = view.camera;
          const interactions = view.interactions;

          // Remove any previously set camera listeners
          view.removeCameraListeners();
          // Amount camera must change before firing 'changed' event.
          camera.percentageChanged = 0.01;

          // Functions to run for each Cesium camera event
          const cameraEvents = {
            moveEnd: [],
            moveStart: [],
            changed: [
              "updateScale",
              "updateViewExtent",
              "debouncedUpdateSearchParams",
            ],
          };
          // add a listener that triggers the same event on the interactions
          // model, and runs any functions configured above.
          Object.entries(cameraEvents).forEach(([label, functions]) => {
            const callback = () => {
              // Rename because 'changed' is too similar to the Backbone event
              const eventName = label === "changed" ? "cameraChanged" : label;
              interactions.trigger(eventName);
              functions.forEach((func) => {
                view[func].call(view);
              });
            };
            const remover = camera[label].addEventListener(callback, view);
            view.cameraListeners.push(remover);
          });
        } catch (e) {
          console.log("Error updating the model on camera events", e);
        }
      },

      /**
       * Remove any previously set mouse listeners.
       * @since 2.27.0
       */
      removeMouseListeners() {
        if (this.mouseEventHandler) this.mouseEventHandler.destroy();
      },

      /**
       * Set up listeners for mouse events on the map. This includes listening
       * for mouse clicks, mouse movement, and mouse hovering over features.
       * These listeners simply update the interactions model with mouse events.
       * @since 2.27.0
       */
      setMouseListeners() {
        const view = this;
        const events = Cesium.ScreenSpaceEventType;

        // Remove previous listeners if they exist.
        view.removeMouseListeners;
        // Create Cesium object that handles interactions with the map.
        const handler = (view.mouseEventHandler =
          new Cesium.ScreenSpaceEventHandler(view.scene.canvas));

        // Every time the user interacts with the map, update the interactions
        // model with the type of interaction that occurred.
        Object.entries(events).forEach(function ([label, value]) {
          handler.setInputAction(function (event) {
            view.interactions.set("previousAction", label);
            if (label == "MOUSE_MOVE") {
              const position = event.position || event.endPosition;
              view.setMousePosition(position);
              view.setHoveredFeatures(position);
            }
          }, value);
        });
      },

      /**
       * Update the search parameters related to the current map position
       * and heading.
       * @since 2.30.0
       */
      updateSearchParams() {
        if (!this.model.get("showShareUrl")) return;

        const { heading, pitch, positionCartographic, roll } =
          this.scene.camera;

        SearchParams.updateDestination({
          heading: Cesium.Math.toDegrees(heading),
          height: positionCartographic.height,
          latitude: Cesium.Math.toDegrees(positionCartographic.latitude),
          longitude: Cesium.Math.toDegrees(positionCartographic.longitude),
          pitch: Cesium.Math.toDegrees(pitch),
          roll: Cesium.Math.toDegrees(roll),
        });

        this.model.get("allLayers").forEach((layer) => {
          const layerId = layer.get("layerId");
          if (layerId && layer.get("visible")) {
            SearchParams.addEnabledLayer(layerId);
          } else {
            SearchParams.removeEnabledLayer(layerId);
          }
        });
      },

      /**
       * When the mouse is moved over the map, update the interactions model
       * with the current mouse position.
       * @param {Object} event - The event object from Cesium
       * @since 2.27.0
       */
      setMousePosition(position) {
        if (!position) return;
        const view = this;
        const pickRay = view.camera.getPickRay(position);
        const cartesian = view.scene.globe.pick(pickRay, view.scene);
        let newPosition = null;
        if (cartesian) {
          newPosition = view.getDegreesFromCartesian(cartesian);
          newPosition.mapWidgetCoords = cartesian;
        }
        view.interactions.setMousePosition(newPosition);
      },

      /**
       * Record the feature hovered over by the mouse based on position.
       * @param {Object} position - The position of the mouse on the map
       * @param {number} [delay=200] - The minimum number of milliseconds that
       * must pass between calls to this function.
       * @since 2.27.0
       */
      setHoveredFeatures(position, delay = 200) {
        const view = this;
        const lastCall = this.setHoveredFeaturesLastCall || 0;
        const now = new Date().getTime();
        if (now - lastCall < delay) return;
        this.setHoveredFeaturesLastCall = now;
        const pickedFeature = view.scene.pick(position);
        view.interactions.setHoveredFeatures([pickedFeature]);
      },

      /**
       * React when the user interacts with the map.
       * @since 2.27.0
       */
      setInteractionListeners() {
        const interactions = this.interactions;
        const hoveredFeatures = interactions.get("hoveredFeatures");
        this.stopListening(hoveredFeatures, "change update");
        this.listenTo(hoveredFeatures, "change update", this.updateCursor);
      },

      /**
       * Change the cursor to a pointer when the mouse is hovering over a
       * feature.
       * @param {Object|null} hoveredFeatures - The feature that the mouse is
       * hovering over or null if the mouse is not hovering over a feature.
       */
      updateCursor(hoveredFeatures) {
        const view = this;
        let cursorStyle = "default";
        if (hoveredFeatures && hoveredFeatures.length) {
          cursorStyle = "pointer";
        }
        view.el.style.cursor = cursorStyle;
      },

      /**
       * Highlight the features that are currently selected in the interactions
       * model.
       * @since 2.27.0
       */
      showSelectedFeatures() {
        // 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);
          });
      },

      /**
       * Add all of the model's layers to the map. This function is called
       * during the render function.
       * @since 2.26.0
       */
      addLayers() {
        const view = this;

        // 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 layerGroups = view.model.getLayerGroups();
        for (const layers of layerGroups) {
          if (layers && layers.length) {
            const layersReverse = layers.last(layers.length).reverse();
            layersReverse.forEach(function (layer) {
              view.addAsset(layer);
            });
          }
        }

        // 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);
        }
      },

      /**
       * 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(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|GeoPoint} 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}.
       * The object may also be a position with longitude, latitude, and height.
       * 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}.
       * For other targets, options will be merged with the target object and
       * passed to Cesium Camera.flyTo(). See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyTo}
       */
      completeFlight(target, options) {
        try {
          // A target is required
          if (!target) return;

          const view = this;
          if (typeof options !== "object") options = {};
          view.resetZoomTarget();

          // 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 object saved in the Feature is an Entity, then this
            // function will get the bounding sphere for the entity on the next
            // run.
            // check if the layer is displayReady
            const layer = target.get("mapAsset");
            const displayReady = layer.get("displayReady");
            if (!displayReady) {
              // Must wait for layer to be rendered in via the dataSourceDisplay
              // before we can get the bounding sphere for the feature.
              view.listenToOnce(layer, "change:displayReady", () => {
                view.flyTo(target, options);
              });
              return;
            }
            view.flyTo(target.get("featureObject"), options);
            return;
          }

          // If the target is a Cesium Entity, then get the bounding sphere for
          // the entity and call this function again.
          const entity = target instanceof Cesium.Entity ? target : target.id;

          if (entity instanceof Cesium.Entity) {
            view.dataSourceDisplay._ready = true;
            view
              .getBoundingSphereFromEntity(entity)
              .then((entityBoundingSphere) => {
                view.flyTo(entityBoundingSphere, options);
              });
            return;
          }

          if (target.type && target.type == "GeoPoint") {
            view.flyTo(target.toJSON(), options);
            return;
          }

          if (
            typeof target === "object" &&
            typeof target.longitude === "number" &&
            typeof target.latitude === "number"
          ) {
            const pointTarget = view.positionToFlightTarget(target);
            view.flyTo(pointTarget, options);
            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") {
            // Merge the options with the target object, if there are any
            // options
            if (options && Object.keys(options).length) {
              target = Object.assign(target, options);
            }
            // Fly to the target
            view.camera.flyTo(target);
            view.resetZoomTarget();
          }
        } catch (e) {
          console.log("Failed to navigate to a target in Cesium.", e);
        }
      },

      getBoundingSphereFromEntity(entity) {
        const view = this;
        const entityBoundingSphere = new Cesium.BoundingSphere();
        const readyState = Cesium.BoundingSphereState.DONE;
        function getBS() {
          return view.dataSourceDisplay.getBoundingSphere(
            entity,
            false,
            entityBoundingSphere,
          );
        }
        // Return a promise that resolves to bounding box when it's ready.
        // Keep running getBS at intervals until it's ready.
        return new Promise(function (resolve, reject) {
          let attempts = 0;
          const maxAttempts = 100;
          const interval = setInterval(function () {
            attempts++;
            const state = getBS();
            if (state !== readyState) {
              // Search for the entity again in case it was removed and
              // re-added to the data source display.
              entity = view.getEntityById(entity.id, entity.entityCollection);
              if (!entity) {
                clearInterval(interval);
                reject(
                  "Failed to get bounding sphere for entity, entity not found.",
                );
              }
            } else {
              clearInterval(interval);
              resolve(entityBoundingSphere);
            }
            if (attempts >= maxAttempts) {
              clearInterval(interval);
              reject("Failed to get bounding sphere for entity.");
            }
          }, 100);
        });
      },

      /**
       * Search an entity collection for an entity with a given id.
       * @param {string} id - The id of the entity to find.
       * @param {Cesium.EntityCollection} collection - The collection to search.
       * @returns {Cesium.Entity} The entity with the given id, or null if no
       * entity with that id exists in the collection.
       * @since 2.27.0
       */
      getEntityById(id, collection) {
        const entities = collection.values;
        for (let i = 0; i < entities.length; i++) {
          const entity = entities[i];
          if (entity.id === id) {
            return entity;
          }
        }
        return null;
      },

      resetZoomTarget() {
        const view = this;
        view.zoomTarget = null;
        view.interactions.set("zoomTarget", null);
        view.zoomOptions = null;
      },

      /**
       * Navigate to the homePosition that's set on the Map.
       * @param {number} duration The duration of the flight in seconds.
       */
      flyHome(duration) {
        const home = this.model.get("homePosition");
        this.flyTo(home, { duration });
      },

      /**
       * Navigate to the homePosition that's set on the Map.
       * @param {Object} position The position to navigate to. Must have
       * longitude, latitude, and may have a height (elevation) in meters,
       * heading, pitch, and roll in degrees.
       * @param {number} duration The duration of the flight in seconds.
       */
      positionToFlightTarget(position, duration) {
        try {
          if (!position) {
            return null;
          }

          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),
              };
            }
            if (Cesium.defined(duration)) {
              target.duration = duration;
            }

            return target;
          }
        } catch (e) {
          console.log("Failed to convert a position to a flight target.", e);
          return null;
        }
      },

      /**
       * 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() {
        return this.getDegreesFromCartesian(this.camera.position);
      },

      /**
       * Update the 'currentViewExtent' attribute in the Map model with the
       * bounding box of the currently visible area of the map.
       */
      updateViewExtent() {
        try {
          this.interactions.setViewExtent(this.getViewExtent());
        } catch (e) {
          console.log("Failed to update the Map view extent.", e);
        }
      },

      /**
       * Get the north, south, east, and west-most lat/long that define a
       * bounding box around the currently visible area of the map. Also gives
       * the height/ altitude of the camera in meters.
       * @returns {MapConfig#ViewExtent} The current view extent.
       */
      getViewExtent() {
        const view = this;
        const scene = view.scene;
        const camera = view.camera;
        // Get the height in meters
        const height = camera.positionCartographic.height;

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

        // 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). If so, limit the bounding box to a
        // smaller extent
        if (view.coversGlobe(coords)) {
          // 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;
            }
          }
        }

        return coords;
      },

      /**
       * Check if a given bounding box covers the entire globe.
       * @param {Object} coords - An object with the north, south, east, and
       * west coordinates of a bounding box
       * @param {Number} latAllowance - The number of degrees latitude to allow
       * as a buffer. If the north and south coords range from -90 to 90, minus
       * this buffer * 2, then it is considered to cover the globe.
       * @param {Number} lonAllowance - The number of degrees longitude to allow
       * as a buffer.
       * @returns {Boolean} Returns true if the bounding box covers the entire
       * globe, false otherwise.
       */
      coversGlobe(coords, latAllowance = 0.5, lonAllowance = 1) {
        const maxLat = 90 - latAllowance;
        const minLat = -90 + latAllowance;
        const maxLon = 180 - lonAllowance;
        const minLon = -180 + lonAllowance;

        return (
          coords.west <= minLon &&
          coords.east >= maxLon &&
          coords.south <= minLat &&
          coords.north >= maxLat
        );
      },

      /**
       * 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(cartesian) {
        const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
        const degrees = {
          height: cartographic.height,
        };
        const coordinates = [
          "longitude",
          "latitude",
          "heading",
          "pitch",
          "roll",
        ];
        coordinates.forEach(function (coordinate) {
          if (Cesium.defined(cartographic[coordinate])) {
            degrees[coordinate] = Cesium.Math.toDegrees(
              cartographic[coordinate],
            );
          }
        });
        return degrees;
      },

      /**
       * 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() {
        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(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(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;
      },

      /**
       * 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.
       */
      updateScale() {
        try {
          const view = this;
          let currentScale = {
            pixels: null,
            meters: null,
          };
          const onePixelInMeters = view.pixelToMeters();
          if (onePixelInMeters || onePixelInMeters === 0) {
            currentScale = {
              pixels: 1,
              meters: onePixelInMeters,
            };
          }
          view.interactions.setScale(currentScale);
        } catch (e) {
          console.log("Error updating the scale from a CesiumWidgetView", e);
        }
      },

      /**
       * 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() {
        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(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 (e) {
          console.error("Error rendering an asset", e, mapAsset);
          mapAsset.set(
            "statusDetails",
            "There was a problem rendering this resource in the map widget.",
          );
          mapAsset.set("status", "error");
        }
      },

      /**
       * When an asset is removed from the map model, remove it from the map.
       * @param {MapAsset} mapAsset - The MapAsset model removed from the map
       * @since 2.27.0
       */
      removeAsset(mapAsset) {
        if (!mapAsset) return;
        // Get the cesium model from the asset
        const cesiumModel = mapAsset.get("cesiumModel");
        if (!cesiumModel) return;
        // Find the remove function for this type of asset
        const removeFunctionName = this.mapAssetRenderFunctions.find(
          function (option) {
            return option.types.includes(mapAsset.get("type"));
          },
        )?.removeFunction;
        const removeFunction = this[removeFunctionName];
        // If there is a function for this type of asset, call it
        if (removeFunction && typeof removeFunction === "function") {
          removeFunction.call(this, cesiumModel);
        } else {
          console.log(
            "No remove function found for this type of asset",
            mapAsset,
          );
        }
      },

      /**
       * 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(cesiumModel) {
        // TODO: Add listener to the map model for when the terrain changes
        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(cesiumModel) {
        this.scene.primitives.add(cesiumModel);
      },

      /**
       * Remove a 3D tileset from the map.
       * @param {Cesium.Cesium3DTileset} cesiumModel The Cesium 3D tileset model
       * to remove from the map
       * @since 2.27.0
       */
      remove3DTileset(cesiumModel) {
        this.scene.primitives.remove(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(cesiumModel) {
        this.dataSourceCollection.add(cesiumModel);
      },

      /**
       * Remove vector data (excluding 3D tilesets) from the Map.
       * @param {Cesium.GeoJsonDataSource} cesiumModel - The Cesium data source
       * model to remove from the map
       * @since 2.27.0
       */
      removeVectorData(cesiumModel) {
        this.dataSourceCollection.remove(cesiumModel);
      },

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

      /**
       * Remove imagery from the Map.
       * @param {Cesium.ImageryLayer} cesiumModel The Cesium imagery model to
       * remove from the map
       * @since 2.27.0
       */
      removeImagery(cesiumModel) {
        console.log("Removing imagery from map", cesiumModel);
        console.log("Imagery layers", this.scene.imageryLayers);
        this.scene.imageryLayers.remove(cesiumModel);
      },

      /**
       * 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() {
        const imageryInMap = this.scene.imageryLayers;
        const imageryModels = _.reduce(
          this.model.getLayerGroups(),
          (models, layers) => {
            models.push(...layers.getAll("CesiumImagery"));
            return models;
          },
          [],
        );

        // 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);
            }
          }
        }
      },

      /**
       * 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(
        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;
});