Source: Models/SdmxJsonCatalogItem.js

'use strict';

/*global require*/
var Mustache = require('mustache');
var URI = require('urijs');
var naturalSort = require('javascript-natural-sort');
naturalSort.insensitive = true;

var clone = require('terriajs-cesium/Source/Core/clone');
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 deprecationWarning = require('terriajs-cesium/Source/Core/deprecationWarning');
var freezeObject = require('terriajs-cesium/Source/Core/freezeObject');
var knockout = require('terriajs-cesium/Source/ThirdParty/knockout');
var loadJson = require('../Core/loadJson');
var when = require('terriajs-cesium/Source/ThirdParty/when');

var arrayProduct = require('../Core/arrayProduct');
var DisplayVariablesConcept = require('../Map/DisplayVariablesConcept');
var inherit = require('../Core/inherit');
var overrideProperty = require('../Core/overrideProperty');
var proxyCatalogItemUrl = require('./proxyCatalogItemUrl');
var RegionMapping = require('../Models/RegionMapping');
var runLater = require('../Core/runLater');
var sdmxJsonLib = require('../ThirdParty/sdmxjsonlib');
var SummaryConcept = require('../Map/SummaryConcept');
var TableCatalogItem = require('./TableCatalogItem');
var TableColumn = require('../Map/TableColumn');
var TableStructure = require('../Map/TableStructure');
var TerriaError = require('../Core/TerriaError');
var VariableConcept = require('../Map/VariableConcept');
var VarType = require('../Map/VarType');

/**
 * A {@link CatalogItem} representing region-mapped data obtained from SDMX-JSON format.
 *
 * Descriptions of this format are available at:
 * - https://data.oecd.org/api/sdmx-json-documentation/
 * - https://github.com/sdmx-twg/sdmx-json/tree/master/data-message/docs
 * - https://sdmx.org/
 * - http://stats.oecd.org/sdmx-json/ (hosts a handy query builder)
 *
 * The URL can be of two types, eg:
 * 1. http://example.com/sdmx-json/data/DATASETID/BD1+BD2.LGA.1+2.A/all?startTime=2013&endTime=2013
 * 2. http://example.com/sdmx-json/data/DATASETID
 *
 * For #2, the dimension names and codes come from (in json format):
 * http://example.com/sdmx-json/dataflow/DATASETID
 *
 * @alias SdmxJsonCatalogItem
 * @constructor
 * @extends TableCatalogItem
 *
 * @param {Terria} terria The Terria instance.
 * @param {String} [url] The base URL from which to retrieve the data.
 */
