Models/ArcGisMapServerCatalogItem.js

"use strict";

/*global require*/

var ArcGisMapServerImageryProvider = require("terriajs-cesium/Source/Scene/ArcGisMapServerImageryProvider")
  .default;
var defaultValue = require("terriajs-cesium/Source/Core/defaultValue").default;
var defined = require("terriajs-cesium/Source/Core/defined").default;

var Ellipsoid = require("terriajs-cesium/Source/Core/Ellipsoid").default;
var getToken = require("./getToken");
var ImageryLayerCatalogItem = require("./ImageryLayerCatalogItem");
var ImageryProvider = require("terriajs-cesium/Source/Scene/ImageryProvider")
  .default;
var inherit = require("../Core/inherit");
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var Legend = require("../Map/Legend");
var LegendUrl = require("../Map/LegendUrl");
var loadJson = require("../Core/loadJson");
var Metadata = require("./Metadata");
var MetadataItem = require("./MetadataItem");
var overrideProperty = require("../Core/overrideProperty");
var proj4 = require("proj4").default;
var proj4definitions = require("../Map/Proj4Definitions");
var proxyCatalogItemUrl = require("./proxyCatalogItemUrl");
var Rectangle = require("terriajs-cesium/Source/Core/Rectangle").default;
var replaceUnderscores = require("../Core/replaceUnderscores");
var RequestErrorEvent = require("terriajs-cesium/Source/Core/RequestErrorEvent")
  .default;
var TerriaError = require("../Core/TerriaError");
var unionRectangleArray = require("../Map/unionRectangleArray");
var URI = require("urijs");
var WebMercatorTilingScheme = require("terriajs-cesium/Source/Core/WebMercatorTilingScheme")
  .default;
var when = require("terriajs-cesium/Source/ThirdParty/when").default;
var i18next = require("i18next").default;

/**
 * A {@link ImageryLayerCatalogItem} representing a layer from an Esri ArcGIS MapServer.
 *
 * @alias ArcGisMapServerCatalogItem
 * @constructor
 * @extends ImageryLayerCatalogItem
 *
 * @param {Terria} terria The Terria instance.
 */
