Models/TableCatalogItem.js

"use strict";

/*global require*/
var clone = require("terriajs-cesium/Source/Core/clone").default;
var defaultValue = require("terriajs-cesium/Source/Core/defaultValue").default;
var defined = require("terriajs-cesium/Source/Core/defined").default;

var DeveloperError = require("terriajs-cesium/Source/Core/DeveloperError")
  .default;

var ImagerySplitDirection = require("terriajs-cesium/Source/Scene/ImagerySplitDirection")
  .default;
var JulianDate = require("terriajs-cesium/Source/Core/JulianDate").default;
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var when = require("terriajs-cesium/Source/ThirdParty/when").default;
var TimeIntervalCollection = require("terriajs-cesium/Source/Core/TimeIntervalCollection")
  .default;

var CatalogItem = require("./CatalogItem");
var ChartData = require("../Charts/ChartData");
var inherit = require("../Core/inherit");
var overrideProperty = require("../Core/overrideProperty");
var Polling = require("./Polling");
var RegionMapping = require("./RegionMapping");
var standardCssColors = require("../Core/standardCssColors");
var TableDataSource = require("../Models/TableDataSource");
var TableStyle = require("../Models/TableStyle");
var TerriaError = require("../Core/TerriaError");
var TableStructure = require("../Map/TableStructure");
var VarType = require("../Map/VarType");
var i18next = require("i18next").default;

var DEFAULT_ID_COLUMN = "id";

/**
 * An abstract {@link CatalogItem} representing tabular data.
 * Extend this class for csv or other data by providing two critical functions:
 * _load and (optionally) startPolling.
 * You can also override concepts for greater control over the display.
 *
 * @alias TableCatalogItem
 * @constructor
 * @extends CatalogItem
 * @abstract
 *
 * @param {Terria} terria The Terria instance.
 * @param {String} [url] The URL from which to retrieve the data.
 * @param {Object} [options] Initial values.
 * @param {TableStyle} [options.tableStyle] An initial table style can be supplied if desired.
 */
