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..ddca767501 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,8 @@ define([ "implementation": ImageryController, "depends": [ "$scope", + "$window", + "$element", "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..d8be8d46dc 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,20 @@ define( * @constructor * @memberof platform/features/imagery */ - function ImageryController($scope, openmct) { + + function ImageryController($scope, $window, element, openmct) { this.$scope = $scope; + this.$window = $window; this.openmct = openmct; this.date = ""; this.time = ""; this.zone = ""; this.imageUrl = ""; + this.requestCount = 0; + this.scrollable = $(element[0]); + this.autoScroll = openmct.time.clock() ? true : false; + this.$scope.imageHistory = []; this.$scope.filters = { brightness: 100, contrast: 100 @@ -50,12 +60,15 @@ 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.$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 +88,152 @@ 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.resolve('Stale request'); + } + values.forEach(function (datum) { + this.updateHistory(datum); + }, this); + this.requestLad(true); + }.bind(this)); + }; + + /** + * 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, { + 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; } }; - // Update displayable values to reflect latest image telemetry + /** + * 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) { + this.requestHistory(newBounds); + } + }; + + /** + * 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; return; } + this.time = this.timeFormat.format(datum); this.imageUrl = this.imageFormat.format(datum); + + }; + + /** + * 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)) { + + 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 +243,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..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, @@ -33,7 +39,9 @@ define( metadata, prefix, controller, - hasLoaded; + hasLoaded, + mockWindow, + mockElement; beforeEach(function () { $scope = jasmine.createSpyObj('$scope', ['$on', '$watch']); @@ -42,14 +50,16 @@ define( ['getId'] ); newDomainObject = { name: 'foo' }; - oldDomainObject.getId.andReturn('testID'); openmct = { objects: jasmine.createSpyObj('objectAPI', [ 'get' ]), time: jasmine.createSpyObj('timeAPI', [ - 'timeSystem' + 'timeSystem', + 'clock', + 'on', + 'off' ]), telemetry: jasmine.createSpyObj('telemetryAPI', [ 'subscribe', @@ -92,13 +102,24 @@ 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, openmct); - + controller = new ImageryController( + $scope, + mockWindow, + mockElement, + openmct + ); }); describe("when loaded", function () { - var callback; + var callback, + boundsListener; + beforeEach(function () { waitsFor(function () { return hasLoaded; @@ -106,12 +127,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 +190,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 +206,25 @@ 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 () { + var mockBounds = {start: 1434600000000, end: 1434600500000}; + 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 ("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); }); });