var SdmxJsonCatalogItem = function(terria, url) {
    TableCatalogItem.call(this, terria, url);

    // We will override item.url to show a custom URL in About This Dataset.
    // So save the original URL here.
    this._originalUrl = url;

    // The options that should be passed to TableColumn when creating a new column.
    this._columnOptions = undefined;

    // Allows conversion between the dimensions and the table columns.
    this._allDimensions = undefined;
    this._loadedDimensions = undefined;

    // Keep track of whether how many columns appear before the value columns (typically a time and a region column).
    this._numberOfInitialColumns = undefined;

    // Holds the time_period and region ids, ie. by default ["TIME_PERIOD", "REGION"].
    this._suppressedIds = [];

    // This is set to the dataflow URL for this data, if relevant.
    this._dataflowUrl = undefined;

    // The array of Concepts to display in the NowViewing panel.
    this._concepts = [];

    // An object containing all the region totals (eg. populations) required, keyed by the dimensionIdsRequestString used.
    this._regionTotals = {};

    /**
     * Gets or sets the 'data' SDMX URL component, eg. 'data' in http://stats.oecd.org/sdmx-json/data/QNA.
     * Defaults to 'data'.
     * @type {String}
     */
    this.dataUrlComponent = undefined;

    /**
     * Gets or sets the 'dataflow' SDMX URL component, eg. 'dataflow' in http://stats.oecd.org/sdmx-json/dataflow/QNA.
     * Defaults to 'dataflow'.
     * @type {String}
     */
    this.dataflowUrlComponent = undefined;

    /**
     * Gets or sets the provider id in the SDMX URL, eg. the final 'all' in http://stats.oecd.org/sdmx-json/data/QNA/.../all.
     * Defaults to 'all'.
     * @type {String}
     */
    this.providerId = undefined;

    /**
     * Gets or sets the SDMX region-type dimension id used with the region code to set the region type.
     * Usually defaults to 'REGIONTYPE'.
     * @type {String}
     */
    this.regionTypeDimensionId = undefined;

    /**
     * Gets or sets the SDMX region dimension id, which is not displayed as a user-choosable dimension. Defaults to 'REGION'.
     * @type {String}
     */
    this.regionDimensionId = undefined;

    /**
     * Gets or sets the SDMX frequency dimension id. Defaults to 'FREQUENCY'.
     * @type {String}
     */
    this.frequencyDimensionId = undefined;

    /**
     * Gets or sets the SDMX time period dimension id, which is not displayed as a user-choosable dimension. Defaults to 'TIME_PERIOD'.
     * @type {String}
     */
    this.timePeriodDimensionId = undefined;

    /**
     * Gets or sets the regiontype directly, which is an alternative to including a regiontype in the data.
     * Eg. "cnt3" would tell us that we should use cnt3 as the table column name.
     * By default this is undefined.
     * @type {String}
     */
    this.regionType = undefined;

    /**
     * Gets or sets a Mustache template used to turn the name of the region provided in the "regionType" variable
     * into a csv-geo-au-compliant column name. The Mustache variable "{{name}}" holds the original name.
     * You can use this to specify a year in the name, even if it is absent on the server.
     * Eg. "{{name}}_code_2016" converts STE to STE_code_2016.
     * By default this is undefined. If it is undefined, the following rules are applied:
     *   - If there's a _, replace the last one with _code_; else append _code. So SA4 -> SA4_code; SA4_2011 -> SA4_code_2011.
     *   - If the name ends in 4 digits without an underscore, insert "_code_", eg. LGA2011 -> LGA_code_2011.
     * @type {String}
     */
    this.regionNameTemplate = undefined;

    /**
     * Gets or sets the concepts which are initially selected, eg. {"MEASURE": ["GDP", "GNP"], "FREQUENCY": ["A"]}.
     * Defaults to the first value in each dimension (when undefined).
     * @type {Object}
     */
    this.selectedInitially = undefined;

    /**
     * Gets or sets the dimensions for which you can only select a single value at a time.
     * The frequency and regiontype dimensions are added to this list in allSingleValuedDimensionIds.
     * @type {String[]}
     */
    this.singleValuedDimensionIds = [];

    /**
     * Gets or sets the startTime to use as part of the ?startTime=...&endTime=... query parameters.
     * Currently a string, but could be extended to be an object with frequency codes as keys.
     * By default this is undefined, and not used as part of the query.
     * @type {String}
     */
    this.startTime = undefined;

    /**
     * Gets or sets the endTime to use as part of the ?startTime=...&endTime=... query parameters.
     * Currently a string, but could be extended to be an object with frequency codes as keys.
     * By default this is undefined, and not used as part of the query.
     * @type {String}
     */
    this.endTime = undefined;

    /**
     * Gets or sets each dimension's allowed values, by id. Eg. {"SUBJECT": ["GDP", "GNP"], "FREQUENCY": ["A"]}.
     * If not defined, all values are allowed.
     * If a dimension is not present, all values for that dimension are allowed.
     * Note this will not be applied to regions or time periods.
     * The expression is first matched as a regular expression (sandwiched between ^ and &);
     * if that fails, it is matched as a literal string.  So eg. "[0-9]+" will match 015 but not A015.
     * @type {Object}
     */
    this.whitelist = {};

    /**
     * Gets or sets each dimension's non-allowed values, by id. Eg. {"COB": ["TOTAL", "1"], "FREQUENCY": ["Q"]}.
     * If not defined, all values are allowed (subject to the whitelist).
     * If a dimension is not present, all values for that dimension are allowed (subject to the whitelist).
     * Note this will not be applied to regions or time periods.
     * If the same value is in both the whitelist and the blacklist, the blacklist wins.
     * The expression is first matched as a regular expression (sandwiched between ^ and &);
     * if that fails, it is matched as a literal string.  So eg. "[0-9]+" will match 015 but not A015.
     * @type {Object}
     */
    this.blacklist = {};

    /**
     * Gets or sets an array of dimension ids whose values should not be shown in the Now Viewing panel;
     * instead, their values should be aggregated and treated as a single value.
     * Eg. useful if a dimension is repeated (eg. STATE and REGION).
     * NOTE: Currently only a single aggregatedDimensionId is supported.
     * This should not be applied to regions or time periods.
     * @type {Object}
     */
    this.aggregatedDimensionIds = [];

    /**
     * Gets or sets how to re-sort the values that appear in the SDMX-JSON response, in the Now Viewing panel.
     * The default is null, so that the order is maintained (except for totalValueIds, which are moved to the top).
     * By setting this to 'name' or 'id', the values are sorted into alphabetical and/or numerical order either by name or by id,
     * respectively.
     * @type {String}
     */
    this.sortValues = null;

    /**
     * Gets or sets value ids for each dimension which correspond to total values.
     * Place the grand total first.
     * If all dimensions (except region-type, region and frequency) have totals
     * available, then a "Display as a percentage of regional total" option becomes available.
     * Eg. Suppose AGE had "10" for 10 year olds, etc, plus "ALL" for all ages, "U21" and "21PLUS" for under and over 21 year olds.
     * Then you would want to specify {"AGE": ["ALL", "U21", "21PLUS"]}.
     * In this case, when the user selects one of these values, any other values will be unselected.
     * And when the user selects any other value (eg. "10"), if any of these values were selected, they will be unselected.
     * In addition, any values provided under a wildcard "*" key are used for _all_ dimensions, and are shown first in the list,
     * if present, eg. {"*": ["ALL"], "AGE": ["U21", "21PLUS"]}.
     * @type {Object}
     */
    this.totalValueIds = {};

    /**
     * Gets or sets whether to remove trailing "(x)"s from the values that appear in the SDMX-JSON response.
     * If true, for example, "Total responses(c)" would be replaced with "Total responses".
     * This is a workaround for an ABS-specific issue.
     * Default false.
     * @type {Boolean}
     */
    this.cleanFootnotes = false;

    /**
     * Gets or sets whether this item can show percentages instead of raw values.
     * This is set to true automatically if total value ids are available on all necessary columns.
     * This property is observable.
     * @type {Boolean}
     * @default false
     */
    this.canDisplayPercent = false;

    /**
     * Gets or sets whether to show percentages or raw values.  This property is observable.
     * @type {Boolean}
     * @default false
     */
    this.displayPercent = false;

    /**
     * Gets or sets a mapping of concept ids to arrays of values which, if selected, mean the results cannot be summed.
     * If one of these values is chosen:
     * - Does not show the "canDisplayPercent" option.
     * - Explains to the user that it can't show multiple values of concepts.
     * eg. {"MEASURE": ["rate"]}.
     * Can also be the boolean "true", if it should apply to all selections.
     * Defaults to none.
     * @type {Object|Boolean}
     */
    this.cannotSum = undefined;

    /**
     * Deprecated. Use cannotSum instead.
     * Defaults to none.
     * @type {Object}
     */
    this.cannotDisplayPercentMap = undefined;

    /**
     * Gets or sets a flag which determines whether the legend comes before (false) or after (true) the display variable choice.
     * Default true.
     * @type {Boolean}
     */
    this.displayChoicesBeforeLegend = true;

    /**
     * Gets or sets an array of dimension ids which, if present, should be shown to the user, even if there is only one value.
     * This is useful if the name of the dataset doesn't convey what is in it, but one of the dimension values does. Eg. ['MEASURE'].
     * Default [].
     * @type {Boolean}
     */
    this.forceShowDimensionIds = [];

    // Tracking _concepts makes this a circular object.
    // _concepts (via concepts) is both set and read in rebuildData.
    // A solution to this would be to make concepts a Promise, but that would require changing the UI side.
    knockout.track(this, ['_concepts', 'displayPercent', 'canDisplayPercent']);

    overrideProperty(this, 'concepts', {
        get: function() {
            return this._concepts;
        }
    });

    // See explanation in the comments for TableCatalogItem.
    overrideProperty(this, 'dataViewId', {
        get: function() {
            // We need an id that depends on the selected concepts. Just use the dimensionRequestString.
            return calculateDimensionRequestString(this, calculateActiveConceptIds(this) || [], this._fullDimensions || []);
        }
    });

    knockout.defineProperty(this, 'activeConcepts', {
        get: function() {
            const isActive = concept => concept.isActive;
            if (defined(this._concepts) && this._concepts.length > 0) {
                return this._concepts.map(concept => concept.getNodes(isActive));
            }
        }
    });

    knockout.getObservable(this, 'activeConcepts').subscribe(function() {
        if (!this.isLoading) {
            // Defer the execution of this so that other knockout observables are updated when we look at them.
            // In particular, DisplayVariablesConcept's activeItems.
            runLater(() => changedActiveItems(this));
        }
    }, this);

    knockout.getObservable(this, 'canDisplayPercent').subscribe(function(canDisplayPercent) {
        // If canDisplayPercent becomes false, must also turn off displayPercent.
        if (!canDisplayPercent) {
            this.displayPercent = false;
        }
    }, this);


    knockout.getObservable(this, 'displayPercent').subscribe(function(displayPercent) {
        var item = this;
        if (defined(item._tableStructure)) {
            item._tableStructure.columns.forEach(function(column) {
                if (displayPercent) {
                    column.isActive = (column.id === 'region percent');
                } else {
                    if (item._concepts.length > 0) {
                        column.isActive = (column.id === 'total selected');
                    }
                    // An example without concepts can only display one thing, so cannot calculate any regional totals.
                }
            });
        }
    }, this);

};

inherit(TableCatalogItem, SdmxJsonCatalogItem);

defineProperties(SdmxJsonCatalogItem.prototype, {
    /**
     * Gets the type of data member represented by this instance.
     * @memberOf SdmxJsonCatalogItem.prototype
     * @type {String}
     */
    type: {
        get: function() {
            return 'sdmx-json';
        }
    },

    /**
     * Gets a human-readable name for this type of data source, 'SDMX-JSON'.
     * @memberOf SdmxJsonCatalogItem.prototype
     * @type {String}
     */
    typeName: {
        get: function() {
            return 'SDMX-JSON';
        }
    },

    /**
     * Gets the set of names of the properties to be serialized for this object for a share link.
     * @memberOf ImageryLayerCatalogItem.prototype
     * @type {String[]}
     */
    propertiesForSharing: {
        get: function() {
            return SdmxJsonCatalogItem.defaultPropertiesForSharing;
        }
    },

    /**
     * Gets the set of functions used to serialize individual properties in {@link CatalogMember#serializeToJson}.
     * When a property name on the model matches the name of a property in the serializers object literal,
     * the value will be called as a function and passed a reference to the model, a reference to the destination
     * JSON object literal, and the name of the property.
     * @memberOf SdmxJsonCatalogItem.prototype
     * @type {Object}
     */
    serializers: {
        get: function() {
            return SdmxJsonCatalogItem.defaultSerializers;
        }
    },

    /**
     * Gets the list of singleValuedDimensionIds with the frequency and region type included.
     * @memberOf SdmxJsonCatalogItem.prototype
     * @type {String[]}
     */
    allSingleValuedDimensionIds: {
        get: function() {
            return [this.regionTypeDimensionId, this.frequencyDimensionId].concat(this.singleValuedDimensionIds);
        }
    },

    /**
     * Gets the original URL of this item.
     * @memberOf SdmxJsonCatalogItem.prototype
     * @type {String}
     */
    originalUrl: {
        get: function() {
            return defaultValue(this._originalUrl, this.url);
        }
    }
});