var TableCatalogItem = function(terria, url, options) {
  CatalogItem.call(this, terria);

  options = defaultValue(options, defaultValue.EMPTY_OBJECT);

  this._tableStructure = undefined;
  // Handle tableStyle as any of: undefined, a regular object, or a TableStyle object. Convert all to TableStyle objects.
  if (defined(options.tableStyle)) {
    if (options.tableStyle instanceof TableStyle) {
      this._tableStyle = options.tableStyle;
    } else {
      this._tableStyle = new TableStyle(options.tableStyle);
    }
  } else {
    this._tableStyle = new TableStyle(); // Start with one so defaultSerializers.tableStyle will work.
  }
  this._dataSource = undefined;
  this._regionMapping = undefined;
  this._rectangle = undefined;
  this._pollTimeout = undefined; // Used internally to store the polling timeout id.

  this.url = url;

  /**
   * Gets or sets the data, represented as a binary Blob, a string, or a Promise for one of those things.
   * If this property is set, {@link CatalogItem#url} is ignored.
   * This property is observable.
   * @type {Blob|String|Promise}
   */
  this.data = undefined;

  /**
   * Gets or sets the URL from which the {@link TableCatalogItem#data} was obtained.  This is informational; it is not
   * used.  This propery is observable.
   * @type {String}
   */
  this.dataSourceUrl = undefined;

  /**
   * Gets or sets the opacity (alpha) of the data item, where 0.0 is fully transparent and 1.0 is
   * fully opaque.  This property is observable.
   * @type {Number}
   * @default 0.8
   */
  this.opacity = 0.8;

  /**
   * Keeps the layer on top of all other imagery layers.  This property is observable.
   * @type {Boolean}
   * @default false
   */
  this.keepOnTop = false;

  /**
   * Gets or sets polling information, such as the number of seconds between polls, and what url to poll.
   * @type {Polling}
   * @default undefined
   */
  this.polling = new Polling();

  /**
   * Should any warnings like failures in region mapping be displayed to the user?
   * @type {Boolean}
   * @default true
   */
  this.showWarnings = true;

  /**
   * Gets or sets the array of color strings used for chart lines.
   * TODO: make this customizable, eg. use colormap / colorPalette.
   * @type {String[]}
   */
  this.colors = standardCssColors.modifiedBrewer8ClassSet2;

  /**
   * Gets or sets the column identifiers (names or indices), so we can identify individual features
   * within a table with a time column, or across multiple polled lat/lon files.
   * Eg. ['lat', 'lon'] for immobile features, or ['identifier'] if a unique identifier is provided
   * (where these are column names in the table; column numbers work as well).
   * For region-mapped files, the region identifier is used instead.
   * For non-spatial files, the x-column is used instead.
   * @type {String[]}
   * @default undefined
   */
  this.idColumns = options.idColumns;

  /**
   * Gets or sets a value indicating whether the rows correspond to "sampled" data.
   * This only makes a difference if there is a time column and idColumns.
   * In this case, if isSampled is true, then feature position, color and size are interpolated
   * to produce smooth animation of the features over time.
   * If isSampled is false, then times are treated as the start of periods, so that
   * feature positions, color and size are kept constant from one time until the next,
   * then change suddenly.
   * Color and size are never interpolated when they are drawn from a text column.
   * @type {Boolean}
   * @default true
   */
  this.isSampled = defaultValue(options.isSampled, true);

  /**
   * Gets or sets which side of the splitter (if present) to display this imagery layer on.  Defaults to both sides.
   * Note that this only applies to region-mapped tables. This property is observable.
   * @type {ImagerySplitDirection}
   */
  this.splitDirection = ImagerySplitDirection.NONE; // NONE means show on both sides of the splitter, if there is one.

  knockout.track(this, [
    "data",
    "dataSourceUrl",
    "opacity",
    "keepOnTop",
    "showWarnings",
    "_tableStructure",
    "_dataSource",
    "_regionMapping",
    "splitDirection"
  ]);

  knockout.getObservable(this, "opacity").subscribe(function(newValue) {
    if (
      defined(this._regionMapping) &&
      defined(this._regionMapping.updateOpacity)
    ) {
      this._regionMapping.updateOpacity(newValue);
      this.terria.currentViewer.notifyRepaintRequired();
    }
  }, this);

  knockout.defineProperty(this, "concepts", {
    get: function() {
      if (defined(this._tableStructure)) {
        return [this._tableStructure];
      } else {
        return [];
      }
    }
  });

  /**
   * Gets the tableStyle object.
   * This needs to be a property on the object (not the prototype), so that updateFromJson sees it.
   * @type {Object}
   */
  knockout.defineProperty(this, "tableStyle", {
    get: function() {
      return this._tableStyle;
    }
  });

  /**
   * Whether the tableStructure has any active items
   * @type {Boolean}
   */
  knockout.defineProperty(this, "allVariablesUnactiveFromTableStructure", {
    get: function() {
      return (
        defined(this._tableStructure) &&
        this._tableStructure.activeItems &&
        this._tableStructure.activeItems.length === 0
      );
    }
  });

  /**
   * Gets an id which is different if the view of the data is different. Defaults to undefined.
   * For a csv file with a fixed TableStructure, undefined is fine.
   * However, if the underlying table changes depending on user selection (eg. for SDMX-JSON or SOS),
   * then the same feature may show different information.
   * If it is time-varying, the feature info panel will show a preview chart of the values over time.
   * This id is used when that chart is expanded, so that it can be opened into a different item, and
   * not override an earlier expanded chart of the same feature (but different data).
   * @type {Object}
   */
  knockout.defineProperty(this, "dataViewId", {
    get: function() {
      return undefined;
    }
  });

  /**
   * Gets javascript dates describing the discrete datetimes (or intervals) available for this item.
   * By declaring this as a knockout defined property, it is cached.
   * @member {Date[]} An array of discrete dates or intervals available for this item, or [] if none.
   * @memberOf TableCatalogItem.prototype
   */
  knockout.defineProperty(
    this,
    "availableDates",
    {
      get: function() {
        if (defined(this.intervals)) {
          const datetimes = [];
          // Only show the start of each interval. If only time instants were given, this is the instant.
          for (let i = 0; i < this.intervals.length; i++) {
            datetimes.push(JulianDate.toDate(this.intervals.get(i).start, 3));
          }
          return datetimes;
        }
        return [];
      }
    },
    this
  );

  /**
   * Gets the TimeIntervalCollection containing all the table's intervals.
   * @type {TimeIntervalCollection}
   */
  knockout.defineProperty(this, "intervals", {
    get: function() {
      if (
        defined(this.tableStructure) &&
        defined(this.tableStructure.activeTimeColumn) &&
        defined(this.tableStructure.activeTimeColumn.timeIntervals)
      ) {
        return this.tableStructure.activeTimeColumn.timeIntervals.reduce(
          function(intervals, interval) {
            if (defined(interval)) {
              intervals.addInterval(interval);
            }
            return intervals;
          },
          new TimeIntervalCollection()
        );
      }
      return undefined;
    }
  });

  overrideProperty(this, "clock", {
    get: function() {
      var timeColumn = this.timeColumn;
      if (this.isMappable && defined(timeColumn)) {
        return timeColumn.clock;
      }
    }
  });

  overrideProperty(this, "legendUrl", {
    get: function() {
      if (defined(this._dataSource)) {
        return this._dataSource.legendUrl;
      } else if (defined(this._regionMapping)) {
        return this._regionMapping.legendUrl;
      }
    }
  });

  overrideProperty(this, "rectangle", {
    get: function() {
      // Override the extent using this.rectangle, otherwise falls back the datasource's extent (with a small margin).
      if (defined(this._rectangle)) {
        return this._rectangle;
      }
      var rect;
      if (defined(this._dataSource)) {
        rect = this._dataSource.extent;
      } else if (defined(this._regionMapping)) {
        rect = this._regionMapping.extent;
      }
      return addMarginToRectangle(rect, 0.08);
    },
    set: function(rect) {
      this._rectangle = rect;
    }
  });

  overrideProperty(this, "dataUrl", {
    // item.dataUrl returns a URI which downloads the table as a csv.
    get: function() {
      if (defined(this._dataUrl)) {
        return this._dataUrl;
      }
      // Even if the file only exists locally, we recreate it as a data URI, since there may have been geocoding or other processing.
      if (defined(this._tableStructure)) {
        return this._tableStructure.toDataUri();
      }
    },
    set: function(value) {
      this._dataUrl = value;
    }
  });

  overrideProperty(this, "dataUrlType", {
    get: function() {
      if (defined(this._dataUrlType)) {
        return this._dataUrlType;
      }
      if (defined(this._tableStructure)) {
        return "data-uri";
      }
    },
    set: function(value) {
      this._dataUrlType = value;
    }
  });

  knockout.getObservable(this, "splitDirection").subscribe(function() {
    this.terria.currentViewer.updateItemForSplitter(this);
  }, this);
};

