5853 plot annotations prototype (#6000)

* Implement new search and tagging for notebooks
* Add inspector and plot annotations
* Clean up inspector for plots and other views
* Bump webpack defaults for windows
* Notebook annotations are shown in inspector now
* Only allow annotations if plot is paused or in fixed time. also do not mutate if immutable
* Key off local events instead of remote (for now)
This commit is contained in:
Scott Bell 2023-01-20 23:34:12 +01:00 committed by GitHub
parent edbbebe329
commit d1c7d133fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1204 additions and 166 deletions

View File

@ -80,6 +80,7 @@ const config = {
projectRootDir, projectRootDir,
"src/api/objects/object-utils.js" "src/api/objects/object-utils.js"
), ),
"kdbush": path.join(projectRootDir, "node_modules/kdbush/kdbush.min.js"),
utils: path.join(projectRootDir, "src/utils") utils: path.join(projectRootDir, "src/utils")
} }
}, },
@ -167,8 +168,8 @@ const config = {
performance: { performance: {
// We should eventually consider chunking to decrease // We should eventually consider chunking to decrease
// these values // these values
maxEntrypointSize: 25000000, maxEntrypointSize: 27000000,
maxAssetSize: 25000000 maxAssetSize: 27000000
} }
}; };

View File

@ -57,12 +57,14 @@ async function createNotebookAndEntry(page, iterations = 1) {
*/ */
async function createNotebookEntryAndTags(page, iterations = 1) { async function createNotebookEntryAndTags(page, iterations = 1) {
const notebook = await createNotebookAndEntry(page, iterations); const notebook = await createNotebookAndEntry(page, iterations);
await page.locator('text=Annotations').click();
for (let iteration = 0; iteration < iterations; iteration++) { for (let iteration = 0; iteration < iterations; iteration++) {
// Hover and click "Add Tag" button // Hover and click "Add Tag" button
// Hover is needed here to "slow down" the actions while running in headless mode // 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(`[aria-label="Notebook Entry"] >> nth = ${iteration}`).click();
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 // Click inside the tag search input
await page.locator('[placeholder="Type to select tag"]').click(); await page.locator('[placeholder="Type to select tag"]').click();
@ -71,8 +73,8 @@ async function createNotebookEntryAndTags(page, iterations = 1) {
// Hover and click "Add Tag" button // Hover and click "Add Tag" button
// Hover is needed here to "slow down" the actions while running in headless mode // 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.hover(`button:has-text("Add Tag")`);
await page.locator(`button:has-text("Add Tag") >> nth = ${iteration}`).click(); await page.locator(`button:has-text("Add Tag")`).click();
// Click inside the tag search input // Click inside the tag search input
await page.locator('[placeholder="Type to select tag"]').click(); await page.locator('[placeholder="Type to select tag"]').click();
// Select the "Science" tag // Select the "Science" tag
@ -84,8 +86,10 @@ async function createNotebookEntryAndTags(page, iterations = 1) {
test.describe('Tagging in Notebooks @addInit', () => { test.describe('Tagging in Notebooks @addInit', () => {
test('Can load tags', async ({ page }) => { test('Can load tags', async ({ page }) => {
await createNotebookAndEntry(page); await createNotebookAndEntry(page);
await page.locator('text=Annotations').click();
await page.locator('button:has-text("Add Tag")').click(); await page.locator('button:has-text("Add Tag")').click();
await page.locator('[placeholder="Type to select tag"]').click(); await page.locator('[placeholder="Type to select tag"]').click();
@ -126,13 +130,12 @@ test.describe('Tagging in Notebooks @addInit', () => {
test('Can delete tags', async ({ page }) => { test('Can delete tags', async ({ page }) => {
await createNotebookEntryAndTags(page); await createNotebookEntryAndTags(page);
await page.locator('[aria-label="Notebook Entries"]').click();
// Delete Driving // Delete Driving
await page.hover('[aria-label="Tag"]:has-text("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 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="Tags Inspector"]')).toContainText("Science");
await expect(page.locator('[aria-label="Notebook Entry"]')).not.toContainText("Driving"); await expect(page.locator('[aria-label="Tags Inspector"]')).not.toContainText("Driving");
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc'); await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc');
await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving"); await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");

View File

@ -41,6 +41,7 @@
"karma-sourcemap-loader": "0.3.8", "karma-sourcemap-loader": "0.3.8",
"karma-spec-reporter": "0.0.36", "karma-spec-reporter": "0.0.36",
"karma-webpack": "5.0.0", "karma-webpack": "5.0.0",
"kdbush": "^3.0.0",
"location-bar": "3.0.1", "location-bar": "3.0.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"mini-css-extract-plugin": "2.7.2", "mini-css-extract-plugin": "2.7.2",

View File

@ -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 // Plugins that are installed by default
this.install(this.plugins.Plot()); this.install(this.plugins.Plot());
this.install(this.plugins.TelemetryTable.default()); this.install(this.plugins.TelemetryTable.default());

View File

@ -52,6 +52,29 @@ const ANNOTATION_LAST_CREATED = 'annotationLastCreated';
* @property {String} foregroundColor eg. "#ffffff" * @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 { export default class AnnotationAPI extends EventEmitter {
/** /**
@ -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 * @typedef {Object} CreateAnnotationOptions
* @property {String} name a name for the new parameter * @property {String} name a name for the new annotation (e.g., "Plot annnotation")
* @property {import('../objects/ObjectAPI').DomainObject} domainObject the domain object to create * @property {DomainObject} domainObject the domain object this annotation was created with
* @property {ANNOTATION_TYPES} annotationType the type of annotation to create * @property {ANNOTATION_TYPES} annotationType the type of annotation to create (e.g., PLOT_SPATIAL)
* @property {Tag[]} tags * @property {Tag[]} tags tags to add to the annotation, e.g., SCIENCE for science related annotations
* @property {String} contentText * @property {String} contentText Some text to add to the annotation, e.g. ("This annotation is about science")
* @property {import('../objects/ObjectAPI').Identifier[]} targets * @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 * @method create
* @param {CreateAnnotationOptions} options * @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 * 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)) { if (!Object.keys(this.ANNOTATION_TYPES).includes(annotationType)) {
throw new Error(`Unknown annotation type: ${annotationType}`); throw new Error(`Unknown annotation type: ${annotationType}`);
} }
@ -107,6 +132,10 @@ export default class AnnotationAPI extends EventEmitter {
throw new Error(`At least one target is required to create an annotation`); throw new Error(`At least one target is required to create an annotation`);
} }
if (!Object.keys(targetDomainObjects).length) {
throw new Error(`At least one targetDomainObject is required to create an annotation`);
}
const domainObjectKeyString = this.openmct.objects.makeKeyString(domainObject.identifier); const domainObjectKeyString = this.openmct.objects.makeKeyString(domainObject.identifier);
const originalPathObjects = await this.openmct.objects.getOriginalPath(domainObjectKeyString); const originalPathObjects = await this.openmct.objects.getOriginalPath(domainObjectKeyString);
const originalContextPath = this.openmct.objects.getRelativePath(originalPathObjects); const originalContextPath = this.openmct.objects.getRelativePath(originalPathObjects);
@ -139,7 +168,9 @@ export default class AnnotationAPI extends EventEmitter {
const success = await this.openmct.objects.save(createdObject); const success = await this.openmct.objects.save(createdObject);
if (success) { if (success) {
this.emit('annotationCreated', createdObject); this.emit('annotationCreated', createdObject);
this.#updateAnnotationModified(domainObject); Object.values(targetDomainObjects).forEach(targetDomainObject => {
this.#updateAnnotationModified(targetDomainObject);
});
return createdObject; return createdObject;
} else { } else {
@ -147,8 +178,15 @@ export default class AnnotationAPI extends EventEmitter {
} }
} }
#updateAnnotationModified(domainObject) { #updateAnnotationModified(targetDomainObject) {
this.openmct.objects.mutate(domainObject, this.ANNOTATION_LAST_CREATED, Date.now()); // 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);
}
} }
/** /**
@ -162,7 +200,7 @@ export default class AnnotationAPI extends EventEmitter {
/** /**
* @method isAnnotation * @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 * @returns {Boolean} Returns true if the domain object is an annotation
*/ */
isAnnotation(domainObject) { isAnnotation(domainObject) {
@ -190,56 +228,19 @@ export default class AnnotationAPI extends EventEmitter {
/** /**
* @method getAnnotations * @method getAnnotations
* @param {String} query - The keystring of the domain object to search for annotations for * @param {Identifier} domainObjectIdentifier - The domain object identifier to use to search for annotations. For example, a telemetry object identifier.
* @returns {import('../objects/ObjectAPI').DomainObject[]} Returns an array of domain objects that match the search query * @returns {DomainObject[]} Returns an array of annotations that match the search query
*/ */
async getAnnotations(query) { async getAnnotations(domainObjectIdentifier) {
const searchResults = (await Promise.all(this.openmct.objects.search(query, null, this.openmct.objects.SEARCH_TYPES.ANNOTATIONS))).flat(); 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; 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 * @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) { deleteAnnotations(annotations) {
if (!annotations) { if (!annotations) {
@ -255,7 +256,7 @@ export default class AnnotationAPI extends EventEmitter {
/** /**
* @method deleteAnnotations * @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) { unDeleteAnnotation(annotation) {
if (!annotation) { if (!annotation) {
@ -265,6 +266,39 @@ export default class AnnotationAPI extends EventEmitter {
this.openmct.objects.mutate(annotation, '_deleted', false); 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) { #getMatchingTags(query) {
if (!query) { if (!query) {
return []; return [];
@ -283,12 +317,7 @@ export default class AnnotationAPI extends EventEmitter {
#addTagMetaInformationToResults(results, matchingTagKeys) { #addTagMetaInformationToResults(results, matchingTagKeys) {
const tagsAddedToResults = results.map(result => { const tagsAddedToResults = results.map(result => {
const fullTagModels = result.tags.map(tagKey => { const fullTagModels = this.#addTagMetaInformationToTags(result.tags);
const tagModel = this.availableTags[tagKey];
tagModel.tagID = tagKey;
return tagModel;
});
return { return {
fullTagModels, fullTagModels,
@ -338,6 +367,33 @@ export default class AnnotationAPI extends EventEmitter {
return combinedResults; 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) {
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 * @method searchForTags
* @param {String} query A query to match against tags. E.g., "dr" will match the tags "drilling" and "driving" * @param {String} query A query to match against tags. E.g., "dr" will match the tags "drilling" and "driving"
@ -360,7 +416,8 @@ export default class AnnotationAPI extends EventEmitter {
const resultsWithValidPath = appliedTargetsModels.filter(result => { const resultsWithValidPath = appliedTargetsModels.filter(result => {
return this.openmct.objects.isReachable(result.targetModels?.[0]?.originalPath); return this.openmct.objects.isReachable(result.targetModels?.[0]?.originalPath);
}); });
const breakApartSeparateTargets = this.#breakApartSeparateTargets(resultsWithValidPath);
return resultsWithValidPath; return breakApartSeparateTargets;
} }
} }

View File

@ -108,6 +108,7 @@ describe("The Annotation API", () => {
annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK,
tags: ['sometag'], tags: ['sometag'],
contentText: "fooContext", contentText: "fooContext",
targetDomainObjects: [mockDomainObject],
targets: {'fooTarget': {}} targets: {'fooTarget': {}}
}; };
const annotationObject = await openmct.annotation.create(annotationCreationArguments); const annotationObject = await openmct.annotation.create(annotationCreationArguments);
@ -124,27 +125,39 @@ describe("The Annotation API", () => {
}); });
describe("Tagging", () => { describe("Tagging", () => {
let tagCreationArguments;
beforeEach(() => {
tagCreationArguments = {
name: 'Test Annotation',
domainObject: mockDomainObject,
annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK,
tags: ['aWonderfulTag'],
contentText: 'fooContext',
targets: {'fooNameSpace:some-object': {entryId: 'fooBarEntry'}},
targetDomainObjects: [mockDomainObject]
};
});
it("can create a tag", async () => { it("can create a tag", async () => {
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); const annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined(); expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation'); expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag'); expect(annotationObject.tags).toContain('aWonderfulTag');
}); });
it("can delete a tag", async () => { it("can delete a tag", async () => {
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); const annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined(); expect(annotationObject).toBeDefined();
openmct.annotation.deleteAnnotations([annotationObject]); openmct.annotation.deleteAnnotations([annotationObject]);
expect(annotationObject._deleted).toBeTrue(); expect(annotationObject._deleted).toBeTrue();
}); });
it("throws an error if deleting non-existent tag", async () => { it("throws an error if deleting non-existent tag", async () => {
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); const annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined(); expect(annotationObject).toBeDefined();
expect(() => { expect(() => {
openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist'); openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist');
}).toThrow(); }).toThrow();
}); });
it("can remove all tags", async () => { it("can remove all tags", async () => {
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); const annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined(); expect(annotationObject).toBeDefined();
expect(() => { expect(() => {
openmct.annotation.deleteAnnotations([annotationObject]); openmct.annotation.deleteAnnotations([annotationObject]);
@ -152,13 +165,13 @@ describe("The Annotation API", () => {
expect(annotationObject._deleted).toBeTrue(); expect(annotationObject._deleted).toBeTrue();
}); });
it("can add/delete/add a tag", async () => { it("can add/delete/add a tag", async () => {
let annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); let annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined(); expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation'); expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag'); expect(annotationObject.tags).toContain('aWonderfulTag');
openmct.annotation.deleteAnnotations([annotationObject]); openmct.annotation.deleteAnnotations([annotationObject]);
expect(annotationObject._deleted).toBeTrue(); expect(annotationObject._deleted).toBeTrue();
annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined(); expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation'); expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag'); expect(annotationObject.tags).toContain('aWonderfulTag');

View File

@ -162,10 +162,12 @@
:selected-section="selectedSection" :selected-section="selectedSection"
:read-only="false" :read-only="false"
:is-locked="selectedPage.isLocked" :is-locked="selectedPage.isLocked"
:selected-entry-id="selectedEntryId"
@cancelEdit="cancelTransaction" @cancelEdit="cancelTransaction"
@editingEntry="startTransaction" @editingEntry="startTransaction"
@deleteEntry="deleteEntry" @deleteEntry="deleteEntry"
@updateEntry="updateEntry" @updateEntry="updateEntry"
@entry-selection="entrySelection(entry)"
/> />
</div> </div>
<div <div
@ -234,6 +236,7 @@ export default {
sidebarCoversEntries: false, sidebarCoversEntries: false,
filteredAndSortedEntries: [], filteredAndSortedEntries: [],
notebookAnnotations: {}, notebookAnnotations: {},
selectedEntryId: '',
activeTransaction: false, activeTransaction: false,
savingTransaction: false savingTransaction: false
}; };
@ -321,6 +324,7 @@ export default {
this.formatSidebar(); this.formatSidebar();
this.setSectionAndPageFromUrl(); this.setSectionAndPageFromUrl();
this.openmct.selection.on('change', this.updateSelection);
this.transaction = null; this.transaction = null;
window.addEventListener('orientationchange', this.formatSidebar); window.addEventListener('orientationchange', this.formatSidebar);
@ -346,6 +350,7 @@ export default {
window.removeEventListener('orientationchange', this.formatSidebar); window.removeEventListener('orientationchange', this.formatSidebar);
window.removeEventListener('hashchange', this.setSectionAndPageFromUrl); window.removeEventListener('hashchange', this.setSectionAndPageFromUrl);
this.openmct.selection.off('change', this.updateSelection);
}, },
updated: function () { updated: function () {
this.$nextTick(() => { this.$nextTick(() => {
@ -375,15 +380,20 @@ export default {
} }
}); });
}, },
updateSelection(selection) {
if (selection?.[0]?.[1]?.context?.targetDetails?.entryId === undefined) {
this.selectedEntryId = '';
}
},
async loadAnnotations() { async loadAnnotations() {
if (!this.openmct.annotation.getAvailableTags().length) { if (!this.openmct.annotation.getAvailableTags().length) {
// don't bother loading annotations if there are no tags
return; return;
} }
this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0; this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0;
const query = this.openmct.objects.makeKeyString(this.domainObject.identifier); const foundAnnotations = await this.openmct.annotation.getAnnotations(this.domainObject.identifier);
const foundAnnotations = await this.openmct.annotation.getAnnotations(query);
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;
@ -941,6 +951,9 @@ export default {
} }
} }
}, },
entrySelection(entry) {
this.selectedEntryId = entry.id;
},
endTransaction() { endTransaction() {
this.openmct.objects.endTransaction(); this.openmct.objects.endTransaction();
this.transaction = null; this.transaction = null;

View File

@ -22,12 +22,13 @@
<template> <template>
<div <div
class="c-notebook__entry c-ne has-local-controls has-tag-applier" class="c-notebook__entry c-ne has-local-controls"
aria-label="Notebook Entry" aria-label="Notebook Entry"
:class="{ 'locked': isLocked }" :class="{ 'locked': isLocked, 'is-selected': isSelectedEntry }"
@dragover="changeCursor" @dragover="changeCursor"
@drop.capture="cancelEditMode" @drop.capture="cancelEditMode"
@drop.prevent="dropOnEntry" @drop.prevent="dropOnEntry"
@click="selectEntry($event, entry)"
> >
<div class="c-ne__time-and-content"> <div class="c-ne__time-and-content">
<div class="c-ne__time-and-creator"> <div class="c-ne__time-and-creator">
@ -82,13 +83,16 @@
</div> </div>
</template> </template>
<TagEditor <div>
:domain-object="domainObject" <div
:annotations="notebookAnnotations" v-for="(tag, index) in entryTags"
:annotation-type="openmct.annotation.ANNOTATION_TYPES.NOTEBOOK" :key="index"
:target-specific-details="{entryId: entry.id}" class="c-tag"
@tags-updated="timestampAndUpdate" :style="{ backgroundColor: tag.backgroundColor, color: tag.foregroundColor }"
/> >
{{ tag.label }}
</div>
</div>
<div class="c-snapshots c-ne__embeds"> <div class="c-snapshots c-ne__embeds">
<NotebookEmbed <NotebookEmbed
@ -139,7 +143,6 @@
<script> <script>
import NotebookEmbed from './NotebookEmbed.vue'; import NotebookEmbed from './NotebookEmbed.vue';
import TagEditor from '../../../ui/components/tags/TagEditor.vue';
import TextHighlight from '../../../utils/textHighlight/TextHighlight.vue'; import TextHighlight from '../../../utils/textHighlight/TextHighlight.vue';
import { createNewEmbed } from '../utils/notebook-entries'; import { createNewEmbed } from '../utils/notebook-entries';
import { saveNotebookImageDomainObject, updateNamespaceOfDomainObject } from '../utils/notebook-image'; import { saveNotebookImageDomainObject, updateNamespaceOfDomainObject } from '../utils/notebook-image';
@ -151,8 +154,7 @@ const UNKNOWN_USER = 'Unknown';
export default { export default {
components: { components: {
NotebookEmbed, NotebookEmbed,
TextHighlight, TextHighlight
TagEditor
}, },
inject: ['openmct', 'snapshotContainer'], inject: ['openmct', 'snapshotContainer'],
props: { props: {
@ -203,6 +205,10 @@ export default {
default() { default() {
return false; return false;
} }
},
selectedEntryId: {
type: String,
required: true
} }
}, },
computed: { computed: {
@ -212,6 +218,14 @@ export default {
createdOnTime() { createdOnTime() {
return this.formatTime(this.entry.createdOn, 'HH:mm:ss'); return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
}, },
isSelectedEntry() {
return this.selectedEntryId === this.entry.id;
},
entryTags() {
const tagsFromAnnotations = this.openmct.annotation.getTagsFromAnnotations(this.notebookAnnotations);
return tagsFromAnnotations;
},
entryText() { entryText() {
let text = this.entry.text; let text = this.entry.text;
@ -357,6 +371,38 @@ export default {
} else { } else {
this.$emit('cancelEdit'); this.$emit('cancelEdit');
} }
},
selectEntry(event, entry) {
const targetDetails = {};
const keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
targetDetails[keyString] = {
entryId: entry.id
};
const targetDomainObjects = {};
targetDomainObjects[keyString] = this.domainObject;
this.openmct.selection.select(
[
{
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: this.domainObject
}
},
{
element: event.currentTarget,
context: {
type: 'notebook-entry-selection',
targetDetails,
targetDomainObjects,
annotations: this.notebookAnnotations,
annotationType: this.openmct.annotation.ANNOTATION_TYPES.NOTEBOOK,
onAnnotationChange: this.timestampAndUpdate
}
}
],
false);
event.stopPropagation();
this.$emit('entry-selection', this.entry);
} }
} }
}; };

View File

@ -85,7 +85,10 @@
<mct-chart <mct-chart
:rectangles="rectangles" :rectangles="rectangles"
:highlights="highlights" :highlights="highlights"
:annotated-points="annotatedPoints"
:annotation-selections="annotationSelections"
:show-limit-line-labels="showLimitLineLabels" :show-limit-line-labels="showLimitLineLabels"
:annotation-viewing-and-editing-allowed="annotationViewingAndEditingAllowed"
@plotReinitializeCanvas="initCanvas" @plotReinitializeCanvas="initCanvas"
@chartLoaded="initialize" @chartLoaded="initialize"
/> />
@ -211,6 +214,7 @@ import MctTicks from "./MctTicks.vue";
import MctChart from "./chart/MctChart.vue"; import MctChart from "./chart/MctChart.vue";
import XAxis from "./axis/XAxis.vue"; import XAxis from "./axis/XAxis.vue";
import YAxis from "./axis/YAxis.vue"; import YAxis from "./axis/YAxis.vue";
import KDBush from 'kdbush';
import _ from "lodash"; import _ from "lodash";
const OFFSET_THRESHOLD = 10; const OFFSET_THRESHOLD = 10;
@ -268,6 +272,8 @@ export default {
return { return {
altPressed: false, altPressed: false,
highlights: [], highlights: [],
annotatedPoints: [],
annotationSelections: [],
lockHighlightPoint: false, lockHighlightPoint: false,
tickWidth: 0, tickWidth: 0,
yKeyOptions: [], yKeyOptions: [],
@ -298,6 +304,10 @@ export default {
isFrozen() { isFrozen() {
return this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true; return this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true;
}, },
annotationViewingAndEditingAllowed() {
// only allow annotations viewing/editing if plot is paused or in fixed time mode
return this.isFrozen || !this.isRealTime;
},
plotLegendPositionClass() { plotLegendPositionClass() {
return !this.isNestedWithinAStackedPlot ? `plot-legend-${this.config.legend.get('position')}` : ''; return !this.isNestedWithinAStackedPlot ? `plot-legend-${this.config.legend.get('position')}` : '';
}, },
@ -361,16 +371,81 @@ export default {
this.removeStatusListener = this.openmct.status.observe(this.domainObject.identifier, this.updateStatus); this.removeStatusListener = this.openmct.status.observe(this.domainObject.identifier, this.updateStatus);
this.openmct.objectViews.on('clearData', this.clearData); this.openmct.objectViews.on('clearData', this.clearData);
this.$on('loadingUpdated', this.loadAnnotations);
this.openmct.selection.on('change', this.updateSelection);
this.setTimeContext(); this.setTimeContext();
this.loaded = true; this.loaded = true;
}, },
beforeDestroy() { beforeDestroy() {
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);
this.destroy(); this.destroy();
}, },
methods: { methods: {
updateSelection(selection) {
const selectionContext = selection?.[0]?.[0]?.context?.item;
if (!selectionContext
|| this.openmct.objects.areIdsEqual(selectionContext.identifier, this.domainObject.identifier)) {
// Selection changed, but it's us, so ignoring it
return;
}
const selectionType = selection?.[0]?.[1]?.context?.type;
if (selectionType !== 'plot-points-selection') {
// wrong type of selection
return;
}
const currentXaxis = this.config.xAxis.get('displayRange');
const currentYaxis = this.config.yAxis.get('displayRange');
// when there is no plot data, the ranges can be undefined
// in which case we should not perform selection
if (!currentXaxis || !currentYaxis) {
return;
}
const selectedAnnotations = selection?.[0]?.[1]?.context?.annotations;
if (selectedAnnotations?.length) {
// just use first annotation
const boundingBoxes = Object.values(selectedAnnotations[0].targets);
let minX = Number.MAX_SAFE_INTEGER;
let minY = Number.MAX_SAFE_INTEGER;
let maxX = Number.MIN_SAFE_INTEGER;
let maxY = Number.MIN_SAFE_INTEGER;
boundingBoxes.forEach(boundingBox => {
if (boundingBox.minX < minX) {
minX = boundingBox.minX;
}
if (boundingBox.maxX > maxX) {
maxX = boundingBox.maxX;
}
if (boundingBox.maxY > maxY) {
maxY = boundingBox.maxY;
}
if (boundingBox.minY < minY) {
minY = boundingBox.minY;
}
});
this.config.xAxis.set('displayRange', {
min: minX,
max: maxX
});
this.config.yAxis.set('displayRange', {
min: minY,
max: maxY
});
this.zoom('out', 0.2);
}
this.prepareExistingAnnotationSelection(selectedAnnotations);
},
handleKeyDown(event) { handleKeyDown(event) {
if (event.key === 'Alt') { if (event.key === 'Alt') {
this.altPressed = true; this.altPressed = true;
@ -445,7 +520,21 @@ export default {
this.checkSameRangeValue(); this.checkSameRangeValue();
this.stopListening(plotSeries); this.stopListening(plotSeries);
}, },
async loadAnnotations() {
if (!this.openmct.annotation.getAvailableTags().length) {
// don't bother loading annotations if there are no tags
return;
}
const rawAnnotationsForPlot = [];
await Promise.all(this.seriesModels.map(async (seriesModel) => {
const seriesAnnotations = await this.openmct.annotation.getAnnotations(seriesModel.model.identifier);
rawAnnotationsForPlot.push(...seriesAnnotations);
}));
if (rawAnnotationsForPlot) {
this.annotatedPoints = this.findAnnotationPoints(rawAnnotationsForPlot);
}
},
loadSeriesData(series) { loadSeriesData(series) {
//this check ensures that duplicate requests don't happen on load //this check ensures that duplicate requests don't happen on load
if (!this.timeContext) { if (!this.timeContext) {
@ -469,8 +558,7 @@ export default {
end: bounds.end end: bounds.end
}; };
series.load(options) series.load(options).then(this.stopLoading.bind(this));
.then(this.stopLoading.bind(this));
}, },
loadMoreData(range, purge) { loadMoreData(range, purge) {
@ -662,10 +750,83 @@ export default {
this.listenTo(this.canvas, 'mousemove', this.trackMousePosition, this); this.listenTo(this.canvas, 'mousemove', this.trackMousePosition, this);
this.listenTo(this.canvas, 'mouseleave', this.untrackMousePosition, this); this.listenTo(this.canvas, 'mouseleave', this.untrackMousePosition, this);
this.listenTo(this.canvas, 'mousedown', this.onMouseDown, this); this.listenTo(this.canvas, 'mousedown', this.onMouseDown, this);
this.listenTo(this.canvas, 'click', this.selectNearbyAnnotations, this);
this.listenTo(this.canvas, 'wheel', this.wheelZoom, this); this.listenTo(this.canvas, 'wheel', this.wheelZoom, this);
} }
}, },
marqueeAnnotations(annotationsToSelect) {
annotationsToSelect.forEach(annotationToSelect => {
const firstTargetKeyString = Object.keys(annotationToSelect.targets)[0];
const firstTarget = annotationToSelect.targets[firstTargetKeyString];
const rectangle = {
start: {
x: firstTarget.minX,
y: firstTarget.minY
},
end: {
x: firstTarget.maxX,
y: firstTarget.maxY
},
color: [1, 1, 1, 0.10]
};
this.rectangles.push(rectangle);
});
},
gatherNearbyAnnotations() {
const nearbyAnnotations = [];
this.config.series.models.forEach(series => {
if (series.closest.annotationsById) {
Object.values(series.closest.annotationsById).forEach(closeAnnotation => {
const addedAnnotationAlready = nearbyAnnotations.some(annotation => {
return _.isEqual(annotation.targets, closeAnnotation.targets)
&& _.isEqual(annotation.tags, closeAnnotation.tags);
});
if (!addedAnnotationAlready) {
nearbyAnnotations.push(closeAnnotation);
}
});
}
});
return nearbyAnnotations;
},
prepareExistingAnnotationSelection(annotations) {
const targetDomainObjects = {};
this.config.series.models.forEach(series => {
targetDomainObjects[series.keyString] = series.domainObject;
});
const targetDetails = {};
const uniqueBoundsAnnotations = [];
annotations.forEach(annotation => {
Object.entries(annotation.targets).forEach(([key, value]) => {
targetDetails[key] = value;
});
const boundingBoxAlreadyAdded = uniqueBoundsAnnotations.some(existingAnnotation => {
const existingBoundingBox = Object.values(existingAnnotation.targets)[0];
const newBoundingBox = Object.values(annotation.targets)[0];
return (existingBoundingBox.minX === newBoundingBox.minX
&& existingBoundingBox.minY === newBoundingBox.minY
&& existingBoundingBox.maxX === newBoundingBox.maxX
&& existingBoundingBox.maxY === newBoundingBox.maxY);
});
if (!boundingBoxAlreadyAdded) {
uniqueBoundsAnnotations.push(annotation);
}
});
this.marqueeAnnotations(uniqueBoundsAnnotations);
return {
targetDomainObjects,
targetDetails
};
},
initialize() { initialize() {
this.handleWindowResize = _.debounce(this.handleWindowResize, 500); this.handleWindowResize = _.debounce(this.handleWindowResize, 500);
this.plotContainerResizeObserver = new ResizeObserver(this.handleWindowResize); this.plotContainerResizeObserver = new ResizeObserver(this.handleWindowResize);
@ -805,7 +966,7 @@ export default {
}, },
onMouseDown(event) { onMouseDown(event) {
// do not monitor drag events on browser context click // do not monitor drag events on browser context click
if (event.ctrlKey) { if (event.ctrlKey) {
return; return;
} }
@ -817,10 +978,12 @@ export default {
const isFrozen = this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true; const isFrozen = this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true;
this.isFrozenOnMouseDown = isFrozen; this.isFrozenOnMouseDown = isFrozen;
if (event.altKey) { if (event.altKey && !event.shiftKey) {
return this.startPan(event); return this.startPan(event);
} else if (this.annotationViewingAndEditingAllowed && event.altKey && event.shiftKey) {
return this.startMarquee(event, true);
} else { } else {
return this.startMarquee(event); return this.startMarquee(event, false);
} }
}, },
@ -828,7 +991,7 @@ export default {
this.stopListening(window, 'mouseup', this.onMouseUp, this); this.stopListening(window, 'mouseup', this.onMouseUp, this);
this.stopListening(window, 'mousemove', this.trackMousePosition, this); this.stopListening(window, 'mousemove', this.trackMousePosition, this);
if (this.isMouseClick()) { if (this.isMouseClick() && event.shiftKey) {
this.lockHighlightPoint = !this.lockHighlightPoint; this.lockHighlightPoint = !this.lockHighlightPoint;
this.$emit('lockHighlightPoint', this.lockHighlightPoint); this.$emit('lockHighlightPoint', this.lockHighlightPoint);
} }
@ -869,7 +1032,9 @@ export default {
this.marquee.endPixels = this.positionOverElement; this.marquee.endPixels = this.positionOverElement;
}, },
startMarquee(event) { startMarquee(event, annotationEvent) {
this.rectangles = [];
this.annotationSelections = [];
this.canvas.classList.remove('plot-drag'); this.canvas.classList.remove('plot-drag');
this.canvas.classList.add('plot-marquee'); this.canvas.classList.add('plot-marquee');
@ -883,12 +1048,153 @@ export default {
end: this.positionOverPlot, end: this.positionOverPlot,
color: [1, 1, 1, 0.5] color: [1, 1, 1, 0.5]
}; };
if (annotationEvent) {
this.marquee.annotationEvent = true;
}
this.rectangles.push(this.marquee); this.rectangles.push(this.marquee);
this.trackHistory(); this.trackHistory();
} }
}, },
selectNearbyAnnotations(event) {
event.stopPropagation();
endMarquee() { if (!this.annotationViewingAndEditingAllowed || this.annotationSelections.length) {
return;
}
const nearbyAnnotations = this.gatherNearbyAnnotations();
const { targetDomainObjects, targetDetails } = this.prepareExistingAnnotationSelection(nearbyAnnotations);
this.selectPlotAnnotations({
targetDetails,
targetDomainObjects,
annotations: nearbyAnnotations
});
},
selectPlotAnnotations({targetDetails, targetDomainObjects, annotations}) {
const selection =
[
{
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: this.domainObject
}
},
{
element: this.$el,
context: {
type: 'plot-points-selection',
targetDetails,
targetDomainObjects,
annotations,
annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL,
onAnnotationChange: this.onAnnotationChange
}
}
];
this.openmct.selection.select(selection, true);
},
selectNewPlotAnnotations(minX, minY, maxX, maxY, pointsInBox, event) {
const boundingBox = {
minX,
minY,
maxX,
maxY
};
let targetDomainObjects = {};
let targetDetails = {};
let annotations = {};
pointsInBox.forEach(pointInBox => {
if (pointInBox.length) {
const seriesID = pointInBox[0].series.keyString;
targetDetails[seriesID] = boundingBox;
targetDomainObjects[seriesID] = pointInBox[0].series.domainObject;
}
});
this.selectPlotAnnotations({
targetDetails,
targetDomainObjects,
annotations
});
},
findAnnotationPoints(rawAnnotations) {
const annotationsByPoints = [];
rawAnnotations.forEach(rawAnnotation => {
if (rawAnnotation.targets) {
const targetValues = Object.values(rawAnnotation.targets);
if (targetValues && targetValues.length) {
// just get the first one
const boundingBox = Object.values(targetValues)?.[0];
const pointsInBox = this.getPointsInBox(boundingBox, rawAnnotation);
if (pointsInBox && pointsInBox.length) {
annotationsByPoints.push(pointsInBox.flat());
}
}
}
});
return annotationsByPoints.flat();
},
getPointsInBox(boundingBox, rawAnnotation) {
// load series models in KD-Trees
const seriesKDTrees = [];
this.seriesModels.forEach(seriesModel => {
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);
rangeResults.forEach(id => {
const seriesDatum = seriesData[id];
if (seriesDatum) {
const result = {
series: seriesModel,
point: seriesDatum
};
searchResults.push(result);
}
if (rawAnnotation) {
if (!seriesDatum.annotationsById) {
seriesDatum.annotationsById = {};
}
const annotationKeyString = this.openmct.objects.makeKeyString(rawAnnotation.identifier);
seriesDatum.annotationsById[annotationKeyString] = rawAnnotation;
}
});
if (searchResults.length) {
seriesKDTrees.push(searchResults);
}
}
});
return seriesKDTrees;
},
endAnnotationMarquee(event) {
const minX = Math.min(this.marquee.start.x, this.marquee.end.x);
const minY = Math.min(this.marquee.start.y, this.marquee.end.y);
const maxX = Math.max(this.marquee.start.x, this.marquee.end.x);
const maxY = Math.max(this.marquee.start.y, this.marquee.end.y);
const boundingBox = {
minX,
minY,
maxX,
maxY
};
const pointsInBox = this.getPointsInBox(boundingBox);
this.annotationSelections = pointsInBox.flat();
this.selectNewPlotAnnotations(minX, minY, maxX, maxY, pointsInBox, event);
},
endZoomMarquee() {
const startPixels = this.marquee.startPixels; const startPixels = this.marquee.startPixels;
const endPixels = this.marquee.endPixels; const endPixels = this.marquee.endPixels;
const marqueeDistance = Math.sqrt( const marqueeDistance = Math.sqrt(
@ -911,9 +1217,25 @@ export default {
// if marquee zoom doesn't occur. // if marquee zoom doesn't occur.
this.plotHistory.pop(); this.plotHistory.pop();
} }
},
endMarquee(event) {
if (this.marquee.annotationEvent) {
this.endAnnotationMarquee(event);
} else {
this.endZoomMarquee();
this.rectangles = [];
}
this.rectangles = []; this.marquee = null;
this.marquee = undefined; },
onAnnotationChange(annotations) {
if (this.marquee) {
this.marquee.annotationEvent = false;
this.endMarquee();
}
this.loadAnnotations();
}, },
zoom(zoomDirection, zoomFactor) { zoom(zoomDirection, zoomFactor) {

View File

@ -50,10 +50,11 @@ import Vue from 'vue';
const MARKER_SIZE = 6.0; const MARKER_SIZE = 6.0;
const HIGHLIGHT_SIZE = MARKER_SIZE * 2.0; const HIGHLIGHT_SIZE = MARKER_SIZE * 2.0;
const ANNOTATION_SIZE = MARKER_SIZE * 3.0;
const CLEARANCE = 15; const CLEARANCE = 15;
export default { export default {
inject: ['openmct', 'domainObject'], inject: ['openmct', 'domainObject', 'path'],
props: { props: {
rectangles: { rectangles: {
type: Array, type: Array,
@ -67,11 +68,27 @@ export default {
return []; return [];
} }
}, },
annotatedPoints: {
type: Array,
default() {
return [];
}
},
annotationSelections: {
type: Array,
default() {
return [];
}
},
showLimitLineLabels: { showLimitLineLabels: {
type: Object, type: Object,
default() { default() {
return {}; return {};
} }
},
annotationViewingAndEditingAllowed: {
type: Boolean,
required: true
} }
}, },
data() { data() {
@ -83,6 +100,12 @@ export default {
highlights() { highlights() {
this.scheduleDraw(); this.scheduleDraw();
}, },
annotatedPoints() {
this.scheduleDraw();
},
annotationSelections() {
this.scheduleDraw();
},
rectangles() { rectangles() {
this.scheduleDraw(); this.scheduleDraw();
}, },
@ -148,10 +171,22 @@ export default {
this.listenTo(series, 'change:alarmMarkers', this.changeAlarmMarkers, this); this.listenTo(series, 'change:alarmMarkers', this.changeAlarmMarkers, this);
this.listenTo(series, 'change:limitLines', this.changeLimitLines, this); this.listenTo(series, 'change:limitLines', this.changeLimitLines, this);
this.listenTo(series, 'change', this.scheduleDraw); this.listenTo(series, 'change', this.scheduleDraw);
this.listenTo(series, 'add', this.scheduleDraw); this.listenTo(series, 'add', this.onAddPoint);
this.makeChartElement(series); this.makeChartElement(series);
this.makeLimitLines(series); this.makeLimitLines(series);
}, },
onAddPoint(point, insertIndex, series) {
const xRange = this.config.xAxis.get('displayRange');
const yRange = this.config.yAxis.get('displayRange');
const xValue = series.getXVal(point);
const yValue = series.getYVal(point);
// if user is not looking at data within the current bounds, don't draw the point
if ((xValue > xRange.min) && (xValue < xRange.max)
&& (yValue > yRange.min) && (yValue < yRange.max)) {
this.scheduleDraw();
}
},
changeInterpolate(mode, o, series) { changeInterpolate(mode, o, series) {
if (mode === o) { if (mode === o) {
return; return;
@ -439,6 +474,12 @@ export default {
this.drawSeries(); this.drawSeries();
this.drawRectangles(); this.drawRectangles();
this.drawHighlights(); this.drawHighlights();
// only draw these in fixed time mode or plot is paused
if (this.annotationViewingAndEditingAllowed) {
this.drawAnnotatedPoints();
this.drawAnnotationSelections();
}
} }
}, },
updateViewport() { updateViewport() {
@ -584,6 +625,65 @@ export default {
disconnected disconnected
); );
}, },
annotatedPointWithinRange(annotatedPoint, xRange, yRange) {
const xValue = annotatedPoint.series.getXVal(annotatedPoint.point);
const yValue = annotatedPoint.series.getYVal(annotatedPoint.point);
return ((xValue > xRange.min) && (xValue < xRange.max)
&& (yValue > yRange.min) && (yValue < yRange.max));
},
drawAnnotatedPoints() {
// we should do this by series, and then plot all the points at once instead
// of doing it one by one
if (this.annotatedPoints && this.annotatedPoints.length) {
const uniquePointsToDraw = [];
const xRange = this.config.xAxis.get('displayRange');
const yRange = this.config.yAxis.get('displayRange');
this.annotatedPoints.forEach((annotatedPoint) => {
// if the annotation is outside the range, don't draw it
if (this.annotatedPointWithinRange(annotatedPoint, xRange, yRange)) {
const canvasXValue = this.offset.xVal(annotatedPoint.point, annotatedPoint.series);
const canvasYValue = this.offset.yVal(annotatedPoint.point, annotatedPoint.series);
const pointToDraw = new Float32Array([canvasXValue, canvasYValue]);
const drawnPoint = uniquePointsToDraw.some((rawPoint) => {
return rawPoint[0] === pointToDraw[0] && rawPoint[1] === pointToDraw[1];
});
if (!drawnPoint) {
uniquePointsToDraw.push(pointToDraw);
this.drawAnnotatedPoint(annotatedPoint, pointToDraw);
}
}
});
}
},
drawAnnotatedPoint(annotatedPoint, pointToDraw) {
if (annotatedPoint.point && annotatedPoint.series) {
const color = annotatedPoint.series.get('color').asRGBAArray();
// set transparency
color[3] = 0.15;
const pointCount = 1;
const shape = annotatedPoint.series.get('markerShape');
this.drawAPI.drawPoints(pointToDraw, color, pointCount, ANNOTATION_SIZE, shape);
}
},
drawAnnotationSelections() {
if (this.annotationSelections && this.annotationSelections.length) {
this.annotationSelections.forEach(this.drawAnnotationSelection, this);
}
},
drawAnnotationSelection(annotationSelection) {
const points = new Float32Array([
this.offset.xVal(annotationSelection.point, annotationSelection.series),
this.offset.yVal(annotationSelection.point, annotationSelection.series)
]);
const color = [255, 255, 255, 1]; // white
const pointCount = 1;
const shape = annotationSelection.series.get('markerShape');
this.drawAPI.drawPoints(points, color, pointCount, ANNOTATION_SIZE, shape);
},
drawHighlights() { drawHighlights() {
if (this.highlights && this.highlights.length) { if (this.highlights && this.highlights.length) {
this.highlights.forEach(this.drawHighlight, this); this.highlights.forEach(this.drawHighlight, this);

View File

@ -29,7 +29,7 @@ import LegendModel from "./LegendModel";
/** /**
* PlotConfiguration model stores the configuration of a plot and some * PlotConfiguration model stores the configuration of a plot and some
* limited state. The indiidual parts of the plot configuration model * limited state. The individual parts of the plot configuration model
* handle setting defaults and updating in response to various changes. * handle setting defaults and updating in response to various changes.
* *
* @extends {Model<PlotConfigModelType, PlotConfigModelOptions>} * @extends {Model<PlotConfigModelType, PlotConfigModelOptions>}

View File

@ -83,6 +83,10 @@ export default class PlotSeries extends Model {
// Model.apply(this, arguments); // Model.apply(this, arguments);
this.onXKeyChange(this.get('xKey')); this.onXKeyChange(this.get('xKey'));
this.onYKeyChange(this.get('yKey')); this.onYKeyChange(this.get('yKey'));
this.xRangeMin = Number.MIN_SAFE_INTEGER;
this.yRangeMin = Number.MIN_SAFE_INTEGER;
this.xRangeMax = Number.MAX_SAFE_INTEGER;
this.yRangeMax = Number.MAX_SAFE_INTEGER;
this.unPlottableValues = [undefined, Infinity, -Infinity]; this.unPlottableValues = [undefined, Infinity, -Infinity];
} }
@ -378,6 +382,7 @@ export default class PlotSeries extends Model {
}); });
} }
} }
/** /**
* Add a point to the data array while maintaining the sort order of * Add a point to the data array while maintaining the sort order of
* the array and preventing insertion of points with a duplicate x * the array and preventing insertion of points with a duplicate x

View File

@ -178,10 +178,10 @@ export default {
}, },
computed: { computed: {
isNestedWithinAStackedPlot() { isNestedWithinAStackedPlot() {
return this.path.find((pathObject, pathObjIndex) => pathObjIndex > 0 && pathObject.type === 'telemetry.plot.stacked'); return this.path.find((pathObject, pathObjIndex) => pathObjIndex > 0 && pathObject?.type === 'telemetry.plot.stacked');
}, },
isStackedPlotObject() { isStackedPlotObject() {
return this.path.find((pathObject, pathObjIndex) => pathObjIndex === 0 && pathObject.type === 'telemetry.plot.stacked'); return this.path.find((pathObject, pathObjIndex) => pathObjIndex === 0 && pathObject?.type === 'telemetry.plot.stacked');
} }
}, },
mounted() { mounted() {

View File

@ -137,7 +137,7 @@
} }
&:hover { &:hover {
background: rgba($colorKey, 0.2); background: rgba($colorBodyFg, 0.1); //$colorInteriorBorder;
//color: $colorBodyFg; //color: $colorBodyFg;
} }
@ -283,6 +283,14 @@
@include discreteItem(); @include discreteItem();
display: flex; display: flex;
padding: $interiorMarginSm $interiorMarginSm $interiorMarginSm $interiorMargin; padding: $interiorMarginSm $interiorMarginSm $interiorMarginSm $interiorMargin;
&:hover {
background: rgba($colorBodyFg, 0.2);
}
&.is-selected {
background: rgba($colorKey, 0.3);
}
&__text, &__text,
&__local-controls { &__local-controls {

View File

@ -42,6 +42,7 @@
@import "../ui/inspector/elements.scss"; @import "../ui/inspector/elements.scss";
@import "../ui/inspector/inspector.scss"; @import "../ui/inspector/inspector.scss";
@import "../ui/inspector/location.scss"; @import "../ui/inspector/location.scss";
@import "../ui/inspector/annotations/annotation-inspector.scss";
@import "../ui/layout/app-logo.scss"; @import "../ui/layout/app-logo.scss";
@import "../ui/layout/create-button.scss"; @import "../ui/layout/create-button.scss";
@import "../ui/layout/layout.scss"; @import "../ui/layout/layout.scss";

View File

@ -57,17 +57,28 @@ export default {
}, },
annotationType: { annotationType: {
type: String, type: String,
required: true required: false,
}, default: null
targetSpecificDetails: {
type: Object,
required: true
}, },
domainObject: { domainObject: {
type: Object, type: Object,
default() { required: true,
return null; default: null
} },
targets: {
type: Object,
required: true,
default: null
},
targetDomainObjects: {
type: Object,
required: true,
default: null
},
onTagChange: {
type: Function,
required: false,
default: null
} }
}, },
data() { data() {
@ -99,7 +110,7 @@ export default {
}, },
methods: { methods: {
annotationsChanged() { annotationsChanged() {
if (this.annotations && this.annotations.length) { if (this.annotations) {
this.tagsChanged(); this.tagsChanged();
} }
}, },
@ -141,27 +152,47 @@ export default {
this.userAddingTag = true; this.userAddingTag = true;
}, },
async tagRemoved(tagToRemove) { async tagRemoved(tagToRemove) {
// Soft delete annotations that match tag instead // Soft delete annotations that match tag instead (that aren't already deleted)
const annotationsToDelete = this.annotations.filter((annotation) => { const annotationsToDelete = this.annotations.filter((annotation) => {
return annotation.tags.includes(tagToRemove); return annotation.tags.includes(tagToRemove) && !annotation._deleted;
}); });
if (annotationsToDelete) { if (annotationsToDelete) {
await this.openmct.annotation.deleteAnnotations(annotationsToDelete); await this.openmct.annotation.deleteAnnotations(annotationsToDelete);
this.$emit('tags-updated', annotationsToDelete); this.$emit('tags-updated', annotationsToDelete);
if (this.onTagChange) {
this.onTagChange(this.annotations);
}
} }
}, },
async tagAdded(newTag) { async tagAdded(newTag) {
// Either undelete an annotation, or create one (1) new annotation // Either undelete an annotation, or create one (1) new annotation
const existingAnnotation = this.annotations.find((annotation) => { let existingAnnotation = this.annotations.find((annotation) => {
return annotation.tags.includes(newTag); return annotation.tags.includes(newTag);
}); });
const createdAnnotation = await this.openmct.annotation.addSingleAnnotationTag(existingAnnotation, if (!existingAnnotation) {
this.domainObject, this.targetSpecificDetails, this.annotationType, newTag); const contentText = `${this.annotationType} tag`;
const annotationCreationArguments = {
name: contentText,
existingAnnotation,
contentText: contentText,
targets: this.targets,
targetDomainObjects: this.targetDomainObjects,
domainObject: this.domainObject,
annotationType: this.annotationType,
tags: [newTag]
};
existingAnnotation = await this.openmct.annotation.create(annotationCreationArguments);
} else if (existingAnnotation._deleted) {
this.openmct.annotation.unDeleteAnnotation(existingAnnotation);
}
this.userAddingTag = false; this.userAddingTag = false;
this.$emit('tags-updated', createdAnnotation); this.$emit('tags-updated', existingAnnotation);
if (this.onTagChange) {
this.onTagChange([existingAnnotation]);
}
} }
} }
}; };

View File

@ -35,6 +35,7 @@
<div <div
v-else v-else
class="c-tag" class="c-tag"
:class="{'c-tag-edit': !readOnly}"
:style="{ background: selectedBackgroundColor, color: selectedForegroundColor }" :style="{ background: selectedBackgroundColor, color: selectedForegroundColor }"
> >
<div <div
@ -42,6 +43,7 @@
aria-label="Tag" aria-label="Tag"
>{{ selectedTagLabel }} </div> >{{ selectedTagLabel }} </div>
<button <button
v-show="!readOnly"
class="c-completed-tag-deletion c-tag__remove-btn icon-x-in-circle" class="c-completed-tag-deletion c-tag__remove-btn icon-x-in-circle"
@click="removeTag" @click="removeTag"
></button> ></button>
@ -77,6 +79,12 @@ export default {
default() { default() {
return false; return false;
} }
},
readOnly: {
type: Boolean,
default() {
return false;
}
} }
}, },
data() { data() {

View File

@ -36,7 +36,6 @@
> >
{{ tabbedView.name }} {{ tabbedView.name }}
</div> </div>
</div> </div>
<div class="c-inspector__content"> <div class="c-inspector__content">
<multipane <multipane
@ -44,10 +43,12 @@
type="vertical" type="vertical"
> >
<pane class="c-inspector__properties"> <pane class="c-inspector__properties">
<Properties <Properties v-if="!activity" />
v-if="!activity" <div
/> v-if="!multiSelect"
<location /> class="c-inspect-properties c-inspect-properties--location"
>
</div>
<inspector-views /> <inspector-views />
</pane> </pane>
<pane <pane
@ -75,39 +76,49 @@
<SavedStylesInspectorView :is-editing="isEditing" /> <SavedStylesInspectorView :is-editing="isEditing" />
</pane> </pane>
</multipane> </multipane>
<multipane
v-show="currentTabbedView.key === '__annotations'"
type="vertical"
>
<pane class="c-inspector__annotations">
<AnnotationsInspectorView
@annotationCreated="updateCurrentTab(tabbedViews[2])"
/>
</pane>
</multipane>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import multipane from '../layout/multipane.vue'; import multipane from "../layout/multipane.vue";
import pane from '../layout/pane.vue'; import pane from "../layout/pane.vue";
import ElementsPool from './ElementsPool.vue'; import ElementsPool from "./ElementsPool.vue";
import Location from './Location.vue'; import Properties from "./details/Properties.vue";
import Properties from './details/Properties.vue'; import ObjectName from "./ObjectName.vue";
import ObjectName from './ObjectName.vue'; import InspectorViews from "./InspectorViews.vue";
import InspectorViews from './InspectorViews.vue';
import _ from "lodash"; import _ from "lodash";
import stylesManager from '@/ui/inspector/styles/StylesManager'; import stylesManager from "@/ui/inspector/styles/StylesManager";
import StylesInspectorView from '@/ui/inspector/styles/StylesInspectorView.vue'; import StylesInspectorView from "@/ui/inspector/styles/StylesInspectorView.vue";
import SavedStylesInspectorView from '@/ui/inspector/styles/SavedStylesInspectorView.vue'; import SavedStylesInspectorView from "@/ui/inspector/styles/SavedStylesInspectorView.vue";
import AnnotationsInspectorView from "./annotations/AnnotationsInspectorView.vue";
export default { export default {
components: { components: {
StylesInspectorView, StylesInspectorView,
SavedStylesInspectorView, SavedStylesInspectorView,
AnnotationsInspectorView,
multipane, multipane,
pane, pane,
ElementsPool, ElementsPool,
Properties, Properties,
ObjectName, ObjectName,
Location,
InspectorViews InspectorViews
}, },
provide: { provide: {
stylesManager: stylesManager stylesManager: stylesManager
}, },
inject: ['openmct'], inject: ["openmct"],
props: { props: {
isEditing: { isEditing: {
type: Boolean, type: Boolean,
@ -117,40 +128,64 @@ export default {
data() { data() {
return { return {
hasComposition: false, hasComposition: false,
multiSelect: false,
showStyles: false, showStyles: false,
tabbedViews: [{ tabbedViews: [
key: '__properties', {
name: 'Properties' key: "__properties",
}, { name: "Properties"
key: '__styles', },
name: 'Styles' {
}], key: "__styles",
name: "Styles"
},
{
key: "__annotations",
name: "Annotations"
}
],
currentTabbedView: {}, currentTabbedView: {},
activity: undefined activity: undefined
}; };
}, },
mounted() { mounted() {
this.excludeObjectTypes = ['folder', 'webPage', 'conditionSet', 'summary-widget', 'hyperlink']; this.excludeObjectTypes = [
this.openmct.selection.on('change', this.updateInspectorViews); "folder",
"webPage",
"conditionSet",
"summary-widget",
"hyperlink"
];
this.openmct.selection.on("change", this.updateInspectorViews);
}, },
destroyed() { destroyed() {
this.openmct.selection.off('change', this.updateInspectorViews); this.openmct.selection.off("change", this.updateInspectorViews);
}, },
methods: { methods: {
updateInspectorViews(selection) { updateInspectorViews(selection) {
this.refreshComposition(selection); this.refreshComposition(selection);
if (this.openmct.types.get('conditionSet')) { if (this.openmct.types.get("conditionSet")) {
this.refreshTabs(selection); this.refreshTabs(selection);
} }
if (selection.length > 1) {
this.multiSelect = true;
// return;
} else {
this.multiSelect = false;
}
this.setActivity(selection); this.setActivity(selection);
}, },
refreshComposition(selection) { refreshComposition(selection) {
if (selection.length > 0 && selection[0].length > 0) { if (selection.length > 0 && selection[0].length > 0) {
let parentObject = selection[0][0].context.item; let parentObject = selection[0][0].context.item;
this.hasComposition = Boolean(parentObject && this.openmct.composition.get(parentObject)); this.hasComposition = Boolean(
parentObject && this.openmct.composition.get(parentObject)
);
} }
}, },
refreshTabs(selection) { refreshTabs(selection) {
@ -160,21 +195,33 @@ export default {
let object = selection[0][0].context.item; let object = selection[0][0].context.item;
if (object) { if (object) {
let type = this.openmct.types.get(object.type); let type = this.openmct.types.get(object.type);
this.showStyles = this.isLayoutObject(selection[0], object.type) || this.isCreatableObject(object, type); this.showStyles =
this.isLayoutObject(selection[0], object.type)
|| this.isCreatableObject(object, type);
} }
if (!this.currentTabbedView.key || (!this.showStyles && this.currentTabbedView.key === this.tabbedViews[1].key)) { if (
!this.currentTabbedView.key
|| (!this.showStyles
&& this.currentTabbedView.key === this.tabbedViews[1].key)
) {
this.updateCurrentTab(this.tabbedViews[0]); this.updateCurrentTab(this.tabbedViews[0]);
} }
} }
}, },
isLayoutObject(selection, objectType) { isLayoutObject(selection, objectType) {
//we allow conditionSets to be styled if they're part of a layout //we allow conditionSets to be styled if they're part of a layout
return selection.length > 1 return (
&& ((objectType === 'conditionSet') || (this.excludeObjectTypes.indexOf(objectType) < 0)); selection.length > 1
&& (objectType === "conditionSet"
|| this.excludeObjectTypes.indexOf(objectType) < 0)
);
}, },
isCreatableObject(object, type) { isCreatableObject(object, type) {
return (this.excludeObjectTypes.indexOf(object.type) < 0) && type.definition.creatable; return (
this.excludeObjectTypes.indexOf(object.type) < 0
&& type.definition.creatable
);
}, },
updateCurrentTab(view) { updateCurrentTab(view) {
this.currentTabbedView = view; this.currentTabbedView = view;
@ -183,10 +230,11 @@ export default {
return _.isEqual(this.currentTabbedView, view); return _.isEqual(this.currentTabbedView, view);
}, },
setActivity(selection) { setActivity(selection) {
this.activity = selection this.activity =
&& selection.length selection
&& selection[0].length && selection.length
&& selection[0][0].activity; && selection[0].length
&& selection[0][0].activity;
} }
} }
}; };

View File

@ -0,0 +1,83 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, 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>
<div class="c-annotation__row">
<textarea
v-model="contentModel"
class="c-annotation__text_area"
type="text"
></textarea>
<div>
<span>{{ modifiedOnDate }}</span>
<span>{{ modifiedOnTime }}</span>
</div>
</div>
</template>
<script>
import Moment from 'moment';
export default {
inject: ['openmct'],
props: {
annotation: {
type: Object,
default() {
return {};
}
}
},
data() {
return {
};
},
computed: {
contentModel: {
get() {
return this.annotation.contentText;
},
set(contentText) {
console.debug(`Set tag called with ${contentText}`);
}
},
modifiedOnDate() {
return this.formatTime(this.annotation.modified, 'YYYY-MM-DD');
},
modifiedOnTime() {
return this.formatTime(this.annotation.modified, 'HH:mm:ss');
}
},
mounted() {
},
methods: {
getAvailableTagByID(tagID) {
return this.openmct.annotation.getAvailableTags().find(tag => {
return tag.id === tagID;
});
},
formatTime(unixTime, timeFormat) {
return Moment.utc(unixTime).format(timeFormat);
}
}
};
</script>

View File

@ -0,0 +1,213 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, 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>
<div
class="c-inspector__properties c-inspect-properties has-tag-applier"
aria-label="Tags Inspector"
>
<div
class="c-inspect-properties__header"
>
Tags
</div>
<div
v-if="shouldShowTagsEditor"
class="c-inspect-properties__section"
>
<TagEditor
:targets="targetDetails"
:target-domain-objects="targetDomainObjects"
:domain-object="domainObject"
:annotations="loadedAnnotations"
:annotation-type="annotationType"
:on-tag-change="onAnnotationChange"
/>
</div>
<div
v-else
class="c-inspect-properties__row--span-all"
>
{{ noTagsMessage }}
</div>
</div>
</template>
<script>
import TagEditor from '../../components/tags/TagEditor.vue';
import _ from 'lodash';
export default {
components: {
TagEditor
},
inject: ['openmct'],
data() {
return {
selection: null,
lastLocalAnnotationCreations: {},
unobserveEntries: {},
loadedAnnotations: []
};
},
computed: {
hasAnnotations() {
return Boolean(
this.loadedAnnotations
&& this.loadedAnnotations.length
);
},
nonTagAnnotations() {
if (!this.loadedAnnotations) {
return [];
}
return this.loadedAnnotations.filter(annotation => {
return !annotation.tags && !annotation._deleted;
});
},
tagAnnotations() {
if (!this.loadedAnnotations) {
return [];
}
return this.loadedAnnotations.filter(annotation => {
return !annotation.tags && !annotation._deleted;
});
},
multiSelection() {
return this.selection && this.selection.length > 1;
},
noAnnotationsMessage() {
return this.multiSelection
? 'No annotations to display for multiple items'
: 'No annotations to display for this item';
},
noTagsMessage() {
return this.multiSelection
? 'No tags to display for multiple items'
: 'No tags to display for this item';
},
domainObject() {
return this?.selection?.[0]?.[0]?.context?.item;
},
targetDetails() {
return this?.selection?.[0]?.[1]?.context?.targetDetails ?? {};
},
shouldShowTagsEditor() {
return Object.keys(this.targetDetails).length > 0;
},
targetDomainObjects() {
return this?.selection?.[0]?.[1]?.context?.targetDomainObjects ?? {};
},
selectedAnnotations() {
return this?.selection?.[0]?.[1]?.context?.annotations;
},
annotationType() {
return this?.selection?.[0]?.[1]?.context?.annotationType;
},
annotationFilter() {
return this?.selection?.[0]?.[1]?.context?.annotationFilter;
},
onAnnotationChange() {
return this?.selection?.[0]?.[1]?.context?.onAnnotationChange;
}
},
async mounted() {
this.openmct.annotation.on('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject);
this.openmct.selection.on('change', this.updateSelection);
await this.updateSelection(this.openmct.selection.get());
},
beforeDestroy() {
this.openmct.selection.off('change', this.updateSelection);
const unobserveEntryFunctions = Object.values(this.unobserveEntries);
unobserveEntryFunctions.forEach(unobserveEntry => {
unobserveEntry();
});
},
methods: {
loadNewAnnotations(annotationsToLoad) {
if (!annotationsToLoad || !annotationsToLoad.length) {
this.loadedAnnotations.splice(0);
return;
}
const sortedAnnotations = annotationsToLoad.sort((annotationA, annotationB) => {
return annotationB.modified - annotationA.modified;
});
const mutableAnnotations = sortedAnnotations.map((annotation) => {
return this.openmct.objects.toMutable(annotation);
});
if (sortedAnnotations.length < this.loadedAnnotations.length) {
this.loadedAnnotations = this.loadedAnnotations.slice(0, mutableAnnotations.length);
}
for (let index = 0; index < mutableAnnotations.length; index += 1) {
this.$set(this.loadedAnnotations, index, mutableAnnotations[index]);
}
},
updateSelection(selection) {
const unobserveEntryFunctions = Object.values(this.unobserveEntries);
unobserveEntryFunctions.forEach(unobserveEntry => {
unobserveEntry();
});
this.unobserveEntries = {};
this.selection = selection;
const targetKeys = Object.keys(this.targetDomainObjects);
targetKeys.forEach(targetKey => {
const targetObject = this.targetDomainObjects[targetKey];
this.lastLocalAnnotationCreations[targetKey] = targetObject?.annotationLastCreated ?? 0;
if (!this.unobserveEntries[targetKey]) {
this.unobserveEntries[targetKey] = this.openmct.objects.observe(targetObject, '*', this.targetObjectChanged);
}
});
this.loadNewAnnotations(this.selectedAnnotations);
},
async targetObjectChanged(target) {
const targetID = this.openmct.objects.makeKeyString(target.identifier);
const lastLocalAnnotationCreation = this.lastLocalAnnotationCreations[targetID] ?? 0;
if (lastLocalAnnotationCreation < target.annotationLastCreated) {
this.lastLocalAnnotationCreations[targetID] = target.annotationLastCreated;
await this.loadAnnotationForTargetObject(target);
}
},
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];
return _.isEqual(fetchedTargetDetails, selectedTargetDetails);
});
this.loadNewAnnotations(filteredAnnotationsForSelection);
}
}
};
</script>

View File

@ -0,0 +1,18 @@
.c-inspect-annotations {
> * + * {
margin-top: $interiorMargin;
}
&__content{
> * + * {
margin-top: $interiorMargin;
}
}
&__content {
display: flex;
flex-direction: column;
}
}

View File

@ -106,6 +106,15 @@
} }
} }
.c-inspect-properties,
.c-inspect-tags {
[class*="header"] {
@include propertiesHeader();
flex: 0 0 auto;
font-size: .85em;
}
}
.c-inspect-properties, .c-inspect-properties,
.c-inspect-styles { .c-inspect-styles {
[class*="header"] { [class*="header"] {
@ -187,6 +196,11 @@
line-height: 1.8em; line-height: 1.8em;
} }
} }
.c-location {
// Always make the location element span columns
grid-column: 1 / 3;
}
} }
/********************************************* INSPECTOR PROPERTIES TAB */ /********************************************* INSPECTOR PROPERTIES TAB */

View File

@ -11,7 +11,7 @@
&:not(:last-child) { &:not(:last-child) {
&:after { &:after {
color: $colorInspectorPropName; // color: $colorInspectorPropName;
content: $glyph-icon-arrow-right; content: $glyph-icon-arrow-right;
font-family: symbolsfont; font-family: symbolsfont;
font-size: 0.7em; font-size: 0.7em;

View File

@ -131,6 +131,44 @@ export default {
let resultUrl = identifierToString(this.openmct, objectPath); let resultUrl = identifierToString(this.openmct, objectPath);
this.openmct.router.navigate(resultUrl); this.openmct.router.navigate(resultUrl);
if (this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL) {
//wait a beat for the navigation
setTimeout(() => {
this.clickedPlotAnnotation();
}, 100);
}
},
clickedPlotAnnotation() {
const targetDetails = {};
const targetDomainObjects = {};
Object.entries(this.result.targets).forEach(([key, value]) => {
targetDetails[key] = value;
});
this.result.targetModels.forEach((targetModel) => {
const keyString = this.openmct.objects.makeKeyString(targetModel.identifier);
targetDomainObjects[keyString] = targetModel;
});
const selection =
[
{
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: this.result
}
},
{
element: this.$el,
context: {
type: 'plot-points-selection',
targetDetails,
targetDomainObjects,
annotations: [this.result],
annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL,
onAnnotationChange: () => {}
}
}
];
this.openmct.selection.select(selection, true);
}, },
isSearchMatched(tag) { isSearchMatched(tag) {
if (this.result.matchingTagKeys) { if (this.result.matchingTagKeys) {

View File

@ -51,7 +51,7 @@
<div class="c-gsearch__results-section-title">Annotation Results</div> <div class="c-gsearch__results-section-title">Annotation Results</div>
<annotation-search-result <annotation-search-result
v-for="(annotationResult) in annotationResults" v-for="(annotationResult) in annotationResults"
:key="openmct.objects.makeKeyString(annotationResult.identifier)" :key="makeKeyForAnnotationResult(annotationResult)"
:result="annotationResult" :result="annotationResult"
@click.native="selectedResult" @click.native="selectedResult"
/> />
@ -102,6 +102,12 @@ export default {
this.resultsShown = false; this.resultsShown = false;
} }
}, },
makeKeyForAnnotationResult(annotationResult) {
const annotationKeyString = this.openmct.objects.makeKeyString(annotationResult.identifier);
const firstTargetKeyString = Object.keys(annotationResult.targets)[0];
return `${annotationKeyString}-${firstTargetKeyString}`;
},
previewChanged(changedPreviewState) { previewChanged(changedPreviewState) {
this.previewVisible = changedPreviewState; this.previewVisible = changedPreviewState;
}, },