var ArcGisMapServerCatalogItem = function(terria) {
  ImageryLayerCatalogItem.call(this, terria);

  this._legendUrl = undefined; // a LegendUrl object for a legend provided explicitly
  this._generatedLegendUrl = undefined; // a LegendUrl object pointing to a data URL of a legend generated by us
  this._mapServerData = undefined; // cached JSON response of server metadata
  this._layersData = undefined; // cached JSON response of layers metadata
  this._thisLayerInLayersData = undefined; // cached JSON response of one single layer
  this._allLayersInLayersData = undefined; // cached JSON response of either all layers, or [one layer].
  this._lastToken = undefined; // cached token
  this._newTokenRequestInFlight = undefined; // a promise for an in-flight token request

  /**
   * Gets or sets the comma-separated list of layer IDs to show.  If this property is undefined,
   * all layers are shown.
   * @type {String}
   */
  this.layers = undefined;

  /**
   * Gets or sets the denominator of the largest scale (smallest denominator) for which tiles should be requested.  For example, if this value is 1000, then tiles representing
   * a scale larger than 1:1000 (i.e. numerically smaller denominator, when zooming in closer) will not be requested.  Instead, tiles of the largest-available scale, as specified by this property,
   * will be used and will simply get blurier as the user zooms in closer.
   * @type {Number}
   */
  this.maximumScale = undefined;

  /**
   * Gets or sets the denominator of the largest scale (smallest denominator) beyond which to show a message explaining that no further zoom levels are available, at the request
   * of the data custodian.
   * @type {Number}
   */
  this.maximumScaleBeforeMessage = undefined;

  /**
   * Gets or sets a value indicating whether to continue showing tiles when the {@link ArcGisMapServerCatalogItem#maximumScaleBeforeMessage}
   * is exceeded.  This property is observable.
   * @type {Boolean}
   * @default true
   */
  this.showTilesAfterMessage = true;

  /**
   * Gets or sets a value indicating whether features in this catalog item can be selected by clicking them on the map.
   * @type {Boolean}
   * @default true
   */
  this.allowFeaturePicking = true;

  /**
   * Gets or sets the URL to use for requesting tokens.
   * @type {String}
   */
  this.tokenUrl = undefined;

  /**
   * Gets or sets the additional parameters to pass to the WMS server when requesting images.
   * All parameter names must be entered in lowercase in order to be consistent with references in TerrisJS code.
   * If this property is undefined, {@link WebMapServiceCatalogItem.defaultParameters} is used.
   * @type {Object}
   */
  this.parameters = {};

  knockout.track(this, [
    "layers",
    "maximumScale",
    "_legendUrl",
    "_generatedLegendUrl",
    "maximumScaleBeforeMessage",
    "showTilesAfterMessage",
    "allowFeaturePicking",
    "tokenUrl",
    "parameters"
  ]);

  // metadataUrl and legendUrl are derived from url if not explicitly specified.
  overrideProperty(this, "metadataUrl", {
    get: function() {
      if (defined(this._metadataUrl)) {
        return this._metadataUrl;
      }

      return cleanUrl(this.url);
    },
    set: function(value) {
      this._metadataUrl = value;
    }
  });

  overrideProperty(this, "legendUrl", {
    get: function() {
      if (defined(this._legendUrl)) {
        return this._legendUrl;
      } else if (defined(this._generatedLegendUrl)) {
        return this._generatedLegendUrl;
      } else {
        return new LegendUrl(cleanUrl(this.url) + "/legend");
      }
    },
    set: function(value) {
      this._legendUrl = value;
    }
  });

  // The dataUrl must be explicitly specified.  Don't try to use `url` as the the dataUrl.
  overrideProperty(this, "dataUrl", {
    get: function() {
      return this._dataUrl;
    },
    set: function(value) {
      this._dataUrl = value;
    }
  });

  overrideProperty(this, "dataUrlType", {
    get: function() {
      return this._dataUrlType;
    },
    set: function(value) {
      this._dataUrlType = value;
    }
  });
};

inherit(ImageryLayerCatalogItem, ArcGisMapServerCatalogItem);

Object.defineProperties(ArcGisMapServerCatalogItem.prototype, {
  /**
   * Gets the type of data item represented by this instance.
   * @memberOf ArcGisMapServerCatalogItem.prototype
   * @type {String}
   */
  type: {
    get: function() {
      return "esri-mapServer";
    }
  },

  /**
   * Gets a human-readable name for this type of data source, 'Esri ArcGIS MapServer'.
   * @memberOf ArcGisMapServerCatalogItem.prototype
   * @type {String}
   */
  typeName: {
    get: function() {
      return i18next.t("models.arcGisMapServerCatalogItem.name");
    }
  },

  /**
   * Gets the metadata associated with this data source and the server that provided it, if applicable.
   * @memberOf ArcGisMapServerCatalogItem.prototype
   * @type {Metadata}
   */
  metadata: {
    get: function() {
      if (!defined(this._metadata)) {
        this._metadata = requestMetadata(this);
      }
      return this._metadata;
    }
  }
});

/*
 Goal: To match URLs ending in MapServer/0 where 0 is any number
 but also allowing for an optional final /, and ? and # terms.
 For simplicity, match any path that includes /MapServer/0
 */
var partsRegex = new RegExp("^(.*/MapServer/)([0-9]+)", "i");

function getBaseURI(item) {
  var uri = new URI(item.url);
  if (uri.segment(-1).match(/\d+/)) {
    uri.segment(-1, "");
  }
  return uri;
}