inherit(CatalogItem, TableCatalogItem);

function addMarginToRectangle(rect, marginFraction) {
  if (defined(rect)) {
    var heightMargin = rect.height * marginFraction;
    var widthMargin = rect.width * marginFraction;
    rect.north = Math.min(Math.PI / 2, rect.north + heightMargin);
    rect.south = Math.max(-Math.PI / 2, rect.south - heightMargin);
    rect.east = Math.min(Math.PI, rect.east + widthMargin);
    rect.west = Math.max(-Math.PI, rect.west - widthMargin);
  }
  return rect;
}

Object.defineProperties(TableCatalogItem.prototype, {
  /**
   * Gets the active time column, if it exists.
   * @memberOf TableCatalogItem.prototype
   * @type {TableColumn}
   */
  timeColumn: {
    get: function() {
      return this._tableStructure && this._tableStructure.activeTimeColumn;
    }
  },

  /**
   * Gets the x-axis column, if it exists (ie. if this is a chart).
   * @memberOf TableCatalogItem.prototype
   * @type {TableColumn}
   */
  xAxis: {
    get: function() {
      if (!this.isMappable && this._tableStructure) {
        if (defined(this._tableStyle.xAxis)) {
          return this._tableStructure.getColumnWithNameOrId(
            this._tableStyle.xAxis
          );
        }
        return (
          this.timeColumn ||
          this._tableStructure.columnsByType[VarType.SCALAR][0]
        );
      }
      return undefined;
    }
  },

  /**
   * Gets a value indicating whether this data source, when enabled, can be reordered with respect to other data sources.
   * Data sources that cannot be reordered are typically displayed above reorderable data sources.
   * @memberOf TableCatalogItem.prototype
   * @type {Boolean}
   */
  supportsReordering: {
    get: function() {
      return (
        defined(this._regionMapping) &&
        defined(this._regionMapping.regionDetails) &&
        !this.keepOnTop
      );
    }
  },

  /**
   * Gets a value indicating whether the opacity of this data source can be changed.
   * @memberOf ImageryLayerCatalogItem.prototype
   * @type {Boolean}
   */
  supportsOpacity: {
    get: function() {
      return (
        defined(this._regionMapping) &&
        defined(this._regionMapping.regionDetails)
      );
    }
  },

  /**
   * Gets a value indicating whether this layer can be split so that it is
   * only shown on the left or right side of the screen.
   * @memberOf TableCatalogItem.prototype
   */
  supportsSplitting: {
    get: function() {
      return (
        defined(this._regionMapping) &&
        defined(this._regionMapping.regionDetails)
      );
    }
  },

  /**
   * Gets the table structure associated with this catalog item.
   * @memberOf TableCatalogItem.prototype
   * @type {TableStructure}
   */
  tableStructure: {
    get: function() {
      return this._tableStructure;
    }
  },

  /**
   * Gets the data source associated with this catalog item.
   * @memberOf TableCatalogItem.prototype
   * @type {DataSource}
   */
  dataSource: {
    get: function() {
      return this._dataSource;
    }
  },

  /**
   * Gets the region mapping associated with this catalog item.
   * @memberOf TableCatalogItem.prototype
   * @type {RegionMapping}
   */
  regionMapping: {
    get: function() {
      return this._regionMapping;
    }
  },

  /**
   * Gets the Cesium or Leaflet imagery layer object associated with this data source.
   * Used in region mapping only.
   * This property is undefined if the data source is not enabled.
   * @memberOf TableCatalogItem.prototype
   * @type {Object}
   */
  imageryLayer: {
    get: function() {
      return this._regionMapping && this._regionMapping.imageryLayer;
    }
  },

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

  /**
   * Gets the set of functions used to update individual properties in {@link CatalogMember#updateFromJson}.
   * When a property name in the returned object literal matches the name of a property on this instance, the value
   * will be called as a function and passed a reference to this instance, a reference to the source JSON object
   * literal, and the name of the property.
   * @memberOf TableCatalogItem.prototype
   * @type {Object}
   */
  updaters: {
    get: function() {
      return TableCatalogItem.defaultUpdaters;
    }
  },

  /**
   * 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 TableCatalogItem.prototype
   * @type {Object}
   */
  serializers: {
    get: function() {
      return TableCatalogItem.defaultSerializers;
    }
  }
});

TableCatalogItem.defaultUpdaters = clone(CatalogItem.defaultUpdaters);

TableCatalogItem.defaultUpdaters.tableStyle = function(
  item,
  json,
  propertyName,
  options
) {
  item._tableStyle.updateFromJson(json[propertyName], options);

  // sync up columns
  if (
    defined(item._tableStyle) &&
    defined(item.tableStructure) &&
    defined(item._tableStyle.columns) &&
    Object.keys(item._tableStyle.columns).length !== 0
  ) {
    item.applyTableStyleColumnsToStructure(
      item._tableStyle,
      item.tableStructure
    );
  }
  // if there are active variables, activate them
  if (
    defined(item.tableStructure) &&
    (!defined(json[propertyName].allVariablesUnactive) ||
      !json[propertyName].allVariablesUnactive)
  ) {
    item.activateColumnFromTableStyle();

    // If tableStructure has been generated & allVariablesUnactive is true, make sure all items are disabled
  } else if (
    defined(item.tableStructure) &&
    defined(json[propertyName].allVariablesUnactive) &&
    json[propertyName].allVariablesUnactive
  ) {
    item.syncAllVariablesUnactiveOnUpdate();
  }
};

