feat: Support thumbnails in ImageryView and ImageryTimeView (#6132)

* fix: get image thumbnail formatter

* refactor: ImageryView cleanup

* docs: add comment

* feat: Support thumbnails in ImageryView

- Prefer an image's thumbnail URL if its available

* feat: Support thumbnails in ImageryTimeView

* refactor: rename variable

* test(WIP): add thumbnail unit test, not working yet

* test: temp disable test

* feat: imagery thumbnail urls for example imagery

* test: add unit test for imagery thumbnails

* test(e2e): check for thumbnail urls

- Update imagery view tests to check for use of thumbnail urls
This commit is contained in:
Jesse Mazzella 2023-01-21 11:25:35 -08:00 committed by GitHub
parent 986c596d90
commit 5e530aa625
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 166 additions and 73 deletions

View File

@ -25,13 +25,13 @@ This test suite is dedicated to tests which verify the basic operations surround
but only assume that example imagery is present.
*/
/* globals process */
const { v4: uuid } = require('uuid');
const { waitForAnimations } = require('../../../../baseFixtures');
const { test, expect } = require('../../../../pluginFixtures');
const { createDomainObjectWithDefaults } = require('../../../../appActions');
const backgroundImageSelector = '.c-imagery__main-image__background-image';
const panHotkey = process.platform === 'linux' ? ['Control', 'Alt'] : ['Alt'];
const expectedAltText = process.platform === 'linux' ? 'Ctrl+Alt drag to pan' : 'Alt drag to pan';
const thumbnailUrlParamsRegexp = /\?w=100&h=100/;
//The following block of tests verifies the basic functionality of example imagery and serves as a template for Imagery objects embedded in other objects.
test.describe('Example Imagery Object', () => {
@ -397,13 +397,11 @@ test.describe('Example Imagery in Time Strip', () => {
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'networkidle' });
timeStripObject = await createDomainObjectWithDefaults(page, {
type: 'Time Strip',
name: 'Time Strip'.concat(' ', uuid())
type: 'Time Strip'
});
await createDomainObjectWithDefaults(page, {
type: 'Example Imagery',
name: 'Example Imagery'.concat(' ', uuid()),
parent: timeStripObject.uuid
});
// Navigate to timestrip
@ -414,17 +412,28 @@ test.describe('Example Imagery in Time Strip', () => {
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5632'
});
// Hover over the timestrip to reveal a thumbnail image
await page.locator('.c-imagery-tsv-container').hover();
// get url of the hovered image
const hoveredImg = page.locator('.c-imagery-tsv div.c-imagery-tsv__image-wrapper:hover img');
const hoveredImgSrc = await hoveredImg.getAttribute('src');
expect(hoveredImgSrc).toBeTruthy();
// Get the img src of the hovered image thumbnail
const hoveredThumbnailImg = page.locator('.c-imagery-tsv div.c-imagery-tsv__image-wrapper:hover img');
const hoveredThumbnailImgSrc = await hoveredThumbnailImg.getAttribute('src');
// Verify that imagery timestrip view uses the thumbnailUrl as img src for thumbnails
expect(hoveredThumbnailImgSrc).toBeTruthy();
expect(hoveredThumbnailImgSrc).toMatch(thumbnailUrlParamsRegexp);
// Click on the hovered thumbnail to open "View Large" view
await page.locator('.c-imagery-tsv-container').click();
// get image of view large container
// Get the img src of the large view image
const viewLargeImg = page.locator('img.c-imagery__main-image__image');
const viewLargeImgSrc = await viewLargeImg.getAttribute('src');
expect(viewLargeImgSrc).toBeTruthy();
expect(viewLargeImgSrc).toEqual(hoveredImgSrc);
// Verify that the image in the large view is the same as the hovered thumbnail
expect(viewLargeImgSrc).toEqual(hoveredThumbnailImgSrc.split('?')[0]);
});
});
@ -441,6 +450,12 @@ test.describe('Example Imagery in Time Strip', () => {
* @param {import('@playwright/test').Page} page
*/
async function performImageryViewOperationsAndAssert(page) {
// Verify that imagery thumbnails use a thumbnail url
const thumbnailImages = page.locator('.c-thumb__image');
const mainImage = page.locator('.c-imagery__main-image__image');
await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp);
await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp);
// Click previous image button
const previousImageButton = page.locator('.c-nav--prev');
await previousImageButton.click();

View File

@ -107,6 +107,15 @@ export default function () {
}
]
},
{
name: 'Image Thumbnail',
key: 'thumbnail-url',
format: 'thumbnail',
hints: {
thumbnail: 1
},
source: 'url'
},
{
name: 'Image Download Name',
key: 'imageDownloadName',
@ -143,6 +152,16 @@ export default function () {
]
});
const formatThumbnail = {
format: function (url) {
return `${url}?w=100&h=100`;
}
};
openmct.telemetry.addFormat({
key: 'thumbnail',
...formatThumbnail
});
openmct.telemetry.addProvider(getRealtimeProvider());
openmct.telemetry.addProvider(getHistoricalProvider());
openmct.telemetry.addProvider(getLadProvider());

