Models/RegionMapping.js

/*global require*/
"use strict";

var CallbackProperty = require("terriajs-cesium/Source/DataSources/CallbackProperty")
  .default;
var CesiumEvent = require("terriajs-cesium/Source/Core/Event").default;
var combine = require("terriajs-cesium/Source/Core/combine").default;
var defaultValue = require("terriajs-cesium/Source/Core/defaultValue").default;
var defined = require("terriajs-cesium/Source/Core/defined").default;

var destroyObject = require("terriajs-cesium/Source/Core/destroyObject")
  .default;
var DeveloperError = require("terriajs-cesium/Source/Core/DeveloperError")
  .default;
var ImageryLayerFeatureInfo = require("terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo")
  .default;
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var TimeInterval = require("terriajs-cesium/Source/Core/TimeInterval").default;
var WebMapServiceImageryProvider = require("terriajs-cesium/Source/Scene/WebMapServiceImageryProvider")
  .default;
var WebMercatorTilingScheme = require("terriajs-cesium/Source/Core/WebMercatorTilingScheme")
  .default;
var when = require("terriajs-cesium/Source/ThirdParty/when").default;
var Rectangle = require("terriajs-cesium/Source/Core/Rectangle").default;
var uniq = require("lodash.uniq");

var calculateImageryLayerIntervals = require("./calculateImageryLayerIntervals");
var ImageryLayerCatalogItem = require("../Models/ImageryLayerCatalogItem");
var ImageryProviderHooks = require("../Map/ImageryProviderHooks");
var Leaflet = require("../Models/Leaflet");
var LegendHelper = require("../Models/LegendHelper");
var proxyCatalogItemUrl = require("../Models/proxyCatalogItemUrl");
var RegionProviderList = require("../Map/RegionProviderList");
var TableStructure = require("../Map/TableStructure");
var TableStyle = require("../Models/TableStyle");
var TerriaError = require("../Core/TerriaError");
var VarType = require("../Map/VarType");
var WebMapServiceCatalogItem = require("../Models/WebMapServiceCatalogItem");
var MapboxVectorTileImageryProvider = require("../Map/MapboxVectorTileImageryProvider");
var { setOpacity, fixNextLayerOrder } = require("./ImageryLayerPreloadHelpers");
var i18next = require("i18next").default;
/**
 * A DataSource for table-based data.
 * Handles the graphical display of lat-lon and region-mapped datasets.
 * For lat-lon data sets, each row is taken to be a feature. RegionMapping generates Cesium entities for each row.
 * For region-mapped data sets, each row is a region. The regions are displayed using a WMS imagery layer.
 * Displaying the points or regions requires a legend.
 *
 * @name RegionMapping
 *
 * @alias RegionMapping
 * @constructor
 * @param {CatalogItem} [catalogItem] The CatalogItem instance.
 * @param {TableStructure} [tableStructure] The Table Structure instance; defaults to a new one.
 * @param {TableStyle} [tableStyle] The table style; defaults to undefined.
 */
var RegionMapping = function(catalogItem, tableStructure, tableStyle) {
  this._tableStructure = defined(tableStructure)
    ? tableStructure
    : new TableStructure();
  if (defined(tableStyle) && !(tableStyle instanceof TableStyle)) {
    throw new DeveloperError("Please pass a TableStyle object.");
  }
  this._tableStyle = tableStyle; // Can be undefined.
  this._changed = new CesiumEvent();
  this._legendHelper = undefined;
  this._legendUrl = undefined;
  this._extent = undefined;
  this._loadingData = false;

  this._catalogItem = catalogItem;
  this._regionMappingDefinitionsUrl = defined(catalogItem)
    ? catalogItem.terria.configParameters.regionMappingDefinitionsUrl
    : undefined;
  this._regionDetails = undefined; // For caching the region details.
  this._imageryLayer = undefined;
  this._nextImageryLayer = undefined; // For pre-rendering time-varying layers
  this._nextImageryLayerInterval = undefined;
  this._hadImageryAtLayerIndex = undefined;
  this._hasDisplayedFeedback = false; // So that we only show the feedback once.

  this._constantRegionRowObjects = undefined;
  this._constantRegionRowDescriptions = undefined;

  // Track _tableStructure so that the catalogItem's concepts are maintained.
  // Track _legendUrl so that the catalogItem can update the legend if it changes.
  // Track _regionDetails so that when it is discovered that region mapping applies,
  //       it updates the legendHelper via activeItems, and catalogItem properties like supportsReordering.
  knockout.track(this, ["_tableStructure", "_legendUrl", "_regionDetails"]);

  // Whenever the active item is changed, recalculate the legend and the display of all the entities.
  // This is triggered both on deactivation and on reactivation, ie. twice per change; it would be nicer to trigger once.
  knockout
    .getObservable(this._tableStructure, "activeItems")
    .subscribe(changedActiveItems.bind(null, this), this);

  knockout
    .getObservable(this._catalogItem, "currentTime")
    .subscribe(function() {
      if (this.hasActiveTimeColumn) {
        onClockTick(this);
      }
    }, this);
};

