From 218ef16160f6fb6d1fb0f08d7a8d73bc9036a117 Mon Sep 17 00:00:00 2001 From: Preston Crowe Date: Mon, 26 Jun 2017 10:31:39 -0700 Subject: [PATCH] [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); }); });