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

"use strict";

define([
  "jquery",
  "underscore",
  "backbone",
  "cesium",
  "models/maps/assets/MapAsset",
], function ($, _, Backbone, Cesium, MapAsset) {
  /**
   * @classdesc A CesiumImagery Model contains the information required for Cesium to
   * request and draw high-resolution image tiles using several standards (Cesium
   * "imagery providers"), including Cesium Ion and Bing Maps. Imagery layers have
   * brightness, contrast, gamma, hue, and saturation properties that can be dynamically
   * changed.
   * @classcategory Models/Maps/Assets
   * @class CesiumImagery
   * @name CesiumImagery
   * @extends MapAsset
   * @since 2.18.0
   * @constructor
   */
  var CesiumImagery = MapAsset.extend(
    /** @lends CesiumImagery.prototype */ {
      /**
       * The name of this type of model
       * @type {string}
       */
      type: "CesiumImagery",

      /**
       * Options that are supported for creating imagery tiles. Any properties provided
       * here are passed to the Cesium constructor function, so other properties that
       * are documented in Cesium are also supported. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/BingMapsImageryProvider.html#.ConstructorOptions}
       * and
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/IonImageryProvider.html#.ConstructorOptions}.
       * @typedef {Object} CesiumImagery#cesiumOptions
       * @property {string|number} ionAssetId - If this imagery is hosted by Cesium
       * Ion, then Ion asset ID.
       * @property {string|number} key - A key or token required to access the tiles.
       * For example, if this is a BingMapsImageryProvider, then the Bing maps key. If
       * one is required and not set, the model will look in the {@link AppModel} for a
       * key, for example, {@link AppModel#bingMapsKey}
       * @property {'GeographicTilingScheme'|'WebMercatorTilingScheme'} tilingScheme -
       * The tiling scheme to use when constructing an imagery provider. If not set,
       * Cesium uses WebMercatorTilingScheme by default.
       * @property {Number[]} rectangle - The rectangle covered by the layer. The list
       * of west, south, east, north bounding degree coordinates, respectively. This
       * will be passed to Cesium.Rectangle.fromDegrees to define the bounding box of
       * the imagery layer. If left undefined, the layer will cover the entire globe.
       */

      /**
       * Default attributes for CesiumImagery models
       * @name CesiumImagery#defaults
       * @extends MapAsset#defaults
       * @type {Object}
       * @property {'BingMapsImageryProvider'|'IonImageryProvider'|'TileMapServiceImageryProvider'|'WebMapTileServiceImageryProvider'} type
       * A string indicating a Cesium Imagery Provider type. See
       * {@link https://cesium.com/learn/cesiumjs-learn/cesiumjs-imagery/#more-imagery-providers}
       * @property {Cesium.ImageryLayer} cesiumModel A 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/ImageryLayer.html?classFilter=ImageryLayer}
       * and
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/?classFilter=ImageryProvider}
       * @property {CesiumImagery#cesiumOptions} cesiumOptions options that 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: "",
          cesiumModel: null,
          cesiumOptions: {},
          // brightness: 1, contrast: 1, gamma: 1, hue: 0, saturation: 1,
        });
      },

      /**
       * Executed when a new CesiumImagery 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.type == "NaturalEarthII") {
            this.initNaturalEarthII(assetConfig);
          } else if (assetConfig.type == "USGSImageryTopo") {
            this.initUSGSImageryTopo(assetConfig);
          }

          this.createCesiumModel();

          this.getThumbnail();
        } catch (e) {
          console.log("Error initializing a CesiumImagery model: ", e);
        }
      },

      /**
       * Initializes a CesiumImagery model for the Natural Earth II asset.
       * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of the
       * attributes, which will be set on the model.
       */
      initNaturalEarthII: function (assetConfig) {
        try {
          if (
            !assetConfig.cesiumOptions ||
            typeof assetConfig.cesiumOptions !== "object"
          ) {
            assetConfig.cesiumOptions = {};
          }

          assetConfig.cesiumOptions.url = Cesium.buildModuleUrl(
            "Assets/Textures/NaturalEarthII",
          );
          this.set("type", "TileMapServiceImageryProvider");
          this.set("cesiumOptions", assetConfig.cesiumOptions);
        } catch (error) {
          console.log(
            "There was an error initializing NaturalEarthII in a CesiumImagery" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Initializes a CesiumImagery model for the USGS Imagery Topo asset.
       * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of the
       * attributes, which will be set on the model.
       */
      initUSGSImageryTopo: function (assetConfig) {
        try {
          if (
            !assetConfig.cesiumOptions ||
            typeof assetConfig.cesiumOptions !== "object"
          ) {
            assetConfig.cesiumOptions = {};
          }
          this.set("type", "WebMapServiceImageryProvider");
          assetConfig.cesiumOptions.url =
            "https://basemap.nationalmap.gov:443/arcgis/services/USGSImageryTopo/MapServer/WmsServer";
          assetConfig.cesiumOptions.layers = "0";
          assetConfig.cesiumOptions.parameters = {
            transparent: true,
            format: "image/png",
          };
          this.set("cesiumOptions", assetConfig.cesiumOptions);
          if (!assetConfig.moreInfoLink) {
            this.set(
              "moreInfoLink",
              "https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryTopo/MapServer",
            );
          }
          if (!assetConfig.attribution) {
            this.set(
              "attribution",
              "USGS The National Map: Orthoimagery and US Topo. Data refreshed January, 2022.",
            );
          }
          if (!assetConfig.description) {
            this.set(
              "description",
              "USGS Imagery Topo is a tile cache base map of orthoimagery in The National Map and US Topo vectors visible to the 1:9,028 scale.",
            );
          }
          if (!assetConfig.label) {
            this.set("label", "USGS Imagery Topo");
          }
        } catch (error) {
          console.log(
            "There was an error initializing USGSImageryTopo in a CesiumImagery" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Creates a Cesium ImageryLayer that contains information about how the imagery
       * should render in Cesium. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/ImageryLayer.html?classFilter=ImageryLay}
       * @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) {
        var model = this;
        const cesiumOptions = this.getCesiumOptions();
        var type = this.get("type");
        var providerFunction = Cesium[type];

        // If the cesium model already exists, don't create it again unless specified
        if (!recreate && this.get("cesiumModel")) {
          console.log("returning existing cesium model");
          return this.get("cesiumModel");
        }

        model.resetStatus();

        var initialAppearance = {
          alpha: this.get("opacity"),
          show: this.get("visible"),
          saturation: this.get("saturation"),
          // TODO: brightness, contrast, gamma, etc.
        };

        if (type === "BingMapsImageryProvider") {
          cesiumOptions.key =
            cesiumOptions.key || MetacatUI.AppConfig.bingMapsKey;
        } else if (type === "IonImageryProvider") {
          cesiumOptions.assetId = Number(cesiumOptions.ionAssetId);
          delete cesiumOptions.ionAssetId;
          cesiumOptions.accessToken =
            cesiumOptions.cesiumToken || MetacatUI.appModel.get("cesiumToken");
        } else if (type === "OpenStreetMapImageryProvider") {
          cesiumOptions.url =
            cesiumOptions.url || "https://a.tile.openstreetmap.org/";
        }
        if (cesiumOptions && cesiumOptions.tilingScheme) {
          const ts = cesiumOptions.tilingScheme;
          const availableTS = [
            "GeographicTilingScheme",
            "WebMercatorTilingScheme",
          ];
          if (availableTS.indexOf(ts) > -1) {
            cesiumOptions.tilingScheme = new Cesium[ts]();
          } else {
            console.log(
              `${ts} is not a valid tiling scheme. Using WebMercatorTilingScheme`,
            );
            cesiumOptions.tilingScheme = new Cesium.WebMercatorTilingScheme();
          }
        }

        if (cesiumOptions.rectangle) {
          cesiumOptions.rectangle = Cesium.Rectangle.fromDegrees(
            ...cesiumOptions.rectangle,
          );
        }

        if (providerFunction && typeof providerFunction === "function") {
          let provider = new providerFunction(cesiumOptions);
          provider.readyPromise
            .then(function () {
              // Imagery must be converted from a Cesium Imagery Provider to a Cesium
              // Imagery Layer. See
              // https://cesium.com/learn/cesiumjs-learn/cesiumjs-imagery/#imagery-providers-vs-layers
              model.set(
                "cesiumModel",
                new Cesium.ImageryLayer(provider, initialAppearance),
              );
              model.set("status", "ready");
              model.setListeners();
            })
            .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.",
          );
        }
      },

      /**
       * Set listeners that update the cesium model when the backbone model is updated.
       */
      setListeners: function () {
        try {
          var cesiumModel = this.get("cesiumModel");

          // Make sure the listeners are only set once!
          this.stopListening(this);

          this.listenTo(this, "change:opacity", function (model, opacity) {
            cesiumModel.alpha = opacity;
            // 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");
          });
          this.listenTo(this, "change:visible", function (model, visible) {
            cesiumModel.show = visible;
            // 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 setting listeners in a cesium Imagery model" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Gets a Cesium Bounding Sphere that can be used to navigate to view the full
       * extent of the imagery. See
       * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html}
       * @returns {Promise} Returns a promise that resolves to a Cesium Bounding Sphere
       * when ready
       */
      getBoundingSphere: function () {
        return this.whenReady()
          .then(function (model) {
            return model.get("cesiumModel").getViewableRectangle();
          })
          .then(function (rectangle) {
            return Cesium.BoundingSphere.fromRectangle3D(rectangle);
          });
      },

      /**
       * Requests a tile from the imagery provider that is at the center of the layer's
       * bounding box and at the minimum level. Once the image is fetched, sets its URL
       * on the thumbnail property of this model. This function is first called when the
       * layer initialized, but waits for the cesiumModel to be ready.
       */
      getThumbnail: function () {
        try {
          if (this.get("status") !== "ready") {
            this.listenToOnce(this, "change:status", this.getThumbnail);
            return;
          }

          const model = this;
          const cesImageryLayer = this.get("cesiumModel");
          const provider = cesImageryLayer.imageryProvider;
          const rect = cesImageryLayer.rectangle;
          var x = (rect.east + rect.west) / 2;
          var y = (rect.north + rect.south) / 2;
          var level = provider.minimumLevel;

          provider
            .requestImage(x, y, level)
            .then(function (response) {
              let data = response.blob;
              let objectURL = null;

              if (!data && response instanceof ImageBitmap) {
                objectURL = model.getDataUriFromBitmap(response);
              } else {
                objectURL = URL.createObjectURL(data);
              }

              model.set("thumbnail", objectURL);
            })
            .otherwise(function (e) {
              console.log(
                "Error requesting an image tile to use as a thumbnail for an " +
                  "Imagery Layer. Error message: " +
                  e,
              );
            });
        } catch (error) {
          console.log(
            "There was an error getting a thumbnail for a CesiumImagery layer" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Gets a data URI from a bitmap image.
       * @param {ImageBitmap} bitmap The bitmap image to convert to a data URI
       * @returns {String} Returns a string containing the requested data URI.
       */
      getDataUriFromBitmap: function (imageBitmap) {
        try {
          const canvas = document.createElement("canvas");
          canvas.width = imageBitmap.width;
          canvas.height = imageBitmap.height;
          const ctx = canvas.getContext("2d");
          // y-flip the image - Natural Earth II bitmaps appear upside down otherwise
          // TODO: Test with other imagery layers
          ctx.translate(0, imageBitmap.height);
          ctx.scale(1, -1);
          ctx.drawImage(imageBitmap, 0, 0);
          return canvas.toDataURL();
        } catch (error) {
          console.log(
            "There was an error converting an ImageBitmap to a data URL" +
              ". Error details: " +
              error,
          );
        }
      },

      // /**
      //  * Parses the given input into a JSON object to be set on the model.
      //  *
      //  * @param {TODO} input - The raw response object
      //  * @return {TODO} - The JSON object of all the Imagery attributes
      //    */
      // parse: function (input) {

      //   try {

      //     var modelJSON = {};

      //     return modelJSON

      //   }
      //   catch (error) {console.log('There was an error parsing a Imagery model' + '.
      //     Error details: ' + error
      //     );
      //   }

      // },

      // /**
      //  * Overrides the default Backbone.Model.validate.function() to check if this if
      //  * the values set on this model are valid.
      //  *
      //  * @param {Object} [attrs] - A literal object of model attributes to validate.
      //  * @param {Object} [options] - A literal object of options for this validation
      //  * process
      //  *
      //  * @return {Object} - Returns a literal object with the invalid attributes and
      //  * their corresponding error message, if there are any. If there are no errors,
      //  * returns nothing.
      //    */
      // validate: function (attrs, options) {try {

      //   }
      //   catch (error) {console.log('There was an error validating a CesiumImagery
      //     model' + '. Error details: ' + error
      //     );
      //   }
      // },

      // /**
      //  * Creates a string using the values set on this model's attributes.
      //  * @return {string} The Imagery string
      //    */
      // serialize: function () {try {var serializedImagery = "";

      //     return serializedImagery;
      //   }
      //   catch (error) {console.log('There was an error serializing a CesiumImagery
      //     model' + '. Error details: ' + error
      //     );
      //   }
      // },
    },
  );

  return CesiumImagery;
});