Object.defineProperties(RegionMapping.prototype, {
  /**
   * Gets the clock settings defined by the loaded data.  If
   * only static data exists, this value is undefined.
   * @memberof RegionMapping.prototype
   * @type {DataSourceClock}
   */
  clock: {
    get: function() {
      if (defined(this._tableStructure)) {
        return this._tableStructure.clock;
      }
      return undefined;
    }
  },
  /**
   * Gets a CesiumEvent that will be raised when the underlying data changes.
   * @memberof RegionMapping.prototype
   * @type {CesiumEvent}
   */
  changedEvent: {
    get: function() {
      return this._changed;
    }
  },

  /**
   * Gets or sets a value indicating if the data source is currently loading data.
   * Whenever loadingData is changed to false, also trigger a redraw.
   * @memberof RegionMapping.prototype
   * @type {Boolean}
   */
  isLoading: {
    get: function() {
      return this._loadingData;
    },
    set: function(value) {
      this._loadingData = value;
      if (!value) {
        changedActiveItems(this);
      }
    }
  },

  /**
   * Gets the TableStructure object holding all the data.
   * @memberof RegionMapping.prototype
   * @type {TableStructure}
   */
  tableStructure: {
    get: function() {
      return this._tableStructure;
    }
  },

  /**
   * Gets the TableStyle object showing how to style the data.
   * @memberof RegionMapping.prototype
   * @type {TableStyle}
   */
  tableStyle: {
    get: function() {
      return this._tableStyle;
    }
  },

  /**
   * Gets a Rectangle covering the extent of the data, based on lat & lon columns. (It could be based on regions too eventually.)
   * @type {Rectangle}
   */
  extent: {
    get: function() {
      return this._extent;
    }
  },

  /**
   * Gets a URL for the legend for this data.
   * @type {String}
   */
  legendUrl: {
    get: function() {
      return this._legendUrl;
    }
  },

  /**
   * Once loaded, gets the region details (an array of "regionDetail" objects, with regionProvider, columnName and disambigColumnName properties).
   * By checking if defined, can be used as the region-mapping equivalent to "hasLatitudeAndLongitude".
   * @type {Object[]}
   */
  regionDetails: {
    get: function() {
      return this._regionDetails;
    }
  },

  /**
   * Gets the Cesium or Leaflet imagery layer object associated with this data source.
   * This property is undefined if the data source is not enabled.
   * @memberOf RegionMapping.prototype
   * @type {Object}
   */
  imageryLayer: {
    get: function() {
      return this._imageryLayer;
    }
  },

  /**
   * Gets a Boolean value saying whether the region mapping has a time column.
   * @memberOf RegionMapping.prototype
   * @type {Boolean}
   */
  hasActiveTimeColumn: {
    get: function() {
      var timeColumn = this._tableStructure.activeTimeColumn;
      return defined(timeColumn) && defined(timeColumn._clock);
    }
  },

  /**
   * Gets a Boolean value saying whether the region mapping will be updated due to its catalog item being polled.
   * @memberOf RegionMapping.prototype
   * @type {Boolean}
   */
  isPolled: {
    get: function() {
      return defined(
        this._catalogItem.polling && this._catalogItem.polling.seconds
      );
    }
  }
});

/**
 * Set the region column type.
 * Currently we only use the first possible region column, and leave any others as they are.
 * @param {Object[]} regionDetails The data source's regionDetails array.
 */
RegionMapping.prototype.setRegionColumnType = function(index) {
  if (!defined(index)) {
    index = 0;
  }
  var regionDetail = this._regionDetails[index];
  console.log(
    "Found region match based on " +
      regionDetail.columnName +
      (defined(regionDetail.disambigColumnName)
        ? " and " + regionDetail.disambigColumnName
        : "")
  );
  this._tableStructure.getColumnWithNameIdOrIndex(
    regionDetail.columnName
  ).type = VarType.REGION;
  if (defined(regionDetail.disambigColumnName)) {
    this._tableStructure.getColumnWithNameIdOrIndex(
      regionDetail.disambigColumnName
    ).type = VarType.REGION;
  }
};

/**
 * Explictly hide the imagery layer (if any).
 */
RegionMapping.prototype.hideImageryLayer = function() {
  // The region mapping was on, but has been switched off, so disable the imagery layer.
  // We are using _hadImageryAtLayerIndex = true to mean it had an ImageryLayer, but its layer was undefined.
  // _hadImageryAtLayerIndex = undefined means it did not have an ImageryLayer.
  var regionMapping = this;
  if (defined(regionMapping._imageryLayer)) {
    regionMapping._hadImageryAtLayerIndex =
      regionMapping._imageryLayer._layerIndex; // Would prefer not to access an internal variable of imageryLayer.
    if (!defined(regionMapping._hadImageryAtLayerIndex)) {
      regionMapping._hadImageryAtLayerIndex = true;
    }
    ImageryLayerCatalogItem.hideLayer(
      regionMapping._catalogItem,
      regionMapping._imageryLayer
    );
    ImageryLayerCatalogItem.disableLayer(
      regionMapping._catalogItem,
      regionMapping._imageryLayer
    );
    regionMapping._imageryLayer = undefined;
  }
};

function reviseLegendHelper(regionMapping) {
  // Currently we only use the first possible region column.
  var activeColumn = regionMapping._tableStructure.activeItems[0];
  var regionProvider = defined(regionMapping._regionDetails)
    ? regionMapping._regionDetails[0].regionProvider
    : undefined;
  regionMapping._legendHelper = new LegendHelper(
    activeColumn,
    regionMapping._tableStyle,
    regionProvider,
    regionMapping._catalogItem.name
  );
  regionMapping._legendUrl = regionMapping._legendHelper.legendUrl();
}

