/*global require*/
"use strict";
var i18next = require("i18next").default;
var CallbackProperty = require("terriajs-cesium/Source/DataSources/CallbackProperty")
.default;
var Cartesian3 = require("terriajs-cesium/Source/Core/Cartesian3").default;
var Cartographic = require("terriajs-cesium/Source/Core/Cartographic").default;
var Color = require("terriajs-cesium/Source/Core/Color").default;
var createGuid = require("terriajs-cesium/Source/Core/createGuid").default;
var defined = require("terriajs-cesium/Source/Core/defined").default;
var destroyObject = require("terriajs-cesium/Source/Core/destroyObject")
.default;
var DeveloperError = require("terriajs-cesium/Source/Core/DeveloperError")
.default;
var EntityCollection = require("terriajs-cesium/Source/DataSources/EntityCollection")
.default;
var EntityCluster = require("terriajs-cesium/Source/DataSources/EntityCluster")
.default;
var CesiumEvent = require("terriajs-cesium/Source/Core/Event").default;
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var HorizontalOrigin = require("terriajs-cesium/Source/Scene/HorizontalOrigin")
.default;
var Iso8601 = require("terriajs-cesium/Source/Core/Iso8601").default;
var Rectangle = require("terriajs-cesium/Source/Core/Rectangle").default;
var SampledPositionProperty = require("terriajs-cesium/Source/DataSources/SampledPositionProperty")
.default;
var SampledProperty = require("terriajs-cesium/Source/DataSources/SampledProperty")
.default;
var TimeInterval = require("terriajs-cesium/Source/Core/TimeInterval").default;
var TimeIntervalCollection = require("terriajs-cesium/Source/Core/TimeIntervalCollection")
.default;
var TimeIntervalCollectionPositionProperty = require("terriajs-cesium/Source/DataSources/TimeIntervalCollectionPositionProperty")
.default;
var TimeIntervalCollectionProperty = require("terriajs-cesium/Source/DataSources/TimeIntervalCollectionProperty")
.default;
var VerticalOrigin = require("terriajs-cesium/Source/Scene/VerticalOrigin")
.default;
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);
};
Object.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;
}
return undefined;
}
},
/**
* 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: i18next.t("models.tableData.unsupportedCharactersTitle"),
message: i18next.t(
"models.tableData.unsupportedCharactersMessage",
{
longitude: specialColumns.longitude.values[i],
latitude: 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;