Source: Models/TableDataSource.js

/*global require*/
'use strict';

var CallbackProperty = require('terriajs-cesium/Source/DataSources/CallbackProperty');
var Cartesian3 = require('terriajs-cesium/Source/Core/Cartesian3');
var Cartographic = require('terriajs-cesium/Source/Core/Cartographic');
var Color = require('terriajs-cesium/Source/Core/Color');
var createGuid = require('terriajs-cesium/Source/Core/createGuid');
var defined = require('terriajs-cesium/Source/Core/defined');
var defineProperties = require('terriajs-cesium/Source/Core/defineProperties');
var destroyObject = require('terriajs-cesium/Source/Core/destroyObject');
var DeveloperError = require('terriajs-cesium/Source/Core/DeveloperError');
var EntityCollection = require('terriajs-cesium/Source/DataSources/EntityCollection');
var EntityCluster = require('terriajs-cesium/Source/DataSources/EntityCluster');
var CesiumEvent = require('terriajs-cesium/Source/Core/Event');
var knockout = require('terriajs-cesium/Source/ThirdParty/knockout');
var HorizontalOrigin = require('terriajs-cesium/Source/Scene/HorizontalOrigin');
var Iso8601 = require('terriajs-cesium/Source/Core/Iso8601');
var Rectangle = require('terriajs-cesium/Source/Core/Rectangle');
var SampledPositionProperty = require('terriajs-cesium/Source/DataSources/SampledPositionProperty');
var SampledProperty = require('terriajs-cesium/Source/DataSources/SampledProperty');
var TimeInterval = require('terriajs-cesium/Source/Core/TimeInterval');
var TimeIntervalCollection = require('terriajs-cesium/Source/Core/TimeIntervalCollection');
var TimeIntervalCollectionPositionProperty = require('terriajs-cesium/Source/DataSources/TimeIntervalCollectionPositionProperty');
var TimeIntervalCollectionProperty = require('terriajs-cesium/Source/DataSources/TimeIntervalCollectionProperty');
var VerticalOrigin = require('terriajs-cesium/Source/Scene/VerticalOrigin');

var Feature = require('../Models/Feature');
var LegendHelper = require('../Models/LegendHelper');
var TableStructure = require('../Map/TableStructure');
var TableStyle = require('../Models/TableStyle');
var TerriaError = require('../Core/TerriaError');
var VarType = require('../Map/VarType');

var defaultFeatureName = 'Site Data';