/**
 * Call when the active column changes, or when the table data source is first shown.
 * Generates a LegendHelper.
 * For lat/lon files, updates entities and extent.
 * For region files, rebuilds and redisplays the imageryLayer.
 * @private
 */
function changedActiveItems(regionMapping) {
  if (defined(regionMapping._regionDetails)) {
    reviseLegendHelper(regionMapping);
    if (!regionMapping._loadingData) {
      if (
        defined(regionMapping._imageryLayer) ||
        defined(regionMapping._hadImageryAtLayerIndex)
      ) {
        redisplayRegions(regionMapping);
      }
      regionMapping._changed.raiseEvent(regionMapping);
    }
  } else {
    regionMapping._legendHelper = undefined;
    regionMapping._legendUrl = undefined;
  }
}

RegionMapping.prototype.showOnSeparateMap = function(globeOrMap) {
  if (defined(this._regionDetails)) {
    var layer = createNewRegionImageryLayer(
      this,
      0,
      undefined,
      globeOrMap,
      globeOrMap.terria.clock.currentTime
    );
    ImageryLayerCatalogItem.showLayer(this._catalogItem, layer, globeOrMap);

    var that = this;
    return function() {
      ImageryLayerCatalogItem.hideLayer(that._catalogItem, layer, globeOrMap);
      ImageryLayerCatalogItem.disableLayer(
        that._catalogItem,
        layer,
        globeOrMap
      );
    };
  }
};

// The functions enable, disable, show and hide are required for region mapping.
RegionMapping.prototype.enable = function(layerIndex) {
  if (defined(this._regionDetails)) {
    setNewRegionImageryLayer(this, layerIndex);
  }
};

RegionMapping.prototype.disable = function() {
  if (defined(this._regionDetails)) {
    ImageryLayerCatalogItem.disableLayer(this._catalogItem, this._imageryLayer);
    this._imageryLayer = undefined;
  }
};

RegionMapping.prototype.show = function() {
  if (defined(this._regionDetails)) {
    ImageryLayerCatalogItem.showLayer(this._catalogItem, this._imageryLayer);
  }
};

RegionMapping.prototype.hide = function() {
  if (defined(this._regionDetails)) {
    ImageryLayerCatalogItem.hideLayer(this._catalogItem, this._imageryLayer);
  }
};

RegionMapping.prototype.updateOpacity = function(opacity) {
  if (defined(this._imageryLayer)) {
    if (defined(this._imageryLayer.alpha)) {
      this._imageryLayer.alpha = opacity;
    }
    if (defined(this._imageryLayer.setOpacity)) {
      this._imageryLayer.setOpacity(opacity);
    }
  }
};

/**
 * Builds a promise which resolves to either:
 *   undefined if no regions;
 *   An array of objects with regionProvider, column and disambigColumn properties.
 * It also caches this object in regionMapping._regionDetails.
 *
 * The steps involved are:
 * 0. Wait for the data to be ready, if needed. (For loaded tables, this is trivially true, but it might be constructed elsewhere.)
 * 1. Get the region provider list (asynchronously).
 * 2. Use this list to find all the possible region identifiers for this table, eg. 'postcode' or 'sa4_code'.
 *    If the user specified a prefered region variable name/type, put this to the front of the list.
 *    Elsewhere, we only offer the user the first region mapping possibility.
 * 3. Load the region ids of each possible region identifier (asynchronously), eg. ['2001', '2002', ...].
 * 4. Once all of these are known, cache and return all the details of all the possible region mapping approaches.
 *
 * These steps are sequenced using a series of promise.thens, so that the caller only sees a promise resolving to the end result.
 *
 * It is safe to call this multiple times, as each asynchronous call returns a cached promise if it exists.
 *
 * @return {Promise} The promise.
 */
RegionMapping.prototype.loadRegionDetails = function() {
  var regionMapping = this;
  if (!regionMapping._regionMappingDefinitionsUrl) {
    return when();
  }
  // RegionProviderList.fromUrl returns a cached version if available.
  return RegionProviderList.fromUrl(
    regionMapping._regionMappingDefinitionsUrl,
    this._catalogItem.terria.corsProxy
  ).then(function(regionProviderList) {
    var targetRegionVariableName, targetRegionType;
    if (defined(regionMapping._tableStyle)) {
      targetRegionVariableName = regionMapping._tableStyle.regionVariable;
      targetRegionType = regionMapping._tableStyle.regionType;
    }
    // We have a region provider list, now get the region provider and load its region ids (another async job).
    // Provide the user-specified region variable name and type. If specified, getRegionDetails will return them as the first object in the returned array.
    var rawRegionDetails = regionProviderList.getRegionDetails(
      regionMapping._tableStructure.getColumnNames(),
      targetRegionVariableName,
      targetRegionType
    );
    if (rawRegionDetails.length > 0) {
      return loadRegionIds(regionMapping, rawRegionDetails);
    }
    return when(); // Nothing more to return.
  });
};