/**
 * Gets or sets the default set of properties that are serialized when serializing a {@link CatalogItem}-derived for a
 * share link.
 * @type {String[]}
 */
SdmxJsonCatalogItem.defaultPropertiesForSharing = clone(TableCatalogItem.defaultPropertiesForSharing);
SdmxJsonCatalogItem.defaultPropertiesForSharing.push('selectedInitially');
SdmxJsonCatalogItem.defaultPropertiesForSharing.push('displayPercent');
freezeObject(SdmxJsonCatalogItem.defaultPropertiesForSharing);

SdmxJsonCatalogItem.defaultSerializers = clone(TableCatalogItem.defaultSerializers);
SdmxJsonCatalogItem.defaultSerializers.selectedInitially = function(item, json) {
    // Create the 'selectedInitially' that would start us off with the same active items as are currently shown.
    json.selectedInitially = {};
    if (item._concepts.length === 0) {
        return;
    }
    item._concepts[0].items.forEach(function(displayConcept) {
        json.selectedInitially[displayConcept.id] = displayConcept.items.filter(function(concept) {
            return concept.isActive;
        }).map(function(concept) {
            return concept.id;
        });
    });
};
SdmxJsonCatalogItem.defaultSerializers.activeConcepts = function() {
    // Don't serialize.
};
SdmxJsonCatalogItem.defaultSerializers.url = function(item, json) {
    // Put the original URL back in as the url when serializing.
    json.url = item.originalUrl;
};
freezeObject(SdmxJsonCatalogItem.defaultSerializers);

// Just the items that would influence the load from the server or the file
SdmxJsonCatalogItem.prototype._getValuesThatInfluenceLoad = function() {
    // Reply with the item's url, which is saved into originalUrl during the load process, and overwritten.
    return [this.originalUrl];
};

// The URL can have two different forms, which require different handling.
// 1. http://stat.abs.gov.au/sdmx-json/data/ABS_REGIONAL_LGA/CABEE_2.LGA2013.1+.A/all?startTime=2013&endTime=2013
//    Read data from this URL directly and construct the table and concepts from it.
// 2. http://stat.abs.gov.au/sdmx-json/data/ABS_REGIONAL_LGA
//    Do not attempt to hit this URL directly.
//    Instead get the concepts from .../dataflow/ABS_REGIONAL_LGA, and then, whenever the active concepts are changed,
//    construct a specific URL like in #1 from those concepts, load the data from it, and construct a table.
//    If no 'dataflow' URL is recognizable, revert to #1 behaviour.
// If the URL fits neither form, assume it is a datafile to be handled like #1.
// You can also force the #1 behaviour by blanking out item.dataflowUrlComponent.
// This function returns undefined for #1, and the dataflow URL for #2.
function getDataflowUrl(item) {
    if (!item.dataflowUrlComponent) {
        return;
    }
    var dataUrlComponent = '/' + item.dataUrlComponent + '/';
    var dataUrlIndex = (item._originalUrl.lastIndexOf(dataUrlComponent));
    // If the URL contains /data/, look for how many / terms come after it.
    if (dataUrlIndex >= 0) {
        var suffix = item._originalUrl.slice(dataUrlIndex + dataUrlComponent.length);
        // eg. suffix would be ABS_REGIONAL_LGA/CABEE_2.LGA2013.1+.A/all...
        // If it contains a /, and anything after the /, then treat it as #1.
        if (suffix.indexOf('/') >= 0 && suffix.indexOf('/') < suffix.length - 1) {
            return;
        } else {
            // return the same URL but with /data/ replaced with /dataflow/.
            var dataflowUrlComponent = '/' + item.dataflowUrlComponent + '/';
            return item._originalUrl.replace(dataUrlComponent, dataflowUrlComponent);
        }
    }
}

/*
 * We access:
 *   - result.structure.dimensions.observation[k] for {keyPosition, id, name, values[]}
 *         to get the name & id of dimension keyPosition and its array of allowed values (with {id, name}).
 *   - result.structure.dimensions.attributes.dataSet
 *         can have units, unit multipliers, reference periods (eg. http://stats.oecd.org/sdmx-json/dataflow/QNA).
 *   - result.structure.dimensions.attributes.observation
 *         can have time formats and status (eg. estimated value, forecast value).
 *
 * (Alternatively, in xml format):
 * http://stats.oecd.org/restsdmx/sdmx.ashx/GetDataStructure/<dataset id> (eg. QNA).
 *
 * Data comes from:
 * http://example.com/sdmx-json/data/<dataset identifier>/<filter expression>/<agency name>[ ?<additional parameters>]
 *
 * Eg.
 * http://stats.oecd.org/sdmx-json/data/QNA/AUS+AUT.GDP+B1_GE.CUR+VOBARSA.Q/all?startTime=2009-Q2&endTime=2011-Q4
 *
 * An example from the ABS could be:
 * http://stat.abs.gov.au/sdmx-json/data/ABS_REGIONAL_LGA/CABEE_2.LGA2013.1+.A/all?startTime=2013&endTime=2013
 *
 * Then access:
 *   - result.structure.dimensions.series[i] for {keyPosition, id, name, values[]}
 *         to get the name & id of dimension keyPosition and its array of allowed values (with {id, name}).
 *   - result.structure.dimensions.observation[i] for {role, id, name, values[]}
 *         to get the name & id of the observations and its array of allowed values (with {id, name}).
 *   - result.dataSets[0].series[key].observations[t][0] with key = "xx:yy:zz"
 *         where xx is the index of a value from dimension 0, etc, and t is the time index (eg. 0 for a single time).
 *
 * Currently, we only parse the first "dataSet" object provided. (This covers all situations of interest to us so far.)
 *
 * Time seems to be handled specially, at least by the OECD.
 * Eg.
 *   http://stats.oecd.org/sdmx-json/dataflow/QNA shows there are 5 dimensions (result.structure.dimensions.observation): LOCATION, SUBJECT, MEASURE, FREQUENCY, TIME_PERIOD.
 *   But http://stats.oecd.org/sdmx-json/data/QNA/.B1_GE.VOBARSA.Q/all only returns 4 dimensions (result.structure.dimensions.series): TIME_PERIOD is gone.
 *   Instead, it has become an observation: result.structure.dimensions.observation[0] has property "values" with lots of {id, name} fields, eg. {id: "1960-Q1", name: "Q1-1960"}.
 *   And result.dataSets[0].series[key].observations[t] has lots of values for different t, not necessarily including t = 0. (eg. key = "21:0:0:0" starts at t = 140).
 */
SdmxJsonCatalogItem.prototype._load = function() {
    // Set some defaults.
    this._originalUrl = this.originalUrl; // Since `this.url` is often set after initialization.
    this.regionTypeDimensionId = defaultValue(this.regionTypeDimensionId, 'REGIONTYPE');
    this.regionDimensionId = defaultValue(this.regionDimensionId, 'REGION');
    this.frequencyDimensionId = defaultValue(this.frequencyDimensionId, 'FREQUENCY');
    this.timePeriodDimensionId = defaultValue(this.timePeriodDimensionId, 'TIME_PERIOD');
    this.providerId = defaultValue(this.providerId, 'all');
    this.dataUrlComponent = defaultValue(this.dataUrlComponent, 'data');
    this.dataflowUrlComponent = defaultValue(this.dataflowUrlComponent, 'dataflow');
    // cannotDisplayPercentMap is deprecated. Replace it with cannotSum.
    if (defined(this.cannotDisplayPercentMap)) {
        deprecationWarning('cannotDisplayPercentMap is deprecated. Use cannotSum instead.');
        if (!defined(this.cannotSum)) {
            this.cannotSum = this.cannotDisplayPercentMap;
        }
    }

    this._suppressedIds = [this.regionDimensionId, this.timePeriodDimensionId];

    var tableStyle = this._tableStyle;
    this._columnOptions = {
        displayDuration: tableStyle.displayDuration,
        displayVariableTypes: TableStructure.defaultDisplayVariableTypes,
        replaceWithNullValues: tableStyle.replaceWithNullValues,
        replaceWithZeroValues: tableStyle.replaceWithZeroValues
    };

    // We pass column options to TableStructure too, but they only do anything if TableStructure itself (eg. via fromJson) adds the columns,
    // which is not the case here.  We will need to pass them to each call to new TableColumn as well.
    this._tableStructure = new TableStructure(this.name, this._columnOptions);
    this._regionMapping = new RegionMapping(this, this._tableStructure, tableStyle);

    this._dataflowUrl = getDataflowUrl(this);
    if (!defined(this.metadataUrl)) {
        this.metadataUrl = this._dataflowUrl; // So a link to the metadata appears in About This Dataset.
    }
    if (this._dataflowUrl) {
        return loadDataflow(this);  // This eventually triggers loadAndBuildTable too, via changedActiveItem.
    } else {
        return loadAndBuildTable(this);
    }
};

