terriajs
Version:
Geospatial data visualization platform.
888 lines (814 loc) • 44.5 kB
JavaScript
/*global require*/
"use strict";
var CallbackProperty = require('terriajs-cesium/Source/DataSources/CallbackProperty');
var CesiumEvent = require('terriajs-cesium/Source/Core/Event');
var clone = require('terriajs-cesium/Source/Core/clone');
var combine = require('terriajs-cesium/Source/Core/combine');
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 destroyObject = require('terriajs-cesium/Source/Core/destroyObject');
var DeveloperError = require('terriajs-cesium/Source/Core/DeveloperError');
var ImageryLayerFeatureInfo = require('terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo');
var knockout = require('terriajs-cesium/Source/ThirdParty/knockout');
var TimeInterval = require('terriajs-cesium/Source/Core/TimeInterval');
var WebMapServiceImageryProvider = require('terriajs-cesium/Source/Scene/WebMapServiceImageryProvider');
var WebMercatorTilingScheme = require('terriajs-cesium/Source/Core/WebMercatorTilingScheme');
var when = require('terriajs-cesium/Source/ThirdParty/when');
var Rectangle = require('terriajs-cesium/Source/Core/Rectangle');
var calculateImageryLayerIntervals = require('./calculateImageryLayerIntervals');
var getUniqueValues = require('../Core/getUniqueValues');
var ImageryLayerCatalogItem = require('../Models/ImageryLayerCatalogItem');
var ImageryProviderHooks = require('../Map/ImageryProviderHooks');
var Leaflet = require('../Models/Leaflet');
var LegendHelper = require('../Models/LegendHelper');
var proxyCatalogItemUrl = require('../Models/proxyCatalogItemUrl');
var RegionProviderList = require('../Map/RegionProviderList');
var TableStructure = require('../Map/TableStructure');
var TableStyle = require('../Models/TableStyle');
var TerriaError = require('../Core/TerriaError');
var VarType = require('../Map/VarType');
var WebMapServiceCatalogItem = require('../Models/WebMapServiceCatalogItem');
var MapboxVectorTileImageryProvider = require('../Map/MapboxVectorTileImageryProvider');
var { setOpacity, fixNextLayerOrder } = require('./ImageryLayerPreloadHelpers');
/**
* A DataSource for table-based data.
* Handles the graphical display of lat-lon and region-mapped datasets.
* For lat-lon data sets, each row is taken to be a feature. RegionMapping generates Cesium entities for each row.
* For region-mapped data sets, each row is a region. The regions are displayed using a WMS imagery layer.
* Displaying the points or regions requires a legend.
*
* @name RegionMapping
*
* @alias RegionMapping
* @constructor
* @param {CatalogItem} [catalogItem] The CatalogItem instance.
* @param {TableStructure} [tableStructure] The Table Structure instance; defaults to a new one.
* @param {TableStyle} [tableStyle] The table style; defaults to undefined.
*/
var RegionMapping = function(catalogItem, tableStructure, tableStyle) {
this._tableStructure = defined(tableStructure) ? tableStructure : new TableStructure();
if (defined(tableStyle) && !(tableStyle instanceof TableStyle)) {
throw new DeveloperError('Please pass a TableStyle object.');
}
this._tableStyle = tableStyle; // Can be undefined.
this._changed = new CesiumEvent();
this._legendHelper = undefined;
this._legendUrl = undefined;
this._extent = undefined;
this._loadingData = false;
this._catalogItem = catalogItem;
this._regionMappingDefinitionsUrl = defined(catalogItem) ? catalogItem.terria.configParameters.regionMappingDefinitionsUrl : undefined;
this._regionDetails = undefined; // For caching the region details.
this._imageryLayer = undefined;
this._nextImageryLayer = undefined; // For pre-rendering time-varying layers
this._nextImageryLayerInterval = undefined;
this._hadImageryAtLayerIndex = undefined;
this._hasDisplayedFeedback = false; // So that we only show the feedback once.
this._constantRegionRowObjects = undefined;
this._constantRegionRowDescriptions = undefined;
// Track _tableStructure so that the catalogItem's concepts are maintained.
// Track _legendUrl so that the catalogItem can update the legend if it changes.
// Track _regionDetails so that when it is discovered that region mapping applies,
// it updates the legendHelper via activeItems, and catalogItem properties like supportsReordering.
knockout.track(this, ['_tableStructure', '_legendUrl', '_regionDetails']);
// 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);
knockout.getObservable(this._catalogItem, 'currentTime').subscribe(function() {
if (this.hasActiveTimeColumn) {
onClockTick(this);
}
}, this);
};
defineProperties(RegionMapping.prototype, {
/**
* Gets the clock settings defined by the loaded data. If
* only static data exists, this value is undefined.
* @memberof RegionMapping.prototype
* @type {DataSourceClock}
*/
clock: {
get: function() {
if (defined(this._tableStructure)) {
return this._tableStructure.clock;
}
}
},
/**
* Gets a CesiumEvent that will be raised when the underlying data changes.
* @memberof RegionMapping.prototype
* @type {CesiumEvent}
*/
changedEvent: {
get: function() {
return this._changed;
}
},
/**
* Gets or sets a value indicating if the data source is currently loading data.
* Whenever loadingData is changed to false, also trigger a redraw.
* @memberof RegionMapping.prototype
* @type {Boolean}
*/
isLoading: {
get: function() {
return this._loadingData;
},
set: function(value) {
this._loadingData = value;
if (!value) {
changedActiveItems(this);
}
}
},
/**
* Gets the TableStructure object holding all the data.
* @memberof RegionMapping.prototype
* @type {TableStructure}
*/
tableStructure: {
get : function() {
return this._tableStructure;
}
},
/**
* Gets the TableStyle object showing how to style the data.
* @memberof RegionMapping.prototype
* @type {TableStyle}
*/
tableStyle: {
get: function() {
return this._tableStyle;
}
},
/**
* 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;
}
},
/**
* Once loaded, gets the region details (an array of "regionDetail" objects, with regionProvider, columnName and disambigColumnName properties).
* By checking if defined, can be used as the region-mapping equivalent to "hasLatitudeAndLongitude".
* @type {Object[]}
*/
regionDetails: {
get: function() {
return this._regionDetails;
}
},
/**
* Gets the Cesium or Leaflet imagery layer object associated with this data source.
* This property is undefined if the data source is not enabled.
* @memberOf RegionMapping.prototype
* @type {Object}
*/
imageryLayer: {
get: function() {
return this._imageryLayer;
}
},
/**
* Gets a Boolean value saying whether the region mapping has a time column.
* @memberOf RegionMapping.prototype
* @type {Boolean}
*/
hasActiveTimeColumn: {
get: function() {
var timeColumn = this._tableStructure.activeTimeColumn;
return (defined(timeColumn) && defined(timeColumn._clock));
}
},
/**
* Gets a Boolean value saying whether the region mapping will be updated due to its catalog item being polled.
* @memberOf RegionMapping.prototype
* @type {Boolean}
*/
isPolled: {
get: function() {
return defined(this._catalogItem.polling && this._catalogItem.polling.seconds);
}
}
});
/**
* Set the region column type.
* Currently we only use the first possible region column, and leave any others as they are.
* @param {Object[]} regionDetails The data source's regionDetails array.
*/
RegionMapping.prototype.setRegionColumnType = function(index) {
if (!defined(index)) {
index = 0;
}
var regionDetail = this._regionDetails[index];
console.log('Found region match based on ' + regionDetail.columnName + (defined(regionDetail.disambigColumnName) ? (' and ' + regionDetail.disambigColumnName) : ''));
this._tableStructure.getColumnWithNameIdOrIndex(regionDetail.columnName).type = VarType.REGION;
if (defined(regionDetail.disambigColumnName)) {
this._tableStructure.getColumnWithNameIdOrIndex(regionDetail.disambigColumnName).type = VarType.REGION;
}
};
/**
* Explictly hide the imagery layer (if any).
*/
RegionMapping.prototype.hideImageryLayer = function() {
// The region mapping was on, but has been switched off, so disable the imagery layer.
// We are using _hadImageryAtLayerIndex = true to mean it had an ImageryLayer, but its layer was undefined.
// _hadImageryAtLayerIndex = undefined means it did not have an ImageryLayer.
var regionMapping = this;
if (defined(regionMapping._imageryLayer)) {
regionMapping._hadImageryAtLayerIndex = regionMapping._imageryLayer._layerIndex; // Would prefer not to access an internal variable of imageryLayer.
if (!defined(regionMapping._hadImageryAtLayerIndex)) {
regionMapping._hadImageryAtLayerIndex = true;
}
ImageryLayerCatalogItem.hideLayer(regionMapping._catalogItem, regionMapping._imageryLayer);
ImageryLayerCatalogItem.disableLayer(regionMapping._catalogItem, regionMapping._imageryLayer);
regionMapping._imageryLayer = undefined;
}
};
function reviseLegendHelper(regionMapping) {
// Currently we only use the first possible region column.
var activeColumn = regionMapping._tableStructure.activeItems[0];
var regionProvider = defined(regionMapping._regionDetails) ? regionMapping._regionDetails[0].regionProvider : undefined;
regionMapping._legendHelper = new LegendHelper(activeColumn, regionMapping._tableStyle, regionProvider, regionMapping._catalogItem.name);
regionMapping._legendUrl = regionMapping._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 imageryLayer.
* @private
*/
function changedActiveItems(regionMapping) {
if (defined(regionMapping._regionDetails)) {
reviseLegendHelper(regionMapping);
if (!regionMapping._loadingData) {
if (defined(regionMapping._imageryLayer) || defined(regionMapping._hadImageryAtLayerIndex)) {
redisplayRegions(regionMapping);
}
regionMapping._changed.raiseEvent(regionMapping);
}
} else {
regionMapping._legendHelper = undefined;
regionMapping._legendUrl = undefined;
}
}
RegionMapping.prototype.showOnSeparateMap = function(globeOrMap) {
if (defined(this._regionDetails)) {
var layer = createNewRegionImageryLayer(this, 0, undefined, globeOrMap, globeOrMap.terria.clock.currentTime);
ImageryLayerCatalogItem.showLayer(this._catalogItem, layer, globeOrMap);
var that = this;
return function() {
ImageryLayerCatalogItem.hideLayer(that._catalogItem, layer, globeOrMap);
ImageryLayerCatalogItem.disableLayer(that._catalogItem, layer, globeOrMap);
};
}
};
// The functions enable, disable, show and hide are required for region mapping.
RegionMapping.prototype.enable = function(layerIndex) {
if (defined(this._regionDetails)) {
setNewRegionImageryLayer(this, layerIndex);
}
};
RegionMapping.prototype.disable = function() {
if (defined(this._regionDetails)) {
ImageryLayerCatalogItem.disableLayer(this._catalogItem, this._imageryLayer);
this._imageryLayer = undefined;
}
};
RegionMapping.prototype.show = function() {
if (defined(this._regionDetails)) {
ImageryLayerCatalogItem.showLayer(this._catalogItem, this._imageryLayer);
}
};
RegionMapping.prototype.hide = function() {
if (defined(this._regionDetails)) {
ImageryLayerCatalogItem.hideLayer(this._catalogItem, this._imageryLayer);
}
};
RegionMapping.prototype.updateOpacity = function(opacity) {
if (defined(this._imageryLayer)) {
if (defined(this._imageryLayer.alpha)) {
this._imageryLayer.alpha = opacity;
}
if (defined(this._imageryLayer.setOpacity)) {
this._imageryLayer.setOpacity(opacity);
}
}
};
/**
* Builds a promise which resolves to either:
* undefined if no regions;
* An array of objects with regionProvider, column and disambigColumn properties.
* It also caches this object in regionMapping._regionDetails.
*
* The steps involved are:
* 0. Wait for the data to be ready, if needed. (For loaded tables, this is trivially true, but it might be constructed elsewhere.)
* 1. Get the region provider list (asynchronously).
* 2. Use this list to find all the possible region identifiers for this table, eg. 'postcode' or 'sa4_code'.
* If the user specified a prefered region variable name/type, put this to the front of the list.
* Elsewhere, we only offer the user the first region mapping possibility.
* 3. Load the region ids of each possible region identifier (asynchronously), eg. ['2001', '2002', ...].
* 4. Once all of these are known, cache and return all the details of all the possible region mapping approaches.
*
* These steps are sequenced using a series of promise.thens, so that the caller only sees a promise resolving to the end result.
*
* It is safe to call this multiple times, as each asynchronous call returns a cached promise if it exists.
*
* @return {Promise} The promise.
*/
RegionMapping.prototype.loadRegionDetails = function() {
var regionMapping = this;
if (!regionMapping._regionMappingDefinitionsUrl) {
return when();
}
// RegionProviderList.fromUrl returns a cached version if available.
return RegionProviderList.fromUrl(regionMapping._regionMappingDefinitionsUrl, this._catalogItem.terria.corsProxy).then(function(regionProviderList) {
var targetRegionVariableName, targetRegionType;
if (defined(regionMapping._tableStyle)) {
targetRegionVariableName = regionMapping._tableStyle.regionVariable;
targetRegionType = regionMapping._tableStyle.regionType;
}
// We have a region provider list, now get the region provider and load its region ids (another async job).
// Provide the user-specified region variable name and type. If specified, getRegionDetails will return them as the first object in the returned array.
var rawRegionDetails = regionProviderList.getRegionDetails(regionMapping._tableStructure.getColumnNames(), targetRegionVariableName, targetRegionType);
if (rawRegionDetails.length > 0) {
return loadRegionIds(regionMapping, rawRegionDetails);
}
return when(); // Nothing more to return.
});
};
// Loads region ids from the region providers, and returns the region details.
function loadRegionIds(regionMapping, rawRegionDetails) {
var promises = rawRegionDetails.map(function(rawRegionDetail) { return rawRegionDetail.regionProvider.loadRegionIDs(); });
return when.all(promises).then(function() {
// Cache the details in a nicer format, storing the actual columns rather than just the column names.
regionMapping._regionDetails = rawRegionDetails.map(function(rawRegionDetail) {
return {
regionProvider: rawRegionDetail.regionProvider,
columnName: rawRegionDetail.variableName,
disambigColumnName: rawRegionDetail.disambigVariableName
};
});
return regionMapping._regionDetails;
}).otherwise(function(e) {
console.log('error loading region ids', e);
});
}
/**
* Returns an array the same length as regionProvider.regions, mapping each region into the relevant index into the table data source.
* Takes the current time into account if a time is provided, and there is a time column with timeIntervals defined.
* @private
* @param {RegionMapping} regionMapping The table data source.
* @param {JulianDate} [time] The current time, eg. terria.clock.currentTime. NOT the time column's ._clock's time, which is different (and comes from a DataSourceClock).
* @param {Array} [failedMatches] An optional empty array. If provided, indices of failed matches are appended to the array.
* @param {Array} [ambiguousMatches] An optional empty array. If provided, indices of matches which duplicate prior matches are appended to the array.
* @return {Array} An array the same length as regionProvider.regions, mapping each region into the relevant index into the table data source.
*/
function calculateRegionIndices(regionMapping, time, failedMatches, ambiguousMatches) {
// As described in load, currently we only use the first possible region column.
var regionDetail = regionMapping._regionDetails[0];
var tableStructure = regionMapping._tableStructure;
var regionColumn = tableStructure.getColumnWithNameIdOrIndex(regionDetail.columnName);
if (!defined(regionColumn)) {
return;
}
var regionColumnValues = regionColumn.values;
// Wipe out the region names from the rows that do not apply at this time, if there is a time column.
var timeColumn = tableStructure.activeTimeColumn;
var disambigColumn = defined(regionDetail.disambigColumnName) ? tableStructure.getColumnWithNameIdOrIndex(regionDetail.disambigColumnName) : undefined;
// regionIndices will be an array the same length as regionProvider.regions, giving the index of each region into the table.
var regionIndices = regionDetail.regionProvider.mapRegionsToIndicesInto(
regionColumnValues,
disambigColumn && disambigColumn.values,
failedMatches,
ambiguousMatches,
defined(timeColumn) ? timeColumn.timeIntervals : undefined,
time
);
return regionIndices;
}
function getRegionValuesFromIndices(regionIndices, tableStructure) {
var regionValues = regionIndices; // Appropriate if no active column: color each region according to its index into the table.
if (tableStructure.activeItems.length > 0) {
var activeColumn = tableStructure.activeItems[0];
regionValues = regionIndices.map(function(i) { return activeColumn.values[i]; });
}
return regionValues;
}
/**
* Put the properties and the description of this row onto the image of the region. Handles time-varying and constant regions.
* @param {RegionMapping} regionMapping The region mapping instance.
* @param {Array} [regionIndices] An array the same length as regionProvider.regions, mapping each region into the relevant index into the table data source;
* only used if the regions are constant.
* @param {WebMapServiceImageryProvider} regionImageryProvider The WebMapServiceImageryProvider instance.
* @private
*/
function addDescriptionAndProperties(regionMapping, regionIndices, regionImageryProvider) {
var tableStructure = regionMapping._tableStructure;
var rowObjects = tableStructure.toStringAndNumberRowObjects();
if (rowObjects.length === 0) {
return;
}
var columnAliases = tableStructure.getColumnAliases();
var rowDescriptions = tableStructure.toRowDescriptions(regionMapping._tableStyle.featureInfoFields);
var regionDetail = regionMapping._regionDetails[0];
var uniqueIdProp = regionDetail.regionProvider.uniqueIdProp;
var idColumnNames = [regionDetail.columnName];
if (defined(regionDetail.disambigColumnName)) {
idColumnNames.push(regionDetail.disambigColumnName);
}
var rowNumbersMap = tableStructure.getIdMapping(idColumnNames);
if (!regionMapping.hasActiveTimeColumn) {
regionMapping._constantRegionRowObjects = regionIndices.map(function(i) { return rowObjects[i]; });
regionMapping._constantRegionRowDescriptions = regionIndices.map(function(i) { return rowDescriptions[i]; });
}
function getRegionRowDescriptionPropertyCallbackForId(uniqueId) {
/**
* Returns a function that returns the value of the regionRowDescription at a given time, updating result if available.
* @private
* @param {JulianDate} [time] The time for which to retrieve the value.
* @param {Object} [result] The object to store the value into, if omitted, a new instance is created and returned.
* @returns {Object} The modified result parameter or a new instance if the result parameter was not supplied or is unsupported.
*/
return function regionRowDescriptionPropertyCallback(time, result) {
// result parameter is unsupported (should it be supported?)
if (!regionMapping.hasActiveTimeColumn) {
return regionMapping._constantRegionRowDescriptions[uniqueId] || 'No data';
}
var timeSpecificRegionIndices = calculateRegionIndices(regionMapping, time);
var timeSpecificRegionRowDescriptions = timeSpecificRegionIndices.map(function(i) { return rowDescriptions[i]; });
if (defined(timeSpecificRegionRowDescriptions[uniqueId])) {
return timeSpecificRegionRowDescriptions[uniqueId];
}
// If it's not defined at this time, is it defined at any time?
// Give a different description in each case.
var timeAgnosticRegionIndices = calculateRegionIndices(regionMapping);
var rowNumberWithThisRegion = timeAgnosticRegionIndices[uniqueId];
if (defined(rowNumberWithThisRegion)) {
return 'No data for the selected date.';
}
return 'No data for the selected region.';
};
}
function getRegionRowPropertiesPropertyCallbackForId(uniqueId) {
/**
* Returns a function that returns the value of the regionRowProperties at a given time, updating result if available.
* @private
* @param {JulianDate} [time] The time for which to retrieve the value.
* @param {Object} [result] The object to store the value into, if omitted, a new instance is created and returned.
* @returns {Object} The modified result parameter or a new instance if the result parameter was not supplied or is unsupported.
*/
return function regionRowPropertiesPropertyCallback(time, result) {
// result parameter is unsupported (should it be supported?)
if (!regionMapping.hasActiveTimeColumn) {
// Only changes due to polling.
var rowObject = regionMapping._constantRegionRowObjects[uniqueId];
if (!defined(rowObject)) {
return {};
}
var constantProperties = rowObject.string;
constantProperties._terria_columnAliases = columnAliases;
constantProperties._terria_numericalProperties = rowObject.number;
return constantProperties;
}
// Changes due to time column in the table (and maybe polling too).
var timeSpecificRegionIndices = calculateRegionIndices(regionMapping, time);
var timeSpecificRegionRowObjects = timeSpecificRegionIndices.map(function(i) { return rowObjects[i]; });
var tsRowObject = timeSpecificRegionRowObjects[uniqueId];
var properties = (tsRowObject && tsRowObject.string) || {};
properties._terria_columnAliases = columnAliases;
properties._terria_numericalProperties = (tsRowObject && tsRowObject.number) || {};
// Even if there is no data for this region at this time,
// we want to get data for all other times for this region so we can chart it.
// So get the region indices again, this time ignoring time,
// so that we can get a row number in the table where this region occurs (if there is one at any time).
// This feels like a slightly roundabout approach. Is there a more streamlined way?
var timeAgnosticRegionIndices = calculateRegionIndices(regionMapping);
var regionIdString = tableStructure.getIdStringForRowNumber(timeAgnosticRegionIndices[uniqueId], idColumnNames);
var rowNumbersForThisRegion = rowNumbersMap[regionIdString];
if (defined(rowNumbersForThisRegion)) {
properties._terria_getChartDetails = function() {
return tableStructure.getChartDetailsForRowNumbers(rowNumbersForThisRegion);
};
}
return properties;
};
}
switch (regionDetail.regionProvider.serverType) {
case "MVT":
return function constructMVTFeatureInfo(feature) {
var imageryLayerFeatureInfo = new ImageryLayerFeatureInfo();
imageryLayerFeatureInfo.name = feature.properties[regionDetail.regionProvider.nameProp];
var uniqueId = feature.properties[uniqueIdProp];
if (!regionMapping.hasActiveTimeColumn && !regionMapping.isPolled) {
// Constant over time - no time column, and no polling.
imageryLayerFeatureInfo.description = regionMapping._constantRegionRowDescriptions[uniqueId];
var cRowObject = regionMapping._constantRegionRowObjects[uniqueId];
if (defined(cRowObject)) {
cRowObject.string._terria_columnAliases = columnAliases;
cRowObject.string._terria_numericalProperties = cRowObject.number;
imageryLayerFeatureInfo.properties = combine(feature.properties, cRowObject.string);
} else {
imageryLayerFeatureInfo.properties = clone(feature.properties);
}
} else {
// Time-varying.
imageryLayerFeatureInfo.description = new CallbackProperty(getRegionRowDescriptionPropertyCallbackForId(uniqueId), false);
// Merge vector tile and data properties
var propertiesCallback = getRegionRowPropertiesPropertyCallbackForId(uniqueId);
imageryLayerFeatureInfo.properties = new CallbackProperty(time => combine(feature.properties, propertiesCallback(time)), false);
}
imageryLayerFeatureInfo.data = { id: uniqueId }; // For region highlight
return imageryLayerFeatureInfo;
};
case "WMS":
ImageryProviderHooks.addPickFeaturesHook(regionImageryProvider, function(imageryLayerFeatureInfos) {
if (!defined(imageryLayerFeatureInfos) || imageryLayerFeatureInfos.length === 0) {
return;
}
for (var i = 0; i < imageryLayerFeatureInfos.length; ++i) {
var imageryLayerFeatureInfo = imageryLayerFeatureInfos[i];
var uniqueId = imageryLayerFeatureInfo.data.properties[uniqueIdProp];
if (!regionMapping.hasActiveTimeColumn && !regionMapping.isPolled) {
// Constant over time - no time column, and no polling.
imageryLayerFeatureInfo.description = regionMapping._constantRegionRowDescriptions[uniqueId];
var cRowObject = regionMapping._constantRegionRowObjects[uniqueId];
if (defined(cRowObject)) {
cRowObject.string._terria_columnAliases = columnAliases;
cRowObject.string._terria_numericalProperties = cRowObject.number;
imageryLayerFeatureInfo.properties = cRowObject.string;
} else {
imageryLayerFeatureInfo.properties = {};
}
} else {
// Time-varying.
imageryLayerFeatureInfo.description = new CallbackProperty(getRegionRowDescriptionPropertyCallbackForId(uniqueId), false);
imageryLayerFeatureInfo.properties = new CallbackProperty(getRegionRowPropertiesPropertyCallbackForId(uniqueId), false);
}
}
// If there was no description or property for a layer then we have nothing to display for it, so just filter it out.
// This helps in cases where the imagery provider returns a feature that doesn't actually match the region.
return imageryLayerFeatureInfos.filter(info => info.properties || info.description);
});
break;
}
}
/**
* Creates and enables a new ImageryLayer onto terria, showing appropriately colored regions.
* @private
* @param {RegionMapping} regionMapping The table data source.
* @param {Number} [layerIndex] The layer index of the new imagery layer.
*
* @param {Array} [regionIndices] An array the same length as regionProvider.regions, mapping each region into the relevant index into the table data source.
* If not provided, it is calculated, and failed/ambiguous warnings are displayed to the user.
*/
function setNewRegionImageryLayer(regionMapping, layerIndex, regionIndices) {
if (!defined(regionMapping._tableStructure.activeTimeColumn)) {
regionMapping._imageryLayer = createNewRegionImageryLayer(regionMapping, layerIndex, regionIndices);
} else {
var catalogItem = regionMapping._catalogItem;
var currentTime = catalogItem.currentTime;
// Calulate the interval of time that the next imagery layer is valid for
var { nextInterval, currentInterval } = calculateImageryLayerIntervals(regionMapping._tableStructure.activeTimeColumn, currentTime, catalogItem.terria.clock.multiplier >= 0.0);
if (currentInterval === regionMapping._imageryLayerInterval && nextInterval === regionMapping._nextImageryLayerInterval) {
// No change in intervals, so nothing to do.
return;
}
if (currentInterval !== regionMapping._imageryLayerInterval) {
// Current layer is incorrect. Can we use the next one?
if (regionMapping._nextImageryLayerInterval && TimeInterval.contains(regionMapping._nextImageryLayerInterval, currentTime)) {
setOpacity(catalogItem, regionMapping._nextImageryLayer, catalogItem.opacity);
fixNextLayerOrder(catalogItem, regionMapping._imageryLayer, regionMapping._nextImageryLayer);
ImageryLayerCatalogItem.disableLayer(catalogItem, regionMapping._imageryLayer);
regionMapping._imageryLayer = regionMapping._nextImageryLayer;
regionMapping._imageryLayerInterval = regionMapping._nextImageryLayerInterval;
regionMapping._nextImageryLayer = undefined;
regionMapping._nextImageryLayerInterval = null;
} else {
// Next is not right, either, possibly because the user is randomly scrubbing
// on the timeline. So create a new layer.
ImageryLayerCatalogItem.disableLayer(catalogItem, regionMapping._imageryLayer);
regionMapping._imageryLayer = createNewRegionImageryLayer(regionMapping, layerIndex, regionIndices);
regionMapping._imageryLayerInterval = currentInterval;
}
}
if (nextInterval !== regionMapping._nextImageryLayerInterval) {
// Next layer is incorrect, so recreate it.
ImageryLayerCatalogItem.disableLayer(catalogItem, regionMapping._nextImageryLayer);
if (nextInterval) {
regionMapping._nextImageryLayer = createNewRegionImageryLayer(regionMapping, layerIndex, undefined, undefined, nextInterval.start, 0.0);
ImageryLayerCatalogItem.showLayer(catalogItem, regionMapping._nextImageryLayer);
} else {
regionMapping._nextImageryLayer = undefined;
}
regionMapping._nextImageryLayerInterval = nextInterval;
}
}
}
function createNewRegionImageryLayer(regionMapping, layerIndex, regionIndices, globeOrMap, time, overrideOpacity) {
var catalogItem = regionMapping._catalogItem;
var opacity = defaultValue(overrideOpacity, catalogItem.opacity);
globeOrMap = defaultValue(globeOrMap, catalogItem.terria.currentViewer);
time = defaultValue(time, catalogItem.currentTime);
var regionDetail = regionMapping._regionDetails[0];
var legendHelper = regionMapping._legendHelper;
if (!defined(legendHelper)) {
return; // Give up. This can happen if a time-series region-mapped table is charted over time; the chart looks like a region-mapped file.
}
var tableStructure = regionMapping._tableStructure;
var failedMatches, ambiguousMatches;
if (!defined(regionIndices)) {
failedMatches = [];
ambiguousMatches = [];
regionIndices = calculateRegionIndices(regionMapping, time, failedMatches, ambiguousMatches);
if (!regionMapping._hasDisplayedFeedback && catalogItem.showWarnings) {
regionMapping._hasDisplayedFeedback = true;
displayFailedAndAmbiguousMatches(regionMapping, failedMatches, ambiguousMatches);
}
}
if (!defined(regionIndices)) {
return;
}
var regionValues = getRegionValuesFromIndices(regionIndices, tableStructure);
if (!defined(regionValues)) {
return;
}
// Recolor the regions.
var colorFunction = regionDetail.regionProvider.getColorLookupFunc(
regionValues,
legendHelper.getColorArrayFromValue.bind(legendHelper)
);
var regionImageryProvider;
var layer;
switch (regionDetail.regionProvider.serverType) {
case "MVT":
var terria = globeOrMap.terria;
// Inform the user that region mapping is not supported in old browsers.
if (typeof ArrayBuffer === 'undefined') {
throw new TerriaError({
sender: catalogItem,
title: terria.configParameters.oldBrowserRegionMappingTitle || 'Outdated Web Browser',
message: terria.configParameters.oldBrowserRegionMappingMessage ||
'You are using a very old web browser that cannot display "region mapped" datasets such as this one. Please upgrade to ' +
'the latest version of Google Chrome, ' +
'Mozilla Firefox, Microsoft Edge, Microsoft Internet Explorer 11, or Apple Safari as soon as possible. Please contact us ' +
'at <a href="mailto:' + terria.supportEmail + '">' + terria.supportEmail + '</a> if you have any concerns.'
});
}
regionImageryProvider = new MapboxVectorTileImageryProvider({
url: regionDetail.regionProvider.server,
layerName: regionDetail.regionProvider.layerName,
styleFunc: function(id) {
var terria = catalogItem.terria;
var color = colorFunction(id);
return color ? {
// color is an Array-like object (note: typed arrays don't have a 'join' method in IE10 & IE11)
fillStyle: 'rgba(' + Array.prototype.join.call(color, ',') + ')',
strokeStyle: terria.baseMapContrastColor,
lineWidth: 1
} : undefined;
},
subdomains: regionDetail.regionProvider.serverSubdomains,
rectangle: Rectangle.fromDegrees.apply(null, regionDetail.regionProvider.bbox),
minimumZoom: regionDetail.regionProvider.serverMinZoom,
maximumNativeZoom: regionDetail.regionProvider.serverMaxNativeZoom,
maximumZoom: regionDetail.regionProvider.serverMaxZoom,
uniqueIdProp: regionDetail.regionProvider.uniqueIdProp,
featureInfoFunc: addDescriptionAndProperties(regionMapping, regionIndices, regionImageryProvider)
});
layer = ImageryLayerCatalogItem.enableLayer(catalogItem, regionImageryProvider, opacity, layerIndex, globeOrMap);
break;
case "WMS":
// Recolor the regions, and add feature descriptions.
regionImageryProvider = new WebMapServiceImageryProvider({
url: proxyCatalogItemUrl(catalogItem, regionDetail.regionProvider.server),
layers: regionDetail.regionProvider.layerName,
parameters: WebMapServiceCatalogItem.defaultParameters,
getFeatureInfoParameters: WebMapServiceCatalogItem.defaultParameters,
tilingScheme: new WebMercatorTilingScheme()
});
addDescriptionAndProperties(regionMapping, regionIndices, regionImageryProvider);
ImageryProviderHooks.addRecolorFunc(regionImageryProvider, colorFunction);
layer = ImageryLayerCatalogItem.enableLayer(catalogItem, regionImageryProvider, opacity, layerIndex, globeOrMap);
if (globeOrMap instanceof Leaflet && colorFunction) {
layer.options.crossOrigin = true; // Allow cross origin tiles
layer.on('tileload', function(evt) {
if (evt.tile._recolored) {
// Already recoloured (this event is called when the recoloured tile "loads")
return;
}
// Below code adapted from Leaflet.TileLayer.Filter (https://github.com/humangeo/leaflet-tilefilter)
/*
@preserve Leaflet Tile Filters, a JavaScript plugin for applying image filters to tile images
(c) 2014, Scott Fairgrieve, HumanGeo
*/
var canvas;
var size = 256;
if (!evt.tile.canvasContext) {
canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
evt.tile.canvasContext = canvas.getContext("2d");
}
var ctx = evt.tile.canvasContext;
if (ctx) {
ctx.drawImage(evt.tile, 0, 0);
var imgd = ctx.getImageData(0, 0, size, size);
imgd = ImageryProviderHooks.recolorImage(imgd, colorFunction);
ctx.putImageData(imgd, 0, 0);
evt.tile.onload = null;
evt.tile.src = ctx.canvas.toDataURL();
evt.tile._recolored = true;
}
});
}
break;
default:
throw new TerriaError({
title: "Invalid serverType " + regionDetail.regionProvider.serverType + " in regionMapping.json",
message: '<div>Expected serverType to be "WMS" or "vector" but got "' + regionDetail.regionProvider.serverType + '"</div>'
});
}
return layer;
}
/**
* Update the region imagery layer, eg. when the active variable changes, or the time changes.
* Following previous practice, when the coloring needs to change, the item is hidden, disabled, then re-enabled and re-shown.
* So, a new imagery layer is created (during 'enable') each time its coloring changes.
* It would look less flickery to reuse the existing one, but when I tried, I found the recoloring doesn't get used.
* @private
* @param {RegionMapping} regionMapping The data source.
* @param {Array} [regionIndices] Passed into setNewRegionImageryLayer. Saves recalculating it if available.
*/
function redisplayRegions(regionMapping, regionIndices) {
if (defined(regionMapping._regionDetails)) {
regionMapping.hideImageryLayer();
setNewRegionImageryLayer(regionMapping, regionMapping._hadImageryAtLayerIndex, regionIndices);
if (regionMapping._catalogItem.isShown) {
ImageryLayerCatalogItem.showLayer(regionMapping._catalogItem, regionMapping._imageryLayer);
}
regionMapping._catalogItem.terria.currentViewer.updateItemForSplitter(regionMapping._catalogItem);
}
}
function onClockTick(regionMapping) {
// Check if record data has changed.
if (regionMapping._imageryLayerInterval && TimeInterval.contains(regionMapping._imageryLayerInterval, regionMapping.clock.currentTime)) {
return;
}
redisplayRegions(regionMapping);
}
function displayFailedAndAmbiguousMatches(regionMapping, failedMatches, ambiguousMatches) {
var msg = "";
var regionDetail = regionMapping._regionDetails[0];
var regionColumnValues = regionMapping._tableStructure.getColumnWithNameIdOrIndex(regionDetail.columnName).values;
var timeColumn = regionMapping._tableStructure.activeTimeColumn;
if (failedMatches.length > 0) {
var failedNames = failedMatches.map(function(indexOfFailedMatch) { return regionColumnValues[indexOfFailedMatch]; });
msg += 'These region names were <span class="warning-text">not recognised</span>: <br><br/>' +
'<samp>' + failedNames.join('</samp>, <samp>') + '</samp>' +
'<br/><br/>';
}
// Only show ambiguous matches if there is no time column.
// There could still be ambiguous matches, but our code doesn't calculate that.
if ((ambiguousMatches.length > 0) && !defined(timeColumn)) {
var ambiguousNames = ambiguousMatches.map(function(indexOfAmbiguousMatch) { return regionColumnValues[indexOfAmbiguousMatch]; });
msg += 'These regions had <span class="warning-text">more than one value</span>: <br/><br/>' +
'<samp>' + getUniqueValues(ambiguousNames).join('</samp>, <samp>') + '</samp>' +
'<br/><br/>';
}
if (msg) {
msg += 'Consult the <a href="https://github.com/TerriaJS/nationalmap/wiki/csv-geo-au">CSV-geo-au specification</a> to see how to format the file.';
var error = new TerriaError({
title: "Issues loading " + regionMapping._catalogItem.name.slice(0, 20), // Long titles mess up the message body.
message: '<div>'+ msg +'</div>'
});
if (failedMatches.length === regionColumnValues.length) {
// Every row failed, so abort - don't add it to catalogue at all.
throw error;
} else {
// Just warn the user. Ideally we'd avoid showing the warning when switching between columns.
regionMapping._catalogItem.terria.error.raiseEvent(error);
}
}
}
/**
* Destroy the object and release resources
*/
RegionMapping.prototype.destroy = function() {
// TODO: Don't we need to explicitly unsubscribe from the clock?
// The comments for destroyObject suggest this is not useful for a RegionMapping object.
return destroyObject(this);
};
module.exports = RegionMapping;