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

"use strict";

define([
  "jquery",
  "underscore",
  "backbone",
  "cesium",
  "models/maps/assets/MapAsset",
], function ($, _, Backbone, Cesium, MapAsset) {
  const PROVIDER_TYPES = {
    WMTS: "WebMapTileServiceImageryProvider",
    WMS: "WebMapServiceImageryProvider",
    TMS: "TileMapServiceImageryProvider",
    ION: "IonImageryProvider",
    OSM: "OpenStreetMapImageryProvider",
    BING: "BingMapsImageryProvider",
    NATURAL_EARTH: "NaturalEarthII",
    USGS: "USGSImageryTopo",
  };
  const OSM_DEFAULT_URL = "https://tile.openstreetmap.org/";
  const BING_DEFAULT_URL = "https://dev.virtualearth.net";
  const VALIDATION_MESSAGES = {
    WMTS_URL: "Enter a tile URL with {TileMatrix}, {TileCol}, and {TileRow}.",
    ION_ID: "Enter a Cesium Ion asset ID as a whole number greater than 0.",
    SERVICE_URL: "Enter the URL of the imagery service.",
    WMS_LAYERS:
      "Enter at least one layer name from the WMS service. Separate multiple names with commas.",
    RECTANGLE:
      "Enter all four coordinates in degrees, or leave all four blank. Longitudes must be between -180 and 180; latitudes between -90 and 90. South must be less than or equal to north.",
  };

  /**
   * @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 (this.get("type") === PROVIDER_TYPES.USGS) {
            this.initUSGSImageryTopo(assetConfig);
          }

          this.createCesiumModelWhenVisible();

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

      /**
       * Save the current imagery source. Provider creation expands shortcuts
       * and converts options on a copy, leaving these attributes as config.
       * @returns {MapConfig#MapAssetConfig} A detached object of settings to save.
       * @since 2.41.0
       */
      toConfig() {
        return {
          ...MapAsset.prototype.toConfig.call(this),
          type: this.get("type"),
          cesiumOptions: this.getCesiumOptions(),
        };
      },

      /**
       * Set default metadata 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.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() || {};
        let type = this.get("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 === PROVIDER_TYPES.NATURAL_EARTH) {
          type = PROVIDER_TYPES.TMS;
          cesiumOptions.url = Cesium.buildModuleUrl(
            "Assets/Textures/NaturalEarthII",
          );
        } else if (type === PROVIDER_TYPES.USGS) {
          type = PROVIDER_TYPES.WMS;
          cesiumOptions.url =
            "https://basemap.nationalmap.gov:443/arcgis/services/USGSImageryTopo/MapServer/WmsServer";
          cesiumOptions.layers = "0";
          cesiumOptions.parameters = {
            transparent: true,
            format: "image/png",
          };
        } else if (type === PROVIDER_TYPES.BING) {
          cesiumOptions.url = cesiumOptions.url || BING_DEFAULT_URL;
          cesiumOptions.key =
            cesiumOptions.key || MetacatUI.AppConfig.bingMapsKey;
        } else if (type === PROVIDER_TYPES.ION) {
          cesiumOptions.assetId = Number(cesiumOptions.ionAssetId);
          delete cesiumOptions.ionAssetId;
          cesiumOptions.accessToken =
            cesiumOptions.cesiumToken || MetacatUI.appModel.get("cesiumToken");
        } else if (type === PROVIDER_TYPES.OSM) {
          cesiumOptions.url = cesiumOptions.url || OSM_DEFAULT_URL;
        }
        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,
          );
        }

        const providerFunction = Cesium[type];
        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
              const imageryLayer = new Cesium.ImageryLayer(
                provider,
                initialAppearance,
              );
              imageryLayer.mapAssetModel = model;
              model.set("cesiumModel", imageryLayer);
              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");

          if (!cesiumModel) {
            this.listenToOnce(this, "change:cesiumModel", this.setListeners);
            return;
          }

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

      /**
       * Returns true when this imagery layer has at least one drawable tile
       * in cache and is currently shown in the scene.
       * @param {Cesium.Scene} scene The scene where this imagery is rendered.
       * @returns {boolean} True when imagery should be considered display-ready.
       * @since 2.41.0
       */
      isDisplayReadyInScene: function (scene) {
        const imageryLayer = this.get("cesiumModel");
        if (!imageryLayer || !scene || !scene.imageryLayers) {
          return false;
        }
        if (!imageryLayer.show || !scene.imageryLayers.contains(imageryLayer)) {
          return false;
        }

        const imageryCache = imageryLayer._imageryCache;
        if (!imageryCache || typeof imageryCache !== "object") {
          return false;
        }

        const cacheEntries = Object.values(imageryCache);
        if (!cacheEntries.length) {
          return false;
        }

        const imageryState = Cesium.ImageryState;
        return cacheEntries.some(function (entry) {
          if (!entry) {
            return false;
          }
          return (
            entry.state === imageryState.READY ||
            entry.state === imageryState.TEXTURE_LOADED
          );
        });
      },

      /**
       * Wrap this layer's imagery provider so outstanding tile requests can
       * be counted directly from the promises Cesium creates, instead of
       * inferred from Cesium's internal tile cache. The cache can retain
       * stale/orphaned entries in a transient state indefinitely - for
       * example, an ancestor tile kept around for level-of-detail fallback
       * that Cesium stops reprocessing once it's no longer needed for the
       * current view - which would otherwise look like "still loading" even
       * though there is no real network/processing activity. Counting
       * requestImage calls avoids that: a call either returns undefined
       * synchronously (Cesium declined to make a request - no work done) or
       * a promise that settles when the real work finishes.
       * @param {Cesium.ImageryProvider} provider The provider to wrap.
       * @param {Function} onPendingCountChange Called with the current
       * pending request count whenever it changes.
       * @returns {Function} A function that restores the original
       * requestImage function.
       * @since 2.41.0
       */
      wrapRequestImageForPendingCount: function (
        provider,
        onPendingCountChange,
      ) {
        if (!provider || typeof provider.requestImage !== "function") {
          return function () {};
        }

        const originalRequestImage = provider.requestImage.bind(provider);
        let pendingCount = 0;
        let isUnwrapped = false;

        provider.requestImage = function (x, y, level, request) {
          const result = originalRequestImage(x, y, level, request);
          if (result && typeof result.then === "function") {
            pendingCount += 1;
            onPendingCountChange(pendingCount);
            const onSettled = function () {
              if (isUnwrapped) {
                return;
              }
              pendingCount = Math.max(0, pendingCount - 1);
              onPendingCountChange(pendingCount);
            };
            result.then(onSettled, onSettled);
          }
          return result;
        };

        return function unwrap() {
          isUnwrapped = true;
          if (provider.requestImage !== originalRequestImage) {
            provider.requestImage = originalRequestImage;
          }
        };
      },

      /**
       * Start watching scene render events to mark this asset display-ready
       * after its first drawable tile, and wrap the imagery provider to
       * continuously track whether the layer currently has outstanding tile
       * requests. Unlike displayReady (a one-time latch), tilesLoading is
       * kept in sync for as long as the layer is in the scene, so the map
       * loading indicator does not get stuck when no tiles are ever in view
       * (or when Cesium abandons stale cache entries), and keeps working
       * while panning brings new tiles into view after the first one loads.
       * @param {object} context A context object with a Cesium scene.
       * @param {Cesium.Scene} context.scene The scene where imagery is drawn.
       * @since 2.41.0
       */
      startLoadingStateTracking: function (context) {
        const scene = context?.scene;
        const model = this;
        this.stopLoadingStateTracking();

        const imageryLayer = this.get("cesiumModel");
        if (!scene || !imageryLayer) {
          return;
        }

        model.set("tilesLoading", false);

        let isCanceled = false;
        let removePostRenderListener = null;
        let unwrapRequestImage = null;

        const cleanupListeners = function () {
          if (isCanceled) {
            return;
          }
          isCanceled = true;
          if (typeof removePostRenderListener === "function") {
            removePostRenderListener();
            removePostRenderListener = null;
          }
          if (typeof unwrapRequestImage === "function") {
            unwrapRequestImage();
            unwrapRequestImage = null;
          }
          model.loadingStateTrackerCancel = null;
        };

        const checkDisplayReady = function () {
          if (isCanceled) {
            return;
          }
          if (model.get("status") === "error") {
            model.set("tilesLoading", false);
            cleanupListeners();
            return;
          }
          if (
            model.get("displayReady") !== true &&
            model.isDisplayReadyInScene(scene)
          ) {
            model.set("displayReady", true);
          }
        };

        unwrapRequestImage = model.wrapRequestImageForPendingCount(
          imageryLayer.imageryProvider,
          function (pendingCount) {
            if (isCanceled) {
              return;
            }
            model.set("tilesLoading", pendingCount > 0);
          },
        );

        removePostRenderListener =
          scene.postRender.addEventListener(checkDisplayReady);
        this.loadingStateTrackerCancel = cleanupListeners;

        checkDisplayReady();
      },

      /**
       * Stop any pending display-ready/loading-state watcher for this
       * imagery layer.
       * @since 2.41.0
       */
      stopLoadingStateTracking: function () {
        if (typeof this.loadingStateTrackerCancel === "function") {
          this.loadingStateTrackerCancel();
        }
        this.loadingStateTrackerCancel = null;
        this.set("tilesLoading", null);
      },

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

      /**
       * Validate candidate imagery source options without changing them.
       * @param {object} attrs The candidate model attributes
       * @returns {object|undefined} Field messages, or no error when valid
       * @since 2.41.0
       */
      validate(attrs) {
        const source = attrs.cesiumOptions || {};
        const errors = {};

        if (attrs.type === PROVIDER_TYPES.WMTS) {
          const isTileTemplate =
            typeof source.url === "string" &&
            ["{TileMatrix}", "{TileCol}", "{TileRow}"].every((placeholder) =>
              source.url.includes(placeholder),
            );
          const isKvpService =
            typeof source.url === "string" &&
            source.url.trim() &&
            // Cesium also supports a single {s} subdomain token in KVP URLs.
            !source.url.replace("{s}", "").includes("{") &&
            ["layer", "style", "tileMatrixSetID"].every(
              (key) => typeof source[key] === "string",
            );
          if (!isTileTemplate && !isKvpService) {
            errors["cesiumOptions.url"] = VALIDATION_MESSAGES.WMTS_URL;
          }
        }

        if (attrs.type === PROVIDER_TYPES.ION) {
          const id =
            typeof source.ionAssetId === "number" ||
            typeof source.ionAssetId === "string"
              ? Number(source.ionAssetId)
              : NaN;
          if (!Number.isInteger(id) || id <= 0) {
            errors["cesiumOptions.ionAssetId"] = VALIDATION_MESSAGES.ION_ID;
          }
        }

        const url =
          attrs.type === PROVIDER_TYPES.BING
            ? source.url || BING_DEFAULT_URL
            : source.url;
        if (
          [
            PROVIDER_TYPES.WMS,
            PROVIDER_TYPES.TMS,
            PROVIDER_TYPES.BING,
          ].includes(attrs.type) &&
          (typeof url !== "string" || !url.trim())
        ) {
          errors["cesiumOptions.url"] = VALIDATION_MESSAGES.SERVICE_URL;
        }

        if (
          attrs.type === PROVIDER_TYPES.WMS &&
          (typeof source.layers !== "string" || !source.layers.trim())
        ) {
          errors["cesiumOptions.layers"] = VALIDATION_MESSAGES.WMS_LAYERS;
        }

        const { rectangle } = source;
        if (
          rectangle !== undefined &&
          !(
            Array.isArray(rectangle) &&
            rectangle.length === 4 &&
            rectangle.every(Number.isFinite) &&
            Math.abs(rectangle[0]) <= 180 &&
            Math.abs(rectangle[1]) <= 90 &&
            Math.abs(rectangle[2]) <= 180 &&
            Math.abs(rectangle[3]) <= 90 &&
            rectangle[1] <= rectangle[3]
          )
        ) {
          errors["cesiumOptions.rectangle"] = VALIDATION_MESSAGES.RECTANGLE;
        }

        return Object.keys(errors).length ? errors : undefined;
      },
    },
  );

  /**
   * Imagery source type identifiers shared by the model and editor.
   * @type {Object<string, string>}
   * @since 2.41.0
   */
  CesiumImagery.PROVIDER_TYPES = PROVIDER_TYPES;

  /**
   * Standard OpenStreetMap tile server URL shared by the model and editor.
   * @type {string}
   * @since 2.41.0
   */
  CesiumImagery.OSM_DEFAULT_URL = OSM_DEFAULT_URL;

  /**
   * Standard Bing Maps service URL shared by the model and editor.
   * @type {string}
   * @since 2.41.0
   */
  CesiumImagery.BING_DEFAULT_URL = BING_DEFAULT_URL;

  return CesiumImagery;
});