// Sets the tableStructure's columns to the new columns, redraws the map, and closes the feature info panel.
function updateColumns(item, newColumns) {
    item._tableStructure.columns = newColumns;
    if (item._tableStructure.columns.length === 0) {
        // Nothing to show, so the attempt to redraw will fail; need to explicitly hide the existing regions.
        item._regionMapping.hideImageryLayer();
        item.terria.currentViewer.notifyRepaintRequired();
    }
    // Close any picked features, as the description of any associated with this catalog item may change.
    item.terria.pickedFeatures = undefined;
}

// Adds the wildcard's exclusive values to the front of those for this dimension id, if any.
function getTotalValueIdsForDimensionId(item, dimensionId) {
    return (item.totalValueIds['*'] || []).concat(item.totalValueIds[dimensionId] || []);
}

// Trims spaces off rawName
// If cleanFootnotes is true, also removes trailing (x)'s, eg. Total(c) => Total.
function renameValue(item, rawName) {
    var trimmedName = rawName.trim();
    if (item.cleanFootnotes) {
        var length = trimmedName.length;
        if ((trimmedName.indexOf('(') === length - 3) && (trimmedName.indexOf(')') === length - 1)) {
            return trimmedName.slice(0, length - 3);
        }
    }
    return trimmedName;
}

/**
 * Returns an array whose elements are objects describing each dimension.
 * The array has length structureSeries.length (assuming the keyPositions are correct),
 * and the index of each element is its keyPosition.
 * Each element is an object with the properties:
 *   - dimensionId
 *   - dimensionName
 *   - values: An array whose elements describe each allowed value of the dimension (eg. countries, measurement types).
 *             Each element is an object with the properties:
 *             - id
 *             - name
 * If there is a whitelist, only the whitelisted values are included.
 * If there is a blacklist, blacklisted values are excluded.
 * @private
 * @param  {SdmxJsonCatalogItem} item The SDMX-JSON catalog item.
 * @param  {Array} structureSeries The structure's series property, json.structure.dimensions.series.
 * @return {Object[]} A description of the dimensions.
 */
function buildDimensions(item, structureSeries) {
    // getFilter returns a function which can be used in list.filter().
    // It tests if the value is in the given list, using regexps if possible.
    // filterList can be either item.whitelist or item.blacklist.
    // set isWhiteList false if is blacklist, so that the return values are negated (except for a missing list).
    function getFilter(filterList, dimensionId, isWhiteList) {
        var thisIdsFilterList = filterList[dimensionId];
        if (!defined(thisIdsFilterList)) {
            return function() { return true; };
        }
        try {
            var thisIdsRegExps = thisIdsFilterList.map(string => new RegExp('^' + string + '$'));
            return function(value) {
                // Test as a straight string, and if that fails, as a regular expression.
                var isPresent = thisIdsFilterList.indexOf(value.id) >= 0 || thisIdsRegExps.map(regExp => value.id.match(regExp)).some(defined);
                if (isWhiteList) {
                    return isPresent;
                }
                return !isPresent;
            };
        } catch (e) {
            // Cannot intepret as a regular expression.
            // Eg. "[" causes Uncaught SyntaxError: Invalid regular expression: /[/: Unterminated character class(…)),
            // So just test as a string.
            return function(value) {
                var isPresent = thisIdsFilterList.indexOf(value.id) >= 0;
                if (isWhiteList) {
                    return isPresent;
                }
                return !isPresent;
            };
        }
    }
    var result = [];
    for (var i = 0; i < structureSeries.length; i++) {
        var thisSeries = structureSeries[i];
        var keyPosition = defined(thisSeries.keyPosition) ? thisSeries.keyPosition : i; // Since time_period can be an observation, without a keyPosition.
        var values = thisSeries.values
            .filter(getFilter(item.whitelist, thisSeries.id, true))
            .filter(getFilter(item.blacklist, thisSeries.id, false));
        if (item.sortValues === 'id') {
            values = values.sort((a, b) => naturalSort(a.id, b.id));
        } else if (item.sortValues === 'name' || item.sortValues === true) {
            values = values.sort((a, b) => naturalSort(a.name, b.name));
        }
        moveTotalValueIdsToFront(values, getTotalValueIdsForDimensionId(item, thisSeries.id));
        result[keyPosition] = {
            id: thisSeries.id,
            name: thisSeries.name,
            // Eg. values: [{id: "BD_2", name: "Births"}, {id: "BD_4", name: "Deaths"}].
            values: values
        };
    }
    return result;
}

function moveTotalValueIdsToFront(values, totalValueIds) {
    if (defined(totalValueIds)) {
        // Go in reverse order so the first one in the list ends up in the front at the end.
        // Not all exclusive values need be present.
        for (var j = totalValueIds.length - 1; j >= 0; j--) {
            var exclusiveValue = totalValueIds[j];
            var currentIndex = values.map(value => value.id).indexOf(exclusiveValue);
            if (currentIndex >= 0) {
                // Move it to the top.
                values.splice(0, 0, values.splice(currentIndex, 1)[0]);
            }
        }
    }
}

// Return dimensions, but removing:
//   - suppressed dimensions,
//   - dimensions with only one value in fullDimensions (unless they are in the force-show list)
//   - aggregated dimensions.
// Dimensions and fullDimensions must have the same ordering of dimensions.
function getShownDimensions(item, dimensions, fullDimensions) {
    return dimensions.filter(function(dimension, i) {
        return (item._suppressedIds.indexOf(dimension.id) === -1) &&
                // note the logic of the next line is repeated in calculateDimensionRequestString
               (fullDimensions[i].values.length > 1 || item.forceShowDimensionIds.indexOf(dimension.id) >= 0) &&
               (item.aggregatedDimensionIds.indexOf(dimension.id) === -1);
    });
}

/**
 * Calculates all the combinations of values that should appear as either:
 *   - columns in our table (by passing the "loadedDimensions" for a given dataset), or
 *   - concepts in the Now Viewing panel (by passing the "fullDimensions", ie. those from the dataflow.)
 * Does not include suppressed (ie. region or time_period) values.
 * Returns an object with properties:
 *   names: An array, each element of which is an array of the names of each relevant dimension value.
 *   ids:   An array, each element of which is an array of the ids of each relevant dimension value.
 * @private
 * @param  {SdmxJsonCatalogItem} item The catalog item.
 * @param {Object[]} dimensions The output of buildDimensions, either fullDimensions or loadedDimensions.
 * @param {Object[]} fullDimensions The output of buildDimensions on the dataflow result (or data if no dataflow). Defaults to dimensions.
 * @return {Object} The values and names of the dimensions to be shown.
 */
function calculateShownDimensionCombinations(item, dimensions, fullDimensions) {
    // Note we need to suppress the time dimension from the dimension list, if any; it appears as an observation instead.
    // We also need to suppress the regions.
    // Convert the values into all the combinations we'll need to load into columns,
    // eg. [[0], [0], [0, 1, 2], [0, 1]] => [[0, 0, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0], [0, 0, 1, 1], [0, 0, 2, 0], [0, 0, 2, 1]].
    if (!defined(fullDimensions)) {
        fullDimensions = dimensions;
    }
    var valuesArrays = getShownDimensions(item, dimensions, fullDimensions).map(function(dimension) {
        return dimension.values;
    });

    var idsArrays = valuesArrays.map(function(values) { return values.map(function(value) { return value.id; }); });
    var namesArrays = valuesArrays.map(function(values) { return values.map(function(value) { return value.name; }); });
    return {
        ids: arrayProduct(idsArrays),
        names: arrayProduct(namesArrays)
    };
}