function getJson(item, uri) {
  return loadJson(
    proxyCatalogItemUrl(item, uri.addQuery("f", "json").toString(), "1d")
  );
}

ArcGisMapServerCatalogItem.prototype._load = function() {
  var that = this;

  if (!defined(this._mapServerData) || !defined(this._layersData)) {
    var uri = new URI(this.url);
    var layers = "layers";
    if (uri.segment(-1).match(/\d+/)) {
      // URL is a single REST layer, like .../arcgis/rest/services/Society/Society_SCRC/MapServer/16
      layers = uri.segment(-1);
      this.layers = layers; // ## is this ok to do?
    }

    var promise = when();
    if (this.tokenUrl) {
      promise = getToken(this.terria, this.tokenUrl, this.url);
    }

    return promise.then(function(token) {
      that._lastToken = token;

      var serviceUri = getBaseURI(that);
      var layersUri = getBaseURI(that).segment(layers); // either 'layers' or a number
      var legendUri = getBaseURI(that).segment("legend");

      if (token) {
        serviceUri.addQuery("token", token);
        layersUri.addQuery("token", token);
        legendUri.addQuery("token", token);
      }

      var serviceMetadata = that._mapServerData || getJson(that, serviceUri);
      var layersMetadata = that._layersData || getJson(that, layersUri);
      var legendMetadata = that._legendData || getJson(that, legendUri);

      return when
        .all([serviceMetadata, layersMetadata, legendMetadata])
        .then(function(results) {
          if (defined(results[1].layers)) {
            that.updateFromMetadata(results[0], results[1], results[2], false);
          } else if (defined(results[1].id)) {
            // Results of a single layer query. Make it look like a multi layer query result.
            that.updateFromMetadata(
              results[0],
              { layers: [results[1]] },
              results[2],
              false,
              results[1]
            );
          } else {
            var message = defined(results[0].error)
              ? results[0].error.message
              : "This dataset returned unusable metadata.";
            throw new TerriaError({
              title: "ArcGIS Mapserver Error",
              message:
                "<p>" +
                message +
                '</p><p>Please report it by \
sending an email to <a href="mailto:' +
                that.terria.supportEmail +
                '">' +
                that.terria.supportEmail +
                "</a>.</p>"
            });
          }
        });
    });
  }
};

ArcGisMapServerCatalogItem.prototype.handleTileError = function(
  detailsRequestPromise,
  imageryProvider,
  x,
  y,
  level
) {
  if (!defined(this.tokenUrl)) {
    return detailsRequestPromise;
  }

  const that = this;
  return detailsRequestPromise
    .otherwise(function(e) {
      if (e && (e.statusCode === 498 || e.statusCode === 499)) {
        return requestToken(that, imageryProvider);
      } else {
        return when.reject(e);
      }
    })
    .then(function(responseText) {
      // On an `export` request with an expired or invalid token, ArcGIS returns
      // a 200 response with a JSON payload indicating an error.
      try {
        const json = JSON.parse(responseText);
        if (json && json.error && json.error.code) {
          if (json.error.code === 498 || json.error.code === 499) {
            return requestToken(that, imageryProvider);
          } else {
            // A non-token error occurred, tile fails.
            return when.reject(
              new RequestErrorEvent(json.error.code, json.error.message)
            );
          }
        }
      } catch (e) {}

      // Not JSON or not an error, so let's retry.
      return responseText;
    });
};

function requestToken(catalogItem, imageryProvider) {
  if (!defined(catalogItem._newTokenRequestInFlight)) {
    catalogItem._newTokenRequestInFlight = getToken(
      catalogItem.terria,
      catalogItem.tokenUrl,
      catalogItem.url
    ).then(function(token) {
      catalogItem._lastToken = token;
      imageryProvider.token = token;
      catalogItem._newTokenRequestInFlight = undefined;
    });
  }

  return catalogItem._newTokenRequestInFlight;
}