// Loads region ids from the region providers, and returns the region details.
function loadRegionIds(regionMapping, rawRegionDetails) {
  var promises = rawRegionDetails.map(function(rawRegionDetail) {
    return rawRegionDetail.regionProvider.loadRegionIDs();
  });
  return when
    .all(promises)
    .then(function() {
      // Cache the details in a nicer format, storing the actual columns rather than just the column names.
      regionMapping._regionDetails = rawRegionDetails.map(function(
        rawRegionDetail
      ) {
        return {
          regionProvider: rawRegionDetail.regionProvider,
          columnName: rawRegionDetail.variableName,
          disambigColumnName: rawRegionDetail.disambigVariableName
        };
      });
      return regionMapping._regionDetails;
    })
    .otherwise(function(e) {
      console.log("error loading region ids", e);
    });
}

/**
 * Returns an array the same length as regionProvider.regions, mapping each region into the relevant index into the table data source.
 * Takes the current time into account if a time is provided, and there is a time column with timeIntervals defined.
 * @private
 * @param {RegionMapping} regionMapping The table data source.
 * @param {JulianDate} [time] The current time, eg. terria.clock.currentTime. NOT the time column's ._clock's time, which is different (and comes from a DataSourceClock).
 * @param {Array} [failedMatches] An optional empty array. If provided, indices of failed matches are appended to the array.
 * @param {Array} [ambiguousMatches] An optional empty array. If provided, indices of matches which duplicate prior matches are appended to the array.
 * @return {Array} An array the same length as regionProvider.regions, mapping each region into the relevant index into the table data source.
 */
function calculateRegionIndices(
  regionMapping,
  time,
  failedMatches,
  ambiguousMatches
) {
  // As described in load, currently we only use the first possible region column.
  var regionDetail = regionMapping._regionDetails[0];
  var tableStructure = regionMapping._tableStructure;
  var regionColumn = tableStructure.getColumnWithNameIdOrIndex(
    regionDetail.columnName
  );
  if (!defined(regionColumn)) {
    return;
  }
  var regionColumnValues = regionColumn.values;
  // Wipe out the region names from the rows that do not apply at this time, if there is a time column.
  var timeColumn = tableStructure.activeTimeColumn;
  var disambigColumn = defined(regionDetail.disambigColumnName)
    ? tableStructure.getColumnWithNameIdOrIndex(regionDetail.disambigColumnName)
    : undefined;
  // regionIndices will be an array the same length as regionProvider.regions, giving the index of each region into the table.
  var regionIndices = regionDetail.regionProvider.mapRegionsToIndicesInto(
    regionColumnValues,
    disambigColumn && disambigColumn.values,
    failedMatches,
    ambiguousMatches,
    defined(timeColumn) ? timeColumn.timeIntervals : undefined,
    time
  );
  return regionIndices;
}

function getRegionValuesFromIndices(regionIndices, tableStructure) {
  var regionValues = regionIndices; // Appropriate if no active column: color each region according to its index into the table.
  if (tableStructure.activeItems.length > 0) {
    var activeColumn = tableStructure.activeItems[0];
    regionValues = regionIndices.map(function(i) {
      return activeColumn.values[i];
    });
  }
  return regionValues;
}

/**
 * Put the properties and the description of this row onto the image of the region. Handles time-varying and constant regions.
 * @param {RegionMapping} regionMapping The region mapping instance.
 * @param {Array} [regionIndices] An array the same length as regionProvider.regions, mapping each region into the relevant index into the table data source;
 *                                only used if the regions are constant.
 * @param {WebMapServiceImageryProvider} regionImageryProvider The WebMapServiceImageryProvider instance.
 * @private
 */
