[Imagery Plugin] Enhancements - Compass, HUD, Freshness (#3675)

* Adds a compass rose component showing spacecraft and camera pointing direction in images, as well as sun location.
* Adds a "heads up display" component that shows heading at the top of images, as well as sun direction
* Adds freshness indicators for spacecraft and camera position

Co-authored-by: Jamie Vigliotta <jamie.j.vigliotta@nasa.gov>

Co-authored-by: David Tsay <david.e.tsay@nasa.gov>
Co-authored-by: charlesh88 <charlesh88@gmail.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
Co-authored-by: charlesh88 <charles.f.hacskaylo@nasa.gov>
Co-authored-by: David Tsay <3614296+davetsay@users.noreply.github.com>
This commit is contained in:
Jamie V 2021-02-23 11:46:09 -08:00 committed by GitHub
parent 29128a891d
commit fc59a4dce4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1433 additions and 27 deletions

View File

@ -1,3 +1,25 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import ImageryViewLayout from './components/ImageryViewLayout.vue';
import Vue from 'vue';

View File

@ -0,0 +1,131 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
<template>
<div
class="c-compass"
:style="compassDimensionsStyle"
>
<CompassHUD
v-if="hasCameraFieldOfView"
:heading="heading"
:sun-heading="sunHeading"
:camera-angle-of-view="cameraAngleOfView"
:camera-pan="cameraPan"
/>
<CompassRose
v-if="hasCameraFieldOfView"
:heading="heading"
:sun-heading="sunHeading"
:camera-angle-of-view="cameraAngleOfView"
:camera-pan="cameraPan"
:lock-compass="lockCompass"
@toggle-lock-compass="toggleLockCompass"
/>
</div>
</template>
<script>
import CompassHUD from './CompassHUD.vue';
import CompassRose from './CompassRose.vue';
const CAMERA_ANGLE_OF_VIEW = 70;
export default {
components: {
CompassHUD,
CompassRose
},
props: {
containerWidth: {
type: Number,
required: true
},
containerHeight: {
type: Number,
required: true
},
naturalAspectRatio: {
type: Number,
required: true
},
image: {
type: Object,
required: true
},
lockCompass: {
type: Boolean,
required: true
}
},
computed: {
hasCameraFieldOfView() {
return this.heading !== undefined && this.cameraPan !== undefined;
},
// compass direction from north in degrees
heading() {
return this.image.heading;
},
pitch() {
return this.image.pitch;
},
// compass direction from north in degrees
sunHeading() {
return this.image.sunOrientation;
},
// relative direction from heading in degrees
cameraPan() {
return this.image.cameraPan;
},
cameraTilt() {
return this.image.cameraTilt;
},
cameraAngleOfView() {
return CAMERA_ANGLE_OF_VIEW;
},
compassDimensionsStyle() {
const containerAspectRatio = this.containerWidth / this.containerHeight;
let width;
let height;
if (containerAspectRatio < this.naturalAspectRatio) {
width = '100%';
height = `${ this.containerWidth / this.naturalAspectRatio }px`;
} else {
width = `${ this.containerHeight * this.naturalAspectRatio }px`;
height = '100%';
}
return {
width: width,
height: height
};
}
},
methods: {
toggleLockCompass() {
this.$emit('toggle-lock-compass');
}
}
};
</script>

View File

@ -0,0 +1,145 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
<template>
<div
class="c-compass__hud c-hud"
>
<div
v-for="point in visibleCompassPoints"
:key="point.direction"
:class="point.class"
:style="point.style"
>
{{ point.direction }}
</div>
<div
v-if="isSunInRange"
ref="sun"
class="c-hud__sun"
:style="sunPositionStyle"
></div>
<div class="c-hud__range"></div>
</div>
</template>
<script>
import {
rotate,
inRange,
percentOfRange
} from './utils';
const COMPASS_POINTS = [
{
direction: 'N',
class: 'c-hud__dir',
degrees: 0
},
{
direction: 'NE',
class: 'c-hud__dir--sub',
degrees: 45
},
{
direction: 'E',
class: 'c-hud__dir',
degrees: 90
},
{
direction: 'SE',
class: 'c-hud__dir--sub',
degrees: 135
},
{
direction: 'S',
class: 'c-hud__dir',
degrees: 180
},
{
direction: 'SW',
class: 'c-hud__dir--sub',
degrees: 225
},
{
direction: 'W',
class: 'c-hud__dir',
degrees: 270
},
{
direction: 'NW',
class: 'c-hud__dir--sub',
degrees: 315
}
];
export default {
props: {
heading: {
type: Number,
required: true
},
sunHeading: {
type: Number,
default: undefined
},
cameraAngleOfView: {
type: Number,
default: undefined
},
cameraPan: {
type: Number,
required: true
}
},
computed: {
visibleCompassPoints() {
return COMPASS_POINTS
.filter(point => inRange(point.degrees, this.visibleRange))
.map(point => {
const percentage = percentOfRange(point.degrees, this.visibleRange);
point.style = Object.assign(
{ left: `${ percentage * 100 }%` }
);
return point;
});
},
isSunInRange() {
return inRange(this.sunHeading, this.visibleRange);
},
sunPositionStyle() {
const percentage = percentOfRange(this.sunHeading, this.visibleRange);
return {
left: `${ percentage * 100 }%`
};
},
visibleRange() {
return [
rotate(this.heading, this.cameraPan, -this.cameraAngleOfView / 2),
rotate(this.heading, this.cameraPan, this.cameraAngleOfView / 2)
];
}
}
};
</script>

View File

@ -0,0 +1,263 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
<template>
<div
class="c-direction-rose"
@click="toggleLockCompass"
>
<div
class="c-nsew"
:style="rotateFrameStyle"
>
<svg
class="c-nsew__minor-ticks"
viewBox="0 0 100 100"
>
<rect
class="c-nsew__tick c-tick-ne"
x="49"
y="0"
width="2"
height="5"
/>
<rect
class="c-nsew__tick c-tick-se"
x="95"
y="49"
width="5"
height="2"
/>
<rect
class="c-nsew__tick c-tick-sw"
x="49"
y="95"
width="2"
height="5"
/>
<rect
class="c-nsew__tick c-tick-nw"
x="0"
y="49"
width="5"
height="2"
/>
</svg>
<svg
class="c-nsew__ticks"
viewBox="0 0 100 100"
>
<polygon
class="c-nsew__tick c-tick-n"
points="50,0 57,5 43,5"
/>
<rect
class="c-nsew__tick c-tick-e"
x="95"
y="49"
width="5"
height="2"
/>
<rect
class="c-nsew__tick c-tick-w"
x="0"
y="49"
width="5"
height="2"
/>
<rect
class="c-nsew__tick c-tick-s"
x="49"
y="95"
width="2"
height="5"
/>
<text
class="c-nsew__label c-label-n"
text-anchor="middle"
:transform="northTextTransform"
>N</text>
<text
class="c-nsew__label c-label-e"
text-anchor="middle"
:transform="eastTextTransform"
>E</text>
<text
class="c-nsew__label c-label-w"
text-anchor="middle"
:transform="southTextTransform"
>W</text>
<text
class="c-nsew__label c-label-s"
text-anchor="middle"
:transform="westTextTransform"
>S</text>
</svg>
</div>
<div
class="c-spacecraft-body"
:style="headingStyle"
>
</div>
<div
class="c-sun"
:style="sunHeadingStyle"
></div>
<div
v-if="showCameraFOV"
class="c-cam-field"
:style="cameraHeadingStyle"
>
<div class="cam-field-half cam-field-half-l">
<div
class="cam-field-area"
:style="cameraFOVStyleLeftHalf"
></div>
</div>
<div class="cam-field-half cam-field-half-r">
<div
class="cam-field-area"
:style="cameraFOVStyleRightHalf"
></div>
</div>
</div>
</div>
</template>
<script>
import { rotate } from './utils';
export default {
props: {
heading: {
type: Number,
required: true
},
sunHeading: {
type: Number,
default: undefined
},
cameraAngleOfView: {
type: Number,
default: undefined
},
cameraPan: {
type: Number,
required: true
},
lockCompass: {
type: Boolean,
required: true
}
},
computed: {
cameraHeading() {
return rotate(this.heading, this.cameraPan);
},
compassHeading() {
return this.lockCompass ? this.cameraHeading : 0;
},
north() {
return rotate(this.compassHeading, -this.cameraHeading);
},
rotateFrameStyle() {
return { transform: `rotate(${ this.north }deg)` };
},
northTextTransform() {
return this.cardinalPointsTextTransform.north;
},
eastTextTransform() {
return this.cardinalPointsTextTransform.east;
},
southTextTransform() {
return this.cardinalPointsTextTransform.south;
},
westTextTransform() {
return this.cardinalPointsTextTransform.west;
},
cardinalPointsTextTransform() {
/**
* cardinal points text must be rotated
* in the opposite direction that north is rotated
* to keep text vertically oriented
*/
const rotation = `rotate(${ -this.north })`;
return {
north: `translate(50,15) ${ rotation }`,
east: `translate(87,50) ${ rotation }`,
south: `translate(13,50) ${ rotation }`,
west: `translate(50,87) ${ rotation }`
};
},
headingStyle() {
const rotation = rotate(this.north, this.heading);
return {
transform: `translateX(-50%) rotate(${ rotation }deg)`
};
},
cameraHeadingStyle() {
const rotation = rotate(this.north, this.cameraHeading);
return {
transform: `rotate(${ rotation }deg)`
};
},
sunHeadingStyle() {
const rotation = rotate(this.north, this.sunHeading);
return {
transform: `rotate(${ rotation }deg)`
};
},
showCameraFOV() {
return this.cameraPan !== undefined && this.cameraAngleOfView > 0;
},
// left half of camera field of view
// rotated counter-clockwise from camera field of view heading
cameraFOVStyleLeftHalf() {
return {
transform: `translateX(50%) rotate(${ -this.cameraAngleOfView / 2 }deg)`
};
},
// right half of camera field of view
// rotated clockwise from camera field of view heading
cameraFOVStyleRightHalf() {
return {
transform: `translateX(-50%) rotate(${ this.cameraAngleOfView / 2 }deg)`
};
}
},
methods: {
toggleLockCompass() {
this.$emit('toggle-lock-compass');
}
}
};
</script>

View File

@ -0,0 +1,214 @@
/***************************** THEME/UI CONSTANTS AND MIXINS */
$interfaceKeyColor: #00B9C5;
$elemBg: rgba(black, 0.7);
@mixin sun($position: 'circle closest-side') {
$color: #ff9900;
$gradEdgePerc: 60%;
background: radial-gradient(#{$position}, $color, $color $gradEdgePerc, rgba($color, 0.4) $gradEdgePerc + 5%, transparent);
}
.c-compass {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
@include userSelectNone;
}
/***************************** COMPASS HUD */
.c-hud {
// To be placed within a imagery view, in the bounding box of the image
$m: 1px;
$padTB: 2px;
$padLR: $padTB;
color: $interfaceKeyColor;
font-size: 0.8em;
position: absolute;
top: $m; right: $m; left: $m;
height: 18px;
svg, div {
position: absolute;
}
&__display {
height: 30px;
pointer-events: all;
position: absolute;
top: 0;
right: 0;
left: 0;
}
&__range {
border: 1px solid $interfaceKeyColor;
border-top-color: transparent;
position: absolute;
top: 50%; right: $padLR; bottom: $padTB; left: $padLR;
}
[class*="__dir"] {
// NSEW
display: inline-block;
font-weight: bold;
text-shadow: 0 1px 2px black;
top: 50%;
transform: translate(-50%,-50%);
z-index: 2;
}
[class*="__dir--sub"] {
font-weight: normal;
opacity: 0.5;
}
&__sun {
$s: 10px;
@include sun('circle farthest-side at bottom');
bottom: $padTB + 2px;
height: $s; width: $s*2;
opacity: 0.8;
transform: translateX(-50%);
z-index: 1;
}
}
/***************************** COMPASS DIRECTIONS */
.c-nsew {
$color: $interfaceKeyColor;
$inset: 7%;
$tickHeightPerc: 15%;
text-shadow: black 0 0 10px;
top: $inset; right: $inset; bottom: $inset; left: $inset;
z-index: 3;
&__tick,
&__label {
fill: $color;
}
&__minor-ticks {
opacity: 0.5;
transform-origin: center;
transform: rotate(45deg);
}
&__label {
dominant-baseline: central;
font-size: 0.8em;
font-weight: bold;
}
.c-label-n {
font-size: 1.1em;
}
}
/***************************** CAMERA FIELD ANGLE */
.c-cam-field {
$color: white;
opacity: 0.2;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
.cam-field-half {
top: 0;
right: 0;
bottom: 0;
left: 0;
.cam-field-area {
background: $color;
top: -30%;
right: 0;
bottom: -30%;
left: 0;
}
// clip-paths overlap a bit to avoid a gap between halves
&-l {
clip-path: polygon(0 0, 50.5% 0, 50.5% 100%, 0 100%);
.cam-field-area {
transform-origin: left center;
}
}
&-r {
clip-path: polygon(49.5% 0, 100% 0, 100% 100%, 49.5% 100%);
.cam-field-area {
transform-origin: right center;
}
}
}
}
/***************************** SPACECRAFT BODY */
.c-spacecraft-body {
$color: $interfaceKeyColor;
$s: 30%;
background: $color;
border-radius: 3px;
height: $s; width: $s;
left: 50%; top: 50%;
opacity: 0.4;
transform-origin: center top;
&:before {
// Direction arrow
$color: rgba(black, 0.5);
$arwPointerY: 60%;
$arwBodyOffset: 25%;
background: $color;
content: '';
display: block;
position: absolute;
top: 10%; right: 20%; bottom: 50%; left: 20%;
clip-path: polygon(50% 0, 100% $arwPointerY, 100%-$arwBodyOffset $arwPointerY, 100%-$arwBodyOffset 100%, $arwBodyOffset 100%, $arwBodyOffset $arwPointerY, 0 $arwPointerY);
}
}
/***************************** DIRECTION ROSE */
.c-direction-rose {
$d: 100px;
$c2: rgba(white, 0.1);
background: $elemBg;
background-image: radial-gradient(circle closest-side, transparent, transparent 80%, $c2);
width: $d;
height: $d;
transform-origin: 0 0;
position: absolute;
bottom: 10px; left: 10px;
clip-path: circle(50% at 50% 50%);
border-radius: 100%;
svg, div {
position: absolute;
}
// Sun
.c-sun {
top: 0;
right: 0;
bottom: 0;
left: 0;
&:before {
$s: 35%;
@include sun();
content: '';
display: block;
position: absolute;
opacity: 0.7;
top: 0; left: 50%;
height:$s; width: $s;
transform: translate(-50%, -60%);
}
}
}

View File

@ -0,0 +1,84 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import Compass from './Compass.vue';
import Vue from 'vue';
const COMPASS_ROSE_CLASS = '.c-direction-rose';
const COMPASS_HUD_CLASS = '.c-compass__hud';
describe("The Compass component", () => {
let app;
let instance;
beforeEach(() => {
let imageDatum = {
heading: 100,
roll: 90,
pitch: 90,
cameraTilt: 100,
cameraPan: 90,
sunAngle: 30
};
let propsData = {
containerWidth: 600,
containerHeight: 600,
naturalAspectRatio: 0.9,
image: imageDatum
};
app = new Vue({
components: { Compass },
data() {
return propsData;
},
template: `<Compass
:container-width="containerWidth"
:container-height="containerHeight"
:natural-aspect-ratio="naturalAspectRatio"
:image="image" />`
});
instance = app.$mount();
});
afterAll(() => {
app.$destroy();
});
describe("when a heading value exists on the image", () => {
it("should display a compass rose", () => {
let compassRoseElement = instance.$el.querySelector(COMPASS_ROSE_CLASS
);
expect(compassRoseElement).toBeDefined();
});
it("should display a compass HUD", () => {
let compassHUDElement = instance.$el.querySelector(COMPASS_HUD_CLASS);
expect(compassHUDElement).toBeDefined();
});
});
});

View File

@ -0,0 +1,33 @@
export function rotate(direction, ...rotations) {
const rotation = rotations.reduce((a, b) => a + b, 0);
return normalizeCompassDirection(direction + rotation);
}
export function normalizeCompassDirection(degrees) {
const base = degrees % 360;
return base >= 0 ? base : 360 + base;
}
export function inRange(degrees, [min, max]) {
return min > max
? (degrees >= min && degrees < 360) || (degrees <= max && degrees >= 0)
: degrees >= min && degrees <= max;
}
export function percentOfRange(degrees, [min, max]) {
let distance = degrees;
let minRange = min;
let maxRange = max;
if (min > max) {
if (distance < max) {
distance += 360;
}
maxRange += 360;
}
return (distance - minRange) / (maxRange - minRange);
}

View File

@ -1,3 +1,25 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
<template>
<div
tabindex="0"
@ -36,14 +58,25 @@
<div class="c-imagery__main-image__bg"
:class="{'paused unnsynced': isPaused,'stale':false }"
>
<div class="c-imagery__main-image__image js-imageryView-image"
:style="{
'background-image': imageUrl ? `url(${imageUrl})` : 'none',
'filter': `brightness(${filters.brightness}%) contrast(${filters.contrast}%)`
}"
:data-openmct-image-timestamp="time"
:data-openmct-object-keystring="keyString"
></div>
<img
ref="focusedImage"
class="c-imagery__main-image__image js-imageryView-image"
:src="imageUrl"
:style="{
'filter': `brightness(${filters.brightness}%) contrast(${filters.contrast}%)`
}"
:data-openmct-image-timestamp="time"
:data-openmct-object-keystring="keyString"
>
<Compass
v-if="shouldDisplayCompass"
:container-width="imageContainerWidth"
:container-height="imageContainerHeight"
:natural-aspect-ratio="focusedImageNaturalAspectRatio"
:image="focusedImage"
:lock-compass="lockCompass"
@toggle-lock-compass="toggleLockCompass"
/>
</div>
<div class="c-local-controls c-local-controls--show-on-hover c-imagery__prev-next-buttons">
<button class="c-nav c-nav--prev"
@ -61,11 +94,25 @@
<div class="c-imagery__control-bar">
<div class="c-imagery__time">
<div class="c-imagery__timestamp u-style-receiver js-style-receiver">{{ time }}</div>
<!-- image fresh -->
<div
v-if="canTrackDuration"
:class="{'c-imagery--new': isImageNew && !refreshCSS}"
class="c-imagery__age icon-timer"
>{{ formattedDuration }}</div>
<!-- spacecraft position fresh -->
<div
v-if="relatedTelemetry.hasRelatedTelemetry && isSpacecraftPositionFresh"
class="c-imagery__age icon-check c-imagery--new"
>POS</div>
<!-- camera position fresh -->
<div
v-if="relatedTelemetry.hasRelatedTelemetry && isCameraPositionFresh"
class="c-imagery__age icon-check c-imagery--new"
>CAM</div>
</div>
<div class="h-local-controls">
<button
@ -76,13 +123,14 @@
</div>
</div>
</div>
<div ref="thumbsWrapper"
class="c-imagery__thumbs-wrapper"
:class="{'is-paused': isPaused}"
@scroll="handleScroll"
<div
ref="thumbsWrapper"
class="c-imagery__thumbs-wrapper"
:class="{'is-paused': isPaused}"
@scroll="handleScroll"
>
<div v-for="(datum, index) in imageHistory"
:key="datum.url"
:key="datum.url + datum[timeKey]"
class="c-imagery__thumb c-thumb"
:class="{ selected: focusedImageIndex === index && isPaused }"
@click="setFocusedImage(index, thumbnailClick)"
@ -97,7 +145,10 @@
</template>
<script>
import _ from 'lodash';
import moment from 'moment';
import Compass from './Compass/Compass.vue';
import RelatedTelemetry from './RelatedTelemetry/RelatedTelemetry';
const DEFAULT_DURATION_FORMATTER = 'duration';
const REFRESH_CSS_MS = 500;
@ -116,6 +167,9 @@ const ARROW_RIGHT = 39;
const ARROW_LEFT = 37;
export default {
components: {
Compass
},
inject: ['openmct', 'domainObject'],
data() {
let timeSystem = this.openmct.time.timeSystem();
@ -137,7 +191,15 @@ export default {
refreshCSS: false,
keyString: undefined,
focusedImageIndex: undefined,
numericDuration: undefined
focusedImageRelatedTelemetry: {},
numericDuration: undefined,
metadataEndpoints: {},
relatedTelemetry: {},
latestRelatedTelemetry: {},
focusedImageNaturalAspectRatio: undefined,
imageContainerWidth: undefined,
imageContainerHeight: undefined,
lockCompass: true
};
},
computed: {
@ -195,15 +257,69 @@ export default {
}
return result;
},
shouldDisplayCompass() {
return this.focusedImage !== undefined
&& this.focusedImageNaturalAspectRatio !== undefined
&& this.imageContainerWidth !== undefined
&& this.imageContainerHeight !== undefined;
},
isSpacecraftPositionFresh() {
let isFresh = undefined;
let latest = this.latestRelatedTelemetry;
let focused = this.focusedImageRelatedTelemetry;
if (this.relatedTelemetry.hasRelatedTelemetry) {
isFresh = true;
for (let key of this.spacecraftKeys) {
if (this.relatedTelemetry[key] && latest[key] && focused[key]) {
if (!this.relatedTelemetry[key].comparisonFunction(latest[key], focused[key])) {
isFresh = false;
}
} else {
isFresh = false;
}
}
}
return isFresh;
},
isCameraPositionFresh() {
let isFresh = undefined;
let latest = this.latestRelatedTelemetry;
let focused = this.focusedImageRelatedTelemetry;
if (this.relatedTelemetry.hasRelatedTelemetry) {
isFresh = true;
// camera freshness relies on spacecraft position freshness
if (this.isSpacecraftPositionFresh) {
for (let key of this.cameraKeys) {
if (this.relatedTelemetry[key] && latest[key] && focused[key]) {
if (!this.relatedTelemetry[key].comparisonFunction(latest[key], focused[key])) {
isFresh = false;
}
} else {
isFresh = false;
}
}
} else {
isFresh = false;
}
}
return isFresh;
}
},
watch: {
focusedImageIndex() {
this.trackDuration();
this.resetAgeCSS();
this.updateRelatedTelemetryForFocusedImage();
this.getImageNaturalDimensions();
}
},
mounted() {
async mounted() {
// listen
this.openmct.time.on('bounds', this.boundsChange);
this.openmct.time.on('timeSystem', this.timeSystemChange);
@ -212,8 +328,14 @@ export default {
// set
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
this.metadata = this.openmct.telemetry.getMetadata(this.domainObject);
this.imageHints = { ...this.metadata.valuesForHints(['image'])[0] };
this.durationFormatter = this.getFormatter(this.timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
this.imageFormatter = this.openmct.telemetry.getValueFormatter(this.metadata.valuesForHints(['image'])[0]);
this.imageFormatter = this.openmct.telemetry.getValueFormatter(this.imageHints);
// related telemetry keys
this.spacecraftKeys = ['heading', 'roll', 'pitch'];
this.cameraKeys = ['cameraPan', 'cameraTilt'];
this.sunKeys = ['sunOrientation'];
// initialize
this.timeKey = this.timeSystem.key;
@ -222,6 +344,18 @@ export default {
// kickoff
this.subscribe();
this.requestHistory();
// related telemetry
await this.initializeRelatedTelemetry();
this.updateRelatedTelemetryForFocusedImage();
this.trackLatestRelatedTelemetry();
// for scrolling through images quickly and resizing the object view
_.debounce(this.updateRelatedTelemetryForFocusedImage, 400);
_.debounce(this.resizeImageContainer, 400);
this.imageContainerResizeObserver = new ResizeObserver(this.resizeImageContainer);
this.imageContainerResizeObserver.observe(this.$refs.focusedImage);
},
updated() {
this.scrollToRight();
@ -232,12 +366,115 @@ export default {
delete this.unsubscribe;
}
this.imageContainerResizeObserver.disconnect();
if (this.relatedTelemetry.hasRelatedTelemetry) {
this.relatedTelemetry.destroy();
}
this.stopDurationTracking();
this.openmct.time.off('bounds', this.boundsChange);
this.openmct.time.off('timeSystem', this.timeSystemChange);
this.openmct.time.off('clock', this.clockChange);
// unsubscribe from related telemetry
if (this.relatedTelemetry.hasRelatedTelemetry) {
for (let key of this.relatedTelemetry.keys) {
if (this.relatedTelemetry[key].unsubscribe) {
this.relatedTelemetry[key].unsubscribe();
}
}
}
},
methods: {
async initializeRelatedTelemetry() {
this.relatedTelemetry = new RelatedTelemetry(
this.openmct,
this.domainObject,
[...this.spacecraftKeys, ...this.cameraKeys, ...this.sunKeys]
);
if (this.relatedTelemetry.hasRelatedTelemetry) {
await this.relatedTelemetry.load();
}
},
async getMostRecentRelatedTelemetry(key, targetDatum) {
if (!this.relatedTelemetry.hasRelatedTelemetry) {
throw new Error(`${this.domainObject.name} does not have any related telemetry`);
}
if (!this.relatedTelemetry[key]) {
throw new Error(`${key} does not exist on related telemetry`);
}
let mostRecent;
let valueKey = this.relatedTelemetry[key].historical.valueKey;
let valuesOnTelemetry = this.relatedTelemetry[key].hasTelemetryOnDatum;
if (valuesOnTelemetry) {
mostRecent = targetDatum[valueKey];
if (mostRecent) {
return mostRecent;
} else {
console.warn(`Related Telemetry for ${key} does NOT exist on this telemetry datum as configuration implied.`);
return;
}
}
mostRecent = await this.relatedTelemetry[key].requestLatestFor(targetDatum);
return mostRecent[valueKey];
},
// will subscribe to data for this key if not already done
subscribeToDataForKey(key) {
if (this.relatedTelemetry[key].isSubscribed) {
return;
}
if (this.relatedTelemetry[key].realtimeDomainObject) {
this.relatedTelemetry[key].unsubscribe = this.openmct.telemetry.subscribe(
this.relatedTelemetry[key].realtimeDomainObject, datum => {
this.relatedTelemetry[key].listeners.forEach(callback => {
callback(datum);
});
}
);
this.relatedTelemetry[key].isSubscribed = true;
}
},
async updateRelatedTelemetryForFocusedImage() {
if (!this.relatedTelemetry.hasRelatedTelemetry || !this.focusedImage) {
return;
}
// set data ON image telemetry as well as in focusedImageRelatedTelemetry
for (let key of this.relatedTelemetry.keys) {
if (this.relatedTelemetry[key] && this.relatedTelemetry[key].historical) {
let valuesOnTelemetry = this.relatedTelemetry[key].hasTelemetryOnDatum;
let value = await this.getMostRecentRelatedTelemetry(key, this.focusedImage);
if (!valuesOnTelemetry) {
this.$set(this.imageHistory[this.focusedImageIndex], key, value); // manually add to telemetry
}
this.$set(this.focusedImageRelatedTelemetry, key, value);
}
}
},
trackLatestRelatedTelemetry() {
[...this.spacecraftKeys, ...this.cameraKeys, ...this.sunKeys].forEach(key => {
if (this.relatedTelemetry[key] && this.relatedTelemetry[key].subscribe) {
this.relatedTelemetry[key].subscribe((datum) => {
let valueKey = this.relatedTelemetry[key].realtime.valueKey;
this.$set(this.latestRelatedTelemetry, key, datum[valueKey]);
});
}
});
},
focusElement() {
this.$el.focus();
},
@ -358,6 +595,7 @@ export default {
this.requestCount++;
const requestId = this.requestCount;
this.imageHistory = [];
let data = await this.openmct.telemetry
.request(this.domainObject, bounds) || [];
@ -509,6 +747,28 @@ export default {
},
isLeftOrRightArrowKey(keyCode) {
return [ARROW_RIGHT, ARROW_LEFT].includes(keyCode);
},
getImageNaturalDimensions() {
this.focusedImageNaturalAspectRatio = undefined;
const img = this.$refs.focusedImage;
// TODO - should probably cache this
img.addEventListener('load', () => {
this.focusedImageNaturalAspectRatio = img.naturalWidth / img.naturalHeight;
}, { once: true });
},
resizeImageContainer() {
if (this.$refs.focusedImage.clientWidth !== this.imageContainerWidth) {
this.imageContainerWidth = this.$refs.focusedImage.clientWidth;
}
if (this.$refs.focusedImage.clientHeight !== this.imageContainerHeight) {
this.imageContainerHeight = this.$refs.focusedImage.clientHeight;
}
},
toggleLockCompass() {
this.lockCompass = !this.lockCompass;
}
}
};

View File

@ -0,0 +1,162 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
function copyRelatedMetadata(metadata) {
let compare = metadata.comparisonFunction;
let copiedMetadata = JSON.parse(JSON.stringify(metadata));
copiedMetadata.comparisonFunction = compare;
return copiedMetadata;
}
export default class RelatedTelemetry {
constructor(openmct, domainObject, telemetryKeys) {
this._openmct = openmct;
this._domainObject = domainObject;
let metadata = this._openmct.telemetry.getMetadata(this._domainObject);
let imageHints = metadata.valuesForHints(['image'])[0];
this.hasRelatedTelemetry = imageHints.relatedTelemetry !== undefined;
if (this.hasRelatedTelemetry) {
this.keys = telemetryKeys;
this._timeFormatter = undefined;
this._timeSystemChange(this._openmct.time.timeSystem());
// grab related telemetry metadata
for (let key of this.keys) {
if (imageHints.relatedTelemetry[key]) {
this[key] = copyRelatedMetadata(imageHints.relatedTelemetry[key]);
}
}
this.load = this.load.bind(this);
this._parseTime = this._parseTime.bind(this);
this._timeSystemChange = this._timeSystemChange.bind(this);
this.destroy = this.destroy.bind(this);
this._openmct.time.on('timeSystem', this._timeSystemChange);
}
}
async load() {
if (!this.hasRelatedTelemetry) {
throw new Error('This domain object does not have related telemetry, use "hasRelatedTelemetry" to check before loading.');
}
await Promise.all(
this.keys.map(async (key) => {
if (this[key].historical) {
await this._initializeHistorical(key);
}
if (this[key].realtime && this[key].realtime.telemetryObjectId) {
await this._intializeRealtime(key);
}
})
);
}
async _initializeHistorical(key) {
if (this[key].historical.telemetryObjectId) {
this[key].historicalDomainObject = await this._openmct.objects.get(this[key].historical.telemetryObjectId);
this[key].requestLatestFor = async (datum) => {
const options = {
start: this._openmct.time.bounds().start,
end: this._parseTime(datum),
strategy: 'latest'
};
let results = await this._openmct.telemetry
.request(this[key].historicalDomainObject, options);
return results[results.length - 1];
};
} else {
this[key].historical.hasTelemetryOnDatum = true;
}
}
async _intializeRealtime(key) {
this[key].realtimeDomainObject = await this._openmct.objects.get(this[key].realtime.telemetryObjectId);
this[key].listeners = [];
this[key].subscribe = (callback) => {
if (!this[key].isSubscribed) {
this._subscribeToDataForKey(key);
}
if (!this[key].listeners.includes(callback)) {
this[key].listeners.push(callback);
return () => {
this[key].listeners.remove(callback);
};
} else {
return () => {};
}
};
}
_subscribeToDataForKey(key) {
if (this[key].isSubscribed) {
return;
}
if (this[key].realtimeDomainObject) {
this[key].unsubscribe = this._openmct.telemetry.subscribe(
this[key].realtimeDomainObject, datum => {
this[key].listeners.forEach(callback => {
callback(datum);
});
}
);
this[key].isSubscribed = true;
}
}
_parseTime(datum) {
return this._timeFormatter.parse(datum);
}
_timeSystemChange(system) {
let key = system.key;
let metadata = this._openmct.telemetry.getMetadata(this._domainObject);
let metadataValue = metadata.value(key) || { format: key };
this._timeFormatter = this._openmct.telemetry.getValueFormatter(metadataValue);
}
destroy() {
this._openmct.time.off('timeSystem', this._timeSystemChange);
for (let key of this.keys) {
if (this[key].unsubscribe) {
this[key].unsubscribe();
}
}
}
}

View File

@ -23,6 +23,7 @@
background-color: $colorPlotBg;
border: 1px solid transparent;
flex: 1 1 auto;
height: 0;
&.unnsynced{
@include sUnsynced();
@ -30,10 +31,9 @@
}
&__image {
@include abs(); // Safari fix
background-position: center;
background-repeat: no-repeat;
background-size: contain;
height: 100%;
width: 100%;
object-fit: contain;
}
}
@ -71,13 +71,14 @@
}
&__age {
border-radius: $controlCr;
border-radius: $smallCr;
display: flex;
flex-shrink: 0;
align-items: baseline;
padding: 1px $interiorMarginSm;
align-items: center;
padding: 2px $interiorMarginSm;
&:before {
font-size: 0.9em;
opacity: 0.5;
margin-right: $interiorMarginSm;
}
@ -86,8 +87,9 @@
&--new {
// New imagery
$bgColor: $colorOk;
color: $colorOkFg;
background: rgba($bgColor, 0.5);
@include flash($animName: flashImageAge, $dur: 250ms, $valStart: rgba($colorOk, 0.7), $valEnd: rgba($colorOk, 0));
@include flash($animName: flashImageAge, $iter: 2, $dur: 250ms, $valStart: rgba($colorOk, 0.7), $valEnd: rgba($colorOk, 0));
}
&__thumbs-wrapper {

View File

@ -1,3 +1,25 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import ImageryViewProvider from './ImageryViewProvider';
export default function () {

View File

@ -32,12 +32,25 @@ const TEN_MINUTES = ONE_MINUTE * 10;
const MAIN_IMAGE_CLASS = '.js-imageryView-image';
const NEW_IMAGE_CLASS = '.c-imagery__age.c-imagery--new';
const REFRESH_CSS_MS = 500;
const TOLERANCE = 0.50;
function comparisonFunction(valueOne, valueTwo) {
let larger = valueOne;
let smaller = valueTwo;
if (larger < smaller) {
larger = valueTwo;
smaller = valueOne;
}
return (larger - smaller) < TOLERANCE;
}
function getImageInfo(doc) {
let imageElement = doc.querySelectorAll(MAIN_IMAGE_CLASS)[0];
let timestamp = imageElement.dataset.openmctImageTimestamp;
let identifier = imageElement.dataset.openmctObjectKeystring;
let url = imageElement.style.backgroundImage;
let url = imageElement.src;
return {
timestamp,
@ -63,7 +76,8 @@ function generateTelemetry(start, count) {
"name": stringRep + " Imagery",
"utc": start + (i * ONE_MINUTE),
"url": location.host + '/' + logo + '?time=' + stringRep,
"timeId": stringRep
"timeId": stringRep,
"value": 100
});
}
@ -105,7 +119,51 @@ describe("The Imagery View Layout", () => {
"image": 1,
"priority": 3
},
"source": "url"
"source": "url",
"relatedTelemetry": {
"heading": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "heading",
"valueKey": "value"
}
},
"roll": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "roll",
"valueKey": "value"
}
},
"pitch": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "pitch",
"valueKey": "value"
}
},
"cameraPan": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "cameraPan",
"valueKey": "value"
}
},
"cameraTilt": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "cameraTilt",
"valueKey": "value"
}
},
"sunOrientation": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "sunOrientation",
"valueKey": "value"
}
}
}
},
{
"name": "Name",
@ -151,6 +209,11 @@ describe("The Imagery View Layout", () => {
child = document.createElement('div');
parent.appendChild(child);
spyOn(window, 'ResizeObserver').and.returnValue({
observe() {},
disconnect() {}
});
spyOn(openmct.telemetry, 'request').and.returnValue(Promise.resolve([]));
imageryPlugin = new ImageryPlugin();
@ -213,6 +276,10 @@ describe("The Imagery View Layout", () => {
return done();
});
afterEach(() => {
imageryView.destroy();
});
it("on mount should show the the most recent image", () => {
const imageInfo = getImageInfo(parent);

View File

@ -17,6 +17,7 @@
@import "../plugins/folderView/components/list-item.scss";
@import "../plugins/folderView/components/list-view.scss";
@import "../plugins/imagery/components/imagery-view-layout.scss";
@import "../plugins/imagery/components/Compass/compass.scss";
@import "../plugins/telemetryTable/components/table-row.scss";
@import "../plugins/telemetryTable/components/table-footer-indicator.scss";
@import "../plugins/tabs/components/tabs.scss";