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

View File

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

View File

@ -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",

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
this.install(this.plugins.Plot());
this.install(this.plugins.TelemetryTable.default());

View File

@ -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 {
/**
@ -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}`);
}
@ -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) {
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);
@ -139,7 +168,9 @@ 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 => {
this.#updateAnnotationModified(targetDomainObject);
});
return createdObject;
} else {
@ -147,8 +178,15 @@ export default class AnnotationAPI extends EventEmitter {
}
}
#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);
}
}
/**
@ -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) {
@ -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) {
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) {
@ -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) {
if (!annotation) {
@ -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 [];
@ -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,
@ -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) {
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"
@ -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;
}
}

View File

@ -108,6 +108,7 @@ describe("The Annotation API", () => {
annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK,
tags: ['sometag'],
contentText: "fooContext",
targetDomainObjects: [mockDomainObject],
targets: {'fooTarget': {}}
};
const annotationObject = await openmct.annotation.create(annotationCreationArguments);
@ -124,27 +125,39 @@ describe("The Annotation API", () => {
});
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 () => {
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.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
});
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();
openmct.annotation.deleteAnnotations([annotationObject]);
expect(annotationObject._deleted).toBeTrue();
});
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(() => {
openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist');
}).toThrow();
});
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(() => {
openmct.annotation.deleteAnnotations([annotationObject]);
@ -152,13 +165,13 @@ describe("The Annotation API", () => {
expect(annotationObject._deleted).toBeTrue();
});
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.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
openmct.annotation.deleteAnnotations([annotationObject]);
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.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');

View File

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

View File

@ -22,12 +22,13 @@
<template>
<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"
:class="{ 'locked': isLocked }"
:class="{ 'locked': isLocked, 'is-selected': isSelectedEntry }"
@dragover="changeCursor"
@drop.capture="cancelEditMode"
@drop.prevent="dropOnEntry"
@click="selectEntry($event, entry)"
>
<div class="c-ne__time-and-content">
<div class="c-ne__time-and-creator">
@ -82,13 +83,16 @@
</div>
</template>
<TagEditor
:domain-object="domainObject"
:annotations="notebookAnnotations"
:annotation-type="openmct.annotation.ANNOTATION_TYPES.NOTEBOOK"
:target-specific-details="{entryId: entry.id}"
@tags-updated="timestampAndUpdate"
/>
<div>
<div
v-for="(tag, index) in entryTags"
:key="index"
class="c-tag"
:style="{ backgroundColor: tag.backgroundColor, color: tag.foregroundColor }"
>
{{ tag.label }}
</div>
</div>
<div class="c-snapshots c-ne__embeds">
<NotebookEmbed
@ -139,7 +143,6 @@
<script>
import NotebookEmbed from './NotebookEmbed.vue';
import TagEditor from '../../../ui/components/tags/TagEditor.vue';
import TextHighlight from '../../../utils/textHighlight/TextHighlight.vue';
import { createNewEmbed } from '../utils/notebook-entries';
import { saveNotebookImageDomainObject, updateNamespaceOfDomainObject } from '../utils/notebook-image';
@ -151,8 +154,7 @@ const UNKNOWN_USER = 'Unknown';
export default {
components: {
NotebookEmbed,
TextHighlight,
TagEditor
TextHighlight
},
inject: ['openmct', 'snapshotContainer'],
props: {
@ -203,6 +205,10 @@ export default {
default() {
return false;
}
},
selectedEntryId: {
type: String,
required: true
}
},
computed: {
@ -212,6 +218,14 @@ export default {
createdOnTime() {
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() {
let text = this.entry.text;
@ -357,6 +371,38 @@ export default {
} else {
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
:rectangles="rectangles"
:highlights="highlights"
:annotated-points="annotatedPoints"
:annotation-selections="annotationSelections"
:show-limit-line-labels="showLimitLineLabels"
:annotation-viewing-and-editing-allowed="annotationViewingAndEditingAllowed"
@plotReinitializeCanvas="initCanvas"
@chartLoaded="initialize"
/>
@ -211,6 +214,7 @@ import MctTicks from "./MctTicks.vue";
import MctChart from "./chart/MctChart.vue";
import XAxis from "./axis/XAxis.vue";
import YAxis from "./axis/YAxis.vue";
import KDBush from 'kdbush';
import _ from "lodash";
const OFFSET_THRESHOLD = 10;
@ -268,6 +272,8 @@ export default {
return {
altPressed: false,
highlights: [],
annotatedPoints: [],
annotationSelections: [],
lockHighlightPoint: false,
tickWidth: 0,
yKeyOptions: [],
@ -298,6 +304,10 @@ export default {
isFrozen() {
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() {
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.openmct.objectViews.on('clearData', this.clearData);
this.$on('loadingUpdated', this.loadAnnotations);
this.openmct.selection.on('change', this.updateSelection);
this.setTimeContext();
this.loaded = true;
},
beforeDestroy() {
this.openmct.selection.off('change', this.updateSelection);
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
this.destroy();
},
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) {
if (event.key === 'Alt') {
this.altPressed = true;
@ -445,7 +520,21 @@ export default {
this.checkSameRangeValue();
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) {
//this check ensures that duplicate requests don't happen on load
if (!this.timeContext) {
@ -469,8 +558,7 @@ export default {
end: bounds.end
};
series.load(options)
.then(this.stopLoading.bind(this));
series.load(options).then(this.stopLoading.bind(this));
},
loadMoreData(range, purge) {
@ -662,10 +750,83 @@ export default {
this.listenTo(this.canvas, 'mousemove', this.trackMousePosition, this);
this.listenTo(this.canvas, 'mouseleave', this.untrackMousePosition, 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);
}
},
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() {
this.handleWindowResize = _.debounce(this.handleWindowResize, 500);
this.plotContainerResizeObserver = new ResizeObserver(this.handleWindowResize);
@ -817,10 +978,12 @@ export default {
const isFrozen = this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true;
this.isFrozenOnMouseDown = isFrozen;
if (event.altKey) {
if (event.altKey && !event.shiftKey) {
return this.startPan(event);
} else if (this.annotationViewingAndEditingAllowed && event.altKey && event.shiftKey) {
return this.startMarquee(event, true);
} 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, 'mousemove', this.trackMousePosition, this);
if (this.isMouseClick()) {
if (this.isMouseClick() && event.shiftKey) {
this.lockHighlightPoint = !this.lockHighlightPoint;
this.$emit('lockHighlightPoint', this.lockHighlightPoint);
}
@ -869,7 +1032,9 @@ export default {
this.marquee.endPixels = this.positionOverElement;
},
startMarquee(event) {
startMarquee(event, annotationEvent) {
this.rectangles = [];
this.annotationSelections = [];
this.canvas.classList.remove('plot-drag');
this.canvas.classList.add('plot-marquee');
@ -883,12 +1048,153 @@ export default {
end: this.positionOverPlot,
color: [1, 1, 1, 0.5]
};
if (annotationEvent) {
this.marquee.annotationEvent = true;
}
this.rectangles.push(this.marquee);
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 endPixels = this.marquee.endPixels;
const marqueeDistance = Math.sqrt(
@ -911,9 +1217,25 @@ export default {
// if marquee zoom doesn't occur.
this.plotHistory.pop();
}
},
endMarquee(event) {
if (this.marquee.annotationEvent) {
this.endAnnotationMarquee(event);
} else {
this.endZoomMarquee();
this.rectangles = [];
this.marquee = undefined;
}
this.marquee = null;
},
onAnnotationChange(annotations) {
if (this.marquee) {
this.marquee.annotationEvent = false;
this.endMarquee();
}
this.loadAnnotations();
},
zoom(zoomDirection, zoomFactor) {

View File

@ -50,10 +50,11 @@ import Vue from 'vue';
const MARKER_SIZE = 6.0;
const HIGHLIGHT_SIZE = MARKER_SIZE * 2.0;
const ANNOTATION_SIZE = MARKER_SIZE * 3.0;
const CLEARANCE = 15;
export default {
inject: ['openmct', 'domainObject'],
inject: ['openmct', 'domainObject', 'path'],
props: {
rectangles: {
type: Array,
@ -67,11 +68,27 @@ export default {
return [];
}
},
annotatedPoints: {
type: Array,
default() {
return [];
}
},
annotationSelections: {
type: Array,
default() {
return [];
}
},
showLimitLineLabels: {
type: Object,
default() {
return {};
}
},
annotationViewingAndEditingAllowed: {
type: Boolean,
required: true
}
},
data() {
@ -83,6 +100,12 @@ export default {
highlights() {
this.scheduleDraw();
},
annotatedPoints() {
this.scheduleDraw();
},
annotationSelections() {
this.scheduleDraw();
},
rectangles() {
this.scheduleDraw();
},
@ -148,10 +171,22 @@ export default {
this.listenTo(series, 'change:alarmMarkers', this.changeAlarmMarkers, this);
this.listenTo(series, 'change:limitLines', this.changeLimitLines, this);
this.listenTo(series, 'change', this.scheduleDraw);
this.listenTo(series, 'add', this.scheduleDraw);
this.listenTo(series, 'add', this.onAddPoint);
this.makeChartElement(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) {
if (mode === o) {
return;
@ -439,6 +474,12 @@ export default {
this.drawSeries();
this.drawRectangles();
this.drawHighlights();
// only draw these in fixed time mode or plot is paused
if (this.annotationViewingAndEditingAllowed) {
this.drawAnnotatedPoints();
this.drawAnnotationSelections();
}
}
},
updateViewport() {
@ -584,6 +625,65 @@ export default {
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() {
if (this.highlights && this.highlights.length) {
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
* 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.
*
* @extends {Model<PlotConfigModelType, PlotConfigModelOptions>}

View File

@ -83,6 +83,10 @@ export default class PlotSeries extends Model {
// Model.apply(this, arguments);
this.onXKeyChange(this.get('xKey'));
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];
}
@ -378,6 +382,7 @@ export default class PlotSeries extends Model {
});
}
}
/**
* Add a point to the data array while maintaining the sort order of
* the array and preventing insertion of points with a duplicate x

View File

@ -178,10 +178,10 @@ export default {
},
computed: {
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() {
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() {

View File

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

View File

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

View File

@ -57,17 +57,28 @@ export default {
},
annotationType: {
type: String,
required: true
},
targetSpecificDetails: {
type: Object,
required: true
required: false,
default: null
},
domainObject: {
type: Object,
default() {
return null;
}
required: true,
default: null
},
targets: {
type: Object,
required: true,
default: null
},
targetDomainObjects: {
type: Object,
required: true,
default: null
},
onTagChange: {
type: Function,
required: false,
default: null
}
},
data() {
@ -99,7 +110,7 @@ export default {
},
methods: {
annotationsChanged() {
if (this.annotations && this.annotations.length) {
if (this.annotations) {
this.tagsChanged();
}
},
@ -141,27 +152,47 @@ export default {
this.userAddingTag = true;
},
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) => {
return annotation.tags.includes(tagToRemove);
return annotation.tags.includes(tagToRemove) && !annotation._deleted;
});
if (annotationsToDelete) {
await this.openmct.annotation.deleteAnnotations(annotationsToDelete);
this.$emit('tags-updated', annotationsToDelete);
if (this.onTagChange) {
this.onTagChange(this.annotations);
}
}
},
async tagAdded(newTag) {
// 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);
});
const createdAnnotation = await this.openmct.annotation.addSingleAnnotationTag(existingAnnotation,
this.domainObject, this.targetSpecificDetails, this.annotationType, newTag);
if (!existingAnnotation) {
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.$emit('tags-updated', createdAnnotation);
this.$emit('tags-updated', existingAnnotation);
if (this.onTagChange) {
this.onTagChange([existingAnnotation]);
}
}
}
};

View File

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

View File

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

View File

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

View File

@ -131,6 +131,44 @@ export default {
let resultUrl = identifierToString(this.openmct, objectPath);
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) {
if (this.result.matchingTagKeys) {

View File

@ -51,7 +51,7 @@
<div class="c-gsearch__results-section-title">Annotation Results</div>
<annotation-search-result
v-for="(annotationResult) in annotationResults"
:key="openmct.objects.makeKeyString(annotationResult.identifier)"
:key="makeKeyForAnnotationResult(annotationResult)"
:result="annotationResult"
@click.native="selectedResult"
/>
@ -102,6 +102,12 @@ export default {
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) {
this.previewVisible = changedPreviewState;
},