TableCatalogItem.defaultUpdaters.polling = function(
  item,
  json,
  propertyName,
  options
) {
  return item[propertyName].updateFromJson(json[propertyName], options);
};

TableCatalogItem.defaultUpdaters.concepts = function() {
  // Don't update from JSON.
};

TableCatalogItem.defaultUpdaters.dataViewId = function() {
  // Don't update from JSON.
};

TableCatalogItem.defaultUpdaters.availableDates = function() {
  // Do not update/serialize availableDates.
};

TableCatalogItem.defaultUpdaters.intervals = function() {
  // Don't update from JSON.
};

Object.freeze(TableCatalogItem.defaultUpdaters);

TableCatalogItem.defaultSerializers = clone(CatalogItem.defaultSerializers);

TableCatalogItem.defaultSerializers.tableStyle = function(
  item,
  json,
  propertyName,
  options
) {
  if (
    item.type === "csv" &&
    (defined(item.url) || defined(item.dataUrl)) &&
    defined(item.tableStyle) &&
    defined(item._tableStructure)
  ) {
    // Sync up table style from current table structure state
    json.tableStyle = item.syncActiveColumns(
      item.tableStyle,
      item.tableStructure
    );
  }
  json[propertyName] = item[propertyName].serializeToJson(options);
  // Add the currently active variable to the tableStyle (if any) so it starts with the right one.
  if (item.allVariablesUnactiveFromTableStructure) {
    // Make sure we unset this for subsequent serialisation from stories or otherwise
    json[propertyName].dataVariable = undefined;
    json[propertyName].allVariablesUnactive = true;
  } else if (
    defined(item._tableStructure) &&
    item._tableStructure.activeItems[0]
  ) {
    json[propertyName].dataVariable = item._tableStructure.activeItems[0].name;
    json[propertyName].allVariablesUnactive = undefined;
  }
};

TableCatalogItem.defaultSerializers.polling = function(
  item,
  json,
  propertyName,
  options
) {
  json[propertyName] = item[propertyName].serializeToJson(options);
};

TableCatalogItem.defaultSerializers.legendUrl = function() {
  // Don't serialize, because legends are generated, and sticking an image embedded in a URL is a terrible idea.
};

TableCatalogItem.defaultSerializers.concepts = function() {
  // Don't serialize.
};

TableCatalogItem.defaultSerializers.dataViewId = function() {
  // Don't serialize.
};

TableCatalogItem.defaultSerializers.allVariablesUnactiveFromTableStructure = function() {
  // Don't serialize.
};

TableCatalogItem.defaultSerializers.dataUrl = function(item, json) {
  // Only serialize this if it was set directly; if it is a data URI containing a representation of the whole table, ignore it.
  // ie. set it to item._dataUrl, not item.dataUrl.
  json.dataUrl = item._dataUrl;
  if (item.isCsvForCharting && !defined(item.url)) {
    json.dataUrl = item.dataUrl;
  }
};

TableCatalogItem.defaultSerializers.clock = function() {
  // Don't serialize. Clock is not part of propertiesForSharing, but it would be shared if this is My Data.
  // See SharePopupViewModel.prototype._addUserAddedCatalog.
};

TableCatalogItem.defaultSerializers.availableDates = function() {
  // Do not update/serialize availableDates.
};

TableCatalogItem.defaultSerializers.intervals = function() {
  // Don't serialize.
};

Object.freeze(TableCatalogItem.defaultSerializers);

/**
 * Gets or sets the default set of properties that are serialized when serializing a {@link CatalogItem}-derived object
 * for a share link.
 * @type {String[]}
 */
TableCatalogItem.defaultPropertiesForSharing = clone(
  CatalogItem.defaultPropertiesForSharing
);
TableCatalogItem.defaultPropertiesForSharing.push("keepOnTop");
TableCatalogItem.defaultPropertiesForSharing.push("opacity");
TableCatalogItem.defaultPropertiesForSharing.push("tableStyle");
TableCatalogItem.defaultPropertiesForSharing.push("splitDirection");
TableCatalogItem.defaultPropertiesForSharing.push("url");
Object.freeze(TableCatalogItem.defaultPropertiesForSharing);

TableCatalogItem.prototype._getValuesThatInfluenceLoad = function() {
  return [this.url, this.data];
};

/**
 * Updates tableStructure for the tableStyle, by looking at tableStyle.columns
 * and applying units, type, active and name.
 * If the data was loaded from a csv file, CsvCatalogItem's loadTableFromCsv
 * will already have taken care of this.
 * This function is needed if the data came directly from a TableStructure.
 *
 * @param  {TableStyle} tableStyle The table style.
 * @param  {TableStructure} tableStructure The table structure to update.
 */
TableCatalogItem.prototype.applyTableStyleColumnsToStructure = function(
  tableStyle,
  tableStructure,
  options = {
    onlyActiveColumns: false
  }
) {
  if (!defined(tableStructure)) {
    console.warn("table structure does not exist");
    return;
  }
  if (defined(tableStyle.columns)) {
    for (const nameOrIndex in tableStyle.columns) {
      if (tableStyle.columns.hasOwnProperty(nameOrIndex)) {
        const columnStyle = tableStyle.columns[nameOrIndex];
        const column = tableStructure.getColumnWithNameIdOrIndex(nameOrIndex);
        if (defined(column)) {
          if (options.onlyActiveColumns) {
            if (defined(columnStyle.active)) {
              column.isActive = columnStyle.active;
            }
          } else {
            if (defined(columnStyle.units)) {
              column.units = columnStyle.units;
            }
            if (defined(columnStyle.type)) {
              column.type = columnStyle.type;
            }
            if (defined(columnStyle.name)) {
              column.name = columnStyle.name;
            }
            if (defined(columnStyle.active)) {
              column.isActive = columnStyle.active;
            }
          }
        }
      }
    }
  }
  return this;
};

