Compare commits
	
		
			36 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					7c82aeb4eb | ||
| 
						 | 
					78a3de78b2 | ||
| 
						 | 
					cff2ef7992 | ||
| 
						 | 
					34f25a3e16 | ||
| 
						 | 
					0751d0fed4 | ||
| 
						 | 
					d49cd2510a | ||
| 
						 | 
					31ff6f228a | ||
| 
						 | 
					242fa3cd25 | ||
| 
						 | 
					02fc1690a9 | ||
| 
						 | 
					25eccbed2c | ||
| 
						 | 
					ce31893797 | ||
| 
						 | 
					ccdaa7d2cc | ||
| 
						 | 
					492289ad82 | ||
| 
						 | 
					c2957acea5 | ||
| 
						 | 
					2d502b7ac2 | ||
| 
						 | 
					981b1afb71 | ||
| 
						 | 
					f37d3aadb6 | ||
| 
						 | 
					66cbc32dd8 | ||
| 
						 | 
					4bec2c459c | ||
| 
						 | 
					97781c216e | ||
| 
						 | 
					9656783fbd | ||
| 
						 | 
					4f37daafb5 | ||
| 
						 | 
					80e16ae254 | ||
| 
						 | 
					cbba210ee7 | ||
| 
						 | 
					060ee35dbe | ||
| 
						 | 
					dda6800858 | ||
| 
						 | 
					7b2ad060ac | ||
| 
						 | 
					7917f0977d | ||
| 
						 | 
					2e5f8e7a47 | ||
| 
						 | 
					1c79d2b5cf | ||
| 
						 | 
					2b7129fe0b | ||
| 
						 | 
					decec8deef | ||
| 
						 | 
					50592fdc0e | ||
| 
						 | 
					8bc698cfed | ||
| 
						 | 
					430428f689 | ||
| 
						 | 
					c6987cd866 | 
@@ -89,7 +89,7 @@ Read more about [Playwright Snapshots](https://playwright.dev/docs/test-snapshot
 | 
			
		||||
#### Open MCT's implementation
 | 
			
		||||
 | 
			
		||||
- Our Snapshot tests receive a `@snapshot` tag.
 | 
			
		||||
- Snapshots need to be executed within the official Playwright container to ensure we're using the exact rendering platform in CI and locally.
 | 
			
		||||
- Snapshots need to be executed within the official Playwright container to ensure we're using the exact rendering platform in CI and locally. To do a valid comparison locally:
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:[GET THIS VERSION FROM OUR CIRCLECI CONFIG FILE]-focal /bin/bash
 | 
			
		||||
@@ -97,9 +97,24 @@ npm install
 | 
			
		||||
npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @snapshot
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### (WIP) Updating Snapshots
 | 
			
		||||
### Updating Snapshots
 | 
			
		||||
 | 
			
		||||
When the `@snapshot` tests fail, they will need to be evaluated to see if the failure is an acceptable change or
 | 
			
		||||
When the `@snapshot` tests fail, they will need to be evaluated to determine if the failure is an acceptable and desireable or an unintended regression.
 | 
			
		||||
 | 
			
		||||
To compare a snapshot, run a test and open the html report with the 'Expected' vs 'Actual' screenshot. If the actual screenshot is preferred, then the source-controlled 'Expected' snapshots will need to be updated with the following scripts.
 | 
			
		||||
 | 
			
		||||
MacOS
 | 
			
		||||
```
 | 
			
		||||
npm run test:e2e:updatesnapshots
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Linux/CI
 | 
			
		||||
```sh
 | 
			
		||||
// Replace {X.X.X} with the current Playwright version 
 | 
			
		||||
// from our package.json or circleCI configuration file
 | 
			
		||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v{X.X.X}-focal /bin/bash
 | 
			
		||||
npm install
 | 
			
		||||
npm run test:e2e:updatesnapshots
 | 
			
		||||
 | 
			
		||||
## Performance Testing
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -144,7 +144,9 @@ async function createNotification(page, createNotificationOptions) {
 | 
			
		||||
 * @param {string} name
 | 
			
		||||
 */
 | 
			
		||||
async function expandTreePaneItemByName(page, name) {
 | 
			
		||||
    const treePane = page.locator('#tree-pane');
 | 
			
		||||
    const treePane = page.getByRole('tree', {
 | 
			
		||||
        name: 'Main Tree'
 | 
			
		||||
    });
 | 
			
		||||
    const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`);
 | 
			
		||||
    const expandTriangle = treeItem.locator('.c-disclosure-triangle');
 | 
			
		||||
    await expandTriangle.click();
 | 
			
		||||
@@ -218,6 +220,30 @@ async function openObjectTreeContextMenu(page, url) {
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Expands the entire object tree (every expandable tree item).
 | 
			
		||||
 * @param {import('@playwright/test').Page} page
 | 
			
		||||
 * @param {"Main Tree" | "Create Modal Tree"} [treeName="Main Tree"]
 | 
			
		||||
 */
 | 
			
		||||
async function expandEntireTree(page, treeName = "Main Tree") {
 | 
			
		||||
    const treeLocator = page.getByRole('tree', {
 | 
			
		||||
        name: treeName
 | 
			
		||||
    });
 | 
			
		||||
    const collapsedTreeItems = treeLocator.getByRole('treeitem', {
 | 
			
		||||
        expanded: false
 | 
			
		||||
    }).locator('span.c-disclosure-triangle.is-enabled');
 | 
			
		||||
 | 
			
		||||
    while (await collapsedTreeItems.count() > 0) {
 | 
			
		||||
        await collapsedTreeItems.nth(0).click();
 | 
			
		||||
 | 
			
		||||
        // FIXME: Replace hard wait with something event-driven.
 | 
			
		||||
        // Without the wait, this fails periodically due to a race condition
 | 
			
		||||
        // with Vue rendering (loop exits prematurely).
 | 
			
		||||
        // eslint-disable-next-line playwright/no-wait-for-timeout
 | 
			
		||||
        await page.waitForTimeout(200);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Gets the UUID of the currently focused object by parsing the current URL
 | 
			
		||||
 * and returning the last UUID in the path.
 | 
			
		||||
@@ -362,6 +388,7 @@ module.exports = {
 | 
			
		||||
    createDomainObjectWithDefaults,
 | 
			
		||||
    createNotification,
 | 
			
		||||
    expandTreePaneItemByName,
 | 
			
		||||
    expandEntireTree,
 | 
			
		||||
    createPlanFromJSON,
 | 
			
		||||
    openObjectTreeContextMenu,
 | 
			
		||||
    getHashUrlToDomainObject,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										32
									
								
								e2e/helper/addInitNotebookWithUrls.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,32 @@
 | 
			
		||||
/*****************************************************************************
 | 
			
		||||
 * 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.
 | 
			
		||||
 *****************************************************************************/
 | 
			
		||||
 | 
			
		||||
// This should be used to install the re-instal default Notebook plugin with a simple url whitelist.
 | 
			
		||||
// e.g.
 | 
			
		||||
// await page.addInitScript({ path: path.join(__dirname, 'addInitNotebookWithUrls.js') });
 | 
			
		||||
const NOTEBOOK_NAME = 'Notebook';
 | 
			
		||||
const URL_WHITELIST = ['google.com'];
 | 
			
		||||
 | 
			
		||||
document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
    const openmct = window.openmct;
 | 
			
		||||
    openmct.install(openmct.plugins.Notebook(NOTEBOOK_NAME, URL_WHITELIST));
 | 
			
		||||
});
 | 
			
		||||
@@ -21,7 +21,7 @@
 | 
			
		||||
 *****************************************************************************/
 | 
			
		||||
 | 
			
		||||
const { test, expect } = require('../../pluginFixtures.js');
 | 
			
		||||
const { createDomainObjectWithDefaults, createNotification } = require('../../appActions.js');
 | 
			
		||||
const { createDomainObjectWithDefaults, createNotification, expandEntireTree } = require('../../appActions.js');
 | 
			
		||||
 | 
			
		||||
test.describe('AppActions', () => {
 | 
			
		||||
    test('createDomainObjectsWithDefaults', async ({ page }) => {
 | 
			
		||||
@@ -109,4 +109,57 @@ test.describe('AppActions', () => {
 | 
			
		||||
        await expect(page.locator('.c-message-banner')).toHaveClass(/error/);
 | 
			
		||||
        await page.locator('[aria-label="Dismiss"]').click();
 | 
			
		||||
    });
 | 
			
		||||
    test('expandEntireTree', async ({ page }) => {
 | 
			
		||||
        await page.goto('./', { waitUntil: 'networkidle' });
 | 
			
		||||
 | 
			
		||||
        const rootFolder = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Folder'
 | 
			
		||||
        });
 | 
			
		||||
        const folder1 = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Folder',
 | 
			
		||||
            parent: rootFolder.uuid
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Clock',
 | 
			
		||||
            parent: folder1.uuid
 | 
			
		||||
        });
 | 
			
		||||
        const folder2 = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Folder',
 | 
			
		||||
            parent: folder1.uuid
 | 
			
		||||
        });
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Folder',
 | 
			
		||||
            parent: folder1.uuid
 | 
			
		||||
        });
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Display Layout',
 | 
			
		||||
            parent: folder2.uuid
 | 
			
		||||
        });
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Folder',
 | 
			
		||||
            parent: folder2.uuid
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await page.goto('./#/browse/mine');
 | 
			
		||||
        await expandEntireTree(page);
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: "Main Tree"
 | 
			
		||||
        });
 | 
			
		||||
        const treePaneCollapsedItems = treePane.getByRole('treeitem', { expanded: false });
 | 
			
		||||
        expect(await treePaneCollapsedItems.count()).toBe(0);
 | 
			
		||||
 | 
			
		||||
        await page.goto('./#/browse/mine');
 | 
			
		||||
        //Click the Create button
 | 
			
		||||
        await page.click('button:has-text("Create")');
 | 
			
		||||
 | 
			
		||||
        // Click the object specified by 'type'
 | 
			
		||||
        await page.click(`li[role='menuitem']:text("Clock")`);
 | 
			
		||||
        await expandEntireTree(page, "Create Modal Tree");
 | 
			
		||||
        const locatorTree = page.getByRole("tree", {
 | 
			
		||||
            name: "Create Modal Tree"
 | 
			
		||||
        });
 | 
			
		||||
        const locatorTreeCollapsedItems = locatorTree.locator('role=treeitem[expanded=false]');
 | 
			
		||||
        expect(await locatorTreeCollapsedItems.count()).toBe(0);
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -52,7 +52,9 @@ test.describe('Move & link item tests', () => {
 | 
			
		||||
        // Attempt to move parent to its own grandparent
 | 
			
		||||
        await page.locator('button[title="Show selected item in tree"]').click();
 | 
			
		||||
 | 
			
		||||
        const treePane = page.locator('#tree-pane');
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: 'Main Tree'
 | 
			
		||||
        });
 | 
			
		||||
        await treePane.getByRole('treeitem', {
 | 
			
		||||
            name: 'Parent Folder'
 | 
			
		||||
        }).click({
 | 
			
		||||
@@ -63,28 +65,30 @@ test.describe('Move & link item tests', () => {
 | 
			
		||||
            name: /Move/
 | 
			
		||||
        }).click();
 | 
			
		||||
 | 
			
		||||
        const locatorTree = page.locator('#locator-tree');
 | 
			
		||||
        const myItemsLocatorTreeItem = locatorTree.getByRole('treeitem', {
 | 
			
		||||
        const createModalTree = page.getByRole('tree', {
 | 
			
		||||
            name: "Create Modal Tree"
 | 
			
		||||
        });
 | 
			
		||||
        const myItemsLocatorTreeItem = createModalTree.getByRole('treeitem', {
 | 
			
		||||
            name: myItemsFolderName
 | 
			
		||||
        });
 | 
			
		||||
        await myItemsLocatorTreeItem.locator('.c-disclosure-triangle').click();
 | 
			
		||||
        await myItemsLocatorTreeItem.click();
 | 
			
		||||
 | 
			
		||||
        const parentFolderLocatorTreeItem = locatorTree.getByRole('treeitem', {
 | 
			
		||||
        const parentFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
 | 
			
		||||
            name: parentFolder.name
 | 
			
		||||
        });
 | 
			
		||||
        await parentFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
 | 
			
		||||
        await parentFolderLocatorTreeItem.click();
 | 
			
		||||
        await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
 | 
			
		||||
 | 
			
		||||
        const childFolderLocatorTreeItem = locatorTree.getByRole('treeitem', {
 | 
			
		||||
        const childFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
 | 
			
		||||
            name: new RegExp(childFolder.name)
 | 
			
		||||
        });
 | 
			
		||||
        await childFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
 | 
			
		||||
        await childFolderLocatorTreeItem.click();
 | 
			
		||||
        await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
 | 
			
		||||
 | 
			
		||||
        const grandchildFolderLocatorTreeItem = locatorTree.getByRole('treeitem', {
 | 
			
		||||
        const grandchildFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
 | 
			
		||||
            name: grandchildFolder.name
 | 
			
		||||
        });
 | 
			
		||||
        await grandchildFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
 | 
			
		||||
@@ -195,7 +199,9 @@ test.describe('Move & link item tests', () => {
 | 
			
		||||
        // Attempt to move parent to its own grandparent
 | 
			
		||||
        await page.locator('button[title="Show selected item in tree"]').click();
 | 
			
		||||
 | 
			
		||||
        const treePane = page.locator('#tree-pane');
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: 'Main Tree'
 | 
			
		||||
        });
 | 
			
		||||
        await treePane.getByRole('treeitem', {
 | 
			
		||||
            name: 'Parent Folder'
 | 
			
		||||
        }).click({
 | 
			
		||||
@@ -206,28 +212,30 @@ test.describe('Move & link item tests', () => {
 | 
			
		||||
            name: /Move/
 | 
			
		||||
        }).click();
 | 
			
		||||
 | 
			
		||||
        const locatorTree = page.locator('#locator-tree');
 | 
			
		||||
        const myItemsLocatorTreeItem = locatorTree.getByRole('treeitem', {
 | 
			
		||||
        const createModalTree = page.getByRole('tree', {
 | 
			
		||||
            name: "Create Modal Tree"
 | 
			
		||||
        });
 | 
			
		||||
        const myItemsLocatorTreeItem = createModalTree.getByRole('treeitem', {
 | 
			
		||||
            name: myItemsFolderName
 | 
			
		||||
        });
 | 
			
		||||
        await myItemsLocatorTreeItem.locator('.c-disclosure-triangle').click();
 | 
			
		||||
        await myItemsLocatorTreeItem.click();
 | 
			
		||||
 | 
			
		||||
        const parentFolderLocatorTreeItem = locatorTree.getByRole('treeitem', {
 | 
			
		||||
        const parentFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
 | 
			
		||||
            name: parentFolder.name
 | 
			
		||||
        });
 | 
			
		||||
        await parentFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
 | 
			
		||||
        await parentFolderLocatorTreeItem.click();
 | 
			
		||||
        await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
 | 
			
		||||
 | 
			
		||||
        const childFolderLocatorTreeItem = locatorTree.getByRole('treeitem', {
 | 
			
		||||
        const childFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
 | 
			
		||||
            name: new RegExp(childFolder.name)
 | 
			
		||||
        });
 | 
			
		||||
        await childFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
 | 
			
		||||
        await childFolderLocatorTreeItem.click();
 | 
			
		||||
        await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
 | 
			
		||||
 | 
			
		||||
        const grandchildFolderLocatorTreeItem = locatorTree.getByRole('treeitem', {
 | 
			
		||||
        const grandchildFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
 | 
			
		||||
            name: grandchildFolder.name
 | 
			
		||||
        });
 | 
			
		||||
        await grandchildFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
 | 
			
		||||
 
 | 
			
		||||
@@ -32,8 +32,7 @@ test.describe('Display Layout', () => {
 | 
			
		||||
 | 
			
		||||
        // Create Sine Wave Generator
 | 
			
		||||
        sineWaveObject = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Sine Wave Generator',
 | 
			
		||||
            name: "Test Sine Wave Generator"
 | 
			
		||||
            type: 'Sine Wave Generator'
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
    test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in real time', async ({ page }) => {
 | 
			
		||||
@@ -48,7 +47,9 @@ test.describe('Display Layout', () => {
 | 
			
		||||
        // Expand the 'My Items' folder in the left tree
 | 
			
		||||
        await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
 | 
			
		||||
        // Add the Sine Wave Generator to the Display Layout and save changes
 | 
			
		||||
        const treePane = page.locator('#tree-pane');
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: 'Main Tree'
 | 
			
		||||
        });
 | 
			
		||||
        const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
 | 
			
		||||
            name: new RegExp(sineWaveObject.name)
 | 
			
		||||
        });
 | 
			
		||||
@@ -80,7 +81,9 @@ test.describe('Display Layout', () => {
 | 
			
		||||
        // Expand the 'My Items' folder in the left tree
 | 
			
		||||
        await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
 | 
			
		||||
        // Add the Sine Wave Generator to the Display Layout and save changes
 | 
			
		||||
        const treePane = page.locator('#tree-pane');
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: 'Main Tree'
 | 
			
		||||
        });
 | 
			
		||||
        const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
 | 
			
		||||
            name: new RegExp(sineWaveObject.name)
 | 
			
		||||
        });
 | 
			
		||||
@@ -116,7 +119,9 @@ test.describe('Display Layout', () => {
 | 
			
		||||
        // Expand the 'My Items' folder in the left tree
 | 
			
		||||
        await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
 | 
			
		||||
        // Add the Sine Wave Generator to the Display Layout and save changes
 | 
			
		||||
        const treePane = page.locator('#tree-pane');
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: 'Main Tree'
 | 
			
		||||
        });
 | 
			
		||||
        const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
 | 
			
		||||
            name: new RegExp(sineWaveObject.name)
 | 
			
		||||
        });
 | 
			
		||||
@@ -131,7 +136,7 @@ test.describe('Display Layout', () => {
 | 
			
		||||
        await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click();
 | 
			
		||||
 | 
			
		||||
        // Bring up context menu and remove
 | 
			
		||||
        await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' });
 | 
			
		||||
        await sineWaveGeneratorTreeItem.nth(1).click({ button: 'right' });
 | 
			
		||||
        await page.locator('li[role="menuitem"]:has-text("Remove")').click();
 | 
			
		||||
        await page.locator('button:has-text("OK")').click();
 | 
			
		||||
 | 
			
		||||
@@ -146,8 +151,7 @@ test.describe('Display Layout', () => {
 | 
			
		||||
        });
 | 
			
		||||
        // Create a Display Layout
 | 
			
		||||
        const displayLayout = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Display Layout',
 | 
			
		||||
            name: "Test Display Layout"
 | 
			
		||||
            type: 'Display Layout'
 | 
			
		||||
        });
 | 
			
		||||
        // Edit Display Layout
 | 
			
		||||
        await page.locator('[title="Edit"]').click();
 | 
			
		||||
@@ -155,7 +159,9 @@ test.describe('Display Layout', () => {
 | 
			
		||||
        // Expand the 'My Items' folder in the left tree
 | 
			
		||||
        await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
 | 
			
		||||
        // Add the Sine Wave Generator to the Display Layout and save changes
 | 
			
		||||
        const treePane = page.locator('#tree-pane');
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: 'Main Tree'
 | 
			
		||||
        });
 | 
			
		||||
        const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
 | 
			
		||||
            name: new RegExp(sineWaveObject.name)
 | 
			
		||||
        });
 | 
			
		||||
@@ -173,7 +179,7 @@ test.describe('Display Layout', () => {
 | 
			
		||||
        await page.goto(sineWaveObject.url);
 | 
			
		||||
 | 
			
		||||
        // Bring up context menu and remove
 | 
			
		||||
        await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' });
 | 
			
		||||
        await sineWaveGeneratorTreeItem.first().click({ button: 'right' });
 | 
			
		||||
        await page.locator('li[role="menuitem"]:has-text("Remove")').click();
 | 
			
		||||
        await page.locator('button:has-text("OK")').click();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -25,26 +25,33 @@ const { createDomainObjectWithDefaults } = require('../../../../appActions');
 | 
			
		||||
 | 
			
		||||
test.describe('Flexible Layout', () => {
 | 
			
		||||
    let sineWaveObject;
 | 
			
		||||
    let clockObject;
 | 
			
		||||
    test.beforeEach(async ({ page }) => {
 | 
			
		||||
        await page.goto('./', { waitUntil: 'networkidle' });
 | 
			
		||||
 | 
			
		||||
        // Create Sine Wave Generator
 | 
			
		||||
        sineWaveObject = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Sine Wave Generator',
 | 
			
		||||
            name: "Test Sine Wave Generator"
 | 
			
		||||
            type: 'Sine Wave Generator'
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Create Clock Object
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Clock',
 | 
			
		||||
            name: "Test Clock"
 | 
			
		||||
        clockObject = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Clock'
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
    test('panes have the appropriate draggable attribute while in Edit and Browse modes', async ({ page }) => {
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: 'Main Tree'
 | 
			
		||||
        });
 | 
			
		||||
        const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
 | 
			
		||||
            name: new RegExp(sineWaveObject.name)
 | 
			
		||||
        });
 | 
			
		||||
        const clockTreeItem = treePane.getByRole('treeitem', {
 | 
			
		||||
            name: new RegExp(clockObject.name)
 | 
			
		||||
        });
 | 
			
		||||
        // Create a Flexible Layout
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Flexible Layout',
 | 
			
		||||
            name: "Test Flexible Layout"
 | 
			
		||||
            type: 'Flexible Layout'
 | 
			
		||||
        });
 | 
			
		||||
        // Edit Flexible Layout
 | 
			
		||||
        await page.locator('[title="Edit"]').click();
 | 
			
		||||
@@ -52,8 +59,8 @@ test.describe('Flexible Layout', () => {
 | 
			
		||||
        // Expand the 'My Items' folder in the left tree
 | 
			
		||||
        await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click();
 | 
			
		||||
        // Add the Sine Wave Generator and Clock to the Flexible Layout
 | 
			
		||||
        await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty');
 | 
			
		||||
        await page.dragAndDrop('text=Test Clock', '.c-fl__container.is-empty');
 | 
			
		||||
        await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first());
 | 
			
		||||
        await clockTreeItem.dragTo(page.locator('.c-fl__container.is-empty'));
 | 
			
		||||
        // Check that panes can be dragged while Flexible Layout is in Edit mode
 | 
			
		||||
        let dragWrapper = page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first();
 | 
			
		||||
        await expect(dragWrapper).toHaveAttribute('draggable', 'true');
 | 
			
		||||
@@ -65,10 +72,15 @@ test.describe('Flexible Layout', () => {
 | 
			
		||||
        await expect(dragWrapper).toHaveAttribute('draggable', 'false');
 | 
			
		||||
    });
 | 
			
		||||
    test('items in a flexible layout can be removed with object tree context menu when viewing the flexible layout', async ({ page }) => {
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: 'Main Tree'
 | 
			
		||||
        });
 | 
			
		||||
        const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
 | 
			
		||||
            name: new RegExp(sineWaveObject.name)
 | 
			
		||||
        });
 | 
			
		||||
        // Create a Display Layout
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Flexible Layout',
 | 
			
		||||
            name: "Test Flexible Layout"
 | 
			
		||||
            type: 'Flexible Layout'
 | 
			
		||||
        });
 | 
			
		||||
        // Edit Flexible Layout
 | 
			
		||||
        await page.locator('[title="Edit"]').click();
 | 
			
		||||
@@ -76,7 +88,7 @@ test.describe('Flexible Layout', () => {
 | 
			
		||||
        // Expand the 'My Items' folder in the left tree
 | 
			
		||||
        await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click();
 | 
			
		||||
        // Add the Sine Wave Generator to the Flexible Layout and save changes
 | 
			
		||||
        await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty');
 | 
			
		||||
        await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first());
 | 
			
		||||
        await page.locator('button[title="Save"]').click();
 | 
			
		||||
        await page.locator('text=Save and Finish Editing').click();
 | 
			
		||||
 | 
			
		||||
@@ -86,7 +98,7 @@ test.describe('Flexible Layout', () => {
 | 
			
		||||
        await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click();
 | 
			
		||||
 | 
			
		||||
        // Bring up context menu and remove
 | 
			
		||||
        await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' });
 | 
			
		||||
        await sineWaveGeneratorTreeItem.first().click({ button: 'right' });
 | 
			
		||||
        await page.locator('li[role="menuitem"]:has-text("Remove")').click();
 | 
			
		||||
        await page.locator('button:has-text("OK")').click();
 | 
			
		||||
 | 
			
		||||
@@ -98,10 +110,16 @@ test.describe('Flexible Layout', () => {
 | 
			
		||||
            type: 'issue',
 | 
			
		||||
            description: 'https://github.com/nasa/openmct/issues/3117'
 | 
			
		||||
        });
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: 'Main Tree'
 | 
			
		||||
        });
 | 
			
		||||
        const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
 | 
			
		||||
            name: new RegExp(sineWaveObject.name)
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Create a Flexible Layout
 | 
			
		||||
        const flexibleLayout = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Flexible Layout',
 | 
			
		||||
            name: "Test Flexible Layout"
 | 
			
		||||
            type: 'Flexible Layout'
 | 
			
		||||
        });
 | 
			
		||||
        // Edit Flexible Layout
 | 
			
		||||
        await page.locator('[title="Edit"]').click();
 | 
			
		||||
@@ -109,7 +127,7 @@ test.describe('Flexible Layout', () => {
 | 
			
		||||
        // Expand the 'My Items' folder in the left tree
 | 
			
		||||
        await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
 | 
			
		||||
        // Add the Sine Wave Generator to the Flexible Layout and save changes
 | 
			
		||||
        await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty');
 | 
			
		||||
        await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first());
 | 
			
		||||
        await page.locator('button[title="Save"]').click();
 | 
			
		||||
        await page.locator('text=Save and Finish Editing').click();
 | 
			
		||||
 | 
			
		||||
@@ -122,7 +140,7 @@ test.describe('Flexible Layout', () => {
 | 
			
		||||
        await page.goto(sineWaveObject.url);
 | 
			
		||||
 | 
			
		||||
        // Bring up context menu and remove
 | 
			
		||||
        await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' });
 | 
			
		||||
        await sineWaveGeneratorTreeItem.first().click({ button: 'right' });
 | 
			
		||||
        await page.locator('li[role="menuitem"]:has-text("Remove")').click();
 | 
			
		||||
        await page.locator('button:has-text("OK")').click();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -25,8 +25,11 @@ This test suite is dedicated to tests which verify the basic operations surround
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
const { test, expect } = require('../../../../pluginFixtures');
 | 
			
		||||
const { expandTreePaneItemByName, createDomainObjectWithDefaults } = require('../../../../appActions');
 | 
			
		||||
const { createDomainObjectWithDefaults } = require('../../../../appActions');
 | 
			
		||||
const nbUtils = require('../../../../helper/notebookUtils');
 | 
			
		||||
const path = require('path');
 | 
			
		||||
 | 
			
		||||
const NOTEBOOK_NAME = 'Notebook';
 | 
			
		||||
 | 
			
		||||
test.describe('Notebook CRUD Operations', () => {
 | 
			
		||||
    test.fixme('Can create a Notebook Object', async ({ page }) => {
 | 
			
		||||
@@ -73,8 +76,7 @@ test.describe('Notebook section tests', () => {
 | 
			
		||||
 | 
			
		||||
        // Create Notebook
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Notebook',
 | 
			
		||||
            name: "Test Notebook"
 | 
			
		||||
            type: NOTEBOOK_NAME
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
    test('Default and new sections are automatically named Unnamed Section with Unnamed Page', async ({ page }) => {
 | 
			
		||||
@@ -135,8 +137,7 @@ test.describe('Notebook page tests', () => {
 | 
			
		||||
 | 
			
		||||
        // Create Notebook
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Notebook',
 | 
			
		||||
            name: "Test Notebook"
 | 
			
		||||
            type: NOTEBOOK_NAME
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
    //Test will need to be implemented after a refactor in #5713
 | 
			
		||||
@@ -207,24 +208,30 @@ test.describe('Notebook search tests', () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test.describe('Notebook entry tests', () => {
 | 
			
		||||
    // Create Notebook with URL Whitelist
 | 
			
		||||
    let notebookObject;
 | 
			
		||||
    test.beforeEach(async ({ page }) => {
 | 
			
		||||
        // eslint-disable-next-line no-undef
 | 
			
		||||
        await page.addInitScript({ path: path.join(__dirname, '../../../../helper/', 'addInitNotebookWithUrls.js') });
 | 
			
		||||
        await page.goto('./', { waitUntil: 'networkidle' });
 | 
			
		||||
 | 
			
		||||
        notebookObject = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: NOTEBOOK_NAME
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
    test.fixme('When a new entry is created, it should be focused', async ({ page }) => {});
 | 
			
		||||
    test('When an object is dropped into a notebook, a new entry is created and it should be focused @unstable', async ({ page }) => {
 | 
			
		||||
        await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
 | 
			
		||||
 | 
			
		||||
        // Create Notebook
 | 
			
		||||
        const notebook = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Notebook',
 | 
			
		||||
            name: "Embed Test Notebook"
 | 
			
		||||
        });
 | 
			
		||||
        // Create Overlay Plot
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Overlay Plot',
 | 
			
		||||
            name: "Dropped Overlay Plot"
 | 
			
		||||
            type: 'Overlay Plot'
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await expandTreePaneItemByName(page, 'My Items');
 | 
			
		||||
        // Navigate to the notebook object
 | 
			
		||||
        await page.goto(notebookObject.url);
 | 
			
		||||
 | 
			
		||||
        // Reveal the notebook in the tree
 | 
			
		||||
        await page.getByTitle('Show selected item in tree').click();
 | 
			
		||||
 | 
			
		||||
        await page.goto(notebook.url);
 | 
			
		||||
        await page.dragAndDrop('role=treeitem[name=/Dropped Overlay Plot/]', '.c-notebook__drag-area');
 | 
			
		||||
 | 
			
		||||
        const embed = page.locator('.c-ne__embed__link');
 | 
			
		||||
@@ -234,22 +241,16 @@ test.describe('Notebook entry tests', () => {
 | 
			
		||||
        expect(embedName).toBe('Dropped Overlay Plot');
 | 
			
		||||
    });
 | 
			
		||||
    test('When an object is dropped into a notebooks existing entry, it should be focused @unstable', async ({ page }) => {
 | 
			
		||||
        await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
 | 
			
		||||
 | 
			
		||||
        // Create Notebook
 | 
			
		||||
        const notebook = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Notebook',
 | 
			
		||||
            name: "Embed Test Notebook"
 | 
			
		||||
        });
 | 
			
		||||
        // Create Overlay Plot
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Overlay Plot',
 | 
			
		||||
            name: "Dropped Overlay Plot"
 | 
			
		||||
            type: 'Overlay Plot'
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await expandTreePaneItemByName(page, 'My Items');
 | 
			
		||||
        // Navigate to the notebook object
 | 
			
		||||
        await page.goto(notebookObject.url);
 | 
			
		||||
 | 
			
		||||
        await page.goto(notebook.url);
 | 
			
		||||
        // Reveal the notebook in the tree
 | 
			
		||||
        await page.getByTitle('Show selected item in tree').click();
 | 
			
		||||
 | 
			
		||||
        await nbUtils.enterTextEntry(page, 'Entry to drop into');
 | 
			
		||||
        await page.dragAndDrop('role=treeitem[name=/Dropped Overlay Plot/]', 'text=Entry to drop into');
 | 
			
		||||
@@ -263,19 +264,14 @@ test.describe('Notebook entry tests', () => {
 | 
			
		||||
    });
 | 
			
		||||
    test.fixme('new entries persist through navigation events without save', async ({ page }) => {});
 | 
			
		||||
    test.fixme('previous and new entries can be deleted', async ({ page }) => {});
 | 
			
		||||
    test.fixme('when a valid link is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => {
 | 
			
		||||
    test('when a valid link is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => {
 | 
			
		||||
        const TEST_LINK = 'http://www.google.com';
 | 
			
		||||
        await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
 | 
			
		||||
 | 
			
		||||
        // Create Notebook
 | 
			
		||||
        const notebook = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Notebook',
 | 
			
		||||
            name: "Entry Link Test"
 | 
			
		||||
        });
 | 
			
		||||
        // Navigate to the notebook object
 | 
			
		||||
        await page.goto(notebookObject.url);
 | 
			
		||||
 | 
			
		||||
        await expandTreePaneItemByName(page, 'My Items');
 | 
			
		||||
 | 
			
		||||
        await page.goto(notebook.url);
 | 
			
		||||
        // Reveal the notebook in the tree
 | 
			
		||||
        await page.getByTitle('Show selected item in tree').click();
 | 
			
		||||
 | 
			
		||||
        await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`);
 | 
			
		||||
 | 
			
		||||
@@ -293,19 +289,14 @@ test.describe('Notebook entry tests', () => {
 | 
			
		||||
 | 
			
		||||
        expect(await validLink.count()).toBe(1);
 | 
			
		||||
    });
 | 
			
		||||
    test.fixme('when an invalid link is entered into a notebook entry, it does not become clickable when viewing', async ({ page }) => {
 | 
			
		||||
    test('when an invalid link is entered into a notebook entry, it does not become clickable when viewing', async ({ page }) => {
 | 
			
		||||
        const TEST_LINK = 'www.google.com';
 | 
			
		||||
        await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
 | 
			
		||||
 | 
			
		||||
        // Create Notebook
 | 
			
		||||
        const notebook = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Notebook',
 | 
			
		||||
            name: "Entry Link Test"
 | 
			
		||||
        });
 | 
			
		||||
        // Navigate to the notebook object
 | 
			
		||||
        await page.goto(notebookObject.url);
 | 
			
		||||
 | 
			
		||||
        await expandTreePaneItemByName(page, 'My Items');
 | 
			
		||||
 | 
			
		||||
        await page.goto(notebook.url);
 | 
			
		||||
        // Reveal the notebook in the tree
 | 
			
		||||
        await page.getByTitle('Show selected item in tree').click();
 | 
			
		||||
 | 
			
		||||
        await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`);
 | 
			
		||||
 | 
			
		||||
@@ -313,20 +304,70 @@ test.describe('Notebook entry tests', () => {
 | 
			
		||||
 | 
			
		||||
        expect(await invalidLink.count()).toBe(0);
 | 
			
		||||
    });
 | 
			
		||||
    test.fixme('when a nefarious link is entered into a notebook entry, it is sanitized when viewing', async ({ page }) => {
 | 
			
		||||
    test('when a link is entered, but it is not in the whitelisted urls, it does not become clickable when viewing', async ({ page }) => {
 | 
			
		||||
        const TEST_LINK = 'http://www.bing.com';
 | 
			
		||||
 | 
			
		||||
        // Navigate to the notebook object
 | 
			
		||||
        await page.goto(notebookObject.url);
 | 
			
		||||
 | 
			
		||||
        // Reveal the notebook in the tree
 | 
			
		||||
        await page.getByTitle('Show selected item in tree').click();
 | 
			
		||||
 | 
			
		||||
        await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`);
 | 
			
		||||
 | 
			
		||||
        const invalidLink = page.locator(`a[href="${TEST_LINK}"]`);
 | 
			
		||||
 | 
			
		||||
        expect(await invalidLink.count()).toBe(0);
 | 
			
		||||
    });
 | 
			
		||||
    test('when a valid link with a subdomain and a valid domain in the whitelisted urls is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => {
 | 
			
		||||
        const INVALID_TEST_LINK = 'http://bing.google.com';
 | 
			
		||||
 | 
			
		||||
        // Navigate to the notebook object
 | 
			
		||||
        await page.goto(notebookObject.url);
 | 
			
		||||
 | 
			
		||||
        // Reveal the notebook in the tree
 | 
			
		||||
        await page.getByTitle('Show selected item in tree').click();
 | 
			
		||||
 | 
			
		||||
        await nbUtils.enterTextEntry(page, `This should be a link: ${INVALID_TEST_LINK} is it?`);
 | 
			
		||||
 | 
			
		||||
        const validLink = page.locator(`a[href="${INVALID_TEST_LINK}"]`);
 | 
			
		||||
 | 
			
		||||
        expect(await validLink.count()).toBe(1);
 | 
			
		||||
    });
 | 
			
		||||
    test('when a valid secure link is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => {
 | 
			
		||||
        const TEST_LINK = 'https://www.google.com';
 | 
			
		||||
 | 
			
		||||
        // Navigate to the notebook object
 | 
			
		||||
        await page.goto(notebookObject.url);
 | 
			
		||||
 | 
			
		||||
        // Reveal the notebook in the tree
 | 
			
		||||
        await page.getByTitle('Show selected item in tree').click();
 | 
			
		||||
 | 
			
		||||
        await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`);
 | 
			
		||||
 | 
			
		||||
        const validLink = page.locator(`a[href="${TEST_LINK}"]`);
 | 
			
		||||
 | 
			
		||||
        // Start waiting for popup before clicking. Note no await.
 | 
			
		||||
        const popupPromise = page.waitForEvent('popup');
 | 
			
		||||
 | 
			
		||||
        await validLink.click();
 | 
			
		||||
        const popup = await popupPromise;
 | 
			
		||||
 | 
			
		||||
        // Wait for the popup to load.
 | 
			
		||||
        await popup.waitForLoadState();
 | 
			
		||||
        expect.soft(popup.url()).toContain('www.google.com');
 | 
			
		||||
 | 
			
		||||
        expect(await validLink.count()).toBe(1);
 | 
			
		||||
    });
 | 
			
		||||
    test('when a nefarious link is entered into a notebook entry, it is sanitized when viewing', async ({ page }) => {
 | 
			
		||||
        const TEST_LINK = 'http://www.google.com?bad=';
 | 
			
		||||
        const TEST_LINK_BAD = `http://www.google.com?bad=<script>alert('gimme your cookies')</script>`;
 | 
			
		||||
        await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
 | 
			
		||||
 | 
			
		||||
        // Create Notebook
 | 
			
		||||
        const notebook = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Notebook',
 | 
			
		||||
            name: "Entry Link Test"
 | 
			
		||||
        });
 | 
			
		||||
        // Navigate to the notebook object
 | 
			
		||||
        await page.goto(notebookObject.url);
 | 
			
		||||
 | 
			
		||||
        await expandTreePaneItemByName(page, 'My Items');
 | 
			
		||||
 | 
			
		||||
        await page.goto(notebook.url);
 | 
			
		||||
        // Reveal the notebook in the tree
 | 
			
		||||
        await page.getByTitle('Show selected item in tree').click();
 | 
			
		||||
 | 
			
		||||
        await nbUtils.enterTextEntry(page, `This should be a link, BUT not a bad link: ${TEST_LINK_BAD} is it?`);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -41,6 +41,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Inspect Notebook Entry Network Requests', async ({ page }) => {
 | 
			
		||||
        await page.getByText('Annotations').click();
 | 
			
		||||
        // Expand sidebar
 | 
			
		||||
        await page.locator('.c-notebook__toggle-nav-button').click();
 | 
			
		||||
 | 
			
		||||
@@ -162,20 +163,20 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
 | 
			
		||||
        await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').press('Enter');
 | 
			
		||||
 | 
			
		||||
        // Add three tags
 | 
			
		||||
        await page.hover(`button:has-text("Add Tag") >> nth=2`);
 | 
			
		||||
        await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
 | 
			
		||||
        await page.hover(`button:has-text("Add Tag")`);
 | 
			
		||||
        await page.locator(`button:has-text("Add Tag")`).click();
 | 
			
		||||
        await page.locator('[placeholder="Type to select tag"]').click();
 | 
			
		||||
        await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click();
 | 
			
		||||
        await page.waitForSelector('[aria-label="Tag"]:has-text("Science")');
 | 
			
		||||
 | 
			
		||||
        await page.hover(`button:has-text("Add Tag") >> nth=2`);
 | 
			
		||||
        await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
 | 
			
		||||
        await page.hover(`button:has-text("Add Tag")`);
 | 
			
		||||
        await page.locator(`button:has-text("Add Tag")`).click();
 | 
			
		||||
        await page.locator('[placeholder="Type to select tag"]').click();
 | 
			
		||||
        await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click();
 | 
			
		||||
        await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")');
 | 
			
		||||
 | 
			
		||||
        await page.hover(`button:has-text("Add Tag") >> nth=2`);
 | 
			
		||||
        await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
 | 
			
		||||
        await page.hover(`button:has-text("Add Tag")`);
 | 
			
		||||
        await page.locator(`button:has-text("Add Tag")`).click();
 | 
			
		||||
        await page.locator('[placeholder="Type to select tag"]').click();
 | 
			
		||||
        await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click();
 | 
			
		||||
        await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")');
 | 
			
		||||
@@ -231,6 +232,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
 | 
			
		||||
            type: 'issue',
 | 
			
		||||
            description: 'https://github.com/akhenry/openmct-yamcs/issues/69'
 | 
			
		||||
        });
 | 
			
		||||
        await page.getByText('Annotations').click();
 | 
			
		||||
        await page.locator('text=To start a new entry, click here or drag and drop any object').click();
 | 
			
		||||
        await page.locator('[aria-label="Notebook Entry Input"]').click();
 | 
			
		||||
        await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
 | 
			
		||||
 
 | 
			
		||||
@@ -198,7 +198,9 @@ test.describe('Tagging in Notebooks @addInit', () => {
 | 
			
		||||
            page.click('.c-disclosure-triangle')
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        const treePane = page.locator('#tree-pane');
 | 
			
		||||
        const treePane = page.getByRole('tree', {
 | 
			
		||||
            name: 'Main Tree'
 | 
			
		||||
        });
 | 
			
		||||
        // Click Clock
 | 
			
		||||
        await treePane.getByRole('treeitem', {
 | 
			
		||||
            name: clock.name
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ test.use({
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test.fixme('ExportAsJSON', () => {
 | 
			
		||||
test.describe('Autoscale', () => {
 | 
			
		||||
    test('User can set autoscale with a valid range @snapshot', async ({ page, openmctConfig }) => {
 | 
			
		||||
        const { myItemsFolderName } = openmctConfig;
 | 
			
		||||
 | 
			
		||||
@@ -47,16 +47,32 @@ test.fixme('ExportAsJSON', () => {
 | 
			
		||||
 | 
			
		||||
        await testYTicks(page, ['-1.00', '-0.50', '0.00', '0.50', '1.00']);
 | 
			
		||||
 | 
			
		||||
        // enter edit mode
 | 
			
		||||
        await page.click('button[title="Edit"]');
 | 
			
		||||
 | 
			
		||||
        await turnOffAutoscale(page);
 | 
			
		||||
 | 
			
		||||
        // Make sure that after turning off autoscale, the user selected range values start at the same values the plot had prior.
 | 
			
		||||
        await testYTicks(page, ['-1.00', '-0.50', '0.00', '0.50', '1.00']);
 | 
			
		||||
        await setUserDefinedMinAndMax(page, '-2', '2');
 | 
			
		||||
 | 
			
		||||
        // save
 | 
			
		||||
        await page.click('button[title="Save"]');
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            page.locator('li[title = "Save and Finish Editing"]').click(),
 | 
			
		||||
            //Wait for Save Banner to appear
 | 
			
		||||
            page.waitForSelector('.c-message-banner__message')
 | 
			
		||||
        ]);
 | 
			
		||||
        //Wait until Save Banner is gone
 | 
			
		||||
        await page.locator('.c-message-banner__close-button').click();
 | 
			
		||||
        await page.waitForSelector('.c-message-banner__message', { state: 'detached'});
 | 
			
		||||
 | 
			
		||||
        // Make sure that after turning off autoscale, the user entered range values are reflexted in the ticks.
 | 
			
		||||
        await testYTicks(page, ['-2.00', '-1.50', '-1.00', '-0.50', '0.00', '0.50', '1.00', '1.50', '2.00']);
 | 
			
		||||
 | 
			
		||||
        const canvas = page.locator('canvas').nth(1);
 | 
			
		||||
 | 
			
		||||
        await canvas.hover({trial: true});
 | 
			
		||||
 | 
			
		||||
        expect(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-prepan.png', { animations: 'disabled' });
 | 
			
		||||
        expect.soft(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-prepan.png', { animations: 'disabled' });
 | 
			
		||||
 | 
			
		||||
        //Alt Drag Start
 | 
			
		||||
        await page.keyboard.down('Alt');
 | 
			
		||||
@@ -76,11 +92,12 @@ test.fixme('ExportAsJSON', () => {
 | 
			
		||||
        await page.keyboard.up('Alt');
 | 
			
		||||
 | 
			
		||||
        // Ensure the drag worked.
 | 
			
		||||
        await testYTicks(page, ['0.00', '0.50', '1.00', '1.50', '2.00']);
 | 
			
		||||
        await testYTicks(page, ['0.00', '0.50', '1.00', '1.50', '2.00', '2.50', '3.00', '3.50']);
 | 
			
		||||
 | 
			
		||||
        //Wait for canvas to stablize.
 | 
			
		||||
        await canvas.hover({trial: true});
 | 
			
		||||
 | 
			
		||||
        expect(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-panned.png', { animations: 'disabled' });
 | 
			
		||||
        expect.soft(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-panned.png', { animations: 'disabled' });
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -152,22 +169,25 @@ async function createSinewaveOverlayPlot(page, myItemsFolderName) {
 | 
			
		||||
 * @param {import('@playwright/test').Page} page
 | 
			
		||||
 */
 | 
			
		||||
async function turnOffAutoscale(page) {
 | 
			
		||||
    // enter edit mode
 | 
			
		||||
    await page.locator('text=Unnamed Overlay Plot Snapshot >> button').nth(3).click();
 | 
			
		||||
 | 
			
		||||
    // uncheck autoscale
 | 
			
		||||
    await page.getByRole('listitem').filter({ hasText: 'Auto scale' }).getByRole('checkbox').uncheck();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
    // save
 | 
			
		||||
    await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click();
 | 
			
		||||
    await Promise.all([
 | 
			
		||||
        page.locator('text=Save and Finish Editing').click(),
 | 
			
		||||
        //Wait for Save Banner to appear
 | 
			
		||||
        page.waitForSelector('.c-message-banner__message')
 | 
			
		||||
    ]);
 | 
			
		||||
    //Wait until Save Banner is gone
 | 
			
		||||
    await page.locator('.c-message-banner__close-button').click();
 | 
			
		||||
    await page.waitForSelector('.c-message-banner__message', { state: 'detached'});
 | 
			
		||||
/**
 | 
			
		||||
 * @param {import('@playwright/test').Page} page
 | 
			
		||||
 * @param {string} min
 | 
			
		||||
 * @param {string} max
 | 
			
		||||
 */
 | 
			
		||||
async function setUserDefinedMinAndMax(page, min, max) {
 | 
			
		||||
    // set minimum value
 | 
			
		||||
    const minRangeInput = page.getByRole('listitem').filter({ hasText: 'Minimum Value' }).locator('input[type="number"]');
 | 
			
		||||
    await minRangeInput.click();
 | 
			
		||||
    await minRangeInput.fill(min);
 | 
			
		||||
 | 
			
		||||
    // set maximum value
 | 
			
		||||
    const maxRangeInput = page.getByRole('listitem').filter({ hasText: 'Maximum Value' }).locator('input[type="number"]');
 | 
			
		||||
    await maxRangeInput.click();
 | 
			
		||||
    await maxRangeInput.fill(max);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -179,7 +199,7 @@ async function testYTicks(page, values) {
 | 
			
		||||
    let promises = [yTicks.count().then(c => expect(c).toBe(values.length))];
 | 
			
		||||
 | 
			
		||||
    for (let i = 0, l = values.length; i < l; i += 1) {
 | 
			
		||||
        promises.push(expect(yTicks.nth(i)).toHaveText(values[i])); // eslint-disable-line
 | 
			
		||||
        promises.push(expect.soft(yTicks.nth(i)).toHaveText(values[i])); // eslint-disable-line
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await Promise.all(promises);
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB  | 
| 
		 Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB  | 
| 
		 Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB  | 
| 
		 Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB  | 
@@ -160,35 +160,16 @@ async function testRegularTicks(page) {
 | 
			
		||||
 */
 | 
			
		||||
async function testLogTicks(page) {
 | 
			
		||||
    const yTicks = await page.locator('.gl-plot-y-tick-label');
 | 
			
		||||
    expect(await yTicks.count()).toBe(28);
 | 
			
		||||
    expect(await yTicks.count()).toBe(9);
 | 
			
		||||
    await expect(yTicks.nth(0)).toHaveText('-2.98');
 | 
			
		||||
    await expect(yTicks.nth(1)).toHaveText('-2.50');
 | 
			
		||||
    await expect(yTicks.nth(2)).toHaveText('-2.00');
 | 
			
		||||
    await expect(yTicks.nth(3)).toHaveText('-1.51');
 | 
			
		||||
    await expect(yTicks.nth(4)).toHaveText('-1.20');
 | 
			
		||||
    await expect(yTicks.nth(5)).toHaveText('-1.00');
 | 
			
		||||
    await expect(yTicks.nth(6)).toHaveText('-0.80');
 | 
			
		||||
    await expect(yTicks.nth(7)).toHaveText('-0.58');
 | 
			
		||||
    await expect(yTicks.nth(8)).toHaveText('-0.40');
 | 
			
		||||
    await expect(yTicks.nth(9)).toHaveText('-0.20');
 | 
			
		||||
    await expect(yTicks.nth(10)).toHaveText('-0.00');
 | 
			
		||||
    await expect(yTicks.nth(11)).toHaveText('0.20');
 | 
			
		||||
    await expect(yTicks.nth(12)).toHaveText('0.40');
 | 
			
		||||
    await expect(yTicks.nth(13)).toHaveText('0.58');
 | 
			
		||||
    await expect(yTicks.nth(14)).toHaveText('0.80');
 | 
			
		||||
    await expect(yTicks.nth(15)).toHaveText('1.00');
 | 
			
		||||
    await expect(yTicks.nth(16)).toHaveText('1.20');
 | 
			
		||||
    await expect(yTicks.nth(17)).toHaveText('1.51');
 | 
			
		||||
    await expect(yTicks.nth(18)).toHaveText('2.00');
 | 
			
		||||
    await expect(yTicks.nth(19)).toHaveText('2.50');
 | 
			
		||||
    await expect(yTicks.nth(20)).toHaveText('2.98');
 | 
			
		||||
    await expect(yTicks.nth(21)).toHaveText('3.50');
 | 
			
		||||
    await expect(yTicks.nth(22)).toHaveText('4.00');
 | 
			
		||||
    await expect(yTicks.nth(23)).toHaveText('4.50');
 | 
			
		||||
    await expect(yTicks.nth(24)).toHaveText('5.31');
 | 
			
		||||
    await expect(yTicks.nth(25)).toHaveText('7.00');
 | 
			
		||||
    await expect(yTicks.nth(26)).toHaveText('8.00');
 | 
			
		||||
    await expect(yTicks.nth(27)).toHaveText('9.00');
 | 
			
		||||
    await expect(yTicks.nth(1)).toHaveText('-1.51');
 | 
			
		||||
    await expect(yTicks.nth(2)).toHaveText('-0.58');
 | 
			
		||||
    await expect(yTicks.nth(3)).toHaveText('-0.00');
 | 
			
		||||
    await expect(yTicks.nth(4)).toHaveText('0.58');
 | 
			
		||||
    await expect(yTicks.nth(5)).toHaveText('1.51');
 | 
			
		||||
    await expect(yTicks.nth(6)).toHaveText('2.98');
 | 
			
		||||
    await expect(yTicks.nth(7)).toHaveText('5.31');
 | 
			
		||||
    await expect(yTicks.nth(8)).toHaveText('9.00');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 
 | 
			
		||||
@@ -29,8 +29,11 @@ const { test, expect } = require('../../../../pluginFixtures');
 | 
			
		||||
const { createDomainObjectWithDefaults } = require('../../../../appActions');
 | 
			
		||||
 | 
			
		||||
test.describe('Overlay Plot', () => {
 | 
			
		||||
    test.beforeEach(async ({ page }) => {
 | 
			
		||||
        await page.goto('./', { waitUntil: 'networkidle' });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Plot legend color is in sync with plot series color', async ({ page }) => {
 | 
			
		||||
        await page.goto('/', { waitUntil: 'networkidle' });
 | 
			
		||||
        const overlayPlot = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Overlay Plot"
 | 
			
		||||
        });
 | 
			
		||||
@@ -56,35 +59,30 @@ test.describe('Overlay Plot', () => {
 | 
			
		||||
 | 
			
		||||
        expect(color).toBe('rgb(255, 166, 61)');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('The elements pool supports dragging series into multiple y-axis buckets', async ({ page }) => {
 | 
			
		||||
        await page.goto('/', { waitUntil: 'networkidle' });
 | 
			
		||||
        const overlayPlot = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Overlay Plot"
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
        const swgA = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Sine Wave Generator",
 | 
			
		||||
            name: 'swg a',
 | 
			
		||||
            parent: overlayPlot.uuid
 | 
			
		||||
        });
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
        const swgB = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Sine Wave Generator",
 | 
			
		||||
            name: 'swg b',
 | 
			
		||||
            parent: overlayPlot.uuid
 | 
			
		||||
        });
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
        const swgC = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Sine Wave Generator",
 | 
			
		||||
            name: 'swg c',
 | 
			
		||||
            parent: overlayPlot.uuid
 | 
			
		||||
        });
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
        const swgD = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Sine Wave Generator",
 | 
			
		||||
            name: 'swg d',
 | 
			
		||||
            parent: overlayPlot.uuid
 | 
			
		||||
        });
 | 
			
		||||
        await createDomainObjectWithDefaults(page, {
 | 
			
		||||
        const swgE = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Sine Wave Generator",
 | 
			
		||||
            name: 'swg e',
 | 
			
		||||
            parent: overlayPlot.uuid
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
@@ -92,33 +90,111 @@ test.describe('Overlay Plot', () => {
 | 
			
		||||
        await page.click('button[title="Edit"]');
 | 
			
		||||
 | 
			
		||||
        // Expand the elements pool vertically
 | 
			
		||||
        await page.locator('.l-pane__handle').nth(2).hover({ trial: true });
 | 
			
		||||
        await page.locator('.l-pane.l-pane--vertical-handle-before', {
 | 
			
		||||
            hasText: 'Elements'
 | 
			
		||||
        }).locator('.l-pane__handle').hover();
 | 
			
		||||
        await page.mouse.down();
 | 
			
		||||
        await page.mouse.move(0, 100);
 | 
			
		||||
        await page.mouse.up();
 | 
			
		||||
 | 
			
		||||
        // Drag swg a, c, e into Y Axis 2
 | 
			
		||||
        await page.locator('#inspector-elements-tree >> text=swg a').dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]'));
 | 
			
		||||
        await page.locator('#inspector-elements-tree >> text=swg c').dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]'));
 | 
			
		||||
        await page.locator('#inspector-elements-tree >> text=swg e').dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]'));
 | 
			
		||||
        const yAxis1PropertyGroup = page.locator('[aria-label="Y Axis Properties"]');
 | 
			
		||||
        const yAxis2PropertyGroup = page.locator('[aria-label="Y Axis 2 Properties"]');
 | 
			
		||||
        const yAxis3PropertyGroup = page.locator('[aria-label="Y Axis 3 Properties"]');
 | 
			
		||||
 | 
			
		||||
        // Drag swg b into Y Axis 3
 | 
			
		||||
        await page.locator('#inspector-elements-tree >> text=swg b').dragTo(page.locator('[aria-label="Element Item Group Y Axis 3"]'));
 | 
			
		||||
        // Assert that Y Axis 1 property group is visible only
 | 
			
		||||
        await expect(yAxis1PropertyGroup).toBeVisible();
 | 
			
		||||
        await expect(yAxis2PropertyGroup).toBeHidden();
 | 
			
		||||
        await expect(yAxis3PropertyGroup).toBeHidden();
 | 
			
		||||
 | 
			
		||||
        // Drag swg a, c, e into Y Axis 2
 | 
			
		||||
        await page.locator(`#inspector-elements-tree >> text=${swgA.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]'));
 | 
			
		||||
        await page.locator(`#inspector-elements-tree >> text=${swgC.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]'));
 | 
			
		||||
        await page.locator(`#inspector-elements-tree >> text=${swgE.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]'));
 | 
			
		||||
 | 
			
		||||
        // Assert that Y Axis 1 and Y Axis 2 property groups are visible only
 | 
			
		||||
        await expect(yAxis1PropertyGroup).toBeVisible();
 | 
			
		||||
        await expect(yAxis2PropertyGroup).toBeVisible();
 | 
			
		||||
        await expect(yAxis3PropertyGroup).toBeHidden();
 | 
			
		||||
 | 
			
		||||
        const yAxis1Group = page.getByLabel("Y Axis 1");
 | 
			
		||||
        const yAxis2Group = page.getByLabel("Y Axis 2");
 | 
			
		||||
        const yAxis3Group = page.getByLabel("Y Axis 3");
 | 
			
		||||
 | 
			
		||||
        // Drag swg b into Y Axis 3
 | 
			
		||||
        await page.locator(`#inspector-elements-tree >> text=${swgB.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 3"]'));
 | 
			
		||||
 | 
			
		||||
        // Assert that all Y Axis property groups are visible
 | 
			
		||||
        await expect(yAxis1PropertyGroup).toBeVisible();
 | 
			
		||||
        await expect(yAxis2PropertyGroup).toBeVisible();
 | 
			
		||||
        await expect(yAxis3PropertyGroup).toBeVisible();
 | 
			
		||||
 | 
			
		||||
        // Verify that the elements are in the correct buckets and in the correct order
 | 
			
		||||
        expect(yAxis1Group.getByRole('listitem', { name: 'swg d' })).toBeTruthy();
 | 
			
		||||
        expect(yAxis1Group.getByRole('listitem').nth(0).getByText('swg d')).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem', { name: 'swg e' })).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem').nth(0).getByText('swg e')).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem', { name: 'swg c' })).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem').nth(1).getByText('swg c')).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem', { name: 'swg a' })).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem').nth(2).getByText('swg a')).toBeTruthy();
 | 
			
		||||
        expect(yAxis3Group.getByRole('listitem', { name: 'swg b' })).toBeTruthy();
 | 
			
		||||
        expect(yAxis3Group.getByRole('listitem').nth(0).getByText('swg b')).toBeTruthy();
 | 
			
		||||
        expect(yAxis1Group.getByRole('listitem', { name: swgD.name })).toBeTruthy();
 | 
			
		||||
        expect(yAxis1Group.getByRole('listitem').nth(0).getByText(swgD.name)).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem', { name: swgE.name })).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem').nth(0).getByText(swgE.name)).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem', { name: swgC.name })).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem').nth(1).getByText(swgC.name)).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem', { name: swgA.name })).toBeTruthy();
 | 
			
		||||
        expect(yAxis2Group.getByRole('listitem').nth(2).getByText(swgA.name)).toBeTruthy();
 | 
			
		||||
        expect(yAxis3Group.getByRole('listitem', { name: swgB.name })).toBeTruthy();
 | 
			
		||||
        expect(yAxis3Group.getByRole('listitem').nth(0).getByText(swgB.name)).toBeTruthy();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Clicking on an item in the elements pool brings up the plot preview with data points', async ({ page }) => {
 | 
			
		||||
        const overlayPlot = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Overlay Plot"
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const swgA = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Sine Wave Generator",
 | 
			
		||||
            parent: overlayPlot.uuid
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await page.goto(overlayPlot.url);
 | 
			
		||||
        await page.click('button[title="Edit"]');
 | 
			
		||||
 | 
			
		||||
        await page.locator(`#inspector-elements-tree >> text=${swgA.name}`).click();
 | 
			
		||||
        await page.locator('.js-overlay canvas').nth(1);
 | 
			
		||||
        const plotPixelSize = await getCanvasPixelsWithData(page);
 | 
			
		||||
        expect(plotPixelSize).toBeGreaterThan(0);
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @param {import('@playwright/test').Page} page
 | 
			
		||||
 */
 | 
			
		||||
async function getCanvasPixelsWithData(page) {
 | 
			
		||||
    const getTelemValuePromise = new Promise(resolve => page.exposeFunction('getCanvasValue', resolve));
 | 
			
		||||
 | 
			
		||||
    await page.evaluate(() => {
 | 
			
		||||
        // The document canvas is where the plot points and lines are drawn.
 | 
			
		||||
        // The only way to access the canvas is using document (using page.evaluate)
 | 
			
		||||
        let data;
 | 
			
		||||
        let canvas;
 | 
			
		||||
        let ctx;
 | 
			
		||||
        canvas = document.querySelector('.js-overlay canvas');
 | 
			
		||||
        ctx = canvas.getContext('2d');
 | 
			
		||||
        data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
 | 
			
		||||
        const imageDataValues = Object.values(data);
 | 
			
		||||
        let plotPixels = [];
 | 
			
		||||
        // Each pixel consists of four values within the ImageData.data array. The for loop iterates by multiples of four.
 | 
			
		||||
        // The values associated with each pixel are R (red), G (green), B (blue), and A (alpha), in that order.
 | 
			
		||||
        for (let i = 0; i < imageDataValues.length;) {
 | 
			
		||||
            if (imageDataValues[i] > 0) {
 | 
			
		||||
                plotPixels.push({
 | 
			
		||||
                    startIndex: i,
 | 
			
		||||
                    endIndex: i + 3,
 | 
			
		||||
                    value: `rgb(${imageDataValues[i]}, ${imageDataValues[i + 1]}, ${imageDataValues[i + 2]}, ${imageDataValues[i + 3]})`
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            i = i + 4;
 | 
			
		||||
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        window.getCanvasValue(plotPixels.length);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return getTelemValuePromise;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										139
									
								
								e2e/tests/functional/plugins/plot/stackedPlot.e2e.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,139 @@
 | 
			
		||||
/*****************************************************************************
 | 
			
		||||
 * 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.
 | 
			
		||||
 *****************************************************************************/
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Tests to verify log plot functionality. Note this test suite if very much under active development and should not
 | 
			
		||||
necessarily be used for reference when writing new tests in this area.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
const { test, expect } = require('../../../../pluginFixtures');
 | 
			
		||||
const { createDomainObjectWithDefaults } = require('../../../../appActions');
 | 
			
		||||
 | 
			
		||||
test.describe('Stacked Plot', () => {
 | 
			
		||||
    let stackedPlot;
 | 
			
		||||
    let swgA;
 | 
			
		||||
    let swgB;
 | 
			
		||||
    let swgC;
 | 
			
		||||
 | 
			
		||||
    test.beforeEach(async ({ page }) => {
 | 
			
		||||
        //Open a browser, navigate to the main page, and wait until all networkevents to resolve
 | 
			
		||||
        await page.goto('/', { waitUntil: 'networkidle' });
 | 
			
		||||
        stackedPlot = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Stacked Plot"
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        swgA = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Sine Wave Generator",
 | 
			
		||||
            parent: stackedPlot.uuid
 | 
			
		||||
        });
 | 
			
		||||
        swgB = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Sine Wave Generator",
 | 
			
		||||
            parent: stackedPlot.uuid
 | 
			
		||||
        });
 | 
			
		||||
        swgC = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: "Sine Wave Generator",
 | 
			
		||||
            parent: stackedPlot.uuid
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Using the remove action removes the correct plot', async ({ page }) => {
 | 
			
		||||
        const swgAElementsPoolItem = page.locator('#inspector-elements-tree').locator('.c-object-label', { hasText: swgA.name });
 | 
			
		||||
        const swgBElementsPoolItem = page.locator('#inspector-elements-tree').locator('.c-object-label', { hasText: swgB.name });
 | 
			
		||||
        const swgCElementsPoolItem = page.locator('#inspector-elements-tree').locator('.c-object-label', { hasText: swgC.name });
 | 
			
		||||
 | 
			
		||||
        await page.goto(stackedPlot.url);
 | 
			
		||||
 | 
			
		||||
        await page.click('button[title="Edit"]');
 | 
			
		||||
 | 
			
		||||
        // Expand the elements pool vertically
 | 
			
		||||
        await page.locator('.l-pane__handle').nth(2).hover({ trial: true });
 | 
			
		||||
        await page.mouse.down();
 | 
			
		||||
        await page.mouse.move(0, 100);
 | 
			
		||||
        await page.mouse.up();
 | 
			
		||||
 | 
			
		||||
        await swgBElementsPoolItem.click({ button: 'right' });
 | 
			
		||||
        await page.getByRole('menuitem').filter({ hasText: /Remove/ }).click();
 | 
			
		||||
        await page.getByRole('button').filter({ hasText: "OK" }).click();
 | 
			
		||||
 | 
			
		||||
        await expect(page.locator('#inspector-elements-tree .js-elements-pool__item')).toHaveCount(2);
 | 
			
		||||
 | 
			
		||||
        // Confirm that the elements pool contains the items we expect
 | 
			
		||||
        await expect(swgAElementsPoolItem).toHaveCount(1);
 | 
			
		||||
        await expect(swgBElementsPoolItem).toHaveCount(0);
 | 
			
		||||
        await expect(swgCElementsPoolItem).toHaveCount(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('Selecting a child plot while in browse and edit modes shows its properties in the inspector', async ({ page }) => {
 | 
			
		||||
        await page.goto(stackedPlot.url);
 | 
			
		||||
 | 
			
		||||
        // Click on the 1st plot
 | 
			
		||||
        await page.locator(`[aria-label="Stacked Plot Item ${swgA.name}"] canvas`).nth(1).click();
 | 
			
		||||
 | 
			
		||||
        // Assert that the inspector shows the Y Axis properties for swgA
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
 | 
			
		||||
        await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgA.name);
 | 
			
		||||
 | 
			
		||||
        // Click on the 2nd plot
 | 
			
		||||
        await page.locator(`[aria-label="Stacked Plot Item ${swgB.name}"] canvas`).nth(1).click();
 | 
			
		||||
 | 
			
		||||
        // Assert that the inspector shows the Y Axis properties for swgB
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
 | 
			
		||||
        await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgB.name);
 | 
			
		||||
 | 
			
		||||
        // Click on the 3rd plot
 | 
			
		||||
        await page.locator(`[aria-label="Stacked Plot Item ${swgC.name}"] canvas`).nth(1).click();
 | 
			
		||||
 | 
			
		||||
        // Assert that the inspector shows the Y Axis properties for swgC
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
 | 
			
		||||
        await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgC.name);
 | 
			
		||||
 | 
			
		||||
        // Go into edit mode
 | 
			
		||||
        await page.click('button[title="Edit"]');
 | 
			
		||||
 | 
			
		||||
        // Click on canvas for the 1st plot
 | 
			
		||||
        await page.locator(`[aria-label="Stacked Plot Item ${swgA.name}"]`).click();
 | 
			
		||||
 | 
			
		||||
        // Assert that the inspector shows the Y Axis properties for swgA
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
 | 
			
		||||
        await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgA.name);
 | 
			
		||||
 | 
			
		||||
        //Click on canvas for the 2nd plot
 | 
			
		||||
        await page.locator(`[aria-label="Stacked Plot Item ${swgB.name}"]`).click();
 | 
			
		||||
 | 
			
		||||
        // Assert that the inspector shows the Y Axis properties for swgB
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
 | 
			
		||||
        await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgB.name);
 | 
			
		||||
 | 
			
		||||
        //Click on canvas for the 3rd plot
 | 
			
		||||
        await page.locator(`[aria-label="Stacked Plot Item ${swgC.name}"]`).click();
 | 
			
		||||
 | 
			
		||||
        // Assert that the inspector shows the Y Axis properties for swgC
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
 | 
			
		||||
        await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
 | 
			
		||||
        await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgC.name);
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
@@ -24,15 +24,22 @@ const { test, expect } = require('../../pluginFixtures.js');
 | 
			
		||||
const { createDomainObjectWithDefaults } = require('../../appActions.js');
 | 
			
		||||
 | 
			
		||||
test.describe('Recent Objects', () => {
 | 
			
		||||
    test('Recent Objects CRUD operations', async ({ page }) => {
 | 
			
		||||
    let recentObjectsList;
 | 
			
		||||
    let clock;
 | 
			
		||||
    let folderA;
 | 
			
		||||
    test.beforeEach(async ({ page }) => {
 | 
			
		||||
        await page.goto('./', { waitUntil: 'networkidle' });
 | 
			
		||||
 | 
			
		||||
        // Set Recent Objects List locator for subsequent tests
 | 
			
		||||
        recentObjectsList = page.getByRole('list', {
 | 
			
		||||
            name: 'Recent Objects'
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Create a folder and nest a Clock within it
 | 
			
		||||
        const recentObjectsList = page.locator('[aria-label="Recent Objects"]');
 | 
			
		||||
        const folderA = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
        folderA = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Folder'
 | 
			
		||||
        });
 | 
			
		||||
        const clock = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
        clock = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Clock',
 | 
			
		||||
            parent: folderA.uuid
 | 
			
		||||
        });
 | 
			
		||||
@@ -42,7 +49,8 @@ test.describe('Recent Objects', () => {
 | 
			
		||||
        await page.mouse.down();
 | 
			
		||||
        await page.mouse.move(0, 100);
 | 
			
		||||
        await page.mouse.up();
 | 
			
		||||
 | 
			
		||||
    });
 | 
			
		||||
    test('Recent Objects CRUD operations', async ({ page }) => {
 | 
			
		||||
        // Verify that both created objects appear in the list and are in the correct order
 | 
			
		||||
        expect(recentObjectsList.getByRole('listitem', { name: clock.name })).toBeTruthy();
 | 
			
		||||
        expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeTruthy();
 | 
			
		||||
@@ -52,7 +60,7 @@ test.describe('Recent Objects', () => {
 | 
			
		||||
        expect(recentObjectsList.getByRole('listitem').nth(1).getByText(folderA.name)).toBeTruthy();
 | 
			
		||||
 | 
			
		||||
        // Navigate to the folder by clicking on the main object name in the recent objects list item
 | 
			
		||||
        await recentObjectsList.getByRole('listitem', { name: folderA.name }).getByText(folderA.name).click();
 | 
			
		||||
        await page.getByRole('listitem', { name: folderA.name }).getByText(folderA.name).click();
 | 
			
		||||
        await page.waitForURL(`**/${folderA.uuid}?*`);
 | 
			
		||||
        expect(recentObjectsList.getByRole('listitem').nth(0).getByText(folderA.name)).toBeTruthy();
 | 
			
		||||
 | 
			
		||||
@@ -63,7 +71,11 @@ test.describe('Recent Objects', () => {
 | 
			
		||||
        await page.keyboard.press('Enter');
 | 
			
		||||
 | 
			
		||||
        // Verify rename has been applied in recent objects list item and objects paths
 | 
			
		||||
        expect(page.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy();
 | 
			
		||||
        expect(await page.getByRole('navigation', {
 | 
			
		||||
            name: `${clock.name} Breadcrumb`
 | 
			
		||||
        }).locator('a').filter({
 | 
			
		||||
            hasText: folderA.name
 | 
			
		||||
        }).count()).toBeGreaterThan(0);
 | 
			
		||||
        expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeTruthy();
 | 
			
		||||
 | 
			
		||||
        // Delete
 | 
			
		||||
@@ -79,7 +91,42 @@ test.describe('Recent Objects', () => {
 | 
			
		||||
        await expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeHidden();
 | 
			
		||||
        await expect(recentObjectsList.getByRole('listitem', { name: clock.name })).toBeHidden();
 | 
			
		||||
    });
 | 
			
		||||
    test.fixme("Clicking on the 'target button' scrolls the object into view in the tree and highlights it");
 | 
			
		||||
    test.fixme("Clicking on an object in the path of a recent object navigates to the object");
 | 
			
		||||
    test.fixme("Tests for context menu actions from recent objects");
 | 
			
		||||
    test("Clicking on an object in the path of a recent object navigates to the object", async ({ page, openmctConfig }) => {
 | 
			
		||||
        const { myItemsFolderName } = openmctConfig;
 | 
			
		||||
        test.info().annotations.push({
 | 
			
		||||
            type: 'issue',
 | 
			
		||||
            description: 'https://github.com/nasa/openmct/issues/6151'
 | 
			
		||||
        });
 | 
			
		||||
        await page.goto('./#/browse/mine');
 | 
			
		||||
 | 
			
		||||
        // Navigate to the folder by clicking on its entry in the Clock's breadcrumb
 | 
			
		||||
        const waitForFolderNavigation = page.waitForURL(`**/${folderA.uuid}?*`);
 | 
			
		||||
        await page.getByRole('navigation', {
 | 
			
		||||
            name: `${clock.name} Breadcrumb`
 | 
			
		||||
        }).locator('a').filter({
 | 
			
		||||
            hasText: folderA.name
 | 
			
		||||
        }).click();
 | 
			
		||||
 | 
			
		||||
        // Verify that the hash URL updates correctly
 | 
			
		||||
        await waitForFolderNavigation;
 | 
			
		||||
        // eslint-disable-next-line no-useless-escape
 | 
			
		||||
        expect(page.url()).toMatch(new RegExp(`.*${folderA.uuid}\?.*`));
 | 
			
		||||
 | 
			
		||||
        // Navigate to My Items by clicking on its entry in the Clock's breadcrumb
 | 
			
		||||
        const waitForMyItemsNavigation = page.waitForURL(`**/mine?*`);
 | 
			
		||||
        await page.getByRole('navigation', {
 | 
			
		||||
            name: `${clock.name} Breadcrumb`
 | 
			
		||||
        }).locator('a').filter({
 | 
			
		||||
            hasText: myItemsFolderName
 | 
			
		||||
        }).click();
 | 
			
		||||
 | 
			
		||||
        // Verify that the hash URL updates correctly
 | 
			
		||||
        await waitForMyItemsNavigation;
 | 
			
		||||
        // eslint-disable-next-line no-useless-escape
 | 
			
		||||
        expect(page.url()).toMatch(new RegExp(`.*mine\?.*`));
 | 
			
		||||
    });
 | 
			
		||||
    test.fixme("Clicking on the 'target button' scrolls the object into view in the tree and highlights it", async ({ page }) => {
 | 
			
		||||
    });
 | 
			
		||||
    test.fixme("Tests for context menu actions from recent objects", async ({ page }) => {
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -116,7 +116,9 @@ async function getAndAssertTreeItems(page, expected) {
 | 
			
		||||
 * @param {string} name
 | 
			
		||||
 */
 | 
			
		||||
async function expandTreePaneItemByName(page, name) {
 | 
			
		||||
    const treePane = page.locator('#tree-pane');
 | 
			
		||||
    const treePane = page.getByRole('tree', {
 | 
			
		||||
        name: 'Main Tree'
 | 
			
		||||
    });
 | 
			
		||||
    const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`);
 | 
			
		||||
    const expandTriangle = treeItem.locator('.c-disclosure-triangle');
 | 
			
		||||
    await expandTriangle.click();
 | 
			
		||||
 
 | 
			
		||||
@@ -57,7 +57,7 @@ test.describe('Visual - Tree Pane', () => {
 | 
			
		||||
            name: 'Z Clock'
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const treePane = "#tree-pane";
 | 
			
		||||
        const treePane = "[role=tree][aria-label='Main Tree']";
 | 
			
		||||
 | 
			
		||||
        await percySnapshot(page, `Tree Pane w/ collapsed tree (theme: ${theme})`, {
 | 
			
		||||
            scope: treePane
 | 
			
		||||
@@ -94,7 +94,7 @@ test.describe('Visual - Tree Pane', () => {
 | 
			
		||||
 * @param {string} name
 | 
			
		||||
 */
 | 
			
		||||
async function expandTreePaneItemByName(page, name) {
 | 
			
		||||
    const treePane = page.locator('#tree-pane');
 | 
			
		||||
    const treePane = page.getByTestId('tree-pane');
 | 
			
		||||
    const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`);
 | 
			
		||||
    const expandTriangle = treeItem.locator('.c-disclosure-triangle');
 | 
			
		||||
    await expandTriangle.click();
 | 
			
		||||
 
 | 
			
		||||
@@ -23,14 +23,18 @@
 | 
			
		||||
import EventEmitter from 'EventEmitter';
 | 
			
		||||
 | 
			
		||||
export default class SinewaveLimitProvider extends EventEmitter {
 | 
			
		||||
    #openmct;
 | 
			
		||||
    #observingStaleness;
 | 
			
		||||
    #watchingTheClock;
 | 
			
		||||
    #isRealTime;
 | 
			
		||||
 | 
			
		||||
    constructor(openmct) {
 | 
			
		||||
        super();
 | 
			
		||||
 | 
			
		||||
        this.openmct = openmct;
 | 
			
		||||
        this.observingStaleness = {};
 | 
			
		||||
        this.watchingTheClock = false;
 | 
			
		||||
        this.isRealTime = undefined;
 | 
			
		||||
        this.#openmct = openmct;
 | 
			
		||||
        this.#observingStaleness = {};
 | 
			
		||||
        this.#watchingTheClock = false;
 | 
			
		||||
        this.#isRealTime = undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    supportsStaleness(domainObject) {
 | 
			
		||||
@@ -38,114 +42,116 @@ export default class SinewaveLimitProvider extends EventEmitter {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isStale(domainObject, options) {
 | 
			
		||||
        if (!this.providingStaleness(domainObject)) {
 | 
			
		||||
            return Promise.resolve({
 | 
			
		||||
                isStale: false,
 | 
			
		||||
                utc: 0
 | 
			
		||||
            });
 | 
			
		||||
        if (!this.#providingStaleness(domainObject)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const id = this.getObjectKeyString(domainObject);
 | 
			
		||||
        const id = this.#getObjectKeyString(domainObject);
 | 
			
		||||
 | 
			
		||||
        if (!this.observerExists(id)) {
 | 
			
		||||
            this.createObserver(id);
 | 
			
		||||
        if (!this.#observerExists(id)) {
 | 
			
		||||
            this.#createObserver(id);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Promise.resolve(this.observingStaleness[id].isStale);
 | 
			
		||||
        return Promise.resolve({
 | 
			
		||||
            isStale: this.#observingStaleness[id].isStale,
 | 
			
		||||
            utc: Date.now()
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    subscribeToStaleness(domainObject, callback) {
 | 
			
		||||
        const id = this.getObjectKeyString(domainObject);
 | 
			
		||||
        const id = this.#getObjectKeyString(domainObject);
 | 
			
		||||
 | 
			
		||||
        if (this.isRealTime === undefined) {
 | 
			
		||||
            this.updateRealTime(this.openmct.time.clock());
 | 
			
		||||
        if (this.#isRealTime === undefined) {
 | 
			
		||||
            this.#updateRealTime(this.#openmct.time.clock());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.handleClockUpdate();
 | 
			
		||||
        this.#handleClockUpdate();
 | 
			
		||||
 | 
			
		||||
        if (this.observerExists(id)) {
 | 
			
		||||
            this.addCallbackToObserver(id, callback);
 | 
			
		||||
        if (this.#observerExists(id)) {
 | 
			
		||||
            this.#addCallbackToObserver(id, callback);
 | 
			
		||||
        } else {
 | 
			
		||||
            this.createObserver(id, callback);
 | 
			
		||||
            this.#createObserver(id, callback);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const intervalId = setInterval(() => {
 | 
			
		||||
            if (this.providingStaleness(domainObject)) {
 | 
			
		||||
                this.updateStaleness(id, !this.observingStaleness[id].isStale);
 | 
			
		||||
            if (this.#providingStaleness(domainObject)) {
 | 
			
		||||
                this.#updateStaleness(id, !this.#observingStaleness[id].isStale);
 | 
			
		||||
            }
 | 
			
		||||
        }, 10000);
 | 
			
		||||
 | 
			
		||||
        return () => {
 | 
			
		||||
            clearInterval(intervalId);
 | 
			
		||||
            this.updateStaleness(id, false);
 | 
			
		||||
            this.handleClockUpdate();
 | 
			
		||||
            this.destroyObserver(id);
 | 
			
		||||
            this.#updateStaleness(id, false);
 | 
			
		||||
            this.#handleClockUpdate();
 | 
			
		||||
            this.#destroyObserver(id);
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleClockUpdate() {
 | 
			
		||||
        let observers = Object.values(this.observingStaleness).length > 0;
 | 
			
		||||
    #handleClockUpdate() {
 | 
			
		||||
        let observers = Object.values(this.#observingStaleness).length > 0;
 | 
			
		||||
 | 
			
		||||
        if (observers && !this.watchingTheClock) {
 | 
			
		||||
            this.watchingTheClock = true;
 | 
			
		||||
            this.openmct.time.on('clock', this.updateRealTime, this);
 | 
			
		||||
        } else if (!observers && this.watchingTheClock) {
 | 
			
		||||
            this.watchingTheClock = false;
 | 
			
		||||
            this.openmct.time.off('clock', this.updateRealTime, this);
 | 
			
		||||
        if (observers && !this.#watchingTheClock) {
 | 
			
		||||
            this.#watchingTheClock = true;
 | 
			
		||||
            this.#openmct.time.on('clock', this.#updateRealTime, this);
 | 
			
		||||
        } else if (!observers && this.#watchingTheClock) {
 | 
			
		||||
            this.#watchingTheClock = false;
 | 
			
		||||
            this.#openmct.time.off('clock', this.#updateRealTime, this);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updateRealTime(clock) {
 | 
			
		||||
        this.isRealTime = clock !== undefined;
 | 
			
		||||
    #updateRealTime(clock) {
 | 
			
		||||
        this.#isRealTime = clock !== undefined;
 | 
			
		||||
 | 
			
		||||
        if (!this.isRealTime) {
 | 
			
		||||
            Object.keys(this.observingStaleness).forEach((id) => {
 | 
			
		||||
                this.updateStaleness(id, false);
 | 
			
		||||
        if (!this.#isRealTime) {
 | 
			
		||||
            Object.keys(this.#observingStaleness).forEach((id) => {
 | 
			
		||||
                this.#updateStaleness(id, false);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updateStaleness(id, isStale) {
 | 
			
		||||
        this.observingStaleness[id].isStale = isStale;
 | 
			
		||||
        this.observingStaleness[id].utc = Date.now();
 | 
			
		||||
        this.observingStaleness[id].callback({
 | 
			
		||||
            isStale: this.observingStaleness[id].isStale,
 | 
			
		||||
            utc: this.observingStaleness[id].utc
 | 
			
		||||
    #updateStaleness(id, isStale) {
 | 
			
		||||
        this.#observingStaleness[id].isStale = isStale;
 | 
			
		||||
        this.#observingStaleness[id].utc = Date.now();
 | 
			
		||||
        this.#observingStaleness[id].callback({
 | 
			
		||||
            isStale: this.#observingStaleness[id].isStale,
 | 
			
		||||
            utc: this.#observingStaleness[id].utc
 | 
			
		||||
        });
 | 
			
		||||
        this.emit('stalenessEvent', {
 | 
			
		||||
            id,
 | 
			
		||||
            isStale: this.observingStaleness[id].isStale
 | 
			
		||||
            isStale: this.#observingStaleness[id].isStale
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    createObserver(id, callback) {
 | 
			
		||||
        this.observingStaleness[id] = {
 | 
			
		||||
    #createObserver(id, callback) {
 | 
			
		||||
        this.#observingStaleness[id] = {
 | 
			
		||||
            isStale: false,
 | 
			
		||||
            utc: Date.now()
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (typeof callback === 'function') {
 | 
			
		||||
            this.addCallbackToObserver(id, callback);
 | 
			
		||||
            this.#addCallbackToObserver(id, callback);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    destroyObserver(id) {
 | 
			
		||||
        delete this.observingStaleness[id];
 | 
			
		||||
    #destroyObserver(id) {
 | 
			
		||||
        if (this.#observingStaleness[id]) {
 | 
			
		||||
            delete this.#observingStaleness[id];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    providingStaleness(domainObject) {
 | 
			
		||||
        return domainObject.telemetry?.staleness === true && this.isRealTime;
 | 
			
		||||
    #providingStaleness(domainObject) {
 | 
			
		||||
        return domainObject.telemetry?.staleness === true && this.#isRealTime;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getObjectKeyString(object) {
 | 
			
		||||
        return this.openmct.objects.makeKeyString(object.identifier);
 | 
			
		||||
    #getObjectKeyString(object) {
 | 
			
		||||
        return this.#openmct.objects.makeKeyString(object.identifier);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    addCallbackToObserver(id, callback) {
 | 
			
		||||
        this.observingStaleness[id].callback = callback;
 | 
			
		||||
    #addCallbackToObserver(id, callback) {
 | 
			
		||||
        this.#observingStaleness[id].callback = callback;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    observerExists(id) {
 | 
			
		||||
        return this.observingStaleness?.[id];
 | 
			
		||||
    #observerExists(id) {
 | 
			
		||||
        return this.#observingStaleness?.[id];
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -275,7 +275,7 @@ function pointForTimestamp(timestamp, name, imageSamples, delay) {
 | 
			
		||||
        local: Math.floor(timestamp / delay) * delay,
 | 
			
		||||
        url,
 | 
			
		||||
        sunOrientation: getCompassValues(0, 360),
 | 
			
		||||
        cameraPan: getCompassValues(0, 360),
 | 
			
		||||
        cameraAzimuth: getCompassValues(0, 360),
 | 
			
		||||
        heading: getCompassValues(0, 360),
 | 
			
		||||
        transformations: navCamTransformations,
 | 
			
		||||
        imageDownloadName
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "openmct",
 | 
			
		||||
  "version": "2.1.6-SNAPSHOT",
 | 
			
		||||
  "version": "2.1.6",
 | 
			
		||||
  "description": "The Open MCT core platform",
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@babel/eslint-parser": "7.18.9",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,47 +1,35 @@
 | 
			
		||||
import CompositionAPI from './CompositionAPI';
 | 
			
		||||
import { createOpenMct, resetApplicationState } from '../../utils/testing';
 | 
			
		||||
import CompositionCollection from './CompositionCollection';
 | 
			
		||||
 | 
			
		||||
describe('The Composition API', function () {
 | 
			
		||||
    let publicAPI;
 | 
			
		||||
    let compositionAPI;
 | 
			
		||||
    let topicService;
 | 
			
		||||
    let mutationTopic;
 | 
			
		||||
 | 
			
		||||
    beforeEach(function () {
 | 
			
		||||
    beforeEach(function (done) {
 | 
			
		||||
        publicAPI = createOpenMct();
 | 
			
		||||
        compositionAPI = publicAPI.composition;
 | 
			
		||||
 | 
			
		||||
        mutationTopic = jasmine.createSpyObj('mutationTopic', [
 | 
			
		||||
            'listen'
 | 
			
		||||
        ]);
 | 
			
		||||
        topicService = jasmine.createSpy('topicService');
 | 
			
		||||
        topicService.and.returnValue(mutationTopic);
 | 
			
		||||
        publicAPI = {};
 | 
			
		||||
        publicAPI.objects = jasmine.createSpyObj('ObjectAPI', [
 | 
			
		||||
            'get',
 | 
			
		||||
            'mutate',
 | 
			
		||||
            'observe',
 | 
			
		||||
            'areIdsEqual'
 | 
			
		||||
        const mockObjectProvider = jasmine.createSpyObj("mock provider", [
 | 
			
		||||
            "create",
 | 
			
		||||
            "update",
 | 
			
		||||
            "get"
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        publicAPI.objects.areIdsEqual.and.callFake(function (id1, id2) {
 | 
			
		||||
            return id1.namespace === id2.namespace && id1.key === id2.key;
 | 
			
		||||
        mockObjectProvider.create.and.returnValue(Promise.resolve(true));
 | 
			
		||||
        mockObjectProvider.update.and.returnValue(Promise.resolve(true));
 | 
			
		||||
        mockObjectProvider.get.and.callFake((identifier) => {
 | 
			
		||||
            return Promise.resolve({identifier});
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        publicAPI.composition = jasmine.createSpyObj('CompositionAPI', [
 | 
			
		||||
            'checkPolicy'
 | 
			
		||||
        ]);
 | 
			
		||||
        publicAPI.composition.checkPolicy.and.returnValue(true);
 | 
			
		||||
        publicAPI.objects.addProvider('test', mockObjectProvider);
 | 
			
		||||
        publicAPI.objects.addProvider('custom', mockObjectProvider);
 | 
			
		||||
 | 
			
		||||
        publicAPI.objects.eventEmitter = jasmine.createSpyObj('eventemitter', [
 | 
			
		||||
            'on'
 | 
			
		||||
        ]);
 | 
			
		||||
        publicAPI.objects.get.and.callFake(function (identifier) {
 | 
			
		||||
            return Promise.resolve({identifier: identifier});
 | 
			
		||||
        });
 | 
			
		||||
        publicAPI.$injector = jasmine.createSpyObj('$injector', [
 | 
			
		||||
            'get'
 | 
			
		||||
        ]);
 | 
			
		||||
        publicAPI.$injector.get.and.returnValue(topicService);
 | 
			
		||||
        compositionAPI = new CompositionAPI(publicAPI);
 | 
			
		||||
        publicAPI.on('start', done);
 | 
			
		||||
        publicAPI.startHeadless();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    afterEach(() => {
 | 
			
		||||
        return resetApplicationState(publicAPI);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns falsy if an object does not support composition', function () {
 | 
			
		||||
@@ -106,6 +94,9 @@ describe('The Composition API', function () {
 | 
			
		||||
            let listener;
 | 
			
		||||
            beforeEach(function () {
 | 
			
		||||
                listener = jasmine.createSpy('reorderListener');
 | 
			
		||||
                spyOn(publicAPI.objects, 'mutate');
 | 
			
		||||
                publicAPI.objects.mutate.and.callThrough();
 | 
			
		||||
 | 
			
		||||
                composition.on('reorder', listener);
 | 
			
		||||
 | 
			
		||||
                return composition.load();
 | 
			
		||||
@@ -136,18 +127,20 @@ describe('The Composition API', function () {
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
        it('supports adding an object to composition', function () {
 | 
			
		||||
            let addListener = jasmine.createSpy('addListener');
 | 
			
		||||
            let mockChildObject = {
 | 
			
		||||
                identifier: {
 | 
			
		||||
                    key: 'mock-key',
 | 
			
		||||
                    namespace: ''
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
            composition.on('add', addListener);
 | 
			
		||||
            composition.add(mockChildObject);
 | 
			
		||||
 | 
			
		||||
            expect(domainObject.composition.length).toBe(4);
 | 
			
		||||
            expect(domainObject.composition[3]).toEqual(mockChildObject.identifier);
 | 
			
		||||
            return new Promise((resolve) => {
 | 
			
		||||
                composition.on('add', resolve);
 | 
			
		||||
                composition.add(mockChildObject);
 | 
			
		||||
            }).then(() => {
 | 
			
		||||
                expect(domainObject.composition.length).toBe(4);
 | 
			
		||||
                expect(domainObject.composition[3]).toEqual(mockChildObject.identifier);
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -224,7 +224,7 @@ export default class CompositionProvider {
 | 
			
		||||
      * @private
 | 
			
		||||
      * @param {DomainObject} oldDomainObject
 | 
			
		||||
      */
 | 
			
		||||
    #onMutation(oldDomainObject) {
 | 
			
		||||
    #onMutation(newDomainObject, oldDomainObject) {
 | 
			
		||||
        const id = objectUtils.makeKeyString(oldDomainObject.identifier);
 | 
			
		||||
        const listeners = this.#listeningTo[id];
 | 
			
		||||
 | 
			
		||||
@@ -232,8 +232,8 @@ export default class CompositionProvider {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const oldComposition = listeners.composition.map(objectUtils.makeKeyString);
 | 
			
		||||
        const newComposition = oldDomainObject.composition.map(objectUtils.makeKeyString);
 | 
			
		||||
        const oldComposition = oldDomainObject.composition.map(objectUtils.makeKeyString);
 | 
			
		||||
        const newComposition = newDomainObject.composition.map(objectUtils.makeKeyString);
 | 
			
		||||
 | 
			
		||||
        const added = _.difference(newComposition, oldComposition).map(objectUtils.parseKeyString);
 | 
			
		||||
        const removed = _.difference(oldComposition, newComposition).map(objectUtils.parseKeyString);
 | 
			
		||||
@@ -248,8 +248,6 @@ export default class CompositionProvider {
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        listeners.composition = newComposition.map(objectUtils.parseKeyString);
 | 
			
		||||
 | 
			
		||||
        added.forEach(function (addedChild) {
 | 
			
		||||
            listeners.add.forEach(notify(addedChild));
 | 
			
		||||
        });
 | 
			
		||||
 
 | 
			
		||||
@@ -99,8 +99,7 @@ export default class DefaultCompositionProvider extends CompositionProvider {
 | 
			
		||||
            objectListeners = this.listeningTo[keyString] = {
 | 
			
		||||
                add: [],
 | 
			
		||||
                remove: [],
 | 
			
		||||
                reorder: [],
 | 
			
		||||
                composition: [].slice.apply(domainObject.composition)
 | 
			
		||||
                reorder: []
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -172,8 +171,9 @@ export default class DefaultCompositionProvider extends CompositionProvider {
 | 
			
		||||
     */
 | 
			
		||||
    add(parent, childId) {
 | 
			
		||||
        if (!this.includes(parent, childId)) {
 | 
			
		||||
            parent.composition.push(childId);
 | 
			
		||||
            this.publicAPI.objects.mutate(parent, 'composition', parent.composition);
 | 
			
		||||
            const composition = structuredClone(parent.composition);
 | 
			
		||||
            composition.push(childId);
 | 
			
		||||
            this.publicAPI.objects.mutate(parent, 'composition', composition);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,6 @@
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<mct-tree
 | 
			
		||||
    id="locator-tree"
 | 
			
		||||
    :is-selector-tree="true"
 | 
			
		||||
    :initial-selection="model.parent"
 | 
			
		||||
    @tree-item-selection="handleItemSelection"
 | 
			
		||||
 
 | 
			
		||||
@@ -75,21 +75,23 @@ class MutableDomainObject {
 | 
			
		||||
        return eventOff;
 | 
			
		||||
    }
 | 
			
		||||
    $set(path, value) {
 | 
			
		||||
        const oldModel = JSON.parse(JSON.stringify(this));
 | 
			
		||||
        const oldValue = _.get(oldModel, path);
 | 
			
		||||
        MutableDomainObject.mutateObject(this, path, value);
 | 
			
		||||
 | 
			
		||||
        //Emit secret synchronization event first, so that all objects are in sync before subsequent events fired.
 | 
			
		||||
        this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), this);
 | 
			
		||||
 | 
			
		||||
        //Emit a general "any object" event
 | 
			
		||||
        this._globalEventEmitter.emit(ANY_OBJECT_EVENT, this);
 | 
			
		||||
        this._globalEventEmitter.emit(ANY_OBJECT_EVENT, this, oldModel);
 | 
			
		||||
        //Emit wildcard event, with path so that callback knows what changed
 | 
			
		||||
        this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this, path, value);
 | 
			
		||||
        this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this, path, value, oldModel, oldValue);
 | 
			
		||||
 | 
			
		||||
        //Emit events specific to properties affected
 | 
			
		||||
        let parentPropertiesList = path.split('.');
 | 
			
		||||
        for (let index = parentPropertiesList.length; index > 0; index--) {
 | 
			
		||||
            let parentPropertyPath = parentPropertiesList.slice(0, index).join('.');
 | 
			
		||||
            this._globalEventEmitter.emit(qualifiedEventName(this, parentPropertyPath), _.get(this, parentPropertyPath));
 | 
			
		||||
            this._globalEventEmitter.emit(qualifiedEventName(this, parentPropertyPath), _.get(this, parentPropertyPath), _.get(oldModel, parentPropertyPath));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //TODO: Emit events for listeners of child properties when parent changes.
 | 
			
		||||
 
 | 
			
		||||
@@ -225,24 +225,21 @@ export default class ObjectAPI {
 | 
			
		||||
            throw new Error('Provider does not support get!');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let objectPromise = provider.get(identifier, abortSignal).then(result => {
 | 
			
		||||
        let objectPromise = provider.get(identifier, abortSignal).then(domainObject => {
 | 
			
		||||
            delete this.cache[keystring];
 | 
			
		||||
            domainObject = this.applyGetInterceptors(identifier, domainObject);
 | 
			
		||||
 | 
			
		||||
            result = this.applyGetInterceptors(identifier, result);
 | 
			
		||||
            if (result.isMutable) {
 | 
			
		||||
                result.$refresh(result);
 | 
			
		||||
            } else {
 | 
			
		||||
                let mutableDomainObject = this.toMutable(result);
 | 
			
		||||
                mutableDomainObject.$refresh(result);
 | 
			
		||||
            if (this.supportsMutation(identifier)) {
 | 
			
		||||
                const mutableDomainObject = this.toMutable(domainObject);
 | 
			
		||||
                mutableDomainObject.$refresh(domainObject);
 | 
			
		||||
                this.destroyMutable(mutableDomainObject);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return result;
 | 
			
		||||
        }).catch((result) => {
 | 
			
		||||
            console.warn(`Failed to retrieve ${keystring}:`, result);
 | 
			
		||||
 | 
			
		||||
            return domainObject;
 | 
			
		||||
        }).catch((error) => {
 | 
			
		||||
            console.warn(`Failed to retrieve ${keystring}:`, error);
 | 
			
		||||
            delete this.cache[keystring];
 | 
			
		||||
 | 
			
		||||
            result = this.applyGetInterceptors(identifier);
 | 
			
		||||
            const result = this.applyGetInterceptors(identifier);
 | 
			
		||||
 | 
			
		||||
            return result;
 | 
			
		||||
        });
 | 
			
		||||
@@ -648,7 +645,7 @@ export default class ObjectAPI {
 | 
			
		||||
     * @param {module:openmct.DomainObject} object the object to observe
 | 
			
		||||
     * @param {string} path the property to observe
 | 
			
		||||
     * @param {Function} callback a callback to invoke when new values for
 | 
			
		||||
     *        this property are observed
 | 
			
		||||
     *        this property are observed.
 | 
			
		||||
     * @method observe
 | 
			
		||||
     * @memberof module:openmct.ObjectAPI#
 | 
			
		||||
     */
 | 
			
		||||
 
 | 
			
		||||
@@ -399,7 +399,7 @@ describe("The Object API", () => {
 | 
			
		||||
                    unlisten = objectAPI.observe(mutableSecondInstance, 'otherAttribute', mutationCallback);
 | 
			
		||||
                    objectAPI.mutate(mutable, 'otherAttribute', 'some-new-value');
 | 
			
		||||
                }).then(function () {
 | 
			
		||||
                    expect(mutationCallback).toHaveBeenCalledWith('some-new-value');
 | 
			
		||||
                    expect(mutationCallback).toHaveBeenCalledWith('some-new-value', 'other-attribute-value');
 | 
			
		||||
                    unlisten();
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
@@ -419,14 +419,20 @@ describe("The Object API", () => {
 | 
			
		||||
 | 
			
		||||
                    objectAPI.mutate(mutable, 'objectAttribute.embeddedObject.embeddedKey', 'updated-embedded-value');
 | 
			
		||||
                }).then(function () {
 | 
			
		||||
                    expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value');
 | 
			
		||||
                    expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value', 'embedded-value');
 | 
			
		||||
                    expect(embeddedObjectCallback).toHaveBeenCalledWith({
 | 
			
		||||
                        embeddedKey: 'updated-embedded-value'
 | 
			
		||||
                    }, {
 | 
			
		||||
                        embeddedKey: 'embedded-value'
 | 
			
		||||
                    });
 | 
			
		||||
                    expect(objectAttributeCallback).toHaveBeenCalledWith({
 | 
			
		||||
                        embeddedObject: {
 | 
			
		||||
                            embeddedKey: 'updated-embedded-value'
 | 
			
		||||
                        }
 | 
			
		||||
                    }, {
 | 
			
		||||
                        embeddedObject: {
 | 
			
		||||
                            embeddedKey: 'embedded-value'
 | 
			
		||||
                        }
 | 
			
		||||
                    });
 | 
			
		||||
 | 
			
		||||
                    listeners.forEach(listener => listener());
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="c-overlay">
 | 
			
		||||
<div class="c-overlay js-overlay">
 | 
			
		||||
    <div
 | 
			
		||||
        class="c-overlay__blocker"
 | 
			
		||||
        @click="destroy"
 | 
			
		||||
@@ -26,7 +26,7 @@
 | 
			
		||||
                v-for="(button, index) in buttons"
 | 
			
		||||
                ref="buttons"
 | 
			
		||||
                :key="index"
 | 
			
		||||
                class="c-button"
 | 
			
		||||
                class="c-button js-overlay__button"
 | 
			
		||||
                tabindex="0"
 | 
			
		||||
                :class="{'c-button--major': focusIndex===index}"
 | 
			
		||||
                @focus="focusIndex=index"
 | 
			
		||||
 
 | 
			
		||||
@@ -180,6 +180,7 @@ export default class TelemetryCollection extends EventEmitter {
 | 
			
		||||
        let beforeStartOfBounds;
 | 
			
		||||
        let afterEndOfBounds;
 | 
			
		||||
        let added = [];
 | 
			
		||||
        let addedIndices = [];
 | 
			
		||||
 | 
			
		||||
        // loop through, sort and dedupe
 | 
			
		||||
        for (let datum of data) {
 | 
			
		||||
@@ -212,6 +213,7 @@ export default class TelemetryCollection extends EventEmitter {
 | 
			
		||||
                    let index = endIndex || startIndex;
 | 
			
		||||
 | 
			
		||||
                    this.boundedTelemetry.splice(index, 0, datum);
 | 
			
		||||
                    addedIndices.push(index);
 | 
			
		||||
                    added.push(datum);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
@@ -230,7 +232,7 @@ export default class TelemetryCollection extends EventEmitter {
 | 
			
		||||
                    this.emit('add', this.boundedTelemetry);
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                this.emit('add', added);
 | 
			
		||||
                this.emit('add', added, addedIndices);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@@ -330,7 +332,8 @@ export default class TelemetryCollection extends EventEmitter {
 | 
			
		||||
                    this.boundedTelemetry = added;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                this.emit('add', added);
 | 
			
		||||
                // Assumption is that added will be of length 1 here, so just send the last index of the boundedTelemetry in the add event
 | 
			
		||||
                this.emit('add', added, [this.boundedTelemetry.length]);
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            // user bounds change, reset
 | 
			
		||||
 
 | 
			
		||||
@@ -32,14 +32,18 @@ class IndependentTimeContext extends TimeContext {
 | 
			
		||||
        this.openmct = openmct;
 | 
			
		||||
        this.unlisteners = [];
 | 
			
		||||
        this.globalTimeContext = globalTimeContext;
 | 
			
		||||
        this.upstreamTimeContext = undefined;
 | 
			
		||||
        // We always start with the global time context.
 | 
			
		||||
        // This upstream context will be undefined when an independent time context is added later.
 | 
			
		||||
        this.upstreamTimeContext = this.globalTimeContext;
 | 
			
		||||
        this.objectPath = objectPath;
 | 
			
		||||
        this.refreshContext = this.refreshContext.bind(this);
 | 
			
		||||
        this.resetContext = this.resetContext.bind(this);
 | 
			
		||||
        this.removeIndependentContext = this.removeIndependentContext.bind(this);
 | 
			
		||||
 | 
			
		||||
        this.refreshContext();
 | 
			
		||||
 | 
			
		||||
        this.globalTimeContext.on('refreshContext', this.refreshContext);
 | 
			
		||||
        this.globalTimeContext.on('removeOwnContext', this.removeIndependentContext);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    bounds(newBounds) {
 | 
			
		||||
@@ -202,16 +206,16 @@ class IndependentTimeContext extends TimeContext {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getUpstreamContext() {
 | 
			
		||||
        const objectKey = this.openmct.objects.makeKeyString(this.objectPath[this.objectPath.length - 1].identifier);
 | 
			
		||||
        const doesObjectHaveTimeContext = this.globalTimeContext.independentContexts.get(objectKey);
 | 
			
		||||
        if (doesObjectHaveTimeContext) {
 | 
			
		||||
        // If a view has an independent context, don't return an upstream context
 | 
			
		||||
        // Be aware that when a new independent time context is created, we assign the global context as default
 | 
			
		||||
        if (this.hasOwnContext()) {
 | 
			
		||||
            return undefined;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let timeContext = this.globalTimeContext;
 | 
			
		||||
        this.objectPath.some((item, index) => {
 | 
			
		||||
            const key = this.openmct.objects.makeKeyString(item.identifier);
 | 
			
		||||
            //last index is the view object itself
 | 
			
		||||
            // we're only interested in parents, not self, so index > 0
 | 
			
		||||
            const itemContext = this.globalTimeContext.independentContexts.get(key);
 | 
			
		||||
            if (index > 0 && itemContext && itemContext.hasOwnContext()) {
 | 
			
		||||
                //upstream time context
 | 
			
		||||
@@ -225,6 +229,43 @@ class IndependentTimeContext extends TimeContext {
 | 
			
		||||
 | 
			
		||||
        return timeContext;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set the time context of a view to follow any upstream time contexts as necessary (defaulting to the global context)
 | 
			
		||||
     * This needs to be separate from refreshContext
 | 
			
		||||
     */
 | 
			
		||||
    removeIndependentContext(viewKey) {
 | 
			
		||||
        const key = this.openmct.objects.makeKeyString(this.objectPath[0].identifier);
 | 
			
		||||
        if (viewKey && key === viewKey) {
 | 
			
		||||
            //this is necessary as the upstream context gets reassigned after this
 | 
			
		||||
            this.stopFollowingTimeContext();
 | 
			
		||||
 | 
			
		||||
            let timeContext = this.globalTimeContext;
 | 
			
		||||
 | 
			
		||||
            this.objectPath.some((item, index) => {
 | 
			
		||||
                const objectKey = this.openmct.objects.makeKeyString(item.identifier);
 | 
			
		||||
                // we're only interested in any parents, not self, so index > 0
 | 
			
		||||
                const itemContext = this.globalTimeContext.independentContexts.get(objectKey);
 | 
			
		||||
                if (index > 0 && itemContext && itemContext.hasOwnContext()) {
 | 
			
		||||
                    //upstream time context
 | 
			
		||||
                    timeContext = itemContext;
 | 
			
		||||
 | 
			
		||||
                    return true;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                return false;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.upstreamTimeContext = timeContext;
 | 
			
		||||
 | 
			
		||||
            this.followTimeContext();
 | 
			
		||||
 | 
			
		||||
            // Emit bounds so that views that are changing context get the upstream bounds
 | 
			
		||||
            this.emit('bounds', this.bounds());
 | 
			
		||||
            // now that the view's context is set, tell others to check theirs in case they were following this view's context.
 | 
			
		||||
            this.globalTimeContext.emit('refreshContext', viewKey);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default IndependentTimeContext;
 | 
			
		||||
 
 | 
			
		||||
@@ -149,7 +149,7 @@ class TimeAPI extends GlobalTimeContext {
 | 
			
		||||
 | 
			
		||||
        return () => {
 | 
			
		||||
            //follow any upstream time context
 | 
			
		||||
            this.emit('refreshContext');
 | 
			
		||||
            this.emit('removeOwnContext', key);
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -93,21 +93,82 @@ describe("The Independent Time API", function () {
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("follows a parent time context given the objectPath", () => {
 | 
			
		||||
        let timeContext = api.getContextForView([{
 | 
			
		||||
        api.getContextForView([{
 | 
			
		||||
            identifier: {
 | 
			
		||||
                namespace: '',
 | 
			
		||||
                key: 'blah'
 | 
			
		||||
            }
 | 
			
		||||
        }]);
 | 
			
		||||
        let destroyTimeContext = api.addIndependentContext('blah', independentBounds);
 | 
			
		||||
        let timeContext = api.getContextForView([{
 | 
			
		||||
            identifier: {
 | 
			
		||||
                namespace: '',
 | 
			
		||||
                key: domainObjectKey
 | 
			
		||||
            }
 | 
			
		||||
        }, {
 | 
			
		||||
            identifier: {
 | 
			
		||||
                namespace: '',
 | 
			
		||||
                key: 'blah'
 | 
			
		||||
            }
 | 
			
		||||
        }]);
 | 
			
		||||
        expect(timeContext.bounds()).toEqual(independentBounds);
 | 
			
		||||
        destroyTimeContext();
 | 
			
		||||
        expect(timeContext.bounds()).toEqual(bounds);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("uses an object's independent time context if the parent doesn't have one", () => {
 | 
			
		||||
        const domainObjectKey2 = `${domainObjectKey}-2`;
 | 
			
		||||
        const domainObjectKey3 = `${domainObjectKey}-3`;
 | 
			
		||||
        let timeContext = api.getContextForView([{
 | 
			
		||||
            identifier: {
 | 
			
		||||
                namespace: '',
 | 
			
		||||
                key: domainObjectKey
 | 
			
		||||
            }
 | 
			
		||||
        }]);
 | 
			
		||||
        let destroyTimeContext = api.addIndependentContext('blah', independentBounds);
 | 
			
		||||
        let timeContext2 = api.getContextForView([{
 | 
			
		||||
            identifier: {
 | 
			
		||||
                namespace: '',
 | 
			
		||||
                key: domainObjectKey2
 | 
			
		||||
            }
 | 
			
		||||
        }]);
 | 
			
		||||
        let timeContext3 = api.getContextForView([{
 | 
			
		||||
            identifier: {
 | 
			
		||||
                namespace: '',
 | 
			
		||||
                key: domainObjectKey3
 | 
			
		||||
            }
 | 
			
		||||
        }]);
 | 
			
		||||
        // all bounds follow global time context
 | 
			
		||||
        expect(timeContext.bounds()).toEqual(bounds);
 | 
			
		||||
        expect(timeContext2.bounds()).toEqual(bounds);
 | 
			
		||||
        expect(timeContext3.bounds()).toEqual(bounds);
 | 
			
		||||
        // only first item has own context
 | 
			
		||||
        let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds);
 | 
			
		||||
        expect(timeContext.bounds()).toEqual(independentBounds);
 | 
			
		||||
        expect(timeContext2.bounds()).toEqual(bounds);
 | 
			
		||||
        expect(timeContext3.bounds()).toEqual(bounds);
 | 
			
		||||
        // first and second item have own context
 | 
			
		||||
        let destroyTimeContext2 = api.addIndependentContext(domainObjectKey2, independentBounds);
 | 
			
		||||
        expect(timeContext.bounds()).toEqual(independentBounds);
 | 
			
		||||
        expect(timeContext2.bounds()).toEqual(independentBounds);
 | 
			
		||||
        expect(timeContext3.bounds()).toEqual(bounds);
 | 
			
		||||
        // all items have own time context
 | 
			
		||||
        let destroyTimeContext3 = api.addIndependentContext(domainObjectKey3, independentBounds);
 | 
			
		||||
        expect(timeContext.bounds()).toEqual(independentBounds);
 | 
			
		||||
        expect(timeContext2.bounds()).toEqual(independentBounds);
 | 
			
		||||
        expect(timeContext3.bounds()).toEqual(independentBounds);
 | 
			
		||||
        //remove own contexts one at a time - should revert to global time context
 | 
			
		||||
        destroyTimeContext();
 | 
			
		||||
        expect(timeContext.bounds()).toEqual(bounds);
 | 
			
		||||
        expect(timeContext2.bounds()).toEqual(independentBounds);
 | 
			
		||||
        expect(timeContext3.bounds()).toEqual(independentBounds);
 | 
			
		||||
        destroyTimeContext2();
 | 
			
		||||
        expect(timeContext.bounds()).toEqual(bounds);
 | 
			
		||||
        expect(timeContext2.bounds()).toEqual(bounds);
 | 
			
		||||
        expect(timeContext3.bounds()).toEqual(independentBounds);
 | 
			
		||||
        destroyTimeContext3();
 | 
			
		||||
        expect(timeContext.bounds()).toEqual(bounds);
 | 
			
		||||
        expect(timeContext2.bounds()).toEqual(bounds);
 | 
			
		||||
        expect(timeContext3.bounds()).toEqual(bounds);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("Allows setting of valid bounds", function () {
 | 
			
		||||
 
 | 
			
		||||
@@ -48,11 +48,11 @@
 | 
			
		||||
                </tr>
 | 
			
		||||
                <lad-row
 | 
			
		||||
                    v-for="ladRow in ladTelemetryObjects[ladTable.key]"
 | 
			
		||||
                    :key="ladRow.key"
 | 
			
		||||
                    :key="combineKeys(ladTable.key, ladRow.key)"
 | 
			
		||||
                    :domain-object="ladRow.domainObject"
 | 
			
		||||
                    :path-to-table="ladTable.objectPath"
 | 
			
		||||
                    :has-units="hasUnits"
 | 
			
		||||
                    :is-stale="staleObjects.includes(ladRow.key)"
 | 
			
		||||
                    :is-stale="staleObjects.includes(combineKeys(ladTable.key, ladRow.key))"
 | 
			
		||||
                    @rowContextClick="updateViewContext"
 | 
			
		||||
                />
 | 
			
		||||
            </template>
 | 
			
		||||
@@ -160,10 +160,18 @@ export default {
 | 
			
		||||
                removeCallback
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        combineKeys(ladKey, telemetryObjectKey) {
 | 
			
		||||
            return `${ladKey}-${telemetryObjectKey}`;
 | 
			
		||||
        },
 | 
			
		||||
        removeLadTable(identifier) {
 | 
			
		||||
            let index = this.ladTableObjects.findIndex(ladTable => this.openmct.objects.makeKeyString(identifier) === ladTable.key);
 | 
			
		||||
            let ladTable = this.ladTableObjects[index];
 | 
			
		||||
 | 
			
		||||
            this.ladTelemetryObjects[ladTable.key].forEach(telemetryObject => {
 | 
			
		||||
                let combinedKey = this.combineKeys(ladTable.key, telemetryObject.key);
 | 
			
		||||
                this.unwatchStaleness(combinedKey);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.$delete(this.ladTelemetryObjects, ladTable.key);
 | 
			
		||||
            this.ladTableObjects.splice(index, 1);
 | 
			
		||||
        },
 | 
			
		||||
@@ -178,59 +186,58 @@ export default {
 | 
			
		||||
                let telemetryObject = {};
 | 
			
		||||
                telemetryObject.key = this.openmct.objects.makeKeyString(domainObject.identifier);
 | 
			
		||||
                telemetryObject.domainObject = domainObject;
 | 
			
		||||
                const combinedKey = this.combineKeys(ladTable.key, telemetryObject.key);
 | 
			
		||||
 | 
			
		||||
                let telemetryObjects = this.ladTelemetryObjects[ladTable.key];
 | 
			
		||||
                const telemetryObjects = this.ladTelemetryObjects[ladTable.key];
 | 
			
		||||
                telemetryObjects.push(telemetryObject);
 | 
			
		||||
 | 
			
		||||
                this.$set(this.ladTelemetryObjects, ladTable.key, telemetryObjects);
 | 
			
		||||
 | 
			
		||||
                // if tracking already, possibly in another table, return
 | 
			
		||||
                if (this.stalenessSubscription[telemetryObject.key]) {
 | 
			
		||||
                    return;
 | 
			
		||||
                } else {
 | 
			
		||||
                    this.stalenessSubscription[telemetryObject.key] = {};
 | 
			
		||||
                    this.stalenessSubscription[telemetryObject.key].stalenessUtils = new StalenessUtils(this.openmct, domainObject);
 | 
			
		||||
                }
 | 
			
		||||
                this.stalenessSubscription[combinedKey] = {};
 | 
			
		||||
                this.stalenessSubscription[combinedKey].stalenessUtils = new StalenessUtils(this.openmct, domainObject);
 | 
			
		||||
 | 
			
		||||
                this.openmct.telemetry.isStale(domainObject).then((stalenessResponse) => {
 | 
			
		||||
                    if (stalenessResponse !== undefined) {
 | 
			
		||||
                        this.handleStaleness(telemetryObject.key, stalenessResponse);
 | 
			
		||||
                        this.handleStaleness(combinedKey, stalenessResponse);
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
                const stalenessSubscription = this.openmct.telemetry.subscribeToStaleness(domainObject, (stalenessResponse) => {
 | 
			
		||||
                    this.handleStaleness(telemetryObject.key, stalenessResponse);
 | 
			
		||||
                    this.handleStaleness(combinedKey, stalenessResponse);
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                this.stalenessSubscription[telemetryObject.key].unsubscribe = stalenessSubscription;
 | 
			
		||||
                this.stalenessSubscription[combinedKey].unsubscribe = stalenessSubscription;
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        removeTelemetryObject(ladTable) {
 | 
			
		||||
            return (identifier) => {
 | 
			
		||||
                const SKIP_CHECK = true;
 | 
			
		||||
                const keystring = this.openmct.objects.makeKeyString(identifier);
 | 
			
		||||
                let telemetryObjects = this.ladTelemetryObjects[ladTable.key];
 | 
			
		||||
                const telemetryObjects = this.ladTelemetryObjects[ladTable.key];
 | 
			
		||||
                const combinedKey = this.combineKeys(ladTable.key, keystring);
 | 
			
		||||
                let index = telemetryObjects.findIndex(telemetryObject => keystring === telemetryObject.key);
 | 
			
		||||
 | 
			
		||||
                this.unwatchStaleness(combinedKey);
 | 
			
		||||
 | 
			
		||||
                telemetryObjects.splice(index, 1);
 | 
			
		||||
 | 
			
		||||
                this.$set(this.ladTelemetryObjects, ladTable.key, telemetryObjects);
 | 
			
		||||
 | 
			
		||||
                this.stalenessSubscription[keystring].unsubscribe();
 | 
			
		||||
                this.stalenessSubscription[keystring].stalenessUtils.destroy();
 | 
			
		||||
                this.handleStaleness(keystring, { isStale: false }, SKIP_CHECK);
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        handleStaleness(id, stalenessResponse, skipCheck = false) {
 | 
			
		||||
            if (skipCheck || this.stalenessSubscription[id].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) {
 | 
			
		||||
                const index = this.staleObjects.indexOf(id);
 | 
			
		||||
                if (stalenessResponse.isStale) {
 | 
			
		||||
                    if (index === -1) {
 | 
			
		||||
                        this.staleObjects.push(id);
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    if (index !== -1) {
 | 
			
		||||
                        this.staleObjects.splice(index, 1);
 | 
			
		||||
                    }
 | 
			
		||||
        unwatchStaleness(combinedKey) {
 | 
			
		||||
            const SKIP_CHECK = true;
 | 
			
		||||
 | 
			
		||||
            this.stalenessSubscription[combinedKey].unsubscribe();
 | 
			
		||||
            this.stalenessSubscription[combinedKey].stalenessUtils.destroy();
 | 
			
		||||
            this.handleStaleness(combinedKey, { isStale: false }, SKIP_CHECK);
 | 
			
		||||
 | 
			
		||||
            delete this.stalenessSubscription[combinedKey];
 | 
			
		||||
        },
 | 
			
		||||
        handleStaleness(combinedKey, stalenessResponse, skipCheck = false) {
 | 
			
		||||
            if (skipCheck || this.stalenessSubscription[combinedKey].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) {
 | 
			
		||||
                const index = this.staleObjects.indexOf(combinedKey);
 | 
			
		||||
                const foundStaleObject = index > -1;
 | 
			
		||||
                if (stalenessResponse.isStale && !foundStaleObject) {
 | 
			
		||||
                    this.staleObjects.push(combinedKey);
 | 
			
		||||
                } else if (!stalenessResponse.isStale && foundStaleObject) {
 | 
			
		||||
                    this.staleObjects.splice(index, 1);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
 
 | 
			
		||||
@@ -232,10 +232,12 @@ export default {
 | 
			
		||||
            this.stalenessSubscription[keyString].stalenessUtils = new StalenessUtils(this.openmct, domainObject);
 | 
			
		||||
 | 
			
		||||
            this.openmct.telemetry.isStale(domainObject).then((stalenessResponse) => {
 | 
			
		||||
                this.hanldeStaleness(keyString, stalenessResponse);
 | 
			
		||||
                if (stalenessResponse !== undefined) {
 | 
			
		||||
                    this.handleStaleness(keyString, stalenessResponse);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
            const stalenessSubscription = this.openmct.telemetry.subscribeToStaleness(domainObject, (stalenessResponse) => {
 | 
			
		||||
                this.hanldeStaleness(keyString, stalenessResponse);
 | 
			
		||||
                this.handleStaleness(keyString, stalenessResponse);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.stalenessSubscription[keyString].unsubscribe = stalenessSubscription;
 | 
			
		||||
@@ -259,9 +261,10 @@ export default {
 | 
			
		||||
                    keyString,
 | 
			
		||||
                    isStale: false
 | 
			
		||||
                });
 | 
			
		||||
                delete this.stalenessSubscription[keyString];
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        hanldeStaleness(keyString, stalenessResponse) {
 | 
			
		||||
        handleStaleness(keyString, stalenessResponse) {
 | 
			
		||||
            if (this.stalenessSubscription[keyString].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) {
 | 
			
		||||
                this.emitStaleness({
 | 
			
		||||
                    keyString,
 | 
			
		||||
 
 | 
			
		||||
@@ -83,6 +83,11 @@ export default class AllTelemetryCriterion extends TelemetryCriterion {
 | 
			
		||||
            if (!this.stalenessSubscription[id]) {
 | 
			
		||||
                this.stalenessSubscription[id] = {};
 | 
			
		||||
                this.stalenessSubscription[id].stalenessUtils = new StalenessUtils(this.openmct, telemetryObject);
 | 
			
		||||
                this.openmct.telemetry.isStale(telemetryObject).then((stalenessResponse) => {
 | 
			
		||||
                    if (stalenessResponse !== undefined) {
 | 
			
		||||
                        this.handleStaleTelemetry(id, stalenessResponse);
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
                this.stalenessSubscription[id].unsubscribe = this.openmct.telemetry.subscribeToStaleness(
 | 
			
		||||
                    telemetryObject,
 | 
			
		||||
                    (stalenessResponse) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@ export default class CreateAction extends PropertiesAction {
 | 
			
		||||
            _.set(this.domainObject, key, value);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const parentDomainObject = parentDomainObjectPath[0];
 | 
			
		||||
        const parentDomainObject = this.openmct.objects.toMutable(parentDomainObjectPath[0]);
 | 
			
		||||
 | 
			
		||||
        this.domainObject.modified = Date.now();
 | 
			
		||||
        this.domainObject.location = this.openmct.objects.makeKeyString(parentDomainObject.identifier);
 | 
			
		||||
@@ -85,6 +85,7 @@ export default class CreateAction extends PropertiesAction {
 | 
			
		||||
            console.error(err);
 | 
			
		||||
            this.openmct.notifications.error(`Error saving objects: ${err}`);
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.openmct.objects.destroyMutable(parentDomainObject);
 | 
			
		||||
            dialog.dismiss();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -142,18 +143,21 @@ export default class CreateAction extends PropertiesAction {
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        this.domainObject = domainObject;
 | 
			
		||||
        this.domainObject = this.openmct.objects.toMutable(domainObject);
 | 
			
		||||
 | 
			
		||||
        if (definition.initialize) {
 | 
			
		||||
            definition.initialize(domainObject);
 | 
			
		||||
            definition.initialize(this.domainObject);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const createWizard = new CreateWizard(this.openmct, domainObject, this.parentDomainObject);
 | 
			
		||||
        const createWizard = new CreateWizard(this.openmct, this.domainObject, this.parentDomainObject);
 | 
			
		||||
        const formStructure = createWizard.getFormStructure(true);
 | 
			
		||||
        formStructure.title = 'Create a New ' + definition.name;
 | 
			
		||||
 | 
			
		||||
        this.openmct.forms.showForm(formStructure)
 | 
			
		||||
            .then(this._onSave.bind(this))
 | 
			
		||||
            .catch(this._onCancel.bind(this));
 | 
			
		||||
            .catch(this._onCancel.bind(this))
 | 
			
		||||
            .finally(() => {
 | 
			
		||||
                this.openmct.objects.destroyMutable(this.domainObject);
 | 
			
		||||
            });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -26,18 +26,23 @@
 | 
			
		||||
    :style="`width: 100%; height: 100%`"
 | 
			
		||||
>
 | 
			
		||||
    <CompassHUD
 | 
			
		||||
        v-if="showCompassHUD"
 | 
			
		||||
        :sun-heading="sunHeading"
 | 
			
		||||
        :camera-angle-of-view="cameraAngleOfView"
 | 
			
		||||
        :camera-pan="cameraPan"
 | 
			
		||||
        :heading="heading"
 | 
			
		||||
        :camera-azimuth="cameraAzimuth"
 | 
			
		||||
        :transformations="transformations"
 | 
			
		||||
        :has-gimble="hasGimble"
 | 
			
		||||
        :normalized-camera-azimuth="normalizedCameraAzimuth"
 | 
			
		||||
        :sun-heading="sunHeading"
 | 
			
		||||
    />
 | 
			
		||||
    <CompassRose
 | 
			
		||||
        v-if="showCompassRose"
 | 
			
		||||
        :camera-pan="cameraPan"
 | 
			
		||||
        :camera-angle-of-view="cameraAngleOfView"
 | 
			
		||||
        :heading="heading"
 | 
			
		||||
        :sized-image-dimensions="sizedImageDimensions"
 | 
			
		||||
        :sun-heading="sunHeading"
 | 
			
		||||
        :camera-azimuth="cameraAzimuth"
 | 
			
		||||
        :transformations="transformations"
 | 
			
		||||
        :has-gimble="hasGimble"
 | 
			
		||||
        :normalized-camera-azimuth="normalizedCameraAzimuth"
 | 
			
		||||
        :sun-heading="sunHeading"
 | 
			
		||||
        :sized-image-dimensions="sizedImageDimensions"
 | 
			
		||||
    />
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -45,6 +50,7 @@
 | 
			
		||||
<script>
 | 
			
		||||
import CompassHUD from './CompassHUD.vue';
 | 
			
		||||
import CompassRose from './CompassRose.vue';
 | 
			
		||||
import { rotate } from './utils';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    components: {
 | 
			
		||||
@@ -62,11 +68,14 @@ export default {
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
        showCompassHUD() {
 | 
			
		||||
            return this.hasCameraPan && this.cameraAngleOfView > 0;
 | 
			
		||||
        hasGimble() {
 | 
			
		||||
            return this.cameraAzimuth !== undefined;
 | 
			
		||||
        },
 | 
			
		||||
        showCompassRose() {
 | 
			
		||||
            return (this.hasCameraPan || this.hasHeading) && this.cameraAngleOfView > 0;
 | 
			
		||||
        // compass ordinal orientation of camera
 | 
			
		||||
        normalizedCameraAzimuth() {
 | 
			
		||||
            return this.hasGimble
 | 
			
		||||
                ? rotate(this.cameraAzimuth)
 | 
			
		||||
                : rotate(this.heading, -this.transformations.rotation || 0);
 | 
			
		||||
        },
 | 
			
		||||
        // horizontal rotation from north in degrees
 | 
			
		||||
        heading() {
 | 
			
		||||
@@ -80,14 +89,11 @@ export default {
 | 
			
		||||
            return this.image.sunOrientation;
 | 
			
		||||
        },
 | 
			
		||||
        // horizontal rotation from north in degrees
 | 
			
		||||
        cameraPan() {
 | 
			
		||||
        cameraAzimuth() {
 | 
			
		||||
            return this.image.cameraPan;
 | 
			
		||||
        },
 | 
			
		||||
        hasCameraPan() {
 | 
			
		||||
            return this.cameraPan !== undefined;
 | 
			
		||||
        },
 | 
			
		||||
        cameraAngleOfView() {
 | 
			
		||||
            return this.transformations?.cameraAngleOfView;
 | 
			
		||||
            return this.transformations.cameraAngleOfView;
 | 
			
		||||
        },
 | 
			
		||||
        transformations() {
 | 
			
		||||
            return this.image.transformations;
 | 
			
		||||
 
 | 
			
		||||
@@ -94,17 +94,33 @@ const COMPASS_POINTS = [
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    props: {
 | 
			
		||||
        cameraAngleOfView: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        heading: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        cameraAzimuth: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            default: undefined
 | 
			
		||||
        },
 | 
			
		||||
        transformations: {
 | 
			
		||||
            type: Object,
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        hasGimble: {
 | 
			
		||||
            type: Boolean,
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        normalizedCameraAzimuth: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        sunHeading: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            default: undefined
 | 
			
		||||
        },
 | 
			
		||||
        cameraAngleOfView: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            default: undefined
 | 
			
		||||
        },
 | 
			
		||||
        cameraPan: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            required: true
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
@@ -130,10 +146,13 @@ export default {
 | 
			
		||||
                left: `${ percentage * 100 }%`
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        cameraRotation() {
 | 
			
		||||
            return this.transformations?.rotation;
 | 
			
		||||
        },
 | 
			
		||||
        visibleRange() {
 | 
			
		||||
            return [
 | 
			
		||||
                rotate(this.cameraPan, -this.cameraAngleOfView / 2),
 | 
			
		||||
                rotate(this.cameraPan, this.cameraAngleOfView / 2)
 | 
			
		||||
                rotate(this.normalizedCameraAzimuth, -this.cameraAngleOfView / 2),
 | 
			
		||||
                rotate(this.normalizedCameraAzimuth, this.cameraAngleOfView / 2)
 | 
			
		||||
            ];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -75,7 +75,6 @@
 | 
			
		||||
                    :style="sunHeadingStyle"
 | 
			
		||||
                />
 | 
			
		||||
 | 
			
		||||
                <!-- Camera FOV -->
 | 
			
		||||
                <mask
 | 
			
		||||
                    id="mask2"
 | 
			
		||||
                    class="c-cr__cam-fov-l-mask"
 | 
			
		||||
@@ -107,55 +106,61 @@
 | 
			
		||||
                        height="100"
 | 
			
		||||
                    />
 | 
			
		||||
                </mask>
 | 
			
		||||
 | 
			
		||||
                <!-- Equipment (spacecraft) body holder. Transforms relative to the camera position. -->
 | 
			
		||||
                <g
 | 
			
		||||
                    v-if="hasHeading"
 | 
			
		||||
                    class="cr-vrover"
 | 
			
		||||
                    :style="camAngleAndPositionStyle"
 | 
			
		||||
                >
 | 
			
		||||
                    <!-- Equipment body. Rotates relative to the camera gimbal value for cams that gimbal. -->
 | 
			
		||||
                    <path
 | 
			
		||||
                        class="cr-vrover__body"
 | 
			
		||||
                        :style="camGimbalAngleStyle"
 | 
			
		||||
                        fill-rule="evenodd"
 | 
			
		||||
                        clip-rule="evenodd"
 | 
			
		||||
                        d="M5 0C2.23858 0 0 2.23858 0 5V95C0 97.7614 2.23858 100 5 100H95C97.7614 100 100 97.7614 100 95V5C100 2.23858 97.7614 0 95 0H5ZM85 59L50 24L15 59H33V75H67.0455V59H85Z"
 | 
			
		||||
                    />
 | 
			
		||||
                </g>
 | 
			
		||||
 | 
			
		||||
                <g
 | 
			
		||||
                    class="c-cr__cam-fov"
 | 
			
		||||
                    class="c-cr-cam-and-body"
 | 
			
		||||
                    :style="cameraHeadingStyle"
 | 
			
		||||
                >
 | 
			
		||||
                    <g mask="url(#mask2)">
 | 
			
		||||
                        <rect
 | 
			
		||||
                            class="c-cr__cam-fov-r"
 | 
			
		||||
                            x="49"
 | 
			
		||||
                            width="51"
 | 
			
		||||
                            height="100"
 | 
			
		||||
                            :style="cameraFOVStyleRightHalf"
 | 
			
		||||
                    <!-- Equipment (spacecraft) body holder. Transforms relative to the camera position. -->
 | 
			
		||||
                    <g
 | 
			
		||||
                        v-if="hasHeading"
 | 
			
		||||
                        class="cr-vrover"
 | 
			
		||||
                        :style="camAngleAndPositionStyle"
 | 
			
		||||
                    >
 | 
			
		||||
                        <!-- Equipment body. Rotates relative to the camera pan value for cameras that gimble. -->
 | 
			
		||||
                        <path
 | 
			
		||||
                            class="cr-vrover__body"
 | 
			
		||||
                            :style="gimbledCameraPanStyle"
 | 
			
		||||
                            x
 | 
			
		||||
                            fill-rule="evenodd"
 | 
			
		||||
                            clip-rule="evenodd"
 | 
			
		||||
                            d="M5 0C2.23858 0 0 2.23858 0 5V95C0 97.7614 2.23858 100 5 100H95C97.7614 100 100 97.7614 100 95V5C100 2.23858 97.7614 0 95 0H5ZM85 59L50 24L15 59H33V75H67.0455V59H85Z"
 | 
			
		||||
                        />
 | 
			
		||||
                    </g>
 | 
			
		||||
                    <g mask="url(#mask1)">
 | 
			
		||||
                        <rect
 | 
			
		||||
                            class="c-cr__cam-fov-l"
 | 
			
		||||
                            width="51"
 | 
			
		||||
                            height="100"
 | 
			
		||||
                            :style="cameraFOVStyleLeftHalf"
 | 
			
		||||
 | 
			
		||||
                    <!-- Camera FOV -->
 | 
			
		||||
                    <g
 | 
			
		||||
                        class="c-cr__cam-fov"
 | 
			
		||||
                    >
 | 
			
		||||
                        <g mask="url(#mask2)">
 | 
			
		||||
                            <rect
 | 
			
		||||
                                class="c-cr__cam-fov-r"
 | 
			
		||||
                                x="49"
 | 
			
		||||
                                width="51"
 | 
			
		||||
                                height="100"
 | 
			
		||||
                                :style="cameraFOVStyleRightHalf"
 | 
			
		||||
                            />
 | 
			
		||||
                        </g>
 | 
			
		||||
                        <g mask="url(#mask1)">
 | 
			
		||||
                            <rect
 | 
			
		||||
                                class="c-cr__cam-fov-l"
 | 
			
		||||
                                width="51"
 | 
			
		||||
                                height="100"
 | 
			
		||||
                                :style="cameraFOVStyleLeftHalf"
 | 
			
		||||
                            />
 | 
			
		||||
                        </g>
 | 
			
		||||
                        <polygon
 | 
			
		||||
                            class="c-cr__cam"
 | 
			
		||||
                            points="0,0 100,0 70,40 70,100 30,100 30,40"
 | 
			
		||||
                        />
 | 
			
		||||
                    </g>
 | 
			
		||||
                    <polygon
 | 
			
		||||
                        class="c-cr__cam"
 | 
			
		||||
                        points="0,0 100,0 70,40 70,100 30,100 30,40"
 | 
			
		||||
                    />
 | 
			
		||||
 | 
			
		||||
                </g>
 | 
			
		||||
            </g>
 | 
			
		||||
 | 
			
		||||
            <!-- NSEW and ticks -->
 | 
			
		||||
            <g
 | 
			
		||||
                class="c-cr__nsew"
 | 
			
		||||
                :style="compassRoseStyle"
 | 
			
		||||
                :style="compassDialStyle"
 | 
			
		||||
            >
 | 
			
		||||
                <g class="c-cr__ticks-major">
 | 
			
		||||
                    <path d="M50 3L43 10H57L50 3Z" />
 | 
			
		||||
@@ -254,23 +259,32 @@ import { throttle } from 'lodash';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    props: {
 | 
			
		||||
        cameraAngleOfView: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        heading: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            required: true,
 | 
			
		||||
            default() {
 | 
			
		||||
                return 0;
 | 
			
		||||
            }
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        sunHeading: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            default: undefined
 | 
			
		||||
        },
 | 
			
		||||
        cameraPan: {
 | 
			
		||||
        cameraAzimuth: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            default: undefined
 | 
			
		||||
        },
 | 
			
		||||
        transformations: {
 | 
			
		||||
            type: Object,
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        hasGimble: {
 | 
			
		||||
            type: Boolean,
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        normalizedCameraAzimuth: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        sunHeading: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            default: undefined
 | 
			
		||||
        },
 | 
			
		||||
        sizedImageDimensions: {
 | 
			
		||||
@@ -284,18 +298,6 @@ export default {
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
        cameraHeading() {
 | 
			
		||||
            return this.cameraPan ?? this.heading;
 | 
			
		||||
        },
 | 
			
		||||
        cameraAngleOfView() {
 | 
			
		||||
            const cameraAngleOfView = this.transformations?.cameraAngleOfView;
 | 
			
		||||
 | 
			
		||||
            if (!cameraAngleOfView) {
 | 
			
		||||
                console.warn('No Camera Angle of View provided');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return cameraAngleOfView;
 | 
			
		||||
        },
 | 
			
		||||
        camAngleAndPositionStyle() {
 | 
			
		||||
            const translateX = this.transformations?.translateX;
 | 
			
		||||
            const translateY = this.transformations?.translateY;
 | 
			
		||||
@@ -304,18 +306,22 @@ export default {
 | 
			
		||||
 | 
			
		||||
            return { transform: `translate(${translateX}%, ${translateY}%) rotate(${rotation}deg) scale(${scale})` };
 | 
			
		||||
        },
 | 
			
		||||
        camGimbalAngleStyle() {
 | 
			
		||||
            const rotation = rotate(this.north, this.heading);
 | 
			
		||||
        gimbledCameraPanStyle() {
 | 
			
		||||
            if (!this.hasGimble) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const gimbledCameraPan = rotate(this.normalizedCameraAzimuth, -this.heading);
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                transform: `rotate(${ rotation }deg)`
 | 
			
		||||
                transform: `rotate(${ -gimbledCameraPan }deg)`
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        compassRoseStyle() {
 | 
			
		||||
        compassDialStyle() {
 | 
			
		||||
            return { transform: `rotate(${ this.north }deg)` };
 | 
			
		||||
        },
 | 
			
		||||
        north() {
 | 
			
		||||
            return this.lockCompass ? rotate(-this.cameraHeading) : 0;
 | 
			
		||||
            return this.lockCompass ? rotate(-this.normalizedCameraAzimuth) : 0;
 | 
			
		||||
        },
 | 
			
		||||
        cardinalTextRotateN() {
 | 
			
		||||
            return { transform: `translateY(-27%) rotate(${ -this.north }deg)` };
 | 
			
		||||
@@ -332,14 +338,6 @@ export default {
 | 
			
		||||
        hasHeading() {
 | 
			
		||||
            return this.heading !== undefined;
 | 
			
		||||
        },
 | 
			
		||||
        headingStyle() {
 | 
			
		||||
            /* Replaced with computed camGimbalStyle, but left here just in case. */
 | 
			
		||||
            const rotation = rotate(this.north, this.heading);
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                transform: `rotate(${ rotation }deg)`
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        hasSunHeading() {
 | 
			
		||||
            return this.sunHeading !== undefined;
 | 
			
		||||
        },
 | 
			
		||||
@@ -351,7 +349,7 @@ export default {
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        cameraHeadingStyle() {
 | 
			
		||||
            const rotation = rotate(this.north, this.cameraHeading);
 | 
			
		||||
            const rotation = rotate(this.north, this.normalizedCameraAzimuth);
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                transform: `rotate(${ rotation }deg)`
 | 
			
		||||
 
 | 
			
		||||
@@ -35,8 +35,15 @@ describe("The Compass component", () => {
 | 
			
		||||
            roll: 90,
 | 
			
		||||
            pitch: 90,
 | 
			
		||||
            cameraTilt: 100,
 | 
			
		||||
            cameraPan: 90,
 | 
			
		||||
            sunAngle: 30
 | 
			
		||||
            cameraAzimuth: 90,
 | 
			
		||||
            sunAngle: 30,
 | 
			
		||||
            transformations: {
 | 
			
		||||
                translateX: 0,
 | 
			
		||||
                translateY: 18,
 | 
			
		||||
                rotation: 0,
 | 
			
		||||
                scale: 0.3,
 | 
			
		||||
                cameraAngleOfView: 70
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        let propsData = {
 | 
			
		||||
            naturalAspectRatio: 0.9,
 | 
			
		||||
@@ -44,8 +51,7 @@ describe("The Compass component", () => {
 | 
			
		||||
            sizedImageDimensions: {
 | 
			
		||||
                width: 100,
 | 
			
		||||
                height: 100
 | 
			
		||||
            },
 | 
			
		||||
            compassRoseSizingClasses: '--rose-small --rose-min'
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        app = new Vue({
 | 
			
		||||
@@ -54,7 +60,6 @@ describe("The Compass component", () => {
 | 
			
		||||
                return propsData;
 | 
			
		||||
            },
 | 
			
		||||
            template: `<Compass
 | 
			
		||||
                :compass-rose-sizing-classes="compassRoseSizingClasses"
 | 
			
		||||
                :image="image"
 | 
			
		||||
                :natural-aspect-ratio="naturalAspectRatio"
 | 
			
		||||
                :sized-image-dimensions="sizedImageDimensions"
 | 
			
		||||
@@ -67,7 +72,7 @@ describe("The Compass component", () => {
 | 
			
		||||
        app.$destroy();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    describe("when a heading value exists on the image", () => {
 | 
			
		||||
    describe("when a heading value and cameraAngleOfView exists on the image", () => {
 | 
			
		||||
 | 
			
		||||
        it("should display a compass rose", () => {
 | 
			
		||||
            let compassRoseElement = instance.$el.querySelector(COMPASS_ROSE_CLASS
 | 
			
		||||
 
 | 
			
		||||
@@ -94,7 +94,6 @@
 | 
			
		||||
                <Compass
 | 
			
		||||
                    v-if="shouldDisplayCompass"
 | 
			
		||||
                    :image="focusedImage"
 | 
			
		||||
                    :natural-aspect-ratio="focusedImageNaturalAspectRatio"
 | 
			
		||||
                    :sized-image-dimensions="sizedImageDimensions"
 | 
			
		||||
                />
 | 
			
		||||
            </div>
 | 
			
		||||
@@ -171,7 +170,7 @@
 | 
			
		||||
        >
 | 
			
		||||
            <ImageThumbnail
 | 
			
		||||
                v-for="(image, index) in imageHistory"
 | 
			
		||||
                :key="`${image.thumbnailUrl || image.url}${image.time}`"
 | 
			
		||||
                :key="`${image.thumbnailUrl || image.url}-${image.time}-${index}`"
 | 
			
		||||
                :image="image"
 | 
			
		||||
                :active="focusedImageIndex === index"
 | 
			
		||||
                :selected="focusedImageIndex === index && isPaused"
 | 
			
		||||
@@ -430,9 +429,12 @@ export default {
 | 
			
		||||
                && imageHeightAndWidth
 | 
			
		||||
                && this.zoomFactor === 1
 | 
			
		||||
                && this.imagePanned !== true;
 | 
			
		||||
            const hasCameraConfigurations = this.focusedImage?.transformations !== undefined;
 | 
			
		||||
            const hasHeading = this.focusedImage?.heading !== undefined;
 | 
			
		||||
            const hasCameraAngleOfView = this.focusedImage?.transformations?.cameraAngleOfView > 0;
 | 
			
		||||
 | 
			
		||||
            return display && hasCameraConfigurations;
 | 
			
		||||
            return display
 | 
			
		||||
                && hasCameraAngleOfView
 | 
			
		||||
                && hasHeading;
 | 
			
		||||
        },
 | 
			
		||||
        isSpacecraftPositionFresh() {
 | 
			
		||||
            let isFresh = undefined;
 | 
			
		||||
@@ -582,11 +584,34 @@ export default {
 | 
			
		||||
            },
 | 
			
		||||
            deep: true
 | 
			
		||||
        },
 | 
			
		||||
        focusedImageIndex() {
 | 
			
		||||
            this.trackDuration();
 | 
			
		||||
            this.resetAgeCSS();
 | 
			
		||||
            this.updateRelatedTelemetryForFocusedImage();
 | 
			
		||||
            this.getImageNaturalDimensions();
 | 
			
		||||
        focusedImage: {
 | 
			
		||||
            handler(newImage, oldImage) {
 | 
			
		||||
                const newTime = newImage?.time;
 | 
			
		||||
                const oldTime = oldImage?.time;
 | 
			
		||||
                const newUrl = newImage?.url;
 | 
			
		||||
                const oldUrl = oldImage?.url;
 | 
			
		||||
 | 
			
		||||
                // Skip if it's all falsy
 | 
			
		||||
                if (!newTime && !oldTime && !newUrl && !oldUrl) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Skip if it's the same image
 | 
			
		||||
                if (newTime === oldTime && newUrl === oldUrl) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Update image duration and reset age CSS
 | 
			
		||||
                this.trackDuration();
 | 
			
		||||
                this.resetAgeCSS();
 | 
			
		||||
 | 
			
		||||
                // Reset image dimensions and calculate new dimensions
 | 
			
		||||
                // on new image load
 | 
			
		||||
                this.getImageNaturalDimensions();
 | 
			
		||||
 | 
			
		||||
                // Get the related telemetry for the new image
 | 
			
		||||
                this.updateRelatedTelemetryForFocusedImage();
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        bounds() {
 | 
			
		||||
            this.scrollHandler();
 | 
			
		||||
@@ -771,6 +796,10 @@ export default {
 | 
			
		||||
            this.layers = layersMetadata;
 | 
			
		||||
            if (this.domainObject.configuration) {
 | 
			
		||||
                const persistedLayers = this.domainObject.configuration.layers;
 | 
			
		||||
                if (!persistedLayers) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                layersMetadata.forEach((layer) => {
 | 
			
		||||
                    const persistedLayer = persistedLayers.find(object => object.name === layer.name);
 | 
			
		||||
                    if (persistedLayer) {
 | 
			
		||||
 
 | 
			
		||||
@@ -76,9 +76,14 @@ export default {
 | 
			
		||||
        this.telemetryCollection.destroy();
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        dataAdded(dataToAdd) {
 | 
			
		||||
            const normalizedDataToAdd = dataToAdd.map(datum => this.normalizeDatum(datum));
 | 
			
		||||
            this.imageHistory = this.imageHistory.concat(normalizedDataToAdd);
 | 
			
		||||
        dataAdded(addedItems, addedItemIndices) {
 | 
			
		||||
            const normalizedDataToAdd = addedItems.map(datum => this.normalizeDatum(datum));
 | 
			
		||||
            let newImageHistory = this.imageHistory.slice();
 | 
			
		||||
            normalizedDataToAdd.forEach(((datum, index) => {
 | 
			
		||||
                newImageHistory.splice(addedItemIndices[index] ?? -1, 0, datum);
 | 
			
		||||
            }));
 | 
			
		||||
            //Assign just once so imageHistory watchers don't get called too often
 | 
			
		||||
            this.imageHistory = newImageHistory;
 | 
			
		||||
        },
 | 
			
		||||
        dataCleared() {
 | 
			
		||||
            this.imageHistory = [];
 | 
			
		||||
@@ -153,9 +158,6 @@ export default {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // forcibly reset the imageContainer size to prevent an aspect ratio distortion
 | 
			
		||||
            delete this.imageContainerWidth;
 | 
			
		||||
            delete this.imageContainerHeight;
 | 
			
		||||
            this.bounds = bounds; // setting bounds for ImageryView watcher
 | 
			
		||||
        },
 | 
			
		||||
        timeSystemChange() {
 | 
			
		||||
 
 | 
			
		||||
@@ -84,6 +84,8 @@ export default class MoveAction {
 | 
			
		||||
        this.addToNewParent(this.object, parent);
 | 
			
		||||
        this.removeFromOldParent(this.object);
 | 
			
		||||
 | 
			
		||||
        await this.saveTransaction();
 | 
			
		||||
 | 
			
		||||
        if (!inNavigationPath) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
@@ -102,8 +104,6 @@ export default class MoveAction {
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.saveTransaction();
 | 
			
		||||
 | 
			
		||||
        this.navigateTo(newObjectPath);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -381,7 +381,7 @@ export default {
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        updateSelection(selection) {
 | 
			
		||||
            if (selection?.[0]?.[1]?.context?.targetDetails?.entryId === undefined) {
 | 
			
		||||
            if (selection?.[0]?.[0]?.context?.targetDetails?.entryId === undefined) {
 | 
			
		||||
                this.selectedEntryId = '';
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
 
 | 
			
		||||
@@ -64,7 +64,7 @@
 | 
			
		||||
                    tabindex="0"
 | 
			
		||||
                >
 | 
			
		||||
                    <TextHighlight
 | 
			
		||||
                        :text="entryText"
 | 
			
		||||
                        :text="formatValidUrls(entry.text)"
 | 
			
		||||
                        :highlight="highlightText"
 | 
			
		||||
                        :highlight-class="'search-highlight'"
 | 
			
		||||
                    />
 | 
			
		||||
@@ -77,13 +77,13 @@
 | 
			
		||||
                    aria-label="Notebook Entry Input"
 | 
			
		||||
                    tabindex="0"
 | 
			
		||||
                    :contenteditable="canEdit"
 | 
			
		||||
                    v-bind.prop="formattedText"
 | 
			
		||||
                    @mouseover="checkEditability($event)"
 | 
			
		||||
                    @mouseleave="canEdit = true"
 | 
			
		||||
                    @focus="editingEntry()"
 | 
			
		||||
                    @blur="updateEntryValue($event)"
 | 
			
		||||
                    @keydown.enter.exact.prevent
 | 
			
		||||
                    @keyup.enter.exact.prevent="forceBlur($event)"
 | 
			
		||||
                    v-html="formattedText"
 | 
			
		||||
                >
 | 
			
		||||
                </div>
 | 
			
		||||
            </template>
 | 
			
		||||
@@ -94,12 +94,12 @@
 | 
			
		||||
                    class="c-ne__text"
 | 
			
		||||
                    contenteditable="false"
 | 
			
		||||
                    tabindex="0"
 | 
			
		||||
                    v-html="formattedText"
 | 
			
		||||
                    v-bind.prop="formattedText"
 | 
			
		||||
                >
 | 
			
		||||
                </div>
 | 
			
		||||
            </template>
 | 
			
		||||
 | 
			
		||||
            <div>
 | 
			
		||||
            <div class="c-ne__tags c-tag-holder">
 | 
			
		||||
                <div
 | 
			
		||||
                    v-for="(tag, index) in entryTags"
 | 
			
		||||
                    :key="index"
 | 
			
		||||
@@ -228,14 +228,17 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
        selectedEntryId: {
 | 
			
		||||
            type: String,
 | 
			
		||||
            required: true
 | 
			
		||||
            default() {
 | 
			
		||||
                return '';
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    data() {
 | 
			
		||||
        return {
 | 
			
		||||
            editMode: false,
 | 
			
		||||
            canEdit: true,
 | 
			
		||||
            enableEmbedsWrapperScroll: false
 | 
			
		||||
            enableEmbedsWrapperScroll: false,
 | 
			
		||||
            urlWhitelist: []
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
@@ -247,28 +250,15 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
        formattedText() {
 | 
			
		||||
            // remove ANY tags
 | 
			
		||||
            let text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA);
 | 
			
		||||
            const text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA);
 | 
			
		||||
 | 
			
		||||
            if (this.editMode || !this.urlWhitelist) {
 | 
			
		||||
                return text;
 | 
			
		||||
            if (this.editMode || this.urlWhitelist.length === 0) {
 | 
			
		||||
                return { innerText: text };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            text = text.replace(URL_REGEX, (match) => {
 | 
			
		||||
                const url = new URL(match);
 | 
			
		||||
                const domain = url.hostname;
 | 
			
		||||
                let result = match;
 | 
			
		||||
                let isMatch = this.urlWhitelist.find((partialDomain) => {
 | 
			
		||||
                    return domain.endsWith(partialDomain);
 | 
			
		||||
                });
 | 
			
		||||
            const html = this.formatValidUrls(text);
 | 
			
		||||
 | 
			
		||||
                if (isMatch) {
 | 
			
		||||
                    result = `<a class="c-hyperlink" target="_blank" href="${match}">${match}</a>`;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                return result;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            return text;
 | 
			
		||||
            return { innerHTML: html };
 | 
			
		||||
        },
 | 
			
		||||
        isSelectedEntry() {
 | 
			
		||||
            return this.selectedEntryId === this.entry.id;
 | 
			
		||||
@@ -354,6 +344,22 @@ export default {
 | 
			
		||||
        deleteEntry() {
 | 
			
		||||
            this.$emit('deleteEntry', this.entry.id);
 | 
			
		||||
        },
 | 
			
		||||
        formatValidUrls(text) {
 | 
			
		||||
            return text.replace(URL_REGEX, (match) => {
 | 
			
		||||
                const url = new URL(match);
 | 
			
		||||
                const domain = url.hostname;
 | 
			
		||||
                let result = match;
 | 
			
		||||
                let isMatch = this.urlWhitelist.find((partialDomain) => {
 | 
			
		||||
                    return domain.endsWith(partialDomain);
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                if (isMatch) {
 | 
			
		||||
                    result = `<a class="c-hyperlink" target="_blank" href="${match}">${match}</a>`;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                return result;
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        manageEmbedLayout() {
 | 
			
		||||
            if (this.$refs.embeds) {
 | 
			
		||||
                const embedsWrapperLength = this.$refs.embedsWrapper.clientWidth;
 | 
			
		||||
@@ -456,7 +462,7 @@ export default {
 | 
			
		||||
            this.editMode = false;
 | 
			
		||||
            const value = $event.target.innerText;
 | 
			
		||||
            if (value !== this.entry.text && value.match(/\S/)) {
 | 
			
		||||
                this.entry.text = value;
 | 
			
		||||
                this.entry.text = sanitizeHtml(value, SANITIZATION_SCHEMA);
 | 
			
		||||
                this.timestampAndUpdate();
 | 
			
		||||
            } else {
 | 
			
		||||
                this.$emit('cancelEdit');
 | 
			
		||||
@@ -472,16 +478,11 @@ export default {
 | 
			
		||||
            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',
 | 
			
		||||
                            item: this.domainObject,
 | 
			
		||||
                            targetDetails,
 | 
			
		||||
                            targetDomainObjects,
 | 
			
		||||
                            annotations: this.notebookAnnotations,
 | 
			
		||||
 
 | 
			
		||||
@@ -6,8 +6,7 @@ export const NOTEBOOK_DEFAULT = 'DEFAULT';
 | 
			
		||||
export const NOTEBOOK_SNAPSHOT = 'SNAPSHOT';
 | 
			
		||||
export const NOTEBOOK_VIEW_TYPE = 'notebook-vue';
 | 
			
		||||
export const RESTRICTED_NOTEBOOK_VIEW_TYPE = 'restricted-notebook-vue';
 | 
			
		||||
export const NOTEBOOK_INSTALLED_KEY = '_NOTEBOOK_PLUGIN_INSTALLED';
 | 
			
		||||
export const RESTRICTED_NOTEBOOK_INSTALLED_KEY = '_RESTRICTED_NOTEBOOK_PLUGIN_INSTALLED';
 | 
			
		||||
export const NOTEBOOK_BASE_INSTALLED = '_NOTEBOOK_BASE_FUNCTIONALITY_INSTALLED';
 | 
			
		||||
 | 
			
		||||
// these only deals with constants, figured this could skip going into a utils file
 | 
			
		||||
export function isNotebookOrAnnotationType(domainObject) {
 | 
			
		||||
 
 | 
			
		||||
@@ -33,8 +33,7 @@ import {
 | 
			
		||||
    RESTRICTED_NOTEBOOK_TYPE,
 | 
			
		||||
    NOTEBOOK_VIEW_TYPE,
 | 
			
		||||
    RESTRICTED_NOTEBOOK_VIEW_TYPE,
 | 
			
		||||
    NOTEBOOK_INSTALLED_KEY,
 | 
			
		||||
    RESTRICTED_NOTEBOOK_INSTALLED_KEY
 | 
			
		||||
    NOTEBOOK_BASE_INSTALLED
 | 
			
		||||
} from './notebook-constants';
 | 
			
		||||
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
@@ -63,7 +62,7 @@ function addLegacyNotebookGetInterceptor(openmct) {
 | 
			
		||||
 | 
			
		||||
function installBaseNotebookFunctionality(openmct) {
 | 
			
		||||
    // only need to do this once
 | 
			
		||||
    if (openmct[NOTEBOOK_INSTALLED_KEY] || openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY]) {
 | 
			
		||||
    if (openmct[NOTEBOOK_BASE_INSTALLED]) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -101,14 +100,12 @@ function installBaseNotebookFunctionality(openmct) {
 | 
			
		||||
    openmct.indicators.add(indicator);
 | 
			
		||||
 | 
			
		||||
    monkeyPatchObjectAPIForNotebooks(openmct);
 | 
			
		||||
 | 
			
		||||
    openmct[NOTEBOOK_BASE_INSTALLED] = true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function NotebookPlugin(name = 'Notebook', entryUrlWhitelist = []) {
 | 
			
		||||
    return function install(openmct) {
 | 
			
		||||
        if (openmct[NOTEBOOK_INSTALLED_KEY]) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const icon = 'icon-notebook';
 | 
			
		||||
        const description = 'Create and save timestamped notes with embedded object snapshots.';
 | 
			
		||||
        const snapshotContainer = getSnapshotContainer(openmct);
 | 
			
		||||
@@ -122,17 +119,11 @@ function NotebookPlugin(name = 'Notebook', entryUrlWhitelist = []) {
 | 
			
		||||
        openmct.objectViews.addProvider(notebookView, entryUrlWhitelist);
 | 
			
		||||
 | 
			
		||||
        installBaseNotebookFunctionality(openmct);
 | 
			
		||||
 | 
			
		||||
        openmct[NOTEBOOK_INSTALLED_KEY] = true;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function RestrictedNotebookPlugin(name = 'Notebook Shift Log', entryUrlWhitelist = []) {
 | 
			
		||||
    return function install(openmct) {
 | 
			
		||||
        if (openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY]) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const icon = 'icon-notebook-shift-log';
 | 
			
		||||
        const description = 'Create and save timestamped notes with embedded object snapshots with the ability to commit and lock pages.';
 | 
			
		||||
        const snapshotContainer = getSnapshotContainer(openmct);
 | 
			
		||||
@@ -144,8 +135,6 @@ function RestrictedNotebookPlugin(name = 'Notebook Shift Log', entryUrlWhitelist
 | 
			
		||||
        openmct.objectViews.addProvider(notebookView, entryUrlWhitelist);
 | 
			
		||||
 | 
			
		||||
        installBaseNotebookFunctionality(openmct);
 | 
			
		||||
 | 
			
		||||
        openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY] = true;
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -23,16 +23,8 @@
 | 
			
		||||
<div
 | 
			
		||||
    v-if="loaded"
 | 
			
		||||
    class="gl-plot"
 | 
			
		||||
    :class="[plotLegendExpandedStateClass, plotLegendPositionClass]"
 | 
			
		||||
>
 | 
			
		||||
    <plot-legend
 | 
			
		||||
        v-if="!isNestedWithinAStackedPlot"
 | 
			
		||||
        :cursor-locked="!!lockHighlightPoint"
 | 
			
		||||
        :series="seriesModels"
 | 
			
		||||
        :highlights="highlights"
 | 
			
		||||
        :legend="legend"
 | 
			
		||||
        @legendHoverChanged="legendHoverChanged"
 | 
			
		||||
    />
 | 
			
		||||
    <slot></slot>
 | 
			
		||||
    <div class="plot-wrapper-axis-and-display-area flex-elem grows">
 | 
			
		||||
        <div
 | 
			
		||||
            v-if="seriesModels.length"
 | 
			
		||||
@@ -42,13 +34,14 @@
 | 
			
		||||
                v-for="(yAxis, index) in yAxesIds"
 | 
			
		||||
                :id="yAxis.id"
 | 
			
		||||
                :key="`yAxis-${yAxis.id}-${index}`"
 | 
			
		||||
                :multiple-left-axes="multipleLeftAxes"
 | 
			
		||||
                :has-multiple-left-axes="hasMultipleLeftAxes"
 | 
			
		||||
                :position="yAxis.id > 2 ? 'right' : 'left'"
 | 
			
		||||
                :class="{'plot-yaxis-right': yAxis.id > 2}"
 | 
			
		||||
                :tick-width="yAxis.tickWidth"
 | 
			
		||||
                :used-tick-width="plotFirstLeftTickWidth"
 | 
			
		||||
                :plot-left-tick-width="yAxis.id > 2 ? yAxis.tickWidth: plotLeftTickWidth"
 | 
			
		||||
                @yKeyChanged="setYAxisKey"
 | 
			
		||||
                @tickWidthChanged="onTickWidthChange"
 | 
			
		||||
                @plotYTickWidth="onYTickWidthChange"
 | 
			
		||||
                @toggleAxisVisibility="toggleSeriesForYAxis"
 | 
			
		||||
            />
 | 
			
		||||
        </div>
 | 
			
		||||
@@ -69,7 +62,6 @@
 | 
			
		||||
                    v-show="gridLines && !options.compact"
 | 
			
		||||
                    :axis-type="'xAxis'"
 | 
			
		||||
                    :position="'right'"
 | 
			
		||||
                    @plotTickWidth="onTickWidthChange"
 | 
			
		||||
                />
 | 
			
		||||
 | 
			
		||||
                <mct-ticks
 | 
			
		||||
@@ -79,7 +71,7 @@
 | 
			
		||||
                    :axis-type="'yAxis'"
 | 
			
		||||
                    :position="'bottom'"
 | 
			
		||||
                    :axis-id="yAxis.id"
 | 
			
		||||
                    @plotTickWidth="onTickWidthChange"
 | 
			
		||||
                    @plotTickWidth="onYTickWidthChange"
 | 
			
		||||
                />
 | 
			
		||||
 | 
			
		||||
                <div
 | 
			
		||||
@@ -94,7 +86,6 @@
 | 
			
		||||
                        :highlights="highlights"
 | 
			
		||||
                        :annotated-points="annotatedPoints"
 | 
			
		||||
                        :annotation-selections="annotationSelections"
 | 
			
		||||
                        :show-limit-line-labels="showLimitLineLabels"
 | 
			
		||||
                        :hidden-y-axis-ids="hiddenYAxisIds"
 | 
			
		||||
                        :annotation-viewing-and-editing-allowed="annotationViewingAndEditingAllowed"
 | 
			
		||||
                        @plotReinitializeCanvas="initCanvas"
 | 
			
		||||
@@ -217,7 +208,6 @@ import LinearScale from "./LinearScale";
 | 
			
		||||
import PlotConfigurationModel from './configuration/PlotConfigurationModel';
 | 
			
		||||
import configStore from './configuration/ConfigStore';
 | 
			
		||||
 | 
			
		||||
import PlotLegend from "./legend/PlotLegend.vue";
 | 
			
		||||
import MctTicks from "./MctTicks.vue";
 | 
			
		||||
import MctChart from "./chart/MctChart.vue";
 | 
			
		||||
import XAxis from "./axis/XAxis.vue";
 | 
			
		||||
@@ -232,7 +222,6 @@ export default {
 | 
			
		||||
    components: {
 | 
			
		||||
        XAxis,
 | 
			
		||||
        YAxis,
 | 
			
		||||
        PlotLegend,
 | 
			
		||||
        MctTicks,
 | 
			
		||||
        MctChart
 | 
			
		||||
    },
 | 
			
		||||
@@ -258,10 +247,14 @@ export default {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        plotTickWidth: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
        parentYTickWidth: {
 | 
			
		||||
            type: Object,
 | 
			
		||||
            default() {
 | 
			
		||||
                return 0;
 | 
			
		||||
                return {
 | 
			
		||||
                    leftTickWidth: 0,
 | 
			
		||||
                    rightTickWidth: 0,
 | 
			
		||||
                    hasMultipleLeftAxes: false
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        limitLineLabels: {
 | 
			
		||||
@@ -296,7 +289,6 @@ export default {
 | 
			
		||||
            isRealTime: this.openmct.time.clock() !== undefined,
 | 
			
		||||
            loaded: false,
 | 
			
		||||
            isTimeOutOfSync: false,
 | 
			
		||||
            showLimitLineLabels: this.limitLineLabels,
 | 
			
		||||
            isFrozenOnMouseDown: false,
 | 
			
		||||
            cursorGuide: this.initCursorGuide,
 | 
			
		||||
            gridLines: this.initGridLines,
 | 
			
		||||
@@ -308,13 +300,14 @@ export default {
 | 
			
		||||
    computed: {
 | 
			
		||||
        xAxisStyle() {
 | 
			
		||||
            const rightAxis = this.yAxesIds.find(yAxis => yAxis.id > 2);
 | 
			
		||||
            const leftOffset = this.multipleLeftAxes ? 2 * AXES_PADDING : AXES_PADDING;
 | 
			
		||||
            const leftOffset = this.hasMultipleLeftAxes ? 2 * AXES_PADDING : AXES_PADDING;
 | 
			
		||||
            let style = {
 | 
			
		||||
                left: `${this.plotLeftTickWidth + leftOffset}px`
 | 
			
		||||
            };
 | 
			
		||||
            const parentRightAxisWidth = this.parentYTickWidth.rightTickWidth;
 | 
			
		||||
 | 
			
		||||
            if (rightAxis) {
 | 
			
		||||
                style.right = `${rightAxis.tickWidth + AXES_PADDING}px`;
 | 
			
		||||
            if (parentRightAxisWidth || rightAxis) {
 | 
			
		||||
                style.right = `${(parentRightAxisWidth || rightAxis.tickWidth) + AXES_PADDING}px`;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return style;
 | 
			
		||||
@@ -322,8 +315,8 @@ export default {
 | 
			
		||||
        yAxesIds() {
 | 
			
		||||
            return this.yAxes.filter(yAxis => yAxis.seriesCount > 0);
 | 
			
		||||
        },
 | 
			
		||||
        multipleLeftAxes() {
 | 
			
		||||
            return this.yAxes.filter(yAxis => yAxis.seriesCount > 0 && yAxis.id <= 2).length > 1;
 | 
			
		||||
        hasMultipleLeftAxes() {
 | 
			
		||||
            return this.parentYTickWidth.hasMultipleLeftAxes || this.yAxes.filter(yAxis => yAxis.seriesCount > 0 && yAxis.id <= 2).length > 1;
 | 
			
		||||
        },
 | 
			
		||||
        isNestedWithinAStackedPlot() {
 | 
			
		||||
            const isNavigatedObject = this.openmct.router.isNavigatedObject([this.domainObject].concat(this.path));
 | 
			
		||||
@@ -334,22 +327,13 @@ export default {
 | 
			
		||||
            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
 | 
			
		||||
        // 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')}` : '';
 | 
			
		||||
        },
 | 
			
		||||
        plotLegendExpandedStateClass() {
 | 
			
		||||
            if (this.isNestedWithinAStackedPlot) {
 | 
			
		||||
                return '';
 | 
			
		||||
            }
 | 
			
		||||
        plotFirstLeftTickWidth() {
 | 
			
		||||
            const firstYAxis = this.yAxes.find(yAxis => yAxis.id === 1);
 | 
			
		||||
 | 
			
		||||
            if (this.config.legend.get('expanded')) {
 | 
			
		||||
                return 'plot-legend-expanded';
 | 
			
		||||
            } else {
 | 
			
		||||
                return 'plot-legend-collapsed';
 | 
			
		||||
            }
 | 
			
		||||
            return firstYAxis ? firstYAxis.tickWidth : 0;
 | 
			
		||||
        },
 | 
			
		||||
        plotLeftTickWidth() {
 | 
			
		||||
            let leftTickWidth = 0;
 | 
			
		||||
@@ -360,17 +344,12 @@ export default {
 | 
			
		||||
 | 
			
		||||
                leftTickWidth = leftTickWidth + yAxis.tickWidth;
 | 
			
		||||
            });
 | 
			
		||||
            const parentLeftTickWidth = this.parentYTickWidth.leftTickWidth;
 | 
			
		||||
 | 
			
		||||
            return this.plotTickWidth || leftTickWidth;
 | 
			
		||||
            return parentLeftTickWidth || leftTickWidth;
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    watch: {
 | 
			
		||||
        limitLineLabels: {
 | 
			
		||||
            handler(limitLineLabels) {
 | 
			
		||||
                this.legendHoverChanged(limitLineLabels);
 | 
			
		||||
            },
 | 
			
		||||
            deep: true
 | 
			
		||||
        },
 | 
			
		||||
        initGridLines(newGridLines) {
 | 
			
		||||
            this.gridLines = newGridLines;
 | 
			
		||||
        },
 | 
			
		||||
@@ -406,8 +385,7 @@ export default {
 | 
			
		||||
            }));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
 | 
			
		||||
        this.$emit('configLoaded', configId);
 | 
			
		||||
        this.$emit('configLoaded', true);
 | 
			
		||||
 | 
			
		||||
        this.listenTo(this.config.series, 'add', this.addSeries, this);
 | 
			
		||||
        this.listenTo(this.config.series, 'remove', this.removeSeries, this);
 | 
			
		||||
@@ -439,15 +417,20 @@ export default {
 | 
			
		||||
    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
 | 
			
		||||
            // on clicking on a search result we highlight the annotation and zoom - we know it's an annotation result when isAnnotationSearchResult === true
 | 
			
		||||
            // We shouldn't zoom when we're selecting existing annotations to view them or creating new annotations.
 | 
			
		||||
            const selectionType = selection?.[0]?.[0]?.context?.type;
 | 
			
		||||
            const validSelectionTypes = ['clicked-on-plot-selection', 'plot-annotation-search-result'];
 | 
			
		||||
            const isAnnotationSearchResult = selectionType === 'plot-annotation-search-result';
 | 
			
		||||
 | 
			
		||||
            if (!validSelectionTypes.includes(selectionType)) {
 | 
			
		||||
                // wrong type of selection
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const selectionType = selection?.[0]?.[1]?.context?.type;
 | 
			
		||||
            if (selectionType !== 'plot-points-selection') {
 | 
			
		||||
                // wrong type of selection
 | 
			
		||||
            if (selectionContext
 | 
			
		||||
                && (!isAnnotationSearchResult)
 | 
			
		||||
                && this.openmct.objects.areIdsEqual(selectionContext.identifier, this.domainObject.identifier)) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@@ -460,7 +443,18 @@ export default {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const selectedAnnotations = selection?.[0]?.[1]?.context?.annotations;
 | 
			
		||||
            const selectedAnnotations = selection?.[0]?.[0]?.context?.annotations;
 | 
			
		||||
            //This section is only for the annotations search results entry to displaying annotations
 | 
			
		||||
            if (isAnnotationSearchResult) {
 | 
			
		||||
                this.showAnnotationsFromSearchResults(selectedAnnotations);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            //This section is common to all entry points for annotation display
 | 
			
		||||
            this.prepareExistingAnnotationSelection(selectedAnnotations);
 | 
			
		||||
        },
 | 
			
		||||
        showAnnotationsFromSearchResults(selectedAnnotations) {
 | 
			
		||||
        //Start section
 | 
			
		||||
 | 
			
		||||
            if (selectedAnnotations?.length) {
 | 
			
		||||
                // just use first annotation
 | 
			
		||||
                const boundingBoxes = Object.values(selectedAnnotations[0].targets);
 | 
			
		||||
@@ -494,10 +488,9 @@ export default {
 | 
			
		||||
                    min: minY,
 | 
			
		||||
                    max: maxY
 | 
			
		||||
                });
 | 
			
		||||
                //Zoom out just a touch so that the highlighted section for annotations doesn't take over the whole view - which is not a nice look.
 | 
			
		||||
                this.zoom('out', 0.2);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.prepareExistingAnnotationSelection(selectedAnnotations);
 | 
			
		||||
        },
 | 
			
		||||
        handleKeyDown(event) {
 | 
			
		||||
            if (event.key === 'Alt') {
 | 
			
		||||
@@ -575,6 +568,14 @@ export default {
 | 
			
		||||
        updateTicksAndSeriesForYAxis(newAxisId, oldAxisId) {
 | 
			
		||||
            this.updateAxisUsageCount(oldAxisId, -1);
 | 
			
		||||
            this.updateAxisUsageCount(newAxisId, 1);
 | 
			
		||||
 | 
			
		||||
            const foundYAxis = this.yAxes.find(yAxis => yAxis.id === oldAxisId);
 | 
			
		||||
            if (foundYAxis.seriesCount === 0) {
 | 
			
		||||
                this.onYTickWidthChange({
 | 
			
		||||
                    width: foundYAxis.tickWidth,
 | 
			
		||||
                    yAxisId: foundYAxis.id
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        updateAxisUsageCount(yAxisId, updateCountBy) {
 | 
			
		||||
@@ -688,9 +689,15 @@ export default {
 | 
			
		||||
                series.reset();
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        shareCommonParent(domainObjectToFind) {
 | 
			
		||||
            return false;
 | 
			
		||||
        },
 | 
			
		||||
        compositionPathContainsId(domainObjectToFind) {
 | 
			
		||||
            if (!domainObjectToFind.composition) {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        compositionPathContainsId(domainObjectToClear) {
 | 
			
		||||
            return domainObjectToClear.composition.some((compositionIdentifier) => {
 | 
			
		||||
            return domainObjectToFind.composition.some((compositionIdentifier) => {
 | 
			
		||||
                return this.openmct.objects.areIdsEqual(compositionIdentifier, this.domainObject.identifier);
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
@@ -820,27 +827,35 @@ export default {
 | 
			
		||||
 | 
			
		||||
        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);
 | 
			
		||||
                Object.keys(annotationToSelect.targets).forEach(targetKeyString => {
 | 
			
		||||
                    const target = annotationToSelect.targets[targetKeyString];
 | 
			
		||||
                    const series = this.seriesModels.find(seriesModel => seriesModel.keyString === targetKeyString);
 | 
			
		||||
                    if (!series) {
 | 
			
		||||
                        return;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    const yAxisId = series.get('yAxisId');
 | 
			
		||||
                    const rectangle = {
 | 
			
		||||
                        start: {
 | 
			
		||||
                            x: target.minX,
 | 
			
		||||
                            y: [target.minY],
 | 
			
		||||
                            yAxisIds: [yAxisId]
 | 
			
		||||
                        },
 | 
			
		||||
                        end: {
 | 
			
		||||
                            x: target.maxX,
 | 
			
		||||
                            y: [target.maxY],
 | 
			
		||||
                            yAxisIds: [yAxisId]
 | 
			
		||||
                        },
 | 
			
		||||
                        color: [1, 1, 1, 0.10]
 | 
			
		||||
                    };
 | 
			
		||||
                    this.rectangles.push(rectangle);
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        gatherNearbyAnnotations() {
 | 
			
		||||
            const nearbyAnnotations = [];
 | 
			
		||||
            this.config.series.models.forEach(series => {
 | 
			
		||||
                if (series.closest.annotationsById) {
 | 
			
		||||
                if (series?.closest?.annotationsById) {
 | 
			
		||||
                    Object.values(series.closest.annotationsById).forEach(closeAnnotation => {
 | 
			
		||||
                        const addedAnnotationAlready = nearbyAnnotations.some(annotation => {
 | 
			
		||||
                            return _.isEqual(annotation.targets, closeAnnotation.targets)
 | 
			
		||||
@@ -938,8 +953,13 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        onTickWidthChange(data, fromDifferentObject) {
 | 
			
		||||
            const {width, yAxisId} = data;
 | 
			
		||||
        /**
 | 
			
		||||
       * Aggregate widths of all left and right y axes and send them up to any parent plots
 | 
			
		||||
       * @param {Object} tickWidthWithYAxisId - the width and yAxisId of the tick bar
 | 
			
		||||
       * @param fromDifferentObject
 | 
			
		||||
       */
 | 
			
		||||
        onYTickWidthChange(tickWidthWithYAxisId, fromDifferentObject) {
 | 
			
		||||
            const {width, yAxisId} = tickWidthWithYAxisId;
 | 
			
		||||
            if (yAxisId) {
 | 
			
		||||
                const index = this.yAxes.findIndex(yAxis => yAxis.id === yAxisId);
 | 
			
		||||
                if (fromDifferentObject) {
 | 
			
		||||
@@ -948,13 +968,23 @@ export default {
 | 
			
		||||
                } else {
 | 
			
		||||
                // Otherwise, only accept tick with if it's larger.
 | 
			
		||||
                    const newWidth = Math.max(width, this.yAxes[index].tickWidth);
 | 
			
		||||
                    if (newWidth !== this.yAxes[index].tickWidth) {
 | 
			
		||||
                    if (width !== this.yAxes[index].tickWidth) {
 | 
			
		||||
                        this.yAxes[index].tickWidth = newWidth;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const id = this.openmct.objects.makeKeyString(this.domainObject.identifier);
 | 
			
		||||
                this.$emit('plotTickWidth', this.yAxes[index].tickWidth, id);
 | 
			
		||||
                const leftTickWidth = this.yAxes.filter(yAxis => yAxis.id < 3).reduce((previous, current) => {
 | 
			
		||||
                    return previous + current.tickWidth;
 | 
			
		||||
                }, 0);
 | 
			
		||||
                const rightTickWidth = this.yAxes.filter(yAxis => yAxis.id > 2).reduce((previous, current) => {
 | 
			
		||||
                    return previous + current.tickWidth;
 | 
			
		||||
                }, 0);
 | 
			
		||||
                this.$emit('plotYTickWidth', {
 | 
			
		||||
                    hasMultipleLeftAxes: this.hasMultipleLeftAxes,
 | 
			
		||||
                    leftTickWidth,
 | 
			
		||||
                    rightTickWidth
 | 
			
		||||
                }, id);
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
@@ -1036,8 +1066,6 @@ export default {
 | 
			
		||||
 | 
			
		||||
        highlightValues(point) {
 | 
			
		||||
            this.highlightPoint = point;
 | 
			
		||||
            // TODO: used in StackedPlotController
 | 
			
		||||
            this.$emit('plotHighlightUpdate', point);
 | 
			
		||||
            if (this.lockHighlightPoint) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
@@ -1149,7 +1177,7 @@ export default {
 | 
			
		||||
                    endPixels: this.positionOverElement,
 | 
			
		||||
                    start: this.positionOverPlot,
 | 
			
		||||
                    end: this.positionOverPlot,
 | 
			
		||||
                    color: [1, 1, 1, 0.5]
 | 
			
		||||
                    color: [1, 1, 1, 0.25]
 | 
			
		||||
                };
 | 
			
		||||
                if (annotationEvent) {
 | 
			
		||||
                    this.marquee.annotationEvent = true;
 | 
			
		||||
@@ -1160,57 +1188,92 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        selectNearbyAnnotations(event) {
 | 
			
		||||
            // need to stop propagation right away to prevent selecting the plot itself
 | 
			
		||||
            event.stopPropagation();
 | 
			
		||||
 | 
			
		||||
            if (!this.annotationViewingAndEditingAllowed || this.annotationSelections.length) {
 | 
			
		||||
            const nearbyAnnotations = this.gatherNearbyAnnotations();
 | 
			
		||||
 | 
			
		||||
            if (this.annotationViewingAndEditingAllowed && this.annotationSelections.length) {
 | 
			
		||||
                //no annotations were found, but we are adding some now
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const nearbyAnnotations = this.gatherNearbyAnnotations();
 | 
			
		||||
            const { targetDomainObjects, targetDetails } = this.prepareExistingAnnotationSelection(nearbyAnnotations);
 | 
			
		||||
            this.selectPlotAnnotations({
 | 
			
		||||
                targetDetails,
 | 
			
		||||
                targetDomainObjects,
 | 
			
		||||
                annotations: nearbyAnnotations
 | 
			
		||||
            });
 | 
			
		||||
            if (this.annotationViewingAndEditingAllowed && nearbyAnnotations.length) {
 | 
			
		||||
                //show annotations if some were found
 | 
			
		||||
                const { targetDomainObjects, targetDetails } = this.prepareExistingAnnotationSelection(nearbyAnnotations);
 | 
			
		||||
                this.selectPlotAnnotations({
 | 
			
		||||
                    targetDetails,
 | 
			
		||||
                    targetDomainObjects,
 | 
			
		||||
                    annotations: nearbyAnnotations
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            //Fall through to here if either there is no new selection add tags or no existing annotations were retrieved
 | 
			
		||||
            this.selectPlot();
 | 
			
		||||
        },
 | 
			
		||||
        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
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    ];
 | 
			
		||||
        selectPlot() {
 | 
			
		||||
            // should show plot itself if we didn't find any annotations
 | 
			
		||||
            const selection = this.createPathSelection();
 | 
			
		||||
            this.openmct.selection.select(selection, true);
 | 
			
		||||
        },
 | 
			
		||||
        selectNewPlotAnnotations(minX, minY, maxX, maxY, pointsInBox, event) {
 | 
			
		||||
            const boundingBox = {
 | 
			
		||||
                minX,
 | 
			
		||||
                minY,
 | 
			
		||||
                maxX,
 | 
			
		||||
                maxY
 | 
			
		||||
        createPathSelection() {
 | 
			
		||||
            let selection = [];
 | 
			
		||||
            selection.unshift({
 | 
			
		||||
                element: this.$el,
 | 
			
		||||
                context: {
 | 
			
		||||
                    item: this.domainObject
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
            this.path.forEach((pathObject, index) => {
 | 
			
		||||
                selection.push({
 | 
			
		||||
                    element: this.openmct.layout.$refs.browseObject.$el,
 | 
			
		||||
                    context: {
 | 
			
		||||
                        item: pathObject
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            return selection;
 | 
			
		||||
        },
 | 
			
		||||
        selectPlotAnnotations({targetDetails, targetDomainObjects, annotations}) {
 | 
			
		||||
            const annotationContext = {
 | 
			
		||||
                type: 'clicked-on-plot-selection',
 | 
			
		||||
                targetDetails,
 | 
			
		||||
                targetDomainObjects,
 | 
			
		||||
                annotations,
 | 
			
		||||
                annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL,
 | 
			
		||||
                onAnnotationChange: this.onAnnotationChange
 | 
			
		||||
            };
 | 
			
		||||
            const selection = this.createPathSelection();
 | 
			
		||||
            if (selection.length && this.openmct.objects.areIdsEqual(selection[0].context.item.identifier, this.domainObject.identifier)) {
 | 
			
		||||
                selection[0].context = {
 | 
			
		||||
                    ...selection[0].context,
 | 
			
		||||
                    ...annotationContext
 | 
			
		||||
                };
 | 
			
		||||
            } else {
 | 
			
		||||
                selection.unshift({
 | 
			
		||||
                    element: this.$el,
 | 
			
		||||
                    context: {
 | 
			
		||||
                        item: this.domainObject,
 | 
			
		||||
                        ...annotationContext
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.openmct.selection.select(selection, true);
 | 
			
		||||
        },
 | 
			
		||||
        selectNewPlotAnnotations(boundingBoxPerYAxis, pointsInBox, event) {
 | 
			
		||||
            let targetDomainObjects = {};
 | 
			
		||||
            let targetDetails = {};
 | 
			
		||||
            let annotations = {};
 | 
			
		||||
            let annotations = [];
 | 
			
		||||
            pointsInBox.forEach(pointInBox => {
 | 
			
		||||
                if (pointInBox.length) {
 | 
			
		||||
                    const seriesID = pointInBox[0].series.keyString;
 | 
			
		||||
                    targetDetails[seriesID] = boundingBox;
 | 
			
		||||
                    const boundingBoxWithId = boundingBoxPerYAxis.find(box => box.id === pointInBox[0].series.get('yAxisId'));
 | 
			
		||||
                    targetDetails[seriesID] = boundingBoxWithId?.boundingBox;
 | 
			
		||||
 | 
			
		||||
                    targetDomainObjects[seriesID] = pointInBox[0].series.domainObject;
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
@@ -1225,10 +1288,23 @@ export default {
 | 
			
		||||
            rawAnnotations.forEach(rawAnnotation => {
 | 
			
		||||
                if (rawAnnotation.targets) {
 | 
			
		||||
                    const targetValues = Object.values(rawAnnotation.targets);
 | 
			
		||||
                    const targetKeys = Object.keys(rawAnnotation.targets);
 | 
			
		||||
                    if (targetValues && targetValues.length) {
 | 
			
		||||
                        // just get the first one
 | 
			
		||||
                        const boundingBox = Object.values(targetValues)?.[0];
 | 
			
		||||
                        const pointsInBox = this.getPointsInBox(boundingBox, rawAnnotation);
 | 
			
		||||
                        let boundingBoxPerYAxis = [];
 | 
			
		||||
                        targetValues.forEach((boundingBox, index) => {
 | 
			
		||||
                            const seriesId = targetKeys[index];
 | 
			
		||||
                            const series = this.seriesModels.find(seriesModel => seriesModel.keyString === seriesId);
 | 
			
		||||
                            if (!series) {
 | 
			
		||||
                                return;
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            boundingBoxPerYAxis.push({
 | 
			
		||||
                                id: series.get('yAxisId'),
 | 
			
		||||
                                boundingBox
 | 
			
		||||
                            });
 | 
			
		||||
                        });
 | 
			
		||||
 | 
			
		||||
                        const pointsInBox = this.getPointsInBox(boundingBoxPerYAxis, rawAnnotation);
 | 
			
		||||
                        if (pointsInBox && pointsInBox.length) {
 | 
			
		||||
                            annotationsByPoints.push(pointsInBox.flat());
 | 
			
		||||
                        }
 | 
			
		||||
@@ -1238,10 +1314,17 @@ export default {
 | 
			
		||||
 | 
			
		||||
            return annotationsByPoints.flat();
 | 
			
		||||
        },
 | 
			
		||||
        getPointsInBox(boundingBox, rawAnnotation) {
 | 
			
		||||
        getPointsInBox(boundingBoxPerYAxis, rawAnnotation) {
 | 
			
		||||
            // load series models in KD-Trees
 | 
			
		||||
            const seriesKDTrees = [];
 | 
			
		||||
            this.seriesModels.forEach(seriesModel => {
 | 
			
		||||
                const boundingBoxWithId = boundingBoxPerYAxis.find(box => box.id === seriesModel.get('yAxisId'));
 | 
			
		||||
                const boundingBox = boundingBoxWithId?.boundingBox;
 | 
			
		||||
                //Series was probably added after the last annotations were saved
 | 
			
		||||
                if (!boundingBox) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const seriesData = seriesModel.getSeriesData();
 | 
			
		||||
                if (seriesData && seriesData.length) {
 | 
			
		||||
                    const kdTree = new KDBush(seriesData,
 | 
			
		||||
@@ -1283,25 +1366,31 @@ export default {
 | 
			
		||||
            return seriesKDTrees;
 | 
			
		||||
        },
 | 
			
		||||
        endAnnotationMarquee(event) {
 | 
			
		||||
            const minX = Math.min(this.marquee.start.x, this.marquee.end.x);
 | 
			
		||||
            const startMinY = this.marquee.start.y.reduce((previousY, currentY) => {
 | 
			
		||||
                return Math.min(previousY, currentY);
 | 
			
		||||
            }, this.marquee.start.y[0]);
 | 
			
		||||
            const endMinY = this.marquee.end.y.reduce((previousY, currentY) => {
 | 
			
		||||
                return Math.min(previousY, currentY);
 | 
			
		||||
            }, this.marquee.end.y[0]);
 | 
			
		||||
            const minY = Math.min(startMinY, endMinY);
 | 
			
		||||
            const maxX = Math.max(this.marquee.start.x, this.marquee.end.x);
 | 
			
		||||
            const maxY = Math.max(startMinY, endMinY);
 | 
			
		||||
            const boundingBox = {
 | 
			
		||||
                minX,
 | 
			
		||||
                minY,
 | 
			
		||||
                maxX,
 | 
			
		||||
                maxY
 | 
			
		||||
            };
 | 
			
		||||
            const pointsInBox = this.getPointsInBox(boundingBox);
 | 
			
		||||
            const boundingBoxPerYAxis = [];
 | 
			
		||||
            this.yAxisListWithRange.forEach((yAxis, yIndex) => {
 | 
			
		||||
                const minX = Math.min(this.marquee.start.x, this.marquee.end.x);
 | 
			
		||||
                const minY = Math.min(this.marquee.start.y[yIndex], this.marquee.end.y[yIndex]);
 | 
			
		||||
                const maxX = Math.max(this.marquee.start.x, this.marquee.end.x);
 | 
			
		||||
                const maxY = Math.max(this.marquee.start.y[yIndex], this.marquee.end.y[yIndex]);
 | 
			
		||||
                const boundingBox = {
 | 
			
		||||
                    minX,
 | 
			
		||||
                    minY,
 | 
			
		||||
                    maxX,
 | 
			
		||||
                    maxY
 | 
			
		||||
                };
 | 
			
		||||
                boundingBoxPerYAxis.push({
 | 
			
		||||
                    id: yAxis.get('id'),
 | 
			
		||||
                    boundingBox
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            const pointsInBox = this.getPointsInBox(boundingBoxPerYAxis);
 | 
			
		||||
            if (!pointsInBox) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.annotationSelections = pointsInBox.flat();
 | 
			
		||||
            this.selectNewPlotAnnotations(minX, minY, maxX, maxY, pointsInBox, event);
 | 
			
		||||
            this.selectNewPlotAnnotations(boundingBoxPerYAxis, pointsInBox, event);
 | 
			
		||||
        },
 | 
			
		||||
        endZoomMarquee() {
 | 
			
		||||
            const startPixels = this.marquee.startPixels;
 | 
			
		||||
@@ -1681,7 +1770,9 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        destroy() {
 | 
			
		||||
            configStore.deleteStore(this.config.id);
 | 
			
		||||
            if (this.config) {
 | 
			
		||||
                configStore.deleteStore(this.config.id);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.stopListening();
 | 
			
		||||
 | 
			
		||||
@@ -1722,9 +1813,6 @@ export default {
 | 
			
		||||
                this.config.series.models.forEach(this.loadSeriesData, this);
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        legendHoverChanged(data) {
 | 
			
		||||
            this.showLimitLineLabels = data;
 | 
			
		||||
        },
 | 
			
		||||
        toggleCursorGuide() {
 | 
			
		||||
            this.cursorGuide = !this.cursorGuide;
 | 
			
		||||
            this.$emit('cursorGuide', this.cursorGuide);
 | 
			
		||||
 
 | 
			
		||||
@@ -86,6 +86,8 @@ import eventHelpers from "./lib/eventHelpers";
 | 
			
		||||
import { ticks, getLogTicks, getFormattedTicks } from "./tickUtils";
 | 
			
		||||
import configStore from "./configuration/ConfigStore";
 | 
			
		||||
 | 
			
		||||
const SECONDARY_TICK_NUMBER = 2;
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    inject: ['openmct', 'domainObject'],
 | 
			
		||||
    props: {
 | 
			
		||||
@@ -205,7 +207,7 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (this.axisType === 'yAxis' && this.axis.get('logMode')) {
 | 
			
		||||
                return getLogTicks(range.min, range.max, number, 4);
 | 
			
		||||
                return getLogTicks(range.min, range.max, number, SECONDARY_TICK_NUMBER);
 | 
			
		||||
            } else {
 | 
			
		||||
                return ticks(range.min, range.max, number);
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
@@ -36,12 +36,26 @@
 | 
			
		||||
            :model="{progressPerc: undefined}"
 | 
			
		||||
        />
 | 
			
		||||
        <mct-plot
 | 
			
		||||
            :class="[plotLegendExpandedStateClass, plotLegendPositionClass]"
 | 
			
		||||
            :init-grid-lines="gridLines"
 | 
			
		||||
            :init-cursor-guide="cursorGuide"
 | 
			
		||||
            :options="options"
 | 
			
		||||
            :limit-line-labels="limitLineLabels"
 | 
			
		||||
            @loadingUpdated="loadingUpdated"
 | 
			
		||||
            @statusUpdated="setStatus"
 | 
			
		||||
        />
 | 
			
		||||
            @configLoaded="updateReady"
 | 
			
		||||
            @lockHighlightPoint="lockHighlightPointUpdated"
 | 
			
		||||
            @highlights="highlightsUpdated"
 | 
			
		||||
        >
 | 
			
		||||
            <plot-legend
 | 
			
		||||
                v-if="configReady"
 | 
			
		||||
                :cursor-locked="lockHighlightPoint"
 | 
			
		||||
                :highlights="highlights"
 | 
			
		||||
                @legendHoverChanged="legendHoverChanged"
 | 
			
		||||
                @expanded="updateExpanded"
 | 
			
		||||
                @position="updatePosition"
 | 
			
		||||
            />
 | 
			
		||||
        </mct-plot>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -50,13 +64,15 @@
 | 
			
		||||
import eventHelpers from './lib/eventHelpers';
 | 
			
		||||
import ImageExporter from '../../exporters/ImageExporter';
 | 
			
		||||
import MctPlot from './MctPlot.vue';
 | 
			
		||||
import PlotLegend from "./legend/PlotLegend.vue";
 | 
			
		||||
import ProgressBar from "../../ui/components/ProgressBar.vue";
 | 
			
		||||
import StalenessUtils from '@/utils/staleness';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    components: {
 | 
			
		||||
        MctPlot,
 | 
			
		||||
        ProgressBar
 | 
			
		||||
        ProgressBar,
 | 
			
		||||
        PlotLegend
 | 
			
		||||
    },
 | 
			
		||||
    inject: ['openmct', 'domainObject', 'path'],
 | 
			
		||||
    props: {
 | 
			
		||||
@@ -77,7 +93,13 @@ export default {
 | 
			
		||||
            gridLines: !this.options.compact,
 | 
			
		||||
            loading: false,
 | 
			
		||||
            status: '',
 | 
			
		||||
            staleObjects: []
 | 
			
		||||
            staleObjects: [],
 | 
			
		||||
            limitLineLabels: undefined,
 | 
			
		||||
            lockHighlightPoint: false,
 | 
			
		||||
            highlights: [],
 | 
			
		||||
            expanded: false,
 | 
			
		||||
            position: undefined,
 | 
			
		||||
            configReady: false
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
@@ -87,6 +109,16 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return '';
 | 
			
		||||
        },
 | 
			
		||||
        plotLegendPositionClass() {
 | 
			
		||||
            return this.position ? `plot-legend-${this.position}` : '';
 | 
			
		||||
        },
 | 
			
		||||
        plotLegendExpandedStateClass() {
 | 
			
		||||
            if (this.expanded) {
 | 
			
		||||
                return 'plot-legend-expanded';
 | 
			
		||||
            } else {
 | 
			
		||||
                return 'plot-legend-collapsed';
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
@@ -134,6 +166,7 @@ export default {
 | 
			
		||||
            this.stalenessSubscription[keystring].unsubscribe();
 | 
			
		||||
            this.stalenessSubscription[keystring].stalenessUtils.destroy();
 | 
			
		||||
            this.handleStaleness(keystring, { isStale: false }, SKIP_CHECK);
 | 
			
		||||
            delete this.stalenessSubscription[keystring];
 | 
			
		||||
        },
 | 
			
		||||
        handleStaleness(id, stalenessResponse, skipCheck = false) {
 | 
			
		||||
            if (skipCheck || this.stalenessSubscription[id].stalenessUtils.shouldUpdateStaleness(stalenessResponse, id)) {
 | 
			
		||||
@@ -183,6 +216,24 @@ export default {
 | 
			
		||||
                exportPNG: this.exportPNG,
 | 
			
		||||
                exportJPG: this.exportJPG
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        lockHighlightPointUpdated(data) {
 | 
			
		||||
            this.lockHighlightPoint = data;
 | 
			
		||||
        },
 | 
			
		||||
        highlightsUpdated(data) {
 | 
			
		||||
            this.highlights = data;
 | 
			
		||||
        },
 | 
			
		||||
        legendHoverChanged(data) {
 | 
			
		||||
            this.limitLineLabels = data;
 | 
			
		||||
        },
 | 
			
		||||
        updateExpanded(expanded) {
 | 
			
		||||
            this.expanded = expanded;
 | 
			
		||||
        },
 | 
			
		||||
        updatePosition(position) {
 | 
			
		||||
            this.position = position;
 | 
			
		||||
        },
 | 
			
		||||
        updateReady(ready) {
 | 
			
		||||
            this.configReady = ready;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -152,7 +152,7 @@ export default {
 | 
			
		||||
            this.selectedXKeyOptionKey = this.xKeyOptions.length > 0 ? this.getXKeyOption(xAxisKey).key : xAxisKey;
 | 
			
		||||
        },
 | 
			
		||||
        onTickWidthChange(width) {
 | 
			
		||||
            this.$emit('tickWidthChanged', width);
 | 
			
		||||
            this.$emit('plotXTickWidth', width);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -101,7 +101,13 @@ export default {
 | 
			
		||||
                return 0;
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        multipleLeftAxes: {
 | 
			
		||||
        usedTickWidth: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
            default() {
 | 
			
		||||
                return 0;
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        hasMultipleLeftAxes: {
 | 
			
		||||
            type: Boolean,
 | 
			
		||||
            default() {
 | 
			
		||||
                return false;
 | 
			
		||||
@@ -138,14 +144,14 @@ export default {
 | 
			
		||||
            let style = {
 | 
			
		||||
                width: `${this.tickWidth + AXIS_PADDING}px`
 | 
			
		||||
            };
 | 
			
		||||
            const multipleAxesPadding = this.multipleLeftAxes ? AXIS_PADDING : 0;
 | 
			
		||||
            const multipleAxesPadding = this.hasMultipleLeftAxes ? AXIS_PADDING : 0;
 | 
			
		||||
 | 
			
		||||
            if (this.position === 'right') {
 | 
			
		||||
                style.left = `-${this.tickWidth + AXIS_PADDING}px`;
 | 
			
		||||
            } else {
 | 
			
		||||
                const thisIsTheSecondLeftAxis = (this.id - 1) > 0;
 | 
			
		||||
                if (this.multipleLeftAxes && thisIsTheSecondLeftAxis) {
 | 
			
		||||
                    style.left = 0;
 | 
			
		||||
                if (this.hasMultipleLeftAxes && thisIsTheSecondLeftAxis) {
 | 
			
		||||
                    style.left = `${this.plotLeftTickWidth - this.usedTickWidth - this.tickWidth}px`;
 | 
			
		||||
                    style['border-right'] = `1px solid`;
 | 
			
		||||
                } else {
 | 
			
		||||
                    style.left = `${ this.plotLeftTickWidth - this.tickWidth + multipleAxesPadding}px`;
 | 
			
		||||
@@ -202,6 +208,7 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.listenTo(series, 'change:yAxisId', this.addOrRemoveSeries.bind(this, series), this);
 | 
			
		||||
            this.listenTo(series, 'change:color', this.updateSeriesColors.bind(this, series), this);
 | 
			
		||||
        },
 | 
			
		||||
        removeSeries(plotSeries) {
 | 
			
		||||
            const seriesIndex = this.seriesModels.findIndex(model => this.openmct.objects.areIdsEqual(model.get('identifier'), plotSeries.get('identifier')));
 | 
			
		||||
@@ -216,6 +223,9 @@ export default {
 | 
			
		||||
                return model.get('yKey') === this.seriesModels[0].get('yKey');
 | 
			
		||||
            });
 | 
			
		||||
            this.singleSeries = this.seriesModels.length === 1;
 | 
			
		||||
            this.updateSeriesColors();
 | 
			
		||||
        },
 | 
			
		||||
        updateSeriesColors() {
 | 
			
		||||
            this.seriesColors = this.seriesModels.map(model => {
 | 
			
		||||
                return model.get('color').asHexString();
 | 
			
		||||
            });
 | 
			
		||||
@@ -252,7 +262,7 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        onTickWidthChange(data) {
 | 
			
		||||
            this.$emit('tickWidthChanged', {
 | 
			
		||||
            this.$emit('plotYTickWidth', {
 | 
			
		||||
                width: data.width,
 | 
			
		||||
                yAxisId: this.id
 | 
			
		||||
            });
 | 
			
		||||
 
 | 
			
		||||
@@ -105,6 +105,9 @@ export default class MCTChartAlarmLineSet {
 | 
			
		||||
 | 
			
		||||
    reset() {
 | 
			
		||||
        this.limits = [];
 | 
			
		||||
        if (this.series.limits) {
 | 
			
		||||
            this.getLimitPoints(this.series);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    destroy() {
 | 
			
		||||
 
 | 
			
		||||
@@ -52,6 +52,41 @@ const MARKER_SIZE = 6.0;
 | 
			
		||||
const HIGHLIGHT_SIZE = MARKER_SIZE * 2.0;
 | 
			
		||||
const ANNOTATION_SIZE = MARKER_SIZE * 3.0;
 | 
			
		||||
const CLEARANCE = 15;
 | 
			
		||||
// These attributes are changed in the plot model, but we don't need to react to the changes.
 | 
			
		||||
const NO_HANDLING_NEEDED_ATTRIBUTES = {
 | 
			
		||||
    label: 'label',
 | 
			
		||||
    values: 'values',
 | 
			
		||||
    format: 'format',
 | 
			
		||||
    color: 'color',
 | 
			
		||||
    name: 'name',
 | 
			
		||||
    unit: 'unit'
 | 
			
		||||
};
 | 
			
		||||
// These attributes in turn set one of HANDLED_ATTRIBUTES, so we don't need specific listeners for them - this prevents excessive redraws.
 | 
			
		||||
const IMPLICIT_HANDLED_ATTRIBUTES = {
 | 
			
		||||
    range: 'range',
 | 
			
		||||
    //series stats update y axis stats
 | 
			
		||||
    stats: 'stats',
 | 
			
		||||
    frozen: 'frozen',
 | 
			
		||||
    autoscale: 'autoscale',
 | 
			
		||||
    autoscalePadding: 'autoscalePadding',
 | 
			
		||||
    logMode: 'logMode',
 | 
			
		||||
    yKey: 'yKey'
 | 
			
		||||
};
 | 
			
		||||
// Attribute changes that we are specifically handling with listeners
 | 
			
		||||
const HANDLED_ATTRIBUTES = {
 | 
			
		||||
    //X and Y Axis attributes
 | 
			
		||||
    key: 'key',
 | 
			
		||||
    displayRange: 'displayRange',
 | 
			
		||||
    //series attributes
 | 
			
		||||
    xKey: 'xKey',
 | 
			
		||||
    interpolate: 'interpolate',
 | 
			
		||||
    markers: 'markers',
 | 
			
		||||
    markerShape: 'markerShape',
 | 
			
		||||
    markerSize: 'markerSize',
 | 
			
		||||
    alarmMarkers: 'alarmMarkers',
 | 
			
		||||
    limitLines: 'limitLines',
 | 
			
		||||
    yAxisId: 'yAxisId'
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    inject: ['openmct', 'domainObject', 'path'],
 | 
			
		||||
@@ -121,6 +156,7 @@ export default {
 | 
			
		||||
        hiddenYAxisIds() {
 | 
			
		||||
            this.hiddenYAxisIds.forEach(id => {
 | 
			
		||||
                this.resetYOffsetAndSeriesDataForYAxis(id);
 | 
			
		||||
                this.drawLimitLines();
 | 
			
		||||
            });
 | 
			
		||||
            this.scheduleDraw();
 | 
			
		||||
        }
 | 
			
		||||
@@ -137,14 +173,16 @@ export default {
 | 
			
		||||
        this.offset = {
 | 
			
		||||
            [yAxisId]: {}
 | 
			
		||||
        };
 | 
			
		||||
        this.listenTo(this.config.yAxis, 'change:key', this.resetYOffsetAndSeriesDataForYAxis.bind(this, yAxisId), this);
 | 
			
		||||
        this.listenTo(this.config.yAxis, 'change', this.updateLimitsAndDraw);
 | 
			
		||||
        this.listenTo(this.config.yAxis, `change:${HANDLED_ATTRIBUTES.displayRange}`, this.scheduleDraw);
 | 
			
		||||
        this.listenTo(this.config.yAxis, `change:${HANDLED_ATTRIBUTES.key}`, this.resetYOffsetAndSeriesDataForYAxis.bind(this, yAxisId), this);
 | 
			
		||||
        this.listenTo(this.config.yAxis, 'change', this.redrawIfNotAlreadyHandled);
 | 
			
		||||
        if (this.config.additionalYAxes.length) {
 | 
			
		||||
            this.config.additionalYAxes.forEach(yAxis => {
 | 
			
		||||
                const id = yAxis.get('id');
 | 
			
		||||
                this.offset[id] = {};
 | 
			
		||||
                this.listenTo(yAxis, 'change', this.updateLimitsAndDraw);
 | 
			
		||||
                this.listenTo(yAxis, 'change:key', this.resetYOffsetAndSeriesDataForYAxis.bind(this, id), this);
 | 
			
		||||
                this.listenTo(yAxis, `change:${HANDLED_ATTRIBUTES.displayRange}`, this.scheduleDraw);
 | 
			
		||||
                this.listenTo(yAxis, `change:${HANDLED_ATTRIBUTES.key}`, this.resetYOffsetAndSeriesDataForYAxis.bind(this, id), this);
 | 
			
		||||
                this.listenTo(yAxis, 'change', this.redrawIfNotAlreadyHandled);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -161,7 +199,8 @@ export default {
 | 
			
		||||
        this.listenTo(this.config.series, 'add', this.onSeriesAdd, this);
 | 
			
		||||
        this.listenTo(this.config.series, 'remove', this.onSeriesRemove, this);
 | 
			
		||||
 | 
			
		||||
        this.listenTo(this.config.xAxis, 'change', this.updateLimitsAndDraw);
 | 
			
		||||
        this.listenTo(this.config.xAxis, 'change:displayRange', this.scheduleDraw);
 | 
			
		||||
        this.listenTo(this.config.xAxis, 'change', this.redrawIfNotAlreadyHandled);
 | 
			
		||||
        this.config.series.forEach(this.onSeriesAdd, this);
 | 
			
		||||
        this.$emit('chartLoaded');
 | 
			
		||||
    },
 | 
			
		||||
@@ -190,21 +229,33 @@ export default {
 | 
			
		||||
            this.changeLimitLines(mode, o, series);
 | 
			
		||||
        },
 | 
			
		||||
        onSeriesAdd(series) {
 | 
			
		||||
            this.listenTo(series, 'change:xKey', this.reDraw, this);
 | 
			
		||||
            this.listenTo(series, 'change:interpolate', this.changeInterpolate, this);
 | 
			
		||||
            this.listenTo(series, 'change:markers', this.changeMarkers, this);
 | 
			
		||||
            this.listenTo(series, 'change:alarmMarkers', this.changeAlarmMarkers, this);
 | 
			
		||||
            this.listenTo(series, 'change:limitLines', this.changeLimitLines, this);
 | 
			
		||||
            this.listenTo(series, 'change:yAxisId', this.resetAxisAndRedraw, this);
 | 
			
		||||
            this.listenTo(series, 'change', this.scheduleDraw);
 | 
			
		||||
            this.listenTo(series, `change:${HANDLED_ATTRIBUTES.xKey}`, this.reDraw, this);
 | 
			
		||||
            this.listenTo(series, `change:${HANDLED_ATTRIBUTES.interpolate}`, this.changeInterpolate, this);
 | 
			
		||||
            this.listenTo(series, `change:${HANDLED_ATTRIBUTES.markers}`, this.changeMarkers, this);
 | 
			
		||||
            this.listenTo(series, `change:${HANDLED_ATTRIBUTES.alarmMarkers}`, this.changeAlarmMarkers, this);
 | 
			
		||||
            this.listenTo(series, `change:${HANDLED_ATTRIBUTES.limitLines}`, this.changeLimitLines, this);
 | 
			
		||||
            this.listenTo(series, `change:${HANDLED_ATTRIBUTES.yAxisId}`, this.resetAxisAndRedraw, this);
 | 
			
		||||
            this.listenTo(series, `change:${HANDLED_ATTRIBUTES.markerShape}`, this.scheduleDraw, this);
 | 
			
		||||
            this.listenTo(series, `change:${HANDLED_ATTRIBUTES.markerSize}`, this.scheduleDraw, this);
 | 
			
		||||
            this.listenTo(series, 'change', this.redrawIfNotAlreadyHandled);
 | 
			
		||||
            this.listenTo(series, 'add', this.onAddPoint);
 | 
			
		||||
            this.makeChartElement(series);
 | 
			
		||||
            this.makeLimitLines(series);
 | 
			
		||||
        },
 | 
			
		||||
        onAddPoint(point, insertIndex, series) {
 | 
			
		||||
            const mainYAxisId = this.config.yAxis.get('id');
 | 
			
		||||
            const seriesYAxisId = series.get('yAxisId');
 | 
			
		||||
            const xRange = this.config.xAxis.get('displayRange');
 | 
			
		||||
            //TODO: get the yAxis of this series
 | 
			
		||||
            const yRange = this.config.yAxis.get('displayRange');
 | 
			
		||||
 | 
			
		||||
            let yRange;
 | 
			
		||||
            if (seriesYAxisId === mainYAxisId) {
 | 
			
		||||
                yRange = this.config.yAxis.get('displayRange');
 | 
			
		||||
            } else {
 | 
			
		||||
                yRange = this.config.additionalYAxes.find(
 | 
			
		||||
                    yAxis => yAxis.get('id') === seriesYAxisId
 | 
			
		||||
                ).get('displayRange');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const xValue = series.getXVal(point);
 | 
			
		||||
            const yValue = series.getYVal(point);
 | 
			
		||||
 | 
			
		||||
@@ -519,6 +570,21 @@ export default {
 | 
			
		||||
 | 
			
		||||
            return true;
 | 
			
		||||
        },
 | 
			
		||||
        redrawIfNotAlreadyHandled(attribute, value, oldValue) {
 | 
			
		||||
            if (Object.keys(HANDLED_ATTRIBUTES).includes(attribute) && oldValue) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (Object.keys(IMPLICIT_HANDLED_ATTRIBUTES).includes(attribute) && oldValue) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (Object.keys(NO_HANDLING_NEEDED_ATTRIBUTES).includes(attribute) && oldValue) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.updateLimitsAndDraw();
 | 
			
		||||
        },
 | 
			
		||||
        updateLimitsAndDraw() {
 | 
			
		||||
            this.drawLimitLines();
 | 
			
		||||
            this.scheduleDraw();
 | 
			
		||||
@@ -615,9 +681,13 @@ export default {
 | 
			
		||||
            alarmSets.forEach(this.drawAlarmPoints, this);
 | 
			
		||||
        },
 | 
			
		||||
        drawLimitLines() {
 | 
			
		||||
            Array.from(this.$refs.limitArea.children).forEach((el) => el.remove());
 | 
			
		||||
            this.config.series.models.forEach(series => {
 | 
			
		||||
                const yAxisId = series.get('yAxisId');
 | 
			
		||||
                this.drawLimitLinesForSeries(yAxisId, series);
 | 
			
		||||
 | 
			
		||||
                if (this.hiddenYAxisIds.indexOf(yAxisId) < 0) {
 | 
			
		||||
                    this.drawLimitLinesForSeries(yAxisId, series);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        drawLimitLinesForSeries(yAxisId, series) {
 | 
			
		||||
@@ -631,12 +701,11 @@ export default {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Array.from(this.$refs.limitArea.children).forEach((el) => el.remove());
 | 
			
		||||
            let limitPointOverlap = [];
 | 
			
		||||
            this.limitLines.forEach((limitLine) => {
 | 
			
		||||
                let limitContainerEl = this.$refs.limitArea;
 | 
			
		||||
                limitLine.limits.forEach((limit) => {
 | 
			
		||||
                    if (!series.includes(limit.seriesKey)) {
 | 
			
		||||
                    if (series.keyString !== limit.seriesKey) {
 | 
			
		||||
                        return;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
@@ -744,6 +813,10 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        annotatedPointWithinRange(annotatedPoint, xRange, yRange) {
 | 
			
		||||
            if (!yRange) {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const xValue = annotatedPoint.series.getXVal(annotatedPoint.point);
 | 
			
		||||
            const yValue = annotatedPoint.series.getYVal(annotatedPoint.point);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -68,27 +68,26 @@ export default class PlotConfigurationModel extends Model {
 | 
			
		||||
        //Add any axes in addition to the main yAxis above - we must always have at least 1 y-axis
 | 
			
		||||
        //Addition axes ids will be the MAIN_Y_AXES_ID + x where x is between 1 and MAX_ADDITIONAL_AXES
 | 
			
		||||
        this.additionalYAxes = [];
 | 
			
		||||
        if (Array.isArray(options.model.additionalYAxes)) {
 | 
			
		||||
            const maxLength = Math.min(MAX_ADDITIONAL_AXES, options.model.additionalYAxes.length);
 | 
			
		||||
            for (let yAxisCount = 0; yAxisCount < maxLength; yAxisCount++) {
 | 
			
		||||
                const yAxis = options.model.additionalYAxes[yAxisCount];
 | 
			
		||||
        const hasAdditionalAxesConfiguration = Array.isArray(options.model.additionalYAxes);
 | 
			
		||||
 | 
			
		||||
        for (let yAxisCount = 0; yAxisCount < MAX_ADDITIONAL_AXES; yAxisCount++) {
 | 
			
		||||
            const yAxisId = MAIN_Y_AXES_ID + yAxisCount + 1;
 | 
			
		||||
            const yAxis = hasAdditionalAxesConfiguration && options.model.additionalYAxes.find(additionalYAxis => additionalYAxis?.id === yAxisId);
 | 
			
		||||
            if (yAxis) {
 | 
			
		||||
                this.additionalYAxes.push(new YAxisModel({
 | 
			
		||||
                    model: yAxis,
 | 
			
		||||
                    plot: this,
 | 
			
		||||
                    openmct: options.openmct,
 | 
			
		||||
                    id: yAxis.id || (MAIN_Y_AXES_ID + yAxisCount + 1)
 | 
			
		||||
                    id: yAxis.id
 | 
			
		||||
                }));
 | 
			
		||||
            } else {
 | 
			
		||||
                this.additionalYAxes.push(new YAxisModel({
 | 
			
		||||
                    plot: this,
 | 
			
		||||
                    openmct: options.openmct,
 | 
			
		||||
                    id: yAxisId
 | 
			
		||||
                }));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If the saved options config doesn't include information about all the additional axes, we initialize the remaining here
 | 
			
		||||
        for (let axesCount = this.additionalYAxes.length; axesCount < MAX_ADDITIONAL_AXES; axesCount++) {
 | 
			
		||||
            this.additionalYAxes.push(new YAxisModel({
 | 
			
		||||
                plot: this,
 | 
			
		||||
                openmct: options.openmct,
 | 
			
		||||
                id: MAIN_Y_AXES_ID + axesCount + 1
 | 
			
		||||
            }));
 | 
			
		||||
        }
 | 
			
		||||
        // end add additional axes
 | 
			
		||||
 | 
			
		||||
        this.legend = new LegendModel({
 | 
			
		||||
 
 | 
			
		||||
@@ -73,7 +73,7 @@ export default class PlotSeries extends Model {
 | 
			
		||||
 | 
			
		||||
        super(options);
 | 
			
		||||
 | 
			
		||||
        this.logMode = options.collection.plot.model.yAxis.logMode;
 | 
			
		||||
        this.logMode = this.getLogMode(options);
 | 
			
		||||
 | 
			
		||||
        this.listenTo(this, 'change:xKey', this.onXKeyChange, this);
 | 
			
		||||
        this.listenTo(this, 'change:yKey', this.onYKeyChange, this);
 | 
			
		||||
@@ -87,6 +87,17 @@ export default class PlotSeries extends Model {
 | 
			
		||||
        this.unPlottableValues = [undefined, Infinity, -Infinity];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getLogMode(options) {
 | 
			
		||||
        const yAxisId = this.get('yAxisId');
 | 
			
		||||
        if (yAxisId === 1) {
 | 
			
		||||
            return options.collection.plot.model.yAxis.logMode;
 | 
			
		||||
        } else {
 | 
			
		||||
            const foundYAxis = options.collection.plot.model.additionalYAxes.find(yAxis => yAxis.id === yAxisId);
 | 
			
		||||
 | 
			
		||||
            return foundYAxis ? foundYAxis.logMode : false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set defaults for telemetry series.
 | 
			
		||||
     * @param {import('./Model').ModelOptions<PlotSeriesModelType, PlotSeriesModelOptions>} options
 | 
			
		||||
@@ -241,6 +252,7 @@ export default class PlotSeries extends Model {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const valueMetadata = this.metadata.value(newKey);
 | 
			
		||||
        //TODO: Should we do this even if there is a persisted config?
 | 
			
		||||
        if (!this.persistedConfig || !this.persistedConfig.interpolate) {
 | 
			
		||||
            if (valueMetadata.format === 'enum') {
 | 
			
		||||
                this.set('interpolate', 'stepAfter');
 | 
			
		||||
 
 | 
			
		||||
@@ -56,6 +56,10 @@ export default class SeriesCollection extends Collection {
 | 
			
		||||
            const series = this.byIdentifier(seriesConfig.identifier);
 | 
			
		||||
            if (series) {
 | 
			
		||||
                series.persistedConfig = seriesConfig;
 | 
			
		||||
                if (!series.persistedConfig.yAxisId) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (series.get('yAxisId') !== series.persistedConfig.yAxisId) {
 | 
			
		||||
                    series.set('yAxisId', series.persistedConfig.yAxisId);
 | 
			
		||||
                }
 | 
			
		||||
@@ -63,6 +67,10 @@ export default class SeriesCollection extends Collection {
 | 
			
		||||
        }, this);
 | 
			
		||||
    }
 | 
			
		||||
    watchTelemetryContainer(domainObject) {
 | 
			
		||||
        if (domainObject.type === 'telemetry.plot.stacked') {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const composition = this.openmct.composition.get(domainObject);
 | 
			
		||||
        this.listenTo(composition, 'add', this.addTelemetryObject, this);
 | 
			
		||||
        this.listenTo(composition, 'remove', this.removeTelemetryObject, this);
 | 
			
		||||
 
 | 
			
		||||
@@ -57,7 +57,14 @@ export default class YAxisModel extends Model {
 | 
			
		||||
        this.listenTo(this, 'change:logMode', this.onLogModeChange, this);
 | 
			
		||||
        this.listenTo(this, 'change:frozen', this.toggleFreeze, this);
 | 
			
		||||
        this.listenTo(this, 'change:range', this.updateDisplayRange, this);
 | 
			
		||||
        this.updateDisplayRange(this.get('range'));
 | 
			
		||||
        const range = this.get('range');
 | 
			
		||||
        this.updateDisplayRange(range);
 | 
			
		||||
        //This is an edge case and should not happen
 | 
			
		||||
        const invalidRange = !range || (range?.min === undefined || range?.max === undefined);
 | 
			
		||||
        const invalidAutoScaleOff = (options.model.autoscale === false) && invalidRange;
 | 
			
		||||
        if (invalidAutoScaleOff) {
 | 
			
		||||
            this.set('autoscale', true);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    /**
 | 
			
		||||
     * @param {import('./SeriesCollection').default} seriesCollection
 | 
			
		||||
@@ -250,23 +257,6 @@ export default class YAxisModel extends Model {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.set('displayRange', _range);
 | 
			
		||||
        } else {
 | 
			
		||||
            // Otherwise use the last known displayRange as the initial
 | 
			
		||||
            // values for the user-defined range, so that we don't end up
 | 
			
		||||
            // with any error from an undefined user range.
 | 
			
		||||
 | 
			
		||||
            const _range = this.get('displayRange');
 | 
			
		||||
 | 
			
		||||
            if (!_range) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (this.get('logMode')) {
 | 
			
		||||
                _range.min = antisymlog(_range.min, 10);
 | 
			
		||||
                _range.max = antisymlog(_range.max, 10);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.set('range', _range);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -287,7 +277,8 @@ export default class YAxisModel extends Model {
 | 
			
		||||
        this.resetSeries();
 | 
			
		||||
    }
 | 
			
		||||
    resetSeries() {
 | 
			
		||||
        this.plot.series.forEach((plotSeries) => {
 | 
			
		||||
        const series = this.getSeriesForYAxis(this.seriesCollection);
 | 
			
		||||
        series.forEach((plotSeries) => {
 | 
			
		||||
            plotSeries.logMode = this.get('logMode');
 | 
			
		||||
            plotSeries.reset(plotSeries.getSeriesData());
 | 
			
		||||
        });
 | 
			
		||||
@@ -376,11 +367,8 @@ export default class YAxisModel extends Model {
 | 
			
		||||
            autoscale: true,
 | 
			
		||||
            logMode: options.model?.logMode ?? false,
 | 
			
		||||
            autoscalePadding: 0.1,
 | 
			
		||||
            id: options.id
 | 
			
		||||
 | 
			
		||||
            // 'range' is not specified here, it is undefined at first. When the
 | 
			
		||||
            // user turns off autoscale, the current 'displayRange' is used for
 | 
			
		||||
            // the initial value of 'range'.
 | 
			
		||||
            id: options.id,
 | 
			
		||||
            range: options.model?.range
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,6 +27,7 @@
 | 
			
		||||
    <ul
 | 
			
		||||
        v-if="!isStackedPlotObject"
 | 
			
		||||
        class="c-tree"
 | 
			
		||||
        aria-label="Plot Series Properties"
 | 
			
		||||
    >
 | 
			
		||||
        <h2 title="Plot series display properties in this object">Plot Series</h2>
 | 
			
		||||
        <plot-options-item
 | 
			
		||||
@@ -43,6 +44,7 @@
 | 
			
		||||
            v-for="(yAxis, index) in yAxesWithSeries"
 | 
			
		||||
            :key="`yAxis-${index}`"
 | 
			
		||||
            class="l-inspector-part js-yaxis-properties"
 | 
			
		||||
            :aria-label="yAxesWithSeries.length > 1 ? `Y Axis ${yAxis.id} Properties` : 'Y Axis Properties'"
 | 
			
		||||
        >
 | 
			
		||||
            <h2 title="Y axis settings for this object">Y Axis {{ yAxesWithSeries.length > 1 ? yAxis.id : '' }}</h2>
 | 
			
		||||
            <li class="grid-row">
 | 
			
		||||
@@ -71,7 +73,7 @@
 | 
			
		||||
                </div>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li
 | 
			
		||||
                v-if="!yAxis.autoscale && yAxis.rangeMin"
 | 
			
		||||
                v-if="!yAxis.autoscale && yAxis.rangeMin !== ''"
 | 
			
		||||
                class="grid-row"
 | 
			
		||||
            >
 | 
			
		||||
                <div
 | 
			
		||||
@@ -81,7 +83,7 @@
 | 
			
		||||
                <div class="grid-cell value">{{ yAxis.rangeMin }}</div>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li
 | 
			
		||||
                v-if="!yAxis.autoscale && yAxis.rangeMax"
 | 
			
		||||
                v-if="!yAxis.autoscale && yAxis.rangeMax !== ''"
 | 
			
		||||
                class="grid-row"
 | 
			
		||||
            >
 | 
			
		||||
                <div
 | 
			
		||||
@@ -93,7 +95,7 @@
 | 
			
		||||
        </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div
 | 
			
		||||
        v-if="plotSeries.length && (isStackedPlotObject || !isNestedWithinAStackedPlot)"
 | 
			
		||||
        v-if="isStackedPlotObject || !isNestedWithinAStackedPlot"
 | 
			
		||||
        class="grid-properties"
 | 
			
		||||
    >
 | 
			
		||||
        <ul
 | 
			
		||||
@@ -190,10 +192,13 @@ export default {
 | 
			
		||||
    mounted() {
 | 
			
		||||
        eventHelpers.extend(this);
 | 
			
		||||
        this.config = this.getConfig();
 | 
			
		||||
        this.initYAxesConfiguration();
 | 
			
		||||
        if (!this.isStackedPlotObject) {
 | 
			
		||||
            this.initYAxesConfiguration();
 | 
			
		||||
            this.registerListeners();
 | 
			
		||||
        } else {
 | 
			
		||||
            this.initLegendConfiguration();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.registerListeners();
 | 
			
		||||
        this.initLegendConfiguration();
 | 
			
		||||
        this.loaded = true;
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
@@ -212,8 +217,8 @@ export default {
 | 
			
		||||
                    autoscale: this.config.yAxis.get('autoscale'),
 | 
			
		||||
                    logMode: this.config.yAxis.get('logMode'),
 | 
			
		||||
                    autoscalePadding: this.config.yAxis.get('autoscalePadding'),
 | 
			
		||||
                    rangeMin: range ? range.min : '',
 | 
			
		||||
                    rangeMax: range ? range.max : ''
 | 
			
		||||
                    rangeMin: range?.min ?? '',
 | 
			
		||||
                    rangeMax: range?.max ?? ''
 | 
			
		||||
                });
 | 
			
		||||
                this.config.additionalYAxes.forEach(yAxis => {
 | 
			
		||||
                    range = yAxis.get('range');
 | 
			
		||||
@@ -225,8 +230,8 @@ export default {
 | 
			
		||||
                        autoscale: yAxis.get('autoscale'),
 | 
			
		||||
                        logMode: yAxis.get('logMode'),
 | 
			
		||||
                        autoscalePadding: yAxis.get('autoscalePadding'),
 | 
			
		||||
                        rangeMin: range ? range.min : '',
 | 
			
		||||
                        rangeMax: range ? range.max : ''
 | 
			
		||||
                        rangeMin: range?.min ?? '',
 | 
			
		||||
                        rangeMax: range?.max ?? ''
 | 
			
		||||
                    });
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
@@ -245,9 +250,9 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        getConfig() {
 | 
			
		||||
            this.configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
 | 
			
		||||
            const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
 | 
			
		||||
 | 
			
		||||
            return configStore.get(this.configId);
 | 
			
		||||
            return configStore.get(configId);
 | 
			
		||||
        },
 | 
			
		||||
        registerListeners() {
 | 
			
		||||
            if (this.config) {
 | 
			
		||||
 
 | 
			
		||||
@@ -27,6 +27,7 @@
 | 
			
		||||
    <ul
 | 
			
		||||
        v-if="!isStackedPlotObject"
 | 
			
		||||
        class="c-tree"
 | 
			
		||||
        aria-label="Plot Series Properties"
 | 
			
		||||
    >
 | 
			
		||||
        <h2 title="Display properties for this object">Plot Series</h2>
 | 
			
		||||
        <li
 | 
			
		||||
@@ -53,7 +54,6 @@
 | 
			
		||||
    >
 | 
			
		||||
        <h2 title="Legend options">Legend</h2>
 | 
			
		||||
        <legend-form
 | 
			
		||||
            v-if="plotSeries.length"
 | 
			
		||||
            class="grid-properties"
 | 
			
		||||
            :legend="config.legend"
 | 
			
		||||
        />
 | 
			
		||||
@@ -97,20 +97,23 @@ export default {
 | 
			
		||||
    mounted() {
 | 
			
		||||
        eventHelpers.extend(this);
 | 
			
		||||
        this.config = this.getConfig();
 | 
			
		||||
        this.yAxes = [{
 | 
			
		||||
            id: this.config.yAxis.id,
 | 
			
		||||
            seriesCount: 0
 | 
			
		||||
        }];
 | 
			
		||||
        if (this.config.additionalYAxes) {
 | 
			
		||||
            this.yAxes = this.yAxes.concat(this.config.additionalYAxes.map(yAxis => {
 | 
			
		||||
                return {
 | 
			
		||||
                    id: yAxis.id,
 | 
			
		||||
                    seriesCount: 0
 | 
			
		||||
                };
 | 
			
		||||
            }));
 | 
			
		||||
        if (!this.isStackedPlotObject) {
 | 
			
		||||
            this.yAxes = [{
 | 
			
		||||
                id: this.config.yAxis.id,
 | 
			
		||||
                seriesCount: 0
 | 
			
		||||
            }];
 | 
			
		||||
            if (this.config.additionalYAxes) {
 | 
			
		||||
                this.yAxes = this.yAxes.concat(this.config.additionalYAxes.map(yAxis => {
 | 
			
		||||
                    return {
 | 
			
		||||
                        id: yAxis.id,
 | 
			
		||||
                        seriesCount: 0
 | 
			
		||||
                    };
 | 
			
		||||
                }));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.registerListeners();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.registerListeners();
 | 
			
		||||
        this.loaded = true;
 | 
			
		||||
    },
 | 
			
		||||
    beforeDestroy() {
 | 
			
		||||
@@ -150,23 +153,50 @@ export default {
 | 
			
		||||
 | 
			
		||||
        addSeries(series, index) {
 | 
			
		||||
            const yAxisId = series.get('yAxisId');
 | 
			
		||||
            this.updateAxisUsageCount(yAxisId, 1);
 | 
			
		||||
            this.incrementAxisUsageCount(yAxisId);
 | 
			
		||||
            this.$set(this.plotSeries, index, series);
 | 
			
		||||
            this.setYAxisLabel(yAxisId);
 | 
			
		||||
 | 
			
		||||
            if (this.isStackedPlotObject) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // If the series moves to a different yAxis, update the seriesCounts for both yAxes
 | 
			
		||||
            // so we can display the configuration options for all used yAxes
 | 
			
		||||
            this.listenTo(series, 'change:yAxisId', (newYAxisId, oldYAxisId) => {
 | 
			
		||||
                this.incrementAxisUsageCount(newYAxisId);
 | 
			
		||||
                this.decrementAxisUsageCount(oldYAxisId);
 | 
			
		||||
            }, this);
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        removeSeries(series, index) {
 | 
			
		||||
            const yAxisId = series.get('yAxisId');
 | 
			
		||||
            this.updateAxisUsageCount(yAxisId, -1);
 | 
			
		||||
            this.decrementAxisUsageCount(yAxisId);
 | 
			
		||||
            this.plotSeries.splice(index, 1);
 | 
			
		||||
            this.setYAxisLabel(yAxisId);
 | 
			
		||||
 | 
			
		||||
            if (this.isStackedPlotObject) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.stopListening(series, 'change:yAxisId');
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        incrementAxisUsageCount(yAxisId) {
 | 
			
		||||
            this.updateAxisUsageCount(yAxisId, 1);
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        decrementAxisUsageCount(yAxisId) {
 | 
			
		||||
            this.updateAxisUsageCount(yAxisId, -1);
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        updateAxisUsageCount(yAxisId, updateCount) {
 | 
			
		||||
            const foundYAxis = this.findYAxisForId(yAxisId);
 | 
			
		||||
            if (foundYAxis) {
 | 
			
		||||
                foundYAxis.seriesCount = foundYAxis.seriesCount + updateCount;
 | 
			
		||||
            if (!foundYAxis) {
 | 
			
		||||
                throw new Error(`yAxis with id ${yAxisId} not found`);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            foundYAxis.seriesCount = foundYAxis.seriesCount + updateCount;
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        updateSeriesConfigForObject(config) {
 | 
			
		||||
 
 | 
			
		||||
@@ -12,11 +12,12 @@ export default function PlotsInspectorViewProvider(openmct) {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            let object = selection[0][0].context.item;
 | 
			
		||||
            let parent = selection[0].length > 1 && selection[0][1].context.item;
 | 
			
		||||
 | 
			
		||||
            const isOverlayPlotObject = object && object.type === 'telemetry.plot.overlay';
 | 
			
		||||
            const isStackedPlotObject = object && object.type === 'telemetry.plot.stacked';
 | 
			
		||||
            const isParentStackedPlotObject = parent && parent.type === 'telemetry.plot.stacked';
 | 
			
		||||
 | 
			
		||||
            return isStackedPlotObject || isOverlayPlotObject;
 | 
			
		||||
            return isOverlayPlotObject || isParentStackedPlotObject;
 | 
			
		||||
        },
 | 
			
		||||
        view: function (selection) {
 | 
			
		||||
            let component;
 | 
			
		||||
 
 | 
			
		||||
@@ -12,12 +12,10 @@ export default function StackedPlotsInspectorViewProvider(openmct) {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const object = selection[0][0].context.item;
 | 
			
		||||
            const parent = selection[0].length > 1 && selection[0][1].context.item;
 | 
			
		||||
 | 
			
		||||
            const isOverlayPlotObject = object && object.type === 'telemetry.plot.overlay';
 | 
			
		||||
            const isParentStackedPlotObject = parent && parent.type === 'telemetry.plot.stacked';
 | 
			
		||||
            const isStackedPlotObject = object && object.type === 'telemetry.plot.stacked';
 | 
			
		||||
 | 
			
		||||
            return !isOverlayPlotObject && isParentStackedPlotObject;
 | 
			
		||||
            return isStackedPlotObject;
 | 
			
		||||
        },
 | 
			
		||||
        view: function (selection) {
 | 
			
		||||
            let component;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,9 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div v-if="loaded">
 | 
			
		||||
    <ul class="l-inspector-part">
 | 
			
		||||
    <ul
 | 
			
		||||
        class="l-inspector-part"
 | 
			
		||||
        :aria-label="id > 1 ? `Y Axis ${id} Properties` : 'Y Axis Properties'"
 | 
			
		||||
    >
 | 
			
		||||
        <h2>Y Axis {{ id > 1 ? id : '' }}</h2>
 | 
			
		||||
        <li class="grid-row">
 | 
			
		||||
            <div
 | 
			
		||||
@@ -78,7 +81,7 @@
 | 
			
		||||
            >Minimum Value</div>
 | 
			
		||||
            <div class="grid-cell value">
 | 
			
		||||
                <input
 | 
			
		||||
                    v-model="rangeMin"
 | 
			
		||||
                    v-model.lazy="rangeMin"
 | 
			
		||||
                    class="c-input--flex"
 | 
			
		||||
                    type="number"
 | 
			
		||||
                    @change="updateForm('range')"
 | 
			
		||||
@@ -91,7 +94,7 @@
 | 
			
		||||
                title="Maximum Y axis value."
 | 
			
		||||
            >Maximum Value</div>
 | 
			
		||||
            <div class="grid-cell value"><input
 | 
			
		||||
                v-model="rangeMax"
 | 
			
		||||
                v-model.lazy="rangeMax"
 | 
			
		||||
                class="c-input--flex"
 | 
			
		||||
                type="number"
 | 
			
		||||
                @change="updateForm('range')"
 | 
			
		||||
@@ -128,6 +131,12 @@ export default {
 | 
			
		||||
            loaded: false
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    beforeDestroy() {
 | 
			
		||||
        if (this.autoscale === false && this.validationErrors.range) {
 | 
			
		||||
            this.autoscale = true;
 | 
			
		||||
            this.updateForm('autoscale');
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
        eventHelpers.extend(this);
 | 
			
		||||
        this.getConfig();
 | 
			
		||||
@@ -169,12 +178,9 @@ export default {
 | 
			
		||||
                    objectPath: `${prefix}.logMode`
 | 
			
		||||
                },
 | 
			
		||||
                range: {
 | 
			
		||||
                    objectPath: `${prefix}.range'`,
 | 
			
		||||
                    objectPath: `${prefix}.range`,
 | 
			
		||||
                    coerce: function coerceRange(range) {
 | 
			
		||||
                        const newRange = {
 | 
			
		||||
                            min: -1,
 | 
			
		||||
                            max: 1
 | 
			
		||||
                        };
 | 
			
		||||
                        const newRange = {};
 | 
			
		||||
 | 
			
		||||
                        if (range && typeof range.min !== 'undefined' && range.min !== null) {
 | 
			
		||||
                            newRange.min = Number(range.min);
 | 
			
		||||
@@ -219,16 +225,18 @@ export default {
 | 
			
		||||
            this.autoscale = this.yAxis.get('autoscale');
 | 
			
		||||
            this.logMode = this.yAxis.get('logMode');
 | 
			
		||||
            this.autoscalePadding = this.yAxis.get('autoscalePadding');
 | 
			
		||||
            const range = this.yAxis.get('range') ?? this.yAxis.get('displayRange');
 | 
			
		||||
            this.rangeMin = range?.min;
 | 
			
		||||
            this.rangeMax = range?.max;
 | 
			
		||||
            const range = this.yAxis.get('range');
 | 
			
		||||
            if (range && range.min !== undefined && range.max !== undefined) {
 | 
			
		||||
                this.rangeMin = range.min;
 | 
			
		||||
                this.rangeMax = range.max;
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        getPrefix() {
 | 
			
		||||
            let prefix = 'yAxis';
 | 
			
		||||
            if (this.isAdditionalYAxis) {
 | 
			
		||||
                let index = -1;
 | 
			
		||||
                if (this.additionalYAxes) {
 | 
			
		||||
                    index = this.additionalYAxes.findIndex((yAxis) => {
 | 
			
		||||
                if (this.domainObject?.configuration?.additionalYAxes) {
 | 
			
		||||
                    index = this.domainObject?.configuration?.additionalYAxes.findIndex((yAxis) => {
 | 
			
		||||
                        return yAxis.id === this.id;
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
@@ -308,6 +316,15 @@ export default {
 | 
			
		||||
                            });
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    //If autoscale is turned off, we must know what the user defined min and max ranges are
 | 
			
		||||
                    if (formKey === 'autoscale' && this.autoscale === false) {
 | 
			
		||||
                        const rangeFormField = this.fields.range;
 | 
			
		||||
                        this.validationErrors.range = rangeFormField.validate?.({
 | 
			
		||||
                            min: this.rangeMin,
 | 
			
		||||
                            max: this.rangeMax
 | 
			
		||||
                        }, this.yAxis);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -49,10 +49,10 @@
 | 
			
		||||
                title="Cursor is point locked. Click anywhere in the plot to unlock."
 | 
			
		||||
            ></div>
 | 
			
		||||
            <plot-legend-item-collapsed
 | 
			
		||||
                v-for="(seriesObject, seriesIndex) in series"
 | 
			
		||||
                :key="`${seriesObject.keyString}-${seriesIndex}`"
 | 
			
		||||
                v-for="(seriesObject, seriesIndex) in seriesModels"
 | 
			
		||||
                :key="`${seriesObject.keyString}-${seriesIndex}-collapsed`"
 | 
			
		||||
                :highlights="highlights"
 | 
			
		||||
                :value-to-show-when-collapsed="legend.get('valueToShowWhenCollapsed')"
 | 
			
		||||
                :value-to-show-when-collapsed="valueToShowWhenCollapsed"
 | 
			
		||||
                :series-object="seriesObject"
 | 
			
		||||
                @legendHoverChanged="legendHoverChanged"
 | 
			
		||||
            />
 | 
			
		||||
@@ -95,11 +95,10 @@
 | 
			
		||||
                </thead>
 | 
			
		||||
                <tbody>
 | 
			
		||||
                    <plot-legend-item-expanded
 | 
			
		||||
                        v-for="(seriesObject, seriesIndex) in series"
 | 
			
		||||
                        v-for="(seriesObject, seriesIndex) in seriesModels"
 | 
			
		||||
                        :key="`${seriesObject.keyString}-${seriesIndex}-expanded`"
 | 
			
		||||
                        :series-object="seriesObject"
 | 
			
		||||
                        :highlights="highlights"
 | 
			
		||||
                        :legend="legend"
 | 
			
		||||
                        @legendHoverChanged="legendHoverChanged"
 | 
			
		||||
                    />
 | 
			
		||||
                </tbody>
 | 
			
		||||
@@ -111,6 +110,9 @@
 | 
			
		||||
<script>
 | 
			
		||||
import PlotLegendItemCollapsed from "./PlotLegendItemCollapsed.vue";
 | 
			
		||||
import PlotLegendItemExpanded from "./PlotLegendItemExpanded.vue";
 | 
			
		||||
import configStore from "../configuration/ConfigStore";
 | 
			
		||||
import eventHelpers from "../lib/eventHelpers";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    components: {
 | 
			
		||||
        PlotLegendItemExpanded,
 | 
			
		||||
@@ -124,57 +126,120 @@ export default {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        series: {
 | 
			
		||||
            type: Array,
 | 
			
		||||
            default() {
 | 
			
		||||
                return [];
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        highlights: {
 | 
			
		||||
            type: Array,
 | 
			
		||||
            default() {
 | 
			
		||||
                return [];
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        legend: {
 | 
			
		||||
            type: Object,
 | 
			
		||||
            default() {
 | 
			
		||||
                return {};
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    data() {
 | 
			
		||||
        return {
 | 
			
		||||
            isLegendExpanded: this.legend.get('expanded') === true
 | 
			
		||||
            isLegendExpanded: false,
 | 
			
		||||
            seriesModels: [],
 | 
			
		||||
            loaded: false
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
        showUnitsWhenExpanded() {
 | 
			
		||||
            return this.legend.get('showUnitsWhenExpanded') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('showUnitsWhenExpanded') === true;
 | 
			
		||||
        },
 | 
			
		||||
        showMinimumWhenExpanded() {
 | 
			
		||||
            return this.legend.get('showMinimumWhenExpanded') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('showMinimumWhenExpanded') === true;
 | 
			
		||||
        },
 | 
			
		||||
        showMaximumWhenExpanded() {
 | 
			
		||||
            return this.legend.get('showMaximumWhenExpanded') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('showMaximumWhenExpanded') === true;
 | 
			
		||||
        },
 | 
			
		||||
        showValueWhenExpanded() {
 | 
			
		||||
            return this.legend.get('showValueWhenExpanded') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('showValueWhenExpanded') === true;
 | 
			
		||||
        },
 | 
			
		||||
        showTimestampWhenExpanded() {
 | 
			
		||||
            return this.legend.get('showTimestampWhenExpanded') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('showTimestampWhenExpanded') === true;
 | 
			
		||||
        },
 | 
			
		||||
        isLegendHidden() {
 | 
			
		||||
            return this.legend.get('hideLegendWhenSmall') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('hideLegendWhenSmall') === true;
 | 
			
		||||
        },
 | 
			
		||||
        valueToShowWhenCollapsed() {
 | 
			
		||||
            return this.loaded && this.legend.get('valueToShowWhenCollapsed');
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
        this.seriesModels = [];
 | 
			
		||||
        eventHelpers.extend(this);
 | 
			
		||||
        this.config = this.getConfig();
 | 
			
		||||
        this.legend = this.config.legend;
 | 
			
		||||
        this.loaded = true;
 | 
			
		||||
        this.isLegendExpanded = this.legend.get('expanded') === true;
 | 
			
		||||
        this.listenTo(this.config.legend, 'change:position', this.updatePosition, this);
 | 
			
		||||
        this.updatePosition();
 | 
			
		||||
 | 
			
		||||
        this.initialize();
 | 
			
		||||
    },
 | 
			
		||||
    beforeDestroy() {
 | 
			
		||||
        if (this.objectComposition) {
 | 
			
		||||
            this.objectComposition.off('add', this.addTelemetryObject);
 | 
			
		||||
            this.objectComposition.off('remove', this.removeTelemetryObject);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.stopListening();
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        initialize() {
 | 
			
		||||
            if (this.domainObject.type === 'telemetry.plot.stacked') {
 | 
			
		||||
                this.objectComposition = this.openmct.composition.get(this.domainObject);
 | 
			
		||||
                this.objectComposition.on('add', this.addTelemetryObject);
 | 
			
		||||
                this.objectComposition.on('remove', this.removeTelemetryObject);
 | 
			
		||||
                this.objectComposition.load();
 | 
			
		||||
            } else {
 | 
			
		||||
                this.registerListeners(this.config);
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        getConfig() {
 | 
			
		||||
            const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
 | 
			
		||||
 | 
			
		||||
            return configStore.get(configId);
 | 
			
		||||
        },
 | 
			
		||||
        addTelemetryObject(object) {
 | 
			
		||||
            //get the config for each child
 | 
			
		||||
            const configId = this.openmct.objects.makeKeyString(object.identifier);
 | 
			
		||||
            const config = configStore.get(configId);
 | 
			
		||||
            if (config) {
 | 
			
		||||
                this.registerListeners(config);
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        removeTelemetryObject(identifier) {
 | 
			
		||||
            const configId = this.openmct.objects.makeKeyString(identifier);
 | 
			
		||||
            const config = configStore.get(configId);
 | 
			
		||||
            if (config) {
 | 
			
		||||
                config.series.forEach(this.removeSeries, this);
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        registerListeners(config) {
 | 
			
		||||
        //listen to any changes to the telemetry endpoints that are associated with the child
 | 
			
		||||
            this.listenTo(config.series, 'add', this.addSeries, this);
 | 
			
		||||
            this.listenTo(config.series, 'remove', this.removeSeries, this);
 | 
			
		||||
            config.series.forEach(this.addSeries, this);
 | 
			
		||||
        },
 | 
			
		||||
        addSeries(series) {
 | 
			
		||||
            this.$set(this.seriesModels, this.seriesModels.length, series);
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        removeSeries(plotSeries) {
 | 
			
		||||
            this.stopListening(plotSeries);
 | 
			
		||||
 | 
			
		||||
            const seriesIndex = this.seriesModels.findIndex(series => series.keyString === plotSeries.keyString);
 | 
			
		||||
            this.seriesModels.splice(seriesIndex, 1);
 | 
			
		||||
        },
 | 
			
		||||
        expandLegend() {
 | 
			
		||||
            this.isLegendExpanded = !this.isLegendExpanded;
 | 
			
		||||
            this.legend.set('expanded', this.isLegendExpanded);
 | 
			
		||||
            this.$emit('expanded', this.isLegendExpanded);
 | 
			
		||||
        },
 | 
			
		||||
        legendHoverChanged(data) {
 | 
			
		||||
            this.$emit('legendHoverChanged', data);
 | 
			
		||||
        },
 | 
			
		||||
        updatePosition() {
 | 
			
		||||
            this.$emit('position', this.legend.get('position'));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -57,15 +57,12 @@
 | 
			
		||||
import {getLimitClass} from "@/plugins/plot/chart/limitUtil";
 | 
			
		||||
import eventHelpers from "../lib/eventHelpers";
 | 
			
		||||
import stalenessMixin from '@/ui/mixins/staleness-mixin';
 | 
			
		||||
import configStore from "../configuration/ConfigStore";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    mixins: [stalenessMixin],
 | 
			
		||||
    inject: ['openmct', 'domainObject'],
 | 
			
		||||
    props: {
 | 
			
		||||
        valueToShowWhenCollapsed: {
 | 
			
		||||
            type: String,
 | 
			
		||||
            required: true
 | 
			
		||||
        },
 | 
			
		||||
        seriesObject: {
 | 
			
		||||
            type: Object,
 | 
			
		||||
            required: true,
 | 
			
		||||
@@ -88,10 +85,14 @@ export default {
 | 
			
		||||
            formattedYValue: '',
 | 
			
		||||
            formattedXValue: '',
 | 
			
		||||
            mctLimitStateClass: '',
 | 
			
		||||
            formattedYValueFromStats: ''
 | 
			
		||||
            formattedYValueFromStats: '',
 | 
			
		||||
            loaded: false
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
        valueToShowWhenCollapsed() {
 | 
			
		||||
            return this.loaded ? this.legend.get('valueToShowWhenCollapsed') : [];
 | 
			
		||||
        },
 | 
			
		||||
        valueToDisplayWhenCollapsedClass() {
 | 
			
		||||
            return `value-to-display-${ this.valueToShowWhenCollapsed }`;
 | 
			
		||||
        },
 | 
			
		||||
@@ -109,6 +110,9 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
        eventHelpers.extend(this);
 | 
			
		||||
        this.config = this.getConfig();
 | 
			
		||||
        this.legend = this.config.legend;
 | 
			
		||||
        this.loaded = true;
 | 
			
		||||
        this.listenTo(this.seriesObject, 'change:color', (newColor) => {
 | 
			
		||||
            this.updateColor(newColor);
 | 
			
		||||
        }, this);
 | 
			
		||||
@@ -122,8 +126,13 @@ export default {
 | 
			
		||||
        this.stopListening();
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        getConfig() {
 | 
			
		||||
            const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
 | 
			
		||||
 | 
			
		||||
            return configStore.get(configId);
 | 
			
		||||
        },
 | 
			
		||||
        initialize(highlightedObject) {
 | 
			
		||||
            const seriesObject = highlightedObject ? highlightedObject.series : this.seriesObject;
 | 
			
		||||
            const seriesObject = highlightedObject?.series || this.seriesObject;
 | 
			
		||||
 | 
			
		||||
            this.isMissing = seriesObject.domainObject.status === 'missing';
 | 
			
		||||
            this.colorAsHexString = seriesObject.get('color').asHexString();
 | 
			
		||||
 
 | 
			
		||||
@@ -83,6 +83,7 @@
 | 
			
		||||
import {getLimitClass} from "@/plugins/plot/chart/limitUtil";
 | 
			
		||||
import eventHelpers from "@/plugins/plot/lib/eventHelpers";
 | 
			
		||||
import stalenessMixin from '@/ui/mixins/staleness-mixin';
 | 
			
		||||
import configStore from "../configuration/ConfigStore";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    mixins: [stalenessMixin],
 | 
			
		||||
@@ -100,10 +101,6 @@ export default {
 | 
			
		||||
            default() {
 | 
			
		||||
                return [];
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        legend: {
 | 
			
		||||
            type: Object,
 | 
			
		||||
            required: true
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    data() {
 | 
			
		||||
@@ -116,24 +113,25 @@ export default {
 | 
			
		||||
            formattedXValue: '',
 | 
			
		||||
            formattedMinY: '',
 | 
			
		||||
            formattedMaxY: '',
 | 
			
		||||
            mctLimitStateClass: ''
 | 
			
		||||
            mctLimitStateClass: '',
 | 
			
		||||
            loaded: false
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
        showUnitsWhenExpanded() {
 | 
			
		||||
            return this.legend.get('showUnitsWhenExpanded') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('showUnitsWhenExpanded') === true;
 | 
			
		||||
        },
 | 
			
		||||
        showMinimumWhenExpanded() {
 | 
			
		||||
            return this.legend.get('showMinimumWhenExpanded') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('showMinimumWhenExpanded') === true;
 | 
			
		||||
        },
 | 
			
		||||
        showMaximumWhenExpanded() {
 | 
			
		||||
            return this.legend.get('showMaximumWhenExpanded') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('showMaximumWhenExpanded') === true;
 | 
			
		||||
        },
 | 
			
		||||
        showValueWhenExpanded() {
 | 
			
		||||
            return this.legend.get('showValueWhenExpanded') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('showValueWhenExpanded') === true;
 | 
			
		||||
        },
 | 
			
		||||
        showTimestampWhenExpanded() {
 | 
			
		||||
            return this.legend.get('showTimestampWhenExpanded') === true;
 | 
			
		||||
            return this.loaded && this.legend.get('showTimestampWhenExpanded') === true;
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    watch: {
 | 
			
		||||
@@ -146,6 +144,9 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
        eventHelpers.extend(this);
 | 
			
		||||
        this.config = this.getConfig();
 | 
			
		||||
        this.legend = this.config.legend;
 | 
			
		||||
        this.loaded = true;
 | 
			
		||||
        this.listenTo(this.seriesObject, 'change:color', (newColor) => {
 | 
			
		||||
            this.updateColor(newColor);
 | 
			
		||||
        }, this);
 | 
			
		||||
@@ -159,8 +160,13 @@ export default {
 | 
			
		||||
        this.stopListening();
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        getConfig() {
 | 
			
		||||
            const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
 | 
			
		||||
 | 
			
		||||
            return configStore.get(configId);
 | 
			
		||||
        },
 | 
			
		||||
        initialize(highlightedObject) {
 | 
			
		||||
            const seriesObject = highlightedObject ? highlightedObject.series : this.seriesObject;
 | 
			
		||||
            const seriesObject = highlightedObject?.series || this.seriesObject;
 | 
			
		||||
 | 
			
		||||
            this.isMissing = seriesObject.domainObject.status === 'missing';
 | 
			
		||||
            this.colorAsHexString = seriesObject.get('color').asHexString();
 | 
			
		||||
 
 | 
			
		||||
@@ -28,7 +28,7 @@ import EventEmitter from "EventEmitter";
 | 
			
		||||
import PlotOptions from "./inspector/PlotOptions.vue";
 | 
			
		||||
import PlotConfigurationModel from "./configuration/PlotConfigurationModel";
 | 
			
		||||
 | 
			
		||||
const TEST_KEY_ID = 'test-key';
 | 
			
		||||
const TEST_KEY_ID = 'some-other-key';
 | 
			
		||||
 | 
			
		||||
describe("the plugin", function () {
 | 
			
		||||
    let element;
 | 
			
		||||
@@ -533,6 +533,30 @@ describe("the plugin", function () {
 | 
			
		||||
                expect(openmct.telemetry.request).toHaveBeenCalledTimes(2);
 | 
			
		||||
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            describe('limits', () => {
 | 
			
		||||
 | 
			
		||||
                it('lines are not displayed by default', () => {
 | 
			
		||||
                    let limitEl = element.querySelectorAll(".js-limit-area .js-limit-line");
 | 
			
		||||
                    expect(limitEl.length).toBe(0);
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                it('lines are displayed when configuration is set to true', (done) => {
 | 
			
		||||
                    const configId = openmct.objects.makeKeyString(testTelemetryObject.identifier);
 | 
			
		||||
                    const config = configStore.get(configId);
 | 
			
		||||
                    config.yAxis.set('displayRange', {
 | 
			
		||||
                        min: 0,
 | 
			
		||||
                        max: 4
 | 
			
		||||
                    });
 | 
			
		||||
                    config.series.models[0].set('limitLines', true);
 | 
			
		||||
 | 
			
		||||
                    Vue.nextTick(() => {
 | 
			
		||||
                        let limitEl = element.querySelectorAll(".js-limit-area .js-limit-line");
 | 
			
		||||
                        expect(limitEl.length).toBe(4);
 | 
			
		||||
                        done();
 | 
			
		||||
                    });
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        describe('controls in time strip view', () => {
 | 
			
		||||
@@ -867,24 +891,5 @@ describe("the plugin", function () {
 | 
			
		||||
                expect(colorSwatch).toBeDefined();
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        describe('limits', () => {
 | 
			
		||||
 | 
			
		||||
            it('lines are not displayed by default', () => {
 | 
			
		||||
                let limitEl = element.querySelectorAll(".js-limit-area .js-limit-line");
 | 
			
		||||
                expect(limitEl.length).toBe(0);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            xit('lines are displayed when configuration is set to true', (done) => {
 | 
			
		||||
                config.series.models[0].set('limitLines', true);
 | 
			
		||||
 | 
			
		||||
                Vue.nextTick(() => {
 | 
			
		||||
                    let limitEl = element.querySelectorAll(".js-limit-area .js-limit-line");
 | 
			
		||||
                    expect(limitEl.length).toBe(4);
 | 
			
		||||
                    done();
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -27,31 +27,34 @@
 | 
			
		||||
    :class="[plotLegendExpandedStateClass, plotLegendPositionClass]"
 | 
			
		||||
>
 | 
			
		||||
    <plot-legend
 | 
			
		||||
        v-if="compositionObjectsConfigLoaded"
 | 
			
		||||
        :cursor-locked="!!lockHighlightPoint"
 | 
			
		||||
        :series="seriesModels"
 | 
			
		||||
        :highlights="highlights"
 | 
			
		||||
        :legend="legend"
 | 
			
		||||
        @legendHoverChanged="legendHoverChanged"
 | 
			
		||||
        @expanded="updateExpanded"
 | 
			
		||||
        @position="updatePosition"
 | 
			
		||||
    />
 | 
			
		||||
    <div class="l-view-section">
 | 
			
		||||
    <div
 | 
			
		||||
        class="l-view-section"
 | 
			
		||||
    >
 | 
			
		||||
        <stacked-plot-item
 | 
			
		||||
            v-for="object in compositionObjects"
 | 
			
		||||
            :key="object.id"
 | 
			
		||||
            v-for="objectWrapper in compositionObjects"
 | 
			
		||||
            :key="objectWrapper.keyString"
 | 
			
		||||
            class="c-plot--stacked-container"
 | 
			
		||||
            :child-object="object"
 | 
			
		||||
            :child-object="objectWrapper.object"
 | 
			
		||||
            :options="options"
 | 
			
		||||
            :grid-lines="gridLines"
 | 
			
		||||
            :color-palette="colorPalette"
 | 
			
		||||
            :cursor-guide="cursorGuide"
 | 
			
		||||
            :show-limit-line-labels="showLimitLineLabels"
 | 
			
		||||
            :plot-tick-width="maxTickWidth"
 | 
			
		||||
            @plotTickWidth="onTickWidthChange"
 | 
			
		||||
            :parent-y-tick-width="maxTickWidth"
 | 
			
		||||
            @plotYTickWidth="onYTickWidthChange"
 | 
			
		||||
            @loadingUpdated="loadingUpdated"
 | 
			
		||||
            @cursorGuide="onCursorGuideChange"
 | 
			
		||||
            @gridLines="onGridLinesChange"
 | 
			
		||||
            @lockHighlightPoint="lockHighlightPointUpdated"
 | 
			
		||||
            @highlights="highlightsUpdated"
 | 
			
		||||
            @configLoaded="registerSeriesListeners"
 | 
			
		||||
            @configLoaded="configLoadedForObject(objectWrapper.keyString)"
 | 
			
		||||
        />
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -66,14 +69,13 @@ import ColorPalette from "@/ui/color/ColorPalette";
 | 
			
		||||
import PlotLegend from "../legend/PlotLegend.vue";
 | 
			
		||||
import StackedPlotItem from './StackedPlotItem.vue';
 | 
			
		||||
import ImageExporter from '../../../exporters/ImageExporter';
 | 
			
		||||
import eventHelpers from "@/plugins/plot/lib/eventHelpers";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    components: {
 | 
			
		||||
        StackedPlotItem,
 | 
			
		||||
        PlotLegend
 | 
			
		||||
    },
 | 
			
		||||
    inject: ['openmct', 'domainObject', 'composition', 'path'],
 | 
			
		||||
    inject: ['openmct', 'domainObject', 'path'],
 | 
			
		||||
    props: {
 | 
			
		||||
        options: {
 | 
			
		||||
            type: Object,
 | 
			
		||||
@@ -87,48 +89,59 @@ export default {
 | 
			
		||||
            hideExportButtons: false,
 | 
			
		||||
            cursorGuide: false,
 | 
			
		||||
            gridLines: true,
 | 
			
		||||
            loading: false,
 | 
			
		||||
            configLoaded: {},
 | 
			
		||||
            compositionObjects: [],
 | 
			
		||||
            tickWidthMap: {},
 | 
			
		||||
            legend: {},
 | 
			
		||||
            loaded: false,
 | 
			
		||||
            lockHighlightPoint: false,
 | 
			
		||||
            highlights: [],
 | 
			
		||||
            seriesModels: [],
 | 
			
		||||
            showLimitLineLabels: undefined,
 | 
			
		||||
            colorPalette: new ColorPalette()
 | 
			
		||||
            colorPalette: new ColorPalette(),
 | 
			
		||||
            compositionObjectsConfigLoaded: false,
 | 
			
		||||
            position: 'top',
 | 
			
		||||
            expanded: false
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
        plotLegendPositionClass() {
 | 
			
		||||
            return `plot-legend-${this.config.legend.get('position')}`;
 | 
			
		||||
            return `plot-legend-${this.position}`;
 | 
			
		||||
        },
 | 
			
		||||
        plotLegendExpandedStateClass() {
 | 
			
		||||
            if (this.config.legend.get('expanded')) {
 | 
			
		||||
            if (this.expanded) {
 | 
			
		||||
                return 'plot-legend-expanded';
 | 
			
		||||
            } else {
 | 
			
		||||
                return 'plot-legend-collapsed';
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
       * Returns the maximum width of the left and right y axes ticks of this stacked plots children
 | 
			
		||||
       * @returns {{rightTickWidth: number, leftTickWidth: number, hasMultipleLeftAxes: boolean}}
 | 
			
		||||
       */
 | 
			
		||||
        maxTickWidth() {
 | 
			
		||||
            return Math.max(...Object.values(this.tickWidthMap));
 | 
			
		||||
            const tickWidthValues = Object.values(this.tickWidthMap);
 | 
			
		||||
            const maxLeftTickWidth = Math.max(...tickWidthValues.map(tickWidthItem => tickWidthItem.leftTickWidth));
 | 
			
		||||
            const maxRightTickWidth = Math.max(...tickWidthValues.map(tickWidthItem => tickWidthItem.rightTickWidth));
 | 
			
		||||
            const hasMultipleLeftAxes = tickWidthValues.some(tickWidthItem => tickWidthItem.hasMultipleLeftAxes === true);
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                leftTickWidth: maxLeftTickWidth,
 | 
			
		||||
                rightTickWidth: maxRightTickWidth,
 | 
			
		||||
                hasMultipleLeftAxes
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    beforeDestroy() {
 | 
			
		||||
        this.destroy();
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
        eventHelpers.extend(this);
 | 
			
		||||
        this.seriesConfig = {};
 | 
			
		||||
 | 
			
		||||
        //We only need to initialize the stacked plot config for legend properties
 | 
			
		||||
        const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
 | 
			
		||||
        this.config = this.getConfig(configId);
 | 
			
		||||
 | 
			
		||||
        this.legend = this.config.legend;
 | 
			
		||||
 | 
			
		||||
        this.loaded = true;
 | 
			
		||||
        this.imageExporter = new ImageExporter(this.openmct);
 | 
			
		||||
 | 
			
		||||
        this.composition = this.openmct.composition.get(this.domainObject);
 | 
			
		||||
        this.composition.on('add', this.addChild);
 | 
			
		||||
        this.composition.on('remove', this.removeChild);
 | 
			
		||||
        this.composition.on('reorder', this.compositionReorder);
 | 
			
		||||
@@ -142,7 +155,6 @@ export default {
 | 
			
		||||
                    id: configId,
 | 
			
		||||
                    domainObject: this.domainObject,
 | 
			
		||||
                    openmct: this.openmct,
 | 
			
		||||
                    palette: this.colorPalette,
 | 
			
		||||
                    callback: (data) => {
 | 
			
		||||
                        this.data = data;
 | 
			
		||||
                    }
 | 
			
		||||
@@ -155,10 +167,19 @@ export default {
 | 
			
		||||
        loadingUpdated(loaded) {
 | 
			
		||||
            this.loading = loaded;
 | 
			
		||||
        },
 | 
			
		||||
        destroy() {
 | 
			
		||||
            this.stopListening();
 | 
			
		||||
            configStore.deleteStore(this.config.id);
 | 
			
		||||
        configLoadedForObject(childObjIdentifier) {
 | 
			
		||||
            const childObjId = this.openmct.objects.makeKeyString(childObjIdentifier);
 | 
			
		||||
            this.configLoaded[childObjId] = true;
 | 
			
		||||
            this.setConfigLoadedForComposition();
 | 
			
		||||
        },
 | 
			
		||||
        setConfigLoadedForComposition() {
 | 
			
		||||
            this.compositionObjectsConfigLoaded = this.compositionObjects.length && this.compositionObjects.every(childObject => {
 | 
			
		||||
                const id = childObject.keyString;
 | 
			
		||||
 | 
			
		||||
                return this.configLoaded[id] === true;
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        destroy() {
 | 
			
		||||
            this.composition.off('add', this.addChild);
 | 
			
		||||
            this.composition.off('remove', this.removeChild);
 | 
			
		||||
            this.composition.off('reorder', this.compositionReorder);
 | 
			
		||||
@@ -167,9 +188,16 @@ export default {
 | 
			
		||||
        addChild(child) {
 | 
			
		||||
            const id = this.openmct.objects.makeKeyString(child.identifier);
 | 
			
		||||
 | 
			
		||||
            this.$set(this.tickWidthMap, id, 0);
 | 
			
		||||
            this.$set(this.tickWidthMap, id, {
 | 
			
		||||
                leftTickWidth: 0,
 | 
			
		||||
                rightTickWidth: 0
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.compositionObjects.push(child);
 | 
			
		||||
            this.compositionObjects.push({
 | 
			
		||||
                object: child,
 | 
			
		||||
                keyString: id
 | 
			
		||||
            });
 | 
			
		||||
            this.setConfigLoadedForComposition();
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        removeChild(childIdentifier) {
 | 
			
		||||
@@ -177,26 +205,36 @@ export default {
 | 
			
		||||
 | 
			
		||||
            this.$delete(this.tickWidthMap, id);
 | 
			
		||||
 | 
			
		||||
            const childObj = this.compositionObjects.filter((c) => {
 | 
			
		||||
                const identifier = c.keyString;
 | 
			
		||||
 | 
			
		||||
                return identifier === id;
 | 
			
		||||
            })[0];
 | 
			
		||||
 | 
			
		||||
            if (childObj) {
 | 
			
		||||
                if (childObj.object.type !== 'telemetry.plot.overlay') {
 | 
			
		||||
                    const config = this.getConfig(childObj.keyString);
 | 
			
		||||
                    if (config) {
 | 
			
		||||
                        config.series.remove(config.series.at(0));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.compositionObjects = this.compositionObjects.filter((c) => {
 | 
			
		||||
                const identifier = c.keyString;
 | 
			
		||||
 | 
			
		||||
                return identifier !== id;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            const configIndex = this.domainObject.configuration.series.findIndex((seriesConfig) => {
 | 
			
		||||
                return this.openmct.objects.areIdsEqual(seriesConfig.identifier, childIdentifier);
 | 
			
		||||
            });
 | 
			
		||||
            if (configIndex > -1) {
 | 
			
		||||
                this.domainObject.configuration.series.splice(configIndex, 1);
 | 
			
		||||
                const cSeries = this.domainObject.configuration.series.slice();
 | 
			
		||||
                this.openmct.objects.mutate(this.domainObject, 'configuration.series', cSeries);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.removeSeries({
 | 
			
		||||
                keyString: id
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            const childObj = this.compositionObjects.filter((c) => {
 | 
			
		||||
                const identifier = this.openmct.objects.makeKeyString(c.identifier);
 | 
			
		||||
 | 
			
		||||
                return identifier === id;
 | 
			
		||||
            })[0];
 | 
			
		||||
            if (childObj) {
 | 
			
		||||
                const index = this.compositionObjects.indexOf(childObj);
 | 
			
		||||
                this.compositionObjects.splice(index, 1);
 | 
			
		||||
            }
 | 
			
		||||
            this.setConfigLoadedForComposition();
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        compositionReorder(reorderPlan) {
 | 
			
		||||
@@ -209,7 +247,10 @@ export default {
 | 
			
		||||
 | 
			
		||||
        resetTelemetryAndTicks(domainObject) {
 | 
			
		||||
            this.compositionObjects = [];
 | 
			
		||||
            this.tickWidthMap = {};
 | 
			
		||||
            this.tickWidthMap = {
 | 
			
		||||
                leftTickWidth: 0,
 | 
			
		||||
                rightTickWidth: 0
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        exportJPG() {
 | 
			
		||||
@@ -232,12 +273,18 @@ export default {
 | 
			
		||||
                    this.hideExportButtons = false;
 | 
			
		||||
                }.bind(this));
 | 
			
		||||
        },
 | 
			
		||||
        onTickWidthChange(width, plotId) {
 | 
			
		||||
        /**
 | 
			
		||||
         * @typedef {Object} PlotYTickData
 | 
			
		||||
         * @property {Number} leftTickWidth the width of the ticks for all the y axes on the left of the plot.
 | 
			
		||||
         * @property {Number} rightTickWidth the width of the ticks for all the y axes on the right of the plot.
 | 
			
		||||
         * @property {Boolean} hasMultipleLeftAxes whether or not there is more than one left y axis.
 | 
			
		||||
         */
 | 
			
		||||
        onYTickWidthChange(data, plotId) {
 | 
			
		||||
            if (!Object.prototype.hasOwnProperty.call(this.tickWidthMap, plotId)) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.$set(this.tickWidthMap, plotId, width);
 | 
			
		||||
            this.$set(this.tickWidthMap, plotId, data);
 | 
			
		||||
        },
 | 
			
		||||
        legendHoverChanged(data) {
 | 
			
		||||
            this.showLimitLineLabels = data;
 | 
			
		||||
@@ -245,39 +292,18 @@ export default {
 | 
			
		||||
        lockHighlightPointUpdated(data) {
 | 
			
		||||
            this.lockHighlightPoint = data;
 | 
			
		||||
        },
 | 
			
		||||
        updateExpanded(expanded) {
 | 
			
		||||
            this.expanded = expanded;
 | 
			
		||||
        },
 | 
			
		||||
        updatePosition(position) {
 | 
			
		||||
            this.position = position;
 | 
			
		||||
        },
 | 
			
		||||
        updateReady(ready) {
 | 
			
		||||
            this.configReady = ready;
 | 
			
		||||
        },
 | 
			
		||||
        highlightsUpdated(data) {
 | 
			
		||||
            this.highlights = data;
 | 
			
		||||
        },
 | 
			
		||||
        registerSeriesListeners(configId) {
 | 
			
		||||
            const config = this.getConfig(configId);
 | 
			
		||||
            this.seriesConfig[configId] = config;
 | 
			
		||||
            const childObject = config.get('domainObject');
 | 
			
		||||
 | 
			
		||||
            //TODO differentiate between objects with composition and those without
 | 
			
		||||
            if (childObject.type === 'telemetry.plot.overlay') {
 | 
			
		||||
                this.listenTo(config.series, 'add', this.addSeries, this);
 | 
			
		||||
                this.listenTo(config.series, 'remove', this.removeSeries, this);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            config.series.models.forEach(this.addSeries, this);
 | 
			
		||||
        },
 | 
			
		||||
        addSeries(series) {
 | 
			
		||||
            const childObject = series.domainObject;
 | 
			
		||||
            //don't add the series if it can have child series this will happen in registerSeriesListeners
 | 
			
		||||
            if (childObject.type !== 'telemetry.plot.overlay') {
 | 
			
		||||
                const index = this.seriesModels.length;
 | 
			
		||||
                this.$set(this.seriesModels, index, series);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        },
 | 
			
		||||
        removeSeries(plotSeries) {
 | 
			
		||||
            const index = this.seriesModels.findIndex(seriesModel => seriesModel.keyString === plotSeries.keyString);
 | 
			
		||||
            if (index > -1) {
 | 
			
		||||
                this.$delete(this.seriesModels, index);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.stopListening(plotSeries);
 | 
			
		||||
        },
 | 
			
		||||
        onCursorGuideChange(cursorGuide) {
 | 
			
		||||
            this.cursorGuide = cursorGuide === true;
 | 
			
		||||
        },
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,9 @@
 | 
			
		||||
 at runtime from the About dialog for additional information.
 | 
			
		||||
-->
 | 
			
		||||
<template>
 | 
			
		||||
<div></div>
 | 
			
		||||
<div
 | 
			
		||||
    :aria-label="`Stacked Plot Item ${childObject.name}`"
 | 
			
		||||
></div>
 | 
			
		||||
</template>
 | 
			
		||||
<script>
 | 
			
		||||
 | 
			
		||||
@@ -28,6 +30,7 @@ import MctPlot from '../MctPlot.vue';
 | 
			
		||||
import Vue from "vue";
 | 
			
		||||
import conditionalStylesMixin from "./mixins/objectStyles-mixin";
 | 
			
		||||
import stalenessMixin from '@/ui/mixins/staleness-mixin';
 | 
			
		||||
import StalenessUtils from '@/utils/staleness';
 | 
			
		||||
import configStore from "@/plugins/plot/configuration/ConfigStore";
 | 
			
		||||
import PlotConfigurationModel from "@/plugins/plot/configuration/PlotConfigurationModel";
 | 
			
		||||
import ProgressBar from "../../../ui/components/ProgressBar.vue";
 | 
			
		||||
@@ -72,13 +75,22 @@ export default {
 | 
			
		||||
                return undefined;
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        plotTickWidth: {
 | 
			
		||||
            type: Number,
 | 
			
		||||
        parentYTickWidth: {
 | 
			
		||||
            type: Object,
 | 
			
		||||
            default() {
 | 
			
		||||
                return 0;
 | 
			
		||||
                return {
 | 
			
		||||
                    leftTickWidth: 0,
 | 
			
		||||
                    rightTickWidth: 0,
 | 
			
		||||
                    hasMultipleLeftAxes: false
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    data() {
 | 
			
		||||
        return {
 | 
			
		||||
            staleObjects: []
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    watch: {
 | 
			
		||||
        gridLines(newGridLines) {
 | 
			
		||||
            this.updateComponentProp('gridLines', newGridLines);
 | 
			
		||||
@@ -86,20 +98,29 @@ export default {
 | 
			
		||||
        cursorGuide(newCursorGuide) {
 | 
			
		||||
            this.updateComponentProp('cursorGuide', newCursorGuide);
 | 
			
		||||
        },
 | 
			
		||||
        plotTickWidth(width) {
 | 
			
		||||
            this.updateComponentProp('plotTickWidth', width);
 | 
			
		||||
        parentYTickWidth(width) {
 | 
			
		||||
            this.updateComponentProp('parentYTickWidth', width);
 | 
			
		||||
        },
 | 
			
		||||
        showLimitLineLabels: {
 | 
			
		||||
            handler(data) {
 | 
			
		||||
                this.updateComponentProp('limitLineLabels', data);
 | 
			
		||||
            },
 | 
			
		||||
            deep: true
 | 
			
		||||
        },
 | 
			
		||||
        staleObjects() {
 | 
			
		||||
            this.isStale = this.staleObjects.length > 0;
 | 
			
		||||
            this.updateComponentProp('isStale', this.isStale);
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
        this.stalenessSubscription = {};
 | 
			
		||||
        this.updateView();
 | 
			
		||||
        this.isEditing = this.openmct.editor.isEditing();
 | 
			
		||||
        this.openmct.editor.on('isEditing', this.setEditState);
 | 
			
		||||
    },
 | 
			
		||||
    beforeDestroy() {
 | 
			
		||||
        this.openmct.editor.off('isEditing', this.setEditState);
 | 
			
		||||
 | 
			
		||||
        if (this.removeSelectable) {
 | 
			
		||||
            this.removeSelectable();
 | 
			
		||||
        }
 | 
			
		||||
@@ -107,8 +128,22 @@ export default {
 | 
			
		||||
        if (this.component) {
 | 
			
		||||
            this.component.$destroy();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.destroyStalenessListeners();
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        setEditState(isEditing) {
 | 
			
		||||
            this.isEditing = isEditing;
 | 
			
		||||
 | 
			
		||||
            if (this.isEditing) {
 | 
			
		||||
                this.setSelection();
 | 
			
		||||
            } else {
 | 
			
		||||
                if (this.removeSelectable) {
 | 
			
		||||
                    this.removeSelectable();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        updateComponentProp(prop, value) {
 | 
			
		||||
            if (this.component) {
 | 
			
		||||
                this.component[prop] = value;
 | 
			
		||||
@@ -117,15 +152,15 @@ export default {
 | 
			
		||||
        updateView() {
 | 
			
		||||
            this.isStale = false;
 | 
			
		||||
 | 
			
		||||
            this.triggerUnsubscribeFromStaleness();
 | 
			
		||||
            this.destroyStalenessListeners();
 | 
			
		||||
 | 
			
		||||
            if (this.component) {
 | 
			
		||||
                this.component.$destroy();
 | 
			
		||||
                this.component = undefined;
 | 
			
		||||
                this.component = null;
 | 
			
		||||
                this.$el.innerHTML = '';
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const onTickWidthChange = this.onTickWidthChange;
 | 
			
		||||
            const onYTickWidthChange = this.onYTickWidthChange;
 | 
			
		||||
            const onLockHighlightPointUpdated = this.onLockHighlightPointUpdated;
 | 
			
		||||
            const onHighlightsUpdated = this.onHighlightsUpdated;
 | 
			
		||||
            const onConfigLoaded = this.onConfigLoaded;
 | 
			
		||||
@@ -144,9 +179,18 @@ export default {
 | 
			
		||||
            let viewContainer = document.createElement('div');
 | 
			
		||||
            this.$el.append(viewContainer);
 | 
			
		||||
 | 
			
		||||
            this.subscribeToStaleness(object, (isStale) => {
 | 
			
		||||
                this.updateComponentProp('isStale', isStale);
 | 
			
		||||
            });
 | 
			
		||||
            if (this.openmct.telemetry.isTelemetryObject(object)) {
 | 
			
		||||
                this.subscribeToStaleness(object, (isStale) => {
 | 
			
		||||
                    this.updateComponentProp('isStale', isStale);
 | 
			
		||||
                });
 | 
			
		||||
            } else {
 | 
			
		||||
                // possibly overlay or other composition based plot
 | 
			
		||||
                this.composition = this.openmct.composition.get(object);
 | 
			
		||||
 | 
			
		||||
                this.composition.on('add', this.watchStaleness);
 | 
			
		||||
                this.composition.on('remove', this.unwatchStaleness);
 | 
			
		||||
                this.composition.load();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.component = new Vue({
 | 
			
		||||
                el: viewContainer,
 | 
			
		||||
@@ -162,7 +206,7 @@ export default {
 | 
			
		||||
                data() {
 | 
			
		||||
                    return {
 | 
			
		||||
                        ...getProps(),
 | 
			
		||||
                        onTickWidthChange,
 | 
			
		||||
                        onYTickWidthChange,
 | 
			
		||||
                        onLockHighlightPointUpdated,
 | 
			
		||||
                        onHighlightsUpdated,
 | 
			
		||||
                        onConfigLoaded,
 | 
			
		||||
@@ -178,10 +222,72 @@ export default {
 | 
			
		||||
                        this.loading = loaded;
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                template: '<div v-if="!isMissing" ref="plotWrapper" class="l-view-section u-style-receiver js-style-receiver" :class="{\'s-status-timeconductor-unsynced\': status && status === \'timeconductor-unsynced\', \'is-stale\': isStale}"><progress-bar v-show="loading !== false" class="c-telemetry-table__progress-bar" :model="{progressPerc: undefined}" /><mct-plot :init-grid-lines="gridLines" :init-cursor-guide="cursorGuide" :plot-tick-width="plotTickWidth" :limit-line-labels="limitLineLabels" :color-palette="colorPalette" :options="options" @plotTickWidth="onTickWidthChange" @lockHighlightPoint="onLockHighlightPointUpdated" @highlights="onHighlightsUpdated" @configLoaded="onConfigLoaded" @cursorGuide="onCursorGuideChange" @gridLines="onGridLinesChange" @statusUpdated="setStatus" @loadingUpdated="loadingUpdated"/></div>'
 | 
			
		||||
                template: `
 | 
			
		||||
                  <div v-if="!isMissing" ref="plotWrapper"
 | 
			
		||||
                      class="l-view-section u-style-receiver js-style-receiver"
 | 
			
		||||
                      :class="{'s-status-timeconductor-unsynced': status && status === 'timeconductor-unsynced', 'is-stale': isStale}">
 | 
			
		||||
                      <progress-bar
 | 
			
		||||
                          v-show="loading !== false"
 | 
			
		||||
                          class="c-telemetry-table__progress-bar"
 | 
			
		||||
                          :model="{progressPerc: undefined}" />
 | 
			
		||||
                      <mct-plot
 | 
			
		||||
                          :init-grid-lines="gridLines"
 | 
			
		||||
                          :init-cursor-guide="cursorGuide"
 | 
			
		||||
                          :parent-y-tick-width="parentYTickWidth"
 | 
			
		||||
                          :limit-line-labels="limitLineLabels"
 | 
			
		||||
                          :color-palette="colorPalette"
 | 
			
		||||
                          :options="options"
 | 
			
		||||
                          @plotYTickWidth="onYTickWidthChange"
 | 
			
		||||
                          @lockHighlightPoint="onLockHighlightPointUpdated"
 | 
			
		||||
                          @highlights="onHighlightsUpdated"
 | 
			
		||||
                          @configLoaded="onConfigLoaded"
 | 
			
		||||
                          @cursorGuide="onCursorGuideChange"
 | 
			
		||||
                          @gridLines="onGridLinesChange"
 | 
			
		||||
                          @statusUpdated="setStatus"
 | 
			
		||||
                          @loadingUpdated="loadingUpdated"/>
 | 
			
		||||
                  </div>`
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.setSelection();
 | 
			
		||||
            if (this.isEditing) {
 | 
			
		||||
                this.setSelection();
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        watchStaleness(domainObject) {
 | 
			
		||||
            const keyString = this.openmct.objects.makeKeyString(domainObject.identifier);
 | 
			
		||||
            this.stalenessSubscription[keyString] = {};
 | 
			
		||||
            this.stalenessSubscription[keyString].stalenessUtils = new StalenessUtils(this.openmct, domainObject);
 | 
			
		||||
 | 
			
		||||
            this.openmct.telemetry.isStale(domainObject).then((stalenessResponse) => {
 | 
			
		||||
                if (stalenessResponse !== undefined) {
 | 
			
		||||
                    this.handleStaleness(keyString, stalenessResponse);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
            const stalenessSubscription = this.openmct.telemetry.subscribeToStaleness(domainObject, (stalenessResponse) => {
 | 
			
		||||
                this.handleStaleness(keyString, stalenessResponse);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.stalenessSubscription[keyString].unsubscribe = stalenessSubscription;
 | 
			
		||||
        },
 | 
			
		||||
        unwatchStaleness(domainObject) {
 | 
			
		||||
            const SKIP_CHECK = true;
 | 
			
		||||
            const keyString = this.openmct.objects.makeKeyString(domainObject.identifier);
 | 
			
		||||
 | 
			
		||||
            this.stalenessSubscription[keyString].unsubscribe();
 | 
			
		||||
            this.stalenessSubscription[keyString].stalenessUtils.destroy();
 | 
			
		||||
            this.handleStaleness(keyString, { isStale: false }, SKIP_CHECK);
 | 
			
		||||
 | 
			
		||||
            delete this.stalenessSubscription[keyString];
 | 
			
		||||
        },
 | 
			
		||||
        handleStaleness(keyString, stalenessResponse, skipCheck = false) {
 | 
			
		||||
            if (skipCheck || this.stalenessSubscription[keyString].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) {
 | 
			
		||||
                const index = this.staleObjects.indexOf(keyString);
 | 
			
		||||
                const foundStaleObject = index > -1;
 | 
			
		||||
                if (stalenessResponse.isStale && !foundStaleObject) {
 | 
			
		||||
                    this.staleObjects.push(keyString);
 | 
			
		||||
                } else if (!stalenessResponse.isStale && foundStaleObject) {
 | 
			
		||||
                    this.staleObjects.splice(index, 1);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        onLockHighlightPointUpdated() {
 | 
			
		||||
            this.$emit('lockHighlightPoint', ...arguments);
 | 
			
		||||
@@ -192,8 +298,8 @@ export default {
 | 
			
		||||
        onConfigLoaded() {
 | 
			
		||||
            this.$emit('configLoaded', ...arguments);
 | 
			
		||||
        },
 | 
			
		||||
        onTickWidthChange() {
 | 
			
		||||
            this.$emit('plotTickWidth', ...arguments);
 | 
			
		||||
        onYTickWidthChange() {
 | 
			
		||||
            this.$emit('plotYTickWidth', ...arguments);
 | 
			
		||||
        },
 | 
			
		||||
        onCursorGuideChange() {
 | 
			
		||||
            this.$emit('cursorGuide', ...arguments);
 | 
			
		||||
@@ -221,7 +327,7 @@ export default {
 | 
			
		||||
                limitLineLabels: this.showLimitLineLabels,
 | 
			
		||||
                gridLines: this.gridLines,
 | 
			
		||||
                cursorGuide: this.cursorGuide,
 | 
			
		||||
                plotTickWidth: this.plotTickWidth,
 | 
			
		||||
                parentYTickWidth: this.parentYTickWidth,
 | 
			
		||||
                options: this.options,
 | 
			
		||||
                status: this.status,
 | 
			
		||||
                colorPalette: this.colorPalette,
 | 
			
		||||
@@ -230,7 +336,7 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
        getPlotObject() {
 | 
			
		||||
            if (this.childObject.configuration && this.childObject.configuration.series) {
 | 
			
		||||
                //If the object has a configuration, allow initialization of the config from it's persisted config
 | 
			
		||||
                //If the object has a configuration (like an overlay plot), allow initialization of the config from it's persisted config
 | 
			
		||||
                return this.childObject;
 | 
			
		||||
            } else {
 | 
			
		||||
                //If object is missing, warn and return object
 | 
			
		||||
@@ -281,6 +387,20 @@ export default {
 | 
			
		||||
 | 
			
		||||
                return this.childObject;
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        destroyStalenessListeners() {
 | 
			
		||||
            this.triggerUnsubscribeFromStaleness();
 | 
			
		||||
 | 
			
		||||
            if (this.composition) {
 | 
			
		||||
                this.composition.off('add', this.watchStaleness);
 | 
			
		||||
                this.composition.off('remove', this.unwatchStaleness);
 | 
			
		||||
                this.composition = null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Object.values(this.stalenessSubscription).forEach(stalenessSubscription => {
 | 
			
		||||
                stalenessSubscription.unsubscribe();
 | 
			
		||||
                stalenessSubscription.stalenessUtils.destroy();
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -57,7 +57,6 @@ export default function StackedPlotViewProvider(openmct) {
 | 
			
		||||
                        provide: {
 | 
			
		||||
                            openmct,
 | 
			
		||||
                            domainObject,
 | 
			
		||||
                            composition: openmct.composition.get(domainObject),
 | 
			
		||||
                            path: objectPath
 | 
			
		||||
                        },
 | 
			
		||||
                        data() {
 | 
			
		||||
 
 | 
			
		||||
@@ -173,7 +173,7 @@ describe("the plugin", function () {
 | 
			
		||||
        let testTelemetryObject2;
 | 
			
		||||
        let config;
 | 
			
		||||
        let component;
 | 
			
		||||
        let mockComposition;
 | 
			
		||||
        let mockCompositionList = [];
 | 
			
		||||
        let plotViewComponentObject;
 | 
			
		||||
 | 
			
		||||
        afterAll(() => {
 | 
			
		||||
@@ -271,14 +271,34 @@ describe("the plugin", function () {
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            mockComposition = new EventEmitter();
 | 
			
		||||
            mockComposition.load = () => {
 | 
			
		||||
                mockComposition.emit('add', testTelemetryObject);
 | 
			
		||||
            stackedPlotObject.composition = [{
 | 
			
		||||
                identifier: testTelemetryObject.identifier
 | 
			
		||||
            }];
 | 
			
		||||
 | 
			
		||||
                return [testTelemetryObject];
 | 
			
		||||
            };
 | 
			
		||||
            mockCompositionList = [];
 | 
			
		||||
            spyOn(openmct.composition, 'get').and.callFake((domainObject) => {
 | 
			
		||||
                //We need unique compositions here - one for the StackedPlot view and one for the PlotLegend view
 | 
			
		||||
                const numObjects = domainObject.composition.length;
 | 
			
		||||
                const mockComposition = new EventEmitter();
 | 
			
		||||
                mockComposition.load = () => {
 | 
			
		||||
                    if (numObjects === 1) {
 | 
			
		||||
                        mockComposition.emit('add', testTelemetryObject);
 | 
			
		||||
 | 
			
		||||
            spyOn(openmct.composition, 'get').and.returnValue(mockComposition);
 | 
			
		||||
                        return [testTelemetryObject];
 | 
			
		||||
                    } else if (numObjects === 2) {
 | 
			
		||||
                        mockComposition.emit('add', testTelemetryObject);
 | 
			
		||||
                        mockComposition.emit('add', testTelemetryObject2);
 | 
			
		||||
 | 
			
		||||
                        return [testTelemetryObject, testTelemetryObject2];
 | 
			
		||||
                    } else {
 | 
			
		||||
                        return [];
 | 
			
		||||
                    }
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                mockCompositionList.push(mockComposition);
 | 
			
		||||
 | 
			
		||||
                return mockComposition;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            let viewContainer = document.createElement("div");
 | 
			
		||||
            child.append(viewContainer);
 | 
			
		||||
@@ -290,7 +310,6 @@ describe("the plugin", function () {
 | 
			
		||||
                provide: {
 | 
			
		||||
                    openmct: openmct,
 | 
			
		||||
                    domainObject: stackedPlotObject,
 | 
			
		||||
                    composition: openmct.composition.get(stackedPlotObject),
 | 
			
		||||
                    path: [stackedPlotObject]
 | 
			
		||||
                },
 | 
			
		||||
                template: "<stacked-plot></stacked-plot>"
 | 
			
		||||
@@ -321,7 +340,7 @@ describe("the plugin", function () {
 | 
			
		||||
            expect(legend.length).toBe(6);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it("Renders X-axis ticks for the telemetry object", (done) => {
 | 
			
		||||
        it("Renders X-axis ticks for the telemetry object", () => {
 | 
			
		||||
            let xAxisElement = element.querySelectorAll(".gl-plot-axis-area.gl-plot-x .gl-plot-tick-wrapper");
 | 
			
		||||
            expect(xAxisElement.length).toBe(1);
 | 
			
		||||
 | 
			
		||||
@@ -329,13 +348,8 @@ describe("the plugin", function () {
 | 
			
		||||
                min: 0,
 | 
			
		||||
                max: 4
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            Vue.nextTick(() => {
 | 
			
		||||
                let ticks = xAxisElement[0].querySelectorAll(".gl-plot-tick");
 | 
			
		||||
                expect(ticks.length).toBe(9);
 | 
			
		||||
 | 
			
		||||
                done();
 | 
			
		||||
            });
 | 
			
		||||
            let ticks = xAxisElement[0].querySelectorAll(".gl-plot-tick");
 | 
			
		||||
            expect(ticks.length).toBe(9);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it("Renders Y-axis ticks for the telemetry object", (done) => {
 | 
			
		||||
@@ -401,17 +415,22 @@ describe("the plugin", function () {
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('plots a new series when a new telemetry object is added', (done) => {
 | 
			
		||||
            mockComposition.emit('add', testTelemetryObject2);
 | 
			
		||||
            //setting composition here so that any new triggers to composition.load with correctly load the mockComposition in the beforeEach
 | 
			
		||||
            stackedPlotObject.composition = [testTelemetryObject, testTelemetryObject2];
 | 
			
		||||
            mockCompositionList[0].emit('add', testTelemetryObject2);
 | 
			
		||||
 | 
			
		||||
            Vue.nextTick(() => {
 | 
			
		||||
                let legend = element.querySelectorAll(".plot-wrapper-collapsed-legend .plot-series-name");
 | 
			
		||||
                expect(legend.length).toBe(2);
 | 
			
		||||
                expect(legend[1].innerHTML).toEqual("Test Object2");
 | 
			
		||||
                done();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('removes plots from series when a telemetry object is removed', (done) => {
 | 
			
		||||
            mockComposition.emit('remove', testTelemetryObject.identifier);
 | 
			
		||||
            stackedPlotObject.composition = [];
 | 
			
		||||
            mockCompositionList[0].emit('remove', testTelemetryObject.identifier);
 | 
			
		||||
            Vue.nextTick(() => {
 | 
			
		||||
                expect(plotViewComponentObject.compositionObjects.length).toBe(0);
 | 
			
		||||
                done();
 | 
			
		||||
@@ -429,16 +448,6 @@ describe("the plugin", function () {
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it("Renders a new series when added to one of the plots", (done) => {
 | 
			
		||||
            mockComposition.emit('add', testTelemetryObject2);
 | 
			
		||||
            Vue.nextTick(() => {
 | 
			
		||||
                let legend = element.querySelectorAll(".plot-wrapper-collapsed-legend .plot-series-name");
 | 
			
		||||
                expect(legend.length).toBe(2);
 | 
			
		||||
                expect(legend[1].innerHTML).toEqual("Test Object2");
 | 
			
		||||
                done();
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it("Adds a new point to the plot", (done) => {
 | 
			
		||||
            let originalLength = config.series.models[0].getSeriesData().length;
 | 
			
		||||
            config.series.models[0].add({
 | 
			
		||||
@@ -459,7 +468,7 @@ describe("the plugin", function () {
 | 
			
		||||
                max: 10
 | 
			
		||||
            });
 | 
			
		||||
            Vue.nextTick(() => {
 | 
			
		||||
                expect(plotViewComponentObject.$children[1].component.$children[1].xScale.domain()).toEqual({
 | 
			
		||||
                expect(plotViewComponentObject.$children[0].component.$children[1].xScale.domain()).toEqual({
 | 
			
		||||
                    min: 0,
 | 
			
		||||
                    max: 10
 | 
			
		||||
                });
 | 
			
		||||
@@ -476,7 +485,7 @@ describe("the plugin", function () {
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
            Vue.nextTick(() => {
 | 
			
		||||
                const yAxesScales = plotViewComponentObject.$children[1].component.$children[1].yScale;
 | 
			
		||||
                const yAxesScales = plotViewComponentObject.$children[0].component.$children[1].yScale;
 | 
			
		||||
                yAxesScales.forEach((yAxisScale) => {
 | 
			
		||||
                    expect(yAxisScale.scale.domain()).toEqual({
 | 
			
		||||
                        min: 10,
 | 
			
		||||
 
 | 
			
		||||
@@ -293,6 +293,7 @@ define([
 | 
			
		||||
            this.stalenessSubscription[keyString].unsubscribe();
 | 
			
		||||
            this.stalenessSubscription[keyString].stalenessUtils.destroy();
 | 
			
		||||
            this.handleStaleness(keyString, { isStale: false }, SKIP_CHECK);
 | 
			
		||||
            delete this.stalenessSubscription[keyString];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        clearData() {
 | 
			
		||||
 
 | 
			
		||||
@@ -227,6 +227,10 @@ export default {
 | 
			
		||||
            if (this.isFixed) {
 | 
			
		||||
                offsets = this.timeOptions.fixedOffsets;
 | 
			
		||||
            } else {
 | 
			
		||||
                if (this.timeOptions.clockOffsets === undefined) {
 | 
			
		||||
                    this.timeOptions.clockOffsets = this.openmct.time.clockOffsets();
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                offsets = this.timeOptions.clockOffsets;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -390,7 +390,7 @@ $colorItemTreeHoverBg: rgba(#fff, 0.1);
 | 
			
		||||
$colorItemTreeHoverFg: #fff;
 | 
			
		||||
$colorItemTreeIcon: $colorKey; // Used
 | 
			
		||||
$colorItemTreeIconHover: $colorItemTreeIcon; // Used
 | 
			
		||||
$colorItemTreeFg: $colorBodyFg;
 | 
			
		||||
$colorItemTreeFg: #ccc;
 | 
			
		||||
$colorItemTreeSelectedBg: $colorSelectedBg;
 | 
			
		||||
$colorItemTreeSelectedFg: $colorItemTreeHoverFg;
 | 
			
		||||
$filterItemTreeSelected: $filterHov;
 | 
			
		||||
 
 | 
			
		||||
@@ -394,7 +394,7 @@ $colorItemTreeHoverBg: rgba(#fff, 0.03);
 | 
			
		||||
$colorItemTreeHoverFg: #fff;
 | 
			
		||||
$colorItemTreeIcon: $colorKey; // Used
 | 
			
		||||
$colorItemTreeIconHover: $colorItemTreeIcon; // Used
 | 
			
		||||
$colorItemTreeFg: $colorBodyFg;
 | 
			
		||||
$colorItemTreeFg: $colorA;
 | 
			
		||||
$colorItemTreeSelectedBg: $colorSelectedBg;
 | 
			
		||||
$colorItemTreeSelectedFg: $colorItemTreeHoverFg;
 | 
			
		||||
$filterItemTreeSelected: $filterHov;
 | 
			
		||||
 
 | 
			
		||||
@@ -94,7 +94,7 @@ $messageListIconD: 32px;
 | 
			
		||||
$tableResizeColHitareaD: 6px;
 | 
			
		||||
/*************** Misc */
 | 
			
		||||
$drawingObjBorderW: 3px;
 | 
			
		||||
 | 
			
		||||
$tagBorderRadius: 3px;
 | 
			
		||||
/************************** MOBILE */
 | 
			
		||||
$mobileMenuIconD: 24px; // Used
 | 
			
		||||
$mobileTreeItemH: 35px; // Used
 | 
			
		||||
 
 | 
			
		||||
@@ -270,9 +270,11 @@ button {
 | 
			
		||||
    flex: 0 0 auto;
 | 
			
		||||
    width: $d;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    visibility: hidden;
 | 
			
		||||
 | 
			
		||||
    &.is-enabled {
 | 
			
		||||
        cursor: pointer;
 | 
			
		||||
        visibility: visible;
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
            color: $colorDisclosureCtrlHov;
 | 
			
		||||
@@ -403,16 +405,18 @@ textarea {
 | 
			
		||||
 | 
			
		||||
    &--autocomplete {
 | 
			
		||||
        &__wrapper {
 | 
			
		||||
            display: inline-flex;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            flex-direction: row;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
            width: 100%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &__input {
 | 
			
		||||
            min-width: 100px;
 | 
			
		||||
            width: 100%;
 | 
			
		||||
 | 
			
		||||
            // Fend off from afford-arrow
 | 
			
		||||
            min-height: 2em;
 | 
			
		||||
            padding-right: 2.5em !important;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -435,7 +439,10 @@ textarea {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &__afford-arrow {
 | 
			
		||||
            $p: 2px;
 | 
			
		||||
            font-size: 0.8em;
 | 
			
		||||
            padding-bottom: $p;
 | 
			
		||||
            padding-top: $p;
 | 
			
		||||
            position: absolute;
 | 
			
		||||
            right: 2px;
 | 
			
		||||
            z-index: 2;
 | 
			
		||||
 
 | 
			
		||||
@@ -664,7 +664,6 @@ mct-plot {
 | 
			
		||||
            border-radius: $smallCr;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            justify-content: stretch;
 | 
			
		||||
            padding: 1px;
 | 
			
		||||
 | 
			
		||||
            .plot-series-swatch-and-name,
 | 
			
		||||
            .plot-series-value {
 | 
			
		||||
 
 | 
			
		||||
@@ -42,7 +42,6 @@
 | 
			
		||||
@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";
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,8 @@
 | 
			
		||||
<ul
 | 
			
		||||
    v-if="orderedPath.length"
 | 
			
		||||
    class="c-location"
 | 
			
		||||
    :aria-label="`${domainObject.name} Breadcrumb`"
 | 
			
		||||
    role="navigation"
 | 
			
		||||
>
 | 
			
		||||
    <li
 | 
			
		||||
        v-for="pathObject in orderedPath"
 | 
			
		||||
@@ -34,6 +36,7 @@
 | 
			
		||||
            :domain-object="pathObject.domainObject"
 | 
			
		||||
            :object-path="pathObject.objectPath"
 | 
			
		||||
            :read-only="readOnly"
 | 
			
		||||
            :navigate-to-path="navigateToPath(pathObject.objectPath)"
 | 
			
		||||
        />
 | 
			
		||||
    </li>
 | 
			
		||||
</ul>
 | 
			
		||||
@@ -110,6 +113,18 @@ export default {
 | 
			
		||||
                this.orderedPath = pathWithDomainObject.slice(1, pathWithDomainObject.length - 1).reverse();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        /**
 | 
			
		||||
         * Generate the hash url for the given object path, removing the '/ROOT' prefix if present.
 | 
			
		||||
         * @param {import('../../api/objects/ObjectAPI').DomainObject[]} objectPath
 | 
			
		||||
         */
 | 
			
		||||
        navigateToPath(objectPath) {
 | 
			
		||||
            /** @type {String} */
 | 
			
		||||
            const path = `/browse/${this.openmct.objects.getRelativePath(objectPath)}`;
 | 
			
		||||
 | 
			
		||||
            return path.replace('ROOT/', '');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -21,10 +21,11 @@
 | 
			
		||||
 *****************************************************************************/
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div class="c-tag-applier">
 | 
			
		||||
<div class="c-tag-applier has-tag-applier">
 | 
			
		||||
    <TagSelection
 | 
			
		||||
        v-for="(addedTag, index) in addedTags"
 | 
			
		||||
        :key="index"
 | 
			
		||||
        :class="{ 'w-tag-wrapper--tag-selector' : addedTag.newTag }"
 | 
			
		||||
        :selected-tag="addedTag.newTag ? null : addedTag"
 | 
			
		||||
        :new-tag="addedTag.newTag"
 | 
			
		||||
        :added-tags="addedTags"
 | 
			
		||||
 
 | 
			
		||||
@@ -21,8 +21,8 @@
 | 
			
		||||
 *****************************************************************************/
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div class="c-tag__parent">
 | 
			
		||||
    <div class="c-tag_selection">
 | 
			
		||||
<div class="w-tag-wrapper">
 | 
			
		||||
    <template v-if="newTag">
 | 
			
		||||
        <AutoCompleteField
 | 
			
		||||
            v-if="newTag"
 | 
			
		||||
            ref="tagSelection"
 | 
			
		||||
@@ -32,8 +32,9 @@
 | 
			
		||||
            :item-css-class="'icon-circle'"
 | 
			
		||||
            @onChange="tagSelected"
 | 
			
		||||
        />
 | 
			
		||||
    </template>
 | 
			
		||||
    <template v-else>
 | 
			
		||||
        <div
 | 
			
		||||
            v-else
 | 
			
		||||
            class="c-tag"
 | 
			
		||||
            :class="{'c-tag-edit': !readOnly}"
 | 
			
		||||
            :style="{ background: selectedBackgroundColor, color: selectedForegroundColor }"
 | 
			
		||||
@@ -48,7 +49,7 @@
 | 
			
		||||
                @click="removeTag"
 | 
			
		||||
            ></button>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    </template>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,19 +1,30 @@
 | 
			
		||||
@mixin tagHolder() {
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
 | 
			
		||||
  > * {
 | 
			
		||||
    $m: $interiorMarginSm;
 | 
			
		||||
 | 
			
		||||
    margin: 0 $m $m 0;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/******************************* TAGS */
 | 
			
		||||
.c-tag {
 | 
			
		||||
  border-radius: 10px; //TODO: convert to theme constant
 | 
			
		||||
  border-radius: $tagBorderRadius;
 | 
			
		||||
  display: inline-flex;
 | 
			
		||||
  padding: 1px 10px; //TODO: convert to theme constant
 | 
			
		||||
 | 
			
		||||
  > * + * {
 | 
			
		||||
    margin-left: $interiorMargin;
 | 
			
		||||
  }
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  padding: 1px 6px; //TODO: convert to theme constant
 | 
			
		||||
  transition: $transIn;
 | 
			
		||||
 | 
			
		||||
  &__remove-btn {
 | 
			
		||||
    color: inherit !important;
 | 
			
		||||
    display: none;
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    padding: 1px !important;
 | 
			
		||||
    padding: 0; // Overrides default <button> padding
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    right: 2px;
 | 
			
		||||
    transition: $transIn;
 | 
			
		||||
    width: 0;
 | 
			
		||||
 | 
			
		||||
@@ -28,28 +39,47 @@
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/******************************* TAG EDITOR */
 | 
			
		||||
.c-tag-applier {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
.c-tag-holder {
 | 
			
		||||
  @include tagHolder;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  > * + * {
 | 
			
		||||
    margin-left: $interiorMargin;
 | 
			
		||||
.w-tag-wrapper {
 | 
			
		||||
    $m: $interiorMarginSm;
 | 
			
		||||
 | 
			
		||||
    margin: 0 $m $m 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/******************************* TAGS IN INSPECTOR / TAG SELECTION & APPLICATION */
 | 
			
		||||
.c-tag-applier {
 | 
			
		||||
  $tagApplierPadding: 3px 6px;
 | 
			
		||||
  @include tagHolder;
 | 
			
		||||
  grid-column: 1 / 3;
 | 
			
		||||
 | 
			
		||||
  &__tags {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__add-btn {
 | 
			
		||||
    border-radius: $tagBorderRadius;
 | 
			
		||||
    padding: 3px 10px 3px 4px;
 | 
			
		||||
 | 
			
		||||
    &:before { font-size: 0.9em; }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .c-tag {
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    padding-right: 3px !important;
 | 
			
		||||
    padding: $tagApplierPadding;
 | 
			
		||||
 | 
			
		||||
    &__remove-btn {
 | 
			
		||||
      display: block;
 | 
			
		||||
    > * + * { margin-left: $interiorMarginSm; }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .c-tag-selection {
 | 
			
		||||
    .c-input--autocomplete__input {
 | 
			
		||||
      min-height: auto !important;
 | 
			
		||||
      padding: $tagApplierPadding;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -62,10 +92,18 @@
 | 
			
		||||
.has-tag-applier {
 | 
			
		||||
  // Apply this class to all components that should trigger tag removal btn on hover
 | 
			
		||||
   &:hover {
 | 
			
		||||
     .c-tag__remove-btn {
 | 
			
		||||
       width: 1.1em;
 | 
			
		||||
       opacity: 0.7;
 | 
			
		||||
     .c-tag {
 | 
			
		||||
       padding-right: 17px !important;
 | 
			
		||||
       transition: $transOut;
 | 
			
		||||
     }
 | 
			
		||||
 | 
			
		||||
     .c-tag__remove-btn {
 | 
			
		||||
       //display: block;
 | 
			
		||||
       //margin-left: $interiorMarginSm;
 | 
			
		||||
       width: 1em;
 | 
			
		||||
       opacity: 0.8;
 | 
			
		||||
       transition: $transOut;
 | 
			
		||||
       //transition-delay: 250ms;
 | 
			
		||||
     }
 | 
			
		||||
   }
 | 
			
		||||
 }
 | 
			
		||||
 
 | 
			
		||||
@@ -30,7 +30,7 @@
 | 
			
		||||
    @drop="emitDropEvent"
 | 
			
		||||
>
 | 
			
		||||
    <div
 | 
			
		||||
        class="c-tree__item c-elements-pool__item"
 | 
			
		||||
        class="c-tree__item c-elements-pool__item js-elements-pool__item"
 | 
			
		||||
        :class="{
 | 
			
		||||
            'is-context-clicked': contextClickActive,
 | 
			
		||||
            'hover': hover,
 | 
			
		||||
 
 | 
			
		||||
@@ -109,6 +109,8 @@ import StylesInspectorView from "@/ui/inspector/styles/StylesInspectorView.vue";
 | 
			
		||||
import SavedStylesInspectorView from "@/ui/inspector/styles/SavedStylesInspectorView.vue";
 | 
			
		||||
import AnnotationsInspectorView from "./annotations/AnnotationsInspectorView.vue";
 | 
			
		||||
 | 
			
		||||
const OVERLAY_PLOT_TYPE = "telemetry.plot.overlay";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    components: {
 | 
			
		||||
        StylesInspectorView,
 | 
			
		||||
@@ -189,12 +191,12 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
        refreshComposition(selection) {
 | 
			
		||||
            if (selection.length > 0 && selection[0].length > 0) {
 | 
			
		||||
                let parentObject = selection[0][0].context.item;
 | 
			
		||||
                const parentObject = selection[0][0].context.item;
 | 
			
		||||
 | 
			
		||||
                this.hasComposition = Boolean(
 | 
			
		||||
                    parentObject && this.openmct.composition.get(parentObject)
 | 
			
		||||
                );
 | 
			
		||||
                this.isOverlayPlot = selection[0][0].context.item.type === 'telemetry.plot.overlay';
 | 
			
		||||
                this.isOverlayPlot = parentObject?.type === OVERLAY_PLOT_TYPE;
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        refreshTabs(selection) {
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,7 @@
 | 
			
		||||
 *****************************************************************************/
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div></div>
 | 
			
		||||
<div aria-label="Inspector Views"></div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@
 | 
			
		||||
        <ul
 | 
			
		||||
            v-if="hasElements"
 | 
			
		||||
            id="inspector-elements-tree"
 | 
			
		||||
            class="c-tree c-elements-pool__tree"
 | 
			
		||||
            class="c-tree c-elements-pool__tree js-elements-pool__tree"
 | 
			
		||||
        >
 | 
			
		||||
            <div class="c-elements-pool__instructions"> Select and drag an element to move it into a different axis. </div>
 | 
			
		||||
            <element-item-group
 | 
			
		||||
@@ -145,7 +145,7 @@ export default {
 | 
			
		||||
 | 
			
		||||
            this.unlistenComposition();
 | 
			
		||||
 | 
			
		||||
            if (this.parentObject) {
 | 
			
		||||
            if (this.parentObject && this.parentObject.type === 'telemetry.plot.overlay') {
 | 
			
		||||
                this.setYAxisIds();
 | 
			
		||||
                this.composition = this.openmct.composition.get(this.parentObject);
 | 
			
		||||
 | 
			
		||||
@@ -175,6 +175,7 @@ export default {
 | 
			
		||||
        setYAxisIds() {
 | 
			
		||||
            const configId = this.openmct.objects.makeKeyString(this.parentObject.identifier);
 | 
			
		||||
            this.config = configStore.get(configId);
 | 
			
		||||
            this.yAxes = [];
 | 
			
		||||
            this.yAxes.push({
 | 
			
		||||
                id: this.config.yAxis.id,
 | 
			
		||||
                elements: this.parentObject.configuration.series.filter(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,83 +0,0 @@
 | 
			
		||||
/*****************************************************************************
 | 
			
		||||
 * 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>
 | 
			
		||||
@@ -22,7 +22,7 @@
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div
 | 
			
		||||
    class="c-inspector__properties c-inspect-properties has-tag-applier"
 | 
			
		||||
    class="c-inspector__properties c-inspect-properties"
 | 
			
		||||
    aria-label="Tags Inspector"
 | 
			
		||||
>
 | 
			
		||||
    <div
 | 
			
		||||
@@ -111,25 +111,31 @@ export default {
 | 
			
		||||
            return this?.selection?.[0]?.[0]?.context?.item;
 | 
			
		||||
        },
 | 
			
		||||
        targetDetails() {
 | 
			
		||||
            return this?.selection?.[0]?.[1]?.context?.targetDetails ?? {};
 | 
			
		||||
            return this?.selection?.[0]?.[0]?.context?.targetDetails ?? {};
 | 
			
		||||
        },
 | 
			
		||||
        shouldShowTagsEditor() {
 | 
			
		||||
            return Object.keys(this.targetDetails).length > 0;
 | 
			
		||||
            const showingTagsEditor = Object.keys(this.targetDetails).length > 0;
 | 
			
		||||
 | 
			
		||||
            if (showingTagsEditor) {
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return false;
 | 
			
		||||
        },
 | 
			
		||||
        targetDomainObjects() {
 | 
			
		||||
            return this?.selection?.[0]?.[1]?.context?.targetDomainObjects ?? {};
 | 
			
		||||
            return this?.selection?.[0]?.[0]?.context?.targetDomainObjects ?? {};
 | 
			
		||||
        },
 | 
			
		||||
        selectedAnnotations() {
 | 
			
		||||
            return this?.selection?.[0]?.[1]?.context?.annotations;
 | 
			
		||||
            return this?.selection?.[0]?.[0]?.context?.annotations;
 | 
			
		||||
        },
 | 
			
		||||
        annotationType() {
 | 
			
		||||
            return this?.selection?.[0]?.[1]?.context?.annotationType;
 | 
			
		||||
            return this?.selection?.[0]?.[0]?.context?.annotationType;
 | 
			
		||||
        },
 | 
			
		||||
        annotationFilter() {
 | 
			
		||||
            return this?.selection?.[0]?.[1]?.context?.annotationFilter;
 | 
			
		||||
            return this?.selection?.[0]?.[0]?.context?.annotationFilter;
 | 
			
		||||
        },
 | 
			
		||||
        onAnnotationChange() {
 | 
			
		||||
            return this?.selection?.[0]?.[1]?.context?.onAnnotationChange;
 | 
			
		||||
            return this?.selection?.[0]?.[0]?.context?.onAnnotationChange;
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    async mounted() {
 | 
			
		||||
@@ -195,6 +201,7 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        async loadAnnotationForTargetObject(target) {
 | 
			
		||||
            console.debug(`📝 Loading annotations for target`, target);
 | 
			
		||||
            const targetID = this.openmct.objects.makeKeyString(target.identifier);
 | 
			
		||||
            const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations(target.identifier);
 | 
			
		||||
            const filteredAnnotationsForSelection = allAnnotationsForTarget.filter(annotation => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +0,0 @@
 | 
			
		||||
.c-inspect-annotations {
 | 
			
		||||
    > * + * {
 | 
			
		||||
        margin-top: $interiorMargin;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__content{
 | 
			
		||||
        > * + * {
 | 
			
		||||
            margin-top: $interiorMargin;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__content {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -39,7 +39,6 @@
 | 
			
		||||
 | 
			
		||||
    &__group {
 | 
			
		||||
        flex: 1 1 auto;
 | 
			
		||||
        overflow: auto;
 | 
			
		||||
        margin-top: $interiorMarginLg;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,6 @@
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
import CreateAction from '@/plugins/formActions/CreateAction';
 | 
			
		||||
import objectUtils from 'objectUtils';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    inject: ['openmct'],
 | 
			
		||||
@@ -74,23 +73,9 @@ export default {
 | 
			
		||||
            this.openmct.menus.showSuperMenu(x, y, this.sortedItems, menuOptions);
 | 
			
		||||
        },
 | 
			
		||||
        create(key) {
 | 
			
		||||
            // Hack for support.  TODO: rewrite create action.
 | 
			
		||||
            // 1. Get contextual object from navigation
 | 
			
		||||
            // 2. Get legacy type from legacy api
 | 
			
		||||
            // 3. Instantiate create action with type, parent, context
 | 
			
		||||
            // 4. perform action.
 | 
			
		||||
            return this.openmct.objects.get(this.openmct.router.path[0].identifier)
 | 
			
		||||
                .then((currentObject) => {
 | 
			
		||||
                    const createAction = new CreateAction(this.openmct, key, currentObject);
 | 
			
		||||
            const createAction = new CreateAction(this.openmct, key, this.openmct.router.path[0]);
 | 
			
		||||
 | 
			
		||||
                    createAction.invoke();
 | 
			
		||||
                });
 | 
			
		||||
        },
 | 
			
		||||
        convertToLegacy(domainObject) {
 | 
			
		||||
            let keyString = objectUtils.makeKeyString(domainObject.identifier);
 | 
			
		||||
            let oldModel = objectUtils.toOldFormat(domainObject);
 | 
			
		||||
 | 
			
		||||
            return this.openmct.$injector.get('instantiate')(oldModel, keyString);
 | 
			
		||||
            createAction.invoke();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -79,9 +79,7 @@
 | 
			
		||||
            <multipane
 | 
			
		||||
                type="vertical"
 | 
			
		||||
            >
 | 
			
		||||
                <pane
 | 
			
		||||
                    id="tree-pane"
 | 
			
		||||
                >
 | 
			
		||||
                <pane>
 | 
			
		||||
                    <mct-tree
 | 
			
		||||
                        ref="mctTree"
 | 
			
		||||
                        :sync-tree-navigation="triggerSync"
 | 
			
		||||
 
 | 
			
		||||
@@ -41,6 +41,7 @@
 | 
			
		||||
        ref="mainTree"
 | 
			
		||||
        class="c-tree-and-search__tree c-tree"
 | 
			
		||||
        role="tree"
 | 
			
		||||
        :aria-label="getAriaLabel"
 | 
			
		||||
        aria-expanded="true"
 | 
			
		||||
    >
 | 
			
		||||
 | 
			
		||||
@@ -192,6 +193,9 @@ export default {
 | 
			
		||||
        focusedItems() {
 | 
			
		||||
            return this.activeSearch ? this.searchResultItems : this.treeItems;
 | 
			
		||||
        },
 | 
			
		||||
        getAriaLabel() {
 | 
			
		||||
            return this.isSelectorTree ? "Create Modal Tree" : "Main Tree";
 | 
			
		||||
        },
 | 
			
		||||
        pageThreshold() {
 | 
			
		||||
            return Math.ceil(this.mainTreeHeight / this.itemHeight) + ITEM_BUFFER;
 | 
			
		||||
        },
 | 
			
		||||
@@ -311,7 +315,7 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        targetedPathAnimationEnd() {
 | 
			
		||||
            this.targetedPath = undefined;
 | 
			
		||||
            this.targetedPath = null;
 | 
			
		||||
        },
 | 
			
		||||
        treeItemSelection(item) {
 | 
			
		||||
            this.selectedItem = item;
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@
 | 
			
		||||
  align-items: flex-start;
 | 
			
		||||
 | 
			
		||||
  > * + * {
 | 
			
		||||
      margin-left: $interiorMargin;
 | 
			
		||||
      margin-left: $interiorMarginSm;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  + .c-recentobjects-listitem {
 | 
			
		||||
@@ -58,7 +58,7 @@
 | 
			
		||||
 | 
			
		||||
  &__type-icon {
 | 
			
		||||
      color: $colorItemTreeIcon;
 | 
			
		||||
      font-size: 2.2em;
 | 
			
		||||
      font-size: 1.25em;
 | 
			
		||||
 | 
			
		||||
      // TEMP: uses object-label component, hide label part
 | 
			
		||||
      .c-object-label__name {
 | 
			
		||||
@@ -72,6 +72,7 @@
 | 
			
		||||
 | 
			
		||||
  &__body {
 | 
			
		||||
      flex: 1 1 auto;
 | 
			
		||||
      padding-top: 2px; // Align with type icon
 | 
			
		||||
 | 
			
		||||
      > * + * {
 | 
			
		||||
          margin-top: $interiorMarginSm;
 | 
			
		||||
@@ -89,7 +90,6 @@
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__tags {
 | 
			
		||||
@@ -102,7 +102,7 @@
 | 
			
		||||
 | 
			
		||||
  &__title {
 | 
			
		||||
      border-radius: $basicCr;
 | 
			
		||||
      color: pullForward($colorBodyFg, 30%);
 | 
			
		||||
      color: $colorItemTreeFg;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
      padding: $interiorMarginSm;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -150,16 +150,11 @@ export default {
 | 
			
		||||
            });
 | 
			
		||||
            const selection =
 | 
			
		||||
                    [
 | 
			
		||||
                        {
 | 
			
		||||
                            element: this.openmct.layout.$refs.browseObject.$el,
 | 
			
		||||
                            context: {
 | 
			
		||||
                                item: this.result
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        {
 | 
			
		||||
                            element: this.$el,
 | 
			
		||||
                            context: {
 | 
			
		||||
                                type: 'plot-points-selection',
 | 
			
		||||
                                item: this.result.targetModels[0],
 | 
			
		||||
                                type: 'plot-annotation-search-result',
 | 
			
		||||
                                targetDetails,
 | 
			
		||||
                                targetDomainObjects,
 | 
			
		||||
                                annotations: [this.result],
 | 
			
		||||
 
 | 
			
		||||