Merge branch 'master' into test-heuristics

This commit is contained in:
Andrew Henry 2022-05-09 14:17:20 -07:00 committed by GitHub
commit b39ff87197
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 3751 additions and 525 deletions

View File

@ -2,7 +2,7 @@ version: 2.1
executors:
pw-focal-development:
docker:
- image: mcr.microsoft.com/playwright:v1.19.2-focal
- image: mcr.microsoft.com/playwright:v1.21.1-focal
environment:
NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed
parameters:
@ -64,7 +64,7 @@ commands:
- run: curl -Os https://uploader.codecov.io/latest/linux/codecov;chmod +x codecov;./codecov
orbs:
node: circleci/node@4.9.0
browser-tools: circleci/browser-tools@1.2.3
browser-tools: circleci/browser-tools@1.3.0
jobs:
npm-audit:
parameters:
@ -149,11 +149,15 @@ workflows:
node-version: lts/fermium
browser: ChromeHeadless
post-steps:
- upload_code_covio
- upload_code_covio
- unit-test:
name: node16-chrome
node-version: lts/gallium
browser: ChromeHeadless
browser: ChromeHeadless
- unit-test:
name: node18-chrome
node-version: "18"
browser: ChromeHeadless
- e2e-test:
name: e2e-ci
node-version: lts/gallium
@ -176,6 +180,10 @@ workflows:
name: node16-chrome-nightly
node-version: lts/gallium
browser: ChromeHeadless
- unit-test:
name: node18-chrome
node-version: "18"
browser: ChromeHeadless
- npm-audit:
node-version: lts/gallium
- e2e-test:

View File

@ -32,12 +32,12 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
uses: github/codeql-action/init@v2
with:
languages: javascript
- name: Autobuild
uses: github/codeql-action/autobuild@v1
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
uses: github/codeql-action/analyze@v2

View File

@ -30,11 +30,11 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: '16'
- run: npx playwright@1.19.2 install
- run: npx playwright@1.21.1 install
- run: npm install
- run: npm run test:e2e:full
- name: Archive test results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
path: test-results
- name: Test success

View File

@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: '16'
- run: npx playwright@1.19.2 install
- run: npx playwright@1.21.1 install
- run: npm install
- name: Run the e2e visual tests
run: npm run test:e2e:visual

View File

@ -18,6 +18,7 @@ jobs:
node_version:
- 14
- 16
- 18
architecture:
- x64
name: Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }}

View File