/**
 * Ensures tableStructure active state reflects updateFromJson updates
 *
 */
TableCatalogItem.prototype.syncAllVariablesUnactiveOnUpdate = function() {
  const tableStyle = this._tableStyle;
  const tableStructure = this._tableStructure;
  if (!defined(tableStyle)) {
    throw new DeveloperError(
      "syncAllVariablesUnactiveOnUpdate was called without a tableStyle?"
    );
  } else if (!defined(tableStructure)) {
    throw new DeveloperError(
      "syncAllVariablesUnactiveOnUpdate was called without a tableStructure?"
    );
  }
  if (!(tableStructure instanceof TableStructure)) {
    throw new DeveloperError(
      "table structure passed in to syncAllVariablesUnactiveOnUpdate isn't a TableStructure"
    );
  }
  if (tableStyle.allVariablesUnactive) {
    tableStructure.items.map(tableStructureItem => {
      if (tableStructureItem.isActive) {
        tableStructureItem.toggleActive();
      }
    });
  }
};

/**
 * Updates tableStyle with a given tableStructure
 * This is needed if the UI toggles concepts via Concept.jsx
 *
 * @param  {TableStyle} tableStyle The table style.
 * @param  {TableStructure} tableStructure The table structure to update.
 */
TableCatalogItem.prototype.syncActiveColumns = function(
  tableStyle,
  tableStructure
) {
  if (!defined(tableStyle)) {
    throw new DeveloperError(
      "syncActiveColumns was called without a tableStyle?"
    );
  } else if (!defined(tableStructure)) {
    throw new DeveloperError(
      "syncActiveColumns was called without a tableStructure?"
    );
  }
  if (!(tableStructure instanceof TableStructure)) {
    throw new DeveloperError(
      "table structure passed in to syncActiveColumns isn't a TableStructure"
    );
  }
  if (
    defined(tableStyle.columns) &&
    defined(tableStructure.getColumnWithNameIdOrIndex)
  ) {
    for (const nameOrIndex in tableStyle.columns) {
      if (tableStyle.columns.hasOwnProperty(nameOrIndex)) {
        const columnStyle = tableStyle.columns[nameOrIndex];
        const columnFromStructure = tableStructure.getColumnWithNameIdOrIndex(
          nameOrIndex
        );
        if (defined(columnFromStructure)) {
          columnStyle.active = columnFromStructure.isActive;
        }
      }
    }
    // }
  } else if (
    tableStructure.allowMultiple &&
    !defined(tableStyle.columns) &&
    defined(tableStructure.getColumnWithNameIdOrIndex)
  ) {
    const newColumnsForTableStyle = tableStructure.items.reduce(
      (acc, item, index) => {
        acc[index] = {
          name: item.name,
          units: item.units,
          format: item.format,
          active: item.isActive,
          chartLineColor: item.color,
          yAxisMin: item.yAxisMin,
          yAxisMax: item.yAxisMax
        };
        return acc;
      },
      {}
    );
    tableStyle.columns = newColumnsForTableStyle;
  }
};

/**
 * Given a TableStructure, determine what sort of table it is. Prepare:
 *  - TableDataSource if it has latitude and longitude
 *  - RegionMapping if it has a region column
 *  - nothing for non-geospatial data (just use the TableStructure directly).
 * @param  {TableStructure} tableStructure
 * @return {Promise} Returns a promise that resolves to true if it is a recognised format.
 */
