UNPKG

terriajs

Version:

Geospatial data visualization platform.

1,032 lines (960 loc) 53.4 kB
'use strict'; /*global require*/ var Mustache = require('mustache'); 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 JulianDate = require('terriajs-cesium/Source/Core/JulianDate'); var knockout = require('terriajs-cesium/Source/ThirdParty/knockout'); var loadWithXhr = require('../Core/loadWithXhr'); var when = require('terriajs-cesium/Source/ThirdParty/when'); var DisplayVariablesConcept = require('../Map/DisplayVariablesConcept'); var inherit = require('../Core/inherit'); var featureDataToGeoJson = require('../Map/featureDataToGeoJson'); var GeoJsonCatalogItem = require('./GeoJsonCatalogItem'); var overrideProperty = require('../Core/overrideProperty'); var proxyCatalogItemUrl = require('./proxyCatalogItemUrl'); var raiseErrorToUser = require('./raiseErrorToUser'); var TableCatalogItem = require('./TableCatalogItem'); var TableColumn = require('../Map/TableColumn'); var TableStructure = require('../Map/TableStructure'); var TerriaError = require('../Core/TerriaError'); var VariableConcept = require('../Map/VariableConcept'); var xml2json = require('../ThirdParty/xml2json'); /** * A {@link CatalogItem} representing data obtained from a Sensor Observation Service (SOS) 2.0 server. * The SOS specifications are available at http://www.opengeospatial.org/standards/sos . * This requires a json configuration file which specifies the procedures and observableProperties to show. * If more than one procedure or observableProperty is provided, the user can choose between the options. * Note because of this need for configuration, there is no SOS catalog "group" (yet). * * The offerings parameter is not used, and no spatial filters are provided. * The default soap XML request body can be overridden to handle custom requirements. * * @alias SensorObservationServiceCatalogItem * @constructor * @extends TableCatalogItem * * @param {Terria} terria The Terria instance. * @param {String} [url] The base URL from which to retrieve the data. */ var SensorObservationServiceCatalogItem = function(terria, url) { TableCatalogItem.call(this, terria, url); this._concepts = []; this._featureMapping = undefined; // A bunch of variables used to manage changing the active concepts (procedure and/or observable property), // so they can handle errors in the result, and so you cannot change active concepts while in the middle of loading observations. this._previousProcedureIdentifier = undefined; this._previousObservablePropertyIdentifier = undefined; this._loadingProcedureIdentifier = undefined; this._loadingObservablePropertyIdentifier = undefined; this._revertingConcepts = false; this._loadingFeatures = false; // Set during changedActiveItems, so tests can access the promise. this._observationDataPromise = undefined; /** * Gets or sets a flag. If true, the catalog item will load all features, then, if * number of features < requestSizeLimit * requestNumberLimit, it will load all the observation data * for those features, and show that. * If false, or there are too many features, the observation data is only loaded when the feature is clicked on * (via a chart in the feature info panel). * Defaults to true. * @type {Boolean} */ this.tryToLoadObservationData = true; /** * Gets or sets the maximum number of timeseries to request of the server in a single GetObservation request. * Servers may have a Response Size Limit, eg. 250. * Note the number of responses may be different to the number requested, * eg. the BoM server can return > 1 timeseries/feature identifier, (such as ...stations/41001702), * so it can be sensible to set this below the response size limit. * @type {Integer} */ this.requestSizeLimit = 200; /** * Gets or sets the maximum number of GetObservation requests that we can fire off at a time. * If the response size limit is 250, and this is 4, then observations for at most 1000 features will load. * If there are more than 1000 features, they will be shown without observation data, until they are clicked. * @type {Integer} */ this.requestNumberLimit = 3; /** * Gets or sets the name seen by the user for the list of procedures. * Defaults to "Procedure", but eg. for BoM, "Frequency" would be better. * @type {String} */ this.proceduresName = 'Procedure'; /** * Gets or sets the name seen by the user for the list of observable properties. * Defaults to "Property", but eg. for BoM, "Observation type" would be better. * @type {String} */ this.observablePropertiesName = 'Property'; /** * Gets or sets the sensor observation service procedures that the user can choose from for this catalog item. * An array of objects with keys 'identifier', 'title' and (optionally) 'defaultDuration' and 'units', eg. * [{ * identifier: 'http://bom.gov.au/waterdata/services/tstypes/Pat7_C_B_1_YearlyMean', * title: 'Annual Mean', * defaultDuration: '20y' // Final character must be s, h, d or y for seconds, hours, days or years. * }] * The identifier is used for communication with the server, and the title is used for display to the user. * If there is only one object, the user is not presented with a choice. * @type {Object[]} */ this.procedures = undefined; /** * Gets or sets the sensor observation service observableProperties that the user can choose from for this catalog item. * An array of objects with keys 'identifier', 'title' and (optionally) 'defaultDuration' and 'units', eg. * [{ * identifier: 'http://bom.gov.au/waterdata/services/parameters/Storage Level', * title: 'Storage Level', * units: 'metres' * }] * The identifier is used for communication with the server, and the title is used for display to the user. * If there is only one object, the user is not presented with a choice. * @type {Object[]} */ this.observableProperties = undefined; /** * Gets or sets the index of the initially selected procedure. Defaults to 0. * @type {Number} */ this.initialProcedureIndex = 0; /** * Gets or sets the index of the initially selected observable property. Defaults to 0. * @type {Number} */ this.initialObservablePropertyIndex = 0; /** * A start date in ISO8601 format. All requests filter to this start date. Set to undefined for no temporal filter. * @type {String} */ this.startDate = undefined; /** * An end date in ISO8601 format. All requests filter to this end date. Set to undefined to use the current date. * @type {String} */ this.endDate = undefined; /** * Gets or sets a flag for whether to display all features at all times, when tryToLoadObservationData is True. * This can help the UX if the server returns some features starting in 1990 and some starting in 1995, * so that the latter still appear (as grey points with no data) in 1990. * It works by adding artificial rows to the table for each feature at the start and end of the total date range, * if not already present. * Set to false (the default) to only show points when they have data (including invalid data). * Set to true to display points even at times that the server does not return them. */ this.showFeaturesAtAllTimes = false; /** * A flag to choose between representing the underlying data as a TableStructure or as GeoJson. * Geojson representation is not fully implemented - eg. currently only points are supported. * Set to true for geojson. This can allow for non-point data (once the code is written). * Set to false (the default) for table structure. This allows all the TableStyle options, and a better legend. */ this.representAsGeoJson = false; // Which columns of the tableStructure define a unique feature. // Use both because sometimes identifier is not unique (!). this._idColumnNames = ['identifier', 'id']; this._geoJsonItem = undefined; /** * Gets or sets the template XML string to POST to the SOS server to query for GetObservation. * If this property is undefined, * {@link SensorObservationServiceCatalogItem.defaultRequestTemplate} is used. * This is used as a Mustache template. See SensorObservationServiceRequestTemplate.xml for the default. * Be careful with newlines inside tags: Mustache can add an extra space in the front of them, * which causes the request to fail on the SOS server. Eg. * <wsa:Action> * http://www.opengis.net/... * </wsa:Action> * will render as <wsa:Action> http://www.opengis.net/...</wsa:Action> * The space before the "http" will cause the request to fail. * This property is observable. * @type {String} */ this.requestTemplate = undefined; knockout.track(this, ['_concepts']); overrideProperty(this, 'concepts', { get: function() { return this._concepts; } }); // See explanation in the comments for TableCatalogItem. overrideProperty(this, 'dataViewId', { get: function() { // We need an id that depends on the selected concepts. if (defined(this.procedures) && defined(this.observableProperties)) { var procedure = getObjectCorrespondingToSelectedConcept(this, 'procedures'); var observableProperty = getObjectCorrespondingToSelectedConcept(this, 'observableProperties'); return [(procedure && procedure.identifier) || '', (observableProperty && observableProperty.identifier) || ''].join('-'); } } }); knockout.defineProperty(this, 'activeConcepts', { get: function() { return this._concepts.map(function(parent) { return parent.items.filter(function(concept) { return concept.isActive; }); }); } }); knockout.getObservable(this, 'activeConcepts').subscribe(function() { // If we are in the middle of reverting concepts back to previous values, just ignore. if (this._revertingConcepts) { return; } // If we are in the middle of loading the features themselves, a change is fine and will happen with no further intervention. if (this._loadingFeatures) { return; } // If either of these names is not available, the user is probably in the middle of a change // (when for a brief moment either 0 or 2 items are selected). So ignore. var procedure = getObjectCorrespondingToSelectedConcept(this, 'procedures'); var observableProperty = getObjectCorrespondingToSelectedConcept(this, 'observableProperties'); if (!defined(procedure) || !defined(observableProperty)) { return; } // If we are loading data (other than the feature data), do not allow a change. if (this.isLoading) { revertConceptsToPrevious(this, this._loadingProcedureIdentifier, this._loadingObservablePropertyIdentifier); var error = new TerriaError({ sender: this, title: 'Data already loading', message: 'Your data is still loading. You will be able to change the display once it has loaded.' }); raiseErrorToUser(this.terria, error); } else { changedActiveItems(this); } }, this); }; SensorObservationServiceCatalogItem.defaultRequestTemplate = require('./SensorObservationServiceRequestTemplate.xml'); inherit(TableCatalogItem, SensorObservationServiceCatalogItem); defineProperties(SensorObservationServiceCatalogItem.prototype, { /** * Gets the type of data member represented by this instance. * @memberOf SensorObservationServiceCatalogItem.prototype * @type {String} */ type: { get: function() { return 'sos'; } }, /** * Gets a human-readable name for this type of data source, 'GPX'. * @memberOf SensorObservationServiceCatalogItem.prototype * @type {String} */ typeName: { get: function() { return 'SOS'; } }, /** * Gets the set of names of the properties to be serialized for this object for a share link. * @memberOf ImageryLayerCatalogItem.prototype * @type {String[]} */ propertiesForSharing: { get: function() { return SensorObservationServiceCatalogItem.defaultPropertiesForSharing; } }, /** * 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 lieral, * 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 SensorObservationServiceCatalogItem.prototype * @type {Object} */ serializers: { get: function() { return SensorObservationServiceCatalogItem.defaultSerializers; } }, /** * Gets the data source associated with this catalog item. Might be a TableDataSource or a GeoJsonDataSource. * @memberOf SensorObservationServiceCatalogItem.prototype * @type {DataSource} */ dataSource : { get : function() { if (defined(this._geoJsonItem)) { return this._geoJsonItem.dataSource; } else if (defined(this._dataSource)) { return this._dataSource; } } } }); /** * Gets or sets the default set of properties that are serialized when serializing a {@link CatalogItem}-derived for a * share link. * @type {String[]} */ SensorObservationServiceCatalogItem.defaultPropertiesForSharing = clone(TableCatalogItem.defaultPropertiesForSharing); SensorObservationServiceCatalogItem.defaultPropertiesForSharing.push('initialProcedureIndex'); SensorObservationServiceCatalogItem.defaultPropertiesForSharing.push('initialObservablePropertyIndex'); freezeObject(SensorObservationServiceCatalogItem.defaultPropertiesForSharing); SensorObservationServiceCatalogItem.defaultSerializers = clone(TableCatalogItem.defaultSerializers); SensorObservationServiceCatalogItem.defaultSerializers.activeConcepts = function() { // Don't serialize. }; freezeObject(SensorObservationServiceCatalogItem.defaultSerializers); // Just the items that would influence the load from the abs server or the file SensorObservationServiceCatalogItem.prototype._getValuesThatInfluenceLoad = function() { return [this.url]; }; SensorObservationServiceCatalogItem.prototype._load = function() { var that = this; if (!that.url) { return undefined; } that._loadingFeatures = true; that._concepts = buildConcepts(that); return loadFeaturesOfInterest(that).then(function() { that._loadingFeatures = false; return loadObservationData(that); }).otherwise(function(e) { throw e; }); }; function loadSoapBody(item, templateContext) { var postDataTemplate = defaultValue(item.requestTemplate, SensorObservationServiceCatalogItem.defaultRequestTemplate); const xml = Mustache.render(postDataTemplate, templateContext); return loadWithXhr({ url : proxyCatalogItemUrl(item, item.url, '0d'), responseType: 'document', method: 'POST', overrideMimeType: 'text/xml', data: xml, headers: {'Content-Type': 'application/soap+xml'} }).then(function(xml) { if (!defined(xml)) { return; } var json = xml2json(xml); if (json.Exception) { var errorMessage = 'The server reported an unknown error.'; if (json.Exception.ExceptionText) { errorMessage = 'The server reported an error:\n\n' + json.Exception.ExceptionText; } throw new TerriaError({ sender: item, title: item.name, message: errorMessage }); } if (!defined(json.Body)) { throw new TerriaError({ sender: item, title: item.name, message: 'The server responded with missing body.' }); } return json.Body; }); } /** * Return the Mustache template context "temporalFilters" for this item. * If a "defaultDuration" parameter (eg. 60d or 12h) exists on either * procedure or observableProperty, restrict to that duration from item.endDate. * @param {SensorObservationServiceCatalogItem} item This catalog item. * @param {Object} [procedure] An element from the item.procedures array. * @param {Object} [observableProperty] An element from the item.observableProperties array. * @return {Object[]} An array of {index, startDate, endDate}, or undefined. */ function getTemporalFiltersContext(item, procedure, observableProperty) { var defaultDuration = (procedure && procedure.defaultDuration) || (observableProperty && observableProperty.defaultDuration); // If the item has no endDate, use the current datetime (to nearest second). var endDateIso8601 = item.endDate || JulianDate.toIso8601(JulianDate.now(), 0); if (defined(defaultDuration)) { var startDateIso8601 = addDurationToIso8601(endDateIso8601, '-' + defaultDuration); // This is just a string-based comparison, so timezones could make it up to 1 day wrong. // That much error is fine here. if (startDateIso8601 < item.startDate) { startDateIso8601 = item.startDate; } return [{index: 1, startDate: startDateIso8601, endDate: endDateIso8601}]; } else { // If there is no procedure- or property-specific duration, use the item's start and end dates, if any. if (item.startDate) { return [{index: 1, startDate: item.startDate, endDate: endDateIso8601}]; } } } function getObjectCorrespondingToSelectedConcept(item, conceptIdAndItemKey) { if (item[conceptIdAndItemKey].length === 1) { return item[conceptIdAndItemKey][0]; } else { var parentConcept = item._concepts.filter(concept => concept.id === conceptIdAndItemKey)[0]; var activeConceptIndices = parentConcept.items.filter(concept => concept.isActive); if (activeConceptIndices.length === 1) { var identifier = activeConceptIndices[0].id; var matches = item[conceptIdAndItemKey].filter(element => element.identifier === identifier); return matches[0]; } } } function getConceptIndexOfIdentifier(item, conceptIdAndItemKey, identifier) { if (item[conceptIdAndItemKey].length === 1) { return 0; } else { var parentConcept = item._concepts.filter(concept => concept.id === conceptIdAndItemKey)[0]; return parentConcept.items.map(concept => concept.id).indexOf(identifier); } } /** * Returns a promise to a table structure of sensor observation data, given one/multiple featureOfInterest identifiers. * Uses the currently active concepts to determine the procedure and observedProperty filter. * A GetObservation request. * This is required by Chart.jsx for any non-csv format (which passes the chart's source url as the sole argument.) * @param {String|String[]} url The featureOfInterest identifier, or array thereof. * @return {Promise} A promise which resolves to a table structure. */ SensorObservationServiceCatalogItem.prototype.loadIntoTableStructure = function(featureOfInterestIdentifiers) { var item = this; if (!Array.isArray(featureOfInterestIdentifiers)) { featureOfInterestIdentifiers = [featureOfInterestIdentifiers]; } var requestNumber = 0; var promises = []; var procedure = getObjectCorrespondingToSelectedConcept(item, 'procedures'); var observableProperty = getObjectCorrespondingToSelectedConcept(item, 'observableProperties'); // If either of these names is not available, the user is probably in the middle of a change // (when for a brief moment either 0 or 2 items are selected). So ignore. if (!defined(procedure.identifier) || (!defined(observableProperty.identifier))) { return when(); } for (var startFeatureNumber = 0; startFeatureNumber < featureOfInterestIdentifiers.length; startFeatureNumber += this.requestSizeLimit) { var theseFeatureIdentifiers = featureOfInterestIdentifiers.slice(startFeatureNumber, startFeatureNumber + this.requestSizeLimit); var paramArray = convertObjectToNameValueArray({ procedure: procedure.identifier, observedProperty: observableProperty.identifier, featureOfInterest: theseFeatureIdentifiers // eg. 'http://bom.gov.au/waterdata/services/stations/425022' }); const templateContext = { action: 'GetObservation', actionClass: 'core', parameters: paramArray, temporalFilters: getTemporalFiltersContext(item, procedure, observableProperty) }; promises.push(loadSoapBody(item, templateContext)); requestNumber++; if (requestNumber >= this.requestNumberLimit) { break; } } // Could improve UX by showing features as they are returned. For now, wait until we have them all. return when.all(promises).then(function(bodies) { var dateValues = []; var valueValues = []; var featureValues = []; var procedureValues = []; var observedPropertyValues = []; bodies.forEach(function(body) { var observationData = body.GetObservationResponse && body.GetObservationResponse.observationData; if (defined(observationData)) { if (!Array.isArray(observationData)) { observationData = [observationData]; } var observations = observationData.map(o => o.OM_Observation); observations.forEach(observation => { if (!defined(observation)) { return; } var points = observation.result.MeasurementTimeseries.point; if (!defined(points)) { return; } if (!Array.isArray(points)) { points = [points]; } var measurements = points.map(point => point.MeasurementTVP); // TVP = Time value pairs, I think. // var procedureTitle = defined(observation.procedure) ? observation.procedure['xlink:title'] : 'value'; // var featureName = observation.featureOfInterest['xlink:title']; var featureIdentifier = observation.featureOfInterest['xlink:href']; dateValues = dateValues.concat(measurements.map(measurement => (typeof measurement.time === 'object' ? null : measurement.time) )); valueValues = valueValues.concat(measurements.map(measurement => (typeof measurement.value === 'object' ? null : parseFloat(measurement.value)) )); featureValues = featureValues.concat(measurements.map(measurement => featureIdentifier )); procedureValues = procedureValues.concat(measurements.map(measurement => procedure.identifier )); observedPropertyValues = observedPropertyValues.concat(measurements.map(measurement => observableProperty.identifier )); }); } }); var observationTableStructure = new TableStructure('observations'); var columnOptions = {tableStructure: observationTableStructure}; var timeColumn = new TableColumn('date', dateValues, columnOptions); var units = observableProperty.units || procedure.units; var valueTitle = observableProperty.title + ' ' + procedure.title + (defined(units) ? ' (' + units + ')' : ''); var valueColumn = new TableColumn(valueTitle, valueValues, columnOptions); valueColumn.id = 'value'; valueColumn.units = units; var featureColumn = new TableColumn('identifier', featureValues, columnOptions); // featureColumn.id must be 'identifier', used as an idColumn. var procedureColumn = new TableColumn(item.proceduresName, procedureValues, columnOptions); var observedPropertyColumn = new TableColumn(item.observablePropertiesName, observedPropertyValues, columnOptions); observationTableStructure.columns = [timeColumn, valueColumn, featureColumn, procedureColumn, observedPropertyColumn]; return observationTableStructure; }).otherwise(function(e) { // Improve the error reporting in the case that the error response is XML like this: // <ExceptionReport> // <Exception exceptionCode="ResponseExceedsSizeLimit"> // <ExceptionText>The search terms matched more than 250 timeseries in the datasource... if (!defined(e.message) && defined(e.response)) { var json = xml2json(e.response); throw new TerriaError({ sender: item, title: json.Exception && json.Exception.exceptionCode, message: json.Exception && json.Exception.ExceptionText }); } throw e; }); }; // It's OK to override TableCatalogItem's enable, disable, because for lat/lon tables, they don't do anything. SensorObservationServiceCatalogItem.prototype._enable = function() { if (defined(this._geoJsonItem)) { this._geoJsonItem._enable(); } }; SensorObservationServiceCatalogItem.prototype._disable = function() { if (defined(this._geoJsonItem)) { this._geoJsonItem._disable(); } }; // However show and hide need to become a combination of both the geojson and the lat/lon table catalog item versions. SensorObservationServiceCatalogItem.prototype._show = function() { if (defined(this._geoJsonItem)) { this._geoJsonItem._show(); } else 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); } }; SensorObservationServiceCatalogItem.prototype._hide = function() { if (defined(this._geoJsonItem)) { this._geoJsonItem._hide(); } else 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); } }; SensorObservationServiceCatalogItem.prototype.showOnSeparateMap = function(globeOrMap) { if (defined(this._geoJsonItem)) { return this._geoJsonItem.showOnSeparateMap(globeOrMap); } else { return TableCatalogItem.prototype.showOnSeparateMap.bind(this)(globeOrMap); } }; function loadFeaturesOfInterest(item) { // return querySos(item, { // request: 'GetFeatureOfInterest' // }).then(function(featuresResponse) { var paramArray = convertObjectToNameValueArray({ procedure: item.procedures.map(procedure => procedure.identifier), // eg. 'http://bom.gov.au/waterdata/services/tstypes/Pat7_C_B_1_YearlyMean', observedProperty: item.observableProperties.map(observable => observable.identifier) // eg. 'http://bom.gov.au/waterdata/services/parameters/Storage Level' }); const templateContext = { action: 'GetFeatureOfInterest', actionClass: 'foiRetrieval', parameters: paramArray, temporalFilters: getTemporalFiltersContext(item) }; return loadSoapBody(item, templateContext).then(function(body) { var featuresResponse = body.GetFeatureOfInterestResponse; // var locations = featuresResponse.featureMember.map(x=>x.MonitoringPoint.shape.Point.pos.text); if (!featuresResponse) { throw new TerriaError({ sender: item, title: item.name, message: 'There are no features matching your query.' }); } if (!defined(featuresResponse.featureMember)) { throw new TerriaError({ sender: item, title: item.name, message: 'The server responded with an unknown feature format.' }); } var featureMembers = featuresResponse.featureMember; if (!Array.isArray(featureMembers)) { featureMembers = [featureMembers]; } if (item.representAsGeoJson) { item._geoJsonItem = createGeoJsonItemFromFeatureMembers(item, featureMembers); return item._geoJsonItem.load().then(function() { item.rectangle = item._geoJsonItem.rectangle; return; }); } else { item._featureMapping = createMappingFromFeatureMembers(featureMembers); } }).otherwise(function(e) { throw e; }); } /** * Given the features already loaded into item._featureMap, this loads the observations according to the user-selected concepts, * and puts them into item._tableStructure. * If there are too many features, fall back to a tableStructure without the observation data. * @param {SensorObservationServiceCatalogItem} item This catalog item. * @return {Promise} A promise which, when it resolves, sets item._tableStructure. * @private */ function loadObservationData(item) { if (!item._featureMapping) { return; } var featuresOfInterest = Object.keys(item._featureMapping); // Are there too many features to load observations (or we've been asked not to try)? if (!item.tryToLoadObservationData || featuresOfInterest.length > item.requestSizeLimit * item.requestNumberLimit) { // MODE 1. Do not load observation data for the features. // Just show where the features are, and when the feature info panel is opened, then load the feature's observation data // (via the 'chart' column in _tableStructure, which generates a call to item.loadIntoTableStructure). var tableStructure = item._tableStructure; if (!defined(tableStructure)) { tableStructure = new TableStructure(item.name); } var columns = createColumnsFromMapping(item, tableStructure); tableStructure.columns = columns; if (!defined(item._tableStructure)) { item._tableStyle.dataVariable = null; // Turn off the legend and give all the points a single colour. item.initializeFromTableStructure(tableStructure); } else { item._tableStructure.columns = tableStructure.columns; } return when(); } // MODE 2. Create a big time-varying tableStructure with all the observations for all the features. // In this mode, the feature info panel shows a chart through as a standard time-series, like it would for any time-varying csv. return item.loadIntoTableStructure(featuresOfInterest).then(function(observationTableStructure) { if (!defined(observationTableStructure) || (observationTableStructure.columns[0].values.length === 0)) { throw new TerriaError({ sender: item, title: item.name, message: 'The Sensor Observation Service did not return any features matching your query.' }); } // Add the extra columns from the mapping into the table. var identifiers = observationTableStructure.getColumnWithName('identifier').values; var newColumns = createColumnsFromMapping(item, observationTableStructure, identifiers); observationTableStructure.activeTimeColumnNameIdOrIndex = undefined; observationTableStructure.columns = observationTableStructure.columns.concat(newColumns); observationTableStructure.idColumnNames = item._idColumnNames; if (item.showFeaturesAtAllTimes) { // Set finalEndJulianDate so that adding new null-valued feature rows doesn't mess with the final date calculations. // To do this, we need to set the active time column, so that finishJulianDates is calculated. observationTableStructure.setActiveTimeColumn(item.tableStyle.timeColumn); var finishDates = observationTableStructure.finishJulianDates.map(d => Number(JulianDate.toDate(d))); // I thought we'd need to unset the time column, because we're about to change the columns again, and there can be interactions // - but it works without unsetting it. // observationTableStructure.setActiveTimeColumn(undefined); observationTableStructure.finalEndJulianDate = JulianDate.fromDate(new Date(Math.max.apply(null, finishDates))); observationTableStructure.columns = observationTableStructure.getColumnsWithFeatureRowsAtStartAndEndDates('date', 'value'); } if (!defined(item._tableStructure)) { observationTableStructure.name = item.name; item.initializeFromTableStructure(observationTableStructure); } else { observationTableStructure.setActiveTimeColumn(item.tableStyle.timeColumn); // Moving this isActive statement earlier stops all points appearing on the map/globe. observationTableStructure.columns.filter(column => column.id === 'value')[0].isActive = true; item._tableStructure.columns = observationTableStructure.columns; // TODO: doesn't do anything. // Force the timeline (terria.clock) to update by toggling "isShown" (see CatalogItem's isShownChanged). if (item.isShown) { item.isShown = false; item.isShown = true; } // Changing the columns triggers a knockout change of the TableDataSource that uses this table. } }); } /** * Returns an array of procedure and/or observableProperty concepts, * and sets item._previousProcedureIdentifier and _previousObservablePropertyIdentifier. * @private */ function buildConcepts(item) { var concepts = []; if (!defined(item.procedures) || !defined(item.observableProperties)) { throw new DeveloperError('Both `procedures` and `observableProperties` arrays must be defined on the catalog item.'); } if (item.procedures.length > 1) { var concept = new DisplayVariablesConcept(item.proceduresName); concept.id = 'procedures'; // must match the key of item['procedures'] concept.requireSomeActive = true; concept.items = item.procedures.map((value, index) => { return new VariableConcept(value.title || value.identifier, { parent: concept, id: value.identifier, // used in the SOS request to identify the procedure. active: (index === item.initialProcedureIndex) }); }); concepts.push(concept); item._previousProcedureIdentifier = concept.items[item.initialProcedureIndex].id; item._loadingProcedureIdentifier = concept.items[item.initialProcedureIndex].id; } if (item.observableProperties.length > 1) { concept = new DisplayVariablesConcept(item.observablePropertiesName); concept.id = 'observableProperties'; concept.requireSomeActive = true; concept.items = item.observableProperties.map((value, index) => { return new VariableConcept(value.title || value.identifier, { parent: concept, id: value.identifier, // used in the SOS request to identify the procedure. active: (index === item.initialObservablePropertyIndex) }); }); concepts.push(concept); item._previousObservablePropertyIdentifier = concept.items[item.initialObservablePropertyIndex].id; item._loadingObservablePropertyIdentifier = concept.items[item.initialObservablePropertyIndex].id; } return concepts; } function getChartTagFromFeatureIdentifier(identifier, chartId) { // Including a chart id which depends on the frequency serves an important purpose: it means that something about the chart has changed, // which tells the FeatureInfoSection React component to re-render. // The feature's definitionChanged event triggers when the feature's properties change, but if this chart tag doesn't change, // React does not know to re-render the chart. if (defined(chartId)) { chartId = ' id="' + encodeURIComponent(chartId) + '"'; } else { chartId = ''; } return '<chart src="' + identifier + '" can-download="false"' + chartId + '></chart>'; } /** * Converts the featureMembers into a mapping from identifier to its lat/lon and other info. * @param {Object[]} featureMembers An array of feature members as returned by GetFeatureOfInterest in body.GetFeatureOfInterestResponse.featuresResponse.featureMember. * @return {Object} Keys = identifier, values = {lat, lon, name, id, identifier, type, chart}. * @private */ function createMappingFromFeatureMembers(featureMembers) { var mapping = {}; featureMembers.forEach(member => { var shape = member.MonitoringPoint.shape; if (defined(shape.Point)) { var posString = shape.Point.pos; if (defined(posString.split)) { // Sometimes shape.Point.pos is actually an object, eg. {srsName: "http://www.opengis.net/def/crs/EPSG/0/4326"} var coords = posString.split(' '); if (coords.length === 2) { var identifier = member.MonitoringPoint.identifier.toString(); mapping[identifier] = { lat: coords[0], lon: coords[1], name: member.MonitoringPoint.name, id: member.MonitoringPoint['gml:id'], identifier: identifier, type: member.MonitoringPoint.type && member.MonitoringPoint.type['xlink:href'] }; return mapping[identifier]; } } } else { throw new DeveloperError('Non-point feature not shown. You may want to implement `representAsGeoJson`. ' + JSON.stringify(shape)); } }); return mapping; } /** * Converts the featureMapping output by createMappingFromFeatureMembers into columns for a TableStructure. * @param {SensorObservationServiceCatalogItem} item This catalog item. * @param {TableStructure} [tableStructure] Used to set the columns' tableStructure (parent). If identifiers given, output columns line up with them. * @param {String[]} identifiers An array of identifier values from tableStructure. Defaults to all available identifiers. * @return {TableColumn[]} An array of columns to add to observationTableStructure. Only include 'identifier' and 'chart' columns if no identifiers provided. * @private */ function createColumnsFromMapping(item, tableStructure, identifiers) { var featureMapping = item._featureMapping; var addChartColumn = !defined(identifiers); if (!defined(identifiers)) { identifiers = Object.keys(featureMapping); } var rows = identifiers.map(identifier => featureMapping[identifier]); var columnOptions = {tableStructure: tableStructure}; var chartColumnOptions = {tableStructure: tableStructure, id: 'chart'}; // So the chart column can be referred to in the FeatureInfoTemplate as 'chart'. var result = [ new TableColumn('type', rows.map(row => row.type), columnOptions), new TableColumn('name', rows.map(row => row.name), columnOptions), new TableColumn('id', rows.map(row => row.id), columnOptions), new TableColumn('lat', rows.map(row => row.lat), columnOptions), new TableColumn('lon', rows.map(row => row.lon), columnOptions) ]; if (addChartColumn) { var procedure = getObjectCorrespondingToSelectedConcept(item, 'procedures'); var observableProperty = getObjectCorrespondingToSelectedConcept(item, 'observableProperties'); var chartName = procedure.title || observableProperty.title || 'chart'; var chartId = procedure.title + '_' + observableProperty.title; var charts = rows.map(row => getChartTagFromFeatureIdentifier(row.identifier, chartId)); result.push( new TableColumn('identifier', rows.map(row => row.identifier), columnOptions), new TableColumn(chartName, charts, chartColumnOptions) ); } return result; } function createGeoJsonItemFromFeatureMembers(item, featureMembers) { var geojson = { type: 'FeatureCollection', features: featureMembers.map(member => { var shape = member.MonitoringPoint.shape; var geometry; if (defined(shape.Point)) { var posString = shape.Point.pos; if (defined(posString.split)) { // Sometimes shape.Point.pos is actually an object, eg. {srsName: "http://www.opengis.net/def/crs/EPSG/0/4326"} var coords = posString.split(' '); if (coords.length === 2) { geometry = { type: 'Point', coordinates: [coords[1], coords[0]] }; } } } else { throw new DeveloperError('Feature shape type not implemented. ' + JSON.stringify(shape)); } return { type: 'Feature', geometry: geometry, properties: { name: member.MonitoringPoint.name, id: member.MonitoringPoint['gml:id'], identifier: member.MonitoringPoint.identifier.toString(), type: member.MonitoringPoint.type && member.MonitoringPoint.type['xlink:href'] } }; }).filter(geojson => defined(geojson.geometry)) }; var geoJsonItem = new GeoJsonCatalogItem(item.terria); geoJsonItem.data = featureDataToGeoJson(geojson); geoJsonItem.style = item.style; // For the future... return geoJsonItem; } /* Load the description of a sensor. In practice, it doesn't really contain anything useful. http://www.bom.gov.au/waterdata/services?service=SOS&version=2.0&request=DescribeSensor&procedureDescriptionFormat=http%3A%2F%2Fwww.opengis.net%2FsensorML%2F1.0.1&procedure=http%3A%2F%2Fbom.gov.au%2Fwaterdata%2Fservices%2Ftstypes%2FPat1_C_B_1 */ // function loadDescription(item) { // return querySos(item, { // request: 'DescribeSensor', // procedure: item.procedure, // procedureDescriptionFormat: 'http://www.opengis.net/sensorML/1.0.1' // }).then(function(sensorml) { // //var description = sensormljson.description.SensorDescription.data.SensorML.member; // console.log('Sensor description: ', sensorml); // }); // } /* Want to get more information about a location? new urijs('http://www.bom.gov.au/waterdata/services').setQuery({service:'SOS',version:'2.0',request:'GetFeatureOfInterest',featureOfInterest:'http://bom.gov.au/waterdata/services/stations/401229'}); http://www.bom.gov.au/waterdata/services?service=SOS&version=2.0&request=GetFeatureOfInterest&featureOfInterest=http%3A%2F%2Fbom.gov.au%2Fwaterdata%2Fservices%2Fstations%2F401229 Point location buried in featureMember -> MonitoringPoint -> shape -> Point Warning: some IDs don't have locations (ie http://bom.gov.au/waterdata/services/stations/system) */ function revertConceptsToPrevious(item, previousProcedureIdentifier, previousObservablePropertyIdentifier) { var parentConcept; item._revertingConcepts = true; // Use the flag above to signify that we do not want to trigger a reload. if (defined(previousProcedureIdentifier)) { parentConcept = item._concepts.filter(concept => concept.id === 'procedures')[0]; // Toggle the old value on again (unless it is already on). This auto-toggles-off the new value. var old = parentConcept && parentConcept.items.filter(concept => !concept.isActive && (concept.id === previousProcedureIdentifier))[0]; if (defined(old)) { old.toggleActive(); } } if (defined(previousObservablePropertyIdentifier)) { parentConcept = item._concepts.filter(concept => concept.id === 'observableProperties')[0]; old = parentConcept && parentConcept.items.filter(concept => !concept.isActive && (concept.id === previousObservablePropertyIdentifier))[0]; if (defined(old)) { old.toggleActive(); } } item._revertingConcepts = false; } function changedActiveItems(item) { // If either of these names is not available, the user is probably in the middle of a change // (when for a brief moment either 0 or 2 items are selected). So ignore. var procedure = getObjectCorrespondingToSelectedConcept(item, 'procedures'); var observableProperty = getObjectCorrespondingToSelectedConcept(item, 'observableProperties'); if (!defined(procedure) || !defined(observableProperty)) { return; } item.isLoading = true; item._loadingProcedureIdentifier = procedure.identifier; item._loadingObservablePropertyIdentifier = observableProperty.identifier; item._observationDataPromise = loadObservationData(item).then(function() { item.isLoading = false; // Save the current values of these concepts so we can fall back to them if there's an error moving to a new set. item._previousProcedureIdentifier = procedure.identifier; item._previousObservablePropertyIdentifier = observableProperty.identifier; // And save them for sharing. item.initialProcedureIndex = getConceptIndexOfIdentifier(item, 'procedures', procedure.identifier); item.initialObservablePropertyIndex = getConceptIndexOfIdentifier(item, 'observableProperties', observableProperty.identifier); }).otherwise(function(e) { revertConceptsToPrevious(item, item._previousProcedureIdentifier, item._previousObservablePropertyIdentifier); item.isLoading = false; raiseErrorToUser(item.terria, e); }); } /** * Converts parameters {x: 'y'} into an array of {name: 'x', value: 'y'} objects. * Converts {x: [1, 2, ...]} into multiple objects: * {name: 'x', value: 1}, {name: 'x', value: 2}, ... * @param {Object} parameters eg. {a: 3, b: [6, 8]} * @return {Object[]} eg. [{name: 'a', value: 3}, {name: 'b', value: 6}, {name: 'b', value: 8}] * @private */ function convertObjectToNameValueArray(parameters) { return Object.keys(parameters).reduce((result, key) => { var values = parameters[key]; if (!Array.isArray(values)) { values = [values]; } return result.concat(values.map(value => { return { name: key, value: value }; })); }, []); } var scratchJulianDate = new JulianDate(); /** * Adds a period to an iso8601-formatted date. * Periods must be (positive or negative) numbers followed by a letter: * s (seconds), h (hours), d (days), y (years). * To avoid confusion between minutes and months, do not use m. * @param {String} dateIso8601 The date in ISO8601 format. * @param {String} durationString The duration string, in the format described. * @return {String} A date string in ISO8601 format. * @private */ function addDurationToIso8601(dateIso8601, durationString) { if (!defined(dateIso8601) || dateIso8601.length < 3) {