function addDescriptionAndProperties(
  regionMapping,
  regionIndices,
  regionImageryProvider
) {
  var tableStructure = regionMapping._tableStructure;
  var rowObjects = tableStructure.toStringAndNumberRowObjects();
  if (rowObjects.length === 0) {
    return;
  }
  var columnAliases = tableStructure.getColumnAliases();
  var rowDescriptions = tableStructure.toRowDescriptions(
    regionMapping._tableStyle.featureInfoFields
  );
  var regionDetail = regionMapping._regionDetails[0];
  var uniqueIdProp = regionDetail.regionProvider.uniqueIdProp;
  var idColumnNames = [regionDetail.columnName];
  if (defined(regionDetail.disambigColumnName)) {
    idColumnNames.push(regionDetail.disambigColumnName);
  }
  var rowNumbersMap = tableStructure.getIdMapping(idColumnNames);

  if (!regionMapping.hasActiveTimeColumn) {
    regionMapping._constantRegionRowObjects = regionIndices.map(function(i) {
      return rowObjects[i];
    });
    regionMapping._constantRegionRowDescriptions = regionIndices.map(function(
      i
    ) {
      return rowDescriptions[i];
    });
  }

  function getRegionRowDescriptionPropertyCallbackForId(uniqueId) {
    /**
     * Returns a function that returns the value of the regionRowDescription at a given time, updating result if available.
     * @private
     * @param {JulianDate} [time] The time for which to retrieve the value.
     * @param {Object} [result] The object to store the value into, if omitted, a new instance is created and returned.
     * @returns {Object} The modified result parameter or a new instance if the result parameter was not supplied or is unsupported.
     */
    return function regionRowDescriptionPropertyCallback(time, result) {
      // result parameter is unsupported (should it be supported?)
      if (!regionMapping.hasActiveTimeColumn) {
        return (
          regionMapping._constantRegionRowDescriptions[uniqueId] || "No data"
        );
      }
      var timeSpecificRegionIndices = calculateRegionIndices(
        regionMapping,
        time
      );
      var timeSpecificRegionRowDescriptions = timeSpecificRegionIndices.map(
        function(i) {
          return rowDescriptions[i];
        }
      );

      if (defined(timeSpecificRegionRowDescriptions[uniqueId])) {
        return timeSpecificRegionRowDescriptions[uniqueId];
      }
      // If it's not defined at this time, is it defined at any time?
      // Give a different description in each case.
      var timeAgnosticRegionIndices = calculateRegionIndices(regionMapping);
      var rowNumberWithThisRegion = timeAgnosticRegionIndices[uniqueId];
      if (defined(rowNumberWithThisRegion)) {
        return i18next.t("models.regionMapping.noDataForDate");
      }
      return i18next.t("models.regionMapping.noDataForRegion");
    };
  }

  function getRegionRowPropertiesPropertyCallbackForId(uniqueId) {
    /**
     * Returns a function that returns the value of the regionRowProperties at a given time, updating result if available.
     * @private
     * @param {JulianDate} [time] The time for which to retrieve the value.
     * @param {Object} [result] The object to store the value into, if omitted, a new instance is created and returned.
     * @returns {Object} The modified result parameter or a new instance if the result parameter was not supplied or is unsupported.
     */
    return function regionRowPropertiesPropertyCallback(time, result) {
      // result parameter is unsupported (should it be supported?)
      if (!regionMapping.hasActiveTimeColumn) {
        // Only changes due to polling.
        var rowObject = regionMapping._constantRegionRowObjects[uniqueId];
        if (!defined(rowObject)) {
          return {};
        }
        var constantProperties = rowObject.string;
        constantProperties._terria_columnAliases = columnAliases;
        constantProperties._terria_numericalProperties = rowObject.number;
        return constantProperties;
      }
      // Changes due to time column in the table (and maybe polling too).
      var timeSpecificRegionIndices = calculateRegionIndices(
        regionMapping,
        time
      );
      var timeSpecificRegionRowObjects = timeSpecificRegionIndices.map(function(
        i
      ) {
        return rowObjects[i];
      });
      var tsRowObject = timeSpecificRegionRowObjects[uniqueId];
      var properties = (tsRowObject && tsRowObject.string) || {};
      properties._terria_columnAliases = columnAliases;
      properties._terria_numericalProperties =
        (tsRowObject && tsRowObject.number) || {};
      // Even if there is no data for this region at this time,
      // we want to get data for all other times for this region so we can chart it.
      // So get the region indices again, this time ignoring time,
      // so that we can get a row number in the table where this region occurs (if there is one at any time).
      // This feels like a slightly roundabout approach. Is there a more streamlined way?
      var timeAgnosticRegionIndices = calculateRegionIndices(regionMapping);
      var regionIdString = tableStructure.getIdStringForRowNumber(
        timeAgnosticRegionIndices[uniqueId],
        idColumnNames
      );
      var rowNumbersForThisRegion = rowNumbersMap[regionIdString];
      if (defined(rowNumbersForThisRegion)) {
        properties._terria_getChartDetails = function() {
          return tableStructure.getChartDetailsForRowNumbers(
            rowNumbersForThisRegion
          );
        };
      }
      return properties;
    };
  }

  switch (regionDetail.regionProvider.serverType) {
    case "MVT":
      return function constructMVTFeatureInfo(feature) {
        var imageryLayerFeatureInfo = new ImageryLayerFeatureInfo();
        imageryLayerFeatureInfo.name =
          feature.properties[regionDetail.regionProvider.nameProp];
        var uniqueId = feature.properties[uniqueIdProp];

        if (!regionMapping.hasActiveTimeColumn && !regionMapping.isPolled) {
          // Constant over time - no time column, and no polling.
          imageryLayerFeatureInfo.description =
            regionMapping._constantRegionRowDescriptions[uniqueId];
          var cRowObject = regionMapping._constantRegionRowObjects[uniqueId];
          if (defined(cRowObject)) {
            cRowObject.string._terria_columnAliases = columnAliases;
            cRowObject.string._terria_numericalProperties = cRowObject.number;
            imageryLayerFeatureInfo.properties = combine(
              feature.properties,
              cRowObject.string
            );
          } else {
            return;
          }
        } else {
          // Time-varying.
          imageryLayerFeatureInfo.description = new CallbackProperty(
            getRegionRowDescriptionPropertyCallbackForId(uniqueId),
            false
          );
          // Merge vector tile and data properties
          var propertiesCallback = getRegionRowPropertiesPropertyCallbackForId(
            uniqueId
          );
          imageryLayerFeatureInfo.properties = new CallbackProperty(
            time => combine(feature.properties, propertiesCallback(time)),
            false
          );
        }
        imageryLayerFeatureInfo.data = { id: uniqueId }; // For region highlight
        return imageryLayerFeatureInfo;
      };
    case "WMS":
      ImageryProviderHooks.addPickFeaturesHook(regionImageryProvider, function(
        imageryLayerFeatureInfos
      ) {
        if (
          !defined(imageryLayerFeatureInfos) ||
          imageryLayerFeatureInfos.length === 0
        ) {
          return;
        }
        for (var i = 0; i < imageryLayerFeatureInfos.length; ++i) {
          var imageryLayerFeatureInfo = imageryLayerFeatureInfos[i];
          var uniqueId = imageryLayerFeatureInfo.data.properties[uniqueIdProp];

          if (!regionMapping.hasActiveTimeColumn && !regionMapping.isPolled) {
            // Constant over time - no time column, and no polling.
            imageryLayerFeatureInfo.description =
              regionMapping._constantRegionRowDescriptions[uniqueId];
            var cRowObject = regionMapping._constantRegionRowObjects[uniqueId];
            if (defined(cRowObject)) {
              cRowObject.string._terria_columnAliases = columnAliases;
              cRowObject.string._terria_numericalProperties = cRowObject.number;
              imageryLayerFeatureInfo.properties = cRowObject.string;
            } else {
              imageryLayerFeatureInfo.properties = {};
            }
          } else {
            // Time-varying.
            imageryLayerFeatureInfo.description = new CallbackProperty(
              getRegionRowDescriptionPropertyCallbackForId(uniqueId),
              false
            );
            imageryLayerFeatureInfo.properties = new CallbackProperty(
              getRegionRowPropertiesPropertyCallbackForId(uniqueId),
              false
            );
          }
        }

        // If there was no description or property for a layer then we have nothing to display for it, so just filter it out.
        // This helps in cases where the imagery provider returns a feature that doesn't actually match the region.
        return imageryLayerFeatureInfos.filter(
          info => info.properties || info.description
        );
      });
      break;
  }
}