TableCatalogItem.prototype.initializeFromTableStructure = function(
  tableStructure
) {
  var item = this;
  var tableStyle = item._tableStyle;

  setDefaultIdColumns(item, tableStructure);
  tableStructure.setActiveTimeColumn(tableStyle.timeColumn);
  item._tableStructure = tableStructure;

  function makeChartable() {
    tableStructure.name = ""; // No need to show the section title 'Display Variables' in Now Viewing.
    tableStructure.allowMultiple = true;
    item.activateColumnFromTableStyle();
    item.setChartable();
    item.startPolling();
  }
  if (!item.isMappable) {
    makeChartable();
    return;
  }

  // Does the csv have addresses we can translate to long and lat?
  if (
    !tableStructure.hasLatitudeAndLongitude &&
    tableStructure.hasAddress &&
    defined(item.terria.batchGeocoder)
  ) {
    var addressGeocoder = item.terria.batchGeocoder;
    return addressGeocoder
      .bulkConvertAddresses(tableStructure, item.terria.corsProxy)
      .then(function(addressGeocoderData) {
        var timeTaken = JulianDate.secondsDifference(
          JulianDate.now(),
          addressGeocoderData.startTime
        );
        var developerMessage =
          "Bulk geocode of " +
          addressGeocoderData.numberOfAddressesConverted +
          " addresses took " +
          timeTaken.toFixed(2) +
          " seconds, which is " +
          (addressGeocoderData.numberOfAddressesConverted / timeTaken).toFixed(
            2
          ) +
          " addresses/s, or " +
          (timeTaken / addressGeocoderData.numberOfAddressesConverted).toFixed(
            2
          ) +
          " s/address.\n";
        console.log(developerMessage);
        var missingAddressesMessage = "";
        if (
          addressGeocoderData.missingAddresses.length > 0 ||
          addressGeocoderData.nullAddresses > 0
        ) {
          if (addressGeocoderData.missingAddresses.length > 0) {
            missingAddressesMessage =
              "\n" +
              i18next.t("models.tableData.bulkGeocoderInfoMessage", {
                number: addressGeocoderData.missingAddresses.length
              }) +
              "\n" +
              addressGeocoderData.missingAddresses.join(", ") +
              ".<br/><br/>";
          }
          if (addressGeocoderData.nullAddresses > 0) {
            missingAddressesMessage += i18next.t(
              "models.tableData.bulkGeocoderInfo2Message",
              { nullAddresses: addressGeocoderData.nullAddresses }
            );
          }
          item.terria.error.raiseEvent(
            new TerriaError({
              sender: this,
              title: i18next.t("models.tableData.bulkGeocoderInfoTitle"),
              message: missingAddressesMessage
            })
          );
        }
        return createDataSourceForLatLong(item, tableStructure);
      })
      .otherwise(function(e) {
        item.terria.error.raiseEvent(
          new TerriaError({
            sender: this,
            title: i18next.t("models.tableData.bulkGeocoderErrorTitle"),
            message: i18next.t("models.tableData.bulkGeocoderErrorMessage")
          })
        );
        console.log("Unable to map addresses to lat-long coordinates.", e);
      });
  }
  if (tableStructure.hasLatitudeAndLongitude) {
    return createDataSourceForLatLong(item, tableStructure);
  }
  var regionMapping = new RegionMapping(item, tableStructure, item._tableStyle);
  // Return a promise which resolves once we've set up region mapping, if any.
  return regionMapping.loadRegionDetails().then(function(regionDetails) {
    if (regionDetails) {
      // Save the region mapping to item._regionMapping.
      item._regionMapping = regionMapping;
      item._regionMapping.changedEvent.addEventListener(
        dataChanged.bind(null, item),
        item
      );
      // Set the first region column to have type VarType.REGION.
      item._regionMapping.setRegionColumnType();
      // Activate a column. This needed to wait until we had a regionMapping, so it can trigger the legendHelper build.
      item.activateColumnFromTableStyle();
      // This needed to wait until we know which column is the region.
      ensureActiveColumn(tableStructure, tableStyle);
      item.startPolling();
      return when(true);
    } else {
      // Non-geospatial data.
      makeChartable();
      return when(true);
    }
  });
};

function setDefaultIdColumns(item, tableStructure) {
  // This just checks there is a time column - not that it's one we would actually activate.
  // Would be better to do the full check.
  // Note we check if === undefined explicitly so the user can set to null to prevent this default.
  if (
    item.idColumns === undefined &&
    !defined(tableStructure.idColumnNames) &&
    defined(tableStructure.columnsByType[VarType.TIME].length > 0) &&
    tableStructure.getColumnNames().indexOf(DEFAULT_ID_COLUMN) >= 0
  ) {
    item.idColumns = [DEFAULT_ID_COLUMN];
    tableStructure.idColumnNames = item.idColumns;
  }
}

/**
 * Creates a datasource based on tableStructure provided and adds it to item. Suitable for TableStructures that contain
 * lat-lon columns.
 *
 * @param {TableCatalogItem} item Item that tableDataSource is created for.
 * @param {TableStructure} tableStructure TableStructure to use in creating datasource.
 * @return {Promise}
 * @private
 */
function createDataSourceForLatLong(item, tableStructure) {
  // Create the TableDataSource and save it to item._dataSource.
  item._dataSource = new TableDataSource(
    item.terria,
    tableStructure,
    item._tableStyle,
    item.name,
    item.polling.seconds > 0
  );
  item._dataSource.changedEvent.addEventListener(
    dataChanged.bind(null, item),
    item
  );
  // Activate a column. This needed to wait until we had a dataSource, so it can trigger the legendHelper build.
  item.activateColumnFromTableStyle();
  ensureActiveColumn(tableStructure, item._tableStyle);
  item.startPolling();
  return when(true); // We're done - nothing to wait for.
}

TableCatalogItem.prototype.setChartable = function() {
  var tableStructure = this._tableStructure;
  tableStructure.allowMultiple = true;
  tableStructure.requireSomeActive = false;
  this.isMappable = false;
  if (!defined(tableStructure.getColorCallback)) {
    tableStructure.getColorCallback = this.getNextColor.bind(this);
  }
  tableStructure.toggleActiveCallback = this.disableIncompatibleTableColumns.bind(
    this
  );
  // Only let the user choose for the y-axis from the scalar, alt, lon and lat columns. Also hide the x-axis.
  var xAxis = this.xAxis;
  tableStructure.columns.forEach(function(column) {
    if ([VarType.ALT, VarType.LON, VarType.LAT].indexOf(column.type) >= 0) {
      // Revert these column types back to scalars for charts, so eg. a column named "height" can be charted.
      column.type = VarType.SCALAR;
    }
    column.isVisible = column !== xAxis && column.type === VarType.SCALAR;
  });
  if (
    !defined(this.tableStyle.allVariablesUnactive) ||
    !this.tableStyle.allVariablesUnactive
  ) {
    ensureActiveColumnForNonSpatial(this);
  }
  // If this item is shown and enabled, ensure it is in the catalog's chartable items, so the ChartPanel can pick it up.
  addToChartableItemsIfNotMappable(this);
};

// An event listened triggered whenever the dataSource or regionMapping changes.
// Used to know when to redraw the display.
function dataChanged(item) {
  item.terria.currentViewer.notifyRepaintRequired();
}