ArcGisMapServerCatalogItem.prototype._createImageryProvider = function() {
  var maximumLevel = maximumScaleToLevel(this.maximumScale);
  var r = partsRegex.exec(this.url);
  var baseUrl = r && r[2] ? r[1] : this.url;
  // Strip trailing forward slash if exists
  baseUrl = baseUrl.replace(/\/$/g, "");

  const dynamicRequired = this.layers && this.layers.length > 0;

  const imageryOptions = {
    url: cleanAndProxyUrl(this, baseUrl),
    layers: getLayerList(this),
    tilingScheme: new WebMercatorTilingScheme(),
    maximumLevel: maximumLevel,
    mapServerData: this._mapServerData,
    enablePickFeatures: defaultValue(this.allowFeaturePicking, true),
    usePreCachedTilesIfAvailable: !dynamicRequired,
    parameters: this.parameters
  };

  if (defined(this._lastToken)) {
    // Using the last token is an optimization; if its still valid it will speed up
    // the operation and if its not then it will just be requested when its needed.
    imageryOptions.token = this._lastToken;
  }

  // if catalog contains a hand-crafted legend image, we respect it.
  if (!defined(this._legendUrl) && defined(this._legendData)) {
    this.loadLegendFromJson(this._legendData); // a promise.
  }

  var imageryProvider = new ArcGisMapServerImageryProvider(imageryOptions);

  var maximumLevelBeforeMessage = maximumScaleToLevel(
    this.maximumScaleBeforeMessage
  );
  if (defined(maximumLevelBeforeMessage)) {
    var realRequestImage = imageryProvider.requestImage;
    var messageDisplayed = false;

    var that = this;
    imageryProvider.requestImage = function(x, y, level) {
      if (level > maximumLevelBeforeMessage) {
        if (!messageDisplayed) {
          that.terria.error.raiseEvent(
            new TerriaError({
              title: "Dataset will not be shown at this scale",
              message:
                'The "' +
                that.name +
                '" dataset will not be shown when zoomed in this close to the map because the data custodian has ' +
                "indicated that the data is not intended or suitable for display at this scale.  Click the dataset's Info button on the " +
                "Now Viewing tab for more information about the dataset and the data custodian."
            })
          );
          messageDisplayed = true;
        }

        if (!that.showTilesAfterMessage) {
          return ImageryProvider.loadImage(
            imageryProvider,
            that.terria.baseUrl + "images/blank.png"
          );
        }
      }
      return realRequestImage.call(imageryProvider, x, y, level);
    };
  }

  return imageryProvider;
};

var noDataRegex = /^No[\s_-]?Data$/i;

/**
 * Updates this catalog item from a the MapServer metadata and the MapServer/layers metadata.
 * @param {Object} mapServerJson The JSON metadata found at the /MapServer URL.
 * @param {Object} layersJson The JSON metadata found at the /MapServer/layers URL.
 * @param {Boolean} [overwrite=false] True to overwrite existing property values with data from the metadata; false to
 *                  preserve any existing values.
 * @param {Object} [thisLayerJson] A reference to this layer within the `layersJson` object.  If this parameter is not
 *                 specified, the layer is found automatically based on this catalog item's `layers` property.
 */
