From 218ef16160f6fb6d1fb0f08d7a8d73bc9036a117 Mon Sep 17 00:00:00 2001 From: Preston Crowe Date: Mon, 26 Jun 2017 10:31:39 -0700 Subject: [PATCH 1/2] [Imagery] Implemented historical view for imagery Implemented auto-scrolling historical imagery view in ImageryController. Imagery domain objects now request historical data on each manual bounds change. Added new specs for ensuring that historical data is requested on bounds change and duplicate bounds / datum are ignored. --- .../general/res/sass/features/_imagery.scss | 4 +- .../themes/espresso/res/sass/_constants.scss | 3 + .../themes/snow/res/sass/_constants.scss | 3 + platform/features/imagery/bundle.js | 14 ++ .../res/templates/imageryTimeline.html | 8 + .../src/controllers/ImageryController.js | 150 +++++++++++++++--- .../imagery/src/policies/ImageryViewPolicy.js | 2 +- .../test/controllers/ImageryControllerSpec.js | 61 ++++++- 8 files changed, 208 insertions(+), 37 deletions(-) create mode 100644 platform/features/imagery/res/templates/imageryTimeline.html diff --git a/platform/commonUI/general/res/sass/features/_imagery.scss b/platform/commonUI/general/res/sass/features/_imagery.scss index 49fcc9c98c..e3d1b58e49 100644 --- a/platform/commonUI/general/res/sass/features/_imagery.scss +++ b/platform/commonUI/general/res/sass/features/_imagery.scss @@ -83,12 +83,10 @@ .l-image-thumbs-wrapper { //@include test(green); - direction: rtl; overflow-x: auto; overflow-y: hidden; padding-bottom: $interiorMargin; white-space: nowrap; - z-index: 70; } .l-image-thumb-item { @@ -114,7 +112,7 @@ width: $imageThumbsD + $imageThumbPad*2; white-space: normal; &:hover { - background: rgba(#fff, 0.2); + background: $colorThumbHoverBg; .l-date, .l-time { color: #fff; diff --git a/platform/commonUI/themes/espresso/res/sass/_constants.scss b/platform/commonUI/themes/espresso/res/sass/_constants.scss index 1a269a22c7..9d1886c13a 100644 --- a/platform/commonUI/themes/espresso/res/sass/_constants.scss +++ b/platform/commonUI/themes/espresso/res/sass/_constants.scss @@ -191,6 +191,9 @@ $colorItemTreeVCHover: pullForward($colorItemTreeVC, 20%); $colorItemTreeSelectedVC: $colorItemTreeVC; $shdwItemTreeIcon: 0.6; +// Images +$colorThumbHoverBg: $colorItemTreeHoverBg; + // Scrollbar $scrollbarTrackSize: 10px; $scrollbarTrackShdw: rgba(#000, 0.7) 0 1px 5px; diff --git a/platform/commonUI/themes/snow/res/sass/_constants.scss b/platform/commonUI/themes/snow/res/sass/_constants.scss index 99e09048a3..4501228e08 100644 --- a/platform/commonUI/themes/snow/res/sass/_constants.scss +++ b/platform/commonUI/themes/snow/res/sass/_constants.scss @@ -191,6 +191,9 @@ $colorItemTreeVCHover: $colorKey; $colorItemTreeSelectedVC: $colorBodyBg; $shdwItemTreeIcon: none; +// Images +$colorThumbHoverBg: $colorItemTreeHoverBg; + // Scrollbar $scrollbarTrackSize: 10px; $scrollbarTrackShdw: rgba(#000, 0.2) 0 1px 2px; diff --git a/platform/features/imagery/bundle.js b/platform/features/imagery/bundle.js index 85aba508ff..c1ff369b5a 100644 --- a/platform/features/imagery/bundle.js +++ b/platform/features/imagery/bundle.js @@ -25,12 +25,14 @@ define([ "./src/controllers/ImageryController", "./src/directives/MCTBackgroundImage", "text!./res/templates/imagery.html", + "text!./res/templates/imageryTimeline.html", 'legacyRegistry' ], function ( ImageryViewPolicy, ImageryController, MCTBackgroundImage, imageryTemplate, + imageryTimelineTemplate, legacyRegistry ) { @@ -48,6 +50,17 @@ define([ "telemetry" ], "editable": false + }, + { + "name": "Historical Imagery", + "key": "historical-imagery", + "cssClass": "icon-image", + "template": imageryTimelineTemplate, + "priority": "preferred", + "needs": [ + "telemetry" + ], + "editable": false } ], "policies": [ @@ -65,6 +78,7 @@ define([ "implementation": ImageryController, "depends": [ "$scope", + "$window", "openmct" ] } diff --git a/platform/features/imagery/res/templates/imageryTimeline.html b/platform/features/imagery/res/templates/imageryTimeline.html new file mode 100644 index 0000000000..a0a2723d86 --- /dev/null +++ b/platform/features/imagery/res/templates/imageryTimeline.html @@ -0,0 +1,8 @@ + +
+
+ +
{{imagery.getTime(image)}}
+
+
diff --git a/platform/features/imagery/src/controllers/ImageryController.js b/platform/features/imagery/src/controllers/ImageryController.js index f5456f2f97..473eb9047c 100644 --- a/platform/features/imagery/src/controllers/ImageryController.js +++ b/platform/features/imagery/src/controllers/ImageryController.js @@ -24,9 +24,13 @@ * This bundle implements views of image telemetry. * @namespace platform/features/imagery */ + define( - ['moment'], - function (moment) { + [ + 'zepto', + 'lodash' + ], + function ($, _) { /** * Controller for the "Imagery" view of a domain object which @@ -34,14 +38,22 @@ define( * @constructor * @memberof platform/features/imagery */ - function ImageryController($scope, openmct) { + + function ImageryController($scope, $window, openmct) { this.$scope = $scope; + this.$window = $window; this.openmct = openmct; this.date = ""; this.time = ""; this.zone = ""; this.imageUrl = ""; + this.requestCount = 0; + this.lastBound = undefined; + this.autoScroll = false; + this.scrollable = + $(document.getElementsByClassName('l-image-thumbs-wrapper')[0]); + this.$scope.imageHistory = []; this.$scope.filters = { brightness: 100, contrast: 100 @@ -50,12 +62,17 @@ define( this.subscribe = this.subscribe.bind(this); this.stopListening = this.stopListening.bind(this); this.updateValues = this.updateValues.bind(this); + this.updateHistory = this.updateHistory.bind(this); + this.onBoundsChange = this.onBoundsChange.bind(this); + this.onScroll = this.onScroll.bind(this); // Subscribe to telemetry when a domain object becomes available this.subscribe(this.$scope.domainObject); // Unsubscribe when the plot is destroyed this.$scope.$on("$destroy", this.stopListening); + this.openmct.time.on('bounds', this.onBoundsChange); + this.scrollable.on('scroll', this.onScroll); } ImageryController.prototype.subscribe = function (domainObject) { @@ -75,50 +92,133 @@ define( .telemetry .getValueFormatter(metadata.valuesForHints(['image'])[0]); this.unsubscribe = this.openmct.telemetry - .subscribe(this.domainObject, this.updateValues); - this.openmct.telemetry - .request(this.domainObject, { - strategy: 'latest', - size: 1 - }) - .then(function (values) { - this.updateValues(values[0]); + .subscribe(this.domainObject, function (datum) { + this.updateHistory(datum); + this.updateValues(datum); }.bind(this)); + this.requestLad(false); + this.requestHistory(this.openmct.time.bounds()); + }.bind(this)); + }; + + ImageryController.prototype.requestHistory = function (bounds) { + this.requestCount++; + this.$scope.imageHistory = []; + var requestId = this.requestCount; + this.openmct.telemetry + .request(this.domainObject, bounds) + .then(function (values) { + if (this.requestCount > requestId) { + return Promise.reject('Stale request'); + } + values.forEach(function (datum) { + this.updateHistory(datum); + }.bind(this)); + this.requestLad(true); + }.bind(this)); + }; + + // Optional addToHistory argument allows for two use cases: + // updating url and timestamp only for standard imagery view, + // i.e to populate the view before history is requested OR + // appending to the running imagery history + ImageryController.prototype.requestLad = function (addToHistory) { + this.openmct.telemetry + .request(this.domainObject, { + strategy: 'latest', + size: 1 + }) + .then(function (values) { + this.updateValues(values[0]); + if (addToHistory !== false) { + this.updateHistory(values[0]); + } }.bind(this)); }; ImageryController.prototype.stopListening = function () { + this.openmct.time.off('bounds', this.onBoundsChange); + this.scrollable.off('scroll', this.onScroll); if (this.unsubscribe) { this.unsubscribe(); delete this.unsubscribe; } }; + // Query for new historical data on manual bound change + ImageryController.prototype.onBoundsChange = function (newBounds, tick) { + if (this.domainObject && !tick && + !_.isEqual(this.lastBound, newBounds)) { + this.lastBound = newBounds; + this.requestHistory(newBounds); + } + }; + // Update displayable values to reflect latest image telemetry ImageryController.prototype.updateValues = function (datum) { if (this.isPaused) { this.nextDatum = datum; return; } + this.time = this.timeFormat.format(datum); this.imageUrl = this.imageFormat.format(datum); + + }; + + // Update displayable values and append datum to running history + ImageryController.prototype.updateHistory = function (datum) { + if (this.$scope.imageHistory.length === 0 || + !_.isEqual(this.$scope.imageHistory.slice(-1)[0], datum)) { + + var index = _.sortedIndex(this.$scope.imageHistory, datum, 'utc'); + this.$scope.imageHistory.splice(index, 0, datum); + return true; + } + + return false; + }; + + ImageryController.prototype.onScroll = function (event) { + this.$window.requestAnimationFrame(function () { + if (this.scrollable[0].scrollLeft < + (this.scrollable[0].scrollWidth - this.scrollable[0].clientWidth) - 20) { + this.autoScroll = false; + } else { + this.autoScroll = true; + } + }.bind(this)); + }; + + ImageryController.prototype.scrollToRight = function () { + if (this.autoScroll) { + this.scrollable[0].scrollLeft = this.scrollable[0].scrollWidth; + } }; /** * Get the time portion (hours, minutes, seconds) of the - * timestamp associated with the incoming image telemetry. + * timestamp associated with the incoming image telemetry + * if no parameter is given, or of a provided datum. + * @param {object} [datum] target telemetry datum * @returns {string} the time */ - ImageryController.prototype.getTime = function () { - return this.time; + ImageryController.prototype.getTime = function (datum) { + return datum ? + this.timeFormat.format(datum) : + this.time; }; /** - * Get the URL of the image telemetry to display. + * Get the URL of the most recent image telemetry if no + * parameter is given, or of a provided datum. + * @param {object} [datum] target telemetry datum * @returns {string} URL for telemetry image */ - ImageryController.prototype.getImageUrl = function () { - return this.imageUrl; + ImageryController.prototype.getImageUrl = function (datum) { + return datum ? + this.imageFormat.format(datum) : + this.imageUrl; }; /** @@ -128,15 +228,15 @@ define( * @returns {boolean} the current state */ ImageryController.prototype.paused = function (state) { - if (arguments.length > 0 && state !== this.isPaused) { - this.isPaused = state; - if (this.nextDatum) { - this.updateValues(this.nextDatum); - delete this.nextDatum; + if (arguments.length > 0 && state !== this.isPaused) { + this.isPaused = state; + if (this.nextDatum) { + this.updateValues(this.nextDatum); + delete this.nextDatum; + } } - } - return this.isPaused; - }; + return this.isPaused; + }; return ImageryController; } diff --git a/platform/features/imagery/src/policies/ImageryViewPolicy.js b/platform/features/imagery/src/policies/ImageryViewPolicy.js index 4811f1e55a..f23f745a2b 100644 --- a/platform/features/imagery/src/policies/ImageryViewPolicy.js +++ b/platform/features/imagery/src/policies/ImageryViewPolicy.js @@ -47,7 +47,7 @@ define([ }; ImageryViewPolicy.prototype.allow = function (view, domainObject) { - if (view.key === 'imagery') { + if (view.key === 'imagery' || view.key === 'historical-imagery') { return this.hasImageTelemetry(domainObject); } diff --git a/platform/features/imagery/test/controllers/ImageryControllerSpec.js b/platform/features/imagery/test/controllers/ImageryControllerSpec.js index c5ff9929d4..e9a708a7fc 100644 --- a/platform/features/imagery/test/controllers/ImageryControllerSpec.js +++ b/platform/features/imagery/test/controllers/ImageryControllerSpec.js @@ -33,7 +33,8 @@ define( metadata, prefix, controller, - hasLoaded; + hasLoaded, + mockWindow; beforeEach(function () { $scope = jasmine.createSpyObj('$scope', ['$on', '$watch']); @@ -42,14 +43,15 @@ define( ['getId'] ); newDomainObject = { name: 'foo' }; - oldDomainObject.getId.andReturn('testID'); openmct = { objects: jasmine.createSpyObj('objectAPI', [ 'get' ]), time: jasmine.createSpyObj('timeAPI', [ - 'timeSystem' + 'timeSystem', + 'on', + 'off' ]), telemetry: jasmine.createSpyObj('telemetryAPI', [ 'subscribe', @@ -92,13 +94,19 @@ define( }); metadata.value.andReturn("timestamp"); metadata.valuesForHints.andReturn(["value"]); + mockWindow = jasmine.createSpyObj('$window', ['requestAnimationFrame']); + mockWindow.requestAnimationFrame.andCallFake(function (f) { + return f(); + }); - controller = new ImageryController($scope, openmct); - + controller = new ImageryController($scope, mockWindow, openmct); }); describe("when loaded", function () { - var callback; + var callback, + boundsListener; + var mockBounds = {start: 1434600000000, end: 1434600500000}; + beforeEach(function () { waitsFor(function () { return hasLoaded; @@ -106,12 +114,16 @@ define( runs(function () { + openmct.time.on.calls.forEach(function (call) { + if (call.args[0] === "bounds") { + boundsListener = call.args[1]; + } + }); callback = openmct.telemetry.subscribe.mostRecentCall.args[1]; }); }); - it("uses LAD telemetry", function () { expect(openmct.telemetry.request).toHaveBeenCalledWith( newDomainObject, @@ -165,7 +177,14 @@ define( ); }); - it("unsubscribes when scope is destroyed", function () { + it("requests telemetry", function () { + expect(openmct.telemetry.request).toHaveBeenCalledWith( + newDomainObject, + jasmine.any(Object) + ); + }); + + it("unsubscribes and unlistens when scope is destroyed", function () { expect(unsubscribe).not.toHaveBeenCalled(); $scope.$on.calls.forEach(function (call) { @@ -174,6 +193,32 @@ define( } }); expect(unsubscribe).toHaveBeenCalled(); + expect(openmct.time.off) + .toHaveBeenCalledWith('bounds', jasmine.any(Function)); + }); + + it("listens for bounds event and responds to tick and manual change", function () { + expect(openmct.time.on).toHaveBeenCalled(); + openmct.telemetry.request.reset(); + boundsListener(mockBounds, true); + expect(openmct.telemetry.request).not.toHaveBeenCalled(); + boundsListener(mockBounds, false); + expect(openmct.telemetry.request).toHaveBeenCalledWith(newDomainObject, mockBounds); + }); + + it("recognizes duplicate bounds", function () { + openmct.telemetry.request.reset(); + boundsListener(mockBounds, false); + boundsListener(mockBounds, false); + boundsListener(mockBounds, false); + expect(openmct.telemetry.request.calls.length).toBe(1); + }); + + it ("doesnt append duplicate datum", function () { + var mockDatum = {url: 'image/url', utc: 1434600000000}; + expect(controller.updateHistory(mockDatum)).toBe(true); + expect(controller.updateHistory(mockDatum)).toBe(false); + expect(controller.updateHistory(mockDatum)).toBe(false); }); }); From ed6ae23dc0b94314be10729fce497213146e7e8e Mon Sep 17 00:00:00 2001 From: Preston Crowe Date: Fri, 14 Jul 2017 13:05:59 -0700 Subject: [PATCH 2/2] [Historical Imagery] JSDoc, code review style changes Added $element dependency and JSDoc for private methods. Autoscroll is now enabled by default when there is an active clock. Inline comments removed. --- platform/features/imagery/bundle.js | 1 + .../src/controllers/ImageryController.js | 55 ++++++++++++------- .../test/controllers/ImageryControllerSpec.js | 32 ++++++----- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/platform/features/imagery/bundle.js b/platform/features/imagery/bundle.js index c1ff369b5a..ddca767501 100644 --- a/platform/features/imagery/bundle.js +++ b/platform/features/imagery/bundle.js @@ -79,6 +79,7 @@ define([ "depends": [ "$scope", "$window", + "$element", "openmct" ] } diff --git a/platform/features/imagery/src/controllers/ImageryController.js b/platform/features/imagery/src/controllers/ImageryController.js index 473eb9047c..d8be8d46dc 100644 --- a/platform/features/imagery/src/controllers/ImageryController.js +++ b/platform/features/imagery/src/controllers/ImageryController.js @@ -39,7 +39,7 @@ define( * @memberof platform/features/imagery */ - function ImageryController($scope, $window, openmct) { + function ImageryController($scope, $window, element, openmct) { this.$scope = $scope; this.$window = $window; this.openmct = openmct; @@ -48,10 +48,8 @@ define( this.zone = ""; this.imageUrl = ""; this.requestCount = 0; - this.lastBound = undefined; - this.autoScroll = false; - this.scrollable = - $(document.getElementsByClassName('l-image-thumbs-wrapper')[0]); + this.scrollable = $(element[0]); + this.autoScroll = openmct.time.clock() ? true : false; this.$scope.imageHistory = []; this.$scope.filters = { @@ -66,11 +64,9 @@ define( this.onBoundsChange = this.onBoundsChange.bind(this); this.onScroll = this.onScroll.bind(this); - // Subscribe to telemetry when a domain object becomes available this.subscribe(this.$scope.domainObject); - // Unsubscribe when the plot is destroyed - this.$scope.$on("$destroy", this.stopListening); + this.$scope.$on('$destroy', this.stopListening); this.openmct.time.on('bounds', this.onBoundsChange); this.scrollable.on('scroll', this.onScroll); } @@ -109,19 +105,24 @@ define( .request(this.domainObject, bounds) .then(function (values) { if (this.requestCount > requestId) { - return Promise.reject('Stale request'); + return Promise.resolve('Stale request'); } values.forEach(function (datum) { this.updateHistory(datum); - }.bind(this)); + }, this); this.requestLad(true); }.bind(this)); }; - // Optional addToHistory argument allows for two use cases: - // updating url and timestamp only for standard imagery view, - // i.e to populate the view before history is requested OR - // appending to the running imagery history + /** + * Makes a request for the most recent datum in the + * telelmetry store. Optional addToHistory argument + * determines whether the requested telemetry should + * be added to history or only used to update the current + * image url and timestamp. + * @private + * @param {boolean} [addToHistory] if true, adds to history + */ ImageryController.prototype.requestLad = function (addToHistory) { this.openmct.telemetry .request(this.domainObject, { @@ -145,16 +146,25 @@ define( } }; - // Query for new historical data on manual bound change + /** + * Responds to bound change event be requesting new + * historical data if the bound change was manual. + * @private + * @param {object} [newBounds] new bounds object + * @param {boolean} [tick] true when change is automatic + */ ImageryController.prototype.onBoundsChange = function (newBounds, tick) { - if (this.domainObject && !tick && - !_.isEqual(this.lastBound, newBounds)) { - this.lastBound = newBounds; + if (this.domainObject && !tick) { this.requestHistory(newBounds); } }; - // Update displayable values to reflect latest image telemetry + /** + * Updates displayable values to match those of the most + * recently recieved datum. + * @param {object} [datum] the datum + * @private + */ ImageryController.prototype.updateValues = function (datum) { if (this.isPaused) { this.nextDatum = datum; @@ -166,7 +176,12 @@ define( }; - // Update displayable values and append datum to running history + /** + * Appends given imagery datum to running history. + * @private + * @param {object} [datum] target telemetry datum + * @returns {boolean} falsy when a duplicate datum is given + */ ImageryController.prototype.updateHistory = function (datum) { if (this.$scope.imageHistory.length === 0 || !_.isEqual(this.$scope.imageHistory.slice(-1)[0], datum)) { diff --git a/platform/features/imagery/test/controllers/ImageryControllerSpec.js b/platform/features/imagery/test/controllers/ImageryControllerSpec.js index e9a708a7fc..ce81e6ef67 100644 --- a/platform/features/imagery/test/controllers/ImageryControllerSpec.js +++ b/platform/features/imagery/test/controllers/ImageryControllerSpec.js @@ -21,8 +21,14 @@ *****************************************************************************/ define( - ["../../src/controllers/ImageryController"], - function (ImageryController) { + [ + "zepto", + "../../src/controllers/ImageryController" + ], + function ($, ImageryController) { + + var MOCK_ELEMENT_TEMPLATE = + '
'; describe("The Imagery controller", function () { var $scope, @@ -34,7 +40,8 @@ define( prefix, controller, hasLoaded, - mockWindow; + mockWindow, + mockElement; beforeEach(function () { $scope = jasmine.createSpyObj('$scope', ['$on', '$watch']); @@ -50,6 +57,7 @@ define( ]), time: jasmine.createSpyObj('timeAPI', [ 'timeSystem', + 'clock', 'on', 'off' ]), @@ -94,18 +102,23 @@ define( }); metadata.value.andReturn("timestamp"); metadata.valuesForHints.andReturn(["value"]); + mockElement = $(MOCK_ELEMENT_TEMPLATE); mockWindow = jasmine.createSpyObj('$window', ['requestAnimationFrame']); mockWindow.requestAnimationFrame.andCallFake(function (f) { return f(); }); - controller = new ImageryController($scope, mockWindow, openmct); + controller = new ImageryController( + $scope, + mockWindow, + mockElement, + openmct + ); }); describe("when loaded", function () { var callback, boundsListener; - var mockBounds = {start: 1434600000000, end: 1434600500000}; beforeEach(function () { waitsFor(function () { @@ -198,6 +211,7 @@ define( }); it("listens for bounds event and responds to tick and manual change", function () { + var mockBounds = {start: 1434600000000, end: 1434600500000}; expect(openmct.time.on).toHaveBeenCalled(); openmct.telemetry.request.reset(); boundsListener(mockBounds, true); @@ -206,14 +220,6 @@ define( expect(openmct.telemetry.request).toHaveBeenCalledWith(newDomainObject, mockBounds); }); - it("recognizes duplicate bounds", function () { - openmct.telemetry.request.reset(); - boundsListener(mockBounds, false); - boundsListener(mockBounds, false); - boundsListener(mockBounds, false); - expect(openmct.telemetry.request.calls.length).toBe(1); - }); - it ("doesnt append duplicate datum", function () { var mockDatum = {url: 'image/url', utc: 1434600000000}; expect(controller.updateHistory(mockDatum)).toBe(true);