@ -6,9 +6,9 @@ const { devices } = require('@playwright/test');
/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
retries: 2,
retries: 1,
testDir: 'tests',
timeout: 90 * 1000,
timeout: 60 * 1000,
webServer: {
command: 'npm run start',
port: 8080,
@ -28,12 +28,12 @@ const config = {
{
name: 'chrome',
use: {
browserName: 'chromium',
...devices['Desktop Chrome']
browserName: 'chromium'
}
},
{
name: 'MMOC',
grepInvert: /@snapshot/,
use: {
browserName: 'chromium',
viewport: {

View File

@ -29,12 +29,12 @@ const config = {
{
name: 'chrome',
use: {
browserName: 'chromium',
...devices['Desktop Chrome']
browserName: 'chromium'
}
},
{
name: 'MMOC',
grepInvert: /@snapshot/,
use: {
browserName: 'chromium',
viewport: {

View File

@ -27,16 +27,115 @@ This test suite is dedicated to tests which verify the basic operations surround
const { test, expect } = require('@playwright/test');
test.describe('Move item tests', () => {
test.fixme('Create a basic object and verify that it can be moved to another Folder', async ({ page }) => {
//Create and save Folder
//Create and save Domain Object
//Verify that the newly created domain object can be moved to Folder from Step 1.
//Verify that newly moved object appears in the correct point in Tree
//Verify that newly moved object appears correctly in Inspector panel
test('Create a basic object and verify that it can be moved to another folder', async ({ page }) => {
// Go to Open MCT
await page.goto('/');
// Create a new folder in the root my items folder
let folder1 = "Folder1";
await page.locator('button:has-text("Create")').click();
await page.locator('li.icon-folder').click();
await page.locator('text=Properties Title Notes >> input[type="text"]').click();
await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folder1);
await Promise.all([
page.waitForNavigation(),
page.locator('text=OK').click(),
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'});
// Create another folder with a new name at default location, which is currently inside Folder 1
let folder2 = "Folder2";
await page.locator('button:has-text("Create")').click();
await page.locator('li.icon-folder').click();
await page.locator('text=Properties Title Notes >> input[type="text"]').click();
await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folder2);
await Promise.all([
page.waitForNavigation(),
page.locator('text=OK').click(),
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'});
// Move Folder 2 from Folder 1 to My Items
await page.locator('text=Open MCT My Items >> span').nth(3).click();
await page.locator('.c-tree__scrollable div div:nth-child(2) .c-tree__item .c-tree__item__view-control').click();
await page.locator(`a:has-text("${folder2}")`).click({
button: 'right'
});
await page.locator('li.icon-move').click();
await page.locator('form[name="mctForm"] >> text=My Items').click();
await Promise.all([
page.waitForNavigation(),
page.locator('text=OK').click()
]);
// Expect that Folder 2 is in My Items, the root folder
expect(page.locator(`text=My Items >> nth=0:has(text=${folder2})`)).toBeTruthy();
});
test.fixme('Create a basic object and verify that it cannot be moved to object without Composition Provider', async ({ page }) => {
//Create and save Telemetry Object
//Create and save Domain Object
//Verify that the newly created domain object cannot be moved to Telemetry Object from step 1.
test('Create a basic object and verify that it cannot be moved to telemetry object without Composition Provider', async ({ page }) => {
// Go to Open MCT
await page.goto('/');
// Create Telemetry Table
let telemetryTable = 'Test Telemetry Table';
await page.locator('button:has-text("Create")').click();
await page.locator('li:has-text("Telemetry Table")').click();
await page.locator('text=Properties Title Notes >> input[type="text"]').click();
await page.locator('text=Properties Title Notes >> input[type="text"]').fill(telemetryTable);
await Promise.all([
page.waitForNavigation(),
page.locator('text=OK').click()
]);
// Finish editing and save Telemetry Table
await page.locator('.c-button--menu.c-button--major.icon-save').click();
await page.locator('text=Save and Finish Editing').click();
// Create New Folder Basic Domain Object
let folder = 'Test Folder';
await page.locator('button:has-text("Create")').click();
await page.locator('li:has-text("Folder")').click();
await page.locator('text=Properties Title Notes >> input[type="text"]').click();
await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folder);
// See if it's possible to put the folder in the Telemetry object during creation (Soft Assert)
await page.locator(`form[name="mctForm"] >> text=${telemetryTable}`).click();
let okButton = await page.locator('button.c-button.c-button--major:has-text("OK")');
let okButtonStateDisabled = await okButton.isDisabled();
expect.soft(okButtonStateDisabled).toBeTruthy();
// Continue test regardless of assertion and create it in My Items
await page.locator('form[name="mctForm"] >> text=My Items').click();
await Promise.all([
page.waitForNavigation(),
page.locator('text=OK').click()
]);
// Open My Items
await page.locator('text=Open MCT My Items >> span').nth(3).click();
// Select Folder Object and select Move from context menu
await Promise.all([
page.waitForNavigation(),
page.locator(`a:has-text("${folder}")`).click()
]);
await page.locator('.c-tree__item.is-navigated-object .c-tree__item__label .c-tree__item__type-icon').click({
button: 'right'
});
await page.locator('li.icon-move').click();
// See if it's possible to put the folder in the Telemetry object after creation
await page.locator('text=Location Open MCT My Items >> span').nth(3).click();
await page.locator(`form[name="mctForm"] >> text=${telemetryTable}`).click();
let okButton2 = await page.locator('button.c-button.c-button--major:has-text("OK")');
let okButtonStateDisabled2 = await okButton2.isDisabled();
expect(okButtonStateDisabled2).toBeTruthy();
});
});

View File

@ -21,45 +21,163 @@
*****************************************************************************/
/*
This test suite is dedicated to tests which verify the basic operations surrounding conditionSets.
This test suite is dedicated to tests which verify the basic operations surrounding conditionSets. Note: this
suite is sharing state between tests which is considered an anti-pattern. Implimenting in this way to
demonstrate some playwright for test developers. This pattern should not be re-used in other CRUD suites.
*/
const { test, expect } = require('@playwright/test');
test.describe('Condition Set Operations', () => {
test('Create new button `condition set` creates new condition object', async ({ page }) => {
//Go to baseURL
await page.goto('/', { waitUntil: 'networkidle' });
let conditionSetUrl;
let getConditionSetIdentifierFromUrl;
//Click the Create button
await page.click('button:has-text("Create")');
test('Create new Condition Set object and store @localStorage', async ({ page, context }) => {
//Go to baseURL
await page.goto('/', { waitUntil: 'networkidle' });
// Click text=Condition Set
await page.click('text=Condition Set');
//Click the Create button
await page.click('button:has-text("Create")');
// Click text=OK
// Click text=Condition Set
await page.click('text=Condition Set');
// Click text=OK
await Promise.all([
page.waitForNavigation(),
page.click('text=OK')
]);
await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set');
//Save localStorage for future test execution
await context.storageState({ path: './e2e/tests/recycled_storage.json' });
//Set object identifier from url
conditionSetUrl = await page.url();
console.log('conditionSetUrl ' + conditionSetUrl);
getConditionSetIdentifierFromUrl = await conditionSetUrl.split('/').pop().split('?')[0];
console.log('getConditionSetIdentifierFromUrl ' + getConditionSetIdentifierFromUrl);
});
test.describe.serial('Condition Set CRUD Operations on @localStorage', () => {
//Load localStorage for subsequent tests
test.use({ storageState: './e2e/tests/recycled_storage.json' });
//Begin suite of tests again localStorage
test('Condition set object properties persist in main view and inspector', async ({ page }) => {
//Navigate to baseURL with injected localStorage
await page.goto(conditionSetUrl, { waitUntil: 'networkidle' });
//Assertions on loaded Condition Set in main view
await expect.soft(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set');
//Assertions on loaded Condition Set in Inspector
await expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy;
//Reload Page
await Promise.all([
page.waitForNavigation(/*{ url: 'http://localhost:8080/#/browse/mine/dab945d4-5a84-480e-8180-222b4aa730fa?tc.mode=fixed&tc.startBound=1639696164435&tc.endBound=1639697964435&tc.timeSystem=utc&view=conditionSet.view' }*/),
page.click('text=OK')
page.reload(),
page.waitForLoadState('networkidle')
]);
await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set');
//Re-verify after reload
await expect.soft(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set');
//Assertions on loaded Condition Set in Inspector
await expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy;
});
test.fixme('condition set object properties exist', async ({ page }) => {
//Go to object created in step one
//Verify the Condition Set properties persist on Save
//Verify the Condition Set properties persist on page.reload()
});
test.fixme('condition set object can be modified', async ({ page }) => {
//Go to object created in step one
test('condition set object can be modified on @localStorage', async ({ page }) => {
await page.goto(conditionSetUrl, { waitUntil: 'networkidle' });
//Assertions on loaded Condition Set in main view
await expect.soft(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set');
//Update the Condition Set properties
//Verify the Condition Set properties persist on Save
//Verify the Condition Set properties persist on page.reload()
// Click Edit Button
await page.locator('text=Conditions View Snapshot >> button').nth(3).click();
//Edit Condition Set Name from main view
await page.locator('text=Unnamed Condition Set').first().fill('Renamed Condition Set');
await page.locator('text=Renamed Condition Set').first().press('Enter');
// Click Save Button
await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click();
// Click Save and Finish Editing Option
await page.locator('text=Save and Finish Editing').click();
//Verify Main section reflects updated Name Property
await expect.soft(page.locator('.l-browse-bar__object-name')).toContainText('Renamed Condition Set');
// Verify Inspector properties
// Verify Inspector has updated Name property
await expect.soft(page.locator('text=Renamed Condition Set').nth(1)).toBeTruthy();
// Verify Inspector Details has updated Name property
await expect.soft(page.locator('text=Renamed Condition Set').nth(2)).toBeTruthy();
// Verify Tree reflects updated Name proprety
// Expand Tree
await page.locator('text=Open MCT My Items >> span >> nth=3').click();
// Verify Condition Set Object is renamed in Tree
await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy();
// Verify Search Tree reflects renamed Name property
await page.locator('input[type="search"]').fill('Renamed');
await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy();
//Reload Page
await Promise.all([
page.reload(),
page.waitForLoadState('networkidle')
]);
//Verify Main section reflects updated Name Property
await expect.soft(page.locator('.l-browse-bar__object-name')).toContainText('Renamed Condition Set');
// Verify Inspector properties
// Verify Inspector has updated Name property
await expect.soft(page.locator('text=Renamed Condition Set').nth(1)).toBeTruthy();
// Verify Inspector Details has updated Name property
await expect.soft(page.locator('text=Renamed Condition Set').nth(2)).toBeTruthy();
// Verify Tree reflects updated Name proprety
// Expand Tree
await page.locator('text=Open MCT My Items >> span >> nth=3').click();
// Verify Condition Set Object is renamed in Tree
await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy();
// Verify Search Tree reflects renamed Name property
await page.locator('input[type="search"]').fill('Renamed');
await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy();
});
test.fixme('condition set object can be deleted', async ({ page }) => {
//Go to object created in step one
//Verify that Condition Set object can be deleted
//Verify the Condition Set object does not exist in Tree
//Verify the Condition Set object does not exist with direct navigation to object's URL
test('condition set object can be deleted by Search Tree Actions menu on @localStorage', async ({ page }) => {
//Navigate to baseURL
await page.goto('/', { waitUntil: 'networkidle' });
//Expect Unnamed Condition Set to be visible in Main View
await expect(page.locator('a:has-text("Unnamed Condition Set Condition Set")')).toBeVisible();
// Search for Unnamed Condition Set
await page.locator('input[type="search"]').fill('Unnamed Condition Set');
// Right Click to Open Actions Menu
await page.locator('a:has-text("Unnamed Condition Set")').click({
button: 'right'
});
// Click Remove Action
await page.locator('text=Remove').click();
await page.locator('text=OK').click();
//Expect Unnamed Condition Set to be removed in Main View
await expect(page.locator('a:has-text("Unnamed Condition Set Condition Set")')).not.toBeVisible();
await page.locator('.c-search__clear-input').click();
// Search for Unnamed Condition Set
await page.locator('input[type="search"]').fill('Unnamed Condition Set');
// Expect Unnamed Condition Set to be removed
await expect(page.locator('a:has-text("Unnamed Condition Set")')).not.toBeVisible();
//Feature?
//Domain Object is still available by direct URL after delete
await page.goto(conditionSetUrl, { waitUntil: 'networkidle' });
await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set');
});
});

View File

@ -24,13 +24,14 @@
This test suite is dedicated to tests which verify the basic operations surrounding imagery,
but only assume that example imagery is present.
*/
/* globals process */
const { test, expect } = require('@playwright/test');
test.describe('Example Imagery', () => {
test.beforeEach(async ({ page }) => {
page.on('console', msg => console.log(msg.text()))
page.on('console', msg => console.log(msg.text()));
//Go to baseURL
await page.goto('/', { waitUntil: 'networkidle' });
@ -42,10 +43,13 @@ test.describe('Example Imagery', () => {
// Click text=OK
await Promise.all([
page.waitForNavigation(/*{ url: 'http://localhost:8080/#/browse/mine/dab945d4-5a84-480e-8180-222b4aa730fa?tc.mode=fixed&tc.startBound=1639696164435&tc.endBound=1639697964435&tc.timeSystem=utc&view=conditionSet.view' }*/),
page.click('text=OK')
page.waitForNavigation({waitUntil: 'networkidle'}),
page.click('text=OK'),
//Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message')
]);
//Wait until Save Banner is gone
await page.waitForSelector('.c-message-banner__message', { state: 'detached'});
await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery');
});
@ -77,9 +81,11 @@ test.describe('Example Imagery', () => {
test('Can use alt+drag to move around image once zoomed in', async ({ page }) => {
const deltaYStep = 100; //equivalent to 1x zoom
const panHotkey = process.platform === 'linux' ? ['Control', 'Alt'] : ['Alt'];
const bgImageLocator = await page.locator(backgroundImageSelector);
await bgImageLocator.hover();
// zoom in
await page.mouse.wheel(0, deltaYStep * 2);
await bgImageLocator.hover();
@ -91,40 +97,49 @@ test.describe('Example Imagery', () => {
// center the mouse pointer
await page.mouse.move(imageCenterX, imageCenterY);
//Get Diagnostic info about process environment
console.log('process.platform is ' + process.platform);
const getUA = await page.evaluate(() => navigator.userAgent);
console.log('navigator.userAgent ' + getUA);
// Pan Imagery Hints
const expectedAltText = process.platform === 'linux' ? 'Ctrl+Alt drag to pan' : 'Alt drag to pan';
const imageryHintsText = await page.locator('.c-imagery__hints').innerText();
expect(expectedAltText).toEqual(imageryHintsText);
// pan right
await page.keyboard.down('Alt');
await Promise.all(panHotkey.map(x => page.keyboard.down(x)));
await page.mouse.down();
await page.mouse.move(imageCenterX - 200, imageCenterY, 10);
await page.mouse.up();
await page.keyboard.up('Alt');
await Promise.all(panHotkey.map(x => page.keyboard.up(x)));
const afterRightPanBoundingBox = await bgImageLocator.boundingBox();
expect(zoomedBoundingBox.x).toBeGreaterThan(afterRightPanBoundingBox.x);
// pan left
await page.keyboard.down('Alt');
await Promise.all(panHotkey.map(x => page.keyboard.down(x)));
await page.mouse.down();
await page.mouse.move(imageCenterX, imageCenterY, 10);
await page.mouse.up();
await page.keyboard.up('Alt');
await Promise.all(panHotkey.map(x => page.keyboard.up(x)));
const afterLeftPanBoundingBox = await bgImageLocator.boundingBox();
expect(afterRightPanBoundingBox.x).toBeLessThan(afterLeftPanBoundingBox.x);
// pan up
await page.mouse.move(imageCenterX, imageCenterY);
await page.keyboard.down('Alt');
await Promise.all(panHotkey.map(x => page.keyboard.down(x)));
await page.mouse.down();
await page.mouse.move(imageCenterX, imageCenterY + 200, 10);
await page.mouse.up();
await page.keyboard.up('Alt');
await Promise.all(panHotkey.map(x => page.keyboard.up(x)));
const afterUpPanBoundingBox = await bgImageLocator.boundingBox();
expect(afterUpPanBoundingBox.y).toBeGreaterThan(afterLeftPanBoundingBox.y);
// pan down
await page.keyboard.down('Alt');
await Promise.all(panHotkey.map(x => page.keyboard.down(x)));
await page.mouse.down();
await page.mouse.move(imageCenterX, imageCenterY - 200, 10);
await page.mouse.up();
await page.keyboard.up('Alt');
await Promise.all(panHotkey.map(x => page.keyboard.up(x)));
const afterDownPanBoundingBox = await bgImageLocator.boundingBox();
expect(afterDownPanBoundingBox.y).toBeLessThan(afterUpPanBoundingBox.y);
@ -156,20 +171,26 @@ test.describe('Example Imagery', () => {
test('Can use the reset button to reset the image', async ({ page }) => {
const bgImageLocator = await page.locator(backgroundImageSelector);
// wait for zoom animation to finish
await bgImageLocator.hover();
const zoomInBtn = await page.locator('.t-btn-zoom-in');
const zoomResetBtn = await page.locator('.t-btn-zoom-reset');
const initialBoundingBox = await bgImageLocator.boundingBox();
await zoomInBtn.click();
// wait for zoom animation to finish
await bgImageLocator.hover();
await zoomInBtn.click();
// wait for zoom animation to finish
await bgImageLocator.hover();
const zoomedInBoundingBox = await bgImageLocator.boundingBox();
expect.soft(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height);
expect.soft(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width);
await zoomResetBtn.click();
// wait for zoom animation to finish
await bgImageLocator.hover();
const resetBoundingBox = await bgImageLocator.boundingBox();
@ -180,38 +201,38 @@ test.describe('Example Imagery', () => {
expect(resetBoundingBox.width).toEqual(initialBoundingBox.width);
});
//test('Can use Mouse Wheel to zoom in and out of previous image');
//test('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
//test.skip('Can zoom into a previous image from thumbstrip in real-time or fixed-time');
//test.skip('Clicking on the left arrow should pause the imagery and go to previous image');
//test.skip('If the imagery view is in pause mode, it should not be updated when new images come in');
//test.skip('If the imagery view is not in pause mode, it should be updated when new images come in');
//test.fixme('Can use Mouse Wheel to zoom in and out of previous image');
//test.fixme('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
//test.fixme('Can zoom into a previous image from thumbstrip in real-time or fixed-time');
//test.fixme('Clicking on the left arrow should pause the imagery and go to previous image');
//test.fixme('If the imagery view is in pause mode, it should not be updated when new images come in');
//test.fixme('If the imagery view is not in pause mode, it should be updated when new images come in');
});
test.describe('Example Imagery in Display layout', () => {
test.skip('Can use Mouse Wheel to zoom in and out of previous image');
test.skip('Can use alt+drag to move around image once zoomed in');
test.skip('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
test.skip('Clicking on the left arrow should pause the imagery and go to previous image');
test.skip('If the imagery view is in pause mode, it should not be updated when new images come in');
test.skip('If the imagery view is not in pause mode, it should be updated when new images come in');
test.fixme('Can use Mouse Wheel to zoom in and out of previous image');
test.fixme('Can use alt+drag to move around image once zoomed in');
test.fixme('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
test.fixme('Clicking on the left arrow should pause the imagery and go to previous image');
test.fixme('If the imagery view is in pause mode, it should not be updated when new images come in');
test.fixme('If the imagery view is not in pause mode, it should be updated when new images come in');
});
test.describe('Example Imagery in Flexible layout', () => {
test.skip('Can use Mouse Wheel to zoom in and out of previous image');
test.skip('Can use alt+drag to move around image once zoomed in');
test.skip('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
test.skip('Clicking on the left arrow should pause the imagery and go to previous image');
test.skip('If the imagery view is in pause mode, it should not be updated when new images come in');
test.skip('If the imagery view is not in pause mode, it should be updated when new images come in');
test.fixme('Can use Mouse Wheel to zoom in and out of previous image');
test.fixme('Can use alt+drag to move around image once zoomed in');
test.fixme('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
test.fixme('Clicking on the left arrow should pause the imagery and go to previous image');
test.fixme('If the imagery view is in pause mode, it should not be updated when new images come in');
test.fixme('If the imagery view is not in pause mode, it should be updated when new images come in');
});
test.describe('Example Imagery in Tabs view', () => {
test.skip('Can use Mouse Wheel to zoom in and out of previous image');
test.skip('Can use alt+drag to move around image once zoomed in');
test.skip('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
test.skip('Can zoom into a previous image from thumbstrip in real-time or fixed-time');
test.skip('Clicking on the left arrow should pause the imagery and go to previous image');
test.skip('If the imagery view is in pause mode, it should not be updated when new images come in');
test.skip('If the imagery view is not in pause mode, it should be updated when new images come in');
test.fixme('Can use Mouse Wheel to zoom in and out of previous image');
test.fixme('Can use alt+drag to move around image once zoomed in');
test.fixme('Can zoom into the latest image and the real-time/fixed-time imagery will pause');
test.fixme('Can zoom into a previous image from thumbstrip in real-time or fixed-time');
test.fixme('Clicking on the left arrow should pause the imagery and go to previous image');
test.fixme('If the imagery view is in pause mode, it should not be updated when new images come in');
test.fixme('If the imagery view is not in pause mode, it should be updated when new images come in');
});

View File

@ -21,7 +21,7 @@
*****************************************************************************/
/*
Test for plot autoscale.
Testsuite for plot autoscale.
*/
const { test: _test, expect } = require('@playwright/test');
@ -47,7 +47,7 @@ test.use({
});
test.describe('ExportAsJSON', () => {
test.only('autoscale off causes no error from undefined user range', async ({ page }) => {
test.slow('User can set autoscale with a valid range @snapshot', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
await setTimeRange(page);
@ -68,14 +68,6 @@ test.describe('ExportAsJSON', () => {
.then(shot => expect(shot).toMatchSnapshot('autoscale-canvas-prepan.png', { maxDiffPixels: 40 }))
]);
let errorCount = 0;
function onError() {
errorCount++;
}
page.on('pageerror', onError);
await page.keyboard.down('Alt');
await canvas.dragTo(canvas, {
@ -91,18 +83,12 @@ test.describe('ExportAsJSON', () => {
await page.keyboard.up('Alt');
page.off('pageerror', onError);
// There would have been an error at this point. So if there isn't, then
// we fixed it.
expect(errorCount).toBe(0);
// Ensure the drag worked.
await Promise.all([
testYTicks(page, ['0.00', '0.50', '1.00', '1.50', '2.00']),
new Promise(r => setTimeout(r, 100))
.then(() => canvas.screenshot())
.then(shot => expect(shot).toMatchSnapshot('autoscale-canvas-panned.png', { maxDiffPixels: 20 }))
.then(shot => expect(shot).toMatchSnapshot('autoscale-canvas-panned.png', { maxDiffPixels: 40 }))
]);
});
});
@ -134,9 +120,14 @@ async function createSinewaveOverlayPlot(page) {
// add overlay plot with defaults
await page.locator('li:has-text("Overlay Plot")').click();
await Promise.all([
page.waitForNavigation(/*{ url: 'http://localhost:8080/#/browse/mine/a9268c6f-45cc-4bcd-a6a0-50ac4036e396?tc.mode=fixed&tc.startBound=1649305424163&tc.endBound=1649307224163&tc.timeSystem=utc&view=plot-overlay' }*/),
page.locator('text=OK').click()
page.waitForNavigation(),
page.locator('text=OK').click(),
//Wait for Save Banner to appear1
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'});
// save (exit edit mode)
await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click();
@ -148,14 +139,19 @@ async function createSinewaveOverlayPlot(page) {
// add sine wave generator with defaults
await page.locator('li:has-text("Sine Wave Generator")').click();
await Promise.all([
page.waitForNavigation(/*{ url: 'http://localhost:8080/#/browse/mine/a9268c6f-45cc-4bcd-a6a0-50ac4036e396/5cfa5c69-17bc-4a99-9545-4da8125380c5?tc.mode=fixed&tc.startBound=1649305424163&tc.endBound=1649307224163&tc.timeSystem=utc&view=plot-single' }*/),
page.locator('text=OK').click()
page.waitForNavigation(),
page.locator('text=OK').click(),
//Wait for Save Banner to appear1
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'});
// focus the overlay plot
await page.locator('text=Open MCT My Items >> span').nth(3).click();
await Promise.all([
page.waitForNavigation(/*{ url: 'http://localhost:8080/#/browse/mine/a9268c6f-45cc-4bcd-a6a0-50ac4036e396?tc.mode=fixed&tc.startBound=1649305424163&tc.endBound=1649307224163&tc.timeSystem=utc&view=plot-overlay' }*/),
page.waitForNavigation(),
page.locator('text=Unnamed Overlay Plot').first().click()
]);
}
@ -168,11 +164,18 @@ async function turnOffAutoscale(page) {
await page.locator('text=Unnamed Overlay Plot Snapshot >> button').nth(3).click();
// uncheck autoscale
await page.locator('text=Y Axis Scaling Auto scale Padding >> input[type="checkbox"]').uncheck();
await page.locator('text=Y Axis Label Log mode Auto scale Padding >> input[type="checkbox"] >> nth=1').uncheck();
// save
await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click();
await page.locator('text=Save and Finish Editing').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'});
}
/**
@ -180,6 +183,7 @@ async function turnOffAutoscale(page) {
*/
async function testYTicks(page, values) {
const yTicks = page.locator('.gl-plot-y-tick-label');
await page.locator('canvas >> nth=1').hover();
let promises = [yTicks.count().then(c => expect(c).toBe(values.length))];
for (let i = 0, l = values.length; i < l; i += 1) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,301 @@
/*****************************************************************************
* 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('@playwright/test');
test.describe('Log plot tests', () => {
test.slow('Log Plot ticks are functionally correct in regular and log mode and after refresh', async ({ page }) => {
await makeOverlayPlot(page);
await testRegularTicks(page);
await enableEditMode(page);
await enableLogMode(page);
await testLogTicks(page);
await disableLogMode(page);
await testRegularTicks(page);
await enableLogMode(page);
await testLogTicks(page);
await saveOverlayPlot(page);
await testLogTicks(page);
//await testLogPlotPixels(page);
// refresh page and wait for charts and ticks to load
await page.waitForTimeout(1 * 1000);
await page.reload({ waitUntil: 'networkidle'});
await page.waitForSelector('.gl-plot-chart-area');
await page.waitForSelector('.gl-plot-y-tick-label');
// test log ticks hold up after refresh
await testLogTicks(page);
//await testLogPlotPixels(page);
});
test.skip('Verify that log mode option is reflected in import/export JSON', async ({ page }) => {
await makeOverlayPlot(page);
await enableEditMode(page);
await enableLogMode(page);
await saveOverlayPlot(page);
// TODO ...export, delete the overlay, then import it...
//await testLogTicks(page);
// TODO, the plot is slightly at different position that in the other test, so this fails.
// ...We can fix it by copying all steps from the first test...
// await testLogPlotPixels(page);
});
});
/**
* Makes an overlay plot with a sine wave generator and clicks on the overlay plot in the sidebar so it is the active thing displayed.
* @param {import('@playwright/test').Page} page
*/
async function makeOverlayPlot(page) {
// fresh page with time range from 2022-03-29 22:00:00.000Z to 2022-03-29 22:00:30.000Z
await page.goto('/', { waitUntil: 'networkidle' });
// Set a specific time range for consistency, otherwise it will change
// on every test to a range based on the current time.
const timeInputs = page.locator('input.c-input--datetime');
await timeInputs.first().click();
await timeInputs.first().fill('2022-03-29 22:00:00.000Z');
await timeInputs.nth(1).click();
await timeInputs.nth(1).fill('2022-03-29 22:00:30.000Z');
// create overlay plot
await page.locator('button.c-create-button').click();
await page.locator('li:has-text("Overlay Plot")').click();
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle'}),
page.locator('text=OK').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'});
// save the overlay plot
await saveOverlayPlot(page);
// create a sinewave generator
await page.locator('button.c-create-button').click();
await page.locator('li:has-text("Sine Wave Generator")').click();
// set amplitude to 6, offset 4, period 2
await page.locator('div:nth-child(5) .form-row .c-form-row__controls .form-control .field input').click();
await page.locator('div:nth-child(5) .form-row .c-form-row__controls .form-control .field input').fill('6');
await page.locator('div:nth-child(6) .form-row .c-form-row__controls .form-control .field input').click();
await page.locator('div:nth-child(6) .form-row .c-form-row__controls .form-control .field input').fill('4');
await page.locator('div:nth-child(7) .form-row .c-form-row__controls .form-control .field input').click();
await page.locator('div:nth-child(7) .form-row .c-form-row__controls .form-control .field input').fill('2');
// Click OK to make generator
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle'}),
page.locator('text=OK').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'});
// click on overlay plot
await page.locator('text=Open MCT My Items >> span').nth(3).click();
await Promise.all([
page.waitForNavigation(),
page.locator('text=Unnamed Overlay Plot').first().click()
]);
}
/**
* @param {import('@playwright/test').Page} page
*/
async function testRegularTicks(page) {
const yTicks = await page.locator('.gl-plot-y-tick-label');
expect(await yTicks.count()).toBe(7);
await expect(yTicks.nth(0)).toHaveText('-2');
await expect(yTicks.nth(1)).toHaveText('0');
await expect(yTicks.nth(2)).toHaveText('2');
await expect(yTicks.nth(3)).toHaveText('4');
await expect(yTicks.nth(4)).toHaveText('6');
await expect(yTicks.nth(5)).toHaveText('8');
await expect(yTicks.nth(6)).toHaveText('10');
}
/**
* @param {import('@playwright/test').Page} page
*/
async function testLogTicks(page) {
const yTicks = await page.locator('.gl-plot-y-tick-label');
expect(await yTicks.count()).toBe(28);
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');
}
/**
* @param {import('@playwright/test').Page} page
*/
async function enableEditMode(page) {
// turn on edit mode
await page.locator('text=Unnamed Overlay Plot Snapshot >> button').nth(3).click();
await expect(await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1)).toBeVisible();
}
/**
* @param {import('@playwright/test').Page} page
*/
async function enableLogMode(page) {
// turn on log mode
await page.locator('text=Y Axis Label Log mode Auto scale Padding >> input[type="checkbox"]').first().check();
}
/**
* @param {import('@playwright/test').Page} page
*/
async function disableLogMode(page) {
// turn off log mode
await page.locator('text=Y Axis Label Log mode Auto scale Padding >> input[type="checkbox"]').first().uncheck();
}
/**
* @param {import('@playwright/test').Page} page
*/
async function saveOverlayPlot(page) {
// save overlay plot
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
*/
async function testLogPlotPixels(page) {
const pixelsMatch = await page.evaluate(async () => {
// TODO get canvas pixels at a few locations to make sure they're the correct color, to test that the plot comes out as expected.
await new Promise((r) => setTimeout(r, 5 * 1000));
// These are some pixels that should be blue points in the log plot.
// If the plot changes shape to an unexpected shape, this will
// likely fail, which is what we want.
//
// I found these pixels by pausing playwright in debug mode at this
// point, and using similar code as below to output the pixel data, then
// I logged those pixels here.
const expectedBluePixels = [
// TODO these pixel sets only work with the first test, but not the second test.
// [60, 35],
// [121, 125],
// [156, 377],
// [264, 73],
// [372, 186],
// [576, 73],
// [659, 439],
// [675, 423]
[60, 35],
[120, 125],
[156, 375],
[264, 73],
[372, 185],
[575, 72],
[659, 437],
[675, 421]
];
// The first canvas in the DOM is the one that has the plot point
// icons (canvas 2d), which is the one we are testing. The second
// one in the DOM is the WebGL canvas with the line. (Why aren't
// they both WebGL?)
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
for (const pixel of expectedBluePixels) {
// XXX Possible optimization: call getImageData only once with
// area including all pixels to be tested.
const data = ctx.getImageData(pixel[0], pixel[1], 1, 1).data;
// #43b0ffff <-- openmct cyanish-blue with 100% opacity
// if (data[0] !== 0x43 || data[1] !== 0xb0 || data[2] !== 0xff || data[3] !== 0xff) {
if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 0) {
// If any pixel is empty, it means we didn't hit a plot point.
return false;
}
}
return true;
});
expect(pixelsMatch).toBe(true);
}

View File

@ -67,3 +67,46 @@ test.describe('Time counductor operations', () => {
expect(endDateValidityStatus).not.toBeTruthy();
});
});
// Testing instructions:
// Try to change the realtime offsets when in realtime (local clock) mode.
test.describe('Time conductor input fields real-time mode', () => {
test('validate input fields in real-time mode', async ({ page }) => {
//Go to baseURL
await page.goto('/', { waitUntil: 'networkidle' });
// Set realtime "local clock" mode offsets
const timeInputs = page.locator('input.c-input--datetime');
// Click fixed timespan button
await page.locator('.c-button__label >> text=Fixed Timespan').click();
// Click local clock
await page.locator('.icon-clock >> text=Local Clock').click();
// Click time offset button
await page.locator('.c-conductor__delta-button >> text=00:30:00').click();
// Input start time offset
await page.fill('.pr-time-controls__secs', '23');
// Click the check button
await page.locator('.icon-check').click();
// Verify time was updated on time offset button
await expect(page.locator('.c-conductor__delta-button').first()).toContainText('00:30:23');
// Click time offset set preceding now button
await page.locator('.c-conductor__delta-button >> text=00:00:30').click();
// Input preceding time offset
await page.fill('.pr-time-controls__secs', '31')
// Click the check buttons
await page.locator('.icon-check').click();
// Verify time was updated on preceding time offset button
await expect(page.locator('.c-conductor__delta-button').nth(1)).toContainText('00:00:31');
});
});

View File

@ -0,0 +1,22 @@
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:8080",
"localStorage": [
{
"name": "tcHistory",
"value": "{\"utc\":[{\"start\":1651513945533,\"end\":1651515745533}]}"
},
{
"name": "mct",
"value": "{\"mine\":{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"c0f99e39-85e7-4ef7-99b1-ef52d4ed69b2\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"persisted\":1651515746374,\"modified\":1651515746374},\"c0f99e39-85e7-4ef7-99b1-ef52d4ed69b2\":{\"name\":\"Unnamed Condition Set\",\"type\":\"conditionSet\",\"identifier\":{\"key\":\"c0f99e39-85e7-4ef7-99b1-ef52d4ed69b2\",\"namespace\":\"\"},\"configuration\":{\"conditionTestData\":[],\"conditionCollection\":[{\"isDefault\":true,\"id\":\"e35a066b-eb0e-4b05-a4c9-cc31dc202572\",\"configuration\":{\"name\":\"Default\",\"output\":\"Default\",\"trigger\":\"all\",\"criteria\":[]},\"summary\":\"Default condition\"}]},\"composition\":[],\"telemetry\":{},\"modified\":1651515746373,\"location\":\"mine\",\"persisted\":1651515746373}}"
},
{
"name": "mct-tree-expanded",
"value": "[]"
}
]
}
]
}

View File

@ -77,7 +77,7 @@
openmct.install(openmct.plugins.LocalStorage());
openmct.install(openmct.plugins.example.Generator());
openmct.install(openmct.plugins.example.EventGeneratorPlugin());
openmct.install(openmct.plugins.example.ExampleImagery());

View File

@ -1,13 +1,13 @@
{
"name": "openmct",
"version": "2.0.2",
"version": "2.0.3",
"description": "The Open MCT core platform",
"devDependencies": {
"@babel/eslint-parser": "7.16.3",
"@braintree/sanitize-url": "6.0.0",
"@percy/cli": "1.0.4",
"@percy/playwright": "1.0.2",
"@playwright/test": "1.19.2",
"@playwright/test": "1.21.1",
"@types/eventemitter3": "^1.0.0",
"@types/jasmine": "^4.0.1",
"@types/karma": "^6.3.2",
@ -20,12 +20,12 @@
"copy-webpack-plugin": "10.2.0",
"cross-env": "7.0.3",
"css-loader": "4.0.0",
"d3-axis": "1.0.x",
"d3-scale": "1.0.x",
"d3-selection": "1.3.x",
"d3-axis": "3.0.0",
"d3-scale": "3.3.0",
"d3-selection": "3.0.0",
"eslint": "8.13.0",
"eslint-plugin-compat": "4.0.2",
"eslint-plugin-playwright": "0.8.0",
"eslint-plugin-playwright": "0.9.0",
"eslint-plugin-vue": "8.5.0",
"eslint-plugin-you-dont-need-lodash-underscore": "6.12.0",
"eventemitter3": "1.2.0",
@ -37,7 +37,7 @@
"imports-loader": "0.8.0",
"jasmine-core": "4.0.1",
"jsdoc": "3.5.5",
"karma": "6.3.17",
"karma": "6.3.18",
"karma-chrome-launcher": "3.1.1",
"karma-cli": "2.0.0",
"karma-coverage": "2.1.1",
@ -46,14 +46,14 @@
"karma-jasmine": "4.0.1",
"karma-junit-reporter": "2.0.1",
"karma-sourcemap-loader": "0.3.8",
"karma-spec-reporter": "0.0.33",
"karma-spec-reporter": "0.0.34",
"karma-webpack": "5.0.0",
"lighthouse": "9.5.0",
"location-bar": "3.0.1",
"lodash": "4.17.21",
"mini-css-extract-plugin": "2.6.0",
"moment": "2.29.1",
"moment-duration-format": "2.2.2",
"moment": "2.29.3",
"moment-duration-format": "2.3.2",
"moment-timezone": "0.5.34",
"node-bourbon": "4.2.3",
"painterro": "1.2.56",
@ -61,7 +61,7 @@
"plotly.js-gl2d-dist": "2.5.0",
"printj": "1.3.1",
"request": "2.88.2",
"resolve-url-loader": "4.0.0",
"resolve-url-loader": "5.0.0",
"sass": "1.49.9",
"sass-loader": "12.6.0",
"sinon": "13.0.1",
@ -92,8 +92,9 @@
"test": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --single-run",
"test:firefox": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --single-run --browsers=FirefoxHeadless",
"test:debug": "cross-env NODE_ENV=debug karma start --no-single-run",
"test:e2e:ci": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome smoke default condition timeConductor",
"test:e2e:ci": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome smoke default condition timeConductor branding clock",
"test:e2e:local": "npx playwright test --config=e2e/playwright-local.config.js --project=chrome",
"test:e2e:updatesnapshots": "npx playwright test --config=e2e/playwright-local.config.js --project=chrome --grep @snapshot --update-snapshots",
"test:e2e:visual": "percy exec --config ./e2e/.percy.yml -- npx playwright test --config=e2e/playwright-visual.config.js default",
"test:e2e:full": "npx playwright test --config=e2e/playwright-ci.config.js",
"test:watch": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --no-single-run",

View File

@ -241,7 +241,6 @@ define([
this.branding = BrandingAPI.default;
// Plugins that are installed by default
this.install(this.plugins.Plot());
this.install(this.plugins.Chart());
this.install(this.plugins.TelemetryTable.default());

View File

@ -1,5 +1,6 @@
import AutoCompleteField from './components/controls/AutoCompleteField.vue';
import ClockDisplayFormatField from './components/controls/ClockDisplayFormatField.vue';
import CheckBoxField from './components/controls/CheckBoxField.vue';
import Datetime from './components/controls/Datetime.vue';
import FileInput from './components/controls/FileInput.vue';
import Locator from './components/controls/Locator.vue';
@ -7,11 +8,13 @@ import NumberField from './components/controls/NumberField.vue';
import SelectField from './components/controls/SelectField.vue';
import TextAreaField from './components/controls/TextAreaField.vue';
import TextField from './components/controls/TextField.vue';
import ToggleSwitchField from './components/controls/ToggleSwitchField.vue';
import Vue from 'vue';
export const DEFAULT_CONTROLS_MAP = {
'autocomplete': AutoCompleteField,
'checkbox': CheckBoxField,
'composite': ClockDisplayFormatField,
'datetime': Datetime,
'file-input': FileInput,
@ -19,7 +22,8 @@ export const DEFAULT_CONTROLS_MAP = {
'numberfield': NumberField,
'select': SelectField,
'textarea': TextAreaField,
'textfield': TextField
'textfield': TextField,
'toggleSwitch': ToggleSwitchField
};
export default class FormControl {
@ -94,4 +98,3 @@ export default class FormControl {
};
}
}

View File

@ -79,10 +79,12 @@ export default {
rowClass() {
let cssClass = this.cssClass;
if (this.row.required) {
cssClass = `${cssClass} req`;
if (!this.row.required) {
return;
}
cssClass = `${cssClass} req`;
if (this.visited && this.valid !== undefined) {
if (this.valid === true) {
cssClass = `${cssClass} valid`;

View File

@ -0,0 +1,55 @@
/*****************************************************************************
* 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>
<span class="form-control shell">
<span
class="field control"
:class="model.cssClass"
>
<input
type="checkbox"
:checked="isChecked"
@input="toggleCheckBox"
>
</span>
</span>
</template>
<script>
import toggleMixin from '../../toggle-check-box-mixin';
export default {
mixins: [toggleMixin],
props: {
model: {
type: Object,
required: true
}
},
data() {
return {
isChecked: this.model.value
};
}
};
</script>

View File

@ -58,7 +58,6 @@ export default {
},
methods: {
updateText() {
console.log('updateText', this.field);
const data = {
model: this.model,
value: this.field

View File

@ -0,0 +1,62 @@
/*****************************************************************************
* 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>
<span class="form-control shell">
<span
class="field control"
:class="model.cssClass"
>
<ToggleSwitch
id="switchId"
:checked="isChecked"
@change="toggleCheckBox"
/>
</span>
</span>
</template>
<script>
import toggleMixin from '../../toggle-check-box-mixin';
import ToggleSwitch from '@/ui/components/ToggleSwitch.vue';
import uuid from 'uuid';
export default {
components: {
ToggleSwitch
},
mixins: [toggleMixin],
props: {
model: {
type: Object,
required: true
}
},
data() {
return {
switchId: `toggleSwitch-${uuid}`,
isChecked: this.model.value
};
}
};
</script>

View File

@ -0,0 +1,19 @@
export default {
data() {
return {
isChecked: false
};
},
methods: {
toggleCheckBox(event) {
this.isChecked = !this.isChecked;
const data = {
model: this.model,
value: this.isChecked
};
this.$emit('onChange', data);
}
}
};

View File

@ -36,13 +36,14 @@ class InMemorySearchProvider {
*/
this.MAX_CONCURRENT_REQUESTS = 100;
/**
* If max results is not specified in query, use this as default.
*/
* If max results is not specified in query, use this as default.
*/
this.DEFAULT_MAX_RESULTS = 100;
this.openmct = openmct;
this.indexedIds = {};
this.indexedCompositions = {};
this.idsToIndex = [];
this.pendingIndex = {};
this.pendingRequests = 0;
@ -58,7 +59,6 @@ class InMemorySearchProvider {
this.onWorkerMessageError = this.onWorkerMessageError.bind(this);
this.onerror = this.onWorkerError.bind(this);
this.startIndexing = this.startIndexing.bind(this);
this.onMutationOfIndexedObject = this.onMutationOfIndexedObject.bind(this);
this.openmct.on('start', this.startIndexing);
this.openmct.on('destroy', () => {
@ -68,6 +68,9 @@ class InMemorySearchProvider {
this.worker.port.onmessageerror = null;
this.worker.port.close();
}
this.destroyObservers(this.indexedIds);
this.destroyObservers(this.indexedCompositions);
});
}
@ -137,7 +140,7 @@ class InMemorySearchProvider {
};
modelResults.hits = await Promise.all(event.data.results.map(async (hit) => {
const identifier = this.openmct.objects.parseKeyString(hit.keyString);
const domainObject = await this.openmct.objects.get(identifier.key);
const domainObject = await this.openmct.objects.get(identifier);
return domainObject;
}));
@ -213,29 +216,52 @@ class InMemorySearchProvider {
}
}
onMutationOfIndexedObject(domainObject) {
onNameMutation(domainObject, name) {
const provider = this;
provider.index(domainObject.identifier, domainObject);
domainObject.name = name;
provider.index(domainObject);
}
onCompositionMutation(domainObject, composition) {
const provider = this;
const indexedComposition = domainObject.composition;
const identifiersToIndex = composition
.filter(identifier => !indexedComposition
.some(indexedIdentifier => this.openmct.objects
.areIdsEqual([identifier, indexedIdentifier])));
identifiersToIndex.forEach(identifier => {
this.openmct.objects.get(identifier).then(objectToIndex => provider.index(objectToIndex));
});
}
/**
* Pass an id and model to the worker to be indexed. If the model has
* composition, schedule those ids for later indexing.
* Pass a domainObject to the worker to be indexed.
* If the object has composition, schedule those ids for later indexing.
* Watch for object changes and re-index object and children if so
*
* @private
* @param id a model id
* @param model a model
* @param domainObject a domainObject
*/
async index(id, domainObject) {
async index(domainObject) {
const provider = this;
const keyString = this.openmct.objects.makeKeyString(id);
const keyString = this.openmct.objects.makeKeyString(domainObject.identifier);
if (!this.indexedIds[keyString]) {
this.openmct.objects.observe(domainObject, `*`, this.onMutationOfIndexedObject);
this.indexedIds[keyString] = this.openmct.objects.observe(
domainObject,
'name',
this.onNameMutation.bind(this, domainObject)
);
this.indexedCompositions[keyString] = this.openmct.objects.observe(
domainObject,
'composition',
this.onCompositionMutation.bind(this, domainObject)
);
}
this.indexedIds[keyString] = true;
if ((id.key !== 'ROOT')) {
if ((keyString !== 'ROOT')) {
if (this.worker) {
this.worker.port.postMessage({
request: 'index',
@ -247,15 +273,12 @@ class InMemorySearchProvider {
}
}
const composition = this.openmct.composition.registry.find(foundComposition => {
return foundComposition.appliesTo(domainObject);
});
const composition = this.openmct.composition.get(domainObject);
if (composition) {
const childIdentifiers = await composition.load(domainObject);
childIdentifiers.forEach(function (childIdentifier) {
provider.scheduleForIndexing(childIdentifier);
});
if (composition !== undefined) {
const children = await composition.load();
children.forEach(child => provider.scheduleForIndexing(child.identifier));
}
}
@ -271,12 +294,12 @@ class InMemorySearchProvider {
const provider = this;
this.pendingRequests += 1;
const identifier = await this.openmct.objects.parseKeyString(keyString);
const domainObject = await this.openmct.objects.get(identifier.key);
const domainObject = await this.openmct.objects.get(keyString);
delete provider.pendingIndex[keyString];
try {
if (domainObject) {
await provider.index(identifier, domainObject);
await provider.index(domainObject);
}
} catch (error) {
console.warn('Failed to index domain object ' + keyString, error);
@ -305,9 +328,9 @@ class InMemorySearchProvider {
}
/**
* A local version of the same SharedWorker function
* if we don't have SharedWorkers available (e.g., iOS)
*/
* A local version of the same SharedWorker function
* if we don't have SharedWorkers available (e.g., iOS)
*/
localIndexItem(keyString, model) {
this.localIndexedItems[keyString] = {
type: model.type,
@ -347,6 +370,16 @@ class InMemorySearchProvider {
};
this.onWorkerMessage(eventToReturn);
}
destroyObservers(observers) {
Object.entries(observers).forEach(([keyString, unobserve]) => {
if (typeof unobserve === 'function') {
unobserve();
}
delete observers[keyString];
});
}
}
export default InMemorySearchProvider;

View File

@ -105,13 +105,18 @@ describe("The Object API Search Function", () => {
beforeEach((done) => {
openmct = createOpenMct();
const defaultObjectProvider = openmct.objects.getProvider({
key: '',
namespace: ''
});
openmct.objects.addProvider('foo', defaultObjectProvider);
spyOn(openmct.objects.inMemorySearchProvider, "query").and.callThrough();
spyOn(openmct.objects.inMemorySearchProvider, "localSearch").and.callThrough();
openmct.on('start', async () => {
mockIdentifier1 = {
key: 'some-object',
namespace: 'some-namespace'
namespace: 'foo'
};
mockDomainObject1 = {
type: 'clock',
@ -120,7 +125,7 @@ describe("The Object API Search Function", () => {
};
mockIdentifier2 = {
key: 'some-other-object',
namespace: 'some-namespace'
namespace: 'foo'
};
mockDomainObject2 = {
type: 'clock',
@ -129,16 +134,16 @@ describe("The Object API Search Function", () => {
};
mockIdentifier3 = {
key: 'yet-another-object',
namespace: 'some-namespace'
namespace: 'foo'
};
mockDomainObject3 = {
type: 'clock',
name: 'redBear',
identifier: mockIdentifier3
};
await openmct.objects.inMemorySearchProvider.index(mockIdentifier1, mockDomainObject1);
await openmct.objects.inMemorySearchProvider.index(mockIdentifier2, mockDomainObject2);
await openmct.objects.inMemorySearchProvider.index(mockIdentifier3, mockDomainObject3);
await openmct.objects.inMemorySearchProvider.index(mockDomainObject1);
await openmct.objects.inMemorySearchProvider.index(mockDomainObject2);
await openmct.objects.inMemorySearchProvider.index(mockDomainObject3);
done();
});
openmct.startHeadless();
@ -175,9 +180,9 @@ describe("The Object API Search Function", () => {
beforeEach(async () => {
openmct.objects.inMemorySearchProvider.worker = null;
// reindex locally
await openmct.objects.inMemorySearchProvider.index(mockIdentifier1, mockDomainObject1);
await openmct.objects.inMemorySearchProvider.index(mockIdentifier2, mockDomainObject2);
await openmct.objects.inMemorySearchProvider.index(mockIdentifier3, mockDomainObject3);
await openmct.objects.inMemorySearchProvider.index(mockDomainObject1);
await openmct.objects.inMemorySearchProvider.index(mockDomainObject2);
await openmct.objects.inMemorySearchProvider.index(mockDomainObject3);
});
it("calls local search", () => {
openmct.objects.search('foo');

View File

@ -22,12 +22,14 @@
export default class Transaction {
constructor(objectAPI) {
this.dirtyObjects = new Set();
this.dirtyObjects = {};
this.objectAPI = objectAPI;
}
add(object) {
this.dirtyObjects.add(object);
const key = this.objectAPI.makeKeyString(object.identifier);
this.dirtyObjects[key] = object;
}
cancel() {
@ -37,7 +39,8 @@ export default class Transaction {
commit() {
const promiseArray = [];
const save = this.objectAPI.save.bind(this.objectAPI);
this.dirtyObjects.forEach(object => {
Object.values(this.dirtyObjects).forEach(object => {
promiseArray.push(this.createDirtyObjectPromise(object, save));
});
@ -48,7 +51,9 @@ export default class Transaction {
return new Promise((resolve, reject) => {
action(object)
.then((success) => {
this.dirtyObjects.delete(object);
const key = this.objectAPI.makeKeyString(object.identifier);
delete this.dirtyObjects[key];
resolve(success);
})
.catch(reject);
@ -57,7 +62,8 @@ export default class Transaction {
getDirtyObject(identifier) {
let dirtyObject;
this.dirtyObjects.forEach(object => {
Object.values(this.dirtyObjects).forEach(object => {
const areIdsEqual = this.objectAPI.areIdsEqual(object.identifier, identifier);
if (areIdsEqual) {
dirtyObject = object;
@ -67,14 +73,11 @@ export default class Transaction {
return dirtyObject;
}
start() {
this.dirtyObjects = new Set();
}
_clear() {
const promiseArray = [];
const refresh = this.objectAPI.refresh.bind(this.objectAPI);
this.dirtyObjects.forEach(object => {
Object.values(this.dirtyObjects).forEach(object => {
promiseArray.push(this.createDirtyObjectPromise(object, refresh));
});

View File

@ -34,24 +34,24 @@ describe("Transaction Class", () => {
});
it('has no dirty objects', () => {
expect(transaction.dirtyObjects.size).toEqual(0);
expect(Object.keys(transaction.dirtyObjects).length).toEqual(0);
});
it('add(), adds object to dirtyObjects', () => {
const mockDomainObjects = createMockDomainObjects();
transaction.add(mockDomainObjects[0]);
expect(transaction.dirtyObjects.size).toEqual(1);
expect(Object.keys(transaction.dirtyObjects).length).toEqual(1);
});
it('cancel(), clears all dirtyObjects', (done) => {
const mockDomainObjects = createMockDomainObjects(3);
mockDomainObjects.forEach(transaction.add.bind(transaction));
expect(transaction.dirtyObjects.size).toEqual(3);
expect(Object.keys(transaction.dirtyObjects).length).toEqual(3);
transaction.cancel()
.then(success => {
expect(transaction.dirtyObjects.size).toEqual(0);
expect(Object.keys(transaction.dirtyObjects).length).toEqual(0);
}).finally(done);
});
@ -59,12 +59,12 @@ describe("Transaction Class", () => {
const mockDomainObjects = createMockDomainObjects(3);
mockDomainObjects.forEach(transaction.add.bind(transaction));
expect(transaction.dirtyObjects.size).toEqual(3);
expect(Object.keys(transaction.dirtyObjects).length).toEqual(3);
spyOn(objectAPI, 'save').and.callThrough();
transaction.commit()
.then(success => {
expect(transaction.dirtyObjects.size).toEqual(0);
expect(Object.keys(transaction.dirtyObjects).length).toEqual(0);
expect(objectAPI.save.calls.count()).toEqual(3);
}).finally(done);
});
@ -73,7 +73,7 @@ describe("Transaction Class", () => {
const mockDomainObjects = createMockDomainObjects();
transaction.add(mockDomainObjects[0]);
expect(transaction.dirtyObjects.size).toEqual(1);
expect(Object.keys(transaction.dirtyObjects).length).toEqual(1);
const dirtyObject = transaction.getDirtyObject(mockDomainObjects[0].identifier);
expect(dirtyObject).toEqual(mockDomainObjects[0]);
@ -82,7 +82,7 @@ describe("Transaction Class", () => {
it('getDirtyObject(), returns empty dirtyObject for no active transaction', () => {
const mockDomainObjects = createMockDomainObjects();
expect(transaction.dirtyObjects.size).toEqual(0);
expect(Object.keys(transaction.dirtyObjects).length).toEqual(0);
const dirtyObject = transaction.getDirtyObject(mockDomainObjects[0].identifier);
expect(dirtyObject).toEqual(undefined);

View File

@ -172,6 +172,7 @@ export class TelemetryCollection extends EventEmitter {
* @private
*/
_processNewTelemetry(telemetryData) {
performance.mark('tlm:process:start');
if (telemetryData === undefined) {
return;
}
@ -352,6 +353,7 @@ export class TelemetryCollection extends EventEmitter {
* @todo handle subscriptions more granually
*/
_reset() {
performance.mark('tlm:reset');
this.boundedTelemetry = [];
this.futureBuffer = [];

View File

@ -1,4 +1,3 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, United States Government
* as represented by the Administrator of the National Aeronautics and Space
@ -114,14 +113,12 @@ export default {
this.metadata = this.openmct.telemetry.getMetadata(this.domainObject);
this.formats = this.openmct.telemetry.getFormatMap(this.metadata);
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
this.bounds = this.openmct.time.bounds();
this.limitEvaluator = this.openmct
.telemetry
.limitEvaluator(this.domainObject);
this.openmct.time.on('timeSystem', this.updateTimeSystem);
this.openmct.time.on('bounds', this.updateBounds);
this.timestampKey = this.openmct.time.timeSystem().key;
@ -135,72 +132,41 @@ export default {
this.valueKey = this.valueMetadata ? this.valueMetadata.key : undefined;
this.unsubscribe = this.openmct
.telemetry
.subscribe(this.domainObject, this.setLatestValues);
this.requestHistory();
this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, {
size: 1,
strategy: 'latest'
});
this.telemetryCollection.on('add', this.setLatestValues);
this.telemetryCollection.on('clear', this.resetValues);
this.telemetryCollection.load();
if (this.hasUnits) {
this.setUnit();
}
},
destroyed() {
this.unsubscribe();
this.openmct.time.off('timeSystem', this.updateTimeSystem);
this.openmct.time.off('bounds', this.updateBounds);
this.telemetryCollection.off('add', this.setLatestValues);
this.telemetryCollection.off('clear', this.resetValues);
this.telemetryCollection.destroy();
},
methods: {
updateView() {
if (!this.updatingView) {
this.updatingView = true;
requestAnimationFrame(() => {
let newTimestamp = this.getParsedTimestamp(this.latestDatum);
if (this.shouldUpdate(newTimestamp)) {
this.timestamp = newTimestamp;
this.datum = this.latestDatum;
}
this.timestamp = this.getParsedTimestamp(this.latestDatum);
this.datum = this.latestDatum;
this.updatingView = false;
});
}
},
setLatestValues(datum) {
this.latestDatum = datum;
setLatestValues(data) {
this.latestDatum = data[data.length - 1];
this.updateView();
},
shouldUpdate(newTimestamp) {
return this.inBounds(newTimestamp)
&& (this.timestamp === undefined || newTimestamp > this.timestamp);
},
requestHistory() {
this.openmct
.telemetry
.request(this.domainObject, {
start: this.bounds.start,
end: this.bounds.end,
size: 1,
strategy: 'latest'
})
.then((array) => this.setLatestValues(array[array.length - 1]))
.catch((error) => {
console.warn('Error fetching data', error);
});
},
updateBounds(bounds, isTick) {
this.bounds = bounds;
if (!isTick) {
this.resetValues();
this.requestHistory();
}
},
inBounds(timestamp) {
return timestamp >= this.bounds.start && timestamp <= this.bounds.end;
},
updateTimeSystem(timeSystem) {
this.resetValues();
this.timestampKey = timeSystem.key;
},
updateViewContext() {
@ -241,4 +207,3 @@ export default {
}
};
</script>

View File

@ -46,6 +46,7 @@ describe("The LAD Table", () => {
let openmct;
let ladPlugin;
let historicalProvider;
let parent;
let child;
let telemetryCount = 3;
@ -81,6 +82,13 @@ describe("The LAD Table", () => {
spyOn(openmct.objects, 'get').and.returnValue(Promise.resolve({}));
historicalProvider = {
request: () => {
return Promise.resolve([]);
}
};
spyOn(openmct.telemetry, 'findRequestProvider').and.returnValue(historicalProvider);
openmct.time.bounds({
start: bounds.start,
end: bounds.end
@ -147,7 +155,7 @@ describe("The LAD Table", () => {
// add another telemetry object as composition in lad table to test multi rows
mockObj.ladTable.composition.push(anotherTelemetryObj.identifier);
beforeEach(async () => {
beforeEach(async (done) => {
let telemetryRequestResolve;
let telemetryObjectResolve;
let anotherTelemetryObjectResolve;
@ -166,11 +174,12 @@ describe("The LAD Table", () => {
callBack();
});
openmct.telemetry.request.and.callFake(() => {
historicalProvider.request = () => {
telemetryRequestResolve(mockTelemetry);
return telemetryRequestPromise;
});
};
openmct.objects.get.and.callFake((obj) => {
if (obj.key === 'telemetry-object') {
telemetryObjectResolve(mockObj.telemetry);
@ -195,6 +204,8 @@ describe("The LAD Table", () => {
await Promise.all([telemetryRequestPromise, telemetryObjectPromise, anotherTelemetryObjectPromise]);
await Vue.nextTick();
done();
});
it("should show one row per object in the composition", () => {

View File

@ -27,7 +27,7 @@
:href="url"
>
<div class="c-condition-widget__label">
{{ internalDomainObject.conditionalLabel || internalDomainObject.label }}
{{ label }}
</div>
</component>
</template>
@ -39,28 +39,112 @@ export default {
inject: ['openmct', 'domainObject'],
data: function () {
return {
internalDomainObject: this.domainObject
conditionalLabel: '',
conditionSetIdentifier: null,
domainObjectLabel: '',
url: null,
urlDefined: false,
useConditionSetOutputAsLabel: false
};
},
computed: {
urlDefined() {
return this.internalDomainObject.url && this.internalDomainObject.url.length > 0;
},
url() {
return this.urlDefined ? sanitizeUrl(this.internalDomainObject.url) : null;
label() {
return this.useConditionSetOutputAsLabel
? this.conditionalLabel
: this.domainObjectLabel
;
}
},
watch: {
conditionSetIdentifier: {
handler(newValue, oldValue) {
if (!oldValue || !newValue || !this.openmct.objects.areIdsEqual(newValue, oldValue)) {
return;
}
this.listenToConditionSetChanges();
},
deep: true
}
},
mounted() {
this.unlisten = this.openmct.objects.observe(this.internalDomainObject, '*', this.updateInternalDomainObject);
this.unlisten = this.openmct.objects.observe(this.domainObject, '*', this.updateDomainObject);
if (this.domainObject) {
this.updateDomainObject(this.domainObject);
this.listenToConditionSetChanges();
}
},
beforeDestroy() {
this.conditionSetIdentifier = null;
if (this.unlisten) {
this.unlisten();
}
this.stopListeningToConditionSetChanges();
},
methods: {
updateInternalDomainObject(domainObject) {
this.internalDomainObject = domainObject;
async listenToConditionSetChanges() {
if (!this.conditionSetIdentifier) {
return;
}
const conditionSetDomainObject = await this.openmct.objects.get(this.conditionSetIdentifier);
this.stopListeningToConditionSetChanges();
if (!conditionSetDomainObject) {
this.openmct.notifications.alert('Unable to find condition set');
}
this.telemetryCollection = this.openmct.telemetry.requestCollection(conditionSetDomainObject, {
size: 1,
strategy: 'latest'
});
this.telemetryCollection.on('add', this.updateConditionLabel, this);
this.telemetryCollection.load();
},
stopListeningToConditionSetChanges() {
if (this.telemetryCollection) {
this.telemetryCollection.off('add', this.updateConditionLabel, this);
this.telemetryCollection.destroy();
this.telemetryCollection = null;
}
},
updateConditionLabel([latestDatum]) {
if (!this.conditionSetIdentifier) {
this.stopListeningToConditionSetChanges();
return;
}
this.conditionalLabel = latestDatum.output || '';
},
updateDomainObject(domainObject) {
if (this.domainObjectLabel !== domainObject.label) {
this.domainObjectLabel = domainObject.label;
}
const urlDefined = domainObject.url && domainObject.url.length > 0;
if (this.urlDefined !== urlDefined) {
this.urlDefined = urlDefined;
}
const url = this.urlDefined ? sanitizeUrl(domainObject.url) : null;
if (this.url !== url) {
this.url = url;
}
const conditionSetIdentifier = domainObject.configuration.objectStyles.conditionSetIdentifier;
if (this.conditionSetIdentifier !== conditionSetIdentifier) {
this.conditionSetIdentifier = conditionSetIdentifier;
}
const useConditionSetOutputAsLabel = this.conditionSetIdentifier && domainObject.configuration.useConditionSetOutputAsLabel;
if (this.useConditionSetOutputAsLabel !== useConditionSetOutputAsLabel) {
this.useConditionSetOutputAsLabel = useConditionSetOutputAsLabel;
}
}
}
};

View File

@ -222,20 +222,20 @@ export default {
.then(this.setObject);
}
this.openmct.time.on("bounds", this.refreshData);
this.status = this.openmct.status.get(this.item.identifier);
this.removeStatusListener = this.openmct.status.observe(this.item.identifier, this.setStatus);
},
beforeDestroy() {
this.removeSubscription();
this.removeStatusListener();
if (this.removeSelectable) {
this.removeSelectable();
}
this.openmct.time.off("bounds", this.refreshData);
this.telemetryCollection.off('add', this.setLatestValues);
this.telemetryCollection.off('clear', this.refreshData);
this.telemetryCollection.destroy();
if (this.mutablePromise) {
this.mutablePromise.then(() => {
@ -253,34 +253,9 @@ export default {
return `At ${timeFormatter.format(this.datum)} ${this.domainObject.name} had a value of ${this.telemetryValue}${unit}`;
},
requestHistoricalData() {
let bounds = this.openmct.time.bounds();
let options = {
start: bounds.start,
end: bounds.end,
size: 1,
strategy: 'latest'
};
this.openmct.telemetry.request(this.domainObject, options)
.then(data => {
if (data.length > 0) {
this.latestDatum = data[data.length - 1];
this.updateView();
}
});
},
subscribeToObject() {
this.subscription = this.openmct.telemetry.subscribe(this.domainObject, function (datum) {
const key = this.openmct.time.timeSystem().key;
const datumTimeStamp = datum[key];
if (this.openmct.time.clock() !== undefined
|| (datumTimeStamp
&& (this.openmct.time.bounds().end >= datumTimeStamp))
) {
this.latestDatum = datum;
this.updateView();
}
}.bind(this));
setLatestValues(data) {
this.latestDatum = data[data.length - 1];
this.updateView();
},
updateView() {
if (!this.updatingView) {
@ -291,17 +266,10 @@ export default {
});
}
},
removeSubscription() {
if (this.subscription) {
this.subscription();
this.subscription = undefined;
}
},
refreshData(bounds, isTick) {
if (!isTick) {
this.latestDatum = undefined;
this.updateView();
this.requestHistoricalData(this.domainObject);
}
},
setObject(domainObject) {
@ -315,8 +283,13 @@ export default {
const valueMetadata = this.metadata.value(this.item.value);
this.customStringformatter = this.openmct.telemetry.customStringFormatter(valueMetadata, this.item.format);
this.requestHistoricalData();
this.subscribeToObject();
this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, {
size: 1,
strategy: 'latest'
});
this.telemetryCollection.on('add', this.setLatestValues);
this.telemetryCollection.on('clear', this.refreshData);
this.telemetryCollection.load();
this.currentObjectPath = this.objectPath.slice();
this.currentObjectPath.unshift(this.domainObject);

View File

@ -57,7 +57,7 @@
/>
<drop-hint
:key="i"
:key="'hint-' + i"
class="c-fl-frame__drop-hint"
:index="i"
:allow-drop="allowDrop"
@ -66,7 +66,7 @@
<resize-handle
v-if="(i !== frames.length - 1)"
:key="i"
:key="'handle-' + i"
:index="i"
:orientation="rowsLayout ? 'horizontal' : 'vertical'"
:is-editing="isEditing"

View File

@ -90,6 +90,9 @@ export default class CreateWizard {
rows: this.properties.map(property => {
const row = JSON.parse(JSON.stringify(property));
row.value = this.getValue(row);
if (property.validate) {
row.validate = property.validate;
}
return row;
}).filter(row => row && row.control)

View File

@ -51,41 +51,29 @@ export default class EditPropertiesAction extends PropertiesAction {
/**
* @private
*/
async _onSave(changes) {
Object.entries(changes).forEach(([key, value]) => {
const properties = key.split('.');
let object = this.domainObject;
const propertiesLength = properties.length;
properties.forEach((property, index) => {
const isComplexProperty = propertiesLength > 1 && index !== propertiesLength - 1;
if (isComplexProperty && object[property] !== null) {
object = object[property];
} else {
object[property] = value;
}
_onSave(changes) {
try {
Object.entries(changes).forEach(([key, value]) => {
const properties = key.split('.');
let object = this.domainObject;
const propertiesLength = properties.length;
properties.forEach((property, index) => {
const isComplexProperty = propertiesLength > 1 && index !== propertiesLength - 1;
if (isComplexProperty && object[property] !== null) {
object = object[property];
} else {
object[property] = value;
}
});
object = value;
this.openmct.objects.mutate(this.domainObject, key, value);
this.openmct.notifications.info('Save successful');
});
object = value;
});
this.domainObject.modified = Date.now();
// Show saving progress dialog
let dialog = this.openmct.overlays.progressDialog({
progressPerc: 'unknown',
message: 'Do not navigate away from this page or close this browser tab while this message is displayed.',
iconClass: 'info',
title: 'Saving'
});
const success = await this.openmct.objects.save(this.domainObject);
if (success) {
this.openmct.notifications.info('Save successful');
} else {
} catch (error) {
this.openmct.notifications.error('Error saving objects');
console.error(error);
}
dialog.dismiss();
}
/**

View File

@ -0,0 +1,199 @@
/*****************************************************************************
* 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.
*****************************************************************************/
import GaugeViewProvider from './GaugeViewProvider';
import GaugeFormController from './components/GaugeFormController.vue';
import Vue from 'vue';
export const GAUGE_TYPES = [
['Filled Dial', 'dial-filled'],
['Needle Dial', 'dial-needle'],
['Vertical Meter', 'meter-vertical'],
['Vertical Meter Inverted', 'meter-vertical-inverted'],
['Horizontal Meter', 'meter-horizontal']
];
export default function () {
return function install(openmct) {
openmct.objectViews.addProvider(new GaugeViewProvider(openmct));
openmct.forms.addNewFormControl('gauge-controller', getGaugeFormController(openmct));
openmct.types.addType('gauge', {
name: "Gauge",
creatable: true,
description: "Graphically visualize a telemetry element's current value between a minimum and maximum.",
cssClass: 'icon-gauge',
initialize(domainObject) {
domainObject.composition = [];
domainObject.configuration = {
gaugeController: {
gaugeType: GAUGE_TYPES[0][1],
isDisplayMinMax: true,
isDisplayCurVal: true,
isUseTelemetryLimits: true,
limitLow: 10,
limitHigh: 90,
max: 100,
min: 0,
precision: 2
}
};
},
form: [
{
name: "Display current value",
control: "toggleSwitch",
cssClass: "l-input",
key: "isDisplayCurVal",
property: [
"configuration",
"gaugeController",
"isDisplayCurVal"
]
},
{
name: "Display range values",
control: "toggleSwitch",
cssClass: "l-input",
key: "isDisplayMinMax",
property: [
"configuration",
"gaugeController",
"isDisplayMinMax"
]
},
{
name: "Float precision",
control: "numberfield",
cssClass: "l-input-sm",
key: "precision",
property: [
"configuration",
"gaugeController",
"precision"
]
},
{
name: "Gauge type",
options: GAUGE_TYPES.map(type => {
return {
name: type[0],
value: type[1]
};
}),
control: "select",
cssClass: "l-input-sm",
key: "gaugeController",
property: [
"configuration",
"gaugeController",
"gaugeType"
]
},
{
name: "Value ranges and limits",
control: "gauge-controller",
cssClass: "l-input",
key: "gaugeController",
required: false,
hideFromInspector: true,
property: [
"configuration",
"gaugeController"
],
validate: ({ value }, callback) => {
if (value.isUseTelemetryLimits) {
return true;
}
const { min, max, limitLow, limitHigh } = value;
const valid = {
min: true,
max: true,
limitLow: true,
limitHigh: true
};
if (min === '') {
valid.min = false;
}
if (max === '') {
valid.max = false;
}
if (max < min) {
valid.min = false;
valid.max = false;
}
if (limitLow !== '') {
valid.limitLow = min <= limitLow && limitLow < max;
}
if (limitHigh !== '') {
valid.limitHigh = min < limitHigh && limitHigh <= max;
}
if (valid.limitLow && valid.limitHigh
&& limitLow !== '' && limitHigh !== ''
&& limitLow > limitHigh) {
valid.limitLow = false;
valid.limitHigh = false;
}
if (callback) {
callback(valid);
}
return valid.min && valid.max && valid.limitLow && valid.limitHigh;
}
}
]
});
};
function getGaugeFormController(openmct) {
return {
show(element, model, onChange) {
const rowComponent = new Vue({
el: element,
components: {
GaugeFormController
},
provide: {
openmct
},
data() {
return {
model,
onChange
};
},
template: `<GaugeFormController :model="model" @onChange="onChange"></GaugeFormController>`
});
return rowComponent;
}
};
}
}

View File

@ -0,0 +1,801 @@
/*****************************************************************************
* 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.
*****************************************************************************/
import {
createOpenMct,
resetApplicationState
} from 'utils/testing';
import { debounce } from 'lodash';
import Vue from 'vue';
let gaugeDomainObject = {
identifier: {
key: 'gauge',
namespace: 'test-namespace'
},
type: 'gauge',
composition: []
};
describe('Gauge plugin', () => {
let openmct;
let child;
let gaugeHolder;
beforeEach((done) => {
gaugeHolder = document.createElement('div');
gaugeHolder.style.display = 'block';
gaugeHolder.style.width = '1920px';
gaugeHolder.style.height = '1080px';
child = document.createElement('div');
gaugeHolder.appendChild(child);
openmct = createOpenMct();
openmct.on('start', done);
openmct.install(openmct.plugins.Gauge());
openmct.startHeadless();
});
afterEach(() => {
return resetApplicationState(openmct);
});
it('Plugin installed by default', () => {
const gaugueType = openmct.types.get('gauge');
expect(gaugueType).not.toBeNull();
expect(gaugueType.definition.name).toEqual('Gauge');
});
it('Gaugue plugin is creatable', () => {
const gaugueType = openmct.types.get('gauge');
expect(gaugueType.definition.creatable).toBeTrue();
});
it('Gaugue plugin is creatable', () => {
const gaugueType = openmct.types.get('gauge');
expect(gaugueType.definition.creatable).toBeTrue();
});
it('Gaugue form controller', () => {
const gaugeController = openmct.forms.getFormControl('gauge-controller');
expect(gaugeController).toBeDefined();
});
describe('Gaugue with Filled Dial', () => {
let gaugeViewProvider;
let gaugeView;
let gaugeViewObject;
let mutablegaugeObject;
let randomValue;
const minValue = -1;
const maxValue = 1;
beforeEach(() => {
randomValue = Math.random();
gaugeViewObject = {
...gaugeDomainObject,
configuration: {
gaugeController: {
gaugeType: 'dial-filled',
isDisplayMinMax: true,
isDisplayCurVal: true,
isUseTelemetryLimits: false,
limitLow: -0.9,
limitHigh: 0.9,
max: maxValue,
min: minValue,
precision: 2
}
},
composition: [
{
namespace: 'test-namespace',
key: 'test-object'
}
],
id: 'test-object',
name: 'gauge'
};
const testObjectProvider = jasmine.createSpyObj('testObjectProvider', [
'get',
'create',
'update',
'observe'
]);
openmct.editor = {};
openmct.editor.isEditing = () => false;
const applicableViews = openmct.objectViews.get(gaugeViewObject, [gaugeViewObject]);
gaugeViewProvider = applicableViews.find(viewProvider => viewProvider.key === 'gauge');
testObjectProvider.get.and.returnValue(Promise.resolve(gaugeViewObject));
testObjectProvider.create.and.returnValue(Promise.resolve(gaugeViewObject));
openmct.objects.addProvider('test-namespace', testObjectProvider);
testObjectProvider.observe.and.returnValue(() => {});
testObjectProvider.create.and.returnValue(Promise.resolve(true));
testObjectProvider.update.and.returnValue(Promise.resolve(true));
spyOn(openmct.telemetry, 'getMetadata').and.returnValue({
valuesForHints: () => {
return [
{
source: 'sin'
}
];
},
value: () => 1
});
spyOn(openmct.telemetry, 'getValueFormatter').and.returnValue({
parse: () => {
return 2000;
}
});
spyOn(openmct.telemetry, 'getFormatMap').and.returnValue({
sin: {
format: (datum) => {
return randomValue;
}
}
});
spyOn(openmct.telemetry, 'getLimits').and.returnValue({ limits: () => Promise.resolve() });
spyOn(openmct.telemetry, 'request').and.returnValue(Promise.resolve([randomValue]));
spyOn(openmct.time, 'bounds').and.returnValue({
start: 1000,
end: 5000
});
return openmct.objects.getMutable(gaugeViewObject.identifier).then((mutableObject) => {
mutablegaugeObject = mutableObject;
gaugeView = gaugeViewProvider.view(mutablegaugeObject);
gaugeView.show(child);
return Vue.nextTick();
});
});
afterEach(() => {
gaugeView.destroy();
return resetApplicationState(openmct);
});
it('provides gauge view', () => {
expect(gaugeViewProvider).toBeDefined();
});
it('renders gauge element', () => {
const gaugeElement = gaugeHolder.querySelectorAll('.js-gauge-wrapper');
expect(gaugeElement.length).toBe(1);
});
it('renders major elements', () => {
const wrapperElement = gaugeHolder.querySelector('.js-gauge-wrapper');
const rangeElement = gaugeHolder.querySelector('.js-gauge-dial-range');
const valueElement = gaugeHolder.querySelector('.js-dial-current-value');
const hasMajorElements = Boolean(wrapperElement && rangeElement && valueElement);
expect(hasMajorElements).toBe(true);
});
it('renders correct min max values', () => {
expect(gaugeHolder.querySelector('.js-gauge-dial-range').textContent).toEqual(`${minValue} ${maxValue}`);
});
it('renders correct current value', (done) => {
function WatchUpdateValue() {
const textElement = gaugeHolder.querySelector('.js-dial-current-value');
expect(Number(textElement.textContent).toFixed(gaugeViewObject.configuration.gaugeController.precision)).toBe(randomValue.toFixed(gaugeViewObject.configuration.gaugeController.precision));
done();
}
const debouncedWatchUpdate = debounce(WatchUpdateValue, 200);
Vue.nextTick(debouncedWatchUpdate);
});
});
describe('Gaugue with Needle Dial', () => {
let gaugeViewProvider;
let gaugeView;
let gaugeViewObject;
let mutablegaugeObject;
let randomValue;
const minValue = -1;
const maxValue = 1;
beforeEach(() => {
randomValue = Math.random();
gaugeViewObject = {
...gaugeDomainObject,
configuration: {
gaugeController: {
gaugeType: 'dial-needle',
isDisplayMinMax: true,
isDisplayCurVal: true,
isUseTelemetryLimits: false,
limitLow: -0.9,
limitHigh: 0.9,
max: maxValue,
min: minValue,
precision: 2
}
},
composition: [
{
namespace: 'test-namespace',
key: 'test-object'
}
],
id: 'test-object',
name: 'gauge'
};
const testObjectProvider = jasmine.createSpyObj('testObjectProvider', [
'get',
'create',
'update',
'observe'
]);
openmct.editor = {};
openmct.editor.isEditing = () => false;
const applicableViews = openmct.objectViews.get(gaugeViewObject, [gaugeViewObject]);
gaugeViewProvider = applicableViews.find(viewProvider => viewProvider.key === 'gauge');
testObjectProvider.get.and.returnValue(Promise.resolve(gaugeViewObject));
testObjectProvider.create.and.returnValue(Promise.resolve(gaugeViewObject));
openmct.objects.addProvider('test-namespace', testObjectProvider);
testObjectProvider.observe.and.returnValue(() => {});
testObjectProvider.create.and.returnValue(Promise.resolve(true));
testObjectProvider.update.and.returnValue(Promise.resolve(true));
spyOn(openmct.telemetry, 'getMetadata').and.returnValue({
valuesForHints: () => {
return [
{
source: 'sin'
}
];
},
value: () => 1
});
spyOn(openmct.telemetry, 'getValueFormatter').and.returnValue({
parse: () => {
return 2000;
}
});
spyOn(openmct.telemetry, 'getFormatMap').and.returnValue({
sin: {
format: (datum) => {
return randomValue;
}
}
});
spyOn(openmct.telemetry, 'getLimits').and.returnValue({ limits: () => Promise.resolve() });
spyOn(openmct.telemetry, 'request').and.returnValue(Promise.resolve([randomValue]));
spyOn(openmct.time, 'bounds').and.returnValue({
start: 1000,
end: 5000
});
return openmct.objects.getMutable(gaugeViewObject.identifier).then((mutableObject) => {
mutablegaugeObject = mutableObject;
gaugeView = gaugeViewProvider.view(mutablegaugeObject);
gaugeView.show(child);
return Vue.nextTick();
});
});
afterEach(() => {
gaugeView.destroy();
return resetApplicationState(openmct);
});
it('provides gauge view', () => {
expect(gaugeViewProvider).toBeDefined();
});
it('renders gauge element', () => {
const gaugeElement = gaugeHolder.querySelectorAll('.js-gauge-wrapper');
expect(gaugeElement.length).toBe(1);
});
it('renders major elements', () => {
const wrapperElement = gaugeHolder.querySelector('.js-gauge-wrapper');
const rangeElement = gaugeHolder.querySelector('.js-gauge-dial-range');
const valueElement = gaugeHolder.querySelector('.js-dial-current-value');
const hasMajorElements = Boolean(wrapperElement && rangeElement && valueElement);
expect(hasMajorElements).toBe(true);
});
it('renders correct min max values', () => {
expect(gaugeHolder.querySelector('.js-gauge-dial-range').textContent).toEqual(`${minValue} ${maxValue}`);
});
it('renders correct current value', (done) => {
function WatchUpdateValue() {
const textElement = gaugeHolder.querySelector('.js-dial-current-value');
expect(Number(textElement.textContent).toFixed(gaugeViewObject.configuration.gaugeController.precision)).toBe(randomValue.toFixed(gaugeViewObject.configuration.gaugeController.precision));
done();
}
const debouncedWatchUpdate = debounce(WatchUpdateValue, 200);
Vue.nextTick(debouncedWatchUpdate);
});
});
describe('Gaugue with Vertical Meter', () => {
let gaugeViewProvider;
let gaugeView;
let gaugeViewObject;
let mutablegaugeObject;
let randomValue;
const minValue = -1;
const maxValue = 1;
beforeEach(() => {
randomValue = Math.random();
gaugeViewObject = {
...gaugeDomainObject,
configuration: {
gaugeController: {
gaugeType: 'meter-vertical',
isDisplayMinMax: true,
isDisplayCurVal: true,
isUseTelemetryLimits: false,
limitLow: -0.9,
limitHigh: 0.9,
max: maxValue,
min: minValue,
precision: 2
}
},
composition: [
{
namespace: 'test-namespace',
key: 'test-object'
}
],
id: 'test-object',
name: 'gauge'
};
const testObjectProvider = jasmine.createSpyObj('testObjectProvider', [
'get',
'create',
'update',
'observe'
]);
openmct.editor = {};
openmct.editor.isEditing = () => false;
const applicableViews = openmct.objectViews.get(gaugeViewObject, [gaugeViewObject]);
gaugeViewProvider = applicableViews.find(viewProvider => viewProvider.key === 'gauge');
testObjectProvider.get.and.returnValue(Promise.resolve(gaugeViewObject));
testObjectProvider.create.and.returnValue(Promise.resolve(gaugeViewObject));
openmct.objects.addProvider('test-namespace', testObjectProvider);
testObjectProvider.observe.and.returnValue(() => {});
testObjectProvider.create.and.returnValue(Promise.resolve(true));
testObjectProvider.update.and.returnValue(Promise.resolve(true));
spyOn(openmct.telemetry, 'getMetadata').and.returnValue({
valuesForHints: () => {
return [
{
source: 'sin'
}
];
},
value: () => 1
});
spyOn(openmct.telemetry, 'getValueFormatter').and.returnValue({
parse: () => {
return 2000;
}
});
spyOn(openmct.telemetry, 'getFormatMap').and.returnValue({
sin: {
format: (datum) => {
return randomValue;
}
}
});
spyOn(openmct.telemetry, 'getLimits').and.returnValue({ limits: () => Promise.resolve() });
spyOn(openmct.telemetry, 'request').and.returnValue(Promise.resolve([randomValue]));
spyOn(openmct.time, 'bounds').and.returnValue({
start: 1000,
end: 5000
});
return openmct.objects.getMutable(gaugeViewObject.identifier).then((mutableObject) => {
mutablegaugeObject = mutableObject;
gaugeView = gaugeViewProvider.view(mutablegaugeObject);
gaugeView.show(child);
return Vue.nextTick();
});
});
afterEach(() => {
gaugeView.destroy();
return resetApplicationState(openmct);
});
it('provides gauge view', () => {
expect(gaugeViewProvider).toBeDefined();
});
it('renders gauge element', () => {
const gaugeElement = gaugeHolder.querySelectorAll('.js-gauge-wrapper');
expect(gaugeElement.length).toBe(1);
});
it('renders major elements', () => {
const wrapperElement = gaugeHolder.querySelector('.js-gauge-wrapper');
const rangeElement = gaugeHolder.querySelector('.js-gauge-meter-range');
const valueElement = gaugeHolder.querySelector('.js-meter-current-value');
const hasMajorElements = Boolean(wrapperElement && rangeElement && valueElement);
expect(hasMajorElements).toBe(true);
});
it('renders correct min max values', () => {
expect(gaugeHolder.querySelector('.js-gauge-meter-range').textContent).toEqual(`${maxValue} ${minValue}`);
});
it('renders correct current value', (done) => {
function WatchUpdateValue() {
const textElement = gaugeHolder.querySelector('.js-meter-current-value');
expect(Number(textElement.textContent).toFixed(gaugeViewObject.configuration.gaugeController.precision)).toBe(randomValue.toFixed(gaugeViewObject.configuration.gaugeController.precision));
done();
}
const debouncedWatchUpdate = debounce(WatchUpdateValue, 200);
Vue.nextTick(debouncedWatchUpdate);
});
});
describe('Gaugue with Vertical Meter Inverted', () => {
let gaugeViewProvider;
let gaugeView;
let gaugeViewObject;
let mutablegaugeObject;
beforeEach(() => {
gaugeViewObject = {
...gaugeDomainObject,
configuration: {
gaugeController: {
gaugeType: 'meter-vertical',
isDisplayMinMax: true,
isDisplayCurVal: true,
isUseTelemetryLimits: false,
limitLow: -0.9,
limitHigh: 0.9,
max: 1,
min: -1,
precision: 2
}
},
id: 'test-object',
name: 'gauge'
};
const testObjectProvider = jasmine.createSpyObj('testObjectProvider', [
'get',
'create',
'update',
'observe'
]);
openmct.editor = {};
openmct.editor.isEditing = () => false;
const applicableViews = openmct.objectViews.get(gaugeViewObject, [gaugeViewObject]);
gaugeViewProvider = applicableViews.find(viewProvider => viewProvider.key === 'gauge');
testObjectProvider.get.and.returnValue(Promise.resolve(gaugeViewObject));
testObjectProvider.create.and.returnValue(Promise.resolve(gaugeViewObject));
openmct.objects.addProvider('test-namespace', testObjectProvider);
testObjectProvider.observe.and.returnValue(() => {});
testObjectProvider.create.and.returnValue(Promise.resolve(true));
testObjectProvider.update.and.returnValue(Promise.resolve(true));
return openmct.objects.getMutable(gaugeViewObject.identifier).then((mutableObject) => {
mutablegaugeObject = mutableObject;
gaugeView = gaugeViewProvider.view(mutablegaugeObject);
gaugeView.show(child);
return Vue.nextTick();
});
});
afterEach(() => {
gaugeView.destroy();
return resetApplicationState(openmct);
});
it('provides gauge view', () => {
expect(gaugeViewProvider).toBeDefined();
});
it('renders gauge element', () => {
const gaugeElement = gaugeHolder.querySelectorAll('.js-gauge-wrapper');
expect(gaugeElement.length).toBe(1);
});
it('renders major elements', () => {
const wrapperElement = gaugeHolder.querySelector('.js-gauge-wrapper');
const rangeElement = gaugeHolder.querySelector('.js-gauge-meter-range');
const valueElement = gaugeHolder.querySelector('.js-meter-current-value');
const hasMajorElements = Boolean(wrapperElement && rangeElement && valueElement);
expect(hasMajorElements).toBe(true);
});
});
describe('Gaugue with Horizontal Meter', () => {
let gaugeViewProvider;
let gaugeView;
let gaugeViewObject;
let mutablegaugeObject;
beforeEach(() => {
gaugeViewObject = {
...gaugeDomainObject,
configuration: {
gaugeController: {
gaugeType: 'meter-vertical',
isDisplayMinMax: true,
isDisplayCurVal: true,
isUseTelemetryLimits: false,
limitLow: -0.9,
limitHigh: 0.9,
max: 1,
min: -1,
precision: 2
}
},
id: 'test-object',
name: 'gauge'
};
const testObjectProvider = jasmine.createSpyObj('testObjectProvider', [
'get',
'create',
'update',
'observe'
]);
openmct.editor = {};
openmct.editor.isEditing = () => false;
const applicableViews = openmct.objectViews.get(gaugeViewObject, [gaugeViewObject]);
gaugeViewProvider = applicableViews.find(viewProvider => viewProvider.key === 'gauge');
testObjectProvider.get.and.returnValue(Promise.resolve(gaugeViewObject));
testObjectProvider.create.and.returnValue(Promise.resolve(gaugeViewObject));
openmct.objects.addProvider('test-namespace', testObjectProvider);
testObjectProvider.observe.and.returnValue(() => {});
testObjectProvider.create.and.returnValue(Promise.resolve(true));
testObjectProvider.update.and.returnValue(Promise.resolve(true));
return openmct.objects.getMutable(gaugeViewObject.identifier).then((mutableObject) => {
mutablegaugeObject = mutableObject;
gaugeView = gaugeViewProvider.view(mutablegaugeObject);
gaugeView.show(child);
return Vue.nextTick();
});
});
afterEach(() => {
gaugeView.destroy();
return resetApplicationState(openmct);
});
it('provides gauge view', () => {
expect(gaugeViewProvider).toBeDefined();
});
it('renders gauge element', () => {
const gaugeElement = gaugeHolder.querySelectorAll('.js-gauge-wrapper');
expect(gaugeElement.length).toBe(1);
});
it('renders major elements', () => {
const wrapperElement = gaugeHolder.querySelector('.js-gauge-wrapper');
const rangeElement = gaugeHolder.querySelector('.c-gauge__range');
const curveElement = gaugeHolder.querySelector('.c-meter');
const hasMajorElements = Boolean(wrapperElement && rangeElement && curveElement);
expect(hasMajorElements).toBe(true);
});
});
describe('Gaugue with Filled Dial with Use Telemetry Limits', () => {
let gaugeViewProvider;
let gaugeView;
let gaugeViewObject;
let mutablegaugeObject;
let randomValue;
beforeEach(() => {
randomValue = Math.random();
gaugeViewObject = {
...gaugeDomainObject,
configuration: {
gaugeController: {
gaugeType: 'dial-filled',
isDisplayMinMax: true,
isDisplayCurVal: true,
isUseTelemetryLimits: true,
limitLow: 10,
limitHigh: 90,
max: 100,
min: 0,
precision: 2
}
},
composition: [
{
namespace: 'test-namespace',
key: 'test-object'
}
],
id: 'test-object',
name: 'gauge'
};
const testObjectProvider = jasmine.createSpyObj('testObjectProvider', [
'get',
'create',
'update',
'observe'
]);
openmct.editor = {};
openmct.editor.isEditing = () => false;
const applicableViews = openmct.objectViews.get(gaugeViewObject, [gaugeViewObject]);
gaugeViewProvider = applicableViews.find(viewProvider => viewProvider.key === 'gauge');
testObjectProvider.get.and.returnValue(Promise.resolve(gaugeViewObject));
testObjectProvider.create.and.returnValue(Promise.resolve(gaugeViewObject));
openmct.objects.addProvider('test-namespace', testObjectProvider);
testObjectProvider.observe.and.returnValue(() => {});
testObjectProvider.create.and.returnValue(Promise.resolve(true));
testObjectProvider.update.and.returnValue(Promise.resolve(true));
spyOn(openmct.telemetry, 'getMetadata').and.returnValue({
valuesForHints: () => {
return [
{
source: 'sin'
}
];
},
value: () => 1
});
spyOn(openmct.telemetry, 'getValueFormatter').and.returnValue({
parse: () => {
return 2000;
}
});
spyOn(openmct.telemetry, 'getFormatMap').and.returnValue({
sin: {
format: (datum) => {
return randomValue;
}
}
});
spyOn(openmct.telemetry, 'getLimits').and.returnValue(
{
limits: () => Promise.resolve({
CRITICAL: {
high: 0.99,
low: -0.99
}
})
}
);
spyOn(openmct.telemetry, 'request').and.returnValue(Promise.resolve([randomValue]));
spyOn(openmct.time, 'bounds').and.returnValue({
start: 1000,
end: 5000
});
return openmct.objects.getMutable(gaugeViewObject.identifier).then((mutableObject) => {
mutablegaugeObject = mutableObject;
gaugeView = gaugeViewProvider.view(mutablegaugeObject);
gaugeView.show(child);
return Vue.nextTick();
});
});
afterEach(() => {
gaugeView.destroy();
return resetApplicationState(openmct);
});
it('provides gauge view', () => {
expect(gaugeViewProvider).toBeDefined();
});
it('renders gauge element', () => {
const gaugeElement = gaugeHolder.querySelectorAll('.js-gauge-wrapper');
expect(gaugeElement.length).toBe(1);
});
it('renders major elements', () => {
const wrapperElement = gaugeHolder.querySelector('.js-gauge-wrapper');
const rangeElement = gaugeHolder.querySelector('.js-gauge-dial-range');
const valueElement = gaugeHolder.querySelector('.js-dial-current-value');
const hasMajorElements = Boolean(wrapperElement && rangeElement && valueElement);
expect(hasMajorElements).toBe(true);
});
it('renders correct min max values', () => {
expect(gaugeHolder.querySelector('.js-gauge-dial-range').textContent).toEqual(`${gaugeViewObject.configuration.gaugeController.min} ${gaugeViewObject.configuration.gaugeController.max}`);
});
it('renders correct current value', (done) => {
function WatchUpdateValue() {
const textElement = gaugeHolder.querySelector('.js-dial-current-value');
expect(Number(textElement.textContent).toFixed(gaugeViewObject.configuration.gaugeController.precision)).toBe(randomValue.toFixed(gaugeViewObject.configuration.gaugeController.precision));
done();
}
const debouncedWatchUpdate = debounce(WatchUpdateValue, 200);
Vue.nextTick(debouncedWatchUpdate);
});
});
});

View File

@ -0,0 +1,67 @@
/*****************************************************************************
* 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.
*****************************************************************************/
import GaugeComponent from './components/Gauge.vue';
import Vue from 'vue';
export default function GaugeViewProvider(openmct) {
return {
key: 'gauge',
name: 'Gauge',
cssClass: 'icon-gauge',
canView: function (domainObject) {
return domainObject.type === 'gauge';
},
canEdit: function (domainObject) {
if (domainObject.type === 'gauge') {
return true;
}
},
view: function (domainObject) {
let component;
return {
show: function (element) {
component = new Vue({
el: element,
components: {
GaugeComponent
},
provide: {
openmct,
domainObject,
composition: openmct.composition.get(domainObject)
},
template: '<gauge-component></gauge-component>'
});
},
destroy: function (element) {
component.$destroy();
component = undefined;
}
};
},
priority: function () {
return 1;
}
};
}

View File

@ -0,0 +1,566 @@
/*****************************************************************************
* 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-gauge__wrapper js-gauge-wrapper"
:class="`c-gauge--${gaugeType}`"
>
<template v-if="typeDial">
<svg
width="0"
height="0"
class="c-dial__clip-paths"
>
<defs>
<clipPath
id="gaugeBgMask"
clipPathUnits="objectBoundingBox"
>
<path d="M0.853553 0.853553C0.944036 0.763071 1 0.638071 1 0.5C1 0.223858 0.776142 0 0.5 0C0.223858 0 0 0.223858 0 0.5C0 0.638071 0.0559644 0.763071 0.146447 0.853553L0.285934 0.714066C0.23115 0.659281 0.197266 0.583598 0.197266 0.5C0.197266 0.332804 0.332804 0.197266 0.5 0.197266C0.667196 0.197266 0.802734 0.332804 0.802734 0.5C0.802734 0.583598 0.76885 0.659281 0.714066 0.714066L0.853553 0.853553Z" />
</clipPath>
<clipPath
id="gaugeValueMask"
clipPathUnits="objectBoundingBox"
>
<path d="M0.18926 0.81074C0.109735 0.731215 0.0605469 0.621351 0.0605469 0.5C0.0605469 0.257298 0.257298 0.0605469 0.5 0.0605469C0.742702 0.0605469 0.939453 0.257298 0.939453 0.5C0.939453 0.621351 0.890265 0.731215 0.81074 0.81074L0.714066 0.714066C0.76885 0.659281 0.802734 0.583599 0.802734 0.5C0.802734 0.332804 0.667196 0.197266 0.5 0.197266C0.332804 0.197266 0.197266 0.332804 0.197266 0.5C0.197266 0.583599 0.23115 0.659281 0.285934 0.714066L0.18926 0.81074Z" />
</clipPath>
</defs>
</svg>
<svg
class="c-dial__range c-gauge__range js-gauge-dial-range"
viewBox="0 0 512 512"
>
<text
v-if="displayMinMax"
font-size="35"
transform="translate(105 455) rotate(-45)"
>{{ rangeLow }}</text>
<text
v-if="displayMinMax"
font-size="35"
transform="translate(407 455) rotate(45)"
text-anchor="end"
>{{ rangeHigh }}</text>
</svg>
<svg
class="c-dial__current-value-text-wrapper"
viewBox="0 0 512 512"
>
<svg
v-if="displayCurVal"
class="c-dial__current-value-text-sizer"
:viewBox="curValViewBox"
>
<text
class="c-dial__current-value-text js-dial-current-value"
lengthAdjust="spacing"
text-anchor="middle"
style="transform: translate(50%, 70%)"
>{{ curVal }}</text>
</svg>
</svg>
<svg
class="c-dial__bg"
viewBox="0 0 10 10"
>
<g
v-if="limitLow !== null && dialLowLimitDeg < getLimitDegree('low', 'max')"
class="c-dial__limit-low"
:style="`transform: rotate(${dialLowLimitDeg}deg)`"
>
<rect
v-if="dialLowLimitDeg >= getLimitDegree('low', 'q1')"
class="c-dial__low-limit__low"
x="5"
y="5"
width="5"
height="5"
/>
<rect
v-if="dialLowLimitDeg >= getLimitDegree('low', 'q2')"
class="c-dial__low-limit__mid"
x="5"
y="0"
width="5"
height="5"
/>
<rect
v-if="dialLowLimitDeg >= getLimitDegree('low', 'q3')"
class="c-dial__low-limit__high"
x="0"
y="0"
width="5"
height="5"
/>
</g>
<g
v-if="limitHigh !== null && dialHighLimitDeg < getLimitDegree('high', 'max')"
class="c-dial__limit-high"
:style="`transform: rotate(${dialHighLimitDeg}deg)`"
>
<rect
v-if="dialHighLimitDeg <= getLimitDegree('high', 'max')"
class="c-dial__high-limit__low"
x="0"
y="5"
width="5"
height="5"
/>
<rect
v-if="dialHighLimitDeg <= getLimitDegree('high', 'q2')"
class="c-dial__high-limit__mid"
x="0"
y="0"
width="5"
height="5"
/>
<rect
v-if="dialHighLimitDeg <= getLimitDegree('high', 'q3')"
class="c-dial__high-limit__high"
x="5"
y="0"
width="5"
height="5"
/>
</g>
</svg>
<svg
v-if="typeFilledDial"
class="c-dial__filled-value-wrapper"
viewBox="0 0 10 10"
>
<g
class="c-dial__filled-value"
:style="`transform: rotate(${degValueFilledDial}deg)`"
>
<rect
v-if="degValue >= getLimitDegree('low', 'q1')"
class="c-dial__filled-value__low"
x="5"
y="5"
width="5"
height="5"
/>
<rect
v-if="degValue >= getLimitDegree('low', 'q2')"
class="c-dial__filled-value__mid"
x="5"
y="0"
width="5"
height="5"
/>
<rect
v-if="degValue >= getLimitDegree('low', 'q3')"
class="c-dial__filled-value__high"
x="0"
y="0"
width="5"
height="5"
/>
</g>
</svg>
<svg
v-if="valueInBounds && typeNeedleDial"
class="c-dial__needle-value-wrapper"
viewBox="0 0 10 10"
>
<g
class="c-dial__needle-value"
:style="`transform: rotate(${degValue}deg)`"
>
<path d="M4.90234 9.39453L5.09766 9.39453L5.30146 8.20874C6.93993 8.05674 8.22265 6.67817 8.22266 5C8.22266 3.22018 6.77982 1.77734 5 1.77734C3.22018 1.77734 1.77734 3.22018 1.77734 5C1.77734 6.67817 3.06007 8.05674 4.69854 8.20874L4.90234 9.39453Z" />
</g>
</svg>
</template>
<template v-if="typeMeter">
<div class="c-meter">
<div
v-if="displayMinMax"
class="c-gauge__range c-meter__range js-gauge-meter-range"
>
<div class="c-meter__range__high">{{ rangeHigh }}</div>
<div class="c-meter__range__low">{{ rangeLow }}</div>
</div>
<div class="c-meter__bg">
<template v-if="typeMeterVertical">
<div
class="c-meter__value"
:style="`transform: translateY(${meterValueToPerc}%)`"
></div>
<div
v-if="limitHigh !== null && meterHighLimitPerc > 0"
class="c-meter__limit-high"
:style="`height: ${meterHighLimitPerc}%`"
></div>
<div
v-if="limitLow !== null && meterLowLimitPerc > 0"
class="c-meter__limit-low"
:style="`height: ${meterLowLimitPerc}%`"
></div>
</template>
<template v-if="typeMeterHorizontal">
<div
class="c-meter__value"
:style="`transform: translateX(${meterValueToPerc * -1}%)`"
></div>
<div
v-if="limitHigh !== null && meterHighLimitPerc > 0"
class="c-meter__limit-high"
:style="`width: ${meterHighLimitPerc}%`"
></div>
<div
v-if="limitLow !== null && meterLowLimitPerc > 0"
class="c-meter__limit-low"
:style="`width: ${meterLowLimitPerc}%`"
></div>
</template>
<svg
class="c-meter__current-value-text-wrapper"
viewBox="0 0 512 512"
>
<svg
v-if="displayCurVal"
class="c-meter__current-value-text-sizer"
:viewBox="curValViewBox"
preserveAspectRatio="xMidYMid meet"
>
<text
class="c-dial__current-value-text js-meter-current-value"
lengthAdjust="spacing"
text-anchor="middle"
style="transform: translate(50%, 70%)"
>{{ curVal }}</text>
</svg>
</svg>
</div>
</div>
</template>
</div>
</template>
<script>
import { DIAL_VALUE_DEG_OFFSET, getLimitDegree } from '../gauge-limit-util';
const LIMIT_PADDING_IN_PERCENT = 10;
export default {
name: 'Gauge',
inject: ['openmct', 'domainObject', 'composition'],
data() {
let gaugeController = this.domainObject.configuration.gaugeController;
return {
curVal: 0,
digits: 3,
precision: gaugeController.precision,
displayMinMax: gaugeController.isDisplayMinMax,
displayCurVal: gaugeController.isDisplayCurVal,
limitHigh: gaugeController.limitHigh,
limitLow: gaugeController.limitLow,
rangeHigh: gaugeController.max,
rangeLow: gaugeController.min,
gaugeType: gaugeController.gaugeType,
activeTimeSystem: this.openmct.time.timeSystem()
};
},
computed: {
degValue() {
return this.percentToDegrees(this.valToPercent(this.curVal));
},
degValueFilledDial() {
if (this.curVal > this.rangeHigh) {
return this.percentToDegrees(100);
}
return this.percentToDegrees(this.valToPercent(this.curVal));
},
dialHighLimitDeg() {
return this.percentToDegrees(this.valToPercent(this.limitHigh));
},
dialLowLimitDeg() {
return this.percentToDegrees(this.valToPercent(this.limitLow));
},
curValViewBox() {
const DIGITS_RATIO = 10;
const VIEWBOX_STR = '0 0 X 15';
return VIEWBOX_STR.replace('X', this.digits * DIGITS_RATIO);
},
typeDial() {
return this.matchGaugeType('dial');
},
typeFilledDial() {
return this.matchGaugeType('dial-filled');
},
typeNeedleDial() {
return this.matchGaugeType('dial-needle');
},
typeMeter() {
return this.matchGaugeType('meter');
},
typeMeterHorizontal() {
return this.matchGaugeType('horizontal');
},
typeMeterVertical() {
return this.matchGaugeType('vertical');
},
typeMeterInverted() {
return this.matchGaugeType('inverted');
},
meterValueToPerc() {
const meterDirection = (this.typeMeterInverted) ? -1 : 1;
if (this.curVal <= this.rangeLow) {
return meterDirection * 100;
}
if (this.curVal >= this.rangeHigh) {
return 0;
}
return this.valToPercentMeter(this.curVal) * meterDirection;
},
meterHighLimitPerc() {
return this.valToPercentMeter(this.limitHigh);
},
meterLowLimitPerc() {
return 100 - this.valToPercentMeter(this.limitLow);
},
valueInBounds() {
return (this.curVal >= this.rangeLow && this.curVal <= this.rangeHigh);
},
timeFormatter() {
const timeSystem = this.activeTimeSystem;
const metadataValue = this.metadata.value(timeSystem.key) || { format: timeSystem.key };
return this.openmct.telemetry.getValueFormatter(metadataValue);
}
},
watch: {
curVal(newCurValue) {
if (this.digits < newCurValue.toString().length) {
this.digits = newCurValue.toString().length;
}
}
},
mounted() {
this.composition.on('add', this.addedToComposition);
this.composition.on('remove', this.removeTelemetryObject);
this.composition.load();
this.openmct.time.on('bounds', this.refreshData);
this.openmct.time.on('timeSystem', this.setTimeSystem);
},
destroyed() {
this.composition.off('add', this.addedToComposition);
this.composition.off('remove', this.removeTelemetryObject);
if (this.unsubscribe) {
this.unsubscribe();
}
this.openmct.time.off('bounds', this.refreshData);
this.openmct.time.off('timeSystem', this.setTimeSystem);
},
methods: {
getLimitDegree: getLimitDegree,
addTelemetryObjectAndSubscribe(domainObject) {
this.telemetryObject = domainObject;
this.request();
this.subscribe();
},
addedToComposition(domainObject) {
if (this.telemetryObject) {
this.confirmRemoval(domainObject);
} else {
this.addTelemetryObjectAndSubscribe(domainObject);
}
},
confirmRemoval(domainObject) {
const dialog = this.openmct.overlays.dialog({
iconClass: 'alert',
message: 'This action will replace the current telemetry source. Do you want to continue?',
buttons: [
{
label: 'Ok',
emphasis: true,
callback: () => {
this.removeFromComposition();
this.removeTelemetryObject();
this.addTelemetryObjectAndSubscribe(domainObject);
dialog.dismiss();
}
},
{
label: 'Cancel',
callback: () => {
this.removeFromComposition(domainObject);
dialog.dismiss();
}
}
]
});
},
matchGaugeType(str) {
return this.gaugeType.indexOf(str) !== -1;
},
percentToDegrees(vPercent) {
return this.round(((vPercent / 100) * 270) + DIAL_VALUE_DEG_OFFSET, 2);
},
removeFromComposition(telemetryObject = this.telemetryObject) {
let composition = this.domainObject.composition.filter(id =>
!this.openmct.objects.areIdsEqual(id, telemetryObject.identifier)
);
this.openmct.objects.mutate(this.domainObject, 'composition', composition);
},
refreshData(bounds, isTick) {
if (!isTick) {
this.request();
}
},
removeTelemetryObject() {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
this.metadata = null;
this.formats = null;
this.valueKey = null;
this.limitHigh = null;
this.limitLow = null;
this.rangeHigh = null;
this.rangeLow = null;
},
request(domainObject = this.telemetryObject) {
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
this.formats = this.openmct.telemetry.getFormatMap(this.metadata);
const LimitEvaluator = this.openmct.telemetry.getLimits(domainObject);
LimitEvaluator.limits().then(this.updateLimits);
this.valueKey = this
.metadata
.valuesForHints(['range'])[0].source;
this.openmct
.telemetry
.request(domainObject, { strategy: 'latest' })
.then(values => {
const length = values.length;
this.updateValue(values[length - 1]);
});
},
round(val, decimals = this.precision) {
let precision = Math.pow(10, decimals);
return Math.round(val * precision) / precision;
},
setTimeSystem(timeSystem) {
this.activeTimeSystem = timeSystem;
},
subscribe(domainObject = this.telemetryObject) {
this.unsubscribe = this.openmct
.telemetry
.subscribe(domainObject, this.updateValue.bind(this));
},
updateLimits(telemetryLimit) {
if (!telemetryLimit || !this.domainObject.configuration.gaugeController.isUseTelemetryLimits) {
return;
}
let limits = {
high: 0,
low: 0
};
if (telemetryLimit.CRITICAL) {
limits = telemetryLimit.CRITICAL;
} else if (telemetryLimit.DISTRESS) {
limits = telemetryLimit.DISTRESS;
} else if (telemetryLimit.SEVERE) {
limits = telemetryLimit.SEVERE;
} else if (telemetryLimit.WARNING) {
limits = telemetryLimit.WARNING;
} else if (telemetryLimit.WATCH) {
limits = telemetryLimit.WATCH;
} else {
this.openmct.notifications.error('No limits definition for given telemetry');
}
this.limitHigh = this.round(limits.high[this.valueKey]);
this.limitLow = this.round(limits.low[this.valueKey]);
this.rangeHigh = this.round(this.limitHigh + this.limitHigh * LIMIT_PADDING_IN_PERCENT / 100);
this.rangeLow = this.round(this.limitLow - Math.abs(this.limitLow * LIMIT_PADDING_IN_PERCENT / 100));
},
updateValue(datum) {
this.datum = datum;
if (this.isRendering) {
return;
}
const { start, end } = this.openmct.time.bounds();
const parsedValue = this.timeFormatter.parse(this.datum);
const beforeStartOfBounds = parsedValue < start;
const afterEndOfBounds = parsedValue > end;
if (afterEndOfBounds || beforeStartOfBounds) {
return;
}
this.isRendering = true;
requestAnimationFrame(() => {
this.isRendering = false;
this.curVal = this.round(this.formats[this.valueKey].format(this.datum), this.precision);
});
},
valToPercent(vValue) {
// Used by dial
if (vValue >= this.rangeHigh && this.typeFilledDial) {
// For filled dial, clip values over the high range to prevent over-rotation
return 100;
}
return ((vValue - this.rangeLow) / (this.rangeHigh - this.rangeLow)) * 100;
},
valToPercentMeter(vValue) {
return this.round((this.rangeHigh - vValue) / (this.rangeHigh - this.rangeLow) * 100, 2);
}
}
};
</script>

View File

@ -0,0 +1,171 @@
/*****************************************************************************
* 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>
<span class="form-control">
<span
class="field control"
:class="model.cssClass"
>
<ToggleSwitch
:id="'gaugeToggle'"
:checked="isUseTelemetryLimits"
label="Use telemetry limits for minimum and maximum ranges"
@change="toggleUseTelemetryLimits"
/>
<div
v-if="!isUseTelemetryLimits"
class="c-form--sub-grid"
>
<div class="c-form__row">
<span class="req-indicator req">
</span>
<label>Range minimum value</label>
<input
ref="min"
v-model.number="min"
data-field-name="min"
type="number"
@input="onChange"
>
</div>
<div class="c-form__row">
<span class="req-indicator">
</span>
<label>Range low limit</label>
<input
ref="limitLow"
v-model.number="limitLow"
data-field-name="limitLow"
type="number"
@input="onChange"
>
</div>
<div class="c-form__row">
<span class="req-indicator req">
</span>
<label>Range maximum value</label>
<input
ref="max"
v-model.number="max"
data-field-name="max"
type="number"
@input="onChange"
>
</div>
<div class="c-form__row">
<span class="req-indicator">
</span>
<label>Range high limit</label>
<input
ref="limitHigh"
v-model.number="limitHigh"
data-field-name="limitHigh"
type="number"
@input="onChange"
>
</div>
</div>
</span>
</span>
</template>
<script>
import ToggleSwitch from '@/ui/components/ToggleSwitch.vue';
export default {
components: {
ToggleSwitch
},
props: {
model: {
type: Object,
required: true
}
},
data() {
return {
isUseTelemetryLimits: this.model.value.isUseTelemetryLimits,
isDisplayMinMax: this.model.value.isDisplayMinMax,
isDisplayCurVal: this.model.value.isDisplayCurVal,
limitHigh: this.model.value.limitHigh,
limitLow: this.model.value.limitLow,
max: this.model.value.max,
min: this.model.value.min
};
},
methods: {
onChange(event) {
const data = {
model: this.model,
value: {
gaugeType: this.model.value.gaugeType,
isDisplayMinMax: this.isDisplayMinMax,
isDisplayCurVal: this.isDisplayCurVal,
isUseTelemetryLimits: this.isUseTelemetryLimits,
limitLow: this.limitLow,
limitHigh: this.limitHigh,
max: this.max,
min: this.min,
precision: this.model.value.precision
}
};
if (event) {
const target = event.target;
const targetIndicator = target.parentElement.querySelector('.req-indicator');
if (targetIndicator.classList.contains('req')) {
targetIndicator.classList.add('visited');
}
this.model.validate(data, (valid) => {
Object.entries(valid).forEach(([key, isValid]) => {
const element = this.$refs[key];
const reqIndicatorElement = element.parentElement.querySelector('.req-indicator');
reqIndicatorElement.classList.toggle('invalid', !isValid);
if (reqIndicatorElement.classList.contains('req') && (!isValid || reqIndicatorElement.classList.contains('visited'))) {
reqIndicatorElement.classList.toggle('valid', isValid);
}
});
});
}
this.$emit('onChange', data);
},
toggleUseTelemetryLimits() {
this.isUseTelemetryLimits = !this.isUseTelemetryLimits;
this.onChange();
},
toggleMinMax() {
this.isDisplayMinMax = !this.isDisplayMinMax;
this.onChange();
}
}
};
</script>

View File

@ -0,0 +1,39 @@
const GAUGE_LIMITS = {
q1: 0,
q2: 90,
q3: 180,
q4: 270
};
export const DIAL_VALUE_DEG_OFFSET = 45;
// type: low, high
// quadrant: low, mid, high, max
export function getLimitDegree(type, quadrant) {
if (quadrant === 'max') {
return GAUGE_LIMITS.q4 + DIAL_VALUE_DEG_OFFSET;
}
return type === 'low'
? getLowLimitDegree(quadrant)
: getHighLimitDegree(quadrant)
;
}
function getLowLimitDegree(quadrant) {
return GAUGE_LIMITS[quadrant] + DIAL_VALUE_DEG_OFFSET;
}
function getHighLimitDegree(quadrant) {
if (quadrant === 'q1') {
return GAUGE_LIMITS.q4 + DIAL_VALUE_DEG_OFFSET;
}
if (quadrant === 'q2') {
return GAUGE_LIMITS.q3 + DIAL_VALUE_DEG_OFFSET;
}
if (quadrant === 'q3') {
return GAUGE_LIMITS.q2 + DIAL_VALUE_DEG_OFFSET;
}
}

View File

@ -0,0 +1,223 @@
.is-object-type-gauge {
overflow: hidden;
}
.req-indicator {
width: 20px;
&.invalid,
&.invalid.req { @include validationState($glyph-icon-x, $colorFormInvalid); }
&.valid,
&.valid.req { @include validationState($glyph-icon-check, $colorFormValid); }
&.req { @include validationState($glyph-icon-asterisk, $colorFormRequired); }
}
.c-gauge {
// Both dial and meter types
overflow: hidden;
&__range {
$c: $colorGaugeRange;
color: $c;
text {
fill: $c;
}
}
&__wrapper {
@include abs();
overflow: hidden;
}
}
/********************************************** DIAL GAUGE */
svg[class*='c-dial'] {
max-height: 100%;
max-width: 100%;
position: absolute;
g {
transform-origin: center;
}
}
.c-dial {
&__bg {
background: $colorGaugeBg;
clip-path: url(#gaugeBgMask);
}
&__limit-high rect { fill: $colorGaugeLimitHigh; }
&__limit-low rect { fill: $colorGaugeLimitLow; }
&__filled-value-wrapper {
clip-path: url(#gaugeValueMask);
}
&__needle-value-wrapper {
clip-path: url(#gaugeValueMask);
}
&__filled-value { fill: $colorGaugeValue; }
&__needle-value {
fill: $colorGaugeValue;
transition: transform $transitionTimeGauge;
}
&__current-value-text {
fill: $colorGaugeTextValue;
font-family: $heroFont;
}
}
/********************************************** METER GAUGE */
.c-meter {
// Common styles for c-meter
@include abs();
display: flex;
svg {
// current-value-text
position: absolute;
height: 100%;
width: 100%;
}
&__range {
display: flex;
flex: 0 0 auto;
justify-content: space-between;
}
&__bg {
background: $colorGaugeBg;
border-radius: $basicCr;
flex: 1 1 auto;
overflow: hidden;
}
&__value {
// Filled area
position: absolute;
background: $colorGaugeValue;
transition: transform $transitionTimeGauge;
z-index: 1;
}
.c-gauge__curval {
fill: $colorGaugeMeterTextValue !important;
}
[class*='limit'] {
position: absolute;
}
&__limit-high {
background: $colorGaugeLimitHigh;
}
&__limit-low {
background: $colorGaugeLimitLow;
}
}
.c-meter {
.c-gauge--meter-vertical &,
.c-gauge--meter-vertical-inverted & {
&__range {
flex-direction: column;
min-width: min-content;
margin-right: $interiorMarginSm;
text-align: right;
}
&__value {
// Filled area
$lrM: $marginGaugeMeterValue;
left: $lrM;
right: $lrM;
top: 0;
bottom: 0;
}
[class*='limit'] {
left: 0;
right: 0;
}
}
.c-gauge--meter-vertical & {
&__limit-low {
bottom: 0;
}
&__limit-high {
top: 0;
}
}
.c-gauge--meter-vertical-inverted & {
&__limit-low {
top: 0;
}
&__limit-high {
bottom: 0;
}
&__range__low {
order: 1;
}
&__range__high {
order: 2;
}
}
.c-gauge--meter-horizontal & {
flex-direction: column;
&__range {
flex-direction: row;
min-height: min-content;
margin-top: $interiorMarginSm;
order: 2;
&__high {
order: 2;
}
&__low {
order: 1;
}
}
&__bg {
order: 1;
}
&__value {
// Filled area
$m: $marginGaugeMeterValue;
top: $m;
bottom: $m;
left: 0;
right: 0;
}
[class*='limit'] {
top: 0;
bottom: 0;
}
&__limit-low {
left: 0;
}
&__limit-high {
right: 0;
}
}
}

View File

@ -14,7 +14,7 @@ $elemBg: rgba(black, 0.7);
position: absolute;
left: 0;
top: 0;
z-index: 1;
z-index: 2;
@include userSelectNone;
}

View File

@ -55,7 +55,7 @@
<div
v-if="zoomFactor > 1"
class="c-imagery__hints"
>Alt-drag to pan</div>
>{{formatImageAltText}}</div>
<div
ref="focusedImageWrapper"
class="image-wrapper"
@ -143,13 +143,13 @@
<!-- spacecraft position fresh -->
<div
v-if="relatedTelemetry.hasRelatedTelemetry && isSpacecraftPositionFresh"
class="c-imagery__age icon-check c-imagery--new"
class="c-imagery__age icon-check c-imagery--new no-animation"
>POS</div>
<!-- camera position fresh -->
<div
v-if="relatedTelemetry.hasRelatedTelemetry && isCameraPositionFresh"
class="c-imagery__age icon-check c-imagery--new"
class="c-imagery__age icon-check c-imagery--new no-animation"
>CAM</div>
</div>
<div class="h-local-controls">
@ -163,10 +163,13 @@
</div>
</div>
<div
v-if="displayThumbnails"
class="c-imagery__thumbs-wrapper"
:class="[
{ 'is-paused': isPaused && !isFixed },
{ 'is-autoscroll-off': !resizingWindow && !autoScroll && !isPaused }
{ 'is-autoscroll-off': !resizingWindow && !autoScroll && !isPaused },
{ 'is-small-thumbs': displayThumbnailsSmall },
{ 'hide': !displayThumbnails }
]"
>
<div
@ -179,6 +182,7 @@
:key="image.url + image.time"
class="c-imagery__thumb c-thumb"
:class="{ selected: focusedImageIndex === index && isPaused }"
:title="image.formattedTime"
@click="thumbnailClicked(index)"
>
<a
@ -232,6 +236,8 @@ const ARROW_LEFT = 37;
const SCROLL_LATENCY = 250;
const ZOOM_SCALE_DEFAULT = 1;
const SHOW_THUMBS_THRESHOLD_HEIGHT = 200;
const SHOW_THUMBS_FULLSIZE_THRESHOLD_HEIGHT = 600;
export default {
components: {
@ -272,6 +278,7 @@ export default {
imageContainerHeight: undefined,
sizedImageWidth: 0,
sizedImageHeight: 0,
viewHeight: 0,
lockCompass: true,
resizingWindow: false,
timeContext: undefined,
@ -290,7 +297,8 @@ export default {
imageTranslateY: 0,
pan: undefined,
animateZoom: true,
imagePanned: false
imagePanned: false,
forceShowThumbnails: false
};
},
computed: {
@ -306,6 +314,15 @@ export default {
return compassRoseSizingClasses;
},
displayThumbnails() {
return (
this.forceShowThumbnails
|| this.viewHeight >= SHOW_THUMBS_THRESHOLD_HEIGHT
);
},
displayThumbnailsSmall() {
return this.viewHeight > SHOW_THUMBS_THRESHOLD_HEIGHT && this.viewHeight <= SHOW_THUMBS_FULLSIZE_THRESHOLD_HEIGHT;
},
time() {
return this.formatTime(this.focusedImage);
},
@ -314,6 +331,16 @@ export default {
},
isImageNew() {
let cutoff = FIVE_MINUTES;
if (this.imageFreshnessOptions) {
const { fadeOutDelayTime, fadeOutDurationTime} = this.imageFreshnessOptions;
// convert css duration to IS8601 format for parsing
const isoFormattedDuration = 'PT' + fadeOutDurationTime.toUpperCase();
const isoFormattedDelay = 'PT' + fadeOutDelayTime.toUpperCase();
const parsedDuration = moment.duration(isoFormattedDuration).asMilliseconds();
const parsedDelay = moment.duration(isoFormattedDelay).asMilliseconds();
cutoff = parsedDuration + parsedDelay;
}
let age = this.numericDuration;
return age < cutoff && !this.refreshCSS;
@ -461,6 +488,16 @@ export default {
width: this.sizedImageWidth,
height: this.sizedImageHeight
};
},
formatImageAltText() {
const regexLinux = /Linux/;
const navigator = window.navigator.userAgent;
if (regexLinux.test(navigator)) {
return 'Ctrl+Alt drag to pan';
}
return 'Alt drag to pan';
}
},
watch: {
@ -497,6 +534,8 @@ export default {
if (!this.isPaused) {
this.setFocusedImage(imageIndex);
this.scrollToRight();
} else {
this.scrollToFocused();
}
},
deep: true
@ -536,7 +575,7 @@ export default {
this.updateRelatedTelemetryForFocusedImage = _.debounce(this.updateRelatedTelemetryForFocusedImage, 400);
// for resizing the object view
this.resizeImageContainer = _.debounce(this.resizeImageContainer, 400);
this.resizeImageContainer = _.debounce(this.resizeImageContainer, 400, { leading: true });
if (this.$refs.imageBG) {
this.imageContainerResizeObserver = new ResizeObserver(this.resizeImageContainer);
@ -583,6 +622,9 @@ export default {
},
methods: {
calculateViewHeight() {
this.viewHeight = this.$el.clientHeight;
},
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView(this.objectPath);
@ -956,6 +998,8 @@ export default {
}
this.setSizedImageDimensions();
this.calculateViewHeight();
this.scrollToFocused();
},
setSizedImageDimensions() {
this.focusedImageNaturalAspectRatio = this.$refs.focusedImage.naturalWidth / this.$refs.focusedImage.naturalHeight;
@ -984,6 +1028,8 @@ export default {
this.scrollToRight('reset');
}
this.calculateViewHeight();
this.$nextTick(() => {
this.resizingWindow = false;
});

View File

@ -1,3 +1,5 @@
@use 'sass:math';
@keyframes fade-out {
from {
background-color: rgba($colorOk, 0.5);
@ -137,9 +139,12 @@
animation-name: fade-out;
animation-timing-function: ease-in;
animation-iteration-count: 1;
animation-fill-mode: forwards;
animation-fill-mode: forwards;
&.no-animation {
animation: none;
}
}
&__thumbs-wrapper {
display: flex; // Uses row layout
@ -161,17 +166,11 @@
flex: 0 1 auto;
display: flex;
flex-direction: row;
height: 135px;
height: 145px;
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 1px;
padding-bottom: $interiorMarginSm;
.c-thumb:last-child {
// Hilite the lastest thumb
background: $colorBodyFg;
color: $colorBodyBg;
}
}
&__auto-scroll-resume-button {
@ -184,10 +183,12 @@
/*************************************** THUMBS */
.c-thumb {
$w: $imageThumbsD;
display: flex;
flex-direction: column;
padding: 4px;
width: $imageThumbsD;
min-width: $w;
width: $w;
&:hover {
background: $colorThumbHoverBg;
@ -209,11 +210,19 @@
}
}
.l-layout,
.c-fl {
.is-small-thumbs {
.c-imagery__thumbs-scroll-area {
// When Imagery is in a layout, hide the thumbs area
display: none;
height: 60px; // Allow room for scrollbar
}
.c-thumb {
$w: math.div($imageThumbsD, 2);
min-width: $w;
width: $w;
&__timestamp {
display: none;
}
}
}
@ -222,7 +231,7 @@
.h-local-controls--overlay-content {
position: absolute;
left: $interiorMargin; top: $interiorMargin;
z-index: 2;
z-index: 70;
background: $colorLocalControlOvrBg;
border-radius: $basicCr;
max-width: 250px;

View File

@ -344,6 +344,8 @@ describe("The Imagery View Layouts", () => {
);
openmct.install(clearDataPlugin);
clearDataAction = openmct.actions.getAction('clear-data-action');
// force show the thumbnails
imageryView._getInstance().$children[0].forceShowThumbnails = true;
return Vue.nextTick();
});
@ -523,7 +525,10 @@ describe("The Imagery View Layouts", () => {
expect(clearDataAction).toBeDefined();
});
it('on clearData action should clear data for object is selected', (done) => {
it('on clearData action should clear data for object is selected', async (done) => {
// force show the thumbnails
imageryView._getInstance().$children[0].forceShowThumbnails = true;
await Vue.nextTick();
expect(parent.querySelectorAll('.c-imagery__thumb').length).not.toBe(0);
openmct.objectViews.on('clearData', async (_domainObject) => {
await Vue.nextTick();

View File

@ -20,10 +20,7 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
import PerformancePlugin from './plugin.js';
import {
createOpenMct,
resetApplicationState
} from 'utils/testing';
import { createOpenMct, resetApplicationState } from 'utils/testing';
describe('the plugin', () => {
let openmct;
@ -31,9 +28,8 @@ describe('the plugin', () => {
let child;
let performanceIndicator;
let countFramesPromise;
beforeEach((done) => {
beforeEach(done => {
openmct = createOpenMct();
element = document.createElement('div');
@ -42,11 +38,9 @@ describe('the plugin', () => {
openmct.install(new PerformancePlugin());
countFramesPromise = countFrames();
openmct.on('start', done);
performanceIndicator = openmct.indicators.indicatorObjects.find((indicator) => {
performanceIndicator = openmct.indicators.indicatorObjects.find(indicator => {
return indicator.text && indicator.text() === '~ fps';
});
@ -61,25 +55,21 @@ describe('the plugin', () => {
expect(performanceIndicator).toBeDefined();
});
it('correctly calculates fps', () => {
return countFramesPromise.then((frames) => {
expect(performanceIndicator.text()).toEqual(`${frames} fps`);
});
it('calculates an fps value', async () => {
await loopForABit();
// eslint-disable-next-line
expect(parseInt(performanceIndicator.text().split(' fps')[0])).toBeGreaterThan(0);
});
function countFrames() {
let startTime = performance.now();
function loopForABit() {
let frames = 0;
return new Promise((resolve) => {
requestAnimationFrame(function incrementCount() {
let now = performance.now();
if ((now - startTime) < 1000) {
frames++;
requestAnimationFrame(incrementCount);
return new Promise(resolve => {
requestAnimationFrame(function loop() {
if (++frames === 240) {
resolve();
} else {
resolve(frames);
requestAnimationFrame(loop);
}
});
});

View File

@ -32,7 +32,7 @@ Add a line to install the CouchDB plugin for OpenMCT:
```
openmct.install(openmct.plugins.CouchDB("http://localhost:5984/openmct"));
```
6. Enable cors in CouchDB by editing `/usr/local/etc/local.ini` and add: `
6. Enable cors in CouchDB by editing `~/homebrew/etc/local.ini` and add: `
```
[chttpd]
enable_cors = true
@ -45,4 +45,4 @@ origins = http://localhost:8080
9. Navigate to http://localhost:8080/ and create a random object in OpenMCT (e.g., a `Clock`) and save. You may get an error saying that the objects failed to persist. This is a known error that you can ignore, and will only happen the first time you save.
10. Navigate to: http://127.0.0.1:5984/_utils/#database/openmct/_all_docs
11. Look at the `JSON` tab and ensure you can see the `Clock` object you created above.
12. All done! 🏆
12. All done! 🏆

View File

@ -37,6 +37,7 @@
v-if="seriesModels.length > 0"
:tick-width="tickWidth"
:single-series="seriesModels.length === 1"
:has-same-range-value="hasSameRangeValue"
:series-model="seriesModels[0]"
:style="{
left: (plotWidth - tickWidth) + 'px'
@ -250,7 +251,8 @@ export default {
loaded: false,
isTimeOutOfSync: false,
showLimitLineLabels: undefined,
isFrozenOnMouseDown: false
isFrozenOnMouseDown: false,
hasSameRangeValue: true
};
},
computed: {
@ -362,6 +364,7 @@ export default {
this.setDisplayRange(series, xKey);
}, this);
this.listenTo(series, 'change:yKey', () => {
this.checkSameRangeValue();
this.loadSeriesData(series);
}, this);
@ -369,10 +372,18 @@ export default {
this.loadSeriesData(series);
}, this);
this.checkSameRangeValue();
this.loadSeriesData(series);
},
checkSameRangeValue() {
this.hasSameRangeValue = this.seriesModels.every((model) => {
return model.get('yKey') === this.seriesModels[0].get('yKey');
});
},
removeSeries(plotSeries) {
this.checkSameRangeValue();
this.stopListening(plotSeries);
},
@ -488,7 +499,7 @@ export default {
},
setDisplayRange(series, xKey) {
if (this.config.series.length !== 1) {
if (this.config.series.models.length !== 1) {
return;
}

View File

@ -30,8 +30,8 @@
class="gl-plot-tick-wrapper"
>
<div
v-for="tick in ticks"
:key="tick.value"
v-for="(tick, i) in ticks"
:key="'tick-left' + i"
class="gl-plot-tick gl-plot-x-tick-label"
:style="{
left: (100 * (tick.value - min) / interval) + '%'
@ -46,8 +46,8 @@
class="gl-plot-tick-wrapper"
>
<div
v-for="tick in ticks"
:key="tick.value"
v-for="(tick, i) in ticks"
:key="'tick-top' + i"
class="gl-plot-tick gl-plot-y-tick-label"
:style="{ top: (100 * (max - tick.value) / interval) + '%' }"
:title="tick.fullText || tick.text"
@ -59,8 +59,8 @@
<!-- grid lines follow -->
<template v-if="position === 'right'">
<div
v-for="tick in ticks"
:key="tick.value"
v-for="(tick, i) in ticks"
:key="'tick-right' + i"
class="gl-plot-hash hash-v"
:style="{
right: (100 * (max - tick.value) / interval) + '%',
@ -71,8 +71,8 @@
</template>
<template v-if="position === 'bottom'">
<div
v-for="tick in ticks"
:key="tick.value"
v-for="(tick, i) in ticks"
:key="'tick-bottom' + i"
class="gl-plot-hash hash-h"
:style="{ bottom: (100 * (tick.value - min) / interval) + '%', width: '100%' }"
>
@ -83,7 +83,7 @@
<script>
import eventHelpers from "./lib/eventHelpers";
import { ticks, getFormattedTicks } from "./tickUtils";
import { ticks, getLogTicks, getFormattedTicks } from "./tickUtils";
import configStore from "./configuration/ConfigStore";
export default {
@ -96,6 +96,13 @@ export default {
},
required: true
},
// Make it a prop, then later we can allow user to change it via UI input
tickCount: {
type: Number,
default() {
return 6;
}
},
position: {
required: true,
type: String,
@ -118,7 +125,6 @@ export default {
this.axis = this.getAxisFromConfig();
this.tickCount = 4;
this.tickUpdate = false;
this.listenTo(this.axis, 'change:displayRange', this.updateTicks, this);
this.listenTo(this.axis, 'change:format', this.updateTicks, this);
@ -184,7 +190,11 @@ export default {
}, this);
}
return ticks(range.min, range.max, number);
if (this.axisType === 'yAxis' && this.axis.get('logMode')) {
return getLogTicks(range.min, range.max, number, 4);
} else {
return ticks(range.min, range.max, number);
}
},
updateTicksForceRegeneration() {
@ -193,6 +203,7 @@ export default {
updateTicks(forceRegeneration = false) {
const range = this.axis.get('displayRange');
if (!range) {
delete this.min;
delete this.max;

View File

@ -29,9 +29,9 @@
>
<div
v-if="singleSeries"
v-if="canShowYAxisLabel"
class="gl-plot-label gl-plot-y-label"
:class="{'icon-gear': (yKeyOptions.length > 1)}"
:class="{'icon-gear': (yKeyOptions.length > 1 && singleSeries)}"
>{{ yAxisLabel }}
</div>
@ -76,6 +76,12 @@ export default {
return true;
}
},
hasSameRangeValue: {
type: Boolean,
default() {
return true;
}
},
seriesModel: {
type: Object,
default() {
@ -95,6 +101,11 @@ export default {
loaded: false
};
},
computed: {
canShowYAxisLabel() {
return this.singleSeries === true || this.hasSameRangeValue === true;
}
},
mounted() {
this.yAxis = this.getYAxisFromConfig();
this.loaded = true;

View File

@ -23,7 +23,7 @@
import MCTChartSeriesElement from './MCTChartSeriesElement';
export default class MCTChartLineStepAfter extends MCTChartSeriesElement {
removePoint(point, index, count) {
removePoint(index) {
if (index > 0 && index / 2 < this.count) {
this.buffer[index + 1] = this.buffer[index - 1];
}

View File

@ -85,11 +85,10 @@ export default class MCTChartSeriesElement {
this.removeSegments(removalPoint, vertexCount);
this.removePoint(
this.makePoint(point, series),
removalPoint,
vertexCount
);
// TODO useless makePoint call?
this.makePoint(point, series);
this.removePoint(removalPoint);
this.count -= (vertexCount / 2);
}
@ -109,11 +108,7 @@ export default class MCTChartSeriesElement {
const insertionPoint = this.startIndexForPointAtIndex(index);
this.growIfNeeded(pointsRequired);
this.makeInsertionPoint(insertionPoint, pointsRequired);
this.addPoint(
this.makePoint(point, series),
insertionPoint,
pointsRequired
);
this.addPoint(this.makePoint(point, series), insertionPoint);
this.count += (pointsRequired / 2);
}

View File

@ -71,6 +71,7 @@ export default class Model extends EventEmitter {
}
/**
* @abstract
* @param {ModelOptions<T, O>} options
*/
initialize(options) {

View File

@ -23,6 +23,7 @@ import _ from 'lodash';
import Model from "./Model";
import { MARKER_SHAPES } from '../draw/MarkerShapes';
import configStore from "../configuration/ConfigStore";
import { symlog } from '../mathUtils';
/**
* Plot series handle interpreting telemetry metadata for a single telemetry
@ -63,6 +64,8 @@ import configStore from "../configuration/ConfigStore";
* @extends {Model<PlotSeriesModelType, PlotSeriesModelOptions>}
*/
export default class PlotSeries extends Model {
logMode = false;
/**
@param {import('./Model').ModelOptions<PlotSeriesModelType, PlotSeriesModelOptions>} options
*/
@ -70,6 +73,8 @@ export default class PlotSeries extends Model {
super(options);
this.logMode = options.collection.plot.model.yAxis.logMode;
this.listenTo(this, 'change:xKey', this.onXKeyChange, this);
this.listenTo(this, 'change:yKey', this.onYKeyChange, this);
this.persistedConfig = options.persistedConfig;
@ -229,6 +234,7 @@ export default class PlotSeries extends Model {
this.getXVal = format.parse.bind(format);
}
}
/**
* Update y formatter on change, default to stepAfter interpolation if
* y range is an enumeration.
@ -250,8 +256,13 @@ export default class PlotSeries extends Model {
this.evaluate = function (datum) {
return this.limitEvaluator.evaluate(datum, valueMetadata);
}.bind(this);
this.set('unit', valueMetadata.unit);
const format = this.formats[newKey];
this.getYVal = format.parse.bind(format);
this.getYVal = (value) => {
const y = format.parse(value);
return this.logMode ? symlog(y, 10) : y;
};
}
formatX(point) {
@ -519,7 +530,8 @@ export default class PlotSeries extends Model {
/**
* Update the series data with the given value.
* @returns {Array<{
* This return type definition is totally wrong, only covers sinwave generator. It needs to be generic.
* @return-example {Array<{
cos: number
sin: number
mctLimitState: {

View File

@ -19,6 +19,7 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { antisymlog, symlog } from '../mathUtils';
import Model from './Model';
/**
@ -30,7 +31,7 @@ import Model from './Model';
*
* `autoscale`: boolean, whether or not to autoscale.
* `autoscalePadding`: float, percent of padding to display in plots.
* `displayRange`: the current display range for the x Axis.
* `displayRange`: the current display range for the axis.
* `format`: the formatter for the axis.
* `frozen`: boolean, if true, displayRange will not be updated automatically.
* Used to temporarily disable automatic updates during user interaction.
@ -53,6 +54,7 @@ export default class YAxisModel extends Model {
this.listenTo(this, 'change:stats', this.calculateAutoscaleExtents, this);
this.listenTo(this, 'change:autoscale', this.toggleAutoscale, this);
this.listenTo(this, 'change:autoscalePadding', this.updatePadding, this);
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'));
@ -73,11 +75,6 @@ export default class YAxisModel extends Model {
this.seriesCollection.forEach(this.trackSeries, this);
this.updateFromSeries(this.seriesCollection);
}
updateDisplayRange(range) {
if (!this.get('autoscale')) {
this.set('displayRange', range);
}
}
toggleFreeze(frozen) {
if (!frozen) {
this.toggleAutoscale(this.get('autoscale'));
@ -165,23 +162,95 @@ export default class YAxisModel extends Model {
this.resetStats();
this.updateFromSeries(this.seriesCollection);
}
/**
* This is called in order to map the user-provided `range` to the
* `displayRange` that we actually use for plot display.
*
* @param {import('./XAxisModel').NumberRange} range
*/
updateDisplayRange(range) {
if (this.get('autoscale')) {
return;
}
const _range = { ...range };
if (this.get('logMode')) {
_range.min = symlog(range.min, 10);
_range.max = symlog(range.max, 10);
}
this.set('displayRange', _range);
}
/**
* @param {boolean} autoscale
*/
toggleAutoscale(autoscale) {
if (autoscale && this.has('stats')) {
this.set('displayRange', this.applyPadding(this.get('stats')));
} else {
const range = this.get('range');
if (range) {
// If we already have a user-defined range, make sure it maps to the
// range we'll actually use for the ticks.
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.
this.set('range', this.get('displayRange'));
}
return;
}
const range = this.get('range');
if (range) {
// If we already have a user-defined range, make sure it maps to the
// range we'll actually use for the ticks.
const _range = { ...range };
if (this.get('logMode')) {
_range.min = symlog(range.min, 10);
_range.max = symlog(range.max, 10);
}
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);
}
}
/** @param {boolean} logMode */
onLogModeChange(logMode) {
const range = this.get('displayRange');
if (logMode) {
range.min = symlog(range.min, 10);
range.max = symlog(range.max, 10);
} else {
range.min = antisymlog(range.min, 10);
range.max = antisymlog(range.max, 10);
}
this.set('displayRange', range);
this.resetSeries();
}
resetSeries() {
this.plot.series.forEach((plotSeries) => {
plotSeries.logMode = this.get('logMode');
plotSeries.reset(plotSeries.getSeriesData());
});
// Update the series collection labels and formatting
this.updateFromSeries(this.seriesCollection);
}
/**
* Update yAxis format, values, and label from known series.
@ -189,7 +258,7 @@ export default class YAxisModel extends Model {
*/
updateFromSeries(seriesCollection) {
const plotModel = this.plot.get('domainObject');
const label = plotModel?.configuration?.yAxis?.label;
const label = plotModel.configuration?.yAxis?.label;
const sampleSeries = seriesCollection.first();
if (!sampleSeries) {
if (!label) {
@ -202,7 +271,13 @@ export default class YAxisModel extends Model {
const yKey = sampleSeries.get('yKey');
const yMetadata = sampleSeries.metadata.value(yKey);
const yFormat = sampleSeries.formats[yKey];
this.set('format', yFormat.format.bind(yFormat));
if (this.get('logMode')) {
this.set('format', (n) => yFormat.format(antisymlog(n, 10)));
} else {
this.set('format', (n) => yFormat.format(n));
}
this.set('values', yMetadata.values);
if (!label) {
const labelName = seriesCollection
@ -255,6 +330,7 @@ export default class YAxisModel extends Model {
return {
frozen: false,
autoscale: true,
logMode: options.model?.logMode ?? false,
autoscalePadding: 0.1
// 'range' is not specified here, it is undefined at first. When the
@ -269,6 +345,7 @@ export default class YAxisModel extends Model {
/**
@typedef {import('./XAxisModel').AxisModelType & {
autoscale: boolean
logMode: boolean
autoscalePadding: number
stats?: import('./XAxisModel').NumberRange
values: Array<TODO>

View File

@ -48,11 +48,19 @@
<li class="grid-row">
<div
class="grid-cell label"
title="Automatically scale the Y axis to keep all values in view."
>Autoscale</div>
title="Enable log mode."
>Log mode</div>
<div class="grid-cell value">
{{ autoscale ? "Enabled: " : "Disabled" }}
{{ autoscale ? autoscalePadding : "" }}
{{ logMode ? "Enabled" : "Disabled" }}
</div>
</li>
<li class="grid-row">
<div
class="grid-cell label"
title="Automatically scale the Y axis to keep all values in view."
>Auto scale</div>
<div class="grid-cell value">
{{ autoscale ? "Enabled: " + autoscalePadding : "Disabled" }}
</div>
</li>
<li
@ -142,6 +150,7 @@ export default {
config: {},
label: '',
autoscale: '',
logMode: false,
autoscalePadding: '',
rangeMin: '',
rangeMax: '',
@ -172,6 +181,7 @@ export default {
initConfiguration() {
this.label = this.config.yAxis.get('label');
this.autoscale = this.config.yAxis.get('autoscale');
this.logMode = this.config.yAxis.get('logMode');
this.autoscalePadding = this.config.yAxis.get('autoscalePadding');
const range = this.config.yAxis.get('range');
if (range) {

View File

@ -14,9 +14,22 @@
@change="updateForm('label')"
></div>
</li>
</ul>
<ul class="l-inspector-part">
<h2>Y Axis Scaling</h2>
<li class="grid-row">
<div
class="grid-cell label"
title="Enable log mode."
>
Log mode
</div>
<div class="grid-cell value">
<!-- eslint-disable-next-line vue/html-self-closing -->
<input
v-model="logMode"
type="checkbox"
@change="updateForm('logMode')"
/>
</div>
</li>
<li class="grid-row">
<div
class="grid-cell label"
@ -105,6 +118,7 @@ export default {
return {
label: '',
autoscale: '',
logMode: false,
autoscalePadding: '',
rangeMin: '',
rangeMax: '',
@ -117,38 +131,35 @@ export default {
},
methods: {
initialize: function () {
this.fields = [
{
modelProp: 'label',
this.fields = {
label: {
objectPath: 'configuration.yAxis.label'
},
{
modelProp: 'autoscale',
autoscale: {
coerce: Boolean,
objectPath: 'configuration.yAxis.autoscale'
},
{
modelProp: 'autoscalePadding',
autoscalePadding: {
coerce: Number,
objectPath: 'configuration.yAxis.autoscalePadding'
},
{
modelProp: 'range',
logMode: {
coerce: Boolean,
objectPath: 'configuration.yAxis.logMode'
},
range: {
objectPath: 'configuration.yAxis.range',
coerce: function coerceRange(range) {
if (!range) {
return {
min: 0,
max: 0
};
}
const newRange = {
min: -1,
max: 1
};
const newRange = {};
if (typeof range.min !== 'undefined' && range.min !== null) {
if (range && typeof range.min !== 'undefined' && range.min !== null) {
newRange.min = Number(range.min);
}
if (typeof range.max !== 'undefined' && range.max !== null) {
if (range && typeof range.max !== 'undefined' && range.max !== null) {
newRange.max = Number(range.max);
}
@ -180,11 +191,12 @@ export default {
}
}
}
];
};
},
initFormValues() {
this.label = this.yAxis.get('label');
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;
@ -202,7 +214,7 @@ export default {
}
let oldVal = this.yAxis.get(formKey);
const formField = this.fields.find((field) => field.modelProp === formKey);
const formField = this.fields[formKey];
const validationError = formField.validate?.(newVal, this.yAxis);
this.validationErrors[formKey] = validationError;

View File

@ -0,0 +1,44 @@
/** The natural number `e`. */
export const e = Math.exp(1);
/**
Returns the logarithm of a number, using the given base or the natural number
`e` as base if not specified.
@param {number} n
@param {number=} base log base, defaults to e
*/
export function log(n, base = e) {
if (base === e) {
return Math.log(n);
}
return Math.log(n) / Math.log(base);
}
/**
Returns the inverse of the logarithm of a number, using the given base or the
natural number `e` as base if not specified.
@param {number} n
@param {number=} base log base, defaults to e
*/
export function antilog(n, base = e) {
return Math.pow(base, n);
}
/**
A symmetric logarithm function. See https://github.com/nasa/openmct/issues/2297#issuecomment-1032914258
@param {number} n
@param {number=} base log base, defaults to e
*/
export function symlog(n, base = e) {
return Math.sign(n) * log(Math.abs(n) + 1, base);
}
/**
An inverse symmetric logarithm function. See https://github.com/nasa/openmct/issues/2297#issuecomment-1032914258
@param {number} n
@param {number=} base log base, defaults to e
*/
export function antisymlog(n, base = e) {
return Math.sign(n) * (antilog(Math.abs(n), base) - 1);
}

View File

@ -389,7 +389,7 @@ describe("the plugin", function () {
expect(xAxisElement.length).toBe(1);
let ticks = xAxisElement[0].querySelectorAll(".gl-plot-tick");
expect(ticks.length).toBe(5);
expect(ticks.length).toBe(9);
done();
});
@ -694,7 +694,7 @@ describe("the plugin", function () {
Vue.nextTick(() => {
let ticks = xAxisElement[0].querySelectorAll(".gl-plot-tick");
expect(ticks.length).toBe(5);
expect(ticks.length).toBe(9);
done();
});
@ -1086,7 +1086,9 @@ describe("the plugin", function () {
expandControl.dispatchEvent(clickEvent);
const yAxisProperties = editOptionsEl.querySelectorAll("div.grid-properties:first-of-type .l-inspector-part");
expect(yAxisProperties.length).toEqual(3);
// TODO better test
expect(yAxisProperties.length).toEqual(2);
});
it('renders color palette options', () => {

View File

@ -132,12 +132,6 @@ export default {
}
}
});
if (this.object && this.object.type === 'conditionWidget' && keys.includes('output')) {
this.openmct.objects.mutate(this.object, 'conditionalLabel', styleObj.output);
} else {
this.openmct.objects.mutate(this.object, 'conditionalLabel', '');
}
}
}
};

View File

@ -1,3 +1,5 @@
import { antisymlog, symlog } from "./mathUtils";
const e10 = Math.sqrt(50);
const e5 = Math.sqrt(10);
const e2 = Math.sqrt(2);
@ -40,6 +42,42 @@ function getPrecision(step) {
return precision;
}
export function getLogTicks(start, stop, mainTickCount = 8, secondaryTickCount = 6) {
// log()'ed values
const mainLogTicks = ticks(start, stop, mainTickCount);
// original values
const mainTicks = mainLogTicks.map(n => antisymlog(n, 10));
const result = [];
let i = 0;
for (const logTick of mainLogTicks) {
result.push(logTick);
if (i === mainLogTicks.length - 1) {
break;
}
const tick = mainTicks[i];
const nextTick = mainTicks[i + 1];
const rangeBetweenMainTicks = nextTick - tick;
const secondaryLogTicks = ticks(
tick + rangeBetweenMainTicks / (secondaryTickCount + 1),
nextTick - rangeBetweenMainTicks / (secondaryTickCount + 1),
secondaryTickCount - 2
)
.map(n => symlog(n, 10));
result.push(...secondaryLogTicks);
i++;
}
return result;
}
/**
* Linear tick generation from d3-array.
*/

View File

@ -77,6 +77,7 @@ define([
'./userIndicator/plugin',
'../../example/exampleUser/plugin',
'./localStorage/plugin',
'./gauge/GaugePlugin',
'./timelist/plugin'
], function (
_,
@ -135,6 +136,7 @@ define([
UserIndicator,
ExampleUser,
LocalStorage,
GaugePlugin,
TimeList
) {
const plugins = {};
@ -212,6 +214,7 @@ define([
plugins.DeviceClassifier = DeviceClassifier.default;
plugins.UserIndicator = UserIndicator.default;
plugins.LocalStorage = LocalStorage.default;
plugins.Gauge = GaugePlugin.default;
plugins.Timelist = TimeList.default;
return plugins;

View File

@ -29,10 +29,6 @@ define(
_,
EventEmitter
) {
const LESS_THAN = -1;
const EQUAL = 0;
const GREATER_THAN = 1;
/**
* @constructor
*/
@ -80,10 +76,7 @@ define(
this.rows = [];
}
for (let row of rowsToAdd) {
let index = this.sortedIndex(this.rows, row);
this.rows.splice(index, 0, row);
}
this.sortAndMergeRows(rowsToAdd);
// we emit filter no matter what to trigger
// an update of visible rows
@ -92,58 +85,85 @@ define(
}
}
sortedLastIndex(rows, testRow) {
return this.sortedIndex(rows, testRow, _.sortedLastIndex);
}
sortAndMergeRows(rows) {
const sortedRowsToAdd = this.sortCollection(rows);
/**
* Finds the correct insertion point for the given row.
* Leverages lodash's `sortedIndex` function which implements a binary search.
* @private
*/
sortedIndex(rows, testRow, lodashFunction = _.sortedIndexBy) {
if (this.rows.length === 0) {
return 0;
this.rows = sortedRowsToAdd;
return;
}
const testRowValue = this.getValueForSortColumn(testRow);
const firstValue = this.getValueForSortColumn(this.rows[0]);
const lastValue = this.getValueForSortColumn(this.rows[this.rows.length - 1]);
const firstIncomingRow = sortedRowsToAdd[0];
const lastIncomingRow = sortedRowsToAdd[sortedRowsToAdd.length - 1];
const firstExistingRow = this.rows[0];
const lastExistingRow = this.rows[this.rows.length - 1];
if (this.firstRowInSortOrder(lastIncomingRow, firstExistingRow)
=== lastIncomingRow
) {
this.rows = [...sortedRowsToAdd, ...this.rows];
} else if (this.firstRowInSortOrder(lastExistingRow, firstIncomingRow)
=== lastExistingRow
) {
this.rows = [...this.rows, ...sortedRowsToAdd];
} else {
this.mergeSortedRows(sortedRowsToAdd);
}
}
sortCollection(rows) {
const sortedRows = _.orderBy(
rows,
row => row.getParsedValue(this.sortOptions.key), this.sortOptions.direction
);
return sortedRows;
}
mergeSortedRows(rows) {
const mergedRows = [];
let i = 0;
let j = 0;
while (i < this.rows.length && j < rows.length) {
const existingRow = this.rows[i];
const incomingRow = rows[j];
if (this.firstRowInSortOrder(existingRow, incomingRow) === existingRow) {
mergedRows.push(existingRow);
i++;
} else {
mergedRows.push(incomingRow);
j++;
}
}
// tail of existing rows is all that is left to merge
if (i < this.rows.length) {
for (i; i < this.rows.length; i++) {
mergedRows.push(this.rows[i]);
}
}
// tail of incoming rows is all that is left to merge
if (j < rows.length) {
for (j; j < rows.length; j++) {
mergedRows.push(rows[j]);
}
}
this.rows = mergedRows;
}
firstRowInSortOrder(row1, row2) {
const val1 = this.getValueForSortColumn(row1);
const val2 = this.getValueForSortColumn(row2);
if (this.sortOptions.direction === 'asc') {
if (testRowValue > lastValue) {
return this.rows.length;
} else if (testRowValue === lastValue) {
// Maintain stable sort
return this.rows.length;
} else if (testRowValue <= firstValue) {
return 0;
} else {
return lodashFunction(rows, testRow, (thisRow) => {
return this.getValueForSortColumn(thisRow);
});
}
return val1 <= val2 ? row1 : row2;
} else {
if (testRowValue >= firstValue) {
return 0;
} else if (testRowValue < lastValue) {
return this.rows.length;
} else if (testRowValue === lastValue) {
// Maintain stable sort
return this.rows.length;
} else {
// Use a custom comparison function to support descending sort.
return lodashFunction(rows, testRow, (thisRow) => {
const thisRowValue = this.getValueForSortColumn(thisRow);
if (testRowValue === thisRowValue) {
return EQUAL;
} else if (testRowValue < thisRowValue) {
return LESS_THAN;
} else {
return GREATER_THAN;
}
});
}
return val1 >= val2 ? row1 : row2;
}
}
@ -205,8 +225,9 @@ define(
sortBy(sortOptions) {
if (arguments.length > 0) {
this.sortOptions = sortOptions;
performance.mark('table:row:sort:start');
this.rows = _.orderBy(this.rows, (row) => row.getParsedValue(sortOptions.key), sortOptions.direction);
performance.mark('table:row:sort:stop');
this.emit('sort');
}

View File

@ -613,6 +613,7 @@ export default {
this.calculateScrollbarWidth();
},
sortBy(columnKey) {
performance.mark('table:sort');
// If sorting by the same column, flip the sort direction.
if (this.sortOptions.key === columnKey) {
if (this.sortOptions.direction === 'asc') {
@ -669,6 +670,7 @@ export default {
this.setHeight();
},
rowsAdded(rows) {
performance.mark('row:added');
this.setHeight();
let sizingRow;
@ -690,6 +692,7 @@ export default {
this.updateVisibleRows();
},
rowsRemoved(rows) {
performance.mark('row:removed');
this.setHeight();
this.updateVisibleRows();
},

View File

@ -366,6 +366,16 @@ $legendHoverValueBg: rgba($colorBodyFg, 0.2);
$legendTableHeadBg: $colorTabHeaderBg;
$colorPlotLimitLineBg: rgba($colorBodyBg, 0.2);
// Gauges
$colorGaugeBg: pullForward($colorBodyBg, 5%); // Gauge radial area background, meter background
$colorGaugeValue: rgba(#fff, 0.3); // Gauge value graphic (radial sweep, bar) color
$colorGaugeTextValue: #fff; // Radial gauge text value
$colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid on value bar
$colorGaugeRange: $colorBodyFg; // Range text color
$colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4);
$colorGaugeLimitLow: $colorGaugeLimitHigh;
$transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions
$marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges
// Time Strip and Lists
$colorCurrentBg: rgba($colorStatusAlert, 0.3);
$colorCurrentFg: pullForward($colorBodyFg, 20%);

View File

@ -370,6 +370,16 @@ $legendHoverValueBg: rgba($colorBodyFg, 0.2);
$legendTableHeadBg: rgba($colorBodyFg, 0.15);
$colorPlotLimitLineBg: rgba($colorBodyBg, 0.2);
// Gauges
$colorGaugeBg: pullForward($colorBodyBg, 5%); // Gauge radial area background, meter background
$colorGaugeValue: rgba(#fff, 0.3); // Gauge value graphic (radial sweep, bar) color
$colorGaugeTextValue: #fff; // Radial gauge text value
$colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid on value bar
$colorGaugeRange: $colorBodyFg; // Range text color
$colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4);
$colorGaugeLimitLow: $colorGaugeLimitHigh;
$transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions
$marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges
// Time Strip and Lists
$colorCurrentBg: rgba($colorStatusAlert, 0.3);
$colorCurrentFg: pullForward($colorBodyFg, 20%);

View File

@ -366,6 +366,16 @@ $legendHoverValueBg: rgba($colorBodyFg, 0.2);
$legendTableHeadBg: rgba($colorBodyFg, 0.15);
$colorPlotLimitLineBg: rgba($colorBodyBg, 0.4);
// Gauges
$colorGaugeBg: pullForward($colorBodyBg, 20%); // Gauge radial area background, meter background
$colorGaugeValue: rgba(#000, 0.3); // Gauge value graphic (radial sweep, bar) color
$colorGaugeTextValue: pullForward($colorBodyFg, 20%); // Radial gauge text value
$colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid on value bar
$colorGaugeRange: $colorBodyFg; // Range text color
$colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.2);
$colorGaugeLimitLow: $colorGaugeLimitHigh;
$transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions
$marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges
// Time Strip and Lists
$colorCurrentBg: rgba($colorStatusAlert, 0.3);
$colorCurrentFg: pullForward($colorBodyFg, 20%);

View File

@ -70,8 +70,24 @@
padding: $formTBPad $formLRPad;
text-transform: uppercase;
}
&--sub-grid {
// 3 columns: <req> <label> <input/control>
display: grid;
grid-column-gap: $interiorMargin;
grid-template-columns: 20px max-content 1fr;
grid-row-gap: $interiorMargin;
margin-top: $interiorMarginLg;
width: max-content;
.c-form__row {
display: contents;
}
}
}
.c-form-row {
align-items: start;

View File

@ -53,6 +53,7 @@
@import "../ui/toolbar/components/toolbar-checkbox.scss";
@import "./notebook.scss";
@import "../plugins/notebook/components/sidebar.scss";
@import "../plugins/gauge/gauge.scss";
#splash-screen {
display: none;

View File

@ -213,12 +213,6 @@ export default {
}
}
});
if (this.domainObject && this.domainObject.type === 'conditionWidget' && keys.includes('output')) {
this.openmct.objects.mutate(this.domainObject, 'conditionalLabel', styleObj.output);
} else {
this.openmct.objects.mutate(this.domainObject, 'conditionalLabel', '');
}
},
updateView(immediatelySelect) {
this.clear();
@ -450,4 +444,3 @@ export default {
}
};
</script>

View File

@ -123,7 +123,7 @@ export default {
}
},
drawAxis(bounds, timeSystem) {
let viewBounds = Object.assign({}, bounds);
let viewBounds = Object.create(bounds);
this.setScale(viewBounds, timeSystem);
this.setAxis(viewBounds);

View File

@ -166,7 +166,8 @@ export default {
}
return definition.form
.map((field) => {
.filter(field => !field.hideFromInspector)
.map(field => {
let path = field.property;
if (typeof path === 'string') {
path = [path];

View File

@ -128,7 +128,7 @@ export default {
return;
}
const isExistingView = viewProvider.key === this.existingView.key;
const isExistingView = viewProvider.key === this.existingView?.key;
this.clear();

View File

@ -44,7 +44,7 @@ const config = {
"bourbon": "bourbon.scss",
"plotly-basic": "plotly.js-basic-dist",
"plotly-gl2d": "plotly.js-gl2d-dist",
"d3-scale": path.join(__dirname, "node_modules/d3-scale/build/d3-scale.min.js"),
"d3-scale": path.join(__dirname, "node_modules/d3-scale/dist/d3-scale.min.js"),
"printj": path.join(__dirname, "node_modules/printj/dist/printj.min.js"),
"styles": path.join(__dirname, "src/styles"),
"MCT": path.join(__dirname, "src/MCT"),