View File

@ -39,7 +39,7 @@
<img
ref="img"
class="c-thumb__image"
:src="image.url"
:src="`${image.thumbnailUrl || image.url}`"
fetchpriority="low"
@load="imageLoadCompleted"
>

View File

@ -186,17 +186,17 @@ export default {
item.remove();
});
let imagery = this.$el.querySelectorAll(`.${IMAGE_WRAPPER_CLASS}`);
imagery.forEach(item => {
imagery.forEach(imageElm => {
if (clearAllImagery) {
item.remove();
imageElm.remove();
} else {
const id = item.getAttributeNS(null, 'id');
const id = imageElm.getAttributeNS(null, 'id');
if (id) {
const timestamp = id.replace(ID_PREFIX, '');
if (!this.isImageryInBounds({
time: timestamp
})) {
item.remove();
imageElm.remove();
}
}
}
@ -343,25 +343,25 @@ export default {
imageElement.style.display = 'block';
}
},
updateExistingImageWrapper(existingImageWrapper, item, showImagePlaceholders) {
updateExistingImageWrapper(existingImageWrapper, image, showImagePlaceholders) {
//Update the x co-ordinates of the image wrapper and the url of image
//this is to avoid tearing down all elements completely and re-drawing them
this.setNSAttributesForElement(existingImageWrapper, {
'data-show-image-placeholders': showImagePlaceholders
});
existingImageWrapper.style.left = `${this.xScale(item.time)}px`;
existingImageWrapper.style.left = `${this.xScale(image.time)}px`;
let imageElement = existingImageWrapper.querySelector('img');
this.setNSAttributesForElement(imageElement, {
src: item.url
src: image.thumbnailUrl || image.url
});
this.setImageDisplay(imageElement, showImagePlaceholders);
},
createImageWrapper(index, item, showImagePlaceholders) {
const id = `${ID_PREFIX}${item.time}`;
createImageWrapper(index, image, showImagePlaceholders) {
const id = `${ID_PREFIX}${image.time}`;
let imageWrapper = document.createElement('div');
imageWrapper.classList.add(IMAGE_WRAPPER_CLASS);
imageWrapper.style.left = `${this.xScale(item.time)}px`;
imageWrapper.style.left = `${this.xScale(image.time)}px`;
this.setNSAttributesForElement(imageWrapper, {
id,
'data-show-image-placeholders': showImagePlaceholders
@ -383,7 +383,7 @@ export default {
//create image element
let imageElement = document.createElement('img');
this.setNSAttributesForElement(imageElement, {
src: item.url
src: image.thumbnailUrl || image.url
});
imageElement.style.width = `${IMAGE_SIZE}px`;
imageElement.style.height = `${IMAGE_SIZE}px`;
@ -392,7 +392,7 @@ export default {
//handle mousedown event to show the image in a large view
imageWrapper.addEventListener('mousedown', (e) => {
if (e.button === 0) {
this.expand(item.time);
this.expand(image.time);
}
});

View File

@ -171,7 +171,7 @@
>
<ImageThumbnail
v-for="(image, index) in imageHistory"
:key="image.url + image.time"
:key="`${image.thumbnailUrl || image.url}${image.time}`"
:image="image"
:active="focusedImageIndex === index"
:selected="focusedImageIndex === index && isPaused"
@ -225,6 +225,9 @@ const SHOW_THUMBS_FULLSIZE_THRESHOLD_HEIGHT = 600;
const IMAGE_CONTAINER_BORDER_WIDTH = 1;
const DEFAULT_IMAGE_PAN_ALT_TEXT = "Alt drag to pan";
const LINUX_IMAGE_PAN_ALT_TEXT = `Ctrl+${DEFAULT_IMAGE_PAN_ALT_TEXT}`;
export default {
name: 'ImageryView',
components: {
@ -308,8 +311,8 @@ export default {
},
focusImageStyles() {
return {
'filter': `brightness(${this.filters.brightness}%) contrast(${this.filters.contrast}%)`,
'background-image':
filter: `brightness(${this.filters.brightness}%) contrast(${this.filters.contrast}%)`,
backgroundImage:
`${this.imageUrl ? (
`url(${this.imageUrl}),
repeating-linear-gradient(
@ -320,10 +323,10 @@ export default {
rgba(125,125,125,.2) 8px
)`
) : ''}`,
'transform': `scale(${this.zoomFactor}) translate(${this.imageTranslateX}px, ${this.imageTranslateY}px)`,
'transition': `${!this.pan && this.animateZoom ? 'transform 250ms ease-in' : 'initial'}`,
'width': `${this.sizedImageWidth}px`,
'height': `${this.sizedImageHeight}px`
transform: `scale(${this.zoomFactor}) translate(${this.imageTranslateX}px, ${this.imageTranslateY}px)`,
transition: `${!this.pan && this.animateZoom ? 'transform 250ms ease-in' : 'initial'}`,
width: `${this.sizedImageWidth}px`,
height: `${this.sizedImageHeight}px`
};
},
time() {
@ -334,11 +337,12 @@ export default {
},
imageWrapperStyle() {
return {
'cursor-zoom-in': this.cursorStates.showCursorZoomIn,
'cursor-zoom-out': this.cursorStates.showCursorZoomOut,
'pannable': this.cursorStates.isPannable,
'paused unnsynced': this.isPaused && !this.isFixed,
'stale': false
cursorZoomIn: this.cursorStates.showCursorZoomIn,
cursorZoomOut: this.cursorStates.showCursorZoomOut,
pannable: this.cursorStates.isPannable,
paused: this.isPaused && !this.isFixed,
unsynced: this.isPaused && !this.isFixed,
stale: false
};
},
isImageNew() {
@ -515,10 +519,10 @@ export default {
const navigator = window.navigator.userAgent;
if (regexLinux.test(navigator)) {
return 'Ctrl+Alt drag to pan';
return LINUX_IMAGE_PAN_ALT_TEXT;
}
return 'Alt drag to pan';
return DEFAULT_IMAGE_PAN_ALT_TEXT;
},
viewableArea() {
if (this.zoomFactor === 1) {
@ -679,9 +683,9 @@ export default {
},
getVisibleLayerStyles(layer) {
return {
'background-image': `url(${layer.source})`,
'transform': `scale(${this.zoomFactor}) translate(${this.imageTranslateX}px, ${this.imageTranslateY}px)`,
'transition': `${!this.pan && this.animateZoom ? 'transform 250ms ease-in' : 'initial'}`
backgroundImage: `url(${layer.source})`,
transform: `scale(${this.zoomFactor}) translate(${this.imageTranslateX}px, ${this.imageTranslateY}px)`,
transition: `${!this.pan && this.animateZoom ? 'transform 250ms ease-in' : 'initial'}`
};
},
setTimeContext() {
@ -759,26 +763,26 @@ export default {
return mostRecent[valueKey];
},
loadVisibleLayers() {
const metaDataValues = this.metadata.valuesForHints(['image'])[0];
this.imageFormat = this.openmct.telemetry.getValueFormatter(metaDataValues);
let layersMetadata = metaDataValues.layers;
if (layersMetadata) {
this.layers = layersMetadata;
if (this.domainObject.configuration) {
let persistedLayers = this.domainObject.configuration.layers;
layersMetadata.forEach((layer) => {
const persistedLayer = persistedLayers.find(object => object.name === layer.name);
if (persistedLayer) {
layer.visible = persistedLayer.visible === true;
}
});
this.visibleLayers = this.layers.filter(layer => layer.visible);
} else {
this.visibleLayers = [];
this.layers.forEach((layer) => {
layer.visible = false;
});
}
const layersMetadata = this.imageMetadataValue.layers;
if (!layersMetadata) {
return;
}
this.layers = layersMetadata;
if (this.domainObject.configuration) {
const persistedLayers = this.domainObject.configuration.layers;
layersMetadata.forEach((layer) => {
const persistedLayer = persistedLayers.find(object => object.name === layer.name);
if (persistedLayer) {
layer.visible = persistedLayer.visible === true;
}
});
this.visibleLayers = this.layers.filter(layer => layer.visible);
} else {
this.visibleLayers = [];
this.layers.forEach((layer) => {
layer.visible = false;
});
}
},
persistVisibleLayers() {

View File

@ -29,7 +29,7 @@
flex-direction: column;
flex: 1 1 auto;
&.unnsynced{
&.unsynced{
@include sUnsynced();
}

View File

@ -21,6 +21,9 @@
*****************************************************************************/
const DEFAULT_DURATION_FORMATTER = 'duration';
const IMAGE_HINT_KEY = 'image';
const IMAGE_THUMBNAIL_HINT_KEY = 'thumbnail';
const IMAGE_DOWNLOAD_NAME_HINT_KEY = 'imageDownloadName';
export default {
inject: ['openmct', 'domainObject', 'objectPath'],
@ -32,13 +35,20 @@ export default {
this.setDataTimeContext();
this.openmct.objectViews.on('clearData', this.dataCleared);
// set
// Get metadata and formatters
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.imageMetadataValue = { ...this.metadata.valuesForHints([IMAGE_HINT_KEY])[0] };
this.imageFormatter = this.getFormatter(this.imageMetadataValue.key);
this.imageThumbnailMetadataValue = { ...this.metadata.valuesForHints([IMAGE_THUMBNAIL_HINT_KEY])[0] };
this.imageThumbnailFormatter = this.imageThumbnailMetadataValue.key
? this.getFormatter(this.imageThumbnailMetadataValue.key)
: null;
this.durationFormatter = this.getFormatter(this.timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
this.imageFormatter = this.openmct.telemetry.getValueFormatter(this.imageHints);
this.imageDownloadNameHints = { ...this.metadata.valuesForHints(['imageDownloadName'])[0]};
this.imageDownloadNameMetadataValue = { ...this.metadata.valuesForHints([IMAGE_DOWNLOAD_NAME_HINT_KEY])[0]};
// initialize
this.timeKey = this.timeSystem.key;
@ -105,12 +115,19 @@ export default {
return this.imageFormatter.format(datum);
},
formatImageThumbnailUrl(datum) {
if (!datum || !this.imageThumbnailFormatter) {
return;
}
return this.imageThumbnailFormatter.format(datum);
},
formatTime(datum) {
if (!datum) {
return;
}
let dateTimeStr = this.timeFormatter.format(datum);
const dateTimeStr = this.timeFormatter.format(datum);
// Replace ISO "T" with a space to allow wrapping
return dateTimeStr.replace("T", " ");
@ -118,7 +135,7 @@ export default {
getImageDownloadName(datum) {
let imageDownloadName = '';
if (datum) {
const key = this.imageDownloadNameHints.key;
const key = this.imageDownloadNameMetadataValue.key;
imageDownloadName = datum[key];
}
@ -150,6 +167,7 @@ export default {
normalizeDatum(datum) {
const formattedTime = this.formatTime(datum);
const url = this.formatImageUrl(datum);
const thumbnailUrl = this.formatImageThumbnailUrl(datum);
const time = this.parseTime(formattedTime);
const imageDownloadName = this.getImageDownloadName(datum);
@ -157,13 +175,14 @@ export default {
...datum,
formattedTime,
url,
thumbnailUrl,
time,
imageDownloadName
};
},
getFormatter(key) {
let metadataValue = this.metadata.value(key) || { format: key };
let valueFormatter = this.openmct.telemetry.getValueFormatter(metadataValue);
const metadataValue = this.metadata.value(key) || { format: key };
const valueFormatter = this.openmct.telemetry.getValueFormatter(metadataValue);
return valueFormatter;
}

View File

@ -35,6 +35,10 @@ const MAIN_IMAGE_CLASS = '.js-imageryView-image';
const NEW_IMAGE_CLASS = '.c-imagery__age.c-imagery--new';
const REFRESH_CSS_MS = 500;
function formatThumbnail(url) {
return url.replace('logo-openmct.svg', 'logo-nasa.svg');
}
function getImageInfo(doc) {
let imageElement = doc.querySelectorAll(MAIN_IMAGE_CLASS)[0];
let timestamp = imageElement.dataset.openmctImageTimestamp;
@ -124,6 +128,16 @@ describe("The Imagery View Layouts", () => {
},
"source": "url"
},
{
"name": "Image Thumbnail",
"key": "thumbnail-url",
"format": "thumbnail",
"hints": {
"thumbnail": 1,
"priority": 3
},
"source": "url"
},
{
"name": "Name",
"key": "name",
@ -200,6 +214,11 @@ describe("The Imagery View Layouts", () => {
originalRouterPath = openmct.router.path;
openmct.telemetry.addFormat({
key: 'thumbnail',
format: formatThumbnail
});
openmct.on('start', done);
openmct.startHeadless();
});
@ -384,15 +403,32 @@ describe("The Imagery View Layouts", () => {
//Looks like we need Vue.nextTick here so that computed properties settle down
await Vue.nextTick();
const layerEls = parent.querySelectorAll('.js-layer-image');
console.log(layerEls);
expect(layerEls.length).toEqual(1);
});
it("should use the image thumbnailUrl for thumbnails", async () => {
await Vue.nextTick();
const fullSizeImageUrl = imageTelemetry[5].url;
const thumbnailUrl = formatThumbnail(imageTelemetry[5].url);
// Ensure thumbnails are shown w/ thumbnail Urls
const thumbnails = parent.querySelectorAll(`img[src='${thumbnailUrl}']`);
expect(thumbnails.length).toBeGreaterThan(0);
// Click a thumbnail
parent.querySelectorAll(`img[src='${thumbnailUrl}']`)[0].click();
await Vue.nextTick();
// Ensure full size image is shown w/ full size url
const fullSizeImages = parent.querySelectorAll(`img[src='${fullSizeImageUrl}']`);
expect(fullSizeImages.length).toBeGreaterThan(0);
});
it("should show the clicked thumbnail as the main image", async () => {
//Looks like we need Vue.nextTick here so that computed properties settle down
await Vue.nextTick();
const target = imageTelemetry[5].url;
parent.querySelectorAll(`img[src='${target}']`)[0].click();
const thumbnailUrl = formatThumbnail(imageTelemetry[5].url);
parent.querySelectorAll(`img[src='${thumbnailUrl}']`)[0].click();
await Vue.nextTick();
const imageInfo = getImageInfo(parent);
@ -417,7 +453,7 @@ describe("The Imagery View Layouts", () => {
it("should show that an image is not new", async () => {
await Vue.nextTick();
const target = imageTelemetry[4].url;
const target = formatThumbnail(imageTelemetry[4].url);
parent.querySelectorAll(`img[src='${target}']`)[0].click();
await Vue.nextTick();