ArcGisMapServerCatalogItem.prototype.updateFromMetadata = function(
  mapServerJson,
  layersJson,
  legendJson,
  overwrite,
  thisLayerJson
) {
  var i;

  if (!defined(thisLayerJson)) {
    thisLayerJson = findLayers(layersJson.layers, this.layers);
    if (!defined(thisLayerJson)) {
      return;
    }

    if (defined(this.layers)) {
      var layers = this.layers.split(",");
      for (i = 0; i < thisLayerJson.length; ++i) {
        if (!defined(thisLayerJson[i])) {
          console.log(
            'A layer with the name or ID "' +
              layers[i] +
              '" does not exist on the ArcGIS MapServer - ignoring it.'
          );
          thisLayerJson.splice(i, 1);
          layers.splice(i, 1);
          --i;
        }
      }
    }

    if (thisLayerJson.length === 0) {
      return;
    }
  }
  /*
   * Set the name of catalog item, item name check is done because
   * ArcGisMapServerCatalogGroup is setting the name for its ArcGisMapServerCatalogItems
   * so we avoid to set name twice
   */
  if (
    defined(thisLayerJson.name) &&
    (this.name === this.url || this.name === "Unnamed Item")
  ) {
    this.name = replaceUnderscores(thisLayerJson.name);
  }

  this._mapServerData = mapServerJson;
  this._layersData = layersJson;
  this._legendData = legendJson;

  if (Array.isArray(thisLayerJson)) {
    this._thisLayerInLayersData = thisLayerJson[0];
    this._allLayersInLayersData = thisLayerJson;
    thisLayerJson = this._thisLayerInLayersData;
  } else {
    this._thisLayerInLayersData = thisLayerJson;
    this._allLayersInLayersData = [thisLayerJson];
  }

  updateValue(
    this,
    overwrite,
    "dataCustodian",
    getDataCustodian(mapServerJson)
  );
  updateValue(
    this,
    overwrite,
    "rectangle",
    getRectangleFromLayers(this._allLayersInLayersData)
  );

  var minimumMaxScale = Number.MAX_VALUE;
  var minimumMaxScaleWithoutNoData = Number.MAX_VALUE;
  for (i = 0; i < this._allLayersInLayersData.length; ++i) {
    var l = this._allLayersInLayersData[i];
    if (l.maxScale < minimumMaxScale) {
      minimumMaxScale = l.maxScale;
    }
    if (
      !noDataRegex.test(l.name) &&
      l.maxScale < minimumMaxScaleWithoutNoData
    ) {
      minimumMaxScaleWithoutNoData = l.maxScale;
    }
  }

  if (minimumMaxScale !== Number.MAX_VALUE) {
    updateValue(this, overwrite, "maximumScale", minimumMaxScale);
  }
  if (minimumMaxScaleWithoutNoData !== minimumMaxScale) {
    updateValue(
      this,
      overwrite,
      "maximumScaleBeforeMessage",
      minimumMaxScaleWithoutNoData
    );
  }

  updateInfoSection(
    this,
    overwrite,
    i18next.t("models.arcGisMapServerCatalogItem.dataDescription"),
    thisLayerJson.description
  );
  updateInfoSection(
    this,
    overwrite,
    i18next.t("models.arcGisMapServerCatalogItem.serviceDescription"),
    mapServerJson.serviceDescription
  );
  updateInfoSection(
    this,
    overwrite,
    i18next.t("models.arcGisMapServerCatalogItem.serviceDescription"),
    mapServerJson.description
  );

  var copyrightText =
    defined(thisLayerJson.copyrightText) &&
    thisLayerJson.copyrightText.length > 0
      ? thisLayerJson.copyrightText
      : mapServerJson.copyrightText;
  updateInfoSection(
    this,
    overwrite,
    i18next.t("models.arcGisMapServerCatalogItem.copyrightText"),
    copyrightText
  );
};

function maximumScaleToLevel(maximumScale) {
  if (!defined(maximumScale) || maximumScale <= 0.0) {
    return undefined;
  }

  var dpi = 96; // Esri default DPI, unless we specify otherwise.
  var centimetersPerInch = 2.54;
  var centimetersPerMeter = 100;
  var dotsPerMeter = (dpi * centimetersPerMeter) / centimetersPerInch;
  var tileWidth = 256;

  var circumferenceAtEquator = 2 * Math.PI * Ellipsoid.WGS84.maximumRadius;
  var distancePerPixelAtLevel0 = circumferenceAtEquator / tileWidth;
  var level0ScaleDenominator = distancePerPixelAtLevel0 * dotsPerMeter;

  // 1e-6 epsilon from WMS 1.3.0 spec, section 7.2.4.6.9.
  var ratio = level0ScaleDenominator / (maximumScale - 1e-6);
  var levelAtMinScaleDenominator = Math.log(ratio) / Math.log(2);
  return levelAtMinScaleDenominator | 0;
}