/**
 * Creates and enables a new ImageryLayer onto terria, showing appropriately colored regions.
 * @private
 * @param {RegionMapping} regionMapping    The table data source.
 * @param {Number} [layerIndex] The layer index of the new imagery layer.
 *
 * @param {Array} [regionIndices] An array the same length as regionProvider.regions, mapping each region into the relevant index into the table data source.
 *                  If not provided, it is calculated, and failed/ambiguous warnings are displayed to the user.
 */
function setNewRegionImageryLayer(regionMapping, layerIndex, regionIndices) {
  if (!defined(regionMapping._tableStructure.activeTimeColumn)) {
    regionMapping._imageryLayer = createNewRegionImageryLayer(
      regionMapping,
      layerIndex,
      regionIndices
    );
  } else {
    var catalogItem = regionMapping._catalogItem;
    var currentTime = catalogItem.currentTime;

    // Calulate the interval of time that the next imagery layer is valid for
    var { nextInterval, currentInterval } = calculateImageryLayerIntervals(
      regionMapping._tableStructure.activeTimeColumn,
      currentTime,
      catalogItem.terria.clock.multiplier >= 0.0
    );
    if (
      currentInterval === regionMapping._imageryLayerInterval &&
      nextInterval === regionMapping._nextImageryLayerInterval
    ) {
      // No change in intervals, so nothing to do.
      return;
    }

    if (currentInterval !== regionMapping._imageryLayerInterval) {
      // Current layer is incorrect.  Can we use the next one?
      if (
        regionMapping._nextImageryLayerInterval &&
        TimeInterval.contains(
          regionMapping._nextImageryLayerInterval,
          currentTime
        )
      ) {
        setOpacity(
          catalogItem,
          regionMapping._nextImageryLayer,
          catalogItem.opacity
        );
        fixNextLayerOrder(
          catalogItem,
          regionMapping._imageryLayer,
          regionMapping._nextImageryLayer
        );
        ImageryLayerCatalogItem.disableLayer(
          catalogItem,
          regionMapping._imageryLayer
        );
        regionMapping._imageryLayer = regionMapping._nextImageryLayer;
        regionMapping._imageryLayerInterval =
          regionMapping._nextImageryLayerInterval;
        regionMapping._nextImageryLayer = undefined;
        regionMapping._nextImageryLayerInterval = null;
      } else {
        // Next is not right, either, possibly because the user is randomly scrubbing
        // on the timeline.  So create a new layer.
        ImageryLayerCatalogItem.disableLayer(
          catalogItem,
          regionMapping._imageryLayer
        );
        regionMapping._imageryLayer = createNewRegionImageryLayer(
          regionMapping,
          layerIndex,
          regionIndices
        );
        regionMapping._imageryLayerInterval = currentInterval;
      }
    }

    if (nextInterval !== regionMapping._nextImageryLayerInterval) {
      // Next layer is incorrect, so recreate it.
      ImageryLayerCatalogItem.disableLayer(
        catalogItem,
        regionMapping._nextImageryLayer
      );

      if (nextInterval) {
        regionMapping._nextImageryLayer = createNewRegionImageryLayer(
          regionMapping,
          layerIndex,
          undefined,
          undefined,
          nextInterval.start,
          0.0
        );
        ImageryLayerCatalogItem.showLayer(
          catalogItem,
          regionMapping._nextImageryLayer
        );
      } else {
        regionMapping._nextImageryLayer = undefined;
      }
      regionMapping._nextImageryLayerInterval = nextInterval;
    }
  }
}

