feat: Annotation API changes to support Geospatial (Map) annotations (#6703)

* feat: `getAnnotations` can take an `abortSignal`

* feat: add `MAP` annotationType

* fix: handle `MAP` annotations in search results

* fix: have `loadAnnotationForTargetObject` take an `abortSignal`

* fix(#5646): abort pending annotations requests on nav away from notebooks or plots

* fix: handle AbortErrors gracefully

* fix: remove redundant `MAP` annotation type

* docs: add comment

* fix: navigate before selection for geospatial results

* feat: comparators for annotation target equality

- Adds `addTargetComparator()` to the Annotation API, allowing plugins to define additional comparators for certain annotation types.
- Update usage of `_.isEqual()` for targets to use the `areAnnotationTargetsEqual()` method, which uses any additional comparators before falling back to a deep equality check.
- Handle aborted `getAnnotations()` calls gracefully in the AnnotationInspectorView

* test: add unit tests for target comparators

---------

Co-authored-by: Scott Bell <scott@traclabs.com>
This commit is contained in:
Jesse Mazzella 2023-06-14 12:33:26 -07:00 committed by GitHub
parent 8b2d3b0622
commit 9a01cee5fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 157 additions and 25 deletions

View File

@ -76,6 +76,9 @@ const ANNOTATION_LAST_CREATED = 'annotationLastCreated';
* @constructor * @constructor
*/ */
export default class AnnotationAPI extends EventEmitter { export default class AnnotationAPI extends EventEmitter {
/** @type {Map<ANNOTATION_TYPES, Array<(a, b) => boolean >>} */
#targetComparatorMap;
/** /**
* @param {OpenMCT} openmct * @param {OpenMCT} openmct
*/ */
@ -84,6 +87,7 @@ export default class AnnotationAPI extends EventEmitter {
this.openmct = openmct; this.openmct = openmct;
this.availableTags = {}; this.availableTags = {};
this.namespaceToSaveAnnotations = ''; this.namespaceToSaveAnnotations = '';
this.#targetComparatorMap = new Map();
this.ANNOTATION_TYPES = ANNOTATION_TYPES; this.ANNOTATION_TYPES = ANNOTATION_TYPES;
this.ANNOTATION_TYPE = ANNOTATION_TYPE; this.ANNOTATION_TYPE = ANNOTATION_TYPE;
@ -246,15 +250,16 @@ export default class AnnotationAPI extends EventEmitter {
/** /**
* @method getAnnotations * @method getAnnotations
* @param {Identifier} domainObjectIdentifier - The domain object identifier to use to search for annotations. For example, a telemetry object identifier. * @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 * @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 keyStringQuery = this.openmct.objects.makeKeyString(domainObjectIdentifier);
const searchResults = ( const searchResults = (
await Promise.all( await Promise.all(
this.openmct.objects.search( this.openmct.objects.search(
keyStringQuery, keyStringQuery,
null, abortSignal,
this.openmct.objects.SEARCH_TYPES.ANNOTATIONS this.openmct.objects.SEARCH_TYPES.ANNOTATIONS
) )
) )
@ -384,7 +389,8 @@ export default class AnnotationAPI extends EventEmitter {
const combinedResults = []; const combinedResults = [];
results.forEach((currentAnnotation) => { results.forEach((currentAnnotation) => {
const existingAnnotation = combinedResults.find((annotationToFind) => { const existingAnnotation = combinedResults.find((annotationToFind) => {
return _.isEqual(currentAnnotation.targets, annotationToFind.targets); const { annotationType, targets } = currentAnnotation;
return this.areAnnotationTargetsEqual(annotationType, targets, annotationToFind.targets);
}); });
if (!existingAnnotation) { if (!existingAnnotation) {
combinedResults.push(currentAnnotation); combinedResults.push(currentAnnotation);
@ -460,4 +466,35 @@ export default class AnnotationAPI extends EventEmitter {
return breakApartSeparateTargets; 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)
);
}
} }

View File

@ -265,4 +265,52 @@ describe('The Annotation API', () => {
expect(results.length).toEqual(0); 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();
});
});
}); });

View File

@ -41,7 +41,6 @@
<script> <script>
import TagEditor from './tags/TagEditor.vue'; import TagEditor from './tags/TagEditor.vue';
import _ from 'lodash';
export default { export default {
components: { components: {
@ -123,6 +122,7 @@ export default {
} }
}, },
async mounted() { async mounted() {
this.abortController = null;
this.openmct.annotation.on('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject); this.openmct.annotation.on('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject);
this.openmct.selection.on('change', this.updateSelection); this.openmct.selection.on('change', this.updateSelection);
await this.updateSelection(this.openmct.selection.get()); await this.updateSelection(this.openmct.selection.get());
@ -190,20 +190,34 @@ export default {
} }
}, },
async loadAnnotationForTargetObject(target) { async loadAnnotationForTargetObject(target) {
const targetID = this.openmct.objects.makeKeyString(target.identifier); // If the user changes targets while annotations are loading,
const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations( // abort the previous request.
target.identifier if (this.abortController !== null) {
); this.abortController.abort();
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];
return _.isEqual(fetchedTargetDetails, selectedTargetDetails); 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); this.loadNewAnnotations(filteredAnnotationsForSelection);
} catch (err) {
if (err.name !== 'AbortError') {
throw err;
}
} finally {
this.abortController = null;
}
} }
} }
}; };

View File

@ -306,13 +306,22 @@ export default {
this.getSearchResults = debounce(this.getSearchResults, 500); this.getSearchResults = debounce(this.getSearchResults, 500);
this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100); this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100);
}, },
async mounted() { async created() {
this.transaction = null;
this.abortController = new AbortController();
try {
await this.loadAnnotations(); await this.loadAnnotations();
} catch (err) {
if (err.name !== 'AbortError') {
throw err;
}
}
},
mounted() {
this.formatSidebar(); this.formatSidebar();
this.setSectionAndPageFromUrl(); this.setSectionAndPageFromUrl();
this.openmct.selection.on('change', this.updateSelection); this.openmct.selection.on('change', this.updateSelection);
this.transaction = null;
window.addEventListener('orientationchange', this.formatSidebar); window.addEventListener('orientationchange', this.formatSidebar);
window.addEventListener('hashchange', this.setSectionAndPageFromUrl); window.addEventListener('hashchange', this.setSectionAndPageFromUrl);
@ -324,6 +333,7 @@ export default {
); );
}, },
beforeDestroy() { beforeDestroy() {
this.abortController.abort();
if (this.unlisten) { if (this.unlisten) {
this.unlisten(); this.unlisten();
} }
@ -387,8 +397,10 @@ export default {
this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0; this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0;
const foundAnnotations = await this.openmct.annotation.getAnnotations( const foundAnnotations = await this.openmct.annotation.getAnnotations(
this.domainObject.identifier this.domainObject.identifier,
this.abortController.signal
); );
foundAnnotations.forEach((foundAnnotation) => { foundAnnotations.forEach((foundAnnotation) => {
const targetId = Object.keys(foundAnnotation.targets)[0]; const targetId = Object.keys(foundAnnotation.targets)[0];
const entryId = foundAnnotation.targets[targetId].entryId; const entryId = foundAnnotation.targets[targetId].entryId;
@ -425,7 +437,11 @@ export default {
: [...filteredPageEntriesByTime].reverse(); : [...filteredPageEntriesByTime].reverse();
if (this.lastLocalAnnotationCreation < this.domainObject.annotationLastCreated) { if (this.lastLocalAnnotationCreation < this.domainObject.annotationLastCreated) {
this.loadAnnotations(); this.loadAnnotations().catch((err) => {
if (err.name !== 'AbortError') {
throw err;
}
});
} }
}, },
changeSelectedSection({ sectionId, pageId }) { changeSelectedSection({ sectionId, pageId }) {

View File

@ -339,6 +339,9 @@ export default {
this.cursorGuide = newCursorGuide; this.cursorGuide = newCursorGuide;
} }
}, },
created() {
this.abortController = new AbortController();
},
mounted() { mounted() {
this.yAxisIdVisibility = {}; this.yAxisIdVisibility = {};
this.offsetWidth = 0; this.offsetWidth = 0;
@ -398,6 +401,7 @@ export default {
this.loaded = true; this.loaded = true;
}, },
beforeDestroy() { beforeDestroy() {
this.abortController.abort();
this.openmct.selection.off('change', this.updateSelection); this.openmct.selection.off('change', this.updateSelection);
document.removeEventListener('keydown', this.handleKeyDown); document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp); document.removeEventListener('keyup', this.handleKeyUp);
@ -621,7 +625,8 @@ export default {
await Promise.all( await Promise.all(
this.seriesModels.map(async (seriesModel) => { this.seriesModels.map(async (seriesModel) => {
const seriesAnnotations = await this.openmct.annotation.getAnnotations( const seriesAnnotations = await this.openmct.annotation.getAnnotations(
seriesModel.model.identifier seriesModel.model.identifier,
this.abortController.signal
); );
rawAnnotationsForPlot.push(...seriesAnnotations); rawAnnotationsForPlot.push(...seriesAnnotations);
}) })
@ -1524,7 +1529,11 @@ export default {
this.endMarquee(); this.endMarquee();
} }
this.loadAnnotations(); this.loadAnnotations().catch((err) => {
if (err.name !== 'AbortError') {
throw err;
}
});
}, },
zoom(zoomDirection, zoomFactor) { zoom(zoomDirection, zoomFactor) {

View File

@ -98,6 +98,13 @@ export default {
} }
return 'Could not find any matching Notebook entries'; 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 { } else {
return this.result.targetModels[0].name; return this.result.targetModels[0].name;
} }
@ -133,7 +140,8 @@ export default {
// if we're not on the correct page, navigate to the object, // 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 // then wait for the selection event to fire before issuing a new selection
if ( if (
this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL ||
this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL
) { ) {
this.openmct.selection.on('change', this.clickedPlotAnnotation); this.openmct.selection.on('change', this.clickedPlotAnnotation);
} }