function getDimensionById(dimensions, id) {
    var result;
    for (var i = 0; i < dimensions.length; i++) {
        if (dimensions[i].id === id) {
            result = dimensions[i];
        }
    }
    return result;
}

function getDimensionIndexById(dimensions, id) {
    var result;
    for (var i = 0; i < dimensions.length; i++) {
        if (dimensions[i].id === id) {
            result = i;
        }
    }
    return result;
}

function getRegionColumnName(item, dimensions, regionTypeIndex) {
    var regionTypeDimension = getDimensionById(dimensions, item.regionTypeDimensionId);
    var regionDimension = getDimensionById(dimensions, item.regionDimensionId);
    if (defined(regionTypeDimension)) {
        // If there is a REGIONTYPE dimension, use its id.
        var regionTypeId = regionTypeDimension.values[regionTypeIndex || 0].id;
        // If there is a regionNameTemplate, apply it to the id.
        if (defined(item.regionNameTemplate)) {
            return Mustache.render(item.regionNameTemplate, {"name": regionTypeId});
        }
        // Fall back to this default approach to convert it to csv-geo-au:
        // Assume the raw data is just missing the word "code", eg. SA4 or SA4_2013 should be SA4_code or SA4_code_2013.
        // So, if there's a _, replace the last one with _code_; else append _code.
        // Also handle the case that the raw data ends in 4 digits without the underscore, eg. LGA2011 -> LGA_code_2011.
        var underscoreIndex = regionTypeId.lastIndexOf('_');
        if (underscoreIndex >= 0) {
            return regionTypeId.slice(0, underscoreIndex) + '_code' + regionTypeId.slice(underscoreIndex);
        } else {
            var fourDigitSuffixMatch = regionTypeId.match(/(.+)([0-9]{4})$/);
            if (defined(fourDigitSuffixMatch)) {
                return fourDigitSuffixMatch[1] + '_code_' + fourDigitSuffixMatch[2];
            }
            return regionTypeId + '_code';
        }
    } else if (defined(regionDimension)) {
        // Else, if there is a REGION dimension and item.regionType has been defined, return item.regionType (and don't append anything).
        if (defined(item.regionType) && defined(item.regionType)) {
            return item.regionType;
        }
        // Else, use the REGION dimension id, if present.
        return regionDimension.id;
    }
}

// If there are times 2010, 2011, and regions AUS, MEX,
// then the table has rows in this order:
// date, region, ...
// 2010, AUS
// 2010, MEX
// 2011, AUS ... etc.
function buildRegionAndTimeColumns(item, dimensions) {
    var regionDimension = getDimensionById(dimensions, item.regionDimensionId);
    var timePeriodDimension = getDimensionById(dimensions, item.timePeriodDimensionId);
    if (!defined(regionDimension) && !defined(timePeriodDimension)) {
        // No region dimension (with the actual region values in it) AND no time dimension - we're done.
        return [];
    }
    var regionValues = [];
    var timePeriodValues = [];
    var regionCount = defined(regionDimension) ? regionDimension.values.length : 1;
    var timePeriodCount = defined(timePeriodDimension) ? timePeriodDimension.values.length : 1;
    for (var timePeriodIndex = 0; timePeriodIndex < timePeriodCount; timePeriodIndex++) {
        for (var regionIndex = 0; regionIndex < regionCount; regionIndex++) {
            if (defined(regionDimension)) {
                regionValues.push(regionDimension.values[regionIndex].id);
            }
            if (defined(timePeriodDimension)) {
                timePeriodValues.push(timePeriodDimension.values[timePeriodIndex].id);
            }
        }
    }
    var timePeriodColumn;
    if (defined(timePeriodDimension)) {
        var thisColumnOptions = clone(item._columnOptions);
        if (timePeriodCount === 1) {
            thisColumnOptions.type = VarType.ENUM; // Don't trigger timeline off a single-valued time dimension.
        }
        timePeriodColumn = new TableColumn('date', timePeriodValues, thisColumnOptions);
    }
    if (!defined(regionDimension)) {
        return [timePeriodColumn];
    }
    // If there are multiple region types in the data, only use the first region type.
    var regionColumnName = getRegionColumnName(item, dimensions, 0);
    var regionColumn = new TableColumn(regionColumnName, regionValues, item._columnOptions);
    if (defined(timePeriodDimension) && defined(regionDimension)) {
        return [timePeriodColumn, regionColumn];
    } else {
        return [regionColumn];
    }
}

// Sums an array, treating undefined's as 0 in the sum, but leaving undefined + undefined = undefined.
// (Note the "defined" function catches null and defined.)
function sumArray(array) {
    return array.filter(defined).reduce((x, y) => x + y, null);
}

// Eg. ids = ['GDP', 'METHOD-B'].
// We want to map this to an array of ids, eg. ['GDP, 'METHOD-B', undefined, 'LGA', 'A'] for SUBJECT, METHOD, REGION, REGIONTYPE, FREQUENCY (for example),
// with undefined for any suppressed dimensions and values[0] for all single-valued dimensions.
// This can then easily be turned into a colon-separated key.
function mapDimensionValueIdsToKeyValues(item, loadedDimensions, ids) {
    var result = [];
    var shownDimensions = getShownDimensions(item, loadedDimensions, item._fullDimensions);
    var shownDimensionIds = shownDimensions.map(function(dimension) { return dimension.id; });
    for (var dimensionIndex = 0; dimensionIndex < loadedDimensions.length; dimensionIndex++) {
        var outputDimension = loadedDimensions[dimensionIndex];
        var i = shownDimensionIds.indexOf(outputDimension.id);
        if (i >= 0) {
            result.push(ids[i]);
        } else if (item._suppressedIds.indexOf(outputDimension.id) >= 0) {
            result.push(undefined);
        } else {
            result.push(outputDimension.values[0].id);
        }
    }
    return result;
}

// Slightly generalise dataSets[key].obsValue to handle undefined data, and the
// possibility of aggregating across some dimensions.
function getObsValue(item, loadedDimensions, dimensionIndices, dataSets) {
    // Usually no dimensions need to be aggregated, so just return dataSets[key].obsValue.
    var key = dimensionIndices.join(':');
    var valueObject = dataSets[key];
    if (item.aggregatedDimensionIds.length === 0) {
        return defined(valueObject) ? valueObject.obsValue : null;
    }
    // This implementation can handle at most a single aggregated dimension id.
    if (item.aggregatedDimensionIds.length === 1) {
        var aggregatedDimensionId = item.aggregatedDimensionIds[0];
        var valuesToAggregate = [];
        var aggregatedDimensionIndex = getDimensionIndexById(loadedDimensions, aggregatedDimensionId);
        var dimension = getDimensionById(loadedDimensions, aggregatedDimensionId);
        if (!defined(dimension)) {
            console.warn('Tried to aggregate on a dimension that doesn\'t exist, ' + aggregatedDimensionId);
            return defined(valueObject) ? valueObject.obsValue : null;
        }
        dimension.values.forEach(function(thisValueObject) {
            dimensionIndices[aggregatedDimensionIndex] = thisValueObject.id;
            var thisKey = dimensionIndices.join(':');
            thisValueObject = dataSets[thisKey];
            if (defined(thisValueObject)) {
                valuesToAggregate.push(thisValueObject.obsValue);
            }
        });
        return sumArray(valuesToAggregate);
    }
    console.warn('SDMX-JSON aggregatedDimensionIds - only a single dimension id is implemented.');
}