function getRectangleFromLayer(thisLayerJson) {
  var extent = thisLayerJson.extent;
  if (
    defined(extent) &&
    extent.spatialReference &&
    extent.spatialReference.wkid
  ) {
    var wkid = "EPSG:" + extent.spatialReference.wkid;
    if (!defined(proj4definitions[wkid])) {
      return undefined;
    }

    var source = new proj4.Proj(proj4definitions[wkid]);
    var dest = new proj4.Proj("EPSG:4326");

    var p = proj4(source, dest, [extent.xmin, extent.ymin]);

    var west = p[0];
    var south = p[1];

    p = proj4(source, dest, [extent.xmax, extent.ymax]);

    var east = p[0];
    var north = p[1];

    return Rectangle.fromDegrees(west, south, east, north);
  }

  return undefined;
}

function getRectangleFromLayers(layers) {
  if (!Array.isArray(layers)) {
    return getRectangleFromLayer(layers);
  }

  return unionRectangleArray(
    layers.map(function(item) {
      return getRectangleFromLayer(item);
    })
  );
}

function updateInfoSection(item, overwrite, sectionName, sectionValue) {
  if (!defined(sectionValue) || sectionValue.length === 0) {
    return;
  }

  var section = item.findInfoSection(sectionName);
  if (!defined(section)) {
    item.info.push({
      name: sectionName,
      content: sectionValue
    });
  } else if (overwrite) {
    section.content = sectionValue;
  }
}

function updateValue(item, overwrite, propertyName, propertyValue) {
  if (!defined(propertyValue)) {
    return;
  }

  if (overwrite || !defined(item[propertyName])) {
    item[propertyName] = propertyValue;
  }
}

function getDataCustodian(mapServerJson) {
  if (
    mapServerJson &&
    mapServerJson.documentInfo &&
    mapServerJson.documentInfo.Author &&
    mapServerJson.documentInfo.Author.length > 0
  ) {
    return mapServerJson.documentInfo.Author;
  }
  return undefined;
}

function cleanAndProxyUrl(catalogItem, url) {
  return proxyCatalogItemUrl(catalogItem, cleanUrl(url));
}

function cleanUrl(url) {
  // Strip off the search portion of the URL
  var uri = new URI(url);
  uri.search("");
  return uri.toString();
}

function requestMetadata(item) {
  var result = new Metadata();

  result.isLoading = true;

  result.promise = when(item.load())
    .then(function() {
      populateMetadataGroup(result.serviceMetadata, item._mapServerData);

      if (!defined(item.layers)) {
        result.dataSourceErrorMessage =
          "Using all layers from this service that are visible by default.  See the Service Details below.";
      } else if (defined(item._thisLayerInLayersData)) {
        populateMetadataGroup(
          result.dataSourceMetadata,
          item._thisLayerInLayersData
        );
      } else {
        result.dataSourceErrorMessage = "No details are available.";
      }

      result.isLoading = false;
    })
    .otherwise(function() {
      result.dataSourceErrorMessage =
        "An error occurred while invoking the ArcGIS map service.";
      result.serviceErrorMessage =
        "An error occurred while invoking the ArcGIS map service.";
      result.isLoading = false;
    });

  return result;
}

