Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

5853 plot annotations prototype #6000

Merged
merged 87 commits into from Jan 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
fdaaaaa
implement new search and tagging for notebooks
scottbell May 12, 2022
2db70bb
add example tags, remove inspector reference
scottbell May 12, 2022
95c8d15
include annotations in mct
scottbell May 12, 2022
fb4a5da
work with empty notebook entries
scottbell May 13, 2022
00db5a5
add inspector and plot annotations
scottbell May 13, 2022
a85bf54
fix conflicts
scottbell May 26, 2022
64e7b5a
merged
scottbell Sep 27, 2022
7adff3b
fix plot prototype with new annotations
scottbell Sep 28, 2022
3dbd08b
Merge remote-tracking branch 'origin/master' into mct4820-prototype
scottbell Sep 30, 2022
99bfc69
Merge remote-tracking branch 'origin/master' into mct4820-prototype
scottbell Oct 5, 2022
2cbf3a8
Merge remote-tracking branch 'origin/mct4820-prototype' into 5853-plo…
scottbell Oct 7, 2022
f567e52
clean up inspector for plots and other views
scottbell Oct 7, 2022
aa14cfe
bump webpack defaults for windows
scottbell Oct 11, 2022
e7bb0df
Merge remote-tracking branch 'origin/master' into 5853-plot-annotatio…
scottbell Nov 1, 2022
abad31a
annotations retrieved properly
scottbell Nov 2, 2022
dcf8ae6
Merge remote-tracking branch 'origin/master' into 5853-plot-annotatio…
scottbell Nov 7, 2022
018d981
wip
scottbell Nov 8, 2022
0e17826
Merge remote-tracking branch 'origin/master' into 5853-plot-annotatio…
scottbell Nov 9, 2022
dc95f7a
get rid of console debugging, allow for notebook entry selection
scottbell Nov 9, 2022
45a1508
notebook entry selection works
scottbell Nov 9, 2022
60735b9
add target specific details
scottbell Nov 9, 2022
854e4e4
most works for tagging
scottbell Nov 10, 2022
a3b8fa1
need to react to adds
scottbell Nov 10, 2022
23b89dc
Merge remote-tracking branch 'origin/master' into 5853-plot-annotatio…
scottbell Nov 17, 2022
f9c5d32
ignore tags on things we don't support
scottbell Nov 17, 2022
567f4f1
listen to new annotations
scottbell Nov 17, 2022
667adfd
check for null domain objects
scottbell Nov 21, 2022
6369465
notebook annotations work in inspector now
scottbell Nov 21, 2022
45dfb60
pass function for when tags change so notebook entry can timestamp
scottbell Nov 21, 2022
c690b5b
need to supress other selection event firing
scottbell Nov 21, 2022
9091747
no only getting one event
scottbell Nov 28, 2022
608712c
Merge remote-tracking branch 'origin/master' into 5853-plot-annotatio…
scottbell Dec 2, 2022
20be2a8
showing all tags for a given domain objects
scottbell Dec 2, 2022
ffa65f1
works in plots again
scottbell Dec 2, 2022
e523fe5
wip moving to multiple targets per annotations
scottbell Dec 3, 2022
94ebfee
hopefully now compatible with plots
scottbell Dec 5, 2022
df10af4
do not show annotations if nothing selected
scottbell Dec 5, 2022
1feb191
need to work on plots now
scottbell Dec 5, 2022
17d15ce
need to work on clicking
scottbell Dec 5, 2022
37029e8
try to draw rectangles
scottbell Dec 5, 2022
29a7761
allow selection of annotations
scottbell Dec 5, 2022
7aad7d6
selecting rectangles now work
scottbell Dec 5, 2022
4557d7f
Merge remote-tracking branch 'origin/master' into 5853-plot-annotatio…
scottbell Dec 6, 2022
2e7e36e
deal with multiple annotations
scottbell Dec 6, 2022
1430da8
displaying annotations properly now
scottbell Dec 6, 2022
a4ca5db
need to filter annotations
scottbell Dec 7, 2022
e687704
Merge remote-tracking branch 'origin/master' into 5853-plot-annotatio…
scottbell Dec 7, 2022
436f91b
close
scottbell Dec 7, 2022
8921dc8
now works across multiple series
scottbell Dec 7, 2022
f7c4846
make editing smarter
scottbell Dec 8, 2022
846c03e
only draw annotations if we haven't drawn them before
scottbell Dec 9, 2022
45d618a
only draw one unique bounding box
scottbell Dec 9, 2022
7a2a116
be more careful about deleting tags
scottbell Dec 9, 2022
ff1990d
Merge remote-tracking branch 'origin/master' into 5853-plot-annotatio…
scottbell Dec 12, 2022
d702333
performance improvements
scottbell Dec 12, 2022
10d6926
remove annotation text and perform plot zoom on search result
scottbell Dec 13, 2022
450029b
plot selection works
scottbell Dec 13, 2022
b2aa365
zoom out abit post selection
scottbell Dec 13, 2022
4720c4b
fix tests
scottbell Dec 13, 2022
c64dec9
Merge branch 'master' into 5853-plot-annotations-prototype
scottbell Dec 13, 2022
0279290
linting
scottbell Dec 13, 2022
0dfe563
adjust notebook tagging to accomodate inspector tags
scottbell Dec 14, 2022
8ae97b8
linting
scottbell Dec 14, 2022
ca741bd
short circuit annotations in plots if no tags are defined
scottbell Dec 14, 2022
36cda89
fix tests
scottbell Dec 14, 2022
3efb777
linting
scottbell Dec 14, 2022
14b353a
Merge branch 'master' into 5853-plot-annotations-prototype
scottbell Dec 16, 2022
8499882
just use the annotation create function
scottbell Dec 16, 2022
396aa13
update docs
scottbell Dec 19, 2022
73ce822
use undefined
scottbell Dec 19, 2022
ba892f3
get rid of console debug outputs
scottbell Dec 19, 2022
0fbaa6c
refactor to use identifier
scottbell Dec 19, 2022
9ea2375
resolve conflicts
scottbell Jan 5, 2023
e7b6ea4
Merge branch 'master' into 5853-plot-annotations-prototype
scottbell Jan 9, 2023
96c4702
resolve conflicts
scottbell Jan 18, 2023
16268a9
fix import with new webpack config
scottbell Jan 18, 2023
3fa3489
addressing pr comments
scottbell Jan 18, 2023
0cb483b
Merge branch 'master' into 5853-plot-annotations-prototype
scottbell Jan 19, 2023
1f4670b
fix tests
scottbell Jan 19, 2023
590e587
possible performance improvement
scottbell Jan 19, 2023
38a2bd3
move performance fix to MctChart
scottbell Jan 20, 2023
2d859c1
disable various annotation editing and selection in real time mode
scottbell Jan 20, 2023
3c66351
lint
scottbell Jan 20, 2023
2acea4f
Merge branch 'master' into 5853-plot-annotations-prototype
scottbell Jan 20, 2023
e4e7fad
only allow annotations if plot is paused or in fixed time. also do no…
scottbell Jan 20, 2023
9f9afc0
resolve conflicts
scottbell Jan 20, 2023
7c7e6a4
key off local events instead of remote
scottbell Jan 20, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 3 additions & 2 deletions .webpack/webpack.common.js
Expand Up @@ -80,6 +80,7 @@ const config = {
projectRootDir,
"src/api/objects/object-utils.js"
),
"kdbush": path.join(projectRootDir, "node_modules/kdbush/kdbush.min.js"),
utils: path.join(projectRootDir, "src/utils")
}
},
Expand Down Expand Up @@ -167,8 +168,8 @@ const config = {
performance: {
// We should eventually consider chunking to decrease
// these values
maxEntrypointSize: 25000000,
maxAssetSize: 25000000
maxEntrypointSize: 27000000,
maxAssetSize: 27000000
}
};

