34 - Image Pan and Zoom (#4736)

This commit is contained in:
Michael Rogers 2022-03-23 16:10:50 -05:00 committed by GitHub
parent 0cf30940c8
commit 0f9e727675
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 986 additions and 151 deletions

View File

@ -0,0 +1,217 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, 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.
*****************************************************************************/
/*
This test suite is dedicated to tests which verify the basic operations surrounding imagery,
but only assume that example imagery is present.
*/
const { test, expect } = require('@playwright/test');
test.describe('Example Imagery', () => {
test.beforeEach(async ({ page }) => {
page.on('console', msg => console.log(msg.text()))
//Go to baseURL
await page.goto('/', { waitUntil: 'networkidle' });
//Click the Create button
await page.click('button:has-text("Create")');
// Click text=Example Imagery
await page.click('text=Example Imagery');
// Click text=OK
await Promise.all([
page.waitForNavigation(/*{ url: 'http://localhost:8080/#/browse/mine/dab945d4-5a84-480e-8180-222b4aa730fa?tc.mode=fixed&tc.startBound=1639696164435&tc.endBound=1639697964435&tc.timeSystem=utc&view=conditionSet.view' }*/),
page.click('text=OK')
]);
await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery');
});
const backgroundImageSelector = '.c-imagery__main-image__background-image';
test('Can use Mouse Wheel to zoom in and out of latest image', async ({ page }) => {
const bgImageLocator = await page.locator(backgroundImageSelector);
const deltaYStep = 100; //equivalent to 1x zoom
await bgImageLocator.hover();
const originalImageDimensions = await page.locator(backgroundImageSelector).boundingBox();
// zoom in
await bgImageLocator.hover();
await page.mouse.wheel(0, deltaYStep * 2);
// wait for zoom animation to finish
await bgImageLocator.hover();
const imageMouseZoomedIn = await page.locator(backgroundImageSelector).boundingBox();
// zoom out
await bgImageLocator.hover();
await page.mouse.wheel(0, -deltaYStep);
// wait for zoom animation to finish
await bgImageLocator.hover();
const imageMouseZoomedOut = await page.locator(backgroundImageSelector).boundingBox();
expect(imageMouseZoomedIn.height).toBeGreaterThan(originalImageDimensions.height);
expect(imageMouseZoomedIn.width).toBeGreaterThan(originalImageDimensions.width);
expect(imageMouseZoomedOut.height).toBeLessThan(imageMouseZoomedIn.height);
expect(imageMouseZoomedOut.width).toBeLessThan(imageMouseZoomedIn.width);
});
test('Can use alt+drag to move around image once zoomed in', async ({ page }) => {
const deltaYStep = 100; //equivalent to 1x zoom
const bgImageLocator = await page.locator(backgroundImageSelector);
await bgImageLocator.hover();
// zoom in
await page.mouse.wheel(0, deltaYStep * 2);
await bgImageLocator.hover();
const zoomedBoundingBox = await bgImageLocator.boundingBox();
const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2;
const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2;
// move to the right
// center the mouse pointer
await page.mouse.move(imageCenterX, imageCenterY);
// pan right
await page.keyboard.down('Alt');
await page.mouse.down();
await page.mouse.move(imageCenterX - 200, imageCenterY, 10);
await page.mouse.up();
await page.keyboard.up('Alt');
const afterRightPanBoundingBox = await bgImageLocator.boundingBox();
expect(zoomedBoundingBox.x).toBeGreaterThan(afterRightPanBoundingBox.x);
// pan left
await page.keyboard.down('Alt');
await page.mouse.down();
await page.mouse.move(imageCenterX, imageCenterY, 10);
await page.mouse.up();
await page.keyboard.up('Alt');
const afterLeftPanBoundingBox = await bgImageLocator.boundingBox();
expect(afterRightPanBoundingBox.x).toBeLessThan(afterLeftPanBoundingBox.x);
// pan up
await page.mouse.move(imageCenterX, imageCenterY);
await page.keyboard.down('Alt');
await page.mouse.down();
await page.mouse.move(imageCenterX, imageCenterY + 200, 10);
await page.mouse.up();
await page.keyboard.up('Alt');
const afterUpPanBoundingBox = await bgImageLocator.boundingBox();
expect(afterUpPanBoundingBox.y).toBeGreaterThan(afterLeftPanBoundingBox.y);
// pan down
await page.keyboard.down('Alt');
await page.mouse.down();
await page.mouse.move(imageCenterX, imageCenterY - 200, 10);
await page.mouse.up();
await page.keyboard.up('Alt');
const afterDownPanBoundingBox = await bgImageLocator.boundingBox();
expect(afterDownPanBoundingBox.y).toBeLessThan(afterUpPanBoundingBox.y);
});
test('Can use + - buttons to zoom on the image', async ({ page }) => {
const bgImageLocator = await page.locator(backgroundImageSelector);
await bgImageLocator.hover();
const zoomInBtn = await page.locator('.t-btn-zoom-in');
const zoomOutBtn = await page.locator('.t-btn-zoom-out');
const initialBoundingBox = await bgImageLocator.boundingBox();
await zoomInBtn.click();
await zoomInBtn.click();
// wait for zoom animation to finish
await bgImageLocator.hover();
const zoomedInBoundingBox = await bgImageLocator.boundingBox();
expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height);
expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width);
await zoomOutBtn.click();
// wait for zoom animation to finish
await bgImageLocator.hover();
const zoomedOutBoundingBox = await bgImageLocator.boundingBox();
expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height);
expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width);
});
test('Can use the reset button to reset the image', async ({ page }) => {
const bgImageLocator = await page.locator(backgroundImageSelector);
await bgImageLocator.hover();
const zoomInBtn = await page.locator('.t-btn-zoom-in');
const zoomResetBtn = await page.locator('.t-btn-zoom-reset');
const initialBoundingBox = await bgImageLocator.boundingBox();
await zoomInBtn.click();
await zoomInBtn.click();
// wait for zoom animation to finish
await bgImageLocator.hover();
const zoomedInBoundingBox = await bgImageLocator.boundingBox();
expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height);
expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width);
await zoomResetBtn.click();
await bgImageLocator.hover();
const resetBoundingBox = await bgImageLocator.boundingBox();
expect(resetBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height);
expect(resetBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width);
expect(resetBoundingBox.height).toEqual(initialBoundingBox.height);
expect(resetBoundingBox.width).toEqual(initialBoundingBox.width);
});
//test('Can use Mouse Wheel to zoom in and out of previous image');
//test('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
//test.skip('Can zoom into a previous image from thumbstrip in real-time or fixed-time');
//test.skip('Clicking on the left arrow should pause the imagery and go to previous image');
//test.skip('If the imagery view is in pause mode, it should not be updated when new images come in');
//test.skip('If the imagery view is not in pause mode, it should be updated when new images come in');
});
test.describe('Example Imagery in Display layout', () => {
test.skip('Can use Mouse Wheel to zoom in and out of previous image');
test.skip('Can use alt+drag to move around image once zoomed in');
test.skip('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
test.skip('Clicking on the left arrow should pause the imagery and go to previous image');
test.skip('If the imagery view is in pause mode, it should not be updated when new images come in');
test.skip('If the imagery view is not in pause mode, it should be updated when new images come in');
});
test.describe('Example Imagery in Flexible layout', () => {
test.skip('Can use Mouse Wheel to zoom in and out of previous image');
test.skip('Can use alt+drag to move around image once zoomed in');
test.skip('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
test.skip('Clicking on the left arrow should pause the imagery and go to previous image');
test.skip('If the imagery view is in pause mode, it should not be updated when new images come in');
test.skip('If the imagery view is not in pause mode, it should be updated when new images come in');
});
test.describe('Example Imagery in Tabs view', () => {
test.skip('Can use Mouse Wheel to zoom in and out of previous image');
test.skip('Can use alt+drag to move around image once zoomed in');
test.skip('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
test.skip('Can zoom into a previous image from thumbstrip in real-time or fixed-time');
test.skip('Clicking on the left arrow should pause the imagery and go to previous image');
test.skip('If the imagery view is in pause mode, it should not be updated when new images come in');
test.skip('If the imagery view is not in pause mode, it should be updated when new images come in');
});

View File

@ -0,0 +1,273 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, 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="h-local-controls h-local-controls--overlay-content c-local-controls--show-on-hover c-image-controls__controls">
<div class="c-image-controls__control c-image-controls__zoom icon-magnify">
<div class="c-button-set c-button-set--strip-h">
<button
class="c-button t-btn-zoom-out icon-minus"
title="Zoom out"
@click="zoomOut"
></button>
<button
class="c-button t-btn-zoom-in icon-plus"
title="Zoom in"
@click="zoomIn"
></button>
</div>
<button
class="c-button t-btn-zoom-lock"
title="Lock current zoom and pan across all images"
:class="{'icon-unlocked': !panZoomLocked, 'icon-lock': panZoomLocked}"
@click="toggleZoomLock"
></button>
<button
class="c-button icon-reset t-btn-zoom-reset"
title="Remove zoom and pan"
@click="handleResetImage"
></button>
<span class="c-image-controls__zoom-factor">x{{ formattedZoomFactor }}</span>
</div>
<div class="c-image-controls__control c-image-controls__brightness-contrast">
<span
class="c-image-controls__sliders"
draggable="true"
@dragstart.stop.prevent
>
<div class="c-image-controls__input icon-brightness">
<input
v-model="filters.contrast"
type="range"
min="0"
max="500"
@change="notifyFiltersChanged"
>
</div>
<div class="c-image-controls__input icon-contrast">
<input
v-model="filters.brightness"
type="range"
min="0"
max="500"
@change="notifyFiltersChanged"
>
</div>
</span>
<span class="t-reset-btn-holder c-imagery__lc__reset-btn c-image-controls__btn-reset">
<button
class="c-icon-link icon-reset t-btn-reset"
@click="handleResetFilters"
></button>
</span>
</div>
</div>
</template>
<script>
import _ from 'lodash';
const DEFAULT_FILTER_VALUES = {
brightness: '100',
contrast: '100'
};
const ZOOM_LIMITS_MAX_DEFAULT = 20;
const ZOOM_LIMITS_MIN_DEFAULT = 1;
const ZOOM_STEP = 1;
const ZOOM_WHEEL_SENSITIVITY_REDUCTION = 0.01;
export default {
inject: ['openmct', 'domainObject'],
props: {
zoomFactor: {
type: Number,
required: true
},
imageUrl: String
},
data() {
return {
altPressed: false,
shiftPressed: false,
metaPressed: false,
panZoomLocked: false,
wheelZooming: false,
filters: {
brightness: 100,
contrast: 100
}
};
},
computed: {
formattedZoomFactor() {
return Number.parseFloat(this.zoomFactor).toPrecision(2);
},
cursorStates() {
const isPannable = this.altPressed && this.zoomFactor > 1;
const showCursorZoomIn = this.metaPressed && !this.shiftPressed;
const showCursorZoomOut = this.metaPressed && this.shiftPressed;
const modifierKeyPressed = Boolean(this.metaPressed || this.shiftPressed || this.altPressed);
return {
isPannable,
showCursorZoomIn,
showCursorZoomOut,
modifierKeyPressed
};
}
},
watch: {
imageUrl(newUrl, oldUrl) {
// reset image pan/zoom if newUrl only if not locked
if (newUrl && !this.panZoomLocked) {
this.$emit('resetImage');
}
},
cursorStates(states) {
this.$emit('cursorsUpdated', states);
}
},
mounted() {
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keyup', this.handleKeyUp);
this.clearWheelZoom = _.debounce(this.clearWheelZoom, 600);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
},
methods: {
handleResetImage() {
this.$emit('resetImage');
},
handleUpdatePanZoom(options) {
this.$emit('panZoomUpdated', options);
},
toggleZoomLock() {
this.panZoomLocked = !this.panZoomLocked;
},
notifyFiltersChanged() {
this.$emit('filtersUpdated', this.filters);
},
handleResetFilters() {
this.filters = DEFAULT_FILTER_VALUES;
this.notifyFiltersChanged();
},
limitZoomRange(factor) {
return Math.min(Math.max(ZOOM_LIMITS_MIN_DEFAULT, factor), ZOOM_LIMITS_MAX_DEFAULT);
},
// used to increment the zoom without knowledge of current level
processZoom(increment, userCoordX, userCoordY) {
const newFactor = this.limitZoomRange(this.zoomFactor + increment);
this.zoomImage(newFactor, userCoordX, userCoordY);
},
zoomImage(newScaleFactor, screenClientX, screenClientY) {
if (!(newScaleFactor || Number.isInteger(newScaleFactor))) {
console.error('Scale factor provided is invalid');
return;
}
if (newScaleFactor > ZOOM_LIMITS_MAX_DEFAULT) {
newScaleFactor = ZOOM_LIMITS_MAX_DEFAULT;
}
if (newScaleFactor <= 0 || newScaleFactor <= ZOOM_LIMITS_MIN_DEFAULT) {
return this.handleResetImage();
}
this.handleUpdatePanZoom({
newScaleFactor,
screenClientX,
screenClientY
});
},
wheelZoom(e) {
// only use x,y coordinates on scrolling in
if (this.wheelZooming === false && e.deltaY > 0) {
this.wheelZooming = true;
// grab first x,y coordinates
this.processZoom(e.deltaY * ZOOM_WHEEL_SENSITIVITY_REDUCTION, e.clientX, e.clientY);
} else {
// ignore subsequent event x,y so scroll drift doesn't occur
this.processZoom(e.deltaY * ZOOM_WHEEL_SENSITIVITY_REDUCTION);
}
// debounced method that will only fire after the scroll series is complete
this.clearWheelZoom();
},
/* debounced method so that wheelZooming state will
** remain true through a zoom event series
*/
clearWheelZoom() {
this.wheelZooming = false;
},
handleKeyDown(event) {
if (event.key === 'Alt') {
this.altPressed = true;
}
if (event.metaKey) {
this.metaPressed = true;
}
if (event.shiftKey) {
this.shiftPressed = true;
}
},
handleKeyUp(event) {
if (event.key === 'Alt') {
this.altPressed = false;
}
this.shiftPressed = false;
if (!event.metaKey) {
this.metaPressed = false;
}
},
zoomIn() {
this.processZoom(ZOOM_STEP);
},
zoomOut() {
this.processZoom(-ZOOM_STEP);
},
// attached to onClick listener in ImageryView
handlePanZoomClick(e) {
if (this.altPressed) {
return this.$emit('startPan', e);
}
if (!(this.metaPressed && e.button === 0)) {
return;
}
const newScaleFactor = this.zoomFactor + (this.shiftPressed ? -ZOOM_STEP : ZOOM_STEP);
this.zoomImage(newScaleFactor, e.clientX, e.clientY);
}
}
};
</script>

View File

@ -29,59 +29,78 @@
@mouseover="focusElement" @mouseover="focusElement"
> >
<div class="c-imagery__main-image-wrapper has-local-controls"> <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 c-image-controls__controls"> <ImageControls
<span ref="imageControls"
class="c-image-controls__sliders" :zoom-factor="zoomFactor"
draggable="true" :image-url="imageUrl"
@dragstart="startDrag" @resetImage="resetImage"
> @panZoomUpdated="handlePanZoomUpdate"
<div class="c-image-controls__slider-wrapper icon-brightness"> @filtersUpdated="setFilters"
<input @cursorsUpdated="setCursorStates"
v-model="filters.brightness" @startPan="startPan"
type="range" />
min="0"
max="500"
>
</div>
<div class="c-image-controls__slider-wrapper icon-contrast">
<input
v-model="filters.contrast"
type="range"
min="0"
max="500"
>
</div>
</span>
<span class="t-reset-btn-holder c-imagery__lc__reset-btn c-image-controls__btn-reset">
<a
class="s-icon-button icon-reset t-btn-reset"
@click="filters={brightness: 100, contrast: 100}"
></a>
</span>
</div>
<div <div
ref="imageBG" ref="imageBG"
class="c-imagery__main-image__bg" class="c-imagery__main-image__bg"
:class="{'paused unnsynced': isPaused && !isFixed,'stale':false }" :class="{
'paused unnsynced': isPaused && !isFixed,
'stale': false,
'pannable': cursorStates.isPannable,
'cursor-zoom-in': cursorStates.showCursorZoomIn,
'cursor-zoom-out': cursorStates.showCursorZoomOut
}"
@click="expand" @click="expand"
> >
<div <div
v-if="zoomFactor > 1"
class="c-imagery__hints"
>Alt-drag to pan</div>
<div
ref="focusedImageWrapper"
class="image-wrapper" class="image-wrapper"
:style="{ :style="{
'width': `${sizedImageDimensions.width}px`, 'width': `${sizedImageWidth}px`,
'height': `${sizedImageDimensions.height}px` 'height': `${sizedImageHeight}px`
}" }"
@mousedown="handlePanZoomClick"
> >
<img <img
ref="focusedImage" ref="focusedImage"
class="c-imagery__main-image__image js-imageryView-image" class="c-imagery__main-image__image js-imageryView-image "
:src="imageUrl" :src="imageUrl"
:draggable="!isSelectable"
:style="{ :style="{
'filter': `brightness(${filters.brightness}%) contrast(${filters.contrast}%)` 'filter': `brightness(${filters.brightness}%) contrast(${filters.contrast}%)`
}" }"
:data-openmct-image-timestamp="time" :data-openmct-image-timestamp="time"
:data-openmct-object-keystring="keyString" :data-openmct-object-keystring="keyString"
> >
<div
v-if="imageUrl"
ref="focusedImageElement"
class="c-imagery__main-image__background-image"
:draggable="!isSelectable"
:style="{
'filter': `brightness(${filters.brightness}%) contrast(${filters.contrast}%)`,
'background-image':
`${imageUrl ? (
`url(${imageUrl}),
repeating-linear-gradient(
45deg,
transparent,
transparent 4px,
rgba(125,125,125,.2) 4px,
rgba(125,125,125,.2) 8px
)`
) : ''}`,
'transform': `scale(${zoomFactor}) translate(${imageTranslateX}px, ${imageTranslateY}px)`,
'transition': `${!pan && animateZoom ? 'transform 250ms ease-in' : 'initial'}`,
'width': `${sizedImageWidth}px`,
'height': `${sizedImageHeight}px`,
}"
></div>
<Compass <Compass
v-if="shouldDisplayCompass" v-if="shouldDisplayCompass"
:compass-rose-sizing-classes="compassRoseSizingClasses" :compass-rose-sizing-classes="compassRoseSizingClasses"
@ -134,7 +153,7 @@
v-if="!isFixed" v-if="!isFixed"
class="c-button icon-pause pause-play" class="c-button icon-pause pause-play"
:class="{'is-paused': isPaused}" :class="{'is-paused': isPaused}"
@click="paused(!isPaused, 'button')" @click="paused(!isPaused)"
></button> ></button>
</div> </div>
</div> </div>
@ -156,7 +175,7 @@
:key="image.url + image.time" :key="image.url + image.time"
class="c-imagery__thumb c-thumb" class="c-imagery__thumb c-thumb"
:class="{ selected: focusedImageIndex === index && isPaused }" :class="{ selected: focusedImageIndex === index && isPaused }"
@click="setFocusedImage(index, thumbnailClick)" @click="thumbnailClicked(index)"
> >
<a <a
href="" href=""
@ -182,12 +201,13 @@
</template> </template>
<script> <script>
import eventHelpers from '../lib/eventHelpers';
import _ from 'lodash'; import _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
import RelatedTelemetry from './RelatedTelemetry/RelatedTelemetry'; import RelatedTelemetry from './RelatedTelemetry/RelatedTelemetry';
import Compass from './Compass/Compass.vue'; import Compass from './Compass/Compass.vue';
import ImageControls from './ImageControls.vue';
import imageryData from "../../imagery/mixins/imageryData"; import imageryData from "../../imagery/mixins/imageryData";
const REFRESH_CSS_MS = 500; const REFRESH_CSS_MS = 500;
@ -207,9 +227,12 @@ const ARROW_LEFT = 37;
const SCROLL_LATENCY = 250; const SCROLL_LATENCY = 250;
const ZOOM_SCALE_DEFAULT = 1;
export default { export default {
components: { components: {
Compass Compass,
ImageControls
}, },
mixins: [imageryData], mixins: [imageryData],
inject: ['openmct', 'domainObject', 'objectPath', 'currentView'], inject: ['openmct', 'domainObject', 'objectPath', 'currentView'],
@ -232,10 +255,6 @@ export default {
timeSystem: timeSystem, timeSystem: timeSystem,
keyString: undefined, keyString: undefined,
autoScroll: true, autoScroll: true,
filters: {
brightness: 100,
contrast: 100
},
thumbnailClick: THUMBNAIL_CLICKED, thumbnailClick: THUMBNAIL_CLICKED,
isPaused: false, isPaused: false,
refreshCSS: false, refreshCSS: false,
@ -247,19 +266,37 @@ export default {
focusedImageNaturalAspectRatio: undefined, focusedImageNaturalAspectRatio: undefined,
imageContainerWidth: undefined, imageContainerWidth: undefined,
imageContainerHeight: undefined, imageContainerHeight: undefined,
sizedImageWidth: 0,
sizedImageHeight: 0,
lockCompass: true, lockCompass: true,
resizingWindow: false, resizingWindow: false,
timeContext: undefined timeContext: undefined,
zoomFactor: ZOOM_SCALE_DEFAULT,
filters: {
brightness: 100,
contrast: 100
},
cursorStates: {
isPannable: false,
showCursorZoomIn: false,
showCursorZoomOut: false,
modifierKeyPressed: false
},
imageTranslateX: 0,
imageTranslateY: 0,
pan: undefined,
animateZoom: true,
imagePanned: false
}; };
}, },
computed: { computed: {
compassRoseSizingClasses() { compassRoseSizingClasses() {
let compassRoseSizingClasses = ''; let compassRoseSizingClasses = '';
if (this.sizedImageDimensions.width < 300) { if (this.sizedImageWidth < 300) {
compassRoseSizingClasses = '--rose-small --rose-min'; compassRoseSizingClasses = '--rose-small --rose-min';
} else if (this.sizedImageDimensions.width < 500) { } else if (this.sizedImageWidth < 500) {
compassRoseSizingClasses = '--rose-small'; compassRoseSizingClasses = '--rose-small';
} else if (this.sizedImageDimensions.width > 1000) { } else if (this.sizedImageWidth > 1000) {
compassRoseSizingClasses = '--rose-max'; compassRoseSizingClasses = '--rose-max';
} }
@ -328,10 +365,18 @@ export default {
return result; return result;
}, },
shouldDisplayCompass() { shouldDisplayCompass() {
return this.focusedImage !== undefined const imageHeightAndWidth = this.sizedImageHeight !== 0
&& this.sizedImageWidth !== 0;
const display = this.focusedImage !== undefined
&& this.focusedImageNaturalAspectRatio !== undefined && this.focusedImageNaturalAspectRatio !== undefined
&& this.imageContainerWidth !== undefined && this.imageContainerWidth !== undefined
&& this.imageContainerHeight !== undefined; && this.imageContainerHeight !== undefined
&& imageHeightAndWidth
&& this.zoomFactor === 1
&& this.imagePanned !== true;
return display;
}, },
isSpacecraftPositionFresh() { isSpacecraftPositionFresh() {
let isFresh = undefined; let isFresh = undefined;
@ -393,20 +438,6 @@ export default {
return isFresh; return isFresh;
}, },
sizedImageDimensions() {
let sizedImageDimensions = {};
if ((this.imageContainerWidth / this.imageContainerHeight) > this.focusedImageNaturalAspectRatio) {
// container is wider than image
sizedImageDimensions.width = this.imageContainerHeight * this.focusedImageNaturalAspectRatio;
sizedImageDimensions.height = this.imageContainerHeight;
} else {
// container is taller than image
sizedImageDimensions.width = this.imageContainerWidth;
sizedImageDimensions.height = this.imageContainerWidth / this.focusedImageNaturalAspectRatio;
}
return sizedImageDimensions;
},
isFixed() { isFixed() {
let clock; let clock;
if (this.timeContext) { if (this.timeContext) {
@ -416,6 +447,16 @@ export default {
} }
return clock === undefined; return clock === undefined;
},
isSelectable() {
return true;
},
sizedImageDimensions() {
return {
width: this.sizedImageWidth,
height: this.sizedImageHeight
};
} }
}, },
watch: { watch: {
@ -424,16 +465,35 @@ export default {
const newSize = newHistory.length; const newSize = newHistory.length;
let imageIndex; let imageIndex;
if (this.focusedImageTimestamp !== undefined) { if (this.focusedImageTimestamp !== undefined) {
const foundImageIndex = this.imageHistory.findIndex(image => { const foundImageIndex = newHistory.findIndex(img => img.time === this.focusedImageTimestamp);
return image.time === this.focusedImageTimestamp; imageIndex = foundImageIndex > -1
}); ? foundImageIndex
imageIndex = foundImageIndex > -1 ? foundImageIndex : newSize - 1; : newSize - 1;
} else { } else {
imageIndex = newSize > 0 ? newSize - 1 : undefined; imageIndex = newSize > 0
? newSize - 1
: undefined;
} }
this.setFocusedImage(imageIndex, false); this.nextImageIndex = imageIndex;
this.scrollToRight();
if (this.previousFocusedImage && newHistory.length) {
const matchIndex = this.matchIndexOfPreviousImage(
this.previousFocusedImage,
newHistory
);
if (matchIndex > -1) {
this.setFocusedImage(matchIndex);
} else {
this.paused();
}
}
if (!this.isPaused) {
this.setFocusedImage(imageIndex);
this.scrollToRight();
}
}, },
deep: true deep: true
}, },
@ -445,6 +505,10 @@ export default {
} }
}, },
async mounted() { async mounted() {
eventHelpers.extend(this);
this.focusedImageWrapper = this.$refs.focusedImageWrapper;
this.focusedImageElement = this.$refs.focusedImageElement;
//We only need to use this till the user focuses an image manually //We only need to use this till the user focuses an image manually
if (this.focusedImageTimestamp !== undefined) { if (this.focusedImageTimestamp !== undefined) {
this.isPaused = true; this.isPaused = true;
@ -485,6 +549,7 @@ export default {
this.thumbWrapperResizeObserver.observe(this.$refs.thumbsWrapper); this.thumbWrapperResizeObserver.observe(this.$refs.thumbsWrapper);
} }
this.listenTo(this.focusedImageWrapper, 'wheel', this.wheelZoom, this);
}, },
beforeDestroy() { beforeDestroy() {
this.stopFollowingTimeContext(); this.stopFollowingTimeContext();
@ -509,6 +574,9 @@ export default {
} }
} }
} }
this.stopListening(this.focusedImageWrapper, 'wheel', this.wheelZoom, this);
}, },
methods: { methods: {
setTimeContext() { setTimeContext() {
@ -525,6 +593,11 @@ export default {
} }
}, },
expand() { expand() {
// check for modifier keys so it doesnt interfere with the layout
if (this.cursorStates.modifierKeyPressed) {
return;
}
const actionCollection = this.openmct.actions.getActionsCollection(this.objectPath, this.currentView); const actionCollection = this.openmct.actions.getActionsCollection(this.objectPath, this.currentView);
const visibleActions = actionCollection.getVisibleActions(); const visibleActions = actionCollection.getVisibleActions();
const viewLargeAction = visibleActions const viewLargeAction = visibleActions
@ -630,6 +703,7 @@ export default {
focusElement() { focusElement() {
this.$el.focus(); this.$el.focus();
}, },
handleScroll() { handleScroll() {
const thumbsWrapper = this.$refs.thumbsWrapper; const thumbsWrapper = this.$refs.thumbsWrapper;
if (!thumbsWrapper || this.resizingWindow) { if (!thumbsWrapper || this.resizingWindow) {
@ -640,20 +714,15 @@ export default {
const disableScroll = scrollWidth > Math.ceil(scrollLeft + clientWidth); const disableScroll = scrollWidth > Math.ceil(scrollLeft + clientWidth);
this.autoScroll = !disableScroll; this.autoScroll = !disableScroll;
}, },
paused(state, type) { paused(state) {
this.isPaused = state; this.isPaused = Boolean(state);
if (type === 'button') { if (!state) {
this.setFocusedImage(this.imageHistory.length - 1); this.previousFocusedImage = null;
}
if (this.nextImageIndex) {
this.setFocusedImage(this.nextImageIndex); this.setFocusedImage(this.nextImageIndex);
delete this.nextImageIndex; this.autoScroll = true;
this.scrollToRight();
} }
this.autoScroll = true;
this.scrollToRight();
}, },
scrollToFocused() { scrollToFocused() {
const thumbsWrapper = this.$refs.thumbsWrapper; const thumbsWrapper = this.$refs.thumbsWrapper;
@ -692,51 +761,24 @@ export default {
&& x.time === previous.time && x.time === previous.time
)); ));
}, },
setFocusedImage(index, thumbnailClick = false) { thumbnailClicked(index) {
let focusedIndex = index; this.setFocusedImage(index);
this.paused(true);
this.setPreviousFocusedImage(index);
},
setPreviousFocusedImage(index) {
this.focusedImageTimestamp = undefined;
this.previousFocusedImage = this.imageHistory[index]
? JSON.parse(JSON.stringify(this.imageHistory[index]))
: undefined;
},
setFocusedImage(index) {
if (!(Number.isInteger(index) && index > -1)) { if (!(Number.isInteger(index) && index > -1)) {
return; return;
} }
if (thumbnailClick) { this.focusedImageIndex = index;
//We use the props till the user changes what they want to see
this.focusedImageTimestamp = undefined;
//set the previousFocusedImage when a user chooses an image
this.previousFocusedImage = this.imageHistory[focusedIndex] ? JSON.parse(JSON.stringify(this.imageHistory[focusedIndex])) : undefined;
}
if (this.previousFocusedImage) {
// determine if the previous image exists in the new bounds of imageHistory
if (!thumbnailClick) {
const matchIndex = this.matchIndexOfPreviousImage(
this.previousFocusedImage,
this.imageHistory
);
focusedIndex = matchIndex > -1 ? matchIndex : this.imageHistory.length - 1;
}
if (!(this.isPaused || thumbnailClick)
|| focusedIndex === this.imageHistory.length - 1) {
delete this.previousFocusedImage;
}
}
this.focusedImageIndex = focusedIndex;
//TODO: do we even need this anymore?
if (this.isPaused && !thumbnailClick && this.focusedImageTimestamp === undefined) {
this.nextImageIndex = focusedIndex;
//this could happen if bounds changes
if (this.focusedImageIndex > this.imageHistory.length - 1) {
this.focusedImageIndex = focusedIndex;
}
return;
}
if (thumbnailClick && !this.isPaused) {
this.paused(true);
}
}, },
trackDuration() { trackDuration() {
if (this.canTrackDuration) { if (this.canTrackDuration) {
@ -774,7 +816,7 @@ export default {
let index = this.focusedImageIndex; let index = this.focusedImageIndex;
this.setFocusedImage(++index, THUMBNAIL_CLICKED); this.thumbnailClicked(++index);
if (index === this.imageHistory.length - 1) { if (index === this.imageHistory.length - 1) {
this.paused(false); this.paused(false);
} }
@ -787,14 +829,50 @@ export default {
let index = this.focusedImageIndex; let index = this.focusedImageIndex;
if (index === this.imageHistory.length - 1) { if (index === this.imageHistory.length - 1) {
this.setFocusedImage(this.imageHistory.length - 2, THUMBNAIL_CLICKED); this.thumbnailClicked(this.imageHistory.length - 2);
} else { } else {
this.setFocusedImage(--index, THUMBNAIL_CLICKED); this.thumbnailClicked(--index);
} }
}, },
startDrag(e) { resetImage() {
e.preventDefault(); this.imagePanned = false;
e.stopPropagation(); this.zoomFactor = ZOOM_SCALE_DEFAULT;
this.imageTranslateX = 0;
this.imageTranslateY = 0;
},
handlePanZoomUpdate({ newScaleFactor, screenClientX, screenClientY }) {
if (!this.isPaused) {
this.paused(true);
}
if (!(screenClientX || screenClientY)) {
return this.updatePanZoom(newScaleFactor, 0, 0);
}
// handle mouse events
const imageRect = this.focusedImageWrapper.getBoundingClientRect();
const imageContainerX = screenClientX - imageRect.left;
const imageContainerY = screenClientY - imageRect.top;
const offsetFromCenterX = (imageRect.width / 2) - imageContainerX;
const offsetFromCenterY = (imageRect.height / 2) - imageContainerY;
this.updatePanZoom(newScaleFactor, offsetFromCenterX, offsetFromCenterY);
},
updatePanZoom(newScaleFactor, offsetFromCenterX, offsetFromCenterY) {
const currentScale = this.zoomFactor;
const previousTranslateX = this.imageTranslateX;
const previousTranslateY = this.imageTranslateY;
const offsetXInOriginalScale = offsetFromCenterX / currentScale;
const offsetYInOriginalScale = offsetFromCenterY / currentScale;
const translateX = offsetXInOriginalScale + previousTranslateX;
const translateY = offsetYInOriginalScale + previousTranslateY;
this.imageTranslateX = translateX;
this.imageTranslateY = translateY;
this.zoomFactor = newScaleFactor;
},
handlePanZoomClick(e) {
this.$refs.imageControls.handlePanZoomClick(e);
}, },
arrowDownHandler(event) { arrowDownHandler(event) {
let key = event.keyCode; let key = event.keyCode;
@ -857,7 +935,7 @@ export default {
// TODO - should probably cache this // TODO - should probably cache this
img.addEventListener('load', () => { img.addEventListener('load', () => {
this.focusedImageNaturalAspectRatio = img.naturalWidth / img.naturalHeight; this.setSizedImageDimensions();
}, { once: true }); }, { once: true });
}, },
resizeImageContainer() { resizeImageContainer() {
@ -872,6 +950,21 @@ export default {
if (this.$refs.imageBG.clientHeight !== this.imageContainerHeight) { if (this.$refs.imageBG.clientHeight !== this.imageContainerHeight) {
this.imageContainerHeight = this.$refs.imageBG.clientHeight; this.imageContainerHeight = this.$refs.imageBG.clientHeight;
} }
this.setSizedImageDimensions();
},
setSizedImageDimensions() {
this.focusedImageNaturalAspectRatio = this.$refs.focusedImage.naturalWidth / this.$refs.focusedImage.naturalHeight;
if ((this.imageContainerWidth / this.imageContainerHeight) > this.focusedImageNaturalAspectRatio) {
// container is wider than image
this.sizedImageWidth = this.imageContainerHeight * this.focusedImageNaturalAspectRatio;
this.sizedImageHeight = this.imageContainerHeight;
} else {
// container is taller than image
this.sizedImageWidth = this.imageContainerWidth;
this.sizedImageHeight = this.imageContainerWidth / this.focusedImageNaturalAspectRatio;
}
}, },
handleThumbWindowResizeStart() { handleThumbWindowResizeStart() {
if (!this.autoScroll) { if (!this.autoScroll) {
@ -890,6 +983,73 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.resizingWindow = false; this.resizingWindow = false;
}); });
},
// debounced method
clearWheelZoom() {
this.$refs.imageControls.clearWheelZoom();
},
wheelZoom(e) {
e.preventDefault();
if (!this.isPaused) {
this.paused(true);
}
this.$refs.imageControls.wheelZoom(e);
},
startPan(e) {
e.preventDefault();
if (!this.pan && this.zoomFactor > 1) {
this.animateZoom = false;
this.imagePanned = true;
this.pan = {
x: e.clientX,
y: e.clientY
};
this.listenTo(window, 'mouseup', this.onMouseUp, this);
this.listenTo(window, 'mousemove', this.trackMousePosition, this);
}
return false;
},
trackMousePosition(e) {
if (!e.altKey) {
return this.onMouseUp(e);
}
this.updatePan(e);
e.preventDefault();
},
updatePan(e) {
if (!this.pan) {
return;
}
const dX = e.clientX - this.pan.x;
const dY = e.clientY - this.pan.y;
this.pan = {
x: e.clientX,
y: e.clientY
};
this.updatePanZoom(this.zoomFactor, dX, dY);
},
endPan() {
this.pan = undefined;
this.animateZoom = true;
},
onMouseUp(event) {
this.stopListening(window, 'mouseup', this.onMouseUp, this);
this.stopListening(window, 'mousemove', this.trackMousePosition, this);
if (this.pan) {
return this.endPan(event);
}
},
setFilters(filtersObj) {
this.filters = filtersObj;
},
setCursorStates(states) {
this.cursorStates = states;
} }
} }
}; };

View File

@ -18,6 +18,11 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.image-wrapper {
overflow: visible clip;
background-image: repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(125, 125, 125, 0.2) 4px, rgba(125, 125, 125, 0.2) 8px);
}
&__main-image { &__main-image {
&__bg { &__bg {
background-color: $colorPlotBg; background-color: $colorPlotBg;
@ -27,18 +32,46 @@
justify-content: center; justify-content: center;
flex: 1 1 auto; flex: 1 1 auto;
height: 0; height: 0;
overflow: hidden;
&.unnsynced{ &.unnsynced{
@include sUnsynced(); @include sUnsynced();
} }
} &.cursor-zoom-in {
cursor: zoom-in;
}
&.cursor-zoom-out {
cursor: zoom-out;
}
&.pannable {
@include cursorGrab();
}
}
&__background-image {
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
&__image { &__image {
height: 100%; height: 100%;
width: 100%; width: 100%;
visibility: hidden;
display: contents;
} }
} }
&__hints {
$m: $interiorMargin;
background: rgba(black, 0.2);
border-radius: $smallCr;
padding: 2px $interiorMargin;
position: absolute;
right: $m;
top: $m;
opacity: 0.9;
z-index: 2;
}
&__control-bar, &__control-bar,
&__time { &__time {
display: flex; display: flex;
@ -177,8 +210,8 @@
z-index: 2; z-index: 2;
background: $colorLocalControlOvrBg; background: $colorLocalControlOvrBg;
border-radius: $basicCr; border-radius: $basicCr;
max-width: 200px; max-width: 250px;
min-width: 70px; min-width: 170px;
width: 35%; width: 35%;
align-items: center; align-items: center;
padding: $interiorMargin $interiorMarginLg; padding: $interiorMargin $interiorMarginLg;
@ -202,6 +235,7 @@
&__lc { &__lc {
&__reset-btn { &__reset-btn {
// Span that holds bracket graphics and button
$bc: $scrollbarTrackColorBg; $bc: $scrollbarTrackColorBg;
&:before, &:before,
@ -222,18 +256,50 @@
border-bottom: 1px solid $bc; border-bottom: 1px solid $bc;
margin-top: 2px; margin-top: 2px;
} }
.c-icon-link {
color: $colorBtnFg;
}
} }
} }
} }
.c-image-controls { .c-image-controls {
// Brightness/contrast
&__controls { &__controls {
// Sliders and reset element display: flex;
align-items: stretch;
flex-direction: column;
> * + * {
margin-top: $interiorMargin;
}
[class*='c-button'] { flex: 0 0 auto; }
}
&__control,
&__input {
display: flex; display: flex;
align-items: center; align-items: center;
margin-right: $interiorMargin; // Need some extra space due to proximity to close button width: 100%;
&:before {
color: rgba($colorMenuFg, 0.5);
margin-right: $interiorMarginSm;
}
}
&__input {
// A wrapper is needed to add the type icon to left of each control
input[type='range'] {
//width: 100%; // Do we need this?
}
}
&__zoom {
> * + * { margin-left: $interiorMargin; }
} }
&__sliders { &__sliders {
@ -246,21 +312,6 @@
} }
} }
&__slider-wrapper {
// A wrapper is needed to add the type icon to left of each range input
display: flex;
align-items: center;
&:before {
color: rgba($colorMenuFg, 0.5);
margin-right: $interiorMarginSm;
}
input[type='range'] {
width: 100px;
}
}
&__btn-reset { &__btn-reset {
flex: 0 0 auto; flex: 0 0 auto;
} }

View File

@ -0,0 +1,98 @@
/*****************************************************************************
* 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.
*****************************************************************************/
define([], function () {
const helperFunctions = {
listenTo: function (object, event, callback, context) {
if (!this._listeningTo) {
this._listeningTo = [];
}
const listener = {
object: object,
event: event,
callback: callback,
context: context,
_cb: context ? callback.bind(context) : callback
};
if (object.$watch && event.indexOf('change:') === 0) {
const scopePath = event.replace('change:', '');
listener.unlisten = object.$watch(scopePath, listener._cb, true);
} else if (object.$on) {
listener.unlisten = object.$on(event, listener._cb);
} else if (object.addEventListener) {
object.addEventListener(event, listener._cb);
} else {
object.on(event, listener._cb);
}
this._listeningTo.push(listener);
},
stopListening: function (object, event, callback, context) {
if (!this._listeningTo) {
this._listeningTo = [];
}
this._listeningTo.filter(function (listener) {
if (object && object !== listener.object) {
return false;
}
if (event && event !== listener.event) {
return false;
}
if (callback && callback !== listener.callback) {
return false;
}
if (context && context !== listener.context) {
return false;
}
return true;
})
.map(function (listener) {
if (listener.unlisten) {
listener.unlisten();
} else if (listener.object.removeEventListener) {
listener.object.removeEventListener(listener.event, listener._cb);
} else {
listener.object.off(listener.event, listener._cb);
}
return listener;
})
.forEach(function (listener) {
this._listeningTo.splice(this._listeningTo.indexOf(listener), 1);
}, this);
},
extend: function (object) {
object.listenTo = helperFunctions.listenTo;
object.stopListening = helperFunctions.stopListening;
}
};
return helperFunctions;
});

View File

@ -483,6 +483,42 @@ describe("The Imagery View Layouts", () => {
}); });
}); });
}); });
xit('should change the image zoom factor when using the zoom buttons', async (done) => {
await Vue.nextTick();
let imageSizeBefore;
let imageSizeAfter;
// test clicking the zoom in button
imageSizeBefore = parent.querySelector('.c-imagery_main-image_background-image').getBoundingClientRect();
parent.querySelector('.t-btn-zoom-in').click();
await Vue.nextTick();
imageSizeAfter = parent.querySelector('.c-imagery_main-image_background-image').getBoundingClientRect();
expect(imageSizeAfter.height).toBeGreaterThan(imageSizeBefore.height);
expect(imageSizeAfter.width).toBeGreaterThan(imageSizeBefore.width);
// test clicking the zoom out button
imageSizeBefore = parent.querySelector('.c-imagery_main-image_background-image').getBoundingClientRect();
parent.querySelector('.t-btn-zoom-out').click();
await Vue.nextTick();
imageSizeAfter = parent.querySelector('.c-imagery_main-image_background-image').getBoundingClientRect();
expect(imageSizeAfter.height).toBeLessThan(imageSizeBefore.height);
expect(imageSizeAfter.width).toBeLessThan(imageSizeBefore.width);
done();
});
xit('should reset the zoom factor on the image when clicking the zoom button', async (done) => {
await Vue.nextTick();
// test clicking the zoom reset button
// zoom in to scale up the image dimensions
parent.querySelector('.t-btn-zoom-in').click();
await Vue.nextTick();
let imageSizeBefore = parent.querySelector('.c-imagery_main-image_background-image').getBoundingClientRect();
await Vue.nextTick();
parent.querySelector('.t-btn-zoom-reset').click();
let imageSizeAfter = parent.querySelector('.c-imagery_main-image_background-image').getBoundingClientRect();
expect(imageSizeAfter.height).toBeLessThan(imageSizeBefore.height);
expect(imageSizeAfter.width).toBeLessThan(imageSizeBefore.width);
done();
});
it('clear data action is installed', () => { it('clear data action is installed', () => {
expect(clearDataAction).toBeDefined(); expect(clearDataAction).toBeDefined();
}); });