// Create a column for each combination of (non-region) dimension values.
// The column has values for each region.
function buildValueColumns(item, loadedDimensions, columnCombinations, dataSets) {
    var thisColumnOptions = clone(item._columnOptions);
    thisColumnOptions.tableStructure = item._tableStructure;
    var columns = [];
    var hasNoConcepts = (item._concepts.length === 0);
    var regionDimension = getDimensionById(loadedDimensions, item.regionDimensionId);
    var regionDimensionIndex = getDimensionIndexById(loadedDimensions, item.regionDimensionId);
    var timePeriodDimension = getDimensionById(loadedDimensions, item.timePeriodDimensionId);
    var timePeriodDimensionIndex = getDimensionIndexById(loadedDimensions, item.timePeriodDimensionId);

    var regionCount = defined(regionDimension) ? regionDimension.values.length : 1;
    var timePeriodCount = defined(timePeriodDimension) ? timePeriodDimension.values.length : 1;

    for (var combinationIndex = 0; combinationIndex < columnCombinations.ids.length; combinationIndex++) {
        var ids = columnCombinations.ids[combinationIndex];
        var dimensionIndices = mapDimensionValueIdsToKeyValues(item, loadedDimensions, ids);
        // The name is just the joined names of all the columns involved, or 'value' if no columns still have names.
        var combinationName = columnCombinations.names[combinationIndex].filter(function(name) {return !!name; }).join(' ') || 'Value';
        var combinationId = ids.join(' ') || 'Value';
        var values = [];
        for (var timePeriodIndex = 0; timePeriodIndex < timePeriodCount; timePeriodIndex++) {
            for (var regionIndex = 0; regionIndex < regionCount; regionIndex++) {
                if (defined(regionDimensionIndex)) {
                    dimensionIndices[regionDimensionIndex] = regionDimension.values[regionIndex].id;
                }
                if (defined(timePeriodDimensionIndex)) {
                    dimensionIndices[timePeriodDimensionIndex] = timePeriodDimension.values[timePeriodIndex].id;
                }
                values.push(getObsValue(item, loadedDimensions, dimensionIndices, dataSets));
            }
        }
        thisColumnOptions.id = combinationId; // So we can refer to the dimension in a template by a sequence of ids or names.
        var column = new TableColumn(combinationName, values, thisColumnOptions);
        if (hasNoConcepts) {
            // If there are no concepts displayed to the user, there is only one value column, and we won't add a "total" column.
            // So make this column active.
            column.isActive = true;
        }
        columns.push(column);
    }
    return columns;
}


// Map the active concepts into arrays of arrays of ids.
// Eg. Return [['GDP', 'GNP'], ['Q']].
function calculateActiveConceptIds(item) {
    if (item._concepts.length === 0) {
        return [];
    }
    var conceptItems = item._concepts[0].items;
    return conceptItems.map(parent =>
        // Note this wouldn't work whilst following through on the activeItems knockout subscription,
        // if we hadn't wrapped that in a runLater. You would have had to explicitly call
        // parent.items.filter(concept => concept.isActive).map(concept => concept.id)
        parent.activeItems.map(concept => concept.id)
    );
}

// Map the _total_ concept ids into arrays of (arrays of length 1).
// If a total is not available for a dimension, but it is in singleValuedDimensionIds,
// then use its current value instead - eg. region type, frequency, and some measure types.
// Returns undefined if any concepts do not have a total available.
// Eg. Return [['TOT'], ['3'], ['TOT']].
function calculateTotalConceptIds(item) {
    if (item._concepts.length === 0) {
        return [];
    }
    var conceptItems = item._concepts[0].items;
    var totalConceptIds = conceptItems.map(function(parent) {
        // Because any available totals are sorted to the top of the list of children,
        // and the grand total is always the first one, just check if the first child
        // is in the list of exclusiveChildIds for the parent, and return it if so.
        var firstChildId = parent.items[0].id;
        if (parent.exclusiveChildIds.indexOf(firstChildId) >= 0) {
            return [firstChildId];
        }
        // Dimensions in singleValuedDimensionIds became concepts with allowMultiple false.
        if (!parent.allowMultiple) {
            // Needs runLater for activeItems to work correctly - see comment above.
            return parent.activeItems.map(concept => concept.id);
        }
    });
    // totalConceptIds will have undefined elements if it couldn't find a total for a dimension.
    // Only return the array if every element is defined. Otherwise, return undefined.
    if (totalConceptIds.every(defined)) {
        return totalConceptIds;
    }
    return undefined;
}

// Check if item.selectedInitially has at least one value that exists in dimension.values,
// and if it doesn't, reset item.selectedInitially.
function fixSelectedInitially(item, conceptDimensions) {
    conceptDimensions.forEach(dimension => {
        if (defined(item.selectedInitially)) {
            var thisSelectedInitially = item.selectedInitially[dimension.id];
            if (thisSelectedInitially) {
                var valueIds = dimension.values.map(value => value.id);
                if (!thisSelectedInitially.some(initialValue => valueIds.indexOf(initialValue) >= 0)) {
                    console.warn('Ignoring invalid initial selection ' + thisSelectedInitially + ' on ' + dimension.name);
                    item.selectedInitially[dimension.id] = undefined;
                }
            }
        }
    });
}

// Build out the concepts displayed in the NowViewing panel. Also fixes selectedInitially, if broken.
function buildConcepts(item, fullDimensions) {

    function isInitiallyActive(dimensionId, value, index) {
        if (!defined(item.selectedInitially)) {
            return index === 0;
        }
        var dimensionSelectedInitially = item.selectedInitially[dimensionId];
        if (!defined(dimensionSelectedInitially)) {
            return index === 0;
        }
        return dimensionSelectedInitially.indexOf(value.id) >= 0;
    }

    var conceptDimensions = getShownDimensions(item, fullDimensions, fullDimensions);
    fixSelectedInitially(item, conceptDimensions);  // Note side-effect.
    var concepts = conceptDimensions.map(function(dimension, i) {
        var allowMultiple = item.allSingleValuedDimensionIds.indexOf(dimension.id) === -1;
        var concept = new DisplayVariablesConcept(dimension.name, {
            isOpen: false,
            allowMultiple: allowMultiple,
            requireSomeActive: true,
            exclusiveChildIds: getTotalValueIdsForDimensionId(item, dimension.id)
        });
        concept.id = dimension.id;
        concept.items = dimension.values.map(function(value, index) {
            return new VariableConcept(renameValue(item, value.name), {
                parent: concept,
                id: value.id,
                active: isInitiallyActive(concept.id, value, index)
            });
        });
        return concept;
    });
    if (concepts.length > 0) {
        return [new SummaryConcept(undefined, {items: concepts, isOpen: false})];
    }
    return [];
}

// Returns true if the results of this can be summed meaningfully.
// By default, we assume they can be. But if item.cannotSum is set, at least some selections
// may be rates or averages (rather than counts or totals), which cannot be summed.
function canResultsBeSummed(item) {
    var result = true;
    if (defined(item.cannotSum)) {
        if (typeof item.cannotSum === 'object') {
            var conceptItems = item._concepts[0].items;
            conceptItems.forEach(concept => {
                var valuesThatCannotDisplayPercent = item.cannotSum[concept.id];
                if (defined(valuesThatCannotDisplayPercent)) {
                    var activeValueIds = concept.activeItems.map(activeConcept => activeConcept.id);
                    if (valuesThatCannotDisplayPercent.some(cannotValue => activeValueIds.indexOf(cannotValue) >= 0)) {
                        result = false;
                    }
                }
            });
        } else {
            result = !item.cannotSum;  // ie. if it is true or false.
        }
    }
    return result;
}

// Only show a warning if more than one value of a concept has been selected.
// Returns true if the user has been warned.
function canTotalBeCalculatedAndIfNotWarnUser(item) {
    if (canResultsBeSummed(item)) {
        return true;
    }
    var conceptItems = item._concepts[0].items;
    var changedActive = [];
    conceptItems.forEach(concept => {
        var numberActive = concept.items.filter(function(subconcept) {return subconcept.isActive;}).length;
        if (numberActive > 1) {
            changedActive.push('"' + concept.name + '"');
        }
    });
    if (changedActive.length > 0) {
        item.terria.error.raiseEvent(new TerriaError({
            sender: item,
            title: 'Cannot calculate a total',
            message: 'You have selected multiple values for ' + changedActive.join(' and ') + ', but the measure you now have chosen cannot be totalled across them. \
            As a result, there is no obvious measure to use to shade the regions (although you can still choose a region to view its data).\
            To see the regions shaded again, please select only one value for ' + changedActive.join(' and ') + ', or select a different measure.'
        }));
        return false;
    }
    return true;
}