function createNewRegionImageryLayer(
  regionMapping,
  layerIndex,
  regionIndices,
  globeOrMap,
  time,
  overrideOpacity
) {
  var catalogItem = regionMapping._catalogItem;

  var opacity = defaultValue(overrideOpacity, catalogItem.opacity);

  globeOrMap = defaultValue(globeOrMap, catalogItem.terria.currentViewer);
  time = defaultValue(time, catalogItem.currentTime);

  var regionDetail = regionMapping._regionDetails[0];
  var legendHelper = regionMapping._legendHelper;
  if (!defined(legendHelper)) {
    return; // Give up. This can happen if a time-series region-mapped table is charted over time; the chart looks like a region-mapped file.
  }
  var tableStructure = regionMapping._tableStructure;
  var failedMatches, ambiguousMatches;
  if (!defined(regionIndices)) {
    failedMatches = [];
    ambiguousMatches = [];
    regionIndices = calculateRegionIndices(
      regionMapping,
      time,
      failedMatches,
      ambiguousMatches
    );
    if (!regionMapping._hasDisplayedFeedback && catalogItem.showWarnings) {
      regionMapping._hasDisplayedFeedback = true;
      displayFailedAndAmbiguousMatches(
        regionMapping,
        failedMatches,
        ambiguousMatches
      );
    }
  }
  if (!defined(regionIndices)) {
    return;
  }
  var regionValues = getRegionValuesFromIndices(regionIndices, tableStructure);
  if (!defined(regionValues)) {
    return;
  }
  // Recolor the regions.
  var colorFunction = regionDetail.regionProvider.getColorLookupFunc(
    regionValues,
    legendHelper.getColorArrayFromValue.bind(legendHelper)
  );

  var regionImageryProvider;
  var layer;

  // Handle the case where a region mapped dataset crosses the antimeridian
  // and we get 404's retrieving tiles, this prevents the catalog item
  // from crashing
  catalogItem.treat404AsError = false;

  switch (regionDetail.regionProvider.serverType) {
    case "MVT":
      var terria = globeOrMap.terria;

      // Inform the user that region mapping is not supported in old browsers.
      if (typeof ArrayBuffer === "undefined") {
        throw new TerriaError({
          sender: catalogItem,
          title:
            terria.configParameters.oldBrowserRegionMappingTitle ||
            i18next.t("models.regionMapping.outdatedBrowserTitle"),
          message:
            terria.configParameters.oldBrowserRegionMappingMessage ||
            i18next.t("models.regionMapping.outdatedBrowserMessage", {
              email:
                '<a href="mailto:' +
                terria.supportEmail +
                '">' +
                terria.supportEmail +
                "</a>"
            })
        });
      }

      regionImageryProvider = new MapboxVectorTileImageryProvider({
        url: regionDetail.regionProvider.server,
        layerName: regionDetail.regionProvider.layerName,
        styleFunc: function(id) {
          var terria = catalogItem.terria;
          var color = colorFunction(id);
          return color
            ? {
                // color is an Array-like object (note: typed arrays don't have a 'join' method in IE10 & IE11)
                fillStyle:
                  "rgba(" + Array.prototype.join.call(color, ",") + ")",
                strokeStyle: terria.baseMapContrastColor,
                lineWidth: 1
              }
            : undefined;
        },
        subdomains: regionDetail.regionProvider.serverSubdomains,
        rectangle: Rectangle.fromDegrees.apply(
          null,
          regionDetail.regionProvider.bbox
        ),
        minimumZoom: regionDetail.regionProvider.serverMinZoom,
        maximumNativeZoom: regionDetail.regionProvider.serverMaxNativeZoom,
        maximumZoom: regionDetail.regionProvider.serverMaxZoom,
        uniqueIdProp: regionDetail.regionProvider.uniqueIdProp,
        featureInfoFunc: addDescriptionAndProperties(
          regionMapping,
          regionIndices,
          regionImageryProvider
        )
      });
      layer = ImageryLayerCatalogItem.enableLayer(
        catalogItem,
        regionImageryProvider,
        opacity,
        layerIndex,
        globeOrMap,
        undefined
      );
      break;

    case "WMS":
      // Recolor the regions, and add feature descriptions.
      regionImageryProvider = new WebMapServiceImageryProvider({
        url: proxyCatalogItemUrl(
          catalogItem,
          regionDetail.regionProvider.server
        ),
        layers: regionDetail.regionProvider.layerName,
        parameters: WebMapServiceCatalogItem.defaultParameters,
        getFeatureInfoParameters: WebMapServiceCatalogItem.defaultParameters,
        tilingScheme: new WebMercatorTilingScheme()
      });

      addDescriptionAndProperties(
        regionMapping,
        regionIndices,
        regionImageryProvider
      );

      ImageryProviderHooks.addRecolorFunc(regionImageryProvider, colorFunction);
      layer = ImageryLayerCatalogItem.enableLayer(
        catalogItem,
        regionImageryProvider,
        opacity,
        layerIndex,
        globeOrMap,
        undefined
      );
      if (globeOrMap instanceof Leaflet && colorFunction) {
        layer.options.crossOrigin = true; // Allow cross origin tiles
        layer.on("tileload", function(evt) {
          if (evt.tile._recolored) {
            // Already recoloured (this event is called when the recoloured tile "loads")
            return;
          }
          // Below code adapted from Leaflet.TileLayer.Filter (https://github.com/humangeo/leaflet-tilefilter)
          /*
                @preserve Leaflet Tile Filters, a JavaScript plugin for applying image filters to tile images
                (c) 2014, Scott Fairgrieve, HumanGeo
                */
          var canvas;
          var size = 256;
          if (!evt.tile.canvasContext) {
            canvas = document.createElement("canvas");
            canvas.width = canvas.height = size;
            evt.tile.canvasContext = canvas.getContext("2d");
          }
          var ctx = evt.tile.canvasContext;
          if (ctx) {
            ctx.drawImage(evt.tile, 0, 0);
            var imgd = ctx.getImageData(0, 0, size, size);
            imgd = ImageryProviderHooks.recolorImage(imgd, colorFunction);
            ctx.putImageData(imgd, 0, 0);
            evt.tile.onload = null;
            evt.tile.src = ctx.canvas.toDataURL();
            evt.tile._recolored = true;
          }
        });
      }
      break;

    default:
      throw new TerriaError({
        title: i18next.t("models.regionMapping.invalidServerTypeTitle", {
          serverType: regionDetail.regionProvider.serverType
        }),
        message:
          "<div>" +
          i18next.t("models.regionMapping.invalidServerTypeTitle", {
            serverType: regionDetail.regionProvider.serverType
          }) +
          "</div>"
      });
  }

  return layer;
}

