terriajs
Version:
Geospatial data visualization platform.
1,025 lines (927 loc) • 41.5 kB
JavaScript
'use strict';
/*global require*/
var clone = require('terriajs-cesium/Source/Core/clone');
var defaultValue = require('terriajs-cesium/Source/Core/defaultValue');
var defined = require('terriajs-cesium/Source/Core/defined');
var defineProperties = require('terriajs-cesium/Source/Core/defineProperties');
var DeveloperError = require('terriajs-cesium/Source/Core/DeveloperError');
var freezeObject = require('terriajs-cesium/Source/Core/freezeObject');
var ImagerySplitDirection = require('terriajs-cesium/Source/Scene/ImagerySplitDirection');
var JulianDate = require('terriajs-cesium/Source/Core/JulianDate');
var knockout = require('terriajs-cesium/Source/ThirdParty/knockout');
var when = require('terriajs-cesium/Source/ThirdParty/when');
var TimeIntervalCollection = require('terriajs-cesium/Source/Core/TimeIntervalCollection');
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 VarType = require('../Map/VarType');
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;
}
});
/**
* 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;
}
});
/**
* 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 ImageryLayerCatalogItem.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());
}
}
});
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;
}
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];
}
}
},
/**
* 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) {
return item._tableStyle.updateFromJson(json[propertyName], options);
};
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.
};
freezeObject(TableCatalogItem.defaultUpdaters);
TableCatalogItem.defaultSerializers = clone(CatalogItem.defaultSerializers);
TableCatalogItem.defaultSerializers.tableStyle = function(item, json, propertyName, options) {
json[propertyName] = item[propertyName].serializeToJson(options);
// Add the currently active variable to the tableStyle (if any) so it starts with the right one.
if (defined(item._tableStructure)) {
var activeItems = item._tableStructure.activeItems;
json[propertyName].dataVariable = activeItems[0] && activeItems[0].name;
}
};
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.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;
};
TableCatalogItem.defaultSerializers.clock = function() {
// Don't serialize. Clock is not part of propertiesForSharing, but it would be shared if this is user-added data.
// See SharePopupViewModel.prototype._addUserAddedCatalog.
};
TableCatalogItem.defaultSerializers.availableDates = function() {
// Do not update/serialize availableDates.
};
TableCatalogItem.defaultSerializers.intervals = function() {
// Don't serialize.
};
freezeObject(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');
freezeObject(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.applyTableStyleColumnsToStructure = function(tableStyle, tableStructure) {
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 (defined(columnStyle.units)) {
column.units = columnStyle.units;
}
if (defined(columnStyle.type)) {
column.type = columnStyle.type;
}
if (defined(columnStyle.active)) {
column.isActive = columnStyle.active;
}
if (defined(columnStyle.name)) {
column.name = columnStyle.name;
}
}
}
}
}
};
/**
* 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 = "\nThe CSV contains addresses, but " + addressGeocoderData.missingAddresses.length
+ " can\'t be located on the map:\n"
+ addressGeocoderData.missingAddresses.join(", ") + ".<br/><br/>";
}
if (addressGeocoderData.nullAddresses > 0) {
missingAddressesMessage += addressGeocoderData.nullAddresses + " addresses are missing from the CSV.";
}
item.terria.error.raiseEvent(new TerriaError({
sender: this,
title: 'Bulk Geocoder Information',
message: missingAddressesMessage
}));
}
return createDataSourceForLatLong(item, tableStructure);
})
.otherwise(function(e) {
item.terria.error.raiseEvent(new TerriaError({
sender: this,
title: 'Bulk Geocoder Error',
message: "Unable to map addresses to lat-long coordinates, as an error occurred while retrieving address coordinates. Please check your internet connection or try again later."
}));
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);
});
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();
}
}
}
/**
* 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.
*/
TableCatalogItem.prototype.activateColumnFromTableStyle = function() {
var tableStyle = this._tableStyle;
if (defined(tableStyle) && defined(tableStyle.dataVariable)) {
var columnToActivate = this._tableStructure.getColumnWithNameOrId(tableStyle.dataVariable);
if (columnToActivate) {
columnToActivate.toggleActive();
}
}
};
/**
* 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.chartableItems.push(item);
item.disableIncompatibleTableColumns();
item.setColorOnActiveColumns();
}
}
function removeFromChartableItems(item) {
var indexInChartableItems = item.terria.catalog.chartableItems.indexOf(item);
if (indexInChartableItems >= 0) {
item.terria.catalog.chartableItems.splice(indexInChartableItems, 1);
}
}
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();
}
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).
var colorsUsed = catalog.chartableItems.map(function(item) {
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.
*
* @returns {ChartData}
*/
TableCatalogItem.prototype.chartData = function() {
const item = this;
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;
this._terria.catalog.chartableItems.forEach(otherItem => {
if (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;