// Create columns for the total selected values.
// If <=1 active column, returns [].
function buildTotalSelectedColumn(item, columnCombinations) {
    // Build a total column equal to the sum of all the active concepts.
    if (!canTotalBeCalculatedAndIfNotWarnUser(item)) {
        return [];
    }
    var thisColumnOptions = clone(item._columnOptions);
    thisColumnOptions.tableStructure = item._tableStructure;
    thisColumnOptions.id = 'total selected';
    var activeConceptIds = calculateActiveConceptIds(item);
    if (activeConceptIds.length === 0) {
        return [];
    }
    // Find all the combinations of active concepts.
    // Eg. [['GDP'], ['METHOD-A', 'METHOD-C'], ['Q']] => [['GDP', 'METHOD-A', 'Q'], ['GDP', 'METHOD-C', 'Q']]
    var activeCombinations = arrayProduct(activeConceptIds);
    // Look up which columns these correspond to.
    // Note we need to convert the arrays to strings for indexOf to work.
    // Join with + as we know it cannot appear in an id, since it's used in the URL.
    // (If this string appears in any id, it will confuse things.)
    var joinString = '+';
    var stringifiedCombinations = columnCombinations.ids.map(function(combination) { return combination.join(joinString); });
    var indicesIntoCombinations = activeCombinations.map(function(activeCombination) {
        var stringifiedActiveCombination = activeCombination.join(joinString);
        return stringifiedCombinations.indexOf(stringifiedActiveCombination);
    });
    // Slice off the initial region &/or time columns, and only keep the value columns (ignoring total columns which come at the end).
    var valueColumns = item._tableStructure.columns.slice(item._numberOfInitialColumns, columnCombinations.ids.length + item._numberOfInitialColumns);
    var includedColumns = valueColumns.filter(function(column, i) { return indicesIntoCombinations.indexOf(i) >= 0; });
    if (includedColumns.length === 0) {
        return [];
    }
    var totalColumn = new TableColumn('Total selected', TableColumn.sumValues(includedColumns), thisColumnOptions);
    totalColumn.isActive = !item.displayPercent;
    return totalColumn;
}

function buildPercentColumn(item, totalSelectedColumn, regionTotalColumn) {
    var thisColumnOptions = clone(item._columnOptions);
    thisColumnOptions.tableStructure = item._tableStructure;
    thisColumnOptions.id = 'region percent';
    var values = totalSelectedColumn.values.map((totalSelected, index) => {
        var regionTotal = regionTotalColumn.values[index];
        if (!regionTotal) {
            return null; // Return null if the denominator would be zero, null, undefined.
        }
        var fraction = totalSelected / regionTotal;
        return (fraction < 0.01) ? (Math.round(fraction * 1000) / 10) : (Math.round(fraction * 10000) / 100);
    });
    var column = new TableColumn('Percent selected in region', values, thisColumnOptions);
    column.isActive = item.displayPercent;
    return column;
}

// Returns the dimension request string, eg. "BD_2+BD_4.LGA_2013..A." appropriate for the active concept values.
// One trick is that the time dimension can appear in the dataflow, but should not be included in the data (or this request string).
// The dimension values need to be in the order of the original dimensions, not the concepts.
// Returns undefined if any dimension has no value selected.
// conceptIds should be the concept ids to load, eg. [['BD_2', 'BD_4'], ['A']].
function calculateDimensionRequestString(item, conceptIds, fullDimensions) {
    var hasAtLeastOneValuePerDimension = conceptIds.every(function(list) { return list.length > 0; });
    if (!hasAtLeastOneValuePerDimension) {
        return;
    }
    var nextConceptIndex = 0;
    var nonTimePeriodDimensions = fullDimensions.filter(function(dimension) {
        return (item.timePeriodDimensionId !== dimension.id);
    });
    var dimensionRequestArrays = nonTimePeriodDimensions.map(function(dimension, dimensionIndex) {
        if (dimension.id === item.regionDimensionId) {
            return ['']; // A missing id loads all ids.
        }
        if (item.aggregatedDimensionIds.indexOf(dimension.id) >= 0) {
            return ['']; // An aggregated dimension (eg. STATE when there's also LGA) loads all ids.
        }
        if (dimension.values.length === 1 && item.forceShowDimensionIds.indexOf(dimension.id) === -1) {
            // These do not appear as concepts - directly supply the only value's id.
            return [dimension.values[0].id];
        }
        return conceptIds[nextConceptIndex++];
    });
    if (dimensionRequestArrays.some(a => !defined(a))) {
        throw new TerriaError({
            sender: item,
            title: 'Dimension has no allowed values',
            message: 'One of this catalog item\'s dimensions has no allowed values. This can be caused by a badly-formed whitelist.'
        });
    }
    return dimensionRequestArrays.map(function(values) {
        return values.join('+');
    }).join('.');
}

// Called when the active column changes.
// Returns a promise.
function changedActiveItems(item) {
    if (!defined(item._dataflowUrl)) {
        // All the data is already here, just update the total columns.
        var shownDimensionCombinations = calculateShownDimensionCombinations(item, item._fullDimensions);
        var columns = item._tableStructure.columns.slice(0, shownDimensionCombinations.ids.length + item._numberOfInitialColumns);
        if (columns.length > 0) {
            columns = columns.concat(buildTotalSelectedColumn(item, shownDimensionCombinations));
            updateColumns(item, columns);
        }
        return when();
    } else {
        // Get the URL for the data request, and load & build the appropriate table.
        var activeConceptIds = calculateActiveConceptIds(item);
        var dimensionRequestString = calculateDimensionRequestString(item, activeConceptIds, item._fullDimensions);
        if (!defined(dimensionRequestString)) {
            return; // No value for a dimension, so ignore.
        }
        return loadAndBuildTable(item, dimensionRequestString);
    }
}

// Convert a dimension request string like "a+b+c.d.e+f.g" into a URL.
function getUrlFromDimensionRequestString(item, dimensionRequestString) {
    var url = item._originalUrl;
    if (url[url.length - 1] !== '/') {
        url += '/';
    }
    url += dimensionRequestString + '/' + item.providerId;
    if (defined(item.startTime)) {
        url += '?startTime=' + item.startTime;
        if (defined(item.endTime)) {
            url += '&endTime=' + item.endTime;
        }
    } else if (defined(item.endTime)) {
        url += '?endTime=' + item.endTime;
    }
    return url;
}

// This is called when the URL gives a datasetId, but no specifics.
// We start by loading in the structure (without any data) from the dataflow URL.
function loadDataflow(item) {
    var dataflowUrl = cleanAndProxyUrl(item, item._dataflowUrl);
    return loadJson(dataflowUrl).then(function(json) {
        // Then access:
        //   - result.structure.dimensions.observation[k] for {keyPosition, id, name, values[]} to get the name & id of dimension keyPosition and its array of allowed values (with {id, name}).
        //   - result.structure.dimensions.attributes.dataSet has some potentially interesting things such as units, unit multipliers, reference periods (eg. http://stats.oecd.org/sdmx-json/dataflow/QNA).
        //   - result.structure.dimensions.attributes.observation has some potentially interesting things such as time formats and status (eg. estimated value, forecast value).
        var structureSeries = json.structure.dimensions.observation;

        item._fullDimensions = buildDimensions(item, structureSeries);
        if (!defined(getDimensionIndexById(item._fullDimensions, item.regionDimensionId))) {
            throw noRegionsError(item);
        }
        item._concepts = buildConcepts(item, item._fullDimensions);
        // console.log('concepts', item._concepts);
        // The rest of the magic occurs because the concepts are made active.
        // So that the loading flow works properly, make that happen now.
        return changedActiveItems(item);
    });
}