/**
 * Update the region imagery layer, eg. when the active variable changes, or the time changes.
 * Following previous practice, when the coloring needs to change, the item is hidden, disabled, then re-enabled and re-shown.
 * So, a new imagery layer is created (during 'enable') each time its coloring changes.
 * It would look less flickery to reuse the existing one, but when I tried, I found the recoloring doesn't get used.
 * @private
 * @param  {RegionMapping} regionMapping The data source.
 * @param  {Array} [regionIndices] Passed into setNewRegionImageryLayer. Saves recalculating it if available.
 */
function redisplayRegions(regionMapping, regionIndices) {
  if (defined(regionMapping._regionDetails)) {
    regionMapping.hideImageryLayer();
    setNewRegionImageryLayer(
      regionMapping,
      regionMapping._hadImageryAtLayerIndex,
      regionIndices
    );
    if (regionMapping._catalogItem.isShown) {
      ImageryLayerCatalogItem.showLayer(
        regionMapping._catalogItem,
        regionMapping._imageryLayer
      );
    }
    regionMapping._catalogItem.terria.currentViewer.updateItemForSplitter(
      regionMapping._catalogItem
    );
  }
}

function onClockTick(regionMapping) {
  // Check if record data has changed.
  if (
    regionMapping._imageryLayerInterval &&
    TimeInterval.contains(
      regionMapping._imageryLayerInterval,
      regionMapping.clock.currentTime
    )
  ) {
    return;
  }
  redisplayRegions(regionMapping);
}

function displayFailedAndAmbiguousMatches(
  regionMapping,
  failedMatches,
  ambiguousMatches
) {
  var msg = "";
  var regionDetail = regionMapping._regionDetails[0];
  var regionColumnValues = regionMapping._tableStructure.getColumnWithNameIdOrIndex(
    regionDetail.columnName
  ).values;
  var timeColumn = regionMapping._tableStructure.activeTimeColumn;

  if (failedMatches.length > 0) {
    var failedNames = failedMatches.map(function(indexOfFailedMatch) {
      return regionColumnValues[indexOfFailedMatch];
    });
    msg += i18next.t("models.regionMapping.notRecognised", {
      notRecognisedText:
        '<span class="warning-text">' +
        i18next.t("models.regionMapping.notRecognisedText") +
        "</span>: <br><br/>" +
        "<samp>" +
        failedNames.join("</samp>, <samp>") +
        "</samp>" +
        "<br/><br/>"
    });
  }
  // Only show ambiguous matches if there is no time column.
  // There could still be ambiguous matches, but our code doesn't calculate that.
  if (ambiguousMatches.length > 0 && !defined(timeColumn)) {
    var ambiguousNames = ambiguousMatches.map(function(indexOfAmbiguousMatch) {
      return regionColumnValues[indexOfAmbiguousMatch];
    });
    msg += i18next.t("models.regionMapping.moreThanOneValue", {
      moreThanOneValueText:
        '<span class="warning-text">' +
        i18next.t("models.regionMapping.moreThanOneValueText") +
        "</span>: <br/><br/>" +
        "<samp>" +
        uniq(ambiguousNames).join("</samp>, <samp>") +
        "</samp>" +
        "<br/><br/>"
    });
  }
  if (msg) {
    msg += i18next.t("models.regionMapping.msg", {
      link:
        '<a href="https://github.com/TerriaJS/nationalmap/wiki/csv-geo-au">' +
        i18next.t("models.regionMapping.csvSpecification") +
        "</a>"
    });
    var error = new TerriaError({
      title: i18next.t("models.regionMapping.issuesLoadingTitle", {
        name: regionMapping._catalogItem.name.slice(0, 20) // Long titles mess up the message body.
      }),
      message: "<div>" + msg + "</div>"
    });
    if (failedMatches.length === regionColumnValues.length) {
      // Every row failed, so abort - don't add it to catalogue at all.
      throw error;
    } else {
      // Just warn the user. Ideally we'd avoid showing the warning when switching between columns.
      regionMapping._catalogItem.terria.error.raiseEvent(error);
    }
  }
}

/**
 * Destroy the object and release resources
 */
RegionMapping.prototype.destroy = function() {
  // TODO: Don't we need to explicitly unsubscribe from the clock?
  // The comments for destroyObject suggest this is not useful for a RegionMapping object.
  return destroyObject(this);
};

module.exports = RegionMapping;