/**
* A DataSource for table-based data where each row corresponds to a single feature or point - not region-mapped.
* Generates Cesium entities for each row.
* Displaying the points requires a legend.
*
* @name TableDataSource
*
* @alias TableDataSource
* @constructor
* @param {TableStructure} [tableStructure] The Table Structure instance; defaults to a new one.
* @param {TableStyle} [tableStyle] The table style; defaults to undefined.
* @param {String} [name] A name to show in the legend if no columns are available.
* @param {Boolean} [isUpdating] Is the underlying data going to update? Defaults to false.
*                  If true, replaces constant feature properties and description with a CallbackProperty.
*/
var TableDataSource = function(terria, tableStructure, tableStyle, name, isUpdating) {
    this._guid = createGuid(); // Used internally to give features a globally unique id.
    this._name = name;
    this._isUpdating = isUpdating || false;
    this._hasFeaturePerRow = undefined;  // If this changes, need to remove old features.
    this._changed = new CesiumEvent();
    this._error = new CesiumEvent();
    this._loading = new CesiumEvent();
    this._entityCollection = new EntityCollection(this);
    this._entityCluster = new EntityCluster();
    this._terria = terria;

    this._tableStructure = defined(tableStructure) ? tableStructure : new TableStructure();
    if (defined(tableStyle) && !(tableStyle instanceof TableStyle)) {
        throw new DeveloperError('Please pass a TableStyle object.');
    }

    /**
     * Gets the TableStyle object showing how to style the data.
     * @memberof TableDataSource.prototype
     * @type {TableStyle}
     */
    this.tableStyle = tableStyle;  // Can be undefined.

    this._legendHelper = undefined;
    this._legendUrl = undefined;
    this._extent = undefined;
    this._rowObjects = undefined;  // The most recent properties and descriptions are saved here.
    this._rowDescriptions = undefined;

    this.loadingData = false;

    // Track _tableStructure so that csvCatalogItem's concepts are maintained.
    // Track _legendUrl so that csvCatalogItem can update the legend if it changes.
    // Track _extent so that the TableCatalogItem's rectangle updates properly, which also feeds into catalog item's canZoomTo property.
    knockout.track(this, ['_tableStructure', '_legendUrl', '_extent']);

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

defineProperties(TableDataSource.prototype, {
    /**
     * Gets a human-readable name for this instance.
     * @memberof TableDataSource.prototype
     * @type {String}
     */
    name : {
        get : function() {
            return this._name;
        }
    },
    /**
     * Gets the clock settings defined by the loaded data.  If
     * only static data exists, this value is undefined.
     * @memberof TableDataSource.prototype
     * @type {DataSourceClock}
     */
   clock : {
        get : function() {
            if (defined(this._tableStructure)) {
                return this._tableStructure.clock;
            }
        }
    },
    /**
     * Gets the collection of {@link Entity} instances.
     * @memberof TableDataSource.prototype
     * @type {EntityCollection}
     */
   entities : {
        get : function() {
            return this._entityCollection;
        }
    },
    /**
     * Gets a value indicating if the data source is currently loading data.
     * @memberof TableDataSource.prototype
     * @type {Boolean}
     */
   isLoading : {
        get : function() {
            return this.loadingData;
        }
    },
    /**
     * Gets a CesiumEvent that will be raised when the underlying data changes.
     * @memberof TableDataSource.prototype
     * @type {CesiumEvent}
     */
   changedEvent : {
        get : function() {
            return this._changed;
        }
    },
    /**
     * Gets a CesiumEvent that will be raised if an error is encountered during processing.
     * @memberof TableDataSource.prototype
     * @type {CesiumEvent}
     */
   errorEvent : {
        get : function() {
            return this._error;
        }
    },
    /**
     * Gets a CesiumEvent that will be raised when the data source either starts or stops loading.
     * @memberof TableDataSource.prototype
     * @type {CesiumEvent}
     */
    loadingEvent : {
        get : function() {
            return this._loading;
        }
    },

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

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

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

    /**
     * Gets or sets the clustering options for this data source. This object can be shared between multiple data sources.
     *
     * @memberof CustomDataSource.prototype
     * @type {EntityCluster}
     */
    clustering : {
        get : function() {
            return this._entityCluster;
        },
        set : function(value) {
            //>>includeStart('debug', pragmas.debug);
            if (!defined(value)) {
                throw new DeveloperError('value must be defined.');
            }
            //>>includeEnd('debug');
            this._entityCluster = value;
        }
    }
});

/**
 * Creates a table structure from the csv provided, and attaches it to this datasource.
 * @param  {String} csvString Csv-formatted string.
 */

TableDataSource.prototype.loadFromCsv = function(csvString) {
    this._tableStructure.loadFromCsv(csvString);
};

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

/**
 * Call when the active column changes, or when the table data source is first shown.
 * Generates a LegendHelper.
 * For lat/lon files, updates entities and extent.
 * For region files, rebuilds and redisplays the regionImageryLayer.
 * @private
 */
function changedActiveItems(dataSource) {
    reviseLegendHelper(dataSource);
    updateEntitiesAndExtent(dataSource);  // Only does anything if there are lat & lon columns.
    dataSource._changed.raiseEvent(dataSource);
}


/**
 * Calculate the "show" interval collection property, given the availability.
 * The show property has data=true/false over the period it is visible/invisible.
 * If availability is undefined, it has data=false over all possible time.
 * @private
 * @param  {TimeIntervalCollection} [availability] The availability interval, used to get the start and stop dates. Only the first interval in the collection is used.
 * @return {TimeIntervalCollectionProperty} Has data=false/true over the period this entry is invisible/visible (even if timeColumn is undefined).
 */
function calculateShow(availability) {
    var show = new TimeIntervalCollectionProperty();
    if (!defined(availability) || !defined(availability.start)) {
        show.intervals.addInterval(new TimeInterval({start: Iso8601.MINIMUM_VALUE, stop: Iso8601.MAXIMUM_VALUE, data: true}));
    } else {
        var start = availability.start;
        var stop = availability.stop;
        show.intervals.addInterval(new TimeInterval({start: Iso8601.MINIMUM_VALUE, stop: Iso8601.MAXIMUM_VALUE, data: false}));
        show.intervals.addInterval(new TimeInterval({start: start, stop: stop, data: true}));
    }
    return show;
}

// Adds a point of the given scale or pixelSize, color and show (availability) to the entity.
// If there is an image defined in the tableColumnStyle, use a billboard instead.
function addPointToEntity(entity, tableColumnStyle, scale, pixelSize, color, show) {
    //no image so use point
    if (!defined(tableColumnStyle) || !defined(tableColumnStyle.imageUrl) || tableColumnStyle.imageUrl === '') {
        entity.point = {
            outlineColor: new Color(0, 0, 0, 1),
            outlineWidth: 1,
            pixelSize: pixelSize,
            color: color,
            show: show
        };
    } else {
        entity.billboard = {
            horizontalOrigin : HorizontalOrigin.CENTER,
            verticalOrigin : VerticalOrigin.BOTTOM,
            image : tableColumnStyle.imageUrl,
            scale : scale,
            color : color,
            show : show
        };
    }
}

function getPositionOfRowNumber(specialColumns, rowNumber) {
    if (!defined(specialColumns.latitude.values[rowNumber]) || !defined(specialColumns.longitude.values[rowNumber])) {
        console.log('Missing lat/lon on row ' + rowNumber);
        return;
    }
    return Cartesian3.fromDegrees(
        specialColumns.longitude.values[rowNumber],
        specialColumns.latitude.values[rowNumber],
        defined(specialColumns.height) && !isNaN(specialColumns.height) ? specialColumns.height.values[rowNumber] : undefined
    );
}

function setOneFeaturePerRow(dataSource, tableStructure, specialColumns) {
    // These two subfunctions are only used for POLLED csv items.
    // If tableStructure.idColumnNameOrIds (from csvItem.idColumns) is not specified, use the row number to link rows.
    function getRowDescriptionPropertyCallbackForRow(rowNumber) {
        return function callback() {
            return dataSource._rowDescriptions[rowNumber];
        };
    }
    function getRowPropertiesPropertyCallbackForRow(rowNumber) {
        return function callback() {
            var properties = dataSource._rowObjects[rowNumber].string;
            properties._terria_columnAliases = tableStructure.getColumnAliases();
            properties._terria_numericalProperties = dataSource._rowObjects[rowNumber].number;
            return properties;
        };
    }

    var legendHelper = dataSource._legendHelper;
    var tableColumnStyle = legendHelper.tableColumnStyle;
    var fallbackNameField = chooseFallbackNameField(tableStructure.getColumnNames());

    // If there more entities already exist than there are in the table,
    // remove the extras.
    var entities = dataSource._entityCollection;
    if (entities.values.length > dataSource._rowObjects.length) {
        for (var i = dataSource._rowObjects.length; i < entities.values.length; i++) {
            entities.removeById(dataSource._guid + '-' + i);
        }
    }
    // Simply overwrite the others, and add to the collection if there are more.
    var isNewFeature;
    var nanErrorDisplayed = false;
    for (i = 0; i < dataSource._rowObjects.length; i++) {
        if (!defined(specialColumns.latitude.values[i]) || !defined(specialColumns.longitude.values[i])) {
            // console.log('Missing lat/lon on row ' + i);
            continue;
        }
        if (isNaN(specialColumns.latitude.values[i]) || isNaN(specialColumns.longitude.values[i])) {
            if (!nanErrorDisplayed) {
                // Only show this error once even if there are lots of badly formed rows
                dataSource._terria.error.raiseEvent(new TerriaError({
                    sender: dataSource,
                    title: 'Invalid characters in latitude and longitude columns',
                    message: `Could not interpret latitude and longitude pair ${specialColumns.longitude.values[i]},${specialColumns.latitude.values[i]}`
                }));
                nanErrorDisplayed = true;
            }
            continue;
        }
        var rowObject = dataSource._rowObjects[i].string;
        var feature = entities.getById(dataSource._guid + '-' + i);
        isNewFeature = !defined(feature);
        if (isNewFeature) {
            feature = new Feature({
                id: dataSource._guid + '-' + i
            });
        }
        feature.name = rowObject.title || rowObject[fallbackNameField] || defaultFeatureName;
        feature.position = getPositionOfRowNumber(specialColumns, i);
        if (!dataSource._isUpdating) {
            feature.description = dataSource._rowDescriptions[i];
            rowObject._terria_columnAliases = tableStructure.getColumnAliases();
            rowObject._terria_numericalProperties = dataSource._rowObjects[i].number;
            feature.properties = rowObject;
        } else {
            feature.description = new CallbackProperty(getRowDescriptionPropertyCallbackForRow(i), false);
            feature.properties = new CallbackProperty(getRowPropertiesPropertyCallbackForRow(i), false);
        }

        var value = defined(specialColumns.value) ? specialColumns.value.values[i] : undefined;
        var color = legendHelper.getColorFromValue(value);
        var scale = legendHelper.getScaleFromValue(value);
        if (specialColumns.time && specialColumns.time.timeIntervals && specialColumns.time.timeIntervals[i]) {
            feature.availability = new TimeIntervalCollection([specialColumns.time.timeIntervals[i]]);
        }
        var show = calculateShow(feature.availability);
        addPointToEntity(feature, tableColumnStyle, scale, scale * 8, color, show);
        if (isNewFeature) {
            dataSource._entityCollection.add(feature);
        }
    }
}

/**
 * Set up features which are maintained over time, using their ids.
 * Only appropriate if there is a time column and idColumns are specified.
 * @private
 */
function setOneFeaturePerId(dataSource, tableStructure, specialColumns) {
    var legendHelper = dataSource._legendHelper;
    var tableColumnStyle = legendHelper.tableColumnStyle;
    var fallbackNameField = chooseFallbackNameField(tableStructure.getColumnNames(), tableStructure.idColumnNames);
    var rowNumbersMap = tableStructure.getIdMapping();
    var columnAliases = tableStructure.getColumnAliases();
    var isSampled = tableStructure.isSampled;
    var shouldInterpolateColorAndSize = isSampled && (defined(specialColumns.value) && !specialColumns.value.isEnum);

    if (dataSource._hasFeaturePerRow) {
        // If for any reason features were set up per row already (eg. if time column was set after first load),
        // remove those features.
        dataSource._entityCollection.removeAll();
    }

    function getChartDetailsFunction(tableStructure, rowNumbersForThisFeatureId) {
        return function() {
            return tableStructure.getChartDetailsForRowNumbers(rowNumbersForThisFeatureId);
        };
    }

    for (var key in rowNumbersMap) {
        if (rowNumbersMap.hasOwnProperty(key)) {
            var firstRow = dataSource._rowObjects[rowNumbersMap[key][0]].string;
            var featureId = dataSource._guid + '-' + key;
            var feature = dataSource._entityCollection.getById(featureId);
            var isExistingFeature = defined(feature);
            if (!isExistingFeature) {
                feature = new Feature({
                    id: featureId,
                    name: firstRow.title || firstRow[fallbackNameField] || defaultFeatureName
                });
            }
            var availability = new TimeIntervalCollection();

            var position;
            if (isSampled) {
                position = new SampledPositionProperty();
            } else {
                position = new TimeIntervalCollectionPositionProperty();
            }
            // Color and size are never interpolated when they are drawn from a text column.
            var color, scale, pixelSize;
            if (shouldInterpolateColorAndSize) {
                color = new SampledProperty(Color);
                scale = new SampledProperty(Number);
                pixelSize = new SampledProperty(Number);
            } else {
                color = new TimeIntervalCollectionProperty();
                scale = new TimeIntervalCollectionProperty();
                pixelSize = new TimeIntervalCollectionProperty();
            }
            var properties = new TimeIntervalCollectionProperty();
            var description = new TimeIntervalCollectionProperty();

            var rowNumbersForThisFeatureId = rowNumbersMap[key];
            var chartDetailsFunction = getChartDetailsFunction(tableStructure, rowNumbersForThisFeatureId);
            for (var i = 0; i < rowNumbersForThisFeatureId.length; i++) {
                var rowNumber = rowNumbersForThisFeatureId[i];
                var point = getPositionOfRowNumber(specialColumns, rowNumber);
                var interval = specialColumns.time.timeIntervals[rowNumber];
                availability.addInterval(interval);

                // Add the feature properties.
                var propertiesInterval = interval.clone();
                propertiesInterval.data = dataSource._rowObjects[rowNumber].string;
                propertiesInterval.data._terria_columnAliases = columnAliases;
                propertiesInterval.data._terria_numericalProperties = dataSource._rowObjects[rowNumber].number;
                if (defined(rowNumbersForThisFeatureId)) {
                    propertiesInterval.data._terria_getChartDetails = chartDetailsFunction;
                }

                properties.intervals.addInterval(propertiesInterval);

                // Add the feature description.
                var descriptionInterval = interval.clone();
                descriptionInterval.data = dataSource._rowDescriptions[rowNumber];
                description.intervals.addInterval(descriptionInterval);

                // Add the feature position.
                if (isSampled) {
                    if (defined(point)) {
                        position.addSample(specialColumns.time.julianDates[rowNumber], point);
                    }
                } else {
                    var positionInterval = interval.clone();
                    positionInterval.data = point;
                    position.intervals.addInterval(positionInterval);
                }

                // Add the feature color, scale and pixelSize.
                var value = defined(specialColumns.value) ? specialColumns.value.values[rowNumber] : undefined;
                if (shouldInterpolateColorAndSize) {
                    var julianDate = specialColumns.time.julianDates[rowNumber];
                    color.addSample(julianDate, legendHelper.getColorFromValue(value));
                    scale.addSample(julianDate, legendHelper.getScaleFromValue(value));
                    pixelSize.addSample(julianDate, legendHelper.getScaleFromValue(value) * 8);
                } else {
                    var colorInterval = interval.clone();
                    var scaleInterval = interval.clone();
                    var pixelSizeInterval = interval.clone();
                    colorInterval.data = legendHelper.getColorFromValue(value);
                    scaleInterval.data = legendHelper.getScaleFromValue(value);
                    pixelSizeInterval.data = legendHelper.getScaleFromValue(value) * 8;
                    color.intervals.addInterval(colorInterval);
                    scale.intervals.addInterval(scaleInterval);
                    pixelSize.intervals.addInterval(pixelSizeInterval);
                }
            }
            // We show this feature only when the time intervals say to.
            // Note this means a missing data point for one feature will make it disappear
            // for that period, not be interpolated.
            // We could enhance this by adding a TableStructure version of the TableColumn
            // timeInterval calculation, which uses idColumnNames and works per-feature.
            var show = calculateShow(availability);
            // Update the feature in as few commands as possible, since each one triggers a definitionChanged event.
            feature.availability = availability;
            feature.position = position;
            feature.properties = properties;
            feature.description = description;
            // Turn the color, scale, pixelSize and "show" into a Cesium entity.
            addPointToEntity(feature, tableColumnStyle, scale, pixelSize, color, show);
            if (!isExistingFeature) {
                dataSource._entityCollection.add(feature);
            }
        }
    }
}

// Set the features (entities) on this data source, using tableColumn to provide values and tableStyle + legendHelper.tableColumnStyle for styling.
// Set the extent based on those entities.
function updateEntitiesAndExtent(dataSource) {
    var tableStructure = dataSource._tableStructure;
    var legendHelper = dataSource._legendHelper;
    var tableStyle = legendHelper.tableStyle;
    var specialColumns = {
        longitude: tableStructure.columnsByType[VarType.LON][0],
        latitude: tableStructure.columnsByType[VarType.LAT][0],
        height: tableStructure.columnsByType[VarType.ALT][0],
        time: tableStructure.activeTimeColumn,
        value: legendHelper.tableColumn
    };
    if (defined(specialColumns.longitude) && defined(specialColumns.latitude)) {
        var rowObjects = tableStructure.toStringAndNumberRowObjects();
        var rowDescriptions = tableStructure.toRowDescriptions(tableStyle && tableStyle.featureInfoFields);
        dataSource._rowObjects = rowObjects;
        dataSource._rowDescriptions = rowDescriptions;
        var entities = dataSource._entityCollection;
        entities.suspendEvents();
        if (defined(specialColumns.time) && defined(tableStructure.idColumnNames)
                && tableStructure.idColumnNames.length > 0) {
            setOneFeaturePerId(dataSource, tableStructure, specialColumns);
            dataSource._hasFeaturePerRow = false;
        } else {
            setOneFeaturePerRow(dataSource, tableStructure, specialColumns);
            dataSource._hasFeaturePerRow = true;
        }
        entities.resumeEvents();

        // Generate extent from all positions that aren't NaN
        dataSource._extent = Rectangle.fromCartographicArray(
            specialColumns.longitude.values
                .map((lon, i) => [lon, specialColumns.latitude.values[i]])
                .filter(pos => pos.every(v => !isNaN(v)))
                .map(pos => Cartographic.fromDegrees(...pos))
        );
    }
}


function chooseFallbackNameField(keys, idColumnNames) {
    // Choose a name field by the same logic as Cesium's GeoJsonDataSource.
    // Following Cesium's approach, we override this with 'title' if it is truthy.
    //1) The first case-insensitive property with the name 'title',
    //2) The first case-insensitive property with the name 'name',
    //3) The first property containing the word 'title',
    //4) The first property containing the word 'name',
    //5) The first idColumnNames, if provided.
    var nameProperty;
    var namePropertyPrecedence = Number.MAX_VALUE;
    for (var i = 0; i < keys.length; i++) {
        var key = keys[i];
        var lowerKey = key.toLowerCase();
        if (namePropertyPrecedence > 1 && lowerKey === 'title') {
            namePropertyPrecedence = 1;
            nameProperty = key;
            break;
        } else if (namePropertyPrecedence > 2 && lowerKey === 'name') {
            namePropertyPrecedence = 2;
            nameProperty = key;
        } else if (namePropertyPrecedence > 3 && /title/i.test(key)) {
            namePropertyPrecedence = 3;
            nameProperty = key;
        } else if (namePropertyPrecedence > 4 && /name/i.test(key)) {
            namePropertyPrecedence = 4;
            nameProperty = key;
        }
    }
    return nameProperty || (idColumnNames && idColumnNames[0]);
}

/**
* Destroy the object and release resources
*/
TableDataSource.prototype.destroy = function() {
    // Do we need to explicitly unsubscribe from the clock?
    return destroyObject(this);
};

module.exports = TableDataSource;