diff --git a/platform/features/plot/src/PlotController.js b/platform/features/plot/src/PlotController.js index e4be264ea9..ea34e1b878 100644 --- a/platform/features/plot/src/PlotController.js +++ b/platform/features/plot/src/PlotController.js @@ -217,8 +217,8 @@ define( if (handle) { handle.unsubscribe(); handle = undefined; - conductor.off("timeOfInterest", changeTimeOfInterest); } + conductor.off("timeOfInterest", changeTimeOfInterest); } function requery() { diff --git a/platform/features/table/bundle.js b/platform/features/table/bundle.js index c034677a02..b5d67d626b 100644 --- a/platform/features/table/bundle.js +++ b/platform/features/table/bundle.js @@ -87,7 +87,7 @@ define([ { "key": "TelemetryTableController", "implementation": TelemetryTableController, - "depends": ["$scope", "openmct"] + "depends": ["$scope", "$timeout", "openmct"] }, { "key": "TableOptionsController", diff --git a/platform/features/table/res/templates/mct-table.html b/platform/features/table/res/templates/mct-table.html index 7e24be2c43..3a805bf4e0 100644 --- a/platform/features/table/res/templates/mct-table.html +++ b/platform/features/table/res/templates/mct-table.html @@ -5,7 +5,7 @@ Export -
\ No newline at end of file diff --git a/platform/features/table/src/TableConfiguration.js b/platform/features/table/src/TableConfiguration.js index f6d33d3269..a63ea569d6 100644 --- a/platform/features/table/src/TableConfiguration.js +++ b/platform/features/table/src/TableConfiguration.js @@ -53,6 +53,7 @@ define( var formatter = telemetryApi.getValueFormatter(metadatum); self.addColumn({ + metadata: metadatum, getTitle: function () { return metadatum.name; }, diff --git a/platform/features/table/src/controllers/MCTTableController.js b/platform/features/table/src/controllers/MCTTableController.js index 3ab1887c29..e9c18400ee 100644 --- a/platform/features/table/src/controllers/MCTTableController.js +++ b/platform/features/table/src/controllers/MCTTableController.js @@ -12,12 +12,12 @@ define( * @param element * @constructor */ - function MCTTableController($scope, $timeout, element, exportService, formatService, openmct) { + function MCTTableController($scope, $window, element, exportService, formatService, openmct) { var self = this; this.$scope = $scope; this.element = $(element[0]); - this.$timeout = $timeout; + this.$window = $window; this.maxDisplayRows = 50; this.scrollable = this.element.find('.l-view-section.scrolling').first(); @@ -27,15 +27,39 @@ define( this.conductor = openmct.conductor; this.toiFormatter = undefined; this.formatService = formatService; + this.callbacks = {}; //Bind all class functions to 'this' - Object.keys(MCTTableController.prototype).filter(function (key) { - return typeof MCTTableController.prototype[key] === 'function'; - }).forEach(function (key) { - this[key] = MCTTableController.prototype[key].bind(this); - }.bind(this)); + _.bindAll(this, [ + 'destroyConductorListeners', + 'changeTimeSystem', + 'scrollToBottom', + 'addRow', + 'removeRow', + 'onScroll', + 'firstVisible', + 'lastVisible', + 'setVisibleRows', + 'setHeaders', + 'setElementSizes', + 'binarySearch', + 'insertSorted', + 'sortComparator', + 'sortRows', + 'buildLargestRow', + 'resize', + 'filterAndSort', + 'setRows', + 'filterRows', + 'scrollToRow', + 'setTimeOfInterestRow', + 'changeTimeOfInterest', + 'changeBounds', + 'onRowClick', + 'digest' + ]); - this.scrollable.on('scroll', this.onScroll.bind(this)); + this.scrollable.on('scroll', this.onScroll); $scope.visibleRows = []; @@ -86,7 +110,7 @@ define( $scope.sortDirection = 'asc'; } self.setRows($scope.rows); - self.setTimeOfInterest(self.conductor.timeOfInterest()); + self.setTimeOfInterestRow(self.conductor.timeOfInterest()); }; /* @@ -108,7 +132,11 @@ define( * Populated from the default-sort attribute on MctTable * directive tag. */ - $scope.$watch('sortColumn', $scope.toggleSort); + $scope.$watch('defaultSort', function (newColumn, oldColumn) { + if (newColumn !== oldColumn) { + $scope.toggleSort(newColumn) + } + }); /* * Listen for resize events to trigger recalculation of table width @@ -125,7 +153,7 @@ define( this.destroyConductorListeners(); this.conductor.on('timeSystem', this.changeTimeSystem); - this.conductor.on('timeOfInterest', this.setTimeOfInterest); + this.conductor.on('timeOfInterest', this.changeTimeOfInterest); this.conductor.on('bounds', this.changeBounds); // If time system defined, set initially @@ -135,12 +163,22 @@ define( } }.bind(this)); - $scope.$on('$destroy', this.destroyConductorListeners); - } + console.log('constructed'); + + $scope.$on('$destroy', function() { + this.scrollable.off('scroll', this.onScroll); + this.destroyConductorListeners(); + + // In case for some reason this controller instance lingers around, + // destroy scope as it can be extremely large for large tables. + delete this.$scope; + + }.bind(this)); + }; MCTTableController.prototype.destroyConductorListeners = function () { this.conductor.off('timeSystem', this.changeTimeSystem); - this.conductor.off('timeOfInterest', this.setTimeOfInterest); + this.conductor.off('timeOfInterest', this.changeTimeOfInterest); this.conductor.off('bounds', this.changeBounds); }; @@ -160,7 +198,7 @@ define( //Use timeout to defer execution until next digest when any // pending UI changes have completed, eg. a new row in the table. if (this.$scope.autoScroll) { - this.$timeout(function () { + this.digest(function () { self.scrollable[0].scrollTop = self.scrollable[0].scrollHeight; }); } @@ -183,6 +221,12 @@ define( this.resize([this.$scope.sizingRow, row]) .then(this.setVisibleRows.bind(this)) .then(this.scrollToBottom.bind(this)); + + var toi = this.conductor.timeOfInterest(); + if (toi !== -1) { + this.setTimeOfInterestRow(toi); + } + } }; @@ -193,8 +237,8 @@ define( */ MCTTableController.prototype.removeRow = function (event, rowIndex) { var row = this.$scope.rows[rowIndex], - // Do a sequential search here. Only way of finding row is by - // object equality, so array is in effect unsorted. + // Do a sequential search here. Only way of finding row is by + // object equality, so array is in effect unsorted. indexInDisplayRows = this.$scope.displayRows.indexOf(row); if (indexInDisplayRows !== -1) { this.$scope.displayRows.splice(indexInDisplayRows, 1); @@ -522,6 +566,27 @@ define( return largestRow; }; + MCTTableController.prototype.digest = function (callback) { + var scope = this.$scope; + var callbacks = this.callbacks; + var requestAnimationFrame = this.$window.requestAnimationFrame; + + var promise = callbacks[callback]; + + if (!promise){ + promise = new Promise(function (resolve) { + requestAnimationFrame(function() { + scope.$digest(); + delete callbacks[callback]; + resolve(callback && callback()); + }); + }); + callbacks[callback] = promise; + } + + return promise; + }; + /** * Calculates the widest row in the table, and if necessary, resizes * the table accordingly @@ -533,7 +598,7 @@ define( */ MCTTableController.prototype.resize = function (rows) { this.$scope.sizingRow = this.buildLargestRow(rows); - return this.$timeout(this.setElementSizes.bind(this)); + return this.digest(this.setElementSizes); }; /** @@ -566,15 +631,15 @@ define( .then(this.setVisibleRows) //Timeout following setVisibleRows to allow digest to // perform DOM changes, otherwise scrollTo won't work. - .then(this.$timeout) + .then(this.digest) .then(function () { //If TOI specified, scroll to it var timeOfInterest = this.conductor.timeOfInterest(); if (timeOfInterest) { - this.setTimeOfInterest(timeOfInterest); + this.setTimeOfInterestRow(timeOfInterest); + this.scrollToRow(this.$scope.toiRowIndex); } }.bind(this)); - }; /** @@ -635,7 +700,7 @@ define( * Update rows with new data. If filtering is enabled, rows * will be sorted before display. */ - MCTTableController.prototype.setTimeOfInterest = function (newTOI) { + MCTTableController.prototype.setTimeOfInterestRow = function (newTOI) { var isSortedByTime = this.$scope.timeColumns && this.$scope.timeColumns.indexOf(this.$scope.sortColumn) !== -1; @@ -652,17 +717,22 @@ define( if (rowIndex > 0 && rowIndex < this.$scope.displayRows.length) { this.$scope.toiRowIndex = rowIndex; - this.scrollToRow(this.$scope.toiRowIndex); } } }; + MCTTableController.prototype.changeTimeOfInterest = function (newTOI) { + this.setTimeOfInterestRow(newTOI); + this.scrollToRow(this.$scope.toiRowIndex); + }; + /** * On zoom, pan, etc. reset TOI * @param bounds */ MCTTableController.prototype.changeBounds = function (bounds) { - this.setTimeOfInterest(this.conductor.timeOfInterest()); + this.setTimeOfInterestRow(this.conductor.timeOfInterest()); + this.scrollToRow(this.$scope.toiRowIndex); }; /** diff --git a/platform/features/table/src/controllers/TelemetryTableController.js b/platform/features/table/src/controllers/TelemetryTableController.js index 8eea6887dc..5aa89e8f1e 100644 --- a/platform/features/table/src/controllers/TelemetryTableController.js +++ b/platform/features/table/src/controllers/TelemetryTableController.js @@ -42,43 +42,47 @@ define( */ function TelemetryTableController( $scope, + $timeout, openmct ) { - var self = this; - this.$scope = $scope; + this.$timeout = $timeout; + this.openmct = openmct; + this.batchSize = 1000; + + /* + * Initialization block + */ this.columns = {}; //Range and Domain columns - this.handle = undefined; + this.deregisterListeners = []; + this.subscriptions = []; + this.timeColumns = []; + $scope.rows = []; this.table = new TableConfiguration($scope.domainObject, openmct); - this.changeListeners = []; - this.conductor = openmct.conductor; - this.openmct = openmct; - this.newObject = objectUtils.toNewFormat($scope.domainObject.getModel(), $scope.domainObject.getId()); + this.lastBounds = this.openmct.conductor.bounds(); + this.requestTime = 0; - $scope.rows = []; + /* + * Create a new format object from legacy object, and replace it + * when it changes + */ + this.newObject = objectUtils.toNewFormat($scope.domainObject.getModel(), + $scope.domainObject.getId()); - // Subscribe to telemetry when a domain object becomes available - this.$scope.$watch('domainObject', function () { - self.subscribe(); - self.registerChangeListeners(); - }); - this.mutationListener = openmct.objects.observe(this.newObject, "*", function (domainObject){ - self.newObject = domainObject; - }); + _.bindAll(this, [ + 'destroy', + 'sortByTimeSystem', + 'loadColumns', + 'getHistoricalData', + 'subscribeToNewData', + 'changeBounds' + ]); - this.destroy = this.destroy.bind(this); + this.getData(); + this.registerChangeListeners(); - // Unsubscribe when the plot is destroyed this.$scope.$on("$destroy", this.destroy); - this.timeColumns = []; - - - this.sortByTimeSystem = this.sortByTimeSystem.bind(this); - this.conductor.on('timeSystem', this.sortByTimeSystem); - this.conductor.off('timeSystem', this.sortByTimeSystem); - - this.subscriptions = []; } /** @@ -91,133 +95,254 @@ define( scope.defaultSort = undefined; if (timeSystem) { this.table.columns.forEach(function (column) { - if (column.domainMetadata && column.domainMetadata.key === timeSystem.metadata.key) { + if (column.metadata.key === timeSystem.metadata.key) { scope.defaultSort = column.getTitle(); } }); + this.$scope.rows = _.sortBy(this.$scope.rows, function (row) { + return row[this.$scope.defaultSort]; + }); } }; - TelemetryTableController.prototype.unregisterChangeListeners = function () { - this.changeListeners.forEach(function (listener) { - return listener && listener(); - }); - this.changeListeners = []; - }; - /** - * Defer registration of change listeners until domain object is - * available in order to avoid race conditions + * Attach listeners to domain object to respond to changes due to + * composition, etc. * @private */ TelemetryTableController.prototype.registerChangeListeners = function () { - var self = this; - this.unregisterChangeListeners(); + this.deregisterListeners.forEach(function (deregister){ + deregister(); + }); + this.deregisterListeners = []; - // When composition changes, re-subscribe to the various - // telemetry subscriptions - this.changeListeners.push(this.$scope.$watchCollection( - 'domainObject.getModel().composition', - function (newVal, oldVal) { - if (newVal !== oldVal) { - self.subscribe(); - } - }) + this.deregisterListeners.push( + this.openmct.objects.observe(this.newObject, "*", + function (domainObject){ + this.newObject = domainObject; + this.getData(); + }.bind(this) + ) ); + this.openmct.conductor.on('timeSystem', this.sortByTimeSystem); + this.openmct.conductor.on('bounds', this.changeBounds); + }; + + TelemetryTableController.prototype.tick = function (bounds) { + // Can't do ticking until we change how data is handled + // Pass raw values to table, with format function + + /*if (this.$scope.defaultSort) { + this.$scope.rows.filter(function (row){ + return row[] + }) + }*/ + }; + + TelemetryTableController.prototype.changeBounds = function (bounds) { + var follow = this.openmct.conductor.follow(); + var isTick = follow && + bounds.start !== this.lastBounds.start && + bounds.end !== this.lastBounds.end; + var isDeltaChange = follow && + !isTick && + (bounds.start !== this.lastBounds.start || + bounds.end !== this.lastBounds.end); + + if (isTick){ + // Treat it as a realtime tick + // Drop old data that falls outside of bounds + this.tick(bounds); + } else if (isDeltaChange){ + // No idea... + // Historical query for bounds, then tick on + this.getData(); + } else { + // Is fixed bounds change + this.getData(); + } + this.lastBounds = bounds; }; /** * Release the current subscription (called when scope is destroyed) */ TelemetryTableController.prototype.destroy = function () { + + this.openmct.conductor.off('timeSystem', this.sortByTimeSystem); + this.openmct.conductor.off('bounds', this.changeBounds); + this.subscriptions.forEach(function (subscription) { - subscription() + subscription(); }); - this.mutationListener(); + this.deregisterListeners.forEach(function (deregister){ + deregister(); + }); + this.subscriptions = []; + this.deregisterListeners = []; + + if (this.timeoutHandle) { + this.$timeout.cancel(this.timeoutHandle); + } + + // In case controller instance lingers around (currently there is a + // temporary memory leak with PlotController), clean up scope as it + // can be extremely large. + this.$scope = null; + this.table = null; }; + /** + * @private + * @param objects + * @returns {*} + */ + TelemetryTableController.prototype.loadColumns = function (objects) { + var telemetryApi = this.openmct.telemetry; + + if (objects.length > 0) { + var metadatas = objects.map(telemetryApi.getMetadata.bind(telemetryApi)); + var allColumns = telemetryApi.commonValuesForHints(metadatas, []); + + this.table.populateColumns(allColumns); + + this.timeColumns = telemetryApi.commonValuesForHints(metadatas, ['x']).map(function (metadatum) { + return metadatum.name; + }); + + this.filterColumns(); + + var timeSystem = this.openmct.conductor.timeSystem(); + if (timeSystem) { + this.sortByTimeSystem(timeSystem); + } + } + return objects; + }; /** - Create a new subscription. This can be overridden by children to - change default behaviour (which is to retrieve historical telemetry - only). + * @private + * @param objects The domain objects to request telemetry for + * @returns {*|{configFile}|app|boolean|Route|Object} */ - TelemetryTableController.prototype.subscribe = function () { - var self = this; + TelemetryTableController.prototype.getHistoricalData = function (objects) { + var openmct = this.openmct; + var bounds = openmct.conductor.bounds(); + var scope = this.$scope; + var processedObjects = 0; + var requestTime = this.lastRequestTime = Date.now(); + + return new Promise(function (resolve, reject){ + console.log('Created promise'); + function finishProcessing(tableRows){ + scope.rows = tableRows; + scope.loading = false; + console.log('Resolved promise'); + resolve(tableRows); + } + + function processData(historicalData, index, rowData, limitEvaluator){ + console.log("Processing batch"); + if (index >= historicalData.length) { + processedObjects++; + + if (processedObjects === objects.length) { + finishProcessing(rowData); + } + } else { + rowData = rowData.concat(historicalData.slice(index, index + this.batchSize) + .map(this.table.getRowValues.bind(this.table, limitEvaluator))); + this.timeoutHandle = this.$timeout(processData.bind( + this, + historicalData, + index + this.batchSize, + rowData, + limitEvaluator + )); + } + } + + function makeTableRows(object, historicalData) { + // Only process one request at a time + if (requestTime === this.lastRequestTime) { + console.log('Processing request'); + var limitEvaluator = openmct.telemetry.limitEvaluator(object); + processData.call(this, historicalData, 0, [], limitEvaluator); + } else { + console.log('Ignoring returned data because of staleness'); + resolve([]); + } + } + + function requestData (object) { + return openmct.telemetry.request(object, { + start: bounds.start, + end: bounds.end + }).then(makeTableRows.bind(this, object)) + .catch(reject); + } + this.$timeout.cancel(this.timeoutHandle); + + if (objects.length > 0){ + objects.forEach(requestData.bind(this)); + } else { + scope.loading = false; + console.log('Resolved promise'); + resolve([]); + } + }.bind(this)); + }; + + /** + * @private + * @param objects + * @returns {*} + */ + TelemetryTableController.prototype.subscribeToNewData = function (objects) { + var telemetryApi = this.openmct.telemetry; + //Set table max length to avoid unbounded growth. + var maxRows = 100000; + + this.subscriptions.forEach(function (subscription) { + subscription(); + }); + this.subscriptions = []; + + function newData(domainObject, datum) { + this.$scope.rows.push(this.table.getRowValues( + telemetryApi.limitEvaluator(domainObject), datum)); + + //Inform table that a new row has been added + if (this.$scope.rows.length > maxRows) { + this.$scope.$broadcast('remove:row', 0); + this.$scope.rows.shift(); + } + + this.$scope.$broadcast('add:row', + this.$scope.rows.length - 1); + + } + + objects.forEach(function (object){ + this.subscriptions.push( + telemetryApi.subscribe(object, newData.bind(this, object), {})); + console.log('subscribed'); + }.bind(this)); + + return objects; + }; + + TelemetryTableController.prototype.getData = function () { var telemetryApi = this.openmct.telemetry; var compositionApi = this.openmct.composition; - var subscriptions = this.subscriptions; - var tableConfiguration = this.table; var scope = this.$scope; - var maxRows = 100000; - var conductor = this.conductor; var newObject = this.newObject; this.$scope.loading = true; - function makeTableRows(object, historicalData){ - var limitEvaluator = telemetryApi.limitEvaluator(object); - return historicalData.map(tableConfiguration.getRowValues.bind(tableConfiguration, limitEvaluator)); - } - - function requestData(objects) { - var bounds = conductor.bounds(); - - return Promise.all( - objects.map(function (object) { - return telemetryApi.request(object, { - start: bounds.start, - end: bounds.end - }).then( - makeTableRows.bind(this, object) - ); - }) - ); - } - - function addHistoricalData(historicalData){ - scope.rows = Array.prototype.concat.apply([], historicalData); - scope.loading = false; - } - - function newData(domainObject, datum) { - scope.rows.push(tableConfiguration.getRowValues(datum, telemetryApi.limitEvaluator(domainObject))); - - //Inform table that a new row has been added - if (scope.rows.length > maxRows) { - scope.$broadcast('remove:row', 0); - scope.rows.shift(); - } - - scope.$broadcast('add:row', - scope.rows.length - 1); - - } - - function subscribe(objects) { - objects.forEach(function (object){ - subscriptions.push(telemetryApi.subscribe(object, newData.bind(this, object), {})); - }); - return objects; - } - function error(e) { - throw e; - } - - function loadColumns(objects) { - var metadatas = objects.map(telemetryApi.getMetadata.bind(telemetryApi)); - var allColumns = telemetryApi.commonValuesForHints(metadatas, []); - - tableConfiguration.populateColumns(allColumns); - - this.timeColumns = telemetryApi.commonValuesForHints(metadatas, ['x']).map(function (metadatum){ - return metadatum.name; - }); - - self.filterColumns(); - - return Promise.resolve(objects); + scope.loading = false; + console.error(e); } function filterForTelemetry(objects){ @@ -248,18 +373,10 @@ define( getDomainObjects() .then(filterForTelemetry) + .then(this.loadColumns) + //.then(this.subscribeToNewData) + .then(this.getHistoricalData) .catch(error) - .then(function (objects){ - if (objects.length > 0){ - return loadColumns(objects) - .then(subscribe) - .then(requestData) - .then(addHistoricalData) - .catch(error); - } else { - scope.loading = false; - } - }) }; /** diff --git a/platform/features/table/src/directives/MCTTable.js b/platform/features/table/src/directives/MCTTable.js index dad23c2eb5..b240fa7f43 100644 --- a/platform/features/table/src/directives/MCTTable.js +++ b/platform/features/table/src/directives/MCTTable.js @@ -77,13 +77,13 @@ define( * * @constructor */ - function MCTTable($timeout) { + function MCTTable() { return { restrict: "E", template: TableTemplate, controller: [ '$scope', - '$timeout', + '$window', '$element', 'exportService', 'formatService', @@ -104,7 +104,7 @@ define( timeColumns: "=?", // Indicate a column to sort on. Allows control of sort // via configuration (eg. for default sort column). - sortColumn: "=?" + defaultSort: "=?" } }; } diff --git a/src/api/telemetry/TelemetryValueFormatter.js b/src/api/telemetry/TelemetryValueFormatter.js index 801aee1176..a5e8cb8720 100644 --- a/src/api/telemetry/TelemetryValueFormatter.js +++ b/src/api/telemetry/TelemetryValueFormatter.js @@ -28,6 +28,18 @@ define([ // TODO: needs reference to formatService; function TelemetryValueFormatter(valueMetadata, formatService) { + var numberFormatter = { + parse: function (x) { + return Number(x); + }, + format: function (x) { + return x; + }, + validate: function (x) { + return true; + } + }; + this.valueMetadata = valueMetadata; this.parseCache = new WeakMap(); this.formatCache = new WeakMap(); @@ -36,17 +48,7 @@ define([ .getFormat(valueMetadata.format, valueMetadata); } catch (e) { // TODO: Better formatting - this.formatter = { - parse: function (x) { - return Number(x); - }, - format: function (x) { - return x; - }, - validate: function (x) { - return true; - } - }; + this.formatter = numberFormatter; } if (valueMetadata.type === 'enum') { |