diff --git a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js index 1311597f0a..41aea55c7c 100644 --- a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js +++ b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js @@ -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(); diff --git a/example/imagery/plugin.js b/example/imagery/plugin.js index e193c99afd..2eabb32361 100644 --- a/example/imagery/plugin.js +++ b/example/imagery/plugin.js @@ -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()); diff --git a/src/plugins/imagery/components/ImageThumbnail.vue b/src/plugins/imagery/components/ImageThumbnail.vue index 99ef0febc7..1748c32bb4 100644 --- a/src/plugins/imagery/components/ImageThumbnail.vue +++ b/src/plugins/imagery/components/ImageThumbnail.vue @@ -39,7 +39,7 @@ diff --git a/src/plugins/imagery/components/ImageryTimeView.vue b/src/plugins/imagery/components/ImageryTimeView.vue index ccaf7b71ba..5adfd2d476 100644 --- a/src/plugins/imagery/components/ImageryTimeView.vue +++ b/src/plugins/imagery/components/ImageryTimeView.vue @@ -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); } }); diff --git a/src/plugins/imagery/components/ImageryView.vue b/src/plugins/imagery/components/ImageryView.vue index 5b18cd29d7..20446109aa 100644 --- a/src/plugins/imagery/components/ImageryView.vue +++ b/src/plugins/imagery/components/ImageryView.vue @@ -171,7 +171,7 @@ > { - 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() { diff --git a/src/plugins/imagery/components/imagery-view.scss b/src/plugins/imagery/components/imagery-view.scss index ee0713244b..22e38d9040 100644 --- a/src/plugins/imagery/components/imagery-view.scss +++ b/src/plugins/imagery/components/imagery-view.scss @@ -29,7 +29,7 @@ flex-direction: column; flex: 1 1 auto; - &.unnsynced{ + &.unsynced{ @include sUnsynced(); } diff --git a/src/plugins/imagery/mixins/imageryData.js b/src/plugins/imagery/mixins/imageryData.js index 94118e6813..0e51b6fd35 100644 --- a/src/plugins/imagery/mixins/imageryData.js +++ b/src/plugins/imagery/mixins/imageryData.js @@ -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; } diff --git a/src/plugins/imagery/pluginSpec.js b/src/plugins/imagery/pluginSpec.js index 15a81b09e3..226de27136 100644 --- a/src/plugins/imagery/pluginSpec.js +++ b/src/plugins/imagery/pluginSpec.js @@ -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();