function ensureActiveColumn(tableStructure, tableStyle) {
  // Find and activate the first SCALAR or ENUM column, if no columns are active, unless the tableStyle sets dataVariable to null.
  if (tableStyle.dataVariable === null) {
    // We still need to trigger an active column change to update TableDataSource and RegionMapping, so toggle one twice.
    tableStructure.columns[0].toggleActive();
    tableStructure.columns[0].toggleActive();
    return;
  }
  if (tableStructure.activeItems.length === 0) {
    var suitableColumns = tableStructure.columns
      .filter(col => col.type === VarType.SCALAR)
      .concat(tableStructure.columns.filter(col => col.type === VarType.ENUM));
    if (suitableColumns.length > 0) {
      // Look for the first non-trivial column
      // (where a trivial ENUM column has too few or too many unique values,
      // and a trivial SCALAR column has min === max.)
      for (var i = 0; i < suitableColumns.length; i++) {
        var column = suitableColumns[i];
        if (column.isEnum) {
          var numberOfUniqueValues = column.uniqueValues.length;
          if (
            numberOfUniqueValues > 2 &&
            numberOfUniqueValues < 20 &&
            numberOfUniqueValues < column.values.length * 0.8
          ) {
            column.toggleActive();
            return;
          }
        } else {
          if (column.minimumValue < column.maximumValue) {
            column.toggleActive();
            return;
          }
        }
      }
      // If it can't find any non-trivial columns, just use the first enum or scalar.
      suitableColumns[0].toggleActive();
    } else {
      // There are no suitable columns.
      // We need to trigger an active column change to update TableDataSource and RegionMapping, so toggle one twice.
      tableStructure.columns[0].toggleActive();
      tableStructure.columns[0].toggleActive();
    }
  } else if (tableStructure.activeItems.length === 1) {
    // Otherwise we have a single active item but it needs to be toggled so it can trigger an.. update?paint(?)
    const column = tableStructure.getColumnWithNameIdOrIndex(
      tableStructure.activeItems[0].name
    );
    if (defined(column)) {
      column.toggleActive();
      column.toggleActive();
    }
  }
}

/**
 * Set the color (for charts) on the active columns. Assumes the table's getColorCallback has been set.
 */
TableCatalogItem.prototype.setColorOnActiveColumns = function() {
  var tableStructure = this._tableStructure;
  tableStructure.columns
    .filter(column => column.isActive && !defined(column.color))
    .forEach(column => {
      column.color = tableStructure.getColorCallback(
        tableStructure.getColumnIndex(column.id)
      );
    });
};

function ensureActiveColumnForNonSpatial(item) {
  // If it is not mappable, and has no time column, then the first scalar column will be treated as the x-variable, so choose the second one.
  var tableStructure = item._tableStructure;
  if (tableStructure.activeItems.length === 0) {
    var suitableColumns = tableStructure.columnsByType[VarType.SCALAR];
    if (
      suitableColumns.length > 1 &&
      tableStructure.columnsByType[VarType.TIME].length === 0
    ) {
      suitableColumns[1].toggleActive();
    } else if (suitableColumns.length > 0) {
      suitableColumns[0].toggleActive();
    }
  } else {
    // There's already an active column, but it may not have a color set yet.
    item.setColorOnActiveColumns();
  }
}

/**
 * Activates the column specified in the table style's "dataVariable" parameter, if any.
 * If columns are specified, those active statuses will take precedence
 */
TableCatalogItem.prototype.activateColumnFromTableStyle = function() {
  var tableStyle = this._tableStyle;
  if (defined(tableStyle) && defined(tableStyle.dataVariable)) {
    var columnToActivate = this._tableStructure.getColumnWithNameOrId(
      tableStyle.dataVariable
    );
    if (columnToActivate && !columnToActivate.isActive) {
      columnToActivate.toggleActive();
    }
  }
  if (defined(tableStyle) && defined(tableStyle.columns)) {
    this.applyTableStyleColumnsToStructure(tableStyle, this.tableStructure, {
      onlyActiveColumns: true
    });
  }
};

/**
 * Your derived class should implement its own version of startPolling, if it is allowed.
 * No return value.
 */
TableCatalogItem.prototype.startPolling = function() {
  const polling = this.polling;
  if (defined(polling.seconds) && polling.seconds > 0) {
    throw new DeveloperError("Polling is not available on this dataset.");
  }
};

/**
 * Your derived class must implement _load.
 * @returns {Promise} A promise that resolves when the load is complete, or undefined if the function is already loaded.
 */
TableCatalogItem.prototype._load = function() {
  throw new DeveloperError("_load must be implemented in the derived class.");
};

function addToChartableItemsIfNotMappable(item) {
  // If this is not mappable, assume it is chartable - add it to the chartable items array,
  // And then handle incompatible x-axes on existing chartable items.
  if (
    item.isEnabled &&
    item.isShown &&
    !item.isMappable &&
    item.terria.catalog.chartableItems.indexOf(item) < 0
  ) {
    item.terria.catalog.addChartableItem(item);
    item.disableIncompatibleTableColumns();
    item.setColorOnActiveColumns();
  }
}

function removeFromChartableItems(item) {
  item.terria.catalog.removeChartableItem(item);
}

TableCatalogItem.prototype._enable = function(layerIndex) {
  if (defined(this._regionMapping)) {
    this._regionMapping.enable(layerIndex);
  }
  addToChartableItemsIfNotMappable(this);
  this.terria.currentViewer.updateItemForSplitter(this);
};