function populateMetadataGroup(metadataGroup, sourceMetadata) {
  if (typeof sourceMetadata === "string" || sourceMetadata instanceof String) {
    return;
  }

  if (
    sourceMetadata instanceof Array &&
    (sourceMetadata.length === 0 || typeof sourceMetadata[0] !== "object")
  ) {
    return;
  }

  for (var name in sourceMetadata) {
    if (sourceMetadata.hasOwnProperty(name)) {
      var value = sourceMetadata[name];

      var dest = new MetadataItem();
      dest.name = name;
      dest.value = value;

      populateMetadataGroup(dest, value);

      metadataGroup.items.push(dest);
    }
  }
}

function findLayer(layers, id) {
  id = id.toString();
  var idLowerCase = id.toLowerCase();
  var foundByName;
  for (var i = 0; i < layers.length; ++i) {
    var layer = layers[i];
    if (layer.id.toString() === id) {
      return layer;
    } else if (layer.name.toLowerCase() === idLowerCase) {
      foundByName = layer;
    }
  }
  return foundByName;
}

/* Given a comma-separated string of layer names, returns the layer objects corresponding to them. */
function findLayers(layers, names) {
  if (!defined(names)) {
    // If a list of layers is not specified, we're using all layers.
    return layers;
  }
  return names.split(",").map(function(id) {
    return findLayer(layers, id);
  });
}

function getLayerList(catalogItem) {
  if (
    catalogItem._allLayersInLayersData &&
    catalogItem._allLayersInLayersData.length > 0
  ) {
    var layers = [];
    for (var i = 0; i < catalogItem._allLayersInLayersData.length; ++i) {
      if (
        defined(catalogItem._allLayersInLayersData[i]) &&
        defined(catalogItem._allLayersInLayersData[i].id)
      ) {
        layers.push(catalogItem._allLayersInLayersData[i].id.toString());
      }
    }
    return layers.join(",");
  } else {
    return catalogItem.layers;
  }
}

// Load a data URI and wait for it to load, returning an item. All of this is because data URI's don't load instantly,
// and we need to load the image in order to pass its dimensions.
// Alternative solution: just hardcode 26x26.
function loadImage(title, imageURI) {
  var img = new Image();
  img.src = imageURI;
  var deferred = when.defer();
  img.onload = deferred.resolve;
  return deferred.promise.then(function() {
    return {
      title: title,
      image: img,
      imageUrl: imageURI,
      imageWidth: img.width,
      imageHeight: img.height
    };
  });
}

var labelsRegex = /_Labels$/;

/**
 * Turns JSON into a LegendUrl.
 * @param  {Object} json  JSON retrieved from server.
 * @return {Promise}
 */
ArcGisMapServerCatalogItem.prototype.loadLegendFromJson = function(json) {
  var options = { title: "" };
  var layers = !defined(this.layers)
    ? []
    : this.layers.toLowerCase().split(",");
  var itemPromises = [];
  var shownLegends = {};
  json.layers.forEach(function(l) {
    if (noDataRegex.test(l.layerName) || labelsRegex.test(l.layerName)) {
      return;
    }
    if (
      defined(this.layers) &&
      layers.indexOf(String(l.layerId)) < 0 &&
      layers.indexOf(l.layerName.toLowerCase()) < 0
    ) {
      return;
    }
    options.title = replaceUnderscores(l.layerName);
    l.legend.forEach(function(leg) {
      if (shownLegends[leg.label + leg.imageData]) {
        // Hide truly duplicate layers.
        return;
      }
      shownLegends[leg.label + leg.imageData] = true;
      var title = leg.label !== "" ? leg.label : l.layerName;
      itemPromises.push(
        loadImage(
          replaceUnderscores(title),
          "data:" + leg.contentType + ";base64," + leg.imageData
        )
      );
    }, this);
  }, this);
  var that = this;
  if (itemPromises.length === 0) {
    return;
  }
  return when
    .all(itemPromises)
    .then(function(items) {
      items.reverse();
      options.items = items;
      return (that._generatedLegendUrl = new Legend(options).getLegendUrl());
    })
    .otherwise(function(error) {
      throw error;
    });
};

module.exports = ArcGisMapServerCatalogItem;