UNPKG

terriajs

Version:

Geospatial data visualization platform.

888 lines (814 loc) 44.5 kB
/*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;