Source: Models/RegionMapping.js

/*global require*/
"use strict";

var CallbackProperty = require('terriajs-cesium/Source/DataSources/CallbackProperty');
var CesiumEvent = require('terriajs-cesium/Source/Core/Event');
var clone = require('terriajs-cesium/Source/Core/clone');
var combine = require('terriajs-cesium/Source/Core/combine');
var defaultValue = require('terriajs-cesium/Source/Core/defaultValue');
var defined = require('terriajs-cesium/Source/Core/defined');
var defineProperties = require('terriajs-cesium/Source/Core/defineProperties');
var destroyObject = require('terriajs-cesium/Source/Core/destroyObject');
var DeveloperError = require('terriajs-cesium/Source/Core/DeveloperError');
var ImageryLayerFeatureInfo = require('terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo');
var knockout = require('terriajs-cesium/Source/ThirdParty/knockout');
var TimeInterval = require('terriajs-cesium/Source/Core/TimeInterval');
var WebMapServiceImageryProvider = require('terriajs-cesium/Source/Scene/WebMapServiceImageryProvider');
var WebMercatorTilingScheme = require('terriajs-cesium/Source/Core/WebMercatorTilingScheme');
var when = require('terriajs-cesium/Source/ThirdParty/when');
var Rectangle = require('terriajs-cesium/Source/Core/Rectangle');

var calculateImageryLayerIntervals = require('./calculateImageryLayerIntervals');
var getUniqueValues = require('../Core/getUniqueValues');
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');


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

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;
            }
        }
    },
    /**
     * 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 'No data for the selected date.';
            }
            return  'No data for the selected region.';
        };
    }

    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 {
                    imageryLayerFeatureInfo.properties = clone(feature.properties);
                }
            } 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;

    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 || 'Outdated Web Browser',
                message: terria.configParameters.oldBrowserRegionMappingMessage ||
                    'You are using a very old web browser that cannot display "region mapped" datasets such as this one.  Please upgrade to ' +
                    'the latest version of Google Chrome, ' +
                    'Mozilla Firefox, Microsoft Edge, Microsoft Internet Explorer 11, or Apple Safari as soon as possible.  Please contact us ' +
                    'at <a href="mailto:' + terria.supportEmail + '">' + terria.supportEmail + '</a> if you have any concerns.'
            });
        }

        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);
        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);
        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: "Invalid serverType " + regionDetail.regionProvider.serverType + " in regionMapping.json",
            message: '<div>Expected serverType to be "WMS" or "vector" but got "' + 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 += 'These region names were <span class="warning-text">not recognised</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 += 'These regions had <span class="warning-text">more than one value</span>: <br/><br/>' +
        '<samp>' + getUniqueValues(ambiguousNames).join('</samp>, <samp>') + '</samp>' +
        '<br/><br/>';
    }
    if (msg) {
        msg += 'Consult the <a href="https://github.com/TerriaJS/nationalmap/wiki/csv-geo-au">CSV-geo-au specification</a> to see how to format the file.';

        var error = new TerriaError({
            title: "Issues loading " + 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;