mirror of
https://github.com/nasa/openmct.git
synced 2024-12-20 05:37:53 +00:00
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:
parent
8b2d3b0622
commit
9a01cee5fa
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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.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() {
|
||||
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 }) {
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user