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

'use strict';

define(
  [
    'jquery',
    'underscore',
    'backbone',
    'cesium',
    'models/maps/assets/MapAsset',
    'models/maps/AssetColor',
    'models/maps/AssetColorPalette',
    'collections/maps/VectorFilters'
  ],
  function (
    $,
    _,
    Backbone,
    Cesium,
    MapAsset,
    AssetColor,
    AssetColorPalette,
    VectorFilters
  ) {
    /**
     * @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 is the only supported DataSource so far, eventually this model
     * could be used to support Cesium's CzmlDataSource and KmlDataSource (and perhaps a
     * Cesium CustomDataSource).
     * @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'. (The only Cesium DataSource supported so far.)
         * @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.
        */
        defaults: function () {
          return _.extend(
            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>'
            }
          );
        },

        /**
         * 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 {

            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)

            this.createCesiumModel();
          }
          catch (error) {
            console.log(
              'There was an error initializing a CesiumVectorData model' +
              '. Error details: ' + 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 - 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 = model.get('cesiumOptions')
            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
            if (!recreate && model.get('cesiumModel')) {
              return model.get('cesiumModel')
            }

            model.resetStatus();

            if (!cesiumOptions || !cesiumOptions.data) {
              model.set('status', 'error');
              model.set('statusDetails', 'Vector data source is missing: A URL or GeoJSON/TopoJson object is required')
              return
            }

            if (dataSourceFunction && typeof dataSourceFunction === 'function') {
              let dataSource = new dataSourceFunction(label)

              const data = cesiumOptions.data;
              delete cesiumOptions.data

              dataSource.load(data, cesiumOptions)
                .then(function (loadedData) {
                  model.set('cesiumModel', loadedData)
                  model.setListeners()
                  model.updateFeatureVisibility()
                  model.updateAppearance()
                  model.set('status', 'ready')
                })
                .otherwise(function (error) {
                  // See https://cesium.com/learn/cesiumjs/ref-doc/RequestErrorEvent.html
                  let details = error;
                  // Write a helpful error message
                  switch (error.statusCode) {
                    case 404:
                      details = 'The resource was not found (error code 404).'
                      break;
                    case 500:
                      details = 'There was a server error (error code 500).'
                      break;
                  }
                  model.set('status', 'error');
                  model.set('statusDetails', details)
                })
            } else {
              model.set('status', 'error')
              model.set('statusDetails', type + ' is not a supported imagery type.')
            }
          }
          catch (error) {
            console.log(
              'Failed to create a Cesium Model for a CesiumVectorData model' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Set listeners that update the cesium model when the backbone model is updated.
         */
        setListeners: function () {
          try {
            this.stopListening(this, 'change:visible change:opacity change:color')
            this.listenTo(
              this, 'change:visible change:opacity change:color', this.updateAppearance
            )
            this.stopListening(this.get('filters'), 'update')
            this.listenTo(this.get('filters'), 'update', this.updateFeatureVisibility)
          }
          catch (error) {
            console.log(
              'There was an error setting listeners in a CesiumVectorData model' +
              '. Error details: ' + 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)
                  }
                })
              });
            })
        },

        /**
         * 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(feature) {
          try {
            const featureProps = feature.properties
            let properties = {}
            if (featureProps) {
              properties = feature.properties.getValue(new Date())
            }
            properties = this.addCustomProperties(properties)
            return properties
          }
          catch (error) {
            console.log(
              'There was an error getting properties from a Cesium Entity' +
              '. Error details: ' + error +
              '. Returning an empty object.'
            );
            return {}
          }
        },

        /**
         * Updates the styles set on the cesiumModel object based on the colorPalette and
         * filters attributes.
         */
        updateAppearance: function () {
          try {

            const model = this;
            const cesiumModel = this.get('cesiumModel')
            const opacity = this.get('opacity')
            const entities = cesiumModel.entities.values

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

            // If the asset isn't visible at all, don't bother setting up colors. Just set
            // every feature to hidden.
            if (!model.isVisible()) {
              cesiumModel.entities.show = false
              // Indicate that the layer is hidden if the opacity is zero by updating the
              // visibility property
              if (model.get('opacity') === 0) {
                model.set('visible', false);
              }
            } else {
              cesiumModel.entities.show = true
              for (var i = 0; i < entities.length; i++) {

                const entity = entities[i];
                const properties = model.getPropertiesFromFeature(entity)

                let outlineColor = null
                let featureOpacity = opacity
                let outline = false
                // For polylines
                let lineWidth = 3
                // For billboard pins and points. We could make size configurable. Size
                // could also be set according to a vector property
                let markerSize = 25

                // If the feature is selected, set the opacity to 1, and add an outline
                if (model.featureIsSelected(entity)) {
                  featureOpacity = 1
                  outline = true
                  // TODO: This colour should be configurable in the Map model
                  outlineColor = Cesium.Color.WHITE
                  lineWidth = 7
                  markerSize = 34
                }

                const rgb = model.getColor(properties)
                const color = new Cesium.Color(
                  rgb.red, rgb.green, rgb.blue, featureOpacity
                )

                if (entity.polygon) {
                  entity.polygon.material = color
                  entity.polygon.outline = outline;
                  entity.polygon.outlineColor = outlineColor
                  entity.polygon.outlineWidth = outline ? 2 : 0
                }
                if (entity.billboard) {
                  if (!model.pinBuilder) {
                    model.pinBuilder = new Cesium.PinBuilder()
                  }
                  entity.billboard.image = model.pinBuilder.fromColor(color, markerSize).toDataURL()
                  // To convert the automatically created billboards to points instead:
                  // entity.billboard = undefined;
                  // entity.point = new Cesium.PointGraphics();
                }
                if (entity.point) {
                  entity.point.color = color
                  entity.point.outlineColor = outlineColor
                  entity.point.outlineWidth = outline ? 2 : 0
                  // Points look better a little smaller than billboards
                  entity.point.pixelSize = (markerSize * 0.5);
                }
                if (entity.polyline) {
                  entity.polyline.material = color
                  entity.polyline.width = lineWidth
                }

              }
            }

            cesiumModel.entities.resumeEvents()

            // Let the map and/or other parent views know that a change has been made that
            // requires the map to be re-rendered
            model.trigger('appearanceChanged')

          }
          catch (error) {
            console.log(
              'There was an error updating CesiumVectorData model styles' +
              '. Error details: ' + error
            );
          }
        },

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

            // Suspending events while updating a large number of entities helps
            // performance.
            cesiumModel.entities.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
            }

            cesiumModel.entities.resumeEvents()
            
            // Let the map and/or other parent views know that a change has been made that
            // requires the map to be re-rendered
            model.trigger('appearanceChanged')
          }
          catch (error) {
            console.log(
              'There was an error updating CesiumVectorData feature visibility' +
              '. Error details: ' + error
            );
          }
        },

        /**
        * 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.get('cesiumModel').entities.values.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 (error) {
              console.log(
                'Failed to get the bounding sphere for a CesiumVectorData model' +
                '. Error details: ' + error
              );
            })
        },

      });

    return CesiumVectorData;

  }
);