Merge pull request #1635 from nasa/historical-imagery

[Historical imagery] [WIP]: Add historical view for image telemetry
This commit is contained in:
Pete Richards 2017-07-26 11:36:21 -07:00 committed by GitHub
commit 34c3763421
8 changed files with 236 additions and 43 deletions

View File

@ -83,12 +83,10 @@
.l-image-thumbs-wrapper { .l-image-thumbs-wrapper {
//@include test(green); //@include test(green);
direction: rtl;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
padding-bottom: $interiorMargin; padding-bottom: $interiorMargin;
white-space: nowrap; white-space: nowrap;
z-index: 70;
} }
.l-image-thumb-item { .l-image-thumb-item {
@ -114,7 +112,7 @@
width: $imageThumbsD + $imageThumbPad*2; width: $imageThumbsD + $imageThumbPad*2;
white-space: normal; white-space: normal;
&:hover { &:hover {
background: rgba(#fff, 0.2); background: $colorThumbHoverBg;
.l-date, .l-date,
.l-time { .l-time {
color: #fff; color: #fff;

View File

@ -191,6 +191,9 @@ $colorItemTreeVCHover: pullForward($colorItemTreeVC, 20%);
$colorItemTreeSelectedVC: $colorItemTreeVC; $colorItemTreeSelectedVC: $colorItemTreeVC;
$shdwItemTreeIcon: 0.6; $shdwItemTreeIcon: 0.6;
// Images
$colorThumbHoverBg: $colorItemTreeHoverBg;
// Scrollbar // Scrollbar
$scrollbarTrackSize: 10px; $scrollbarTrackSize: 10px;
$scrollbarTrackShdw: rgba(#000, 0.7) 0 1px 5px; $scrollbarTrackShdw: rgba(#000, 0.7) 0 1px 5px;

View File

@ -191,6 +191,9 @@ $colorItemTreeVCHover: $colorKey;
$colorItemTreeSelectedVC: $colorBodyBg; $colorItemTreeSelectedVC: $colorBodyBg;
$shdwItemTreeIcon: none; $shdwItemTreeIcon: none;
// Images
$colorThumbHoverBg: $colorItemTreeHoverBg;
// Scrollbar // Scrollbar
$scrollbarTrackSize: 10px; $scrollbarTrackSize: 10px;
$scrollbarTrackShdw: rgba(#000, 0.2) 0 1px 2px; $scrollbarTrackShdw: rgba(#000, 0.2) 0 1px 2px;

View File

@ -25,12 +25,14 @@ define([
"./src/controllers/ImageryController", "./src/controllers/ImageryController",
"./src/directives/MCTBackgroundImage", "./src/directives/MCTBackgroundImage",
"text!./res/templates/imagery.html", "text!./res/templates/imagery.html",
"text!./res/templates/imageryTimeline.html",
'legacyRegistry' 'legacyRegistry'
], function ( ], function (
ImageryViewPolicy, ImageryViewPolicy,
ImageryController, ImageryController,
MCTBackgroundImage, MCTBackgroundImage,
imageryTemplate, imageryTemplate,
imageryTimelineTemplate,
legacyRegistry legacyRegistry
) { ) {
@ -48,6 +50,17 @@ define([
"telemetry" "telemetry"
], ],
"editable": false "editable": false
},
{
"name": "Historical Imagery",
"key": "historical-imagery",
"cssClass": "icon-image",
"template": imageryTimelineTemplate,
"priority": "preferred",
"needs": [
"telemetry"
],
"editable": false
} }
], ],
"policies": [ "policies": [
@ -65,6 +78,8 @@ define([
"implementation": ImageryController, "implementation": ImageryController,
"depends": [ "depends": [
"$scope", "$scope",
"$window",
"$element",
"openmct" "openmct"
] ]
} }

View File

@ -0,0 +1,8 @@
<div class="l-image-thumbs-wrapper" ng-controller="ImageryController as imagery">
<div class="l-image-thumb-item" ng-repeat="image in imageHistory track by $index">
<img class="l-thumb" ng-init="imagery.scrollToRight()"
ng-src={{imagery.getImageUrl(image)}} >
<div class="l-time">{{imagery.getTime(image)}}</div>
</div>
</div>

View File

@ -24,9 +24,13 @@
* This bundle implements views of image telemetry. * This bundle implements views of image telemetry.
* @namespace platform/features/imagery * @namespace platform/features/imagery
*/ */
define( define(
['moment'], [
function (moment) { 'zepto',
'lodash'
],
function ($, _) {
/** /**
* Controller for the "Imagery" view of a domain object which * Controller for the "Imagery" view of a domain object which
@ -34,14 +38,20 @@ define(
* @constructor * @constructor
* @memberof platform/features/imagery * @memberof platform/features/imagery
*/ */
function ImageryController($scope, openmct) {
function ImageryController($scope, $window, element, openmct) {
this.$scope = $scope; this.$scope = $scope;
this.$window = $window;
this.openmct = openmct; this.openmct = openmct;
this.date = ""; this.date = "";
this.time = ""; this.time = "";
this.zone = ""; this.zone = "";
this.imageUrl = ""; this.imageUrl = "";
this.requestCount = 0;
this.scrollable = $(element[0]);
this.autoScroll = openmct.time.clock() ? true : false;
this.$scope.imageHistory = [];
this.$scope.filters = { this.$scope.filters = {
brightness: 100, brightness: 100,
contrast: 100 contrast: 100
@ -50,12 +60,15 @@ define(
this.subscribe = this.subscribe.bind(this); this.subscribe = this.subscribe.bind(this);
this.stopListening = this.stopListening.bind(this); this.stopListening = this.stopListening.bind(this);
this.updateValues = this.updateValues.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); 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) { ImageryController.prototype.subscribe = function (domainObject) {
@ -75,50 +88,152 @@ define(
.telemetry .telemetry
.getValueFormatter(metadata.valuesForHints(['image'])[0]); .getValueFormatter(metadata.valuesForHints(['image'])[0]);
this.unsubscribe = this.openmct.telemetry this.unsubscribe = this.openmct.telemetry
.subscribe(this.domainObject, this.updateValues); .subscribe(this.domainObject, function (datum) {
this.openmct.telemetry this.updateHistory(datum);
.request(this.domainObject, { this.updateValues(datum);
strategy: 'latest',
size: 1
})
.then(function (values) {
this.updateValues(values[0]);
}.bind(this)); }.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)); }.bind(this));
}; };
ImageryController.prototype.stopListening = function () { ImageryController.prototype.stopListening = function () {
this.openmct.time.off('bounds', this.onBoundsChange);
this.scrollable.off('scroll', this.onScroll);
if (this.unsubscribe) { if (this.unsubscribe) {
this.unsubscribe(); this.unsubscribe();
delete 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) { ImageryController.prototype.updateValues = function (datum) {
if (this.isPaused) { if (this.isPaused) {
this.nextDatum = datum; this.nextDatum = datum;
return; return;
} }
this.time = this.timeFormat.format(datum); this.time = this.timeFormat.format(datum);
this.imageUrl = this.imageFormat.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 * 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 * @returns {string} the time
*/ */
ImageryController.prototype.getTime = function () { ImageryController.prototype.getTime = function (datum) {
return this.time; 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 * @returns {string} URL for telemetry image
*/ */
ImageryController.prototype.getImageUrl = function () { ImageryController.prototype.getImageUrl = function (datum) {
return this.imageUrl; return datum ?
this.imageFormat.format(datum) :
this.imageUrl;
}; };
/** /**
@ -128,15 +243,15 @@ define(
* @returns {boolean} the current state * @returns {boolean} the current state
*/ */
ImageryController.prototype.paused = function (state) { ImageryController.prototype.paused = function (state) {
if (arguments.length > 0 && state !== this.isPaused) { if (arguments.length > 0 && state !== this.isPaused) {
this.isPaused = state; this.isPaused = state;
if (this.nextDatum) { if (this.nextDatum) {
this.updateValues(this.nextDatum); this.updateValues(this.nextDatum);
delete this.nextDatum; delete this.nextDatum;
}
} }
} return this.isPaused;
return this.isPaused; };
};
return ImageryController; return ImageryController;
} }

View File

@ -47,7 +47,7 @@ define([
}; };
ImageryViewPolicy.prototype.allow = function (view, domainObject) { ImageryViewPolicy.prototype.allow = function (view, domainObject) {
if (view.key === 'imagery') { if (view.key === 'imagery' || view.key === 'historical-imagery') {
return this.hasImageTelemetry(domainObject); return this.hasImageTelemetry(domainObject);
} }

View File

@ -21,8 +21,14 @@
*****************************************************************************/ *****************************************************************************/
define( define(
["../../src/controllers/ImageryController"], [
function (ImageryController) { "zepto",
"../../src/controllers/ImageryController"
],
function ($, ImageryController) {
var MOCK_ELEMENT_TEMPLATE =
'<div class="l-image-thumbs-wrapper"></div>';
describe("The Imagery controller", function () { describe("The Imagery controller", function () {
var $scope, var $scope,
@ -33,7 +39,9 @@ define(
metadata, metadata,
prefix, prefix,
controller, controller,
hasLoaded; hasLoaded,
mockWindow,
mockElement;
beforeEach(function () { beforeEach(function () {
$scope = jasmine.createSpyObj('$scope', ['$on', '$watch']); $scope = jasmine.createSpyObj('$scope', ['$on', '$watch']);
@ -42,14 +50,16 @@ define(
['getId'] ['getId']
); );
newDomainObject = { name: 'foo' }; newDomainObject = { name: 'foo' };
oldDomainObject.getId.andReturn('testID'); oldDomainObject.getId.andReturn('testID');
openmct = { openmct = {
objects: jasmine.createSpyObj('objectAPI', [ objects: jasmine.createSpyObj('objectAPI', [
'get' 'get'
]), ]),
time: jasmine.createSpyObj('timeAPI', [ time: jasmine.createSpyObj('timeAPI', [
'timeSystem' 'timeSystem',
'clock',
'on',
'off'
]), ]),
telemetry: jasmine.createSpyObj('telemetryAPI', [ telemetry: jasmine.createSpyObj('telemetryAPI', [
'subscribe', 'subscribe',
@ -92,13 +102,24 @@ define(
}); });
metadata.value.andReturn("timestamp"); metadata.value.andReturn("timestamp");
metadata.valuesForHints.andReturn(["value"]); 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 () { describe("when loaded", function () {
var callback; var callback,
boundsListener;
beforeEach(function () { beforeEach(function () {
waitsFor(function () { waitsFor(function () {
return hasLoaded; return hasLoaded;
@ -106,12 +127,16 @@ define(
runs(function () { runs(function () {
openmct.time.on.calls.forEach(function (call) {
if (call.args[0] === "bounds") {
boundsListener = call.args[1];
}
});
callback = callback =
openmct.telemetry.subscribe.mostRecentCall.args[1]; openmct.telemetry.subscribe.mostRecentCall.args[1];
}); });
}); });
it("uses LAD telemetry", function () { it("uses LAD telemetry", function () {
expect(openmct.telemetry.request).toHaveBeenCalledWith( expect(openmct.telemetry.request).toHaveBeenCalledWith(
newDomainObject, 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(); expect(unsubscribe).not.toHaveBeenCalled();
$scope.$on.calls.forEach(function (call) { $scope.$on.calls.forEach(function (call) {
@ -174,6 +206,25 @@ define(
} }
}); });
expect(unsubscribe).toHaveBeenCalled(); 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);
}); });
}); });