Compare commits
	
		
			68 Commits
		
	
	
		
			release/3.
			...
			image-tagg
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ![Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC]](/assets/img/avatar_default.png)  | 92cba55f88 | ||
| ![Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC]](/assets/img/avatar_default.png)  | def37263ea | ||
|   | a131fd185a | ||
|   | 929b5fb8f0 | ||
|   | 01cc501f6a | ||
|   | 16dd43d1c8 | ||
|   | 6878f06b03 | ||
|   | f61eb0a2e9 | ||
|   | a5e3317f8e | ||
|   | 7b0d1d9c8c | ||
|   | d32913c20f | ||
|   | 15e30f52bc | ||
|   | 076c1425a8 | ||
|   | 5be9a5d04f | ||
|   | 7de80778e3 | ||
|   | bfa82abc25 | ||
|   | 9a0923801b | ||
| ![Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC]](/assets/img/avatar_default.png)  | 11295a8042 | ||
|   | ed7e85c8a0 | ||
|   | ec90d4d92a | ||
|   | cfe0c7f68e | ||
|   | 12b7c0e805 | ||
|   | 39c4e581ac | ||
| ![Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC]](/assets/img/avatar_default.png)  | 8d8dd34853 | ||
|   | c530c2998a | ||
| ![Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC]](/assets/img/avatar_default.png)  | 37dd237055 | ||
| ![Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC]](/assets/img/avatar_default.png)  | c7542d0052 | ||
| ![Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC]](/assets/img/avatar_default.png)  | f4007d3dfc | ||
| ![Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC]](/assets/img/avatar_default.png)  | b51654efbb | ||
| ![Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC]](/assets/img/avatar_default.png)  | 3f2c63c9d7 | ||
|   | e35d7a085b | ||
| ![Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC]](/assets/img/avatar_default.png)  | 5152583c3b | ||
|   | fbb37ac382 | ||
|   | 71c1fdc298 | ||
|   | efb55ecafc | ||
|   | 609dcb0460 | ||
|   | 3bff7f9f32 | ||
|   | 09df2f64f2 | ||
|   | 6b2adcb7b7 | ||
|   | 2f310a3432 | ||
|   | 02855d2c9c | ||
|   | 6dd6c87ceb | ||
|   | 8945f27eed | ||
|   | 5ddf8a8ff4 | ||
|   | 3a77efb010 | ||
|   | bd356653db | ||
|   | d1e8b3835d | ||
|   | 051a0adbb7 | ||
|   | 87d695a454 | ||
|   | 85482902be | ||
|   | 8015aceaa7 | ||
|   | a381673f21 | ||
|   | 7a808622ae | ||
|   | 5fb78a9604 | ||
|   | 629e884c9b | ||
|   | a8949d39bf | ||
|   | 91ad130f8b | ||
|   | 72d8779736 | ||
|   | e34093eda7 | ||
|   | 501fdf902b | ||
|   | cb32dd94f8 | ||
|   | 2d868cdb58 | ||
|   | 424a2b30ac | ||
|   | 693b8804ba | ||
|   | 9018dcd319 | ||
|   | 0aaa7998f5 | ||
|   | d426ae86b8 | ||
|   | 002d8d11e8 | 
| @@ -67,7 +67,6 @@ const config = { | ||||
|       MCT: path.join(projectRootDir, 'src/MCT'), | ||||
|       testUtils: path.join(projectRootDir, 'src/utils/testUtils.js'), | ||||
|       objectUtils: path.join(projectRootDir, 'src/api/objects/object-utils.js'), | ||||
|       kdbush: path.join(projectRootDir, 'node_modules/kdbush/kdbush.min.js'), | ||||
|       utils: path.join(projectRootDir, 'src/utils') | ||||
|     } | ||||
|   }, | ||||
|   | ||||
| @@ -23,5 +23,5 @@ module.exports = merge(common, { | ||||
|       __OPENMCT_ROOT_RELATIVE__: '""' | ||||
|     }) | ||||
|   ], | ||||
|   devtool: 'source-map' | ||||
|   devtool: 'eval-source-map' | ||||
| }); | ||||
|   | ||||
| @@ -205,6 +205,71 @@ test.describe('Display Layout', () => { | ||||
|  | ||||
|     expect(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); | ||||
|   }); | ||||
|  | ||||
|   test('When multiple plots are contained in a layout, we only ask for annotations once @couchdb', async ({ | ||||
|     page | ||||
|   }) => { | ||||
|     // Create another Sine Wave Generator | ||||
|     const anotherSineWaveObject = await createDomainObjectWithDefaults(page, { | ||||
|       type: 'Sine Wave Generator' | ||||
|     }); | ||||
|     // Create a Display Layout | ||||
|     await createDomainObjectWithDefaults(page, { | ||||
|       type: 'Display Layout', | ||||
|       name: 'Test Display Layout' | ||||
|     }); | ||||
|     // Edit Display Layout | ||||
|     await page.locator('[title="Edit"]').click(); | ||||
|  | ||||
|     // Expand the 'My Items' folder in the left tree | ||||
|     await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); | ||||
|     // Add the Sine Wave Generator to the Display Layout and save changes | ||||
|     const treePane = page.getByRole('tree', { | ||||
|       name: 'Main Tree' | ||||
|     }); | ||||
|     const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { | ||||
|       name: new RegExp(sineWaveObject.name) | ||||
|     }); | ||||
|  | ||||
|     let layoutGridHolder = page.locator('.l-layout__grid-holder'); | ||||
|     // eslint-disable-next-line playwright/no-force-option | ||||
|     await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true }); | ||||
|  | ||||
|     await page.getByText('View type').click(); | ||||
|     await page.getByText('Overlay Plot').click(); | ||||
|  | ||||
|     const anotherSineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { | ||||
|       name: new RegExp(anotherSineWaveObject.name) | ||||
|     }); | ||||
|     layoutGridHolder = page.locator('.l-layout__grid-holder'); | ||||
|     // eslint-disable-next-line playwright/no-force-option | ||||
|     await anotherSineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true }); | ||||
|  | ||||
|     await page.getByText('View type').click(); | ||||
|     await page.getByText('Overlay Plot').click(); | ||||
|  | ||||
|     await page.locator('button[title="Save"]').click(); | ||||
|     await page.locator('text=Save and Finish Editing').click(); | ||||
|  | ||||
|     // Time to inspect some network traffic | ||||
|     let networkRequests = []; | ||||
|     page.on('request', (request) => { | ||||
|       const searchRequest = request.url().endsWith('_find'); | ||||
|       const fetchRequest = request.resourceType() === 'fetch'; | ||||
|       if (searchRequest && fetchRequest) { | ||||
|         networkRequests.push(request); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     await page.reload(); | ||||
|  | ||||
|     // wait for annotations requests to be batched and requested | ||||
|     await page.waitForLoadState('networkidle'); | ||||
|  | ||||
|     // Network requests for the composite telemetry with multiple items should be: | ||||
|     // 1.  a single batched request for annotations | ||||
|     expect(networkRequests.length).toBe(1); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -30,6 +30,7 @@ const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
| const backgroundImageSelector = '.c-imagery__main-image__background-image'; | ||||
| const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt']; | ||||
| const tagHotkey = ['Shift', 'Alt']; | ||||
| const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan'; | ||||
| const thumbnailUrlParamsRegexp = /\?w=100&h=100/; | ||||
|  | ||||
| @@ -44,7 +45,7 @@ test.describe('Example Imagery Object', () => { | ||||
|  | ||||
|     // Verify that the created object is focused | ||||
|     await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name); | ||||
|     await page.locator(backgroundImageSelector).hover({ trial: true }); | ||||
|     await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); | ||||
|   }); | ||||
|  | ||||
|   test('Can use Mouse Wheel to zoom in and out of latest image', async ({ page }) => { | ||||
| @@ -72,11 +73,11 @@ test.describe('Example Imagery Object', () => { | ||||
|   test('Can use alt+drag to move around image once zoomed in', async ({ page }) => { | ||||
|     const deltaYStep = 100; //equivalent to 1x zoom | ||||
|  | ||||
|     await page.locator(backgroundImageSelector).hover({ trial: true }); | ||||
|     await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); | ||||
|  | ||||
|     // zoom in | ||||
|     await page.mouse.wheel(0, deltaYStep * 2); | ||||
|     await page.locator(backgroundImageSelector).hover({ trial: true }); | ||||
|     await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); | ||||
|     const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|     const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2; | ||||
|     const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2; | ||||
| @@ -131,6 +132,36 @@ test.describe('Example Imagery Object', () => { | ||||
|     expect(afterDownPanBoundingBox.y).toBeLessThan(afterUpPanBoundingBox.y); | ||||
|   }); | ||||
|  | ||||
|   test('Can use alt+shift+drag to create a tag', async ({ page }) => { | ||||
|     const canvas = page.locator('canvas'); | ||||
|     await canvas.hover({ trial: true }); | ||||
|  | ||||
|     const canvasBoundingBox = await canvas.boundingBox(); | ||||
|     const canvasCenterX = canvasBoundingBox.x + canvasBoundingBox.width / 2; | ||||
|     const canvasCenterY = canvasBoundingBox.y + canvasBoundingBox.height / 2; | ||||
|  | ||||
|     await Promise.all(tagHotkey.map((x) => page.keyboard.down(x))); | ||||
|     await page.mouse.down(); | ||||
|     // steps not working for me here | ||||
|     await page.mouse.move(canvasCenterX - 20, canvasCenterY - 20); | ||||
|     await page.mouse.move(canvasCenterX - 100, canvasCenterY - 100); | ||||
|     await page.mouse.up(); | ||||
|     await Promise.all(tagHotkey.map((x) => page.keyboard.up(x))); | ||||
|  | ||||
|     //Wait for canvas to stablize. | ||||
|     await canvas.hover({ trial: true }); | ||||
|  | ||||
|     // add some tags | ||||
|     await page.getByText('Annotations').click(); | ||||
|     await page.getByRole('button', { name: /Add Tag/ }).click(); | ||||
|     await page.getByPlaceholder('Type to select tag').click(); | ||||
|     await page.getByText('Driving').click(); | ||||
|  | ||||
|     await page.getByRole('button', { name: /Add Tag/ }).click(); | ||||
|     await page.getByPlaceholder('Type to select tag').click(); | ||||
|     await page.getByText('Science').click(); | ||||
|   }); | ||||
|  | ||||
|   test('Can use + - buttons to zoom on the image @unstable', async ({ page }) => { | ||||
|     await buttonZoomOnImageAndAssert(page); | ||||
|   }); | ||||
| @@ -692,7 +723,6 @@ async function panZoomAndAssertImageProperties(page) { | ||||
| async function mouseZoomOnImageAndAssert(page, factor = 2) { | ||||
|   // Zoom in | ||||
|   const originalImageDimensions = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|   await page.locator(backgroundImageSelector).hover({ trial: true }); | ||||
|   const deltaYStep = 100; // equivalent to 1x zoom | ||||
|   await page.mouse.wheel(0, deltaYStep * factor); | ||||
|   const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); | ||||
| @@ -703,7 +733,7 @@ async function mouseZoomOnImageAndAssert(page, factor = 2) { | ||||
|   await page.mouse.move(imageCenterX, imageCenterY); | ||||
|  | ||||
|   // Wait for zoom animation to finish | ||||
|   await page.locator(backgroundImageSelector).hover({ trial: true }); | ||||
|   await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); | ||||
|   const imageMouseZoomed = await page.locator(backgroundImageSelector).boundingBox(); | ||||
|  | ||||
|   if (factor > 0) { | ||||
|   | ||||
| @@ -30,6 +30,7 @@ | ||||
|     "eslint-plugin-you-dont-need-lodash-underscore": "6.12.0", | ||||
|     "eventemitter3": "1.2.0", | ||||
|     "file-saver": "2.0.5", | ||||
|     "flatbush": "4.1.0", | ||||
|     "git-rev-sync": "3.0.2", | ||||
|     "html2canvas": "1.4.1", | ||||
|     "imports-loader": "4.0.1", | ||||
| @@ -44,7 +45,6 @@ | ||||
|     "karma-sourcemap-loader": "0.4.0", | ||||
|     "karma-spec-reporter": "0.0.36", | ||||
|     "karma-webpack": "5.0.0", | ||||
|     "kdbush": "3.0.0", | ||||
|     "location-bar": "3.0.1", | ||||
|     "lodash": "4.17.21", | ||||
|     "mini-css-extract-plugin": "2.7.6", | ||||
|   | ||||
| @@ -76,6 +76,9 @@ const ANNOTATION_LAST_CREATED = 'annotationLastCreated'; | ||||
|  * @constructor | ||||
|  */ | ||||
| export default class AnnotationAPI extends EventEmitter { | ||||
|   /** @type {Map<ANNOTATION_TYPES, Array<(a, b) => boolean >>} */ | ||||
|   #targetComparatorMap; | ||||
|  | ||||
|   /** | ||||
|    * @param {OpenMCT} openmct | ||||
|    */ | ||||
| @@ -84,6 +87,7 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|     this.openmct = openmct; | ||||
|     this.availableTags = {}; | ||||
|     this.namespaceToSaveAnnotations = ''; | ||||
|     this.#targetComparatorMap = new Map(); | ||||
|  | ||||
|     this.ANNOTATION_TYPES = ANNOTATION_TYPES; | ||||
|     this.ANNOTATION_TYPE = ANNOTATION_TYPE; | ||||
| @@ -246,15 +250,16 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|   /** | ||||
|    * @method getAnnotations | ||||
|    * @param {Identifier} domainObjectIdentifier - The domain object identifier to use to search for annotations. For example, a telemetry object identifier. | ||||
|    * @param {AbortSignal} abortSignal - An abort signal to cancel the search | ||||
|    * @returns {DomainObject[]} Returns an array of annotations that match the search query | ||||
|    */ | ||||
|   async getAnnotations(domainObjectIdentifier) { | ||||
|   async getAnnotations(domainObjectIdentifier, abortSignal = null) { | ||||
|     const keyStringQuery = this.openmct.objects.makeKeyString(domainObjectIdentifier); | ||||
|     const searchResults = ( | ||||
|       await Promise.all( | ||||
|         this.openmct.objects.search( | ||||
|           keyStringQuery, | ||||
|           null, | ||||
|           abortSignal, | ||||
|           this.openmct.objects.SEARCH_TYPES.ANNOTATIONS | ||||
|         ) | ||||
|       ) | ||||
| @@ -384,7 +389,8 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|     const combinedResults = []; | ||||
|     results.forEach((currentAnnotation) => { | ||||
|       const existingAnnotation = combinedResults.find((annotationToFind) => { | ||||
|         return _.isEqual(currentAnnotation.targets, annotationToFind.targets); | ||||
|         const { annotationType, targets } = currentAnnotation; | ||||
|         return this.areAnnotationTargetsEqual(annotationType, targets, annotationToFind.targets); | ||||
|       }); | ||||
|       if (!existingAnnotation) { | ||||
|         combinedResults.push(currentAnnotation); | ||||
| @@ -460,4 +466,35 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|  | ||||
|     return breakApartSeparateTargets; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Adds a comparator function for a given annotation type. | ||||
|    * The comparator functions will be used to determine if two annotations | ||||
|    * have the same target. | ||||
|    * @param {ANNOTATION_TYPES} annotationType | ||||
|    * @param {(t1, t2) => boolean} comparator | ||||
|    */ | ||||
|   addTargetComparator(annotationType, comparator) { | ||||
|     const comparatorList = this.#targetComparatorMap.get(annotationType) ?? []; | ||||
|     comparatorList.push(comparator); | ||||
|     this.#targetComparatorMap.set(annotationType, comparatorList); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Compare two sets of targets to see if they are equal. First checks if | ||||
|    * any targets comparators evaluate to true, then falls back to a deep | ||||
|    * equality check. | ||||
|    * @param {ANNOTATION_TYPES} annotationType | ||||
|    * @param {*} targets | ||||
|    * @param {*} otherTargets | ||||
|    * @returns true if the targets are equal, false otherwise | ||||
|    */ | ||||
|   areAnnotationTargetsEqual(annotationType, targets, otherTargets) { | ||||
|     const targetComparatorList = this.#targetComparatorMap.get(annotationType); | ||||
|     return ( | ||||
|       (targetComparatorList?.length && | ||||
|         targetComparatorList.some((targetComparator) => targetComparator(targets, otherTargets))) || | ||||
|       _.isEqual(targets, otherTargets) | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -265,4 +265,52 @@ describe('The Annotation API', () => { | ||||
|       expect(results.length).toEqual(0); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('Target Comparators', () => { | ||||
|     let targets; | ||||
|     let otherTargets; | ||||
|     let comparator; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|       targets = { | ||||
|         fooTarget: { | ||||
|           foo: 42 | ||||
|         } | ||||
|       }; | ||||
|       otherTargets = { | ||||
|         fooTarget: { | ||||
|           bar: 42 | ||||
|         } | ||||
|       }; | ||||
|       comparator = (t1, t2) => t1.fooTarget.foo === t2.fooTarget.bar; | ||||
|     }); | ||||
|  | ||||
|     it('can add a comparator function', () => { | ||||
|       const notebookAnnotationType = openmct.annotation.ANNOTATION_TYPES.NOTEBOOK; | ||||
|       expect( | ||||
|         openmct.annotation.areAnnotationTargetsEqual(notebookAnnotationType, targets, otherTargets) | ||||
|       ).toBeFalse(); // without a comparator, these should NOT be equal | ||||
|       // Register a comparator function for the notebook annotation type | ||||
|       openmct.annotation.addTargetComparator(notebookAnnotationType, comparator); | ||||
|       expect( | ||||
|         openmct.annotation.areAnnotationTargetsEqual(notebookAnnotationType, targets, otherTargets) | ||||
|       ).toBeTrue(); // the comparator should make these equal | ||||
|     }); | ||||
|  | ||||
|     it('falls back to deep equality check if no comparator functions', () => { | ||||
|       const annotationTypeWithoutComparator = openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL; | ||||
|       const areEqual = openmct.annotation.areAnnotationTargetsEqual( | ||||
|         annotationTypeWithoutComparator, | ||||
|         targets, | ||||
|         targets | ||||
|       ); | ||||
|       const areNotEqual = openmct.annotation.areAnnotationTargetsEqual( | ||||
|         annotationTypeWithoutComparator, | ||||
|         targets, | ||||
|         otherTargets | ||||
|       ); | ||||
|       expect(areEqual).toBeTrue(); | ||||
|       expect(areNotEqual).toBeFalse(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										378
									
								
								src/plugins/imagery/components/AnnotationsCanvas.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										378
									
								
								src/plugins/imagery/components/AnnotationsCanvas.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,378 @@ | ||||
| <!-- | ||||
|  Open MCT, Copyright (c) 2014-2023, 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> | ||||
|   <canvas | ||||
|     ref="canvas" | ||||
|     class="c-image-canvas" | ||||
|     style="width: 100%; height: 100%" | ||||
|     @mousedown="clearSelectedAnnotations" | ||||
|     @mousemove="trackAnnotationDrag" | ||||
|     @click="selectOrCreateAnnotation" | ||||
|   ></canvas> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import Flatbush from 'flatbush'; | ||||
| const EXISTING_ANNOTATION_STROKE_STYLE = '#D79078'; | ||||
| const EXISTING_ANNOTATION_FILL_STYLE = 'rgba(202, 202, 142, 0.2)'; | ||||
| const SELECTED_ANNOTATION_STROKE_COLOR = '#BD8ECC'; | ||||
| const SELECTED_ANNOTATION_FILL_STYLE = 'rgba(199, 87, 231, 0.2)'; | ||||
|  | ||||
| export default { | ||||
|   inject: ['openmct', 'domainObject', 'objectPath'], | ||||
|   props: { | ||||
|     image: { | ||||
|       type: Object, | ||||
|       required: true | ||||
|     }, | ||||
|     imageryAnnotations: { | ||||
|       type: Array, | ||||
|       default() { | ||||
|         return []; | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       dragging: false, | ||||
|       mouseDown: false, | ||||
|       newAnnotationRectangle: {}, | ||||
|       keyString: null, | ||||
|       context: null, | ||||
|       canvas: null, | ||||
|       annotationsIndex: null, | ||||
|       selectedAnnotations: [], | ||||
|       indexToAnnotationMap: {} | ||||
|     }; | ||||
|   }, | ||||
|   watch: { | ||||
|     imageryAnnotations() { | ||||
|       this.buildAnnotationIndex(); | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.canvas = this.$refs.canvas; | ||||
|     this.context = this.canvas.getContext('2d'); | ||||
|  | ||||
|     // adjust canvas size for retina displays | ||||
|     const scale = window.devicePixelRatio; | ||||
|     this.canvas.width = Math.floor(this.canvas.width * scale); | ||||
|     this.canvas.height = Math.floor(this.canvas.height * scale); | ||||
|  | ||||
|     this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|     this.openmct.selection.on('change', this.updateSelection); | ||||
|     this.buildAnnotationIndex(); | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     this.openmct.selection.off('change', this.updateSelection); | ||||
|     document.body.removeEventListener('click', this.cancelSelection); | ||||
|   }, | ||||
|   methods: { | ||||
|     buildAnnotationIndex() { | ||||
|       if (this.imageryAnnotations.length) { | ||||
|         // create a flatbush index for the annotations | ||||
|         this.annotationsIndex = new Flatbush(this.imageryAnnotations.length); | ||||
|         this.imageryAnnotations.forEach((annotation) => { | ||||
|           const annotationRectangle = annotation.targets[this.keyString].rectangle; | ||||
|           const indexNumber = this.annotationsIndex.add( | ||||
|             annotationRectangle.x, | ||||
|             annotationRectangle.y, | ||||
|             annotationRectangle.x + annotationRectangle.width, | ||||
|             annotationRectangle.y + annotationRectangle.height | ||||
|           ); | ||||
|           this.indexToAnnotationMap[indexNumber] = annotation; | ||||
|         }); | ||||
|         this.annotationsIndex.finish(); | ||||
|  | ||||
|         this.drawAnnotations(); | ||||
|       } | ||||
|     }, | ||||
|     onAnnotationChange(annotations) { | ||||
|       this.selectedAnnotations = annotations; | ||||
|       this.$emit('annotationsChanged', annotations); | ||||
|     }, | ||||
|     updateSelection(selection) { | ||||
|       const selectionContext = selection?.[0]?.[0]?.context?.item; | ||||
|       const selectionType = selection?.[0]?.[0]?.context?.type; | ||||
|       const validSelectionTypes = ['clicked-on-image-selection']; | ||||
|  | ||||
|       if (!validSelectionTypes.includes(selectionType)) { | ||||
|         // wrong type of selection | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if ( | ||||
|         selectionContext && | ||||
|         this.openmct.objects.areIdsEqual(selectionContext.identifier, this.domainObject.identifier) | ||||
|       ) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const incomingSelectedAnnotations = selection?.[0]?.[0]?.context?.annotations; | ||||
|  | ||||
|       this.prepareExistingAnnotationSelection(incomingSelectedAnnotations); | ||||
|     }, | ||||
|     prepareExistingAnnotationSelection(annotations) { | ||||
|       const targetDomainObjects = {}; | ||||
|       targetDomainObjects[this.keyString] = this.domainObject; | ||||
|  | ||||
|       const targetDetails = {}; | ||||
|       annotations.forEach((annotation) => { | ||||
|         Object.entries(annotation.targets).forEach(([key, value]) => { | ||||
|           targetDetails[key] = value; | ||||
|         }); | ||||
|       }); | ||||
|       this.selectedAnnotations = annotations; | ||||
|       this.drawAnnotations(); | ||||
|  | ||||
|       return { | ||||
|         targetDomainObjects, | ||||
|         targetDetails | ||||
|       }; | ||||
|     }, | ||||
|     clearSelectedAnnotations() { | ||||
|       if (!this.openmct.annotation.getAvailableTags().length) { | ||||
|         // don't bother with new annotations if there are no tags | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       this.mouseDown = true; | ||||
|       this.selectedAnnotations = []; | ||||
|     }, | ||||
|     drawRectInCanvas(rectangle, fillStyle, strokeStyle) { | ||||
|       this.context.beginPath(); | ||||
|       this.context.lineWidth = 1; | ||||
|       this.context.fillStyle = fillStyle; | ||||
|       this.context.strokeStyle = strokeStyle; | ||||
|       this.context.rect(rectangle.x, rectangle.y, rectangle.width, rectangle.height); | ||||
|       this.context.fill(); | ||||
|       this.context.stroke(); | ||||
|     }, | ||||
|     trackAnnotationDrag(event) { | ||||
|       if (this.mouseDown && !this.dragging && event.shiftKey && event.altKey) { | ||||
|         this.startAnnotationDrag(event); | ||||
|       } else if (this.dragging) { | ||||
|         const boundingRect = this.canvas.getBoundingClientRect(); | ||||
|         const scaleX = this.canvas.width / boundingRect.width; | ||||
|         const scaleY = this.canvas.height / boundingRect.height; | ||||
|         this.newAnnotationRectangle = { | ||||
|           x: this.newAnnotationRectangle.x, | ||||
|           y: this.newAnnotationRectangle.y, | ||||
|           width: (event.clientX - boundingRect.left) * scaleX - this.newAnnotationRectangle.x, | ||||
|           height: (event.clientY - boundingRect.top) * scaleY - this.newAnnotationRectangle.y | ||||
|         }; | ||||
|         this.drawAnnotations(); | ||||
|         this.drawRectInCanvas( | ||||
|           this.newAnnotationRectangle, | ||||
|           SELECTED_ANNOTATION_FILL_STYLE, | ||||
|           SELECTED_ANNOTATION_STROKE_COLOR | ||||
|         ); | ||||
|       } | ||||
|     }, | ||||
|     clearCanvas() { | ||||
|       this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); | ||||
|     }, | ||||
|     selectImageView() { | ||||
|       // should show ImageView itself if we have no annotations to display | ||||
|       const selection = this.createPathSelection(); | ||||
|       this.openmct.selection.select(selection, true); | ||||
|     }, | ||||
|     createSelection(annotation) { | ||||
|       const selection = this.createPathSelection(); | ||||
|       selection[0].context = annotation; | ||||
|  | ||||
|       return selection; | ||||
|     }, | ||||
|     selectImageAnnotations({ targetDetails, targetDomainObjects, annotations }) { | ||||
|       const annotationContext = { | ||||
|         type: 'clicked-on-image-selection', | ||||
|         targetDetails, | ||||
|         targetDomainObjects, | ||||
|         annotations, | ||||
|         annotationType: this.openmct.annotation.ANNOTATION_TYPES.PIXEL_SPATIAL, | ||||
|         onAnnotationChange: this.onAnnotationChange | ||||
|       }; | ||||
|       const selection = this.createPathSelection(); | ||||
|       if ( | ||||
|         selection.length && | ||||
|         this.openmct.objects.areIdsEqual( | ||||
|           selection[0].context.item.identifier, | ||||
|           this.domainObject.identifier | ||||
|         ) | ||||
|       ) { | ||||
|         selection[0].context = { | ||||
|           ...selection[0].context, | ||||
|           ...annotationContext | ||||
|         }; | ||||
|       } else { | ||||
|         selection.unshift({ | ||||
|           element: this.$el, | ||||
|           context: { | ||||
|             item: this.domainObject, | ||||
|             ...annotationContext | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       this.openmct.selection.select(selection, true); | ||||
|  | ||||
|       document.body.addEventListener('click', this.cancelSelection); | ||||
|     }, | ||||
|     cancelSelection(event) { | ||||
|       if (this.$refs.canvas) { | ||||
|         const clickedInsideCanvas = this.$refs.canvas.contains(event.target); | ||||
|         const clickedInsideInspector = event.target.closest('.js-inspector') !== null; | ||||
|         const clickedOption = event.target.closest('.js-autocomplete-options') !== null; | ||||
|         if (!clickedInsideCanvas && !clickedInsideInspector && !clickedOption) { | ||||
|           this.newAnnotationRectangle = {}; | ||||
|           this.selectedAnnotations = []; | ||||
|           this.drawAnnotations(); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     createNewAnnotation() { | ||||
|       this.dragging = false; | ||||
|       this.selectedAnnotations = []; | ||||
|  | ||||
|       const targetDomainObjects = {}; | ||||
|       targetDomainObjects[this.keyString] = this.domainObject; | ||||
|       const targetDetails = {}; | ||||
|       targetDetails[this.keyString] = { | ||||
|         rectangle: { | ||||
|           x: this.newAnnotationRectangle.x, | ||||
|           y: this.newAnnotationRectangle.y, | ||||
|           width: this.newAnnotationRectangle.width, | ||||
|           height: this.newAnnotationRectangle.height | ||||
|         }, | ||||
|         time: this.image.time | ||||
|       }; | ||||
|       this.selectImageAnnotations({ | ||||
|         targetDetails, | ||||
|         targetDomainObjects, | ||||
|         annotations: [] | ||||
|       }); | ||||
|     }, | ||||
|     attemptToSelectExistingAnnotation(event) { | ||||
|       this.dragging = false; | ||||
|       // use flatbush to find annotations that are close to the click | ||||
|       const boundingRect = this.canvas.getBoundingClientRect(); | ||||
|       const scaleX = this.canvas.width / boundingRect.width; | ||||
|       const scaleY = this.canvas.height / boundingRect.height; | ||||
|       const x = (event.clientX - boundingRect.left) * scaleX; | ||||
|       const y = (event.clientY - boundingRect.top) * scaleY; | ||||
|       if (this.annotationsIndex) { | ||||
|         let nearbyAnnotations = []; | ||||
|         const resultIndicies = this.annotationsIndex.search(x, y, x, y); | ||||
|         resultIndicies.forEach((resultIndex) => { | ||||
|           const foundAnnotation = this.indexToAnnotationMap[resultIndex]; | ||||
|  | ||||
|           nearbyAnnotations.push(foundAnnotation); | ||||
|         }); | ||||
|         //show annotations if some were found | ||||
|         const { targetDomainObjects, targetDetails } = | ||||
|           this.prepareExistingAnnotationSelection(nearbyAnnotations); | ||||
|         this.selectImageAnnotations({ | ||||
|           targetDetails, | ||||
|           targetDomainObjects, | ||||
|           annotations: nearbyAnnotations | ||||
|         }); | ||||
|       } else { | ||||
|         // nothing selected | ||||
|         this.drawAnnotations(); | ||||
|       } | ||||
|     }, | ||||
|     selectOrCreateAnnotation(event) { | ||||
|       event.stopPropagation(); | ||||
|       this.mouseDown = false; | ||||
|       if ( | ||||
|         !this.dragging || | ||||
|         (!this.newAnnotationRectangle.width && !this.newAnnotationRectangle.height) | ||||
|       ) { | ||||
|         this.newAnnotationRectangle = {}; | ||||
|         this.attemptToSelectExistingAnnotation(event); | ||||
|       } else { | ||||
|         this.createNewAnnotation(); | ||||
|       } | ||||
|     }, | ||||
|     createPathSelection() { | ||||
|       let selection = []; | ||||
|       selection.unshift({ | ||||
|         element: this.$el, | ||||
|         context: { | ||||
|           item: this.domainObject | ||||
|         } | ||||
|       }); | ||||
|       this.objectPath.forEach((pathObject, index) => { | ||||
|         selection.push({ | ||||
|           element: this.openmct.layout.$refs.browseObject.$el, | ||||
|           context: { | ||||
|             item: pathObject | ||||
|           } | ||||
|         }); | ||||
|       }); | ||||
|  | ||||
|       return selection; | ||||
|     }, | ||||
|     startAnnotationDrag(event) { | ||||
|       this.$emit('annotationMarqueed'); | ||||
|       this.newAnnotationRectangle = {}; | ||||
|       const boundingRect = this.canvas.getBoundingClientRect(); | ||||
|       const scaleX = this.canvas.width / boundingRect.width; | ||||
|       const scaleY = this.canvas.height / boundingRect.height; | ||||
|       this.newAnnotationRectangle = { | ||||
|         x: (event.clientX - boundingRect.left) * scaleX, | ||||
|         y: (event.clientY - boundingRect.top) * scaleY | ||||
|       }; | ||||
|       this.dragging = true; | ||||
|     }, | ||||
|     isSelectedAnnotation(annotation) { | ||||
|       const someSelectedAnnotationExists = this.selectedAnnotations.some((selectedAnnotation) => { | ||||
|         return this.openmct.objects.areIdsEqual( | ||||
|           selectedAnnotation.identifier, | ||||
|           annotation.identifier | ||||
|         ); | ||||
|       }); | ||||
|  | ||||
|       return someSelectedAnnotationExists; | ||||
|     }, | ||||
|     drawAnnotations() { | ||||
|       this.clearCanvas(); | ||||
|       this.imageryAnnotations.forEach((annotation) => { | ||||
|         if (this.isSelectedAnnotation(annotation)) { | ||||
|           this.drawRectInCanvas( | ||||
|             annotation.targets[this.keyString].rectangle, | ||||
|             SELECTED_ANNOTATION_FILL_STYLE, | ||||
|             SELECTED_ANNOTATION_STROKE_COLOR | ||||
|           ); | ||||
|         } else { | ||||
|           this.drawRectInCanvas( | ||||
|             annotation.targets[this.keyString].rectangle, | ||||
|             EXISTING_ANNOTATION_FILL_STYLE, | ||||
|             EXISTING_ANNOTATION_STROKE_STYLE | ||||
|           ); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
| @@ -19,7 +19,7 @@ $elemBg: rgba(black, 0.7); | ||||
|   position: absolute; | ||||
|   left: 0; | ||||
|   top: 0; | ||||
|   z-index: 2; | ||||
|   z-index: 3; | ||||
|   @include userSelectNone; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -38,6 +38,11 @@ | ||||
|         fetchpriority="low" | ||||
|         @load="imageLoadCompleted" | ||||
|       /> | ||||
|       <i | ||||
|         v-show="showAnnotationIndicator" | ||||
|         class="c-thumb__annotation-indicator icon-status-poll-edit" | ||||
|       > | ||||
|       </i> | ||||
|     </a> | ||||
|     <div v-if="viewableArea" class="c-thumb__viewable-area" :style="viewableAreaStyle"></div> | ||||
|     <div class="c-thumb__timestamp">{{ image.formattedTime }}</div> | ||||
| @@ -66,6 +71,12 @@ export default { | ||||
|       type: Boolean, | ||||
|       required: true | ||||
|     }, | ||||
|     imageryAnnotations: { | ||||
|       type: Array, | ||||
|       default() { | ||||
|         return []; | ||||
|       } | ||||
|     }, | ||||
|     viewableArea: { | ||||
|       type: Object, | ||||
|       default: function () { | ||||
| @@ -125,6 +136,9 @@ export default { | ||||
|         width: `${width}px`, | ||||
|         height: `${height}px` | ||||
|       }; | ||||
|     }, | ||||
|     showAnnotationIndicator() { | ||||
|       return this.imageryAnnotations?.length > 0; | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|   | ||||
| @@ -88,6 +88,13 @@ | ||||
|             :image="focusedImage" | ||||
|             :sized-image-dimensions="sizedImageDimensions" | ||||
|           /> | ||||
|           <AnnotationsCanvas | ||||
|             v-if="shouldDisplayAnnotations" | ||||
|             :image="focusedImage" | ||||
|             :imagery-annotations="imageryAnnotations[focusedImage.time]" | ||||
|             @annotationMarqueed="handlePauseButton(true)" | ||||
|             @annotationsChanged="loadAnnotations" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
| @@ -173,6 +180,7 @@ | ||||
|           :key="`${image.thumbnailUrl || image.url}-${image.time}-${index}`" | ||||
|           :image="image" | ||||
|           :active="focusedImageIndex === index" | ||||
|           :imagery-annotations="imageryAnnotations[image.time]" | ||||
|           :selected="focusedImageIndex === index && isPaused" | ||||
|           :real-time="!isFixed" | ||||
|           :viewable-area="focusedImageIndex === index ? viewableArea : null" | ||||
| @@ -200,6 +208,7 @@ import Compass from './Compass/Compass.vue'; | ||||
| import ImageControls from './ImageControls.vue'; | ||||
| import ImageThumbnail from './ImageThumbnail.vue'; | ||||
| import imageryData from '../../imagery/mixins/imageryData'; | ||||
| import AnnotationsCanvas from './AnnotationsCanvas.vue'; | ||||
|  | ||||
| const REFRESH_CSS_MS = 500; | ||||
| const DURATION_TRACK_MS = 1000; | ||||
| @@ -232,7 +241,8 @@ export default { | ||||
|   components: { | ||||
|     Compass, | ||||
|     ImageControls, | ||||
|     ImageThumbnail | ||||
|     ImageThumbnail, | ||||
|     AnnotationsCanvas | ||||
|   }, | ||||
|   mixins: [imageryData], | ||||
|   inject: ['openmct', 'domainObject', 'objectPath', 'currentView', 'imageFreshnessOptions'], | ||||
| @@ -295,7 +305,8 @@ export default { | ||||
|       animateZoom: true, | ||||
|       imagePanned: false, | ||||
|       forceShowThumbnails: false, | ||||
|       animateThumbScroll: false | ||||
|       animateThumbScroll: false, | ||||
|       imageryAnnotations: {} | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
| @@ -425,6 +436,19 @@ export default { | ||||
|  | ||||
|       return result; | ||||
|     }, | ||||
|     shouldDisplayAnnotations() { | ||||
|       const imageHeightAndWidth = this.sizedImageHeight !== 0 && this.sizedImageWidth !== 0; | ||||
|       const display = | ||||
|         this.focusedImage !== undefined && | ||||
|         this.focusedImageNaturalAspectRatio !== undefined && | ||||
|         this.imageContainerWidth !== undefined && | ||||
|         this.imageContainerHeight !== undefined && | ||||
|         imageHeightAndWidth && | ||||
|         this.zoomFactor === 1 && | ||||
|         this.imagePanned !== true; | ||||
|  | ||||
|       return display; | ||||
|     }, | ||||
|     shouldDisplayCompass() { | ||||
|       const imageHeightAndWidth = this.sizedImageHeight !== 0 && this.sizedImageWidth !== 0; | ||||
|       const display = | ||||
| @@ -689,6 +713,9 @@ export default { | ||||
|  | ||||
|     this.listenTo(this.focusedImageWrapper, 'wheel', this.wheelZoom, this); | ||||
|     this.loadVisibleLayers(); | ||||
|     this.loadAnnotations(); | ||||
|  | ||||
|     this.openmct.selection.on('change', this.updateSelection); | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     this.persistVisibleLayers(); | ||||
| @@ -716,6 +743,15 @@ export default { | ||||
|     } | ||||
|  | ||||
|     this.stopListening(this.focusedImageWrapper, 'wheel', this.wheelZoom, this); | ||||
|  | ||||
|     Object.keys(this.imageryAnnotations).forEach((time) => { | ||||
|       const imageAnnotationsForTime = this.imageryAnnotations[time]; | ||||
|       imageAnnotationsForTime.forEach((imageAnnotation) => { | ||||
|         this.openmct.objects.destroyMutable(imageAnnotation); | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     this.openmct.selection.off('change', this.updateSelection); | ||||
|   }, | ||||
|   methods: { | ||||
|     calculateViewHeight() { | ||||
| @@ -743,6 +779,24 @@ export default { | ||||
|         this.timeContext.off('clock', this.trackDuration); | ||||
|       } | ||||
|     }, | ||||
|     updateSelection(selection) { | ||||
|       const selectionType = selection?.[0]?.[0]?.context?.type; | ||||
|       const validSelectionTypes = ['annotation-search-result']; | ||||
|  | ||||
|       if (!validSelectionTypes.includes(selectionType)) { | ||||
|         // wrong type of selection | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const incomingSelectedAnnotation = selection?.[0]?.[0]?.context?.annotations?.[0]; | ||||
|       console.debug(`📲 incoming search selections`, incomingSelectedAnnotation); | ||||
|       // TODO in https://github.com/nasa/openmct/issues/6731 | ||||
|       // For incoming search results, we should: | ||||
|       // 1. set the the time bounds to match the search result | ||||
|       // 2. search the imageHistory for the image that matches the time of the search result | ||||
|       // 3. using the index from the above, "click" on the image to select it | ||||
|       // 4. pass to the annotation canvas layer the selected annotation | ||||
|     }, | ||||
|     expand() { | ||||
|       // check for modifier keys so it doesnt interfere with the layout | ||||
|       if (this.cursorStates.modifierKeyPressed) { | ||||
| @@ -832,6 +886,40 @@ export default { | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|     async loadAnnotations(existingAnnotations) { | ||||
|       if (!this.openmct.annotation.getAvailableTags().length) { | ||||
|         // don't bother loading annotations if there are no tags | ||||
|         return; | ||||
|       } | ||||
|       let foundAnnotations = existingAnnotations; | ||||
|       if (!foundAnnotations) { | ||||
|         // attempt to load | ||||
|         foundAnnotations = await this.openmct.annotation.getAnnotations( | ||||
|           this.domainObject.identifier | ||||
|         ); | ||||
|       } | ||||
|       foundAnnotations.forEach((foundAnnotation) => { | ||||
|         const targetId = Object.keys(foundAnnotation.targets)[0]; | ||||
|         const timeForAnnotation = foundAnnotation.targets[targetId].time; | ||||
|         if (!this.imageryAnnotations[timeForAnnotation]) { | ||||
|           this.$set(this.imageryAnnotations, timeForAnnotation, []); | ||||
|         } | ||||
|  | ||||
|         const annotationExtant = this.imageryAnnotations[timeForAnnotation].some( | ||||
|           (existingAnnotation) => { | ||||
|             return this.openmct.objects.areIdsEqual( | ||||
|               existingAnnotation.identifier, | ||||
|               foundAnnotation.identifier | ||||
|             ); | ||||
|           } | ||||
|         ); | ||||
|         if (!annotationExtant) { | ||||
|           const annotationArray = this.imageryAnnotations[timeForAnnotation]; | ||||
|           const mutableAnnotation = this.openmct.objects.toMutable(foundAnnotation); | ||||
|           annotationArray.push(mutableAnnotation); | ||||
|         } | ||||
|       }); | ||||
|     }, | ||||
|     persistVisibleLayers() { | ||||
|       if ( | ||||
|         this.domainObject.configuration && | ||||
| @@ -979,7 +1067,9 @@ export default { | ||||
|       } | ||||
|  | ||||
|       await Vue.nextTick(); | ||||
|       this.$refs.thumbsWrapper.scrollLeft = scrollWidth; | ||||
|       if (this.$refs.thumbsWrapper) { | ||||
|         this.$refs.thumbsWrapper.scrollLeft = scrollWidth; | ||||
|       } | ||||
|     }, | ||||
|     scrollHandler() { | ||||
|       if (this.isPaused) { | ||||
|   | ||||
| @@ -293,6 +293,13 @@ | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   &__annotation-indicator { | ||||
|     color: $colorClickIconButton; | ||||
|     position: absolute; | ||||
|     top: 6px; | ||||
|     right: 8px; | ||||
|   } | ||||
|  | ||||
|   &__timestamp { | ||||
|     flex: 0 0 auto; | ||||
|     padding: 2px 3px; | ||||
| @@ -540,3 +547,11 @@ | ||||
|     align-self: flex-end; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .c-image-canvas { | ||||
|   pointer-events: auto; // This allows the image element to receive a browser-level context click | ||||
|   position: absolute; | ||||
|   left: 0; | ||||
|   top: 0; | ||||
|   z-index: 2; | ||||
| } | ||||
|   | ||||
| @@ -41,7 +41,6 @@ | ||||
|  | ||||
| <script> | ||||
| import TagEditor from './tags/TagEditor.vue'; | ||||
| import _ from 'lodash'; | ||||
|  | ||||
| export default { | ||||
|   components: { | ||||
| @@ -123,6 +122,7 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   async mounted() { | ||||
|     this.abortController = null; | ||||
|     this.openmct.annotation.on('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject); | ||||
|     this.openmct.selection.on('change', this.updateSelection); | ||||
|     await this.updateSelection(this.openmct.selection.get()); | ||||
| @@ -190,20 +190,34 @@ export default { | ||||
|       } | ||||
|     }, | ||||
|     async loadAnnotationForTargetObject(target) { | ||||
|       const targetID = this.openmct.objects.makeKeyString(target.identifier); | ||||
|       const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations( | ||||
|         target.identifier | ||||
|       ); | ||||
|       const filteredAnnotationsForSelection = allAnnotationsForTarget.filter((annotation) => { | ||||
|         const matchingTargetID = Object.keys(annotation.targets).filter((loadedTargetID) => { | ||||
|           return targetID === loadedTargetID; | ||||
|         }); | ||||
|         const fetchedTargetDetails = annotation.targets[matchingTargetID]; | ||||
|         const selectedTargetDetails = this.targetDetails[matchingTargetID]; | ||||
|       // If the user changes targets while annotations are loading, | ||||
|       // abort the previous request. | ||||
|       if (this.abortController !== null) { | ||||
|         this.abortController.abort(); | ||||
|       } | ||||
|  | ||||
|         return _.isEqual(fetchedTargetDetails, selectedTargetDetails); | ||||
|       }); | ||||
|       this.loadNewAnnotations(filteredAnnotationsForSelection); | ||||
|       this.abortController = new AbortController(); | ||||
|  | ||||
|       try { | ||||
|         const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations( | ||||
|           target.identifier, | ||||
|           this.abortController.signal | ||||
|         ); | ||||
|         const filteredAnnotationsForSelection = allAnnotationsForTarget.filter((annotation) => | ||||
|           this.openmct.annotation.areAnnotationTargetsEqual( | ||||
|             this.annotationType, | ||||
|             this.targetDetails, | ||||
|             annotation.targets | ||||
|           ) | ||||
|         ); | ||||
|         this.loadNewAnnotations(filteredAnnotationsForSelection); | ||||
|       } catch (err) { | ||||
|         if (err.name !== 'AbortError') { | ||||
|           throw err; | ||||
|         } | ||||
|       } finally { | ||||
|         this.abortController = null; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|   | ||||
| @@ -306,13 +306,22 @@ export default { | ||||
|     this.getSearchResults = debounce(this.getSearchResults, 500); | ||||
|     this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100); | ||||
|   }, | ||||
|   async mounted() { | ||||
|     await this.loadAnnotations(); | ||||
|   async created() { | ||||
|     this.transaction = null; | ||||
|     this.abortController = new AbortController(); | ||||
|     try { | ||||
|       await this.loadAnnotations(); | ||||
|     } catch (err) { | ||||
|       if (err.name !== 'AbortError') { | ||||
|         throw err; | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.formatSidebar(); | ||||
|     this.setSectionAndPageFromUrl(); | ||||
|  | ||||
|     this.openmct.selection.on('change', this.updateSelection); | ||||
|     this.transaction = null; | ||||
|  | ||||
|     window.addEventListener('orientationchange', this.formatSidebar); | ||||
|     window.addEventListener('hashchange', this.setSectionAndPageFromUrl); | ||||
| @@ -324,6 +333,7 @@ export default { | ||||
|     ); | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     this.abortController.abort(); | ||||
|     if (this.unlisten) { | ||||
|       this.unlisten(); | ||||
|     } | ||||
| @@ -387,8 +397,10 @@ export default { | ||||
|       this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0; | ||||
|  | ||||
|       const foundAnnotations = await this.openmct.annotation.getAnnotations( | ||||
|         this.domainObject.identifier | ||||
|         this.domainObject.identifier, | ||||
|         this.abortController.signal | ||||
|       ); | ||||
|  | ||||
|       foundAnnotations.forEach((foundAnnotation) => { | ||||
|         const targetId = Object.keys(foundAnnotation.targets)[0]; | ||||
|         const entryId = foundAnnotation.targets[targetId].entryId; | ||||
| @@ -425,7 +437,11 @@ export default { | ||||
|           : [...filteredPageEntriesByTime].reverse(); | ||||
|  | ||||
|       if (this.lastLocalAnnotationCreation < this.domainObject.annotationLastCreated) { | ||||
|         this.loadAnnotations(); | ||||
|         this.loadAnnotations().catch((err) => { | ||||
|           if (err.name !== 'AbortError') { | ||||
|             throw err; | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|     changeSelectedSection({ sectionId, pageId }) { | ||||
|   | ||||
| @@ -27,7 +27,13 @@ | ||||
| // If the above namespace is ever resolved, we can fold this search provider | ||||
| // back into the object provider. | ||||
|  | ||||
| const BATCH_ANNOTATION_DEBOUNCE_MS = 100; | ||||
|  | ||||
| class CouchSearchProvider { | ||||
|   #bulkPromise; | ||||
|   #batchIds; | ||||
|   #lastAbortSignal; | ||||
|  | ||||
|   constructor(couchObjectProvider) { | ||||
|     this.couchObjectProvider = couchObjectProvider; | ||||
|     this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES; | ||||
| @@ -36,6 +42,8 @@ class CouchSearchProvider { | ||||
|       this.searchTypes.ANNOTATIONS, | ||||
|       this.searchTypes.TAGS | ||||
|     ]; | ||||
|     this.#batchIds = []; | ||||
|     this.#bulkPromise = null; | ||||
|   } | ||||
|  | ||||
|   supportsSearchType(searchType) { | ||||
| @@ -68,28 +76,77 @@ class CouchSearchProvider { | ||||
|     return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal); | ||||
|   } | ||||
|  | ||||
|   searchForAnnotations(keyString, abortSignal) { | ||||
|   async #deferBatchAnnotationSearch() { | ||||
|     // We until the next event loop cycle to "collect" all of the get | ||||
|     // requests triggered in this iteration of the event loop | ||||
|     await this.#waitForDebounce(); | ||||
|     const batchIdsToSearch = [...this.#batchIds]; | ||||
|     this.#clearBatch(); | ||||
|     return this.#bulkAnnotationSearch(batchIdsToSearch); | ||||
|   } | ||||
|  | ||||
|   #clearBatch() { | ||||
|     this.#batchIds = []; | ||||
|     this.#bulkPromise = undefined; | ||||
|   } | ||||
|  | ||||
|   #waitForDebounce() { | ||||
|     let timeoutID; | ||||
|     clearTimeout(timeoutID); | ||||
|  | ||||
|     return new Promise((resolve) => { | ||||
|       timeoutID = setTimeout(() => { | ||||
|         resolve(); | ||||
|       }, BATCH_ANNOTATION_DEBOUNCE_MS); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   #bulkAnnotationSearch(batchIdsToSearch) { | ||||
|     const filter = { | ||||
|       selector: { | ||||
|         $and: [ | ||||
|           { | ||||
|             model: { | ||||
|               targets: {} | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             'model.type': { | ||||
|               $eq: 'annotation' | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             $or: [] | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     }; | ||||
|     filter.selector.$and[0].model.targets[keyString] = { | ||||
|       $exists: true | ||||
|     }; | ||||
|     let lastAbortSignal = null; | ||||
|     // TODO: should remove duplicates from batchIds | ||||
|     batchIdsToSearch.forEach(({ keyString, abortSignal }) => { | ||||
|       const modelFilter = { | ||||
|         model: { | ||||
|           targets: {} | ||||
|         } | ||||
|       }; | ||||
|       modelFilter.model.targets[keyString] = { | ||||
|         $exists: true | ||||
|       }; | ||||
|  | ||||
|     return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal); | ||||
|       filter.selector.$and[1].$or.push(modelFilter); | ||||
|       lastAbortSignal = abortSignal; | ||||
|     }); | ||||
|  | ||||
|     return this.couchObjectProvider.getObjectsByFilter(filter, lastAbortSignal); | ||||
|   } | ||||
|  | ||||
|   async searchForAnnotations(keyString, abortSignal) { | ||||
|     this.#batchIds.push({ keyString, abortSignal }); | ||||
|     if (!this.#bulkPromise) { | ||||
|       this.#bulkPromise = this.#deferBatchAnnotationSearch(); | ||||
|     } | ||||
|  | ||||
|     const returnedData = await this.#bulkPromise; | ||||
|     // only return data that matches the keystring | ||||
|     const filteredByKeyString = returnedData.filter((foundAnnotation) => { | ||||
|       return foundAnnotation.targets[keyString]; | ||||
|     }); | ||||
|     return filteredByKeyString; | ||||
|   } | ||||
|  | ||||
|   searchForTags(tagsArray, abortSignal) { | ||||
|   | ||||
| @@ -183,7 +183,7 @@ import MctTicks from './MctTicks.vue'; | ||||
| import MctChart from './chart/MctChart.vue'; | ||||
| import XAxis from './axis/XAxis.vue'; | ||||
| import YAxis from './axis/YAxis.vue'; | ||||
| import KDBush from 'kdbush'; | ||||
| import Flatbush from 'flatbush'; | ||||
| import _ from 'lodash'; | ||||
|  | ||||
| const OFFSET_THRESHOLD = 10; | ||||
| @@ -339,6 +339,9 @@ export default { | ||||
|       this.cursorGuide = newCursorGuide; | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     this.abortController = new AbortController(); | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.yAxisIdVisibility = {}; | ||||
|     this.offsetWidth = 0; | ||||
| @@ -398,6 +401,7 @@ export default { | ||||
|     this.loaded = true; | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     this.abortController.abort(); | ||||
|     this.openmct.selection.off('change', this.updateSelection); | ||||
|     document.removeEventListener('keydown', this.handleKeyDown); | ||||
|     document.removeEventListener('keyup', this.handleKeyUp); | ||||
| @@ -410,8 +414,8 @@ export default { | ||||
|       // on clicking on a search result we highlight the annotation and zoom - we know it's an annotation result when isAnnotationSearchResult === true | ||||
|       // We shouldn't zoom when we're selecting existing annotations to view them or creating new annotations. | ||||
|       const selectionType = selection?.[0]?.[0]?.context?.type; | ||||
|       const validSelectionTypes = ['clicked-on-plot-selection', 'plot-annotation-search-result']; | ||||
|       const isAnnotationSearchResult = selectionType === 'plot-annotation-search-result'; | ||||
|       const validSelectionTypes = ['clicked-on-plot-selection', 'annotation-search-result']; | ||||
|       const isAnnotationSearchResult = selectionType === 'annotation-search-result'; | ||||
|  | ||||
|       if (!validSelectionTypes.includes(selectionType)) { | ||||
|         // wrong type of selection | ||||
| @@ -621,7 +625,8 @@ export default { | ||||
|       await Promise.all( | ||||
|         this.seriesModels.map(async (seriesModel) => { | ||||
|           const seriesAnnotations = await this.openmct.annotation.getAnnotations( | ||||
|             seriesModel.model.identifier | ||||
|             seriesModel.model.identifier, | ||||
|             this.abortController.signal | ||||
|           ); | ||||
|           rawAnnotationsForPlot.push(...seriesAnnotations); | ||||
|         }) | ||||
| @@ -1393,6 +1398,24 @@ export default { | ||||
|  | ||||
|       return annotationsByPoints.flat(); | ||||
|     }, | ||||
|     searchWithFlatbush(seriesData, seriesModel, boundingBox) { | ||||
|       const flatbush = new Flatbush(seriesData.length); | ||||
|       seriesData.forEach((point) => { | ||||
|         const x = seriesModel.getXVal(point); | ||||
|         const y = seriesModel.getYVal(point); | ||||
|         flatbush.add(x, y, x, y); | ||||
|       }); | ||||
|       flatbush.finish(); | ||||
|  | ||||
|       const rangeResults = flatbush.search( | ||||
|         boundingBox.minX, | ||||
|         boundingBox.minY, | ||||
|         boundingBox.maxX, | ||||
|         boundingBox.maxY | ||||
|       ); | ||||
|  | ||||
|       return rangeResults; | ||||
|     }, | ||||
|     getPointsInBox(boundingBoxPerYAxis, rawAnnotation) { | ||||
|       // load series models in KD-Trees | ||||
|       const seriesKDTrees = []; | ||||
| @@ -1408,22 +1431,8 @@ export default { | ||||
|  | ||||
|         const seriesData = seriesModel.getSeriesData(); | ||||
|         if (seriesData && seriesData.length) { | ||||
|           const kdTree = new KDBush( | ||||
|             seriesData, | ||||
|             (point) => { | ||||
|               return seriesModel.getXVal(point); | ||||
|             }, | ||||
|             (point) => { | ||||
|               return seriesModel.getYVal(point); | ||||
|             } | ||||
|           ); | ||||
|           const searchResults = []; | ||||
|           const rangeResults = kdTree.range( | ||||
|             boundingBox.minX, | ||||
|             boundingBox.minY, | ||||
|             boundingBox.maxX, | ||||
|             boundingBox.maxY | ||||
|           ); | ||||
|           const rangeResults = this.searchWithFlatbush(seriesData, seriesModel, boundingBox); | ||||
|           rangeResults.forEach((id) => { | ||||
|             const seriesDatum = seriesData[id]; | ||||
|             if (seriesDatum) { | ||||
| @@ -1524,7 +1533,11 @@ export default { | ||||
|         this.endMarquee(); | ||||
|       } | ||||
|  | ||||
|       this.loadAnnotations(); | ||||
|       this.loadAnnotations().catch((err) => { | ||||
|         if (err.name !== 'AbortError') { | ||||
|           throw err; | ||||
|         } | ||||
|       }); | ||||
|     }, | ||||
|  | ||||
|     zoom(zoomDirection, zoomFactor) { | ||||
|   | ||||
| @@ -98,6 +98,13 @@ export default { | ||||
|         } | ||||
|  | ||||
|         return 'Could not find any matching Notebook entries'; | ||||
|       } else if ( | ||||
|         this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL | ||||
|       ) { | ||||
|         const targetID = Object.keys(this.result.targets)[0]; | ||||
|         const { layerName, name } = this.result.targets[targetID]; | ||||
|  | ||||
|         return layerName ? `${layerName} - ${name}` : name; | ||||
|       } else { | ||||
|         return this.result.targetModels[0].name; | ||||
|       } | ||||
| @@ -115,11 +122,11 @@ export default { | ||||
|   mounted() { | ||||
|     this.previewAction = new PreviewAction(this.openmct); | ||||
|     this.previewAction.on('isVisible', this.togglePreviewState); | ||||
|     this.clickedPlotAnnotation = this.clickedPlotAnnotation.bind(this); | ||||
|     this.fireAnnotationSelection = this.fireAnnotationSelection.bind(this); | ||||
|   }, | ||||
|   destroyed() { | ||||
|     this.previewAction.off('isVisible', this.togglePreviewState); | ||||
|     this.openmct.selection.off('change', this.clickedPlotAnnotation); | ||||
|     this.openmct.selection.off('change', this.fireAnnotationSelection); | ||||
|   }, | ||||
|   methods: { | ||||
|     clickedResult(event) { | ||||
| @@ -132,17 +139,15 @@ export default { | ||||
|         if (!this.openmct.router.isNavigatedObject(objectPath)) { | ||||
|           // if we're not on the correct page, navigate to the object, | ||||
|           // then wait for the selection event to fire before issuing a new selection | ||||
|           if ( | ||||
|             this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL | ||||
|           ) { | ||||
|             this.openmct.selection.on('change', this.clickedPlotAnnotation); | ||||
|           if (this.result.annotationType) { | ||||
|             this.openmct.selection.on('change', this.fireAnnotationSelection); | ||||
|           } | ||||
|  | ||||
|           this.openmct.router.navigate(resultUrl); | ||||
|         } else { | ||||
|           // if this is the navigated object, then we are already on the correct page | ||||
|           // and just need to issue the selection event | ||||
|           this.clickedPlotAnnotation(); | ||||
|           this.fireAnnotationSelection(); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
| @@ -151,8 +156,8 @@ export default { | ||||
|         this.previewAction.invoke(objectPath); | ||||
|       } | ||||
|     }, | ||||
|     clickedPlotAnnotation() { | ||||
|       this.openmct.selection.off('change', this.clickedPlotAnnotation); | ||||
|     fireAnnotationSelection() { | ||||
|       this.openmct.selection.off('change', this.fireAnnotationSelection); | ||||
|  | ||||
|       const targetDetails = {}; | ||||
|       const targetDomainObjects = {}; | ||||
| @@ -168,11 +173,11 @@ export default { | ||||
|           element: this.$el, | ||||
|           context: { | ||||
|             item: this.result.targetModels[0], | ||||
|             type: 'plot-annotation-search-result', | ||||
|             type: 'annotation-search-result', | ||||
|             targetDetails, | ||||
|             targetDomainObjects, | ||||
|             annotations: [this.result], | ||||
|             annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL, | ||||
|             annotationType: this.result.annotationType, | ||||
|             onAnnotationChange: () => {} | ||||
|           } | ||||
|         } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user