function hasRegionDimension(item) {
    return defined(getDimensionIndexById(item._loadedDimensions, item.regionDimensionId));
}

function mapComponentsIterator(obj, type) {
    // sdmxJsonLib.response.mapComponentsToArray combines observations and attributes.
    // We need to keep track of which is which.
    obj._type = type;
    return obj;
}

function mapDataSetsArrayToObject(dataSetsArray) {
    var obj = {};
    dataSetsArray.forEach(function(element) {
        obj[element._key] = element;
    });
    return obj;
}

function buildDataSetsFromPreparedJson(item, preparedJson) {
    var dataSetsArray = sdmxJsonLib.response.mapDataSetsToArray(preparedJson);
    return mapDataSetsArrayToObject(dataSetsArray);
}

function buildDimensionsFromPreparedJson(item, preparedJson) {
    var structureDimensionsAndAttributes = sdmxJsonLib.response.mapComponentsToArray(preparedJson, mapComponentsIterator);
    var structureSeries = structureDimensionsAndAttributes.filter(function(s) { return s._type === 'dimensions'; });
    return buildDimensions(item, structureSeries);
}

// This is called with item._originalUrl when the URL is for a specific data file, ie. dataflow is not used.
// It is also called with a dimensionRequestString when dataflow is used.
// If we've been provided with a dimensionRequestString, then also try to load totals.
function loadAndBuildTable(item, dimensionRequestString) {
    var url;
    var totalsRequestString;
    var promises = [];
    if (defined(dimensionRequestString)) {
        url = getUrlFromDimensionRequestString(item, dimensionRequestString);
        item.url = url;
        var totalConceptIds = calculateTotalConceptIds(item);
        if (defined(totalConceptIds)) {
            totalsRequestString = calculateDimensionRequestString(item, totalConceptIds, item._fullDimensions);
            if (defined(totalsRequestString)) {
                if (!defined(item._regionTotals[totalsRequestString])) {
                    var totalsUrl = getUrlFromDimensionRequestString(item, totalsRequestString);
                    promises.push(loadJson(proxyCatalogItemUrl(item, totalsUrl)));
                }
            }
        }
    } else {
        url = item.originalUrl;
    }
    promises.push(loadJson(proxyCatalogItemUrl(item, url)));
    item.isLoading = true;
    return when.all(promises).then(function(jsons) {
        // jsons is an array of length 1 or 2, with optional total data json first, then the specific data json.
        var json = jsons[jsons.length - 1];  // the specific json.
        if (jsons.length === 2) {
            // Process and save the region totals as a datasets object.
            var totalsJson = jsons[0];
            sdmxJsonLib.response.prepare(totalsJson);
            item._regionTotals[totalsRequestString] = {
                dataSets: buildDataSetsFromPreparedJson(item, totalsJson),
                dimensions: buildDimensionsFromPreparedJson(item, totalsJson)
            };
        }
        var regionTotalsDataSets = item._regionTotals[totalsRequestString];
        // sdmxJsonLib.response.mapDataSetsToJsonStat might automate more of this?
        sdmxJsonLib.response.prepare(json);
        var dataSets = buildDataSetsFromPreparedJson(item, json);
        item._loadedDimensions = buildDimensionsFromPreparedJson(item, json);
        if (!hasRegionDimension(item)) {
            throw noRegionsError(item);
        }
        if (!defined(item._fullDimensions)) {
            // If we didn't come through the dataflow, ie. we've loaded this file directly, then we need to set the concepts now.
            // In this case, the loaded dimensions are the full set.
            item._fullDimensions = item._loadedDimensions;
            item._concepts = buildConcepts(item, item._fullDimensions);
        }

        var columnCombinations = calculateShownDimensionCombinations(item, item._loadedDimensions, item._fullDimensions);
        var regionAndTimeColumns = buildRegionAndTimeColumns(item, item._loadedDimensions);
        item._numberOfInitialColumns = regionAndTimeColumns.length;
        var valueColumns = buildValueColumns(item, item._loadedDimensions, columnCombinations, dataSets);
        // Build a regional total column, if possible.
        var regionTotalColumns = [];
        // Do not bother showing the region totals if the request itself is already for the region totals.
        // By not even putting the region total column into the table, canDisplayPercent is set to false,
        // so the user doesn't have the option to see meaningless 100%s everywhere.
        if (defined(regionTotalsDataSets) && (totalsRequestString !== dimensionRequestString)) {
            var regionTotalColumnCombinations = calculateShownDimensionCombinations(item, regionTotalsDataSets.dimensions, item._fullDimensions);
            regionTotalColumns = buildValueColumns(item, item._loadedDimensions, regionTotalColumnCombinations, regionTotalsDataSets.dataSets);
            regionTotalColumns.forEach((column, index) => {
                // Give it a simpler id. Should only be one region total column, but just in case.
                column.id = 'region total' + (index > 0 ? ('_' + index) : '');
            });
        }
        // We want to update the columns, which can of course update the region column.
        // RegionMapping watches for changes in the active column and tries to redisplay it if so.
        // Set regionMapping.isLoading true to prevent this.
        // However it does not expect the region column to change - regionDetails does not auto-update its column.
        // Once _regionMapping.loadRegionDetails() is done, it updates regionDetails.
        // Setting _regionMapping.isLoading to false then triggers the changed-active-column event.
        item._regionMapping.isLoading = true;
        // Set the columns and the concepts before building the total column, because it uses them both.
        item._tableStructure.columns = regionAndTimeColumns.concat(valueColumns);
        var totalColumn = buildTotalSelectedColumn(item, columnCombinations); // The region column can't be active, so ok not to pass it.
        var percentColumn = [];
        if (defined(totalColumn) && regionTotalColumns.length > 0) {
            // Usually, this means canDisplayPercent should be set to True.
            // However, there are cases where a rate, or mean, is displayed, when it still doesn't make sense.
            item.canDisplayPercent = canResultsBeSummed(item);
            if (item.canDisplayPercent) {
                percentColumn = buildPercentColumn(item, totalColumn, regionTotalColumns[0]);
            }
        } else {
            item.canDisplayPercent = false;
        }
        var columns = item._tableStructure.columns.concat(totalColumn).concat(regionTotalColumns).concat(percentColumn);
        updateColumns(item, columns);
        item._tableStructure.setActiveTimeColumn(item._tableStyle.timeColumn);
        return item._regionMapping.loadRegionDetails();
    }).then(function(regionDetails) {
        if (regionDetails) {
            item._regionMapping.setRegionColumnType();
            // Force a recalc of the imagery.
            // Required because we load the region details _after_ setting the active column.
            item._regionMapping.isLoading = false;
            item.isLoading = false;
        } else {
            throw noRegionsError(item);
            // item.setChartable();
        }
        return when();
    }).otherwise(function(e) {
        item._regionMapping.isLoading = false;
        item.isLoading = false;
        updateColumns(item, []);  // Remove any data, but leave the concepts alone so the user can recover by choosing again.
        if (e.statusCode === 404) {
            // Sometimes if there is no data available, the SDMX-JSON server can reply with broken json and a 404 error.
            // In this case, we don't want to wipe out the displayed options.
            // In contrast, if the entire dataset is missing, it will return a 400 error.
            item.terria.error.raiseEvent(new TerriaError({
                sender: item,
                title: e.title || 'No data',
                message: 'There is no data available for this combination. Please choose again.'
            }));
        } else {
            item.terria.error.raiseEvent(new TerriaError({
                sender: item,
                title: e.title || 'No data available',
                message: (e.message || e.response)
            }));
        }
    });
}

function noRegionsError(item) {
    return new TerriaError({
        sender: item,
        title: 'No regions recognized',
        message: '\
This dataset cannot be shown geographically, because no regions were recognized in it. \
Please report this issue by sending an email to <a href="mailto:' + item.terria.supportEmail + '">' + item.terria.supportEmail + '</a>.</p>'
    });
}


// cleanAndProxyUrl appears in a few catalog items - we should split it into its own Core file.

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

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


module.exports = SdmxJsonCatalogItem;