Imagery Age to be displayed for realtime mode in Imagery View (#3308)

* fix linting errors

* removing testing units

* WIP: stubbe in age in template, adding getAge function

* WIP: stubbed in age in template, dummy function to start

* added image age for realtime mode, ready for styling

* reverting unnecesarry telemetryview file changes, not needed for this issue

* checking for age tracking conditions on mount

* Image age styling and changes

- Cleaned up code in ImageryPlugin to use const instead of var, changed
image delay time into a const

* Image age styling and changes

- WIP!
- Layout changes for Imagery control-bar;
- New animation effect, WIP;

* Image age styling and changes

- Markup and CSS updates for Imagery view;
- Final layout for age indicator;

* parsing image timestamp in case it is a string

* using moment for human readable durations above 8 hours

* UTC based timesystem check

* reset "new" css class on image age when "time" updates

* WIP: debuggin weird imagery plugin issue for first selection of image in thumbnails

* fixing pause overwriting clicked images selection

* making isImageNew a computed value

* WIP: pr updates

* WIP: tabling PR edits to focus on lower hanging PR edits for testathon

* WIP

* overhaul of imagery plugin logic for optimization PLUS imagery age

* adding next/prev functionality to refactored plugin

* added arrow left and right keys to navigate next and previous

* added arrow key scrolling and scrolling thumbnail into view and hold down scrolling

* adding in missing class

* component based key listening, PR updates

* refactor to use just imageIndex to track focused image, utilized more caching, PR comment edits

Co-authored-by: David Tsay <david.e.tsay@nasa.gov>
Co-authored-by: charlesh88 <charlesh88@gmail.com>
This commit is contained in:
Jamie V 2020-10-06 16:01:47 -07:00 committed by GitHub
parent a91179091f
commit ab76451360
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 378 additions and 142 deletions

View File

@ -27,7 +27,7 @@ define([
) {
function ImageryPlugin() {
var IMAGE_SAMPLES = [
const IMAGE_SAMPLES = [
"https://www.hq.nasa.gov/alsj/a16/AS16-117-18731.jpg",
"https://www.hq.nasa.gov/alsj/a16/AS16-117-18732.jpg",
"https://www.hq.nasa.gov/alsj/a16/AS16-117-18733.jpg",
@ -47,13 +47,14 @@ define([
"https://www.hq.nasa.gov/alsj/a16/AS16-117-18747.jpg",
"https://www.hq.nasa.gov/alsj/a16/AS16-117-18748.jpg"
];
const IMAGE_DELAY = 20000;
function pointForTimestamp(timestamp, name) {
return {
name: name,
utc: Math.floor(timestamp / 5000) * 5000,
local: Math.floor(timestamp / 5000) * 5000,
url: IMAGE_SAMPLES[Math.floor(timestamp / 5000) % IMAGE_SAMPLES.length]
utc: Math.floor(timestamp / IMAGE_DELAY) * IMAGE_DELAY,
local: Math.floor(timestamp / IMAGE_DELAY) * IMAGE_DELAY,
url: IMAGE_SAMPLES[Math.floor(timestamp / IMAGE_DELAY) % IMAGE_SAMPLES.length]
};
}
@ -64,7 +65,7 @@ define([
subscribe: function (domainObject, callback) {
var interval = setInterval(function () {
callback(pointForTimestamp(Date.now(), domainObject.name));
}, 5000);
}, IMAGE_DELAY);
return function () {
clearInterval(interval);
@ -81,9 +82,9 @@ define([
var start = options.start;
var end = Math.min(options.end, Date.now());
var data = [];
while (start <= end && data.length < 5000) {
while (start <= end && data.length < IMAGE_DELAY) {
data.push(pointForTimestamp(start, domainObject.name));
start += 5000;
start += IMAGE_DELAY;
}
return Promise.resolve(data);

View File

@ -1,5 +1,11 @@
<template>
<div class="c-imagery">
<div
tabindex="0"
class="c-imagery"
@keyup="arrowUpHandler"
@keydown="arrowDownHandler"
@mouseover="focusElement"
>
<div class="c-imagery__main-image-wrapper has-local-controls">
<div class="h-local-controls h-local-controls--overlay-content c-local-controls--show-on-hover l-flex-row c-imagery__lc">
<span class="holder flex-elem grows c-imagery__lc__sliders">
@ -22,98 +28,188 @@
></a>
</span>
</div>
<div class="main-image s-image-main c-imagery__main-image has-local-controls js-imageryView-image"
:class="{'paused unnsynced': paused(),'stale':false }"
:style="{'background-image': getImageUrl() ? `url(${getImageUrl()})` : 'none',
<div class="main-image s-image-main c-imagery__main-image has-local-controls"
:class="{'paused unnsynced': isPaused,'stale':false }"
:style="{'background-image': imageUrl ? `url(${imageUrl})` : 'none',
'filter': `brightness(${filters.brightness}%) contrast(${filters.contrast}%)`}"
:data-openmct-image-timestamp="getTime()"
:data-openmct-object-keystring="keystring"
:data-openmct-image-timestamp="time"
:data-openmct-object-keystring="keyString"
>
<div class="c-local-controls c-local-controls--show-on-hover c-imagery__prev-next-buttons">
<button class="c-nav c-nav--prev"
title="Previous image"
:disabled="isPrevDisabled()"
:disabled="isPrevDisabled"
@click="prevImage()"
></button>
<button class="c-nav c-nav--next"
title="Next image"
:disabled="isNextDisabled()"
:disabled="isNextDisabled"
@click="nextImage()"
></button>
</div>
</div>
<div class="c-imagery__control-bar">
<div class="c-imagery__timestamp">{{ getTime() }}</div>
<div class="c-imagery__time">
<div class="c-imagery__timestamp">{{ time }}</div>
<div
v-if="canTrackDuration"
:class="{'c-imagery--new': isImageNew && !refreshCSS}"
class="c-imagery__age icon-timer"
>{{ formattedDuration }}</div>
</div>
<div class="h-local-controls flex-elem">
<button
class="c-button icon-pause pause-play"
:class="{'is-paused': paused()}"
@click="paused(!paused(), true)"
:class="{'is-paused': isPaused}"
@click="paused(!isPaused, 'button')"
></button>
</div>
</div>
</div>
<div ref="thumbsWrapper"
class="c-imagery__thumbs-wrapper"
:class="{'is-paused': paused()}"
:class="{'is-paused': isPaused}"
@scroll="handleScroll"
>
<div v-for="(imageData, index) in imageHistory"
:key="index"
<div v-for="(datum, index) in imageHistory"
:key="datum.url"
class="c-imagery__thumb c-thumb"
:class="{selected: imageData.selected}"
@click="setSelectedImage(imageData)"
:class="{ selected: focusedImageIndex === index && isPaused }"
@click="setFocusedImage(index, thumbnailClick)"
>
<img class="c-thumb__image"
:src="getImageUrl(imageData)"
:src="formatImageUrl(datum)"
>
<div class="c-thumb__timestamp">{{ getTime(imageData) }}</div>
<div class="c-thumb__timestamp">{{ formatTime(datum) }}</div>
</div>
</div>
</div>
</template>
<script>
import moment from 'moment';
const DEFAULT_DURATION_FORMATTER = 'duration';
const REFRESH_CSS_MS = 500;
const DURATION_TRACK_MS = 1000;
const ARROW_DOWN_DELAY_CHECK_MS = 400;
const ARROW_SCROLL_RATE_MS = 100;
const THUMBNAIL_CLICKED = true;
const ONE_MINUTE = 60 * 1000;
const FIVE_MINUTES = 5 * ONE_MINUTE;
const ONE_HOUR = ONE_MINUTE * 60;
const EIGHT_HOURS = 8 * ONE_HOUR;
const TWENTYFOUR_HOURS = EIGHT_HOURS * 3;
const ARROW_RIGHT = 39;
const ARROW_LEFT = 37;
export default {
inject: ['openmct', 'domainObject'],
data() {
let timeSystem = this.openmct.time.timeSystem();
return {
autoScroll: true,
durationFormatter: undefined,
filters: {
brightness: 100,
contrast: 100
},
image: {
selected: ''
},
imageFormat: '',
imageHistory: [],
imageUrl: '',
thumbnailClick: THUMBNAIL_CLICKED,
isPaused: false,
metadata: {},
requestCount: 0,
timeFormat: '',
keystring: ''
timeSystem: timeSystem,
timeFormatter: undefined,
refreshCSS: false,
keyString: undefined,
focusedImageIndex: undefined,
numericDuration: undefined
};
},
computed: {
bounds() {
return this.openmct.time.bounds();
time() {
return this.formatTime(this.focusedImage);
},
imageUrl() {
return this.formatImageUrl(this.focusedImage);
},
isImageNew() {
let cutoff = FIVE_MINUTES;
let age = this.numericDuration;
return age < cutoff && !this.refreshCSS;
},
canTrackDuration() {
return this.openmct.time.clock() && this.timeSystem.isUTCBased;
},
isNextDisabled() {
let disabled = false;
if (this.focusedImageIndex === -1 || this.focusedImageIndex === this.imageHistory.length - 1) {
disabled = true;
}
return disabled;
},
isPrevDisabled() {
let disabled = false;
if (this.focusedImageIndex === 0 || this.imageHistory.length < 2) {
disabled = true;
}
return disabled;
},
focusedImage() {
return this.imageHistory[this.focusedImageIndex];
},
parsedSelectedTime() {
return this.parseTime(this.focusedImage);
},
formattedDuration() {
let result = 'N/A';
let negativeAge = -1;
if (this.numericDuration > TWENTYFOUR_HOURS) {
negativeAge *= (this.numericDuration / TWENTYFOUR_HOURS);
result = moment.duration(negativeAge, 'days').humanize(true);
} else if (this.numericDuration > EIGHT_HOURS) {
negativeAge *= (this.numericDuration / ONE_HOUR);
result = moment.duration(negativeAge, 'hours').humanize(true);
} else if (this.durationFormatter) {
result = this.durationFormatter.format(this.numericDuration);
}
return result;
}
},
watch: {
focusedImageIndex() {
this.trackDuration();
this.resetAgeCSS();
}
},
mounted() {
// set
this.keystring = this.openmct.objects.makeKeyString(this.domainObject.identifier);
this.metadata = this.openmct.telemetry.getMetadata(this.domainObject);
this.imageFormat = this.openmct.telemetry.getValueFormatter(this.metadata.valuesForHints(['image'])[0]);
// initialize
this.timeKey = this.openmct.time.timeSystem().key;
this.timeFormat = this.openmct.telemetry.getValueFormatter(this.metadata.value(this.timeKey));
// listen
this.openmct.time.on('bounds', this.boundsChange);
this.openmct.time.on('timeSystem', this.timeSystemChange);
this.openmct.time.on('clock', this.clockChange);
// set
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
this.metadata = this.openmct.telemetry.getMetadata(this.domainObject);
this.durationFormatter = this.getFormatter(this.timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
this.imageFormatter = this.openmct.telemetry.getValueFormatter(this.metadata.valuesForHints(['image'])[0]);
// initialize
this.timeKey = this.timeSystem.key;
this.timeFormatter = this.getFormatter(this.timeKey);
// kickoff
this.subscribe();
this.requestHistory();
@ -127,41 +223,55 @@ export default {
delete this.unsubscribe;
}
this.stopDurationTracking();
this.openmct.time.off('bounds', this.boundsChange);
this.openmct.time.off('timeSystem', this.timeSystemChange);
this.openmct.time.off('clock', this.clockChange);
},
methods: {
focusElement() {
this.$el.focus();
},
datumIsNotValid(datum) {
if (this.imageHistory.length === 0) {
return false;
}
const datumTime = this.timeFormat.format(datum);
const datumURL = this.imageFormat.format(datum);
const lastHistoryTime = this.timeFormat.format(this.imageHistory.slice(-1)[0]);
const lastHistoryURL = this.imageFormat.format(this.imageHistory.slice(-1)[0]);
const datumURL = this.formatImageUrl(datum);
const lastHistoryURL = this.formatImageUrl(this.imageHistory.slice(-1)[0]);
// datum is not valid if it matches the last datum in history,
// or it is before the last datum in the history
const datumTimeCheck = this.timeFormat.parse(datum);
const historyTimeCheck = this.timeFormat.parse(this.imageHistory.slice(-1)[0]);
const matchesLast = (datumTime === lastHistoryTime) && (datumURL === lastHistoryURL);
const datumTimeCheck = this.parseTime(datum);
const historyTimeCheck = this.parseTime(this.imageHistory.slice(-1)[0]);
const matchesLast = (datumTimeCheck === historyTimeCheck) && (datumURL === lastHistoryURL);
const isStale = datumTimeCheck < historyTimeCheck;
return matchesLast || isStale;
},
getImageUrl(datum) {
return datum
? this.imageFormat.format(datum)
: this.imageUrl;
formatImageUrl(datum) {
if (!datum) {
return;
}
return this.imageFormatter.format(datum);
},
getTime(datum) {
let dateTimeStr = datum
? this.timeFormat.format(datum)
: this.time;
formatTime(datum) {
if (!datum) {
return;
}
let dateTimeStr = this.timeFormatter.format(datum);
// Replace ISO "T" with a space to allow wrapping
return dateTimeStr ? dateTimeStr.replace("T", " ") : "";
return dateTimeStr.replace("T", " ");
},
parseTime(datum) {
if (!datum) {
return;
}
return this.timeFormatter.parse(datum);
},
handleScroll() {
const thumbsWrapper = this.$refs.thumbsWrapper;
@ -174,26 +284,35 @@ export default {
|| (scrollHeight - scrollTop) > 2 * clientHeight;
this.autoScroll = !disableScroll;
},
paused(state, button = false) {
if (arguments.length > 0 && state !== this.isPaused) {
this.unselectAllImages();
this.isPaused = state;
if (state === true && button) {
// If we are pausing, select the latest image in imageHistory
this.setSelectedImage(this.imageHistory[this.imageHistory.length - 1]);
}
paused(state, type) {
if (this.nextDatum) {
this.updateValues(this.nextDatum);
delete this.nextDatum;
} else {
this.updateValues(this.imageHistory[this.imageHistory.length - 1]);
}
this.isPaused = state;
this.autoScroll = true;
if (type === 'button') {
this.setFocusedImage(this.imageHistory.length - 1);
}
return this.isPaused;
if (this.nextImageIndex) {
this.setFocusedImage(this.nextImageIndex);
delete this.nextImageIndex;
}
this.autoScroll = true;
},
scrollToFocused() {
const thumbsWrapper = this.$refs.thumbsWrapper;
if (!thumbsWrapper) {
return;
}
let domThumb = thumbsWrapper.children[this.focusedImageIndex];
if (domThumb) {
domThumb.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
},
scrollToRight() {
if (this.isPaused || !this.$refs.thumbsWrapper || !this.autoScroll) {
@ -207,22 +326,17 @@ export default {
setTimeout(() => this.$refs.thumbsWrapper.scrollLeft = scrollWidth, 0);
},
setSelectedImage(image) {
// If we are paused and the current image IS selected, unpause
// Otherwise, set current image and pause
if (!image) {
setFocusedImage(index, thumbnailClick = false) {
if (this.isPaused && !thumbnailClick) {
this.nextImageIndex = index;
return;
}
if (this.isPaused && image.selected) {
this.paused(false);
this.unselectAllImages();
} else {
this.imageUrl = this.getImageUrl(image);
this.time = this.getTime(image);
this.focusedImageIndex = index;
if (thumbnailClick && !this.isPaused) {
this.paused(true);
this.unselectAllImages();
image.selected = true;
}
},
boundsChange(bounds, isTick) {
@ -230,98 +344,158 @@ export default {
this.requestHistory();
}
},
requestHistory() {
const requestId = ++this.requestCount;
async requestHistory() {
let bounds = this.openmct.time.bounds();
this.requestCount++;
const requestId = this.requestCount;
this.imageHistory = [];
this.openmct.telemetry
.request(this.domainObject, this.bounds)
.then((values = []) => {
if (this.requestCount === requestId) {
// add each image to the history
// update values for the very last image (set current image time and url)
values.forEach((datum, index) => this.updateHistory(datum, index === values.length - 1));
}
let data = await this.openmct.telemetry
.request(this.domainObject, bounds) || [];
if (this.requestCount === requestId) {
data.forEach((datum, index) => {
this.updateHistory(datum, index === data.length - 1);
});
}
},
timeSystemChange(system) {
// reset timesystem dependent variables
this.timeKey = system.key;
this.timeFormat = this.openmct.telemetry.getValueFormatter(this.metadata.value(this.timeKey));
this.timeSystem = this.openmct.time.timeSystem();
this.timeKey = this.timeSystem.key;
this.timeFormatter = this.getFormatter(this.timeKey);
this.durationFormatter = this.getFormatter(this.timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
this.trackDuration();
},
clockChange(clock) {
this.trackDuration();
},
subscribe() {
this.unsubscribe = this.openmct.telemetry
.subscribe(this.domainObject, (datum) => {
let parsedTimestamp = this.timeFormat.parse(datum);
let parsedTimestamp = this.parseTime(datum);
let bounds = this.openmct.time.bounds();
if (parsedTimestamp >= this.bounds.start && parsedTimestamp <= this.bounds.end) {
if (parsedTimestamp >= bounds.start && parsedTimestamp <= bounds.end) {
this.updateHistory(datum);
}
});
},
unselectAllImages() {
this.imageHistory.forEach(image => image.selected = false);
},
updateHistory(datum, updateValues = true) {
updateHistory(datum, setFocused = true) {
if (this.datumIsNotValid(datum)) {
return;
}
this.imageHistory.push(datum);
if (updateValues) {
this.updateValues(datum);
if (setFocused) {
this.setFocusedImage(this.imageHistory.length - 1);
}
},
updateValues(datum) {
if (this.isPaused) {
this.nextDatum = datum;
getFormatter(key) {
let metadataValue = this.metadata.value(key) || { format: key };
let valueFormatter = this.openmct.telemetry.getValueFormatter(metadataValue);
return valueFormatter;
},
trackDuration() {
if (this.canTrackDuration) {
this.stopDurationTracking();
this.updateDuration();
this.durationTracker = window.setInterval(
this.updateDuration, DURATION_TRACK_MS
);
} else {
this.stopDurationTracking();
}
},
stopDurationTracking() {
window.clearInterval(this.durationTracker);
},
updateDuration() {
let currentTime = this.openmct.time.clock().currentValue();
this.numericDuration = currentTime - this.parsedSelectedTime;
},
resetAgeCSS() {
this.refreshCSS = true;
// unable to make this work with nextTick
setTimeout(() => {
this.refreshCSS = false;
}, REFRESH_CSS_MS);
},
nextImage() {
if (this.isNextDisabled) {
return;
}
this.time = this.timeFormat.format(datum);
this.imageUrl = this.imageFormat.format(datum);
},
selectedImageIndex() {
return this.imageHistory.findIndex(image => image.selected);
},
setSelectedByIndex(index) {
this.setSelectedImage(this.imageHistory[index]);
},
nextImage() {
let index = this.selectedImageIndex();
this.setSelectedByIndex(++index);
let index = this.focusedImageIndex;
this.setFocusedImage(++index, THUMBNAIL_CLICKED);
if (index === this.imageHistory.length - 1) {
this.paused(false);
}
},
prevImage() {
let index = this.selectedImageIndex();
if (index === -1) {
this.setSelectedByIndex(this.imageHistory.length - 2);
if (this.isPrevDisabled) {
return;
}
let index = this.focusedImageIndex;
if (index === this.imageHistory.length - 1) {
this.setFocusedImage(this.imageHistory.length - 2, THUMBNAIL_CLICKED);
} else {
this.setSelectedByIndex(--index);
this.setFocusedImage(--index, THUMBNAIL_CLICKED);
}
},
isNextDisabled() {
let disabled = false;
let index = this.selectedImageIndex();
arrowDownHandler(event) {
let key = event.keyCode;
if (index === -1 || index === this.imageHistory.length - 1) {
disabled = true;
if (this.isLeftOrRightArrowKey(key)) {
this.arrowDown = true;
window.clearTimeout(this.arrowDownDelayTimeout);
this.arrowDownDelayTimeout = window.setTimeout(() => {
this.arrowKeyScroll(this.directionByKey(key));
}, ARROW_DOWN_DELAY_CHECK_MS);
}
return disabled;
},
isPrevDisabled() {
let disabled = false;
let index = this.selectedImageIndex();
arrowUpHandler(event) {
let key = event.keyCode;
if (index === 0 || this.imageHistory.length < 2) {
disabled = true;
window.clearTimeout(this.arrowDownDelayTimeout);
if (this.isLeftOrRightArrowKey(key)) {
this.arrowDown = false;
let direction = this.directionByKey(key);
this[direction + 'Image']();
}
},
arrowKeyScroll(direction) {
if (this.arrowDown) {
this.arrowKeyScrolling = true;
this[direction + 'Image']();
setTimeout(() => {
this.arrowKeyScroll(direction);
}, ARROW_SCROLL_RATE_MS);
} else {
window.clearTimeout(this.arrowDownDelayTimeout);
this.arrowKeyScrolling = false;
this.scrollToFocused();
}
},
directionByKey(keyCode) {
let direction;
if (keyCode === ARROW_LEFT) {
direction = 'prev';
}
return disabled;
if (keyCode === ARROW_RIGHT) {
direction = 'next';
}
return direction;
},
isLeftOrRightArrowKey(keyCode) {
return [ARROW_RIGHT, ARROW_LEFT].includes(keyCode);
}
}
};

View File

@ -4,6 +4,10 @@
overflow: hidden;
height: 100%;
&:focus {
outline: none;
}
> * + * {
margin-top: $interiorMargin;
}
@ -25,14 +29,57 @@
}
}
&__control-bar {
padding: 5px 0 0 0;
&__control-bar,
&__time {
display: flex;
align-items: center;
align-items: baseline;
> * + * {
margin-left: $interiorMarginSm;
}
}
&__control-bar {
margin-top: 2px;
padding: $interiorMarginSm 0;
justify-content: space-between;
}
&__time {
flex: 0 1 auto;
overflow: hidden;
}
&__timestamp,
&__age {
@include ellipsize();
flex: 0 1 auto;
}
&__timestamp {
flex: 1 1 auto;
flex-shrink: 10;
}
&__age {
border-radius: $controlCr;
display: flex;
flex-shrink: 0;
align-items: baseline;
padding: 1px $interiorMarginSm;
&:before {
opacity: 0.5;
margin-right: $interiorMarginSm;
}
}
&--new {
// New imagery
$bgColor: $colorOk;
background: rgba($bgColor, 0.5);
@include flash($animName: flashImageAge, $dur: 250ms, $valStart: rgba($colorOk, 0.7), $valEnd: rgba($colorOk, 0));
}
&__thumbs-wrapper {
@ -151,6 +198,8 @@
/*************************************** BUTTONS */
.c-button.pause-play {
// Pause icon set by default in markup
justify-self: end;
&.is-paused {
background: $colorPausedBg !important;
color: $colorPausedFg;

View File

@ -50,6 +50,18 @@
}
/************************** EFFECTS */
@mixin flash($animName: flash, $dur: 500ms, $dir: alternate, $iter: 20, $prop: background, $valStart: rgba($colorOk, 1), $valEnd: rgba($colorOk, 0)) {
@keyframes #{$animName} {
0% { #{$prop}: $valStart; }
100% { #{$prop}: $valEnd; }
}
animation-name: $animName;
animation-duration: $dur;
animation-direction: $dir;
animation-iteration-count: $iter;
animation-timing-function: ease-out;
}
@mixin mixedBg() {
$c1: nth($mixedSettingBg, 1);
$c2: nth($mixedSettingBg, 2);