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
*/
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)
);
}
}

View File

@ -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();
});
});
});

View File

@ -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;
}
}
}
};

View File

@ -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 }) {

View File

@ -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);
@ -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);
})
@ -1524,7 +1529,11 @@ export default {
this.endMarquee();
}
this.loadAnnotations();
this.loadAnnotations().catch((err) => {
if (err.name !== 'AbortError') {
throw err;
}
});
},
zoom(zoomDirection, zoomFactor) {

View File

@ -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;
}
@ -133,7 +140,8 @@ export default {
// 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.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);
}