TableCatalogItem.prototype._disable = function() {
  if (defined(this._regionMapping)) {
    this._regionMapping.disable();
  }
  removeFromChartableItems(this);
};

TableCatalogItem.prototype._show = function() {
  if (defined(this._dataSource)) {
    var dataSources = this.terria.dataSources;
    if (dataSources.contains(this._dataSource)) {
      if (console && console.log) {
        console.log(new Error("This data source is already shown."));
      }
      return;
    }
    dataSources.add(this._dataSource);
  }
  if (defined(this._regionMapping)) {
    this._regionMapping.show();
  }

  if (
    defined(this._tableStyle) &&
    defined(this.tableStructure) &&
    defined(this._tableStyle.allVariablesUnactive)
  ) {
    this.syncAllVariablesUnactiveOnUpdate();
  }
  addToChartableItemsIfNotMappable(this);
};

TableCatalogItem.prototype._hide = function() {
  if (defined(this._dataSource)) {
    var dataSources = this.terria.dataSources;
    if (!dataSources.contains(this._dataSource)) {
      throw new DeveloperError("This data source is not shown.");
    }
    dataSources.remove(this._dataSource, false);
  }
  if (defined(this._regionMapping)) {
    this._regionMapping.hide();
  }
  removeFromChartableItems(this);
};

/**
 * Finds the next unused color for a chart line.
 * @return {String} A string description of the color.
 */
TableCatalogItem.prototype.getNextColor = function() {
  var catalog = this._terria.catalog;
  if (!defined(catalog)) {
    return;
  }
  if (!defined(this.colors) || this.colors.length === 0) {
    return;
  }
  if (!this.isEnabled) {
    // So that previewed charts don't get assigned the next color (which could clash, since it won't be in the list yet).
    return;
  }

  var colors = this.colors.slice();
  // Get all the colors in use (as nested array).
  // TODO move this functionality somewhere like ChartPanel.
  var colorsUsed = catalog.chartableItems.map(function(item) {
    if (!item.tableStructure) {
      return [];
    }
    return item.tableStructure.columns
      .map(function(column) {
        return column.color;
      })
      .filter(function(color) {
        return defined(color);
      });
  });
  // Flatten it.
  colorsUsed = colorsUsed.reduce(function(a, b) {
    return a.concat(b);
  }, []);
  // Remove the colors in use from the full list.
  for (var index = 0; index < colorsUsed.length; index++) {
    var fullColorsIndex = colors.indexOf(colorsUsed[index]);
    if (fullColorsIndex > -1) {
      colors.splice(fullColorsIndex, 1);
    }
    if (colors.length === 0) {
      colors = this.colors.slice(); // Keep cycling through the colors when they're all used.
    }
  }
  return colors[0];
};

/**
 * Returns a {@link ChartData} object for the TableCatalogItem.  See ChartPanel.jsx for an example.
 * Maps each scalar Y column onto a separate ChartData data series.
 * @returns {ChartData}
 */
TableCatalogItem.prototype.chartData = function() {
  const item = this;
  if (!defined(item.tableStructure)) {
    return undefined;
  }
  const xColumn = item.xAxis;
  const yColumns = item.tableStructure.columnsByType[VarType.SCALAR].filter(
    column => column.isActive
  );
  if (yColumns.length > 0) {
    const yColumnNumbers = yColumns.map(yColumn =>
      item.tableStructure.columns.indexOf(yColumn)
    );
    const pointArrays = item.tableStructure.toPointArrays(xColumn, yColumns);
    return pointArrays.map(
      (points, index) =>
        new ChartData(points, {
          id: item.uniqueId + "-" + yColumnNumbers[index],
          name: yColumns[index].name,
          categoryName: item.name,
          units: yColumns[index].units,
          color: yColumns[index].color || this.colors[0],
          yAxisMin: yColumns[index].yAxisMin,
          yAxisMax: yColumns[index].yAxisMax
        })
    );
  }
};

/**
 * Finds any other table structures that do not have the same xColumn type, and disable their columns.
 * @private
 */
TableCatalogItem.prototype.disableIncompatibleTableColumns = function() {
  if (this.isEnabled && this.isShown) {
    var tableStructure = this._tableStructure;
    var xColumn = this.xAxis;

    // Only disable other columns if this table has existing active items
    if (tableStructure.activeItems.length === 0) {
      return;
    }

    this._terria.catalog.chartableItems.forEach(otherItem => {
      if (
        defined(otherItem.tableStructure) &&
        otherItem.tableStructure !== tableStructure
      ) {
        if (otherItem.xAxis.type !== xColumn.type) {
          // Deactivate the other table's columns, which will remove its chart.
          otherItem.tableStructure.columns.forEach(column => {
            column.isActive = false;
          });
        }
      }
    });
  }
};

TableCatalogItem.prototype.showOnSeparateMap = function(globeOrMap) {
  var dataSource = this._dataSource;
  var removeRegionMapping;

  if (defined(this._regionMapping)) {
    removeRegionMapping = this._regionMapping.showOnSeparateMap(globeOrMap);
  }

  if (defined(dataSource)) {
    globeOrMap.addDataSource({
      dataSource: dataSource
    });
  }

  return function() {
    if (defined(removeRegionMapping)) {
      removeRegionMapping();
    }
    if (defined(dataSource)) {
      globeOrMap.removeDataSource({
        dataSource: dataSource
      });
    }
  };
};

module.exports = TableCatalogItem;