Expand Down
19 changes: 11 additions & 8 deletions e2e/tests/functional/plugins/notebook/tags.e2e.spec.js
Expand Up @@ -57,12 +57,14 @@ async function createNotebookAndEntry(page, iterations = 1) {
*/
async function createNotebookEntryAndTags(page, iterations = 1) {
const notebook = await createNotebookAndEntry(page, iterations);
await page.locator('text=Annotations').click();

for (let iteration = 0; iteration < iterations; iteration++) {
// Hover and click "Add Tag" button
// Hover is needed here to "slow down" the actions while running in headless mode
await page.hover(`button:has-text("Add Tag") >> nth = ${iteration}`);
await page.locator(`button:has-text("Add Tag") >> nth = ${iteration}`).click();
await page.locator(`[aria-label="Notebook Entry"] >> nth = ${iteration}`).click();
await page.hover(`button:has-text("Add Tag")`);
await page.locator(`button:has-text("Add Tag")`).click();

// Click inside the tag search input
await page.locator('[placeholder="Type to select tag"]').click();
Expand All @@ -71,8 +73,8 @@ async function createNotebookEntryAndTags(page, iterations = 1) {

// Hover and click "Add Tag" button
// Hover is needed here to "slow down" the actions while running in headless mode
await page.hover(`button:has-text("Add Tag") >> nth = ${iteration}`);
await page.locator(`button:has-text("Add Tag") >> nth = ${iteration}`).click();
await page.hover(`button:has-text("Add Tag")`);
await page.locator(`button:has-text("Add Tag")`).click();
// Click inside the tag search input
await page.locator('[placeholder="Type to select tag"]').click();
// Select the "Science" tag
Expand All @@ -84,8 +86,10 @@ async function createNotebookEntryAndTags(page, iterations = 1) {

test.describe('Tagging in Notebooks @addInit', () => {
test('Can load tags', async ({ page }) => {

await createNotebookAndEntry(page);

await page.locator('text=Annotations').click();

await page.locator('button:has-text("Add Tag")').click();

await page.locator('[placeholder="Type to select tag"]').click();
Expand Down Expand Up @@ -126,13 +130,12 @@ test.describe('Tagging in Notebooks @addInit', () => {

test('Can delete tags', async ({ page }) => {
await createNotebookEntryAndTags(page);
await page.locator('[aria-label="Notebook Entries"]').click();
// Delete Driving
await page.hover('[aria-label="Tag"]:has-text("Driving")');
await page.locator('[aria-label="Tag"]:has-text("Driving") ~ .c-completed-tag-deletion').click();

await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science");
await expect(page.locator('[aria-label="Notebook Entry"]')).not.toContainText("Driving");
await expect(page.locator('[aria-label="Tags Inspector"]')).toContainText("Science");
await expect(page.locator('[aria-label="Tags Inspector"]')).not.toContainText("Driving");

await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc');
await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -41,6 +41,7 @@
"karma-sourcemap-loader": "0.3.8",
"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.2",
Expand Down
9 changes: 9 additions & 0 deletions src/MCT.js
Expand Up @@ -256,6 +256,15 @@ define([
});
});

/**
* MCT's annotation API that enables
* human-created comments and categorization linked to data products
* @type {module:openmct.AnnotationAPI}
* @memberof module:openmct.MCT#
* @name annotation
*/
this.annotation = new api.AnnotationAPI(this);

// Plugins that are installed by default
this.install(this.plugins.Plot());
this.install(this.plugins.TelemetryTable.default());
Expand Down
187 changes: 122 additions & 65 deletions src/api/annotation/AnnotationAPI.js
Expand Up @@ -52,6 +52,29 @@ const ANNOTATION_LAST_CREATED = 'annotationLastCreated';
* @property {String} foregroundColor eg. "#ffffff"
*/

/**
* @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject
*/

/**
* @typedef {import('../objects/ObjectAPI').Identifier} Identifier
*/

/**
* @typedef {import('../../../openmct').OpenMCT} OpenMCT
*/

/**
* An interface for interacting with annotations of domain objects.
* An annotation of a domain object is an operator created object for the purposes
* of further describing data in plots, notebooks, maps, etc. For example, an annotation
* could be a tag on a plot notating an interesting set of points labeled SCIENCE. It could
* also be set of notebook entries the operator has tagged DRIVING when a robot monitored by OpenMCT
* about rationals behind why the robot has taken a certain path.
* Annotations are discoverable using search, and are typically rendered in OpenMCT views to bring attention
* to other users.
* @constructor
*/
export default class AnnotationAPI extends EventEmitter {

/**
Expand Down Expand Up @@ -81,24 +104,26 @@ export default class AnnotationAPI extends EventEmitter {
}
});
}

/**
* Create the a generic annotation
* Creates an annotation on a given domain object (e.g., a plot) and a set of targets (e.g., telemetry objects)
* @typedef {Object} CreateAnnotationOptions
* @property {String} name a name for the new parameter
* @property {import('../objects/ObjectAPI').DomainObject} domainObject the domain object to create
* @property {ANNOTATION_TYPES} annotationType the type of annotation to create
* @property {Tag[]} tags
* @property {String} contentText
* @property {import('../objects/ObjectAPI').Identifier[]} targets
* @property {String} name a name for the new annotation (e.g., "Plot annnotation")
* @property {DomainObject} domainObject the domain object this annotation was created with
* @property {ANNOTATION_TYPES} annotationType the type of annotation to create (e.g., PLOT_SPATIAL)
* @property {Tag[]} tags tags to add to the annotation, e.g., SCIENCE for science related annotations
* @property {String} contentText Some text to add to the annotation, e.g. ("This annotation is about science")
* @property {Object<string, Object>} targets The targets ID keystrings and their specific properties.
* For plots, this will be a bounding box, e.g.: {maxY: 100, minY: 0, maxX: 100, minX: 0}
* For notebooks, this will be an entry ID, e.g.: {entryId: "entry-ecb158f5-d23c-45e1-a704-649b382622ba"}
* @property {DomainObject>} targetDomainObjects the targets ID keystrings and the domain objects this annotation points to (e.g., telemetry objects for a plot)
*/
/**
* @method create
* @param {CreateAnnotationOptions} options
* @returns {Promise<import('../objects/ObjectAPI').DomainObject>} a promise which will resolve when the domain object
* @returns {Promise<DomainObject>} a promise which will resolve when the domain object
* has been created, or be rejected if it cannot be saved
*/
async create({name, domainObject, annotationType, tags, contentText, targets}) {
async create({name, domainObject, annotationType, tags, contentText, targets, targetDomainObjects}) {
if (!Object.keys(this.ANNOTATION_TYPES).includes(annotationType)) {
throw new Error(`Unknown annotation type: ${annotationType}`);
}
Expand All @@ -107,6 +132,10 @@ export default class AnnotationAPI extends EventEmitter {
throw new Error(`At least one target is required to create an annotation`);
}

if (!Object.keys(targetDomainObjects).length) {
scottbell marked this conversation as resolved.
Show resolved Hide resolved
throw new Error(`At least one targetDomainObject is required to create an annotation`);
}

const domainObjectKeyString = this.openmct.objects.makeKeyString(domainObject.identifier);
const originalPathObjects = await this.openmct.objects.getOriginalPath(domainObjectKeyString);
const originalContextPath = this.openmct.objects.getRelativePath(originalPathObjects);
Expand Down Expand Up @@ -139,16 +168,25 @@ export default class AnnotationAPI extends EventEmitter {
const success = await this.openmct.objects.save(createdObject);
if (success) {
this.emit('annotationCreated', createdObject);
this.#updateAnnotationModified(domainObject);
Object.values(targetDomainObjects).forEach(targetDomainObject => {
scottbell marked this conversation as resolved.
Show resolved Hide resolved
this.#updateAnnotationModified(targetDomainObject);
});

return createdObject;
} else {
throw new Error('Failed to create object');
}
}

#updateAnnotationModified(domainObject) {
this.openmct.objects.mutate(domainObject, this.ANNOTATION_LAST_CREATED, Date.now());
#updateAnnotationModified(targetDomainObject) {
// As certain telemetry objects are immutable, we'll need to check here first
// to see if we can add the annotation last created property.
// TODO: This should be removed once we have a better way to handle immutable telemetry objects
if (targetDomainObject.isMutable) {
this.openmct.objects.mutate(targetDomainObject, this.ANNOTATION_LAST_CREATED, Date.now());
} else {
this.emit('targetDomainObjectAnnotated', targetDomainObject);
}
}

/**
Expand All @@ -162,7 +200,7 @@ export default class AnnotationAPI extends EventEmitter {

/**
* @method isAnnotation
* @param {import('../objects/ObjectAPI').DomainObject} domainObject domainObject the domainObject in question
* @param {DomainObject} domainObject the domainObject in question
* @returns {Boolean} Returns true if the domain object is an annotation
*/
isAnnotation(domainObject) {
Expand Down Expand Up @@ -190,56 +228,19 @@ export default class AnnotationAPI extends EventEmitter {

/**
* @method getAnnotations
* @param {String} query - The keystring of the domain object to search for annotations for
* @returns {import('../objects/ObjectAPI').DomainObject[]} Returns an array of domain objects that match the search query
* @param {Identifier} domainObjectIdentifier - The domain object identifier to use to search for annotations. For example, a telemetry object identifier.
* @returns {DomainObject[]} Returns an array of annotations that match the search query
*/
async getAnnotations(query) {
const searchResults = (await Promise.all(this.openmct.objects.search(query, null, this.openmct.objects.SEARCH_TYPES.ANNOTATIONS))).flat();
async getAnnotations(domainObjectIdentifier) {
scottbell marked this conversation as resolved.
Show resolved Hide resolved
const keyStringQuery = this.openmct.objects.makeKeyString(domainObjectIdentifier);
const searchResults = (await Promise.all(this.openmct.objects.search(keyStringQuery, null, this.openmct.objects.SEARCH_TYPES.ANNOTATIONS))).flat();

return searchResults;
}

/**
* @method addSingleAnnotationTag
* @param {import('../objects/ObjectAPI').DomainObject=} existingAnnotation - An optional annotation to add the tag to. If not specified, we will create an annotation.
* @param {import('../objects/ObjectAPI').DomainObject} targetDomainObject - The domain object the annotation will point to.
* @param {Object=} targetSpecificDetails - Optional object to add to the target object. E.g., for notebooks this would be an entryID
* @param {AnnotationType} annotationType - The type of annotation this is for.
* @returns {import('../objects/ObjectAPI').DomainObject[]} Returns the annotation that was either created or passed as an existingAnnotation
*/
async addSingleAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) {
if (!existingAnnotation) {
const targets = {};
const targetKeyString = this.openmct.objects.makeKeyString(targetDomainObject.identifier);
targets[targetKeyString] = targetSpecificDetails;
const contentText = `${annotationType} tag`;
const annotationCreationArguments = {
name: contentText,
domainObject: targetDomainObject,
annotationType,
tags: [tag],
contentText,
targets
};
const newAnnotation = await this.create(annotationCreationArguments);

return newAnnotation;
} else {
if (!existingAnnotation.tags.includes(tag)) {
throw new Error(`Existing annotation did not contain tag ${tag}`);
}

if (existingAnnotation._deleted) {
this.unDeleteAnnotation(existingAnnotation);
}

return existingAnnotation;
}
}

/**
* @method deleteAnnotations
* @param {import('../objects/ObjectAPI').DomainObject[]} existingAnnotation - An array of annotations to delete (set _deleted to true)
* @param {DomainObject[]} existingAnnotation - An array of annotations to delete (set _deleted to true)
*/
deleteAnnotations(annotations) {
if (!annotations) {
Expand All @@ -255,7 +256,7 @@ export default class AnnotationAPI extends EventEmitter {

/**
* @method deleteAnnotations
* @param {import('../objects/ObjectAPI').DomainObject} existingAnnotation - An annotations to undelete (set _deleted to false)
* @param {DomainObject} annotation - An annotation to undelete (set _deleted to false)
*/
unDeleteAnnotation(annotation) {
scottbell marked this conversation as resolved.
Show resolved Hide resolved
if (!annotation) {
Expand All @@ -265,6 +266,39 @@ export default class AnnotationAPI extends EventEmitter {
this.openmct.objects.mutate(annotation, '_deleted', false);
}

getTagsFromAnnotations(annotations, filterDuplicates = true) {
if (!annotations) {
return [];
}

let tagsFromAnnotations = annotations.flatMap((annotation) => {
if (annotation._deleted) {
return [];
} else {
return annotation.tags;
}
});

if (filterDuplicates) {
tagsFromAnnotations = tagsFromAnnotations.filter((tag, index, tagArray) => {
return tagArray.indexOf(tag) === index;
});
}

const fullTagModels = this.#addTagMetaInformationToTags(tagsFromAnnotations);

return fullTagModels;
}

#addTagMetaInformationToTags(tags) {
return tags.map(tagKey => {
const tagModel = this.availableTags[tagKey];
tagModel.tagID = tagKey;

return tagModel;
});
}

#getMatchingTags(query) {
if (!query) {
return [];
Expand All @@ -283,12 +317,7 @@ export default class AnnotationAPI extends EventEmitter {

#addTagMetaInformationToResults(results, matchingTagKeys) {
const tagsAddedToResults = results.map(result => {
const fullTagModels = result.tags.map(tagKey => {
const tagModel = this.availableTags[tagKey];
tagModel.tagID = tagKey;

return tagModel;
});
const fullTagModels = this.#addTagMetaInformationToTags(result.tags);

return {
fullTagModels,
Expand Down Expand Up @@ -338,6 +367,33 @@ export default class AnnotationAPI extends EventEmitter {
return combinedResults;
}

/**
* @method #breakApartSeparateTargets
* @param {Array} results A set of search results that could have the multiple targets for the same result
* @returns {Array} The same set of results, but with each target separated out into its own result
*/
#breakApartSeparateTargets(results) {
scottbell marked this conversation as resolved.
Show resolved Hide resolved
const separateResults = [];
results.forEach(result => {
Object.keys(result.targets).forEach(targetID => {
const separatedResult = {
...result
};
separatedResult.targets = {
[targetID]: result.targets[targetID]
};
separatedResult.targetModels = result.targetModels.filter(targetModel => {
const targetKeyString = this.openmct.objects.makeKeyString(targetModel.identifier);

return targetKeyString === targetID;
});
separateResults.push(separatedResult);
});
});

return separateResults;
}

/**
* @method searchForTags
* @param {String} query A query to match against tags. E.g., "dr" will match the tags "drilling" and "driving"
Expand All @@ -360,7 +416,8 @@ export default class AnnotationAPI extends EventEmitter {
const resultsWithValidPath = appliedTargetsModels.filter(result => {
return this.openmct.objects.isReachable(result.targetModels?.[0]?.originalPath);
});
const breakApartSeparateTargets = this.#breakApartSeparateTargets(resultsWithValidPath);

return resultsWithValidPath;
return breakApartSeparateTargets;
}
}