Source: src/js/models/maps/assets/CesiumVectorData.js

"use strict";

define([
  "underscore",
  "cesium",
  "models/maps/assets/MapAsset",
  "models/maps/AssetColor",
  "models/maps/AssetColorPalette",
  "collections/maps/VectorFilters",
  "common/IconUtilities",
], function (
  _,
  Cesium,
  MapAsset,
  AssetColor,
  AssetColorPalette,
  VectorFilters,
  IconUtilities,
) {
  // Source: https://fontawesome.com/v6/icons/location-dot?f=classic&s=solid
  const PIN_SVG_STRING =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M215.7 499.2C267 435 384 279.4 384 192C384 86 298 0 192 0S0 86 0 192c0 87.4 117 243 168.3 307.2c12.3 15.3 35.1 15.3 47.4 0zM192 128a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/></svg>';
  const PIN_OUTLINE_WIDTH = 30; // The width of the stroke around the pin is relative to the viewBox
  const PIN_OUTLINE_COLOR = "white";
  const PIN_SVG = IconUtilities.formatSvgForCesiumBillboard(
    PIN_SVG_STRING,
    PIN_OUTLINE_WIDTH,
    PIN_OUTLINE_COLOR,
  );

  /**
   * @classdesc A CesiumVectorData Model is a vector layer (excluding
   * Cesium3DTilesets) that can be used in Cesium maps. This model corresponds
   * to "DataSource" models in Cesium. For example, this could represent vectors
   * rendered from a Cesium GeoJSONDataSource.
   * {@link https://cesium.com/learn/cesiumjs/ref-doc/GeoJsonDataSource.html}.
   * Note: GeoJsonDataSource, CzmlDataSource, and CustomDataSource are
   * supported. Eventually this model could support the KmlDataSource.
   * @classcategory Models/Maps/Assets
   * @class CesiumVectorData
   * @name CesiumVectorData
   * @extends MapAsset
   * @since 2.19.0
   * @constructor
   */
  var CesiumVectorData = MapAsset.extend(
    /** @lends CesiumVectorData.prototype */ {
      /**
       * The name of this type of model
       * @type {string}
       */
      type: "CesiumVectorData",

      /**
       * Options that are supported for creating Cesium DataSources. The object
       * will be passed to the cesium DataSource's load method as options, so
       * the properties listed in the Cesium documentation are also supported.
       * Each type of Cesium Data Source has a specific set of load method
       * options. See for example, the GeoJsonDataSource options:
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/GeoJsonDataSource.html}
       * @typedef {Object} CesiumVectorData#cesiumOptions
       * @property {string|Object} data - The url, GeoJSON object, or TopoJSON
       * object to be loaded.
       */

      /**
       * Default attributes for CesiumVectorData models
       * @name CesiumVectorData#defaults
       * @extends MapAsset#defaults
       * @type {Object}
       * @property {'GeoJsonDataSource'} type The format of the data. Must be
       * 'GeoJsonDataSource' or 'CzmlDataSource'.
       * @property {VectorFilters} [filters=new VectorFilters()] A set of
       * conditions used to show or hide specific features of this vector data.
       * @property {AssetColorPalette} [colorPalette=new AssetColorPalette()]
       * The color or colors mapped to attributes of this asset. Used to style
       * the features and to make a legend.
       * @property {Cesium.GeoJsonDataSource} cesiumModel A Cesium DataSource
       * model created and used by Cesium that organizes the data to display in
       * the Cesium Widget. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/DataSource.html?classFilter=DataSource}
       * @property {CesiumVectorData#cesiumOptions} cesiumOptions options are
       * passed to the function that creates the Cesium model. The properties of
       * options are specific to each type of asset.
       * @property {string|AssetColor} [outlineColor=null] The color of the
       * outline of the features. If null, the outline will not be shown. If a
       * string, it should be a valid CSS color string. If an object, it should
       * be an AssetColor object, or a set of RGBA values.
       */
      defaults: function () {
        return Object.assign(this.constructor.__super__.defaults(), {
          type: "GeoJsonDataSource",
          filters: new VectorFilters(),
          cesiumModel: null,
          cesiumOptions: {},
          colorPalette: new AssetColorPalette(),
          icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M384 352h-1l-39-65a64 64 0 0 0 0-62l39-65h1a64 64 0 1 0-55-96H119a64 64 0 1 0-87 87v210a64 64 0 1 0 87 87h210a64 64 0 0 0 119-32c0-35-29-64-64-64zm-288 9V151a64 64 0 0 0 23-23h208l-38 64h-1a64 64 0 1 0 0 128h1l38 64H119a64 64 0 0 0-23-23zm176-105a16 16 0 1 1 32 0 16 16 0 0 1-32 0zM400 96a16 16 0 1 1-32 0 16 16 0 0 1 32 0zM64 80a16 16 0 1 1 0 32 16 16 0 0 1 0-32zM48 416a16 16 0 1 1 32 0 16 16 0 0 1-32 0zm336 16a16 16 0 1 1 0-32 16 16 0 0 1 0 32z"/></svg>',
          outlineColor: null,
          featureType: Cesium.Entity,
        });
      },

      /**
       * Executed when a new CesiumVectorData model is created.
       * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of
       * the attributes, which will be set on the model.
       */
      initialize: function (assetConfig) {
        try {
          if (!assetConfig) assetConfig = {};

          MapAsset.prototype.initialize.call(this, assetConfig);

          if (assetConfig.filters) {
            this.set("filters", new VectorFilters(assetConfig.filters));
          }

          // displayReady will be updated by the Cesium map within which the
          // asset is rendered. The map will set it to true when the data is
          // ready to be rendered. Used to know when it's safe to calculate a
          // bounding sphere.
          this.set("displayReady", false);

          if (
            assetConfig.outlineColor &&
            !(assetConfig.outlineColor instanceof AssetColor)
          ) {
            this.set(
              "outlineColor",
              new AssetColor({ color: assetConfig.outlineColor }),
            );
          }

          if (
            assetConfig.highlightColor &&
            !(assetConfig.highlightColor instanceof AssetColor)
          ) {
            this.set(
              "highlightColor",
              new AssetColor({ color: assetConfig.highlightColor }),
            );
          }

          this.createCesiumModel();
        } catch (error) {
          console.log("Error initializing a CesiumVectorData model.", error);
        }
      },

      /**
       * Creates a Cesium.DataSource model and sets it to this model's
       * 'cesiumModel' attribute. This cesiumModel contains all the information
       * required for Cesium to render the vector data. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/DataSource.html?classFilter=DataSource}
       * @param {Boolean} [recreate = false]  - Set recreate to true to force
       * the function create the Cesium Model again. Otherwise, if a cesium
       * model already exists, that is returned instead.
       */
      createCesiumModel: function (recreate = false) {
        try {
          const model = this;
          const cesiumOptions = this.getCesiumOptions();
          const type = model.get("type");
          const label = model.get("label") || "";
          const dataSourceFunction = Cesium[type];

          // If the cesium model already exists, don't create it again unless
          // specified
          let dataSource = model.get("cesiumModel");
          if (dataSource) {
            if (!recreate) {
              return dataSource;
            } else {
              // If we are recreating the model, remove all entities first. see
              // https://stackoverflow.com/questions/31426796/loading-updated-data-with-geojsondatasource-in-cesium-js
              dataSource.entities.removeAll();
            }
          }

          model.set("displayReady", false);
          model.resetStatus();

          if (typeof dataSourceFunction !== "function") {
            model.setError(`${type} is not a supported data type.`);
            return;
          }
          if (!dataSource) {
            dataSource = new dataSourceFunction(label);
          }
          if (!dataSource) {
            model.setError("Failed to create a Cesium DataSource model.");
            return;
          }

          // There is no data to load for a CustomDataSource
          if (type === "CustomDataSource") {
            model.set("cesiumModel", dataSource);
            model.setListeners();
            model.setReady();
            model.runVisualizers();
            return;
          }

          // For GeoJSON and CZML data sources
          if (!cesiumOptions || !cesiumOptions.data) {
            model.setError(
              "No data was provided to create a Cesium DataSource model.",
            );
            return;
          }
          const data = JSON.parse(JSON.stringify(cesiumOptions.data));
          delete cesiumOptions.data;

          dataSource
            .load(data, cesiumOptions)
            .then(function (loadedData) {
              model.set("cesiumModel", loadedData);
              if (!recreate) {
                model.setListeners();
              }
              model.updateFeatureVisibility();
              model.updateAppearance();
              model.setReady();
            })
            .otherwise(model.setError.bind(model));
        } catch (error) {
          console.log("Failed to create a VectorData Cesium Model.", error);
        }
      },

      /**
       * Set listeners that update the cesium model when the backbone model is
       * updated.
       */
      setListeners: function () {
        try {
          MapAsset.prototype.setListeners.call(this);
          const appearEvents =
            "change:visible change:opacity change:color change:outlineColor" +
            " change:temporarilyHidden";
          this.stopListening(this, appearEvents);
          this.listenTo(this, appearEvents, this.updateAppearance);
          const filters = this.get("filters");
          this.stopListening(filters, "update");
          this.listenTo(filters, "update", this.updateFeatureVisibility);
        } catch (error) {
          console.log("Failed to set CesiumVectorData listeners.", error);
        }
      },

      /**
       * Checks that the map is ready to display this asset. The displayReady
       * attribute is updated by the Cesium map when the dataSourceDisplay is
       * updated.
       * @returns {Promise} Returns a promise that resolves to this model when
       * ready to be displayed.
       */
      whenDisplayReady: function () {
        return this.whenReady().then(function (model) {
          return new Promise(function (resolve, reject) {
            if (model.get("displayReady")) {
              resolve(model);
              return;
            }
            model.stopListening(model, "change:displayReady");
            model.listenTo(model, "change:displayReady", function () {
              if (model.get("displayReady")) {
                model.stopListening(model, "change:displayReady");
                resolve(model);
              }
            });
          });
        });
      },

      /**
       * Try to find Entity object that comes from an object passed from the
       * Cesium map. This is useful when the map is clicked and the map returns
       * an object that may or may not be an Entity.
       * @param {Object} mapObject - An object returned from the Cesium map
       * @returns {Cesium.Entity} - The Entity object if found, otherwise null.
       * @since 2.25.0
       */
      getEntityFromMapObject: function (mapObject) {
        const entityType = this.get("featureType");
        if (mapObject instanceof entityType) return mapObject;
        if (mapObject.id instanceof entityType) return mapObject.id;
        return null;
      },

      /**
       * @inheritdoc
       * @since 2.25.0
       */
      getFeatureAttributes: function (feature) {
        feature = this.getEntityFromMapObject(feature);
        return MapAsset.prototype.getFeatureAttributes.call(this, feature);
      },

      /**
       * @inheritdoc
       * @since 2.25.0
       */
      usesFeatureType: function (feature) {
        // This method could be passed the entity directly, or the object
        // returned from Cesium on a click event (where the entity is in the id
        // property).
        if (!feature) return false;
        const baseMethod = MapAsset.prototype.usesFeatureType;
        let result = baseMethod.call(this, feature);
        if (result) return result;
        result = baseMethod.call(this, feature.id);
        return result;
      },

      /**
       * Given a feature from a Cesium Vector Data source, returns any
       * properties that are set on the feature, similar to an attributes table.
       * @param {Cesium.Entity} feature A Cesium Entity
       * @returns {Object} An object containing key-value mapping of property
       * names to properties.
       */
      getPropertiesFromFeature: function (feature) {
        feature = this.getEntityFromMapObject(feature);
        if (!feature) return null;
        const featureProps = feature.properties;
        let properties = {};
        if (featureProps) {
          properties = feature.properties.getValue(new Date());
        }
        properties = this.addCustomProperties(properties);
        return properties;
      },

      /**
       * Return the label for a feature from a DataSource model
       * @param {Cesium.Entity} feature A Cesium Entity
       * @returns {string} The label
       */
      getLabelFromFeature: function (feature) {
        feature = this.getEntityFromMapObject(feature);
        if (!feature) return null;
        return feature.name;
      },

      /**
       * Return the DataSource model for a feature from a Cesium DataSource
       * model
       * @param {Cesium.Entity} feature A Cesium Entity
       * @returns {Cesium.GeoJsonDataSource|Cesium.CzmlDataSource} The model
       */
      getCesiumModelFromFeature: function (feature) {
        feature = this.getEntityFromMapObject(feature);
        if (!feature) return null;
        return feature.entityCollection.owner;
      },

      /**
       * Return the ID used by Cesium for a feature from a DataSource model
       * @param {Cesium.Entity} feature A Cesium Entity
       * @returns {string} The ID
       */
      getIDFromFeature: function (feature) {
        feature = this.getEntityFromMapObject(feature);
        if (!feature) return null;
        return feature.id;
      },

      /**
       * Updates the styles set on the cesiumModel object based on the
       * colorPalette and filters attributes.
       */
      updateAppearance: function () {
        try {
          const model = this;
          const entities = this.getEntities();
          const entityCollection = this.getEntityCollection();
          this.set("displayReady", false);

          if (entities && entities.length) {
            if (model.isVisible()) {
              // Suspending events while updating a large number of entities helps
              // performance.
              model.suspendEvents();
              entityCollection.show = true;
              this.styleEntities(entities);
              model.resumeEvents();
            } else {
              // If the asset isn't visible, just hide all entities and update the
              // visibility property to indicate that layer is hidden
              entityCollection.show = false;
              if (model.get("opacity") === 0) model.set("visible", false);
            }
          }

          this.runVisualizers();
        } catch (e) {
          console.log("Failed to update CesiumVectorData model styles.", e);
        }
      },

      /**
       * Run the Cesium visualizers for this asset. Visualizers render data
       * associated with DataSource instances. Visualizers must be run after
       * changes are made to the data or the appearance of the data.
       * @since 2.27.0
       * @see {@link https://cesium.com/learn/cesiumjs/ref-doc/Visualizer.html}
       */
      runVisualizers: function () {
        const dataSource = this.get("cesiumModel");
        const visualizers = dataSource?._visualizers;
        if (!visualizers || !visualizers.length) {
          this.whenVisualizersReady(this.runVisualizers.bind(this));
          return;
        }
        const time = Cesium.JulianDate.now();
        let displayReadyNow = true;
        for (let x = 0; x < visualizers.length; x++) {
          displayReadyNow = visualizers[x].update(time) && displayReadyNow;
        }
        if (!displayReadyNow) {
          setTimeout(this.runVisualizers.bind(this), 300);
        } else {
          this.set("displayReady", true);
        }
        this.trigger("appearanceChanged");
      },

      /**
       * Check for the existence of visualizers and run the callback when they
       * are ready. This is useful for waiting to run code that depends on the
       * visualizers being ready. It will attempt to run the callback every
       * pingRate ms until the visualizers are ready, or until the maxPings is
       * reached.
       * @param {Function} callBack - The function to run when the visualizers
       * are ready
       * @param {Number} [pingRate=100] - The number of milliseconds to wait
       * between pings - pings are used to check if the visualizers are ready
       * @param {Number} [maxPings=30] - The maximum number of pings to wait
       * before giving up
       */
      whenVisualizersReady: function (callBack, pingRate = 100, maxPings = 30) {
        const model = this;
        let pings = 0;
        const interval = setInterval(function () {
          pings++;
          if (pings > maxPings) {
            clearInterval(interval);
            return;
          }
          const visualizers = model.get("cesiumModel")?._visualizers;
          if (visualizers && visualizers.length) {
            clearInterval(interval);
            callBack();
          }
        }, pingRate);
      },

      /**
       * Get the Cesium EntityCollection for this asset
       * @returns {Cesium.EntityCollection} The Cesium EntityCollection
       * @since 2.27.0
       */
      getEntityCollection: function () {
        const model = this;
        const dataSource = model.get("cesiumModel");
        return dataSource?.entities;
      },

      /**
       * Get the Cesium Entities for this asset
       * @returns {Cesium.Entity[]} The Cesium Entities
       * @since 2.27.0
       */
      getEntities: function () {
        return this.getEntityCollection()?.values || [];
      },

      /**
       * Suspend events on the Cesium EntityCollection. This will prevent
       * visualizers from running until resumeEvents is called.
       * @since 2.27.0
       */
      suspendEvents: function () {
        const entities = this.getEntityCollection();
        if (entities) entities.suspendEvents();
      },

      /**
       * Resume events on the Cesium EntityCollection. This will allow
       * visualizers to run again.
       * @since 2.27.0
       */
      resumeEvents: function () {
        const entities = this.getEntityCollection();
        if (entities) entities.resumeEvents();
      },

      /**
       * Manually an entity to the Cesium EntityCollection.
       * @param {Object} entity - The ConstructorOptions with properties to pass
       * to Cesium.EntityCollection.add. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/EntityCollection.html?classFilter=EntityCollection#add}
       * @returns {Cesium.Entity} The Cesium Entity that was added
       * @since 2.27.0
       */
      addEntity: function (entity) {
        try {
          const entities = this.getEntityCollection();
          if (!entities) return false;
          const newEntity = entities.add(entity);
          this.styleEntities([newEntity]);
          this.runVisualizers();
          return newEntity;
        } catch (e) {
          console.log("Failed to add an entity.", e);
        }
      },

      /**
       * Manually remove an entity from the Cesium EntityCollection.
       * @param {Cesium.Entity|string} entity - The entity or ID of the entity
       * to remove
       * @returns {Boolean} True if the entity was removed, false otherwise
       * @since 2.27.0
       */
      removeEntity: function (entity) {
        try {
          const entities = this.getEntityCollection();
          if (!entities) return false;
          let removed = false;
          // if entity is a string, remove by ID
          if (typeof entity === "string") {
            removed = entities.removeById(entity);
          } else {
            // Otherwise, assume it's an entity object
            removed = entities.remove(entity);
          }
          this.runVisualizers();
          return removed;
        } catch (e) {
          console.log("Failed to remove an entity.", e);
        }
      },

      /**
       * Update the styles for a set of entities
       * @param {Array} entities - The entities to update
       * @since 2.25.0
       */
      styleEntities: function (entities) {
        // Map of entity types to style functions
        const entityStyleMap = {
          polygon: this.stylePolygon,
          polyline: this.stylePolyline,
          billboard: this.styleBillboard,
          point: this.stylePoint,
        };

        entities.forEach((entity) => {
          const styles = this.getStyles(entity);
          if (!styles) {
            entity.show = false;
            return;
          }
          entity.show = true;
          for (const [type, styleFunction] of Object.entries(entityStyleMap)) {
            if (entity[type]) {
              styleFunction.call(this, entity, styles);
            }
          }
        }, this);
      },

      /**
       * Update the styles for a polygon entity
       * @param {Cesium.Entity} entity - The entity to update
       * @param {Object} styles - Styles to apply, as returned by getStyles
       * @since 2.25.0
       */
      stylePolygon: function (entity, styles) {
        entity.polygon.material = styles.color;
        entity.polygon.outline = styles.outline;
        entity.polygon.outlineColor = styles.outlineColor;
        entity.polygon.outlineWidth = styles.outline ? 2 : 0;
      },

      /**
       * Update the styles for a point entity
       * @param {Cesium.Entity} entity - The entity to update
       * @param {Object} styles - Styles to apply, as returned by getStyles
       * @since 2.25.0
       */
      stylePoint: function (entity, styles) {
        entity.point.color = styles.color;
        entity.point.outlineColor = styles.outlineColor;
        entity.point.outlineWidth = styles.outline ? 2 : 0;
        entity.point.pixelSize = styles.pointSize;
      },

      /**
       * Update the styles for a polyline entity
       * @param {Cesium.Entity} entity - The entity to update
       * @param {Object} styles - Styles to apply, as returned by getStyles
       * @since 2.25.0
       */
      stylePolyline: function (entity, styles) {
        entity.polyline.material = styles.color;
        entity.polyline.width = styles.lineWidth;
      },

      /**
       * Update the styles for a billboard entity
       * @param {Cesium.Entity} entity - The entity to update
       * @param {Object} styles - Styles to apply, as returned by getStyles
       * @since 2.25.0
       */
      styleBillboard: function (entity, styles) {
        const size = styles.markerSize;
        // Since we're converting to raster, start with a larger SVG and
        // scale down so the resulting resolution is better
        PIN_SVG.setAttribute("width", size * 4);
        PIN_SVG.setAttribute("height", size * 4);
        PIN_SVG.setAttribute("fill", styles.color.toCssHexString());
        entity.billboard = {
          image: IconUtilities.svgToBase64(PIN_SVG),
          width: size,
          height: size,
        };
        // To convert the automatically created billboards to points instead:
        // entity.billboard = undefined; entity.point = new
        // Cesium.PointGraphics();
      },

      /**
       * Update the styles for a label entity
       * @param {Cesium.Entity} entity - The entity to update
       * @param {Object} styles - Styles to apply, as returned by getStyles
       * @since 2.25.0
       */
      styleLabel: function (entity, styles) {
        // TODO...
      },

      /**
       * Covert a Color model to a Cesium Color
       * @param {Color} color A Color model
       * @returns {Cesium.Color|null} A Cesium Color or null if the color is
       * invalid
       * @since 2.25.0
       */
      colorToCesiumColor: function (color) {
        color = color?.get ? color.get("color") : color;
        if (!color) return null;
        return new Cesium.Color(
          color.red,
          color.green,
          color.blue,
          color.alpha,
        );
      },

      /**
       * Return the color for a feature based on the colorPalette and filters
       * attributes.
       * @param {Cesium.Entity} entity A Cesium Entity
       * @returns {Cesium.Color|null} A Cesium Color or null if the color is
       * invalid or alpha is 0
       * @since 2.25.0
       */
      colorForEntity: function (entity) {
        const properties = this.getPropertiesFromFeature(entity);
        const color = this.colorToCesiumColor(this.getColor(properties));
        const alpha = color.alpha * this.get("opacity");
        if (alpha === 0) return null;
        color.alpha = alpha;
        return this.colorToCesiumColor(color);
      },

      /**
       * Return the styles for a selected feature
       * @param {Cesium.Entity} entity A Cesium Entity
       * @returns {Object} An object containing the styles for the feature
       * @since 2.25.0
       */
      getSelectedStyles: function (entity) {
        const highlightColor = this.colorToCesiumColor(
          this.get("highlightColor"),
        );
        return {
          color: highlightColor || this.colorForEntity(entity),
          outlineColor: Cesium.Color.WHITE,
          outline: true,
          lineWidth: 7,
          markerSize: 34,
          pointSize: 17,
        };
      },

      /**
       * Return the styles for a feature
       * @param {Cesium.Entity} entity A Cesium Entity
       * @returns {Object} An object containing the styles for the feature
       * @since 2.25.0
       */
      getStyles: function (entity) {
        if (!entity) return null;
        entity = this.getEntityFromMapObject(entity);
        if (this.featureIsSelected(entity)) {
          return this.getSelectedStyles(entity);
        }
        const color = this.colorForEntity(entity);
        if (!color) {
          return null;
        }
        const outlineColor = this.colorToCesiumColor(
          this.get("outlineColor")?.get("color"),
        );
        return {
          color: color,
          outlineColor: outlineColor,
          outline: outlineColor ? true : false,
          lineWidth: 3,
          markerSize: 24,
          pointSize: 13,
        };
      },

      /**
       * Shows or hides each feature from this Map Asset based on the filters.
       */
      updateFeatureVisibility: function () {
        try {
          const model = this;
          const entities = this.getEntities();
          const filters = model.get("filters");

          if (!entities || !filters) return;

          // Suspending events while updating a large number of entities helps
          // performance.
          this.suspendEvents();

          for (var i = 0; i < entities.length; i++) {
            let visible = true;
            const entity = entities[i];
            if (filters && filters.length) {
              const properties = model.getPropertiesFromFeature(entity);
              visible = model.featureIsVisible(properties);
            }
            entity.show = visible;
          }

          this.resumeEvents();
          model.runVisualizers();
        } catch (e) {
          console.log("Failed to update CesiumVectorData model styles.", e);
        }
      },

      /**
       * Waits for the model to be ready to display, then gets a Cesium Bounding
       * Sphere that can be used to navigate to view the full extent of the
       * vector data. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html}.
       * @param {Cesium.DataSourceDisplay} dataSourceDisplay The data source
       * display attached to the CesiumWidget scene that this bounding sphere is
       * for. Required.
       * @returns {Promise} Returns a promise that resolves to a Cesium Bounding
       * Sphere when ready
       */
      getBoundingSphere: function (dataSourceDisplay) {
        return this.whenDisplayReady()
          .then(function (model) {
            const entities = model.getEntities(); // .slice(0)?
            const boundingSpheres = [];
            const boundingSphereScratch = new Cesium.BoundingSphere();
            for (let i = 0, len = entities.length; i < len; i++) {
              let state = Cesium.BoundingSphereState.PENDING;
              state = dataSourceDisplay.getBoundingSphere(
                entities[i],
                false,
                boundingSphereScratch,
              );
              if (state === Cesium.BoundingSphereState.PENDING) {
                return false;
              } else if (state !== Cesium.BoundingSphereState.FAILED) {
                boundingSpheres.push(
                  Cesium.BoundingSphere.clone(boundingSphereScratch),
                );
              }
            }
            if (boundingSpheres.length) {
              return Cesium.BoundingSphere.fromBoundingSpheres(boundingSpheres);
            }
            return false;
          })
          .catch(function (e) {
            console.log("Error getting bounding sphere.", e);
          });
      },
    },
  );

  return CesiumVectorData;
});