Compare commits
48 Commits
imagery-la
...
vulnerabil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f3bf9452 | ||
|
|
d30c4fcb53 | ||
|
|
fff3ce0acf | ||
|
|
db5cb2517f | ||
|
|
5236f1c796 | ||
|
|
1ed253cb07 | ||
|
|
a6553ba010 | ||
|
|
cf6bc5be2d | ||
|
|
a53a3a0297 | ||
|
|
402cd15726 | ||
|
|
a5580912e3 | ||
|
|
54d1b8991c | ||
|
|
7b6acee793 | ||
|
|
04e1c60e5c | ||
|
|
91bcd78d40 | ||
|
|
a3c0e073c8 | ||
|
|
21ae9f45c1 | ||
|
|
0a40c8dd0b | ||
|
|
ef1ea8e712 | ||
|
|
5c4fad77ff | ||
|
|
dbac9e6cd2 | ||
|
|
4b7bcf9c89 | ||
|
|
2b42abd495 | ||
|
|
1f2102b845 | ||
|
|
2ccb90aa41 | ||
|
|
525496fbca | ||
|
|
47099786cb | ||
|
|
3a11291a3b | ||
|
|
476f1b2579 | ||
|
|
6153ad8e1e | ||
|
|
77c0b16050 | ||
|
|
d19088cec6 | ||
|
|
43afb39e56 | ||
|
|
cd8c332fb5 | ||
|
|
b899475939 | ||
|
|
cc1f7659f9 | ||
|
|
0d5539be96 | ||
|
|
0a511e6155 | ||
|
|
47b6d19de8 | ||
|
|
3fd93f47bc | ||
|
|
651e61954c | ||
|
|
d30ec4c757 | ||
|
|
3c24733476 | ||
|
|
04d00fac3d | ||
|
|
150909d4b9 | ||
|
|
2b599a7ff4 | ||
|
|
824a597825 | ||
|
|
cf5edf2db0 |
@@ -2,7 +2,7 @@ version: 2.1
|
||||
executors:
|
||||
pw-focal-development:
|
||||
docker:
|
||||
- image: mcr.microsoft.com/playwright:v1.19.1-focal
|
||||
- image: mcr.microsoft.com/playwright:v1.19.2-focal
|
||||
environment:
|
||||
NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed
|
||||
parameters:
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
equal: [ "FirefoxESR", <<parameters.browser>> ]
|
||||
steps:
|
||||
- browser-tools/install-firefox:
|
||||
version: "91.4.0esr" #https://archive.mozilla.org/pub/firefox/releases/
|
||||
version: "91.7.1esr" #https://archive.mozilla.org/pub/firefox/releases/
|
||||
- when:
|
||||
condition:
|
||||
equal: [ "FirefoxHeadless", <<parameters.browser>> ]
|
||||
@@ -142,21 +142,22 @@ workflows:
|
||||
overall-circleci-commit-status: #These jobs run on every commit
|
||||
jobs:
|
||||
- lint:
|
||||
name: node16-lint
|
||||
node-version: lts/gallium
|
||||
- unit-test:
|
||||
name: node12-chrome
|
||||
node-version: lts/erbium
|
||||
browser: ChromeHeadless
|
||||
- unit-test:
|
||||
name: node14-chrome
|
||||
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
|
||||
@@ -164,13 +165,9 @@ workflows:
|
||||
the-nightly: #These jobs do not run on PRs, but against master at night
|
||||
jobs:
|
||||
- unit-test:
|
||||
name: node12-firefoxESR-nightly
|
||||
node-version: lts/erbium
|
||||
name: node16-firefoxESR-nightly
|
||||
node-version: lts/gallium
|
||||
browser: FirefoxESR
|
||||
- unit-test:
|
||||
name: node12-chrome-nightly
|
||||
node-version: lts/erbium
|
||||
browser: ChromeHeadless
|
||||
- unit-test:
|
||||
name: node14-firefox-nightly
|
||||
node-version: lts/fermium
|
||||
@@ -183,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:
|
||||
|
||||
2
.github/workflows/e2e-pr.yml
vendored
2
.github/workflows/e2e-pr.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
- 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
|
||||
|
||||
14
.github/workflows/lighthouse.yml
vendored
14
.github/workflows/lighthouse.yml
vendored
@@ -9,8 +9,6 @@ on:
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
schedule:
|
||||
- cron: '28 21 * * 1-5'
|
||||
jobs:
|
||||
lighthouse-pr:
|
||||
if: ${{ github.event.label.name == 'pr:lighthouse' }}
|
||||
@@ -20,10 +18,10 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: master #explicitly checkout master for baseline
|
||||
- name: Install Node 14
|
||||
- name: Install Node 16
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '16'
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v2
|
||||
env:
|
||||
@@ -54,10 +52,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Node 14
|
||||
- name: Install Node 16
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '16'
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v2
|
||||
env:
|
||||
@@ -83,9 +81,9 @@ jobs:
|
||||
- name: Install Node 14
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '16'
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
|
||||
2
.github/workflows/pr-platform.yml
vendored
2
.github/workflows/pr-platform.yml
vendored
@@ -16,9 +16,9 @@ jobs:
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
node_version:
|
||||
- 12
|
||||
- 14
|
||||
- 16
|
||||
- 18
|
||||
architecture:
|
||||
- x64
|
||||
name: Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }}
|
||||
|
||||
2
app.js
2
app.js
@@ -64,7 +64,7 @@ app.use(require('webpack-dev-middleware')(
|
||||
compiler,
|
||||
{
|
||||
publicPath: '/dist',
|
||||
logLevel: 'warn'
|
||||
stats: 'errors-warnings'
|
||||
}
|
||||
));
|
||||
|
||||
|
||||
@@ -157,5 +157,10 @@ test.describe('Sine Wave Generator', () => {
|
||||
y: 28
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that where we click on canvas shows the number we clicked on
|
||||
// Note that any number will do, we just care that a number exists
|
||||
await expect(page.locator('.value-to-display-nearestValue')).toContainText(/[+-]?([0-9]*[.])?[0-9]+/);
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
66
e2e/tests/plugins/clock/Clock.e2e.spec.js
Normal file
66
e2e/tests/plugins/clock/Clock.e2e.spec.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2022, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*
|
||||
* Open MCT includes source code licensed under additional open source
|
||||
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
/*
|
||||
This test suite is dedicated to tests which verify the basic operations surrounding Clock.
|
||||
*/
|
||||
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Clock Generator', () => {
|
||||
|
||||
test('Timezone dropdown will collapse when clicked outside or on dropdown icon again', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/4878'
|
||||
});
|
||||
//Go to baseURL
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
|
||||
//Click the Create button
|
||||
await page.click('button:has-text("Create")');
|
||||
|
||||
// Click Clock
|
||||
await page.click('text=Clock');
|
||||
|
||||
// Click .icon-arrow-down
|
||||
await page.locator('.icon-arrow-down').click();
|
||||
//verify if the autocomplete dropdown is visible
|
||||
await expect(page.locator(".optionPreSelected")).toBeVisible();
|
||||
// Click .icon-arrow-down
|
||||
await page.locator('.icon-arrow-down').click();
|
||||
|
||||
// Verify clicking on the autocomplete arrow collapses the dropdown
|
||||
await expect(page.locator(".optionPreSelected")).not.toBeVisible();
|
||||
|
||||
// Click timezone input to open dropdown
|
||||
await page.locator('.autocompleteInput').click();
|
||||
//verify if the autocomplete dropdown is visible
|
||||
await expect(page.locator(".optionPreSelected")).toBeVisible();
|
||||
|
||||
// Verify clicking outside the autocomplete dropdown collapses it
|
||||
await page.locator('text=Timezone').click();
|
||||
// Verify clicking on the autocomplete arrow collapses the dropdown
|
||||
await expect(page.locator(".optionPreSelected")).not.toBeVisible();
|
||||
|
||||
});
|
||||
});
|
||||
@@ -166,17 +166,17 @@ test.describe('Example Imagery', () => {
|
||||
// wait for zoom animation to finish
|
||||
await bgImageLocator.hover();
|
||||
const zoomedInBoundingBox = await bgImageLocator.boundingBox();
|
||||
expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height);
|
||||
expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width);
|
||||
expect.soft(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height);
|
||||
expect.soft(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width);
|
||||
|
||||
await zoomResetBtn.click();
|
||||
await bgImageLocator.hover();
|
||||
|
||||
const resetBoundingBox = await bgImageLocator.boundingBox();
|
||||
expect(resetBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height);
|
||||
expect(resetBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width);
|
||||
expect.soft(resetBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height);
|
||||
expect.soft(resetBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width);
|
||||
|
||||
expect(resetBoundingBox.height).toEqual(initialBoundingBox.height);
|
||||
expect.soft(resetBoundingBox.height).toEqual(initialBoundingBox.height);
|
||||
expect(resetBoundingBox.width).toEqual(initialBoundingBox.width);
|
||||
});
|
||||
|
||||
|
||||
190
e2e/tests/plugins/plot/autoscale.e2e.spec.js
Normal file
190
e2e/tests/plugins/plot/autoscale.e2e.spec.js
Normal file
@@ -0,0 +1,190 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
/*
|
||||
Test for plot autoscale.
|
||||
*/
|
||||
|
||||
const { test: _test, expect } = require('@playwright/test');
|
||||
|
||||
// create a new `test` API that will not append platform details to snapshot
|
||||
// file names, only for the tests in this file, so that the same snapshots will
|
||||
// be used for all platforms.
|
||||
const test = _test.extend({
|
||||
_autoSnapshotSuffix: [
|
||||
async ({}, use, testInfo) => {
|
||||
testInfo.snapshotSuffix = '';
|
||||
await use();
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
});
|
||||
|
||||
test.use({
|
||||
viewport: {
|
||||
width: 1280,
|
||||
height: 720
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('ExportAsJSON', () => {
|
||||
test('autoscale off causes no error from undefined user range', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
|
||||
await setTimeRange(page);
|
||||
|
||||
await createSinewaveOverlayPlot(page);
|
||||
|
||||
await testYTicks(page, ['-1.00', '-0.50', '0.00', '0.50', '1.00']);
|
||||
|
||||
await turnOffAutoscale(page);
|
||||
|
||||
const canvas = page.locator('canvas').nth(1);
|
||||
|
||||
// Make sure that after turning off autoscale, the user selected range values start at the same values the plot had prior.
|
||||
await Promise.all([
|
||||
testYTicks(page, ['-1.00', '-0.50', '0.00', '0.50', '1.00']),
|
||||
new Promise(r => setTimeout(r, 100))
|
||||
.then(() => canvas.screenshot())
|
||||
.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, {
|
||||
sourcePosition: {
|
||||
x: 200,
|
||||
y: 200
|
||||
},
|
||||
targetPosition: {
|
||||
x: 400,
|
||||
y: 400
|
||||
}
|
||||
});
|
||||
|
||||
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: 40 }))
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string} start
|
||||
* @param {string} end
|
||||
*/
|
||||
async function setTimeRange(page, start = '2022-03-29 22:00:00.000Z', end = '2022-03-29 22:00:30.000Z') {
|
||||
// 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(start);
|
||||
|
||||
await timeInputs.nth(1).click();
|
||||
await timeInputs.nth(1).fill(end);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function createSinewaveOverlayPlot(page) {
|
||||
// click create button
|
||||
await page.locator('button:has-text("Create")').click();
|
||||
|
||||
// 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()
|
||||
]);
|
||||
|
||||
// save (exit edit mode)
|
||||
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();
|
||||
|
||||
// click create button
|
||||
await page.locator('button:has-text("Create")').click();
|
||||
|
||||
// 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()
|
||||
]);
|
||||
|
||||
// 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.locator('text=Unnamed Overlay Plot').first().click()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function turnOffAutoscale(page) {
|
||||
// enter edit mode
|
||||
await page.locator('text=Unnamed Overlay Plot Snapshot >> button').nth(3).click();
|
||||
|
||||
// uncheck autoscale
|
||||
await page.locator('text=Y Axis Scaling Auto scale Padding >> input[type="checkbox"]').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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function testYTicks(page, values) {
|
||||
const yTicks = page.locator('.gl-plot-y-tick-label');
|
||||
let promises = [yTicks.count().then(c => expect(c).toBe(values.length))];
|
||||
|
||||
for (let i = 0, l = values.length; i < l; i += 1) {
|
||||
promises.push(expect(yTicks.nth(i)).toHaveText(values[i])); // eslint-disable-line
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
279
e2e/tests/plugins/plot/log-plot.e2e.spec.js
Normal file
279
e2e/tests/plugins/plot/log-plot.e2e.spec.js
Normal file
@@ -0,0 +1,279 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Log plot tests', () => {
|
||||
test.only('Can create a log plot.', 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
|
||||
await page.reload();
|
||||
|
||||
// test log ticks hold up after refresh
|
||||
await testLogTicks(page);
|
||||
await testLogPlotPixels(page);
|
||||
});
|
||||
|
||||
test.only('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(/*{ url: 'http://localhost:8080/#/browse/mine/8caf7072-535b-4af6-8394-edd86e3ea35f?tc.mode=fixed&tc.startBound=1648590633191&tc.endBound=1648592433191&tc.timeSystem=utc&view=plot-overlay' }*/),
|
||||
page.locator('text=OK').click()
|
||||
]);
|
||||
|
||||
// save the overlay plot
|
||||
|
||||
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();
|
||||
|
||||
// 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(/*{ url: 'http://localhost:8080/#/browse/mine/8caf7072-535b-4af6-8394-edd86e3ea35f/6e58b26a-8a73-4df6-b3a6-918decc0bbfa?tc.mode=fixed&tc.startBound=1648590633191&tc.endBound=1648592433191&tc.timeSystem=utc&view=plot-single' }*/),
|
||||
page.locator('text=OK').click()
|
||||
]);
|
||||
|
||||
// click on 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/8caf7072-535b-4af6-8394-edd86e3ea35f?tc.mode=fixed&tc.startBound=1648590633191&tc.endBound=1648592433191&tc.timeSystem=utc&view=plot-overlay' }*/),
|
||||
page.locator('text=Unnamed Overlay Plot').first().click()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function testRegularTicks(page) {
|
||||
const yTicks = 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 = 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 page.locator('text=Save and Finish Editing').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @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, 50));
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -35,8 +35,8 @@ define([
|
||||
phase: 0
|
||||
};
|
||||
|
||||
function GeneratorProvider() {
|
||||
this.workerInterface = new WorkerInterface();
|
||||
function GeneratorProvider(openmct) {
|
||||
this.workerInterface = new WorkerInterface(openmct);
|
||||
}
|
||||
|
||||
GeneratorProvider.prototype.canProvideTelemetry = function (domainObject) {
|
||||
|
||||
@@ -21,20 +21,13 @@
|
||||
*****************************************************************************/
|
||||
|
||||
define([
|
||||
'raw-loader!./generatorWorker.js',
|
||||
'uuid'
|
||||
], function (
|
||||
workerText,
|
||||
uuid
|
||||
) {
|
||||
|
||||
var workerBlob = new Blob(
|
||||
[workerText],
|
||||
{type: 'application/javascript'}
|
||||
);
|
||||
var workerUrl = URL.createObjectURL(workerBlob);
|
||||
|
||||
function WorkerInterface() {
|
||||
function WorkerInterface(openmct) {
|
||||
// eslint-disable-next-line no-undef
|
||||
const workerUrl = `${openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}generatorWorker.js`;
|
||||
this.worker = new Worker(workerUrl);
|
||||
this.worker.onmessage = this.onMessage.bind(this);
|
||||
this.callbacks = {};
|
||||
|
||||
@@ -146,7 +146,7 @@ define([
|
||||
}
|
||||
});
|
||||
|
||||
openmct.telemetry.addProvider(new GeneratorProvider());
|
||||
openmct.telemetry.addProvider(new GeneratorProvider(openmct));
|
||||
openmct.telemetry.addProvider(new GeneratorMetadataProvider());
|
||||
openmct.telemetry.addProvider(new SinewaveLimitProvider());
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
@@ -195,6 +195,7 @@
|
||||
));
|
||||
openmct.install(openmct.plugins.Clock({ enableClockIndicator: true }));
|
||||
openmct.install(openmct.plugins.Timer());
|
||||
openmct.install(openmct.plugins.Timelist());
|
||||
openmct.start();
|
||||
</script>
|
||||
</html>
|
||||
|
||||
@@ -38,6 +38,10 @@ module.exports = (config) => {
|
||||
{
|
||||
pattern: 'dist/inMemorySearchWorker.js*',
|
||||
included: false
|
||||
},
|
||||
{
|
||||
pattern: 'dist/generatorWorker.js*',
|
||||
included: false
|
||||
}
|
||||
],
|
||||
port: 9876,
|
||||
@@ -92,8 +96,7 @@ module.exports = (config) => {
|
||||
},
|
||||
webpack: webpackConfig,
|
||||
webpackMiddleware: {
|
||||
stats: 'errors-only',
|
||||
logLevel: 'warn'
|
||||
stats: 'errors-warnings'
|
||||
},
|
||||
concurrency: 1,
|
||||
singleRun: true,
|
||||
|
||||
48
package.json
48
package.json
@@ -1,25 +1,29 @@
|
||||
{
|
||||
"name": "openmct",
|
||||
"version": "2.0.2-SNAPSHOT",
|
||||
"version": "2.0.3-SNAPSHOT",
|
||||
"description": "The Open MCT core platform",
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.16.3",
|
||||
"@braintree/sanitize-url": "6.0.0",
|
||||
"@percy/cli": "1.0.0-beta.76",
|
||||
"@percy/playwright": "1.0.1",
|
||||
"@percy/cli": "1.0.4",
|
||||
"@percy/playwright": "1.0.2",
|
||||
"@playwright/test": "1.19.2",
|
||||
"@types/eventemitter3": "^1.0.0",
|
||||
"@types/jasmine": "^4.0.1",
|
||||
"@types/karma": "^6.3.2",
|
||||
"@types/lodash": "^4.14.178",
|
||||
"@types/mocha": "^9.1.0",
|
||||
"allure-playwright": "2.0.0-beta.15",
|
||||
"babel-loader": "8.2.3",
|
||||
"babel-plugin-istanbul": "6.1.1",
|
||||
"comma-separated-values": "3.6.4",
|
||||
"copy-webpack-plugin": "10.2.0",
|
||||
"core-js": "3.21.1",
|
||||
"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",
|
||||
"eslint": "8.11.0",
|
||||
"eslint": "8.13.0",
|
||||
"eslint-plugin-compat": "4.0.2",
|
||||
"eslint-plugin-playwright": "0.8.0",
|
||||
"eslint-plugin-vue": "8.5.0",
|
||||
@@ -27,15 +31,13 @@
|
||||
"eventemitter3": "1.2.0",
|
||||
"exports-loader": "0.7.0",
|
||||
"express": "4.13.1",
|
||||
"file-loader": "6.2.0",
|
||||
"file-saver": "2.0.5",
|
||||
"git-rev-sync": "3.0.2",
|
||||
"html-loader": "0.5.5",
|
||||
"html2canvas": "1.4.1",
|
||||
"imports-loader": "0.8.0",
|
||||
"jasmine-core": "4.0.1",
|
||||
"jsdoc": "3.5.5",
|
||||
"karma": "6.3.15",
|
||||
"karma": "6.3.18",
|
||||
"karma-chrome-launcher": "3.1.1",
|
||||
"karma-cli": "2.0.0",
|
||||
"karma-coverage": "2.1.1",
|
||||
@@ -44,40 +46,40 @@
|
||||
"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.12",
|
||||
"mini-css-extract-plugin": "2.4.5",
|
||||
"lodash": "4.17.21",
|
||||
"mini-css-extract-plugin": "2.6.0",
|
||||
"moment": "2.29.1",
|
||||
"moment-duration-format": "2.2.2",
|
||||
"moment-timezone": "0.5.28",
|
||||
"moment-duration-format": "2.3.2",
|
||||
"moment-timezone": "0.5.34",
|
||||
"node-bourbon": "4.2.3",
|
||||
"painterro": "1.2.56",
|
||||
"plotly.js-basic-dist": "2.5.0",
|
||||
"plotly.js-gl2d-dist": "2.5.0",
|
||||
"printj": "1.3.1",
|
||||
"raw-loader": "4.0.2",
|
||||
"request": "2.88.2",
|
||||
"resolve-url-loader": "4.0.0",
|
||||
"sass": "1.49.0",
|
||||
"sass-loader": "12.4.0",
|
||||
"resolve-url-loader": "5.0.0",
|
||||
"sass": "1.49.9",
|
||||
"sass-loader": "12.6.0",
|
||||
"sinon": "13.0.1",
|
||||
"style-loader": "^1.0.1",
|
||||
"uuid": "3.3.3",
|
||||
"vue": "2.6.14",
|
||||
"vue-eslint-parser": "8.2.0",
|
||||
"vue-eslint-parser": "8.3.0",
|
||||
"vue-loader": "15.9.8",
|
||||
"vue-template-compiler": "2.6.14",
|
||||
"webpack": "5.68.0",
|
||||
"webpack-cli": "4.9.2",
|
||||
"webpack-dev-middleware": "3.7.3",
|
||||
"webpack-dev-middleware": "5.3.1",
|
||||
"webpack-hot-middleware": "2.25.1",
|
||||
"webpack-merge": "5.8.0",
|
||||
"zepto": "1.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf ./dist ./node_modules; rm package-lock.json",
|
||||
"clean": "rm -rf ./dist ./node_modules ./package-lock.json",
|
||||
"clean-test-lint": "npm run clean; npm install; npm run test; npm run lint",
|
||||
"start": "node app.js",
|
||||
"lint": "eslint example src --ext .js,.vue openmct.js",
|
||||
@@ -92,6 +94,7 @@
|
||||
"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:local": "npx playwright test --config=e2e/playwright-local.config.js --project=chrome",
|
||||
"test:e2e:debug": "npm run test:e2e:local -- --debug",
|
||||
"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",
|
||||
@@ -107,7 +110,10 @@
|
||||
"url": "https://github.com/nasa/openmct.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
"node": ">=14.19.1"
|
||||
},
|
||||
"overrides": {
|
||||
"core-js": "3.21.1"
|
||||
},
|
||||
"browserslist": [
|
||||
"Firefox ESR",
|
||||
|
||||
@@ -242,6 +242,7 @@ define([
|
||||
|
||||
// Plugins that are installed by default
|
||||
|
||||
this.install(this.plugins.Gauge());
|
||||
this.install(this.plugins.Plot());
|
||||
this.install(this.plugins.Chart());
|
||||
this.install(this.plugins.TelemetryTable.default());
|
||||
|
||||
@@ -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 {
|
||||
@@ -65,10 +69,11 @@ export default class FormControl {
|
||||
*/
|
||||
_getControlViewProvider(control) {
|
||||
const self = this;
|
||||
let rowComponent;
|
||||
|
||||
return {
|
||||
show(element, model, onChange) {
|
||||
const rowComponent = new Vue({
|
||||
rowComponent = new Vue({
|
||||
el: element,
|
||||
components: {
|
||||
FormControlComponent: DEFAULT_CONTROLS_MAP[control]
|
||||
@@ -86,8 +91,10 @@ export default class FormControl {
|
||||
});
|
||||
|
||||
return rowComponent;
|
||||
},
|
||||
destroy() {
|
||||
rowComponent.$destroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -22,17 +22,19 @@
|
||||
|
||||
<template>
|
||||
<div class="form-control autocomplete">
|
||||
<input
|
||||
v-model="field"
|
||||
class="autocompleteInput"
|
||||
type="text"
|
||||
@click="inputClicked()"
|
||||
@keydown="keyDown($event)"
|
||||
>
|
||||
<span
|
||||
class="icon-arrow-down"
|
||||
@click="arrowClicked()"
|
||||
></span>
|
||||
<span class="autocompleteInputAndArrow">
|
||||
<input
|
||||
v-model="field"
|
||||
class="autocompleteInput"
|
||||
type="text"
|
||||
@click="inputClicked()"
|
||||
@keydown="keyDown($event)"
|
||||
>
|
||||
<span
|
||||
class="icon-arrow-down"
|
||||
@click="arrowClicked()"
|
||||
></span>
|
||||
</span>
|
||||
<div
|
||||
class="autocompleteOptions"
|
||||
@blur="hideOptions = true"
|
||||
@@ -108,10 +110,21 @@ export default {
|
||||
|
||||
this.$emit('onChange', data);
|
||||
}
|
||||
},
|
||||
hideOptions(newValue) {
|
||||
if (!newValue) {
|
||||
// adding a event listener when the hideOpntions is false (dropdown is visible)
|
||||
// handleoutsideclick can collapse the dropdown when clicked outside autocomplete
|
||||
document.body.addEventListener('click', this.handleOutsideClick);
|
||||
} else {
|
||||
//removing event listener when hideOptions become true (dropdown is collapsed)
|
||||
document.body.removeEventListener('click', this.handleOutsideClick);
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.options = this.model.options;
|
||||
this.autocompleteInputAndArrow = this.$el.getElementsByClassName('autocompleteInputAndArrow')[0];
|
||||
this.autocompleteInputElement = this.$el.getElementsByClassName('autocompleteInput')[0];
|
||||
if (this.options[0].name) {
|
||||
// If "options" include name, value pair
|
||||
@@ -123,6 +136,9 @@ export default {
|
||||
this.optionNames = this.options;
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
document.body.removeEventListener('click', this.handleOutsideClick);
|
||||
},
|
||||
methods: {
|
||||
decrementOptionIndex() {
|
||||
if (this.optionIndex === 0) {
|
||||
@@ -176,7 +192,21 @@ export default {
|
||||
// to show them all the options
|
||||
this.showFilteredOptions = false;
|
||||
this.autocompleteInputElement.select();
|
||||
this.showOptions();
|
||||
|
||||
if (this.hideOptions) {
|
||||
this.showOptions();
|
||||
} else {
|
||||
this.hideOptions = true;
|
||||
}
|
||||
|
||||
},
|
||||
handleOutsideClick(event) {
|
||||
// if click event is detected outside autocomplete (both input & arrow) while the
|
||||
// dropdown is visible, this will collapse the dropdown.
|
||||
const clickedInsideAutocomplete = this.autocompleteInputAndArrow.contains(event.target);
|
||||
if (!clickedInsideAutocomplete && !this.hideOptions) {
|
||||
this.hideOptions = true;
|
||||
}
|
||||
},
|
||||
optionMouseover(optionId) {
|
||||
this.optionIndex = optionId;
|
||||
|
||||
55
src/api/forms/components/controls/CheckBoxField.vue
Normal file
55
src/api/forms/components/controls/CheckBoxField.vue
Normal 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>
|
||||
@@ -58,7 +58,6 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
updateText() {
|
||||
console.log('updateText', this.field);
|
||||
const data = {
|
||||
model: this.model,
|
||||
value: this.field
|
||||
|
||||
62
src/api/forms/components/controls/ToggleSwitchField.vue
Normal file
62
src/api/forms/components/controls/ToggleSwitchField.vue
Normal 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>
|
||||
19
src/api/forms/toggle-check-box-mixin.js
Normal file
19
src/api/forms/toggle-check-box-mixin.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -172,6 +172,7 @@ export class TelemetryCollection extends EventEmitter {
|
||||
* @private
|
||||
*/
|
||||
_processNewTelemetry(telemetryData) {
|
||||
performance.mark('tlm:process:start');
|
||||
if (telemetryData === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -184,8 +185,8 @@ export class TelemetryCollection extends EventEmitter {
|
||||
|
||||
for (let datum of data) {
|
||||
parsedValue = this.parseTime(datum);
|
||||
beforeStartOfBounds = parsedValue < this.lastBounds.start;
|
||||
afterEndOfBounds = parsedValue > this.lastBounds.end;
|
||||
beforeStartOfBounds = parsedValue <= this.lastBounds.start;
|
||||
afterEndOfBounds = parsedValue >= this.lastBounds.end;
|
||||
|
||||
if (!afterEndOfBounds && !beforeStartOfBounds) {
|
||||
let isDuplicate = false;
|
||||
@@ -352,6 +353,7 @@ export class TelemetryCollection extends EventEmitter {
|
||||
* @todo handle subscriptions more granually
|
||||
*/
|
||||
_reset() {
|
||||
performance.mark('tlm:reset');
|
||||
this.boundedTelemetry = [];
|
||||
this.futureBuffer = [];
|
||||
|
||||
|
||||
@@ -365,3 +365,7 @@ class TimeContext extends EventEmitter {
|
||||
}
|
||||
|
||||
export default TimeContext;
|
||||
|
||||
/**
|
||||
@typedef {{start: number, end: number}} Bounds
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="c-table c-table--sortable c-list-view">
|
||||
<div class="c-table c-table--sortable c-list-view c-list-view--sticky-header c-list-view--selectable">
|
||||
<table class="c-table__body">
|
||||
<thead class="c-table__header">
|
||||
<tr>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/******************************* LIST VIEW */
|
||||
.c-list-view {
|
||||
overflow-x: auto !important;
|
||||
overflow-y: auto;
|
||||
|
||||
tbody tr {
|
||||
background: $colorListItemBg;
|
||||
transition: $transOut;
|
||||
}
|
||||
|
||||
body.desktop & {
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $colorListItemBgHov;
|
||||
filter: $filterHov;
|
||||
transition: $transIn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
$p: floor($interiorMargin * 1.5);
|
||||
@include ellipsize();
|
||||
line-height: 120%; // Needed for icon alignment
|
||||
max-width: 0;
|
||||
padding-top: $p;
|
||||
padding-bottom: $p;
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
199
src/plugins/gauge/GaugePlugin.js
Normal file
199
src/plugins/gauge/GaugePlugin.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
805
src/plugins/gauge/GaugePluginSpec.js
Normal file
805
src/plugins/gauge/GaugePluginSpec.js
Normal file
@@ -0,0 +1,805 @@
|
||||
/*****************************************************************************
|
||||
* 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.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('.c-gauge');
|
||||
expect(gaugeElement.length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders major elements', () => {
|
||||
const wrapperElement = gaugeHolder.querySelector('.c-gauge__wrapper');
|
||||
const rangeElement = gaugeHolder.querySelector('.c-gauge__range');
|
||||
const curveElement = gaugeHolder.querySelector('.c-gauge__curval');
|
||||
const dialElement = gaugeHolder.querySelector('.c-dial');
|
||||
|
||||
const hasMajorElements = Boolean(wrapperElement && rangeElement && curveElement && dialElement);
|
||||
|
||||
expect(hasMajorElements).toBe(true);
|
||||
});
|
||||
|
||||
it('renders correct min max values', () => {
|
||||
expect(gaugeHolder.querySelector('.c-gauge__range').textContent).toEqual(`${minValue} ${maxValue}`);
|
||||
});
|
||||
|
||||
it('renders correct current value', (done) => {
|
||||
function WatchUpdateValue() {
|
||||
const textElement = gaugeHolder.querySelector('.c-gauge__curval-text');
|
||||
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('.c-gauge');
|
||||
expect(gaugeElement.length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders major elements', () => {
|
||||
const wrapperElement = gaugeHolder.querySelector('.c-gauge__wrapper');
|
||||
const rangeElement = gaugeHolder.querySelector('.c-gauge__range');
|
||||
const curveElement = gaugeHolder.querySelector('.c-gauge__curval');
|
||||
const dialElement = gaugeHolder.querySelector('.c-dial');
|
||||
|
||||
const hasMajorElements = Boolean(wrapperElement && rangeElement && curveElement && dialElement);
|
||||
|
||||
expect(hasMajorElements).toBe(true);
|
||||
});
|
||||
|
||||
it('renders correct min max values', () => {
|
||||
expect(gaugeHolder.querySelector('.c-gauge__range').textContent).toEqual(`${minValue} ${maxValue}`);
|
||||
});
|
||||
|
||||
it('renders correct current value', (done) => {
|
||||
function WatchUpdateValue() {
|
||||
const textElement = gaugeHolder.querySelector('.c-gauge__curval-text');
|
||||
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('.c-gauge');
|
||||
expect(gaugeElement.length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders major elements', () => {
|
||||
const wrapperElement = gaugeHolder.querySelector('.c-gauge__wrapper');
|
||||
const rangeElement = gaugeHolder.querySelector('.c-gauge__range');
|
||||
const curveElement = gaugeHolder.querySelector('.c-meter');
|
||||
const dialElement = gaugeHolder.querySelector('.c-meter__bg');
|
||||
|
||||
const hasMajorElements = Boolean(wrapperElement && rangeElement && curveElement && dialElement);
|
||||
|
||||
expect(hasMajorElements).toBe(true);
|
||||
});
|
||||
|
||||
it('renders correct min max values', () => {
|
||||
expect(gaugeHolder.querySelector('.c-gauge__range').textContent).toEqual(`${maxValue} ${minValue}`);
|
||||
});
|
||||
|
||||
it('renders correct current value', (done) => {
|
||||
function WatchUpdateValue() {
|
||||
const textElement = gaugeHolder.querySelector('.c-gauge__curval-text');
|
||||
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('.c-gauge');
|
||||
expect(gaugeElement.length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders major elements', () => {
|
||||
const wrapperElement = gaugeHolder.querySelector('.c-gauge__wrapper');
|
||||
const rangeElement = gaugeHolder.querySelector('.c-gauge__range');
|
||||
const curveElement = gaugeHolder.querySelector('.c-meter');
|
||||
const dialElement = gaugeHolder.querySelector('.c-meter__bg');
|
||||
|
||||
const hasMajorElements = Boolean(wrapperElement && rangeElement && curveElement && dialElement);
|
||||
|
||||
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('.c-gauge');
|
||||
expect(gaugeElement.length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders major elements', () => {
|
||||
const wrapperElement = gaugeHolder.querySelector('.c-gauge__wrapper');
|
||||
const rangeElement = gaugeHolder.querySelector('.c-gauge__range');
|
||||
const curveElement = gaugeHolder.querySelector('.c-meter');
|
||||
const dialElement = gaugeHolder.querySelector('.c-meter__bg');
|
||||
|
||||
const hasMajorElements = Boolean(wrapperElement && rangeElement && curveElement && dialElement);
|
||||
|
||||
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('.c-gauge');
|
||||
expect(gaugeElement.length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders major elements', () => {
|
||||
const wrapperElement = gaugeHolder.querySelector('.c-gauge__wrapper');
|
||||
const rangeElement = gaugeHolder.querySelector('.c-gauge__range');
|
||||
const curveElement = gaugeHolder.querySelector('.c-gauge__curval');
|
||||
const dialElement = gaugeHolder.querySelector('.c-dial');
|
||||
|
||||
const hasMajorElements = Boolean(wrapperElement && rangeElement && curveElement && dialElement);
|
||||
|
||||
expect(hasMajorElements).toBe(true);
|
||||
});
|
||||
|
||||
it('renders correct min max values', () => {
|
||||
expect(gaugeHolder.querySelector('.c-gauge__range').textContent).toEqual(`${gaugeViewObject.configuration.gaugeController.min} ${gaugeViewObject.configuration.gaugeController.max}`);
|
||||
});
|
||||
|
||||
it('renders correct current value', (done) => {
|
||||
function WatchUpdateValue() {
|
||||
const textElement = gaugeHolder.querySelector('.c-gauge__curval-text');
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
src/plugins/gauge/GaugeViewProvider.js
Normal file
67
src/plugins/gauge/GaugeViewProvider.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
467
src/plugins/gauge/components/Gauge.vue
Normal file
467
src/plugins/gauge/components/Gauge.vue
Normal file
@@ -0,0 +1,467 @@
|
||||
/*****************************************************************************
|
||||
* 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"
|
||||
:class="`c-gauge--${gaugeType}`"
|
||||
>
|
||||
<div class="c-gauge__wrapper">
|
||||
<template v-if="typeDial">
|
||||
<svg
|
||||
class="c-gauge__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
|
||||
v-if="displayCurVal"
|
||||
class="c-gauge__curval"
|
||||
:viewBox="curValViewBox"
|
||||
>
|
||||
<text
|
||||
class="c-gauge__curval-text"
|
||||
lengthAdjust="spacing"
|
||||
text-anchor="middle"
|
||||
>{{ curVal }}</text>
|
||||
</svg>
|
||||
|
||||
<div class="c-dial">
|
||||
<svg
|
||||
class="c-dial__bg"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path d="M256,0C114.6,0,0,114.6,0,256S114.6,512,256,512,512,397.4,512,256,397.4,0,256,0Zm0,412A156,156,0,1,1,412,256,155.9,155.9,0,0,1,256,412Z" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-if="limitHigh && dialHighLimitDeg < 270"
|
||||
class="c-dial__limit-high"
|
||||
viewBox="0 0 512 512"
|
||||
:class="{
|
||||
'c-high-limit-clip--90': dialHighLimitDeg > 90,
|
||||
'c-high-limit-clip--180': dialHighLimitDeg >= 180
|
||||
}"
|
||||
>
|
||||
<path
|
||||
d="M100,256A156,156,0,1,1,366.3,366.3L437,437a255.2,255.2,0,0,0,75-181C512,114.6,397.4,0,256,0S0,114.6,0,256A255.2,255.2,0,0,0,75,437l70.7-70.7A155.5,155.5,0,0,1,100,256Z"
|
||||
:style="`transform: rotate(${dialHighLimitDeg}deg)`"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-if="limitLow && dialLowLimitDeg < 270"
|
||||
class="c-dial__limit-low"
|
||||
viewBox="0 0 512 512"
|
||||
:class="{
|
||||
'c-dial-clip--90': dialLowLimitDeg < 90,
|
||||
'c-dial-clip--180': dialLowLimitDeg >= 90 && dialLowLimitDeg < 180
|
||||
}"
|
||||
>
|
||||
<path
|
||||
d="M256,100c86.2,0,156,69.8,156,156s-69.8,156-156,156c-43.1,0-82.1-17.5-110.3-45.7L75,437 c46.3,46.3,110.3,75,181,75c141.4,0,256-114.6,256-256S397.4,0,256,0C185.3,0,121.3,28.7,75,75l70.7,70.7 C173.9,117.5,212.9,100,256,100z"
|
||||
:style="`transform: rotate(${dialLowLimitDeg}deg)`"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
class="c-dial__value"
|
||||
viewBox="0 0 512 512"
|
||||
:class="{
|
||||
'c-dial-clip--90': degValue < 90 && typeFilledDial,
|
||||
'c-dial-clip--180': degValue >= 90 && degValue < 180 && typeFilledDial
|
||||
}"
|
||||
>
|
||||
<path
|
||||
v-if="typeFilledDial && degValue > 0"
|
||||
d="M256,31A224.3,224.3,0,0,0,98.3,95.5l48.4,49.2a156,156,0,1,1-1,221.6L96.9,415.1A224.4,224.4,0,0,0,256,481c124.3,0,225-100.7,225-225S380.3,31,256,31Z"
|
||||
:style="`transform: rotate(${degValue}deg)`"
|
||||
/>
|
||||
<path
|
||||
v-if="typeNeedleDial && valueInBounds"
|
||||
d="M256,86c-93.9,0-170,76.1-170,170c0,43.9,16.6,83.9,43.9,114.1l-38.7,38.7c-3.3,3.3-3.3,8.7,0,12s8.7,3.3,12,0 l38.7-38.7C172.1,409.4,212.1,426,256,426c93.9,0,170-76.1,170-170S349.9,86,256,86z M256,411.7c-86,0-155.7-69.7-155.7-155.7 S170,100.3,256,100.3S411.7,170,411.7,256S342,411.7,256,411.7z"
|
||||
:style="`transform: rotate(${degValue}deg)`"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="typeMeter">
|
||||
<div class="c-meter">
|
||||
<div
|
||||
v-if="displayMinMax"
|
||||
class="c-gauge__range c-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 && meterHighLimitPerc > 0"
|
||||
class="c-meter__limit-high"
|
||||
:style="`height: ${meterHighLimitPerc}%`"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="limitLow && 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 && meterHighLimitPerc > 0"
|
||||
class="c-meter__limit-high"
|
||||
:style="`width: ${meterHighLimitPerc}%`"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="limitLow && meterLowLimitPerc > 0"
|
||||
class="c-meter__limit-low"
|
||||
:style="`width: ${meterLowLimitPerc}%`"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<svg
|
||||
v-if="displayCurVal"
|
||||
class="c-gauge__curval"
|
||||
:viewBox="curValViewBox"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<text
|
||||
class="c-gauge__curval-text"
|
||||
text-anchor="middle"
|
||||
lengthAdjust="spacing"
|
||||
>{{ curVal }}</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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));
|
||||
},
|
||||
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: {
|
||||
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, 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) {
|
||||
// Don't peg at 100% if the gaugeType isn't a filled shape
|
||||
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>
|
||||
170
src/plugins/gauge/components/GaugeFormController.vue
Normal file
170
src/plugins/gauge/components/GaugeFormController.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
/*****************************************************************************
|
||||
* 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
|
||||
: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>
|
||||
269
src/plugins/gauge/gauge.scss
Normal file
269
src/plugins/gauge/gauge.scss
Normal file
@@ -0,0 +1,269 @@
|
||||
$dialClip: polygon(0 0, 100% 0, 100% 100%, 50% 50%, 0 100%);
|
||||
$dialClip90: polygon(0 0, 50% 50%, 0 100%);
|
||||
$dialClip180: polygon(0 0, 100% 0, 0 100%);
|
||||
$limitHighClip90: polygon(0 0, 100% 0, 100% 100%);
|
||||
$limitHighClip180: polygon(100% 0, 100% 100%, 0 100%);
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
svg {
|
||||
path {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
&.c-gauge__curval {
|
||||
@include abs();
|
||||
fill: $colorGaugeTextValue;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
|
||||
.c-gauge__curval-text {
|
||||
font-family: $heroFont;
|
||||
transform: translate(50%, 75%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[class*='dial'] {
|
||||
// Square aspect ratio
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
|
||||
&[class*='meter'] {
|
||||
@include abs();
|
||||
}
|
||||
}
|
||||
|
||||
/********************************************** DIAL GAUGE */
|
||||
.c-dial {
|
||||
// Dial elements
|
||||
@include abs();
|
||||
clip-path: $dialClip;
|
||||
|
||||
svg,
|
||||
&__ticks,
|
||||
&__bg,
|
||||
&[class*='__limit'],
|
||||
&__value {
|
||||
@include abs();
|
||||
}
|
||||
|
||||
.c-high-limit-clip--90 {
|
||||
clip-path: $limitHighClip90;
|
||||
}
|
||||
|
||||
.c-high-limit-clip--180 {
|
||||
clip-path: $limitHighClip180;
|
||||
}
|
||||
|
||||
&__limit-high path { fill: $colorGaugeLimitHigh; }
|
||||
&__limit-low path { fill: $colorGaugeLimitLow; }
|
||||
|
||||
&__value,
|
||||
&__limit-low {
|
||||
&.c-dial-clip--90 {
|
||||
clip-path: $dialClip90;
|
||||
}
|
||||
|
||||
&.c-dial-clip--180 {
|
||||
clip-path: $dialClip180;
|
||||
}
|
||||
}
|
||||
|
||||
&__value {
|
||||
path,
|
||||
polygon {
|
||||
fill: $colorGaugeValue;
|
||||
}
|
||||
}
|
||||
|
||||
&__bg {
|
||||
path {
|
||||
fill: $colorGaugeBg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.c-gauge--dial-needle .c-dial__value {
|
||||
path {
|
||||
transition: transform $transitionTimeGauge;
|
||||
}
|
||||
}
|
||||
|
||||
/********************************************** METER GAUGE */
|
||||
.c-meter {
|
||||
// Common styles for c-meter
|
||||
@include abs();
|
||||
display: flex;
|
||||
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,16 @@ import ImageryViewComponent from './components/ImageryView.vue';
|
||||
|
||||
import Vue from 'vue';
|
||||
|
||||
const DEFAULT_IMAGE_FRESHNESS_OPTIONS = {
|
||||
fadeOutDelayTime: '0s',
|
||||
fadeOutDurationTime: '30s'
|
||||
};
|
||||
export default class ImageryView {
|
||||
constructor(openmct, domainObject, objectPath) {
|
||||
constructor(openmct, domainObject, objectPath, options) {
|
||||
this.openmct = openmct;
|
||||
this.domainObject = domainObject;
|
||||
this.objectPath = objectPath;
|
||||
this.options = options;
|
||||
this.component = undefined;
|
||||
}
|
||||
|
||||
@@ -27,6 +32,7 @@ export default class ImageryView {
|
||||
openmct: this.openmct,
|
||||
domainObject: this.domainObject,
|
||||
objectPath: alternateObjectPath || this.objectPath,
|
||||
imageFreshnessOptions: this.options?.imageFreshness || DEFAULT_IMAGE_FRESHNESS_OPTIONS,
|
||||
currentView: this
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
*****************************************************************************/
|
||||
import ImageryView from './ImageryView';
|
||||
|
||||
export default function ImageryViewProvider(openmct) {
|
||||
export default function ImageryViewProvider(openmct, options) {
|
||||
const type = 'example.imagery';
|
||||
|
||||
function hasImageTelemetry(domainObject) {
|
||||
@@ -43,7 +43,7 @@ export default function ImageryViewProvider(openmct) {
|
||||
return hasImageTelemetry(domainObject) && (!isChildOfTimeStrip || openmct.router.isNavigatedObject(objectPath));
|
||||
},
|
||||
view: function (domainObject, objectPath) {
|
||||
return new ImageryView(openmct, domainObject, objectPath);
|
||||
return new ImageryView(openmct, domainObject, objectPath, options);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ $elemBg: rgba(black, 0.7);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
@include userSelectNone;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,10 @@
|
||||
<!-- image fresh -->
|
||||
<div
|
||||
v-if="canTrackDuration"
|
||||
:style="{
|
||||
'animation-delay': imageFreshnessOptions.fadeOutDelayTime,
|
||||
'animation-duration': imageFreshnessOptions.fadeOutDurationTime
|
||||
}"
|
||||
:class="{'c-imagery--new': isImageNew && !refreshCSS}"
|
||||
class="c-imagery__age icon-timer"
|
||||
>{{ formattedDuration }}</div>
|
||||
@@ -159,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
|
||||
@@ -175,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
|
||||
@@ -228,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: {
|
||||
@@ -235,7 +245,7 @@ export default {
|
||||
ImageControls
|
||||
},
|
||||
mixins: [imageryData],
|
||||
inject: ['openmct', 'domainObject', 'objectPath', 'currentView'],
|
||||
inject: ['openmct', 'domainObject', 'objectPath', 'currentView', 'imageFreshnessOptions'],
|
||||
props: {
|
||||
focusedImageTimestamp: {
|
||||
type: Number,
|
||||
@@ -268,6 +278,7 @@ export default {
|
||||
imageContainerHeight: undefined,
|
||||
sizedImageWidth: 0,
|
||||
sizedImageHeight: 0,
|
||||
viewHeight: 0,
|
||||
lockCompass: true,
|
||||
resizingWindow: false,
|
||||
timeContext: undefined,
|
||||
@@ -286,7 +297,8 @@ export default {
|
||||
imageTranslateY: 0,
|
||||
pan: undefined,
|
||||
animateZoom: true,
|
||||
imagePanned: false
|
||||
imagePanned: false,
|
||||
forceShowThumbnails: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -302,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);
|
||||
},
|
||||
@@ -532,7 +553,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);
|
||||
@@ -579,6 +600,9 @@ export default {
|
||||
|
||||
},
|
||||
methods: {
|
||||
calculateViewHeight() {
|
||||
this.viewHeight = this.$el.clientHeight;
|
||||
},
|
||||
setTimeContext() {
|
||||
this.stopFollowingTimeContext();
|
||||
this.timeContext = this.openmct.time.getContextForView(this.objectPath);
|
||||
@@ -952,6 +976,7 @@ export default {
|
||||
}
|
||||
|
||||
this.setSizedImageDimensions();
|
||||
this.calculateViewHeight();
|
||||
},
|
||||
setSizedImageDimensions() {
|
||||
this.focusedImageNaturalAspectRatio = this.$refs.focusedImage.naturalWidth / this.$refs.focusedImage.naturalHeight;
|
||||
@@ -980,6 +1005,8 @@ export default {
|
||||
this.scrollToRight('reset');
|
||||
}
|
||||
|
||||
this.calculateViewHeight();
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.resizingWindow = false;
|
||||
});
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
background-color: rgba($colorOk, 0.5);
|
||||
}
|
||||
to {
|
||||
background-color: rgba($colorOk, 0);
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.c-imagery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -123,12 +133,17 @@
|
||||
// New imagery
|
||||
$bgColor: $colorOk;
|
||||
color: $colorOkFg;
|
||||
background: rgba($bgColor, 0.5);
|
||||
@include flash($animName: flashImageAge, $iter: 2, $dur: 250ms, $valStart: rgba($colorOk, 0.7), $valEnd: rgba($colorOk, 0));
|
||||
background-color: rgba($bgColor, 0.5);
|
||||
animation-name: fade-out;
|
||||
animation-timing-function: ease-in;
|
||||
animation-iteration-count: 1;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
|
||||
&__thumbs-wrapper {
|
||||
display: flex; // Uses row layout
|
||||
justify-content: flex-end;
|
||||
|
||||
&.is-autoscroll-off {
|
||||
background: $colorInteriorBorder;
|
||||
@@ -146,17 +161,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 {
|
||||
@@ -169,10 +178,12 @@
|
||||
|
||||
/*************************************** THUMBS */
|
||||
.c-thumb {
|
||||
$w: $imageThumbsD;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
width: $imageThumbsD;
|
||||
min-width: $w;
|
||||
width: $w;
|
||||
|
||||
&:hover {
|
||||
background: $colorThumbHoverBg;
|
||||
@@ -194,11 +205,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: $imageThumbsD / 2;
|
||||
min-width: $w;
|
||||
width: $w;
|
||||
|
||||
&__timestamp {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +226,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;
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
import ImageryViewProvider from './ImageryViewProvider';
|
||||
import ImageryTimestripViewProvider from './ImageryTimestripViewProvider';
|
||||
|
||||
export default function () {
|
||||
export default function (options) {
|
||||
return function install(openmct) {
|
||||
openmct.objectViews.addProvider(new ImageryViewProvider(openmct));
|
||||
openmct.objectViews.addProvider(new ImageryViewProvider(openmct, options));
|
||||
openmct.objectViews.addProvider(new ImageryTimestripViewProvider(openmct));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
ref="searchResults"
|
||||
:domain-object="domainObject"
|
||||
:results="searchResults"
|
||||
@cancelEdit="cancelTransaction"
|
||||
@editingEntry="startTransaction"
|
||||
@changeSectionPage="changeSelectedSection"
|
||||
@updateEntries="updateEntries"
|
||||
/>
|
||||
@@ -140,6 +142,8 @@
|
||||
:selected-page="selectedPage"
|
||||
:selected-section="selectedSection"
|
||||
:read-only="false"
|
||||
@cancelEdit="cancelTransaction"
|
||||
@editingEntry="startTransaction"
|
||||
@deleteEntry="deleteEntry"
|
||||
@updateEntry="updateEntry"
|
||||
/>
|
||||
@@ -710,6 +714,8 @@ export default {
|
||||
notebookEntries[this.selectedSection.id][this.selectedPage.id] = entries;
|
||||
|
||||
mutateObject(this.openmct, this.domainObject, 'configuration.entries', notebookEntries);
|
||||
|
||||
this.saveTransaction();
|
||||
},
|
||||
getPageIdFromUrl() {
|
||||
return this.openmct.router.getParams().pageId;
|
||||
@@ -746,6 +752,39 @@ export default {
|
||||
this.selectPage(pageId);
|
||||
|
||||
this.syncUrlWithPageAndSection();
|
||||
},
|
||||
activeTransaction() {
|
||||
return this.openmct.objects.getActiveTransaction();
|
||||
},
|
||||
startTransaction() {
|
||||
if (!this.openmct.editor.isEditing()) {
|
||||
this.openmct.objects.startTransaction();
|
||||
}
|
||||
},
|
||||
saveTransaction() {
|
||||
const transaction = this.activeTransaction();
|
||||
|
||||
if (!transaction || this.openmct.editor.isEditing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return transaction.commit()
|
||||
.catch(error => {
|
||||
throw error;
|
||||
}).finally(() => {
|
||||
this.openmct.objects.endTransaction();
|
||||
});
|
||||
},
|
||||
cancelTransaction() {
|
||||
if (!this.openmct.editor.isEditing()) {
|
||||
const transaction = this.activeTransaction();
|
||||
transaction.cancel()
|
||||
.catch(error => {
|
||||
throw error;
|
||||
}).finally(() => {
|
||||
this.openmct.objects.endTransaction();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
class="c-ne__text c-ne__input"
|
||||
tabindex="0"
|
||||
contenteditable
|
||||
@focus="editingEntry()"
|
||||
@blur="updateEntryValue($event)"
|
||||
@keydown.enter.exact.prevent
|
||||
@keyup.enter.exact.prevent="forceBlur($event)"
|
||||
@@ -284,11 +285,16 @@ export default {
|
||||
|
||||
this.$emit('updateEntry', this.entry);
|
||||
},
|
||||
editingEntry() {
|
||||
this.$emit('editingEntry');
|
||||
},
|
||||
updateEntryValue($event) {
|
||||
const value = $event.target.innerText;
|
||||
if (value !== this.entry.text && value.match(/\S/)) {
|
||||
this.entry.text = value;
|
||||
this.$emit('updateEntry', this.entry);
|
||||
} else {
|
||||
this.$emit('cancelEdit');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
:read-only="true"
|
||||
:selected-page="result.page"
|
||||
:selected-section="result.section"
|
||||
@editingEntry="editingEntry"
|
||||
@cancelEdit="cancelEdit"
|
||||
@changeSectionPage="changeSectionPage"
|
||||
@updateEntries="updateEntries"
|
||||
/>
|
||||
@@ -63,6 +65,12 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editingEntry() {
|
||||
this.$emit('editingEntry');
|
||||
},
|
||||
cancelEdit() {
|
||||
this.$emit('cancelEdit');
|
||||
},
|
||||
changeSectionPage(data) {
|
||||
this.$emit('changeSectionPage', data);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
@@ -76,6 +77,9 @@
|
||||
<div
|
||||
ref="chartContainer"
|
||||
class="gl-plot-chart-wrapper"
|
||||
:class="[
|
||||
{ 'alt-pressed': altPressed },
|
||||
]"
|
||||
>
|
||||
<mct-chart
|
||||
:rectangles="rectangles"
|
||||
@@ -230,6 +234,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
altPressed: false,
|
||||
highlights: [],
|
||||
lockHighlightPoint: false,
|
||||
tickWidth: 0,
|
||||
@@ -246,7 +251,8 @@ export default {
|
||||
loaded: false,
|
||||
isTimeOutOfSync: false,
|
||||
showLimitLineLabels: undefined,
|
||||
isFrozenOnMouseDown: false
|
||||
isFrozenOnMouseDown: false,
|
||||
hasSameRangeValue: true
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -268,6 +274,8 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.handleKeyDown);
|
||||
document.addEventListener('keyup', this.handleKeyUp);
|
||||
eventHelpers.extend(this);
|
||||
this.updateRealTime = this.updateRealTime.bind(this);
|
||||
this.updateDisplayBounds = this.updateDisplayBounds.bind(this);
|
||||
@@ -299,9 +307,21 @@ export default {
|
||||
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
document.removeEventListener('keyup', this.handleKeyUp);
|
||||
this.destroy();
|
||||
},
|
||||
methods: {
|
||||
handleKeyDown(event) {
|
||||
if (event.key === 'Alt') {
|
||||
this.altPressed = true;
|
||||
}
|
||||
},
|
||||
handleKeyUp(event) {
|
||||
if (event.key === 'Alt') {
|
||||
this.altPressed = false;
|
||||
}
|
||||
},
|
||||
setTimeContext() {
|
||||
this.stopFollowingTimeContext();
|
||||
|
||||
@@ -344,6 +364,7 @@ export default {
|
||||
this.setDisplayRange(series, xKey);
|
||||
}, this);
|
||||
this.listenTo(series, 'change:yKey', () => {
|
||||
this.checkSameRangeValue();
|
||||
this.loadSeriesData(series);
|
||||
}, this);
|
||||
|
||||
@@ -351,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);
|
||||
},
|
||||
|
||||
@@ -470,7 +499,7 @@ export default {
|
||||
},
|
||||
|
||||
setDisplayRange(series, xKey) {
|
||||
if (this.config.series.length !== 1) {
|
||||
if (this.config.series.models.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -643,7 +672,7 @@ export default {
|
||||
this.positionOverElement = {
|
||||
x: event.clientX - this.chartElementBounds.left,
|
||||
y: this.chartElementBounds.height
|
||||
- (event.clientY - this.chartElementBounds.top)
|
||||
- (event.clientY - this.chartElementBounds.top)
|
||||
};
|
||||
|
||||
this.positionOverPlot = {
|
||||
|
||||
@@ -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,
|
||||
@@ -112,9 +119,12 @@ export default {
|
||||
mounted() {
|
||||
eventHelpers.extend(this);
|
||||
|
||||
if (!this.axisType) {
|
||||
throw new Error("axis-type prop expected");
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -126,15 +136,16 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
getAxisFromConfig() {
|
||||
if (!this.axisType) {
|
||||
return;
|
||||
const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
||||
|
||||
/** @type {import('./configuration/PlotConfigurationModel').default} */
|
||||
let config = configStore.get(configId);
|
||||
|
||||
if (!config) {
|
||||
throw new Error('config is missing');
|
||||
}
|
||||
|
||||
const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
||||
let config = configStore.get(configId);
|
||||
if (config) {
|
||||
return config[this.axisType];
|
||||
}
|
||||
return config[this.axisType];
|
||||
},
|
||||
/**
|
||||
* Determine whether ticks should be regenerated for a given range.
|
||||
@@ -179,7 +190,12 @@ 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);
|
||||
// return getLogTicks2(range.min, range.max, number);
|
||||
} else {
|
||||
return ticks(range.min, range.max, number);
|
||||
}
|
||||
},
|
||||
|
||||
updateTicksForceRegeneration() {
|
||||
@@ -210,8 +226,8 @@ export default {
|
||||
if (this.shouldRegenerateTicks(range, forceRegeneration)) {
|
||||
let newTicks = this.getTicks();
|
||||
this.tickRange = {
|
||||
min: Math.min.apply(Math, newTicks),
|
||||
max: Math.max.apply(Math, newTicks),
|
||||
min: Math.min(...newTicks),
|
||||
max: Math.max(...newTicks),
|
||||
step: newTicks[1] - newTicks[0]
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
import eventHelpers from '../lib/eventHelpers';
|
||||
|
||||
export default class MCTChartAlarmLineSet {
|
||||
/**
|
||||
* @param {Bounds} bounds
|
||||
*/
|
||||
constructor(series, chart, offset, bounds) {
|
||||
this.series = series;
|
||||
this.chart = chart;
|
||||
@@ -40,6 +43,9 @@ export default class MCTChartAlarmLineSet {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Bounds} bounds
|
||||
*/
|
||||
updateBounds(bounds) {
|
||||
this.bounds = bounds;
|
||||
this.getLimitPoints(this.series);
|
||||
@@ -106,3 +112,7 @@ export default class MCTChartAlarmLineSet {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@typedef {import('@/api/time/TimeContext').Bounds} Bounds
|
||||
*/
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import MCTChartSeriesElement from './MCTChartSeriesElement';
|
||||
|
||||
export default class MCTChartLineLinear extends MCTChartSeriesElement {
|
||||
addPoint(point, start, count) {
|
||||
addPoint(point, start) {
|
||||
this.buffer[start] = point.x;
|
||||
this.buffer[start + 1] = point.y;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export default class MCTChartLineStepAfter extends MCTChartSeriesElement {
|
||||
return 2 + ((index - 1) * 4);
|
||||
}
|
||||
|
||||
addPoint(point, start, count) {
|
||||
addPoint(point, start) {
|
||||
if (start === 0 && this.count === 0) {
|
||||
// First point is easy.
|
||||
this.buffer[start] = point.x;
|
||||
|
||||
@@ -24,7 +24,7 @@ import MCTChartSeriesElement from './MCTChartSeriesElement';
|
||||
|
||||
// TODO: Is this needed? This is identical to MCTChartLineLinear. Why is it a different class?
|
||||
export default class MCTChartPointSet extends MCTChartSeriesElement {
|
||||
addPoint(point, start, count) {
|
||||
addPoint(point, start) {
|
||||
this.buffer[start] = point.x;
|
||||
this.buffer[start + 1] = point.y;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import eventHelpers from '../lib/eventHelpers';
|
||||
|
||||
/** @abstract */
|
||||
export default class MCTChartSeriesElement {
|
||||
constructor(series, chart, offset) {
|
||||
this.series = series;
|
||||
@@ -72,9 +73,11 @@ export default class MCTChartSeriesElement {
|
||||
}
|
||||
}
|
||||
|
||||
removePoint(point, index, count) {
|
||||
// by default, do nothing.
|
||||
}
|
||||
/** @abstract */
|
||||
removePoint(index) {}
|
||||
|
||||
/** @abstract */
|
||||
addPoint(point, index) {}
|
||||
|
||||
remove(point, index, series) {
|
||||
const vertexCount = this.vertexCountForPointAtIndex(index);
|
||||
@@ -82,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);
|
||||
}
|
||||
|
||||
@@ -106,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);
|
||||
}
|
||||
|
||||
@@ -155,3 +153,18 @@ export default class MCTChartSeriesElement {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/** @typedef {any} TODO */
|
||||
|
||||
/** @typedef {import('../configuration/PlotSeries').default} PlotSeries */
|
||||
|
||||
/**
|
||||
@typedef {{
|
||||
x: (x: number) => number
|
||||
y: (y: number) => number
|
||||
xVal: (point: Point, pSeries: PlotSeries) => number
|
||||
yVal: (point: Point, pSeries: PlotSeries) => number
|
||||
xKey: TODO
|
||||
yKey: TODO
|
||||
}} Offset
|
||||
*/
|
||||
|
||||
@@ -21,11 +21,17 @@
|
||||
*****************************************************************************/
|
||||
import Model from './Model';
|
||||
|
||||
/**
|
||||
* @template {object} T
|
||||
* @template {object} O
|
||||
* @extends {Model<T, O>}
|
||||
*/
|
||||
export default class Collection extends Model {
|
||||
/** @type {Constructor} */
|
||||
modelClass = Model;
|
||||
|
||||
initialize(options) {
|
||||
super.initialize(options);
|
||||
this.modelClass = Model;
|
||||
if (options.models) {
|
||||
this.models = options.models.map(this.modelFn, this);
|
||||
} else {
|
||||
@@ -107,3 +113,7 @@ export default class Collection extends Model {
|
||||
this.stopListening();
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {any} TODO */
|
||||
|
||||
/** @typedef {new (...args: any[]) => object} Constructor */
|
||||
|
||||
@@ -19,32 +19,47 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
function ConfigStore() {
|
||||
this.store = {};
|
||||
}
|
||||
class ConfigStore {
|
||||
/** @type {Record<string, Destroyable>} */
|
||||
store = {};
|
||||
|
||||
ConfigStore.prototype.deleteStore = function (id) {
|
||||
if (this.store[id]) {
|
||||
if (this.store[id].destroy) {
|
||||
this.store[id].destroy();
|
||||
/**
|
||||
@param {string} id
|
||||
*/
|
||||
deleteStore(id) {
|
||||
const obj = this.store[id];
|
||||
|
||||
if (obj) {
|
||||
if (obj.destroy) {
|
||||
obj.destroy();
|
||||
}
|
||||
|
||||
delete this.store[id];
|
||||
}
|
||||
|
||||
delete this.store[id];
|
||||
}
|
||||
};
|
||||
|
||||
ConfigStore.prototype.deleteAll = function () {
|
||||
Object.keys(this.store).forEach(id => this.deleteStore(id));
|
||||
};
|
||||
deleteAll() {
|
||||
Object.keys(this.store).forEach(id => this.deleteStore(id));
|
||||
}
|
||||
|
||||
ConfigStore.prototype.add = function (id, config) {
|
||||
this.store[id] = config;
|
||||
};
|
||||
/**
|
||||
@param {string} id
|
||||
@param {any} config
|
||||
*/
|
||||
add(id, config) {
|
||||
this.store[id] = config;
|
||||
}
|
||||
|
||||
ConfigStore.prototype.get = function (id) {
|
||||
return this.store[id];
|
||||
};
|
||||
/**
|
||||
@param {string} id
|
||||
*/
|
||||
get(id) {
|
||||
return this.store[id];
|
||||
}
|
||||
}
|
||||
|
||||
const STORE = new ConfigStore();
|
||||
|
||||
export default STORE;
|
||||
|
||||
/** @typedef {{destroy?(): void}} Destroyable */
|
||||
|
||||
@@ -42,7 +42,10 @@ export default class LegendModel extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
defaults(options) {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
defaultModel(options) {
|
||||
return {
|
||||
position: 'top',
|
||||
expandByDefault: false,
|
||||
|
||||
@@ -20,11 +20,18 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import EventEmitter from 'EventEmitter';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import eventHelpers from "../lib/eventHelpers";
|
||||
import _ from 'lodash';
|
||||
|
||||
/**
|
||||
* @template {object} T
|
||||
* @template {object} O
|
||||
*/
|
||||
export default class Model extends EventEmitter {
|
||||
/**
|
||||
* @param {ModelOptions<T, O>} options
|
||||
*/
|
||||
constructor(options) {
|
||||
super();
|
||||
|
||||
@@ -35,10 +42,14 @@ export default class Model extends EventEmitter {
|
||||
options = {};
|
||||
}
|
||||
|
||||
// FIXME: this.id is defined as a method further below, but here it is
|
||||
// assigned a possibly-undefined value. Is this code unused?
|
||||
this.id = options.id;
|
||||
|
||||
/** @type {ModelType<T>} */
|
||||
this.model = options.model;
|
||||
this.collection = options.collection;
|
||||
const defaults = this.defaults(options);
|
||||
const defaults = this.defaultModel(options);
|
||||
if (!this.model) {
|
||||
this.model = options.model = defaults;
|
||||
} else {
|
||||
@@ -46,14 +57,24 @@ export default class Model extends EventEmitter {
|
||||
}
|
||||
|
||||
this.initialize(options);
|
||||
|
||||
/** @type {keyof ModelType<T> } */
|
||||
this.idAttr = 'id';
|
||||
}
|
||||
|
||||
defaults(options) {
|
||||
/**
|
||||
* @param {ModelOptions<T, O>} options
|
||||
* @returns {ModelType<T>}
|
||||
*/
|
||||
defaultModel(options) {
|
||||
return {};
|
||||
}
|
||||
|
||||
initialize(model) {
|
||||
/**
|
||||
* @abstract
|
||||
* @param {ModelOptions<T, O>} options
|
||||
*/
|
||||
initialize(options) {
|
||||
|
||||
}
|
||||
|
||||
@@ -69,14 +90,29 @@ export default class Model extends EventEmitter {
|
||||
return this.get(this.idAttr);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {keyof ModelType<T>} K
|
||||
* @param {K} attribute
|
||||
* @returns {ModelType<T>[K]}
|
||||
*/
|
||||
get(attribute) {
|
||||
return this.model[attribute];
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {keyof ModelType<T>} K
|
||||
* @param {K} attribute
|
||||
* @returns boolean
|
||||
*/
|
||||
has(attribute) {
|
||||
return _.has(this.model, attribute);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {keyof ModelType<T>} K
|
||||
* @param {K} attribute
|
||||
* @param {ModelType<T>[K]} value
|
||||
*/
|
||||
set(attribute, value) {
|
||||
const oldValue = this.model[attribute];
|
||||
this.model[attribute] = value;
|
||||
@@ -84,6 +120,10 @@ export default class Model extends EventEmitter {
|
||||
this.emit('change:' + attribute, value, oldValue, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {keyof ModelType<T>} K
|
||||
* @param {K} attribute
|
||||
*/
|
||||
unset(attribute) {
|
||||
const oldValue = this.model[attribute];
|
||||
delete this.model[attribute];
|
||||
@@ -91,3 +131,26 @@ export default class Model extends EventEmitter {
|
||||
this.emit('change:' + attribute, undefined, oldValue, this);
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {any} TODO */
|
||||
|
||||
/** @typedef {TODO} OpenMCT */
|
||||
|
||||
/**
|
||||
@template {object} T
|
||||
@typedef {{
|
||||
id?: string
|
||||
} & T} ModelType
|
||||
*/
|
||||
|
||||
/**
|
||||
@template {object} T
|
||||
@template {object} O
|
||||
@typedef {{
|
||||
model?: ModelType<T>
|
||||
models?: T[]
|
||||
openmct: OpenMCT
|
||||
id?: string
|
||||
[k: string]: unknown
|
||||
} & O} ModelOptions
|
||||
*/
|
||||
|
||||
@@ -26,20 +26,30 @@ import SeriesCollection from "./SeriesCollection";
|
||||
import XAxisModel from "./XAxisModel";
|
||||
import YAxisModel from "./YAxisModel";
|
||||
import LegendModel from "./LegendModel";
|
||||
|
||||
/**
|
||||
* PlotConfiguration model stores the configuration of a plot and some
|
||||
* limited state. The indiidual parts of the plot configuration model
|
||||
* handle setting defaults and updating in response to various changes.
|
||||
*
|
||||
* @extends {Model<PlotConfigModelType, PlotConfigModelOptions>}
|
||||
*/
|
||||
export default class PlotConfigurationModel extends Model {
|
||||
/**
|
||||
* Initializes all sub models and then passes references to submodels
|
||||
* to those that need it.
|
||||
*
|
||||
* @override
|
||||
* @param {import('./Model').ModelOptions<PlotConfigModelType, PlotConfigModelOptions>} options
|
||||
*/
|
||||
initialize(options) {
|
||||
this.openmct = options.openmct;
|
||||
|
||||
// This is a type assertion for TypeScript, this error is never thrown in practice.
|
||||
if (!options.model) {
|
||||
throw new Error('Not a collection model.');
|
||||
}
|
||||
|
||||
this.xAxis = new XAxisModel({
|
||||
model: options.model.xAxis,
|
||||
plot: this,
|
||||
@@ -76,6 +86,8 @@ export default class PlotConfigurationModel extends Model {
|
||||
}
|
||||
/**
|
||||
* Retrieve the persisted series config for a given identifier.
|
||||
* @param {import('./PlotSeries').Identifier} identifier
|
||||
* @returns {import('./PlotSeries').PlotSeriesModelType=}
|
||||
*/
|
||||
getPersistedSeriesConfig(identifier) {
|
||||
const domainObject = this.get('domainObject');
|
||||
@@ -123,15 +135,49 @@ export default class PlotConfigurationModel extends Model {
|
||||
/**
|
||||
* Return defaults, which are extracted from the passed in domain
|
||||
* object.
|
||||
* @override
|
||||
* @param {import('./Model').ModelOptions<PlotConfigModelType, PlotConfigModelOptions>} options
|
||||
*/
|
||||
defaults(options) {
|
||||
defaultModel(options) {
|
||||
return {
|
||||
series: [],
|
||||
domainObject: options.domainObject,
|
||||
xAxis: {
|
||||
},
|
||||
yAxis: _.cloneDeep(_.get(options.domainObject, 'configuration.yAxis', {})),
|
||||
legend: _.cloneDeep(_.get(options.domainObject, 'configuration.legend', {}))
|
||||
xAxis: {},
|
||||
yAxis: _.cloneDeep(options.domainObject.configuration?.yAxis ?? {}),
|
||||
legend: _.cloneDeep(options.domainObject.configuration?.legend ?? {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {any} TODO */
|
||||
|
||||
/** @typedef {import('./PlotSeries').default} PlotSeries */
|
||||
|
||||
/**
|
||||
@typedef {{
|
||||
configuration: {
|
||||
series: import('./PlotSeries').PlotSeriesModelType[]
|
||||
yAxis: import('./YAxisModel').YAxisModelType
|
||||
},
|
||||
}} SomeDomainObject_NeedsName
|
||||
*/
|
||||
|
||||
/**
|
||||
@typedef {{
|
||||
xAxis: import('./XAxisModel').XAxisModelType
|
||||
yAxis: import('./YAxisModel').YAxisModelType
|
||||
legend: TODO
|
||||
series: PlotSeries[]
|
||||
domainObject: SomeDomainObject_NeedsName
|
||||
}} PlotConfigModelType
|
||||
*/
|
||||
|
||||
/** @typedef {TODO} SomeOtherDomainObject */
|
||||
|
||||
/**
|
||||
TODO: Is SomeOtherDomainObject the same domain object as with SomeDomainObject_NeedsName?
|
||||
@typedef {{
|
||||
plot: import('./PlotConfigurationModel').default
|
||||
domainObject: SomeOtherDomainObject
|
||||
}} PlotConfigModelOptions
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
@@ -59,11 +60,21 @@ import configStore from "../configuration/ConfigStore";
|
||||
* `metadata`: the Open MCT Telemetry Metadata Manager for the associated
|
||||
* telemetry point.
|
||||
* `formats`: the Open MCT format map for this telemetry point.
|
||||
*
|
||||
* @extends {Model<PlotSeriesModelType, PlotSeriesModelOptions>}
|
||||
*/
|
||||
export default class PlotSeries extends Model {
|
||||
logMode = false;
|
||||
|
||||
/**
|
||||
@param {import('./Model').ModelOptions<PlotSeriesModelType, PlotSeriesModelOptions>} options
|
||||
*/
|
||||
constructor(options) {
|
||||
|
||||
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;
|
||||
@@ -76,8 +87,10 @@ export default class PlotSeries extends Model {
|
||||
|
||||
/**
|
||||
* Set defaults for telemetry series.
|
||||
* @param {import('./Model').ModelOptions<PlotSeriesModelType, PlotSeriesModelOptions>} options
|
||||
* @override
|
||||
*/
|
||||
defaults(options) {
|
||||
defaultModel(options) {
|
||||
this.metadata = options
|
||||
.openmct
|
||||
.telemetry
|
||||
@@ -109,13 +122,21 @@ export default class PlotSeries extends Model {
|
||||
|
||||
/**
|
||||
* Remove real-time subscription when destroyed.
|
||||
* @override
|
||||
*/
|
||||
onDestroy(model) {
|
||||
destroy() {
|
||||
super.destroy();
|
||||
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set defaults for telemetry series.
|
||||
* @override
|
||||
* @param {import('./Model').ModelOptions<PlotSeriesModelType, PlotSeriesModelOptions>} options
|
||||
*/
|
||||
initialize(options) {
|
||||
this.openmct = options.openmct;
|
||||
this.domainObject = options.domainObject;
|
||||
@@ -136,9 +157,11 @@ export default class PlotSeries extends Model {
|
||||
|
||||
});
|
||||
this.openmct.time.on('bounds', this.updateLimits);
|
||||
this.on('destroy', this.onDestroy, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Bounds} bounds
|
||||
*/
|
||||
updateLimits(bounds) {
|
||||
this.emit('limitBounds', bounds);
|
||||
}
|
||||
@@ -188,7 +211,7 @@ export default class PlotSeries extends Model {
|
||||
return this.openmct
|
||||
.telemetry
|
||||
.request(this.domainObject, options)
|
||||
.then(function (points) {
|
||||
.then((points) => {
|
||||
const data = this.getSeriesData();
|
||||
const newPoints = _(data)
|
||||
.concat(points)
|
||||
@@ -196,7 +219,7 @@ export default class PlotSeries extends Model {
|
||||
.uniq(true, point => [this.getXVal(point), this.getYVal(point)].join())
|
||||
.value();
|
||||
this.reset(newPoints);
|
||||
}.bind(this))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Error fetching data', error);
|
||||
});
|
||||
@@ -211,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.
|
||||
@@ -232,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) {
|
||||
@@ -501,8 +530,56 @@ export default class PlotSeries extends Model {
|
||||
|
||||
/**
|
||||
* Update the series data with the given value.
|
||||
* This return type definition is totally wrong, only covers sinwave generator. It needs to be generic.
|
||||
* @return-example {Array<{
|
||||
cos: number
|
||||
sin: number
|
||||
mctLimitState: {
|
||||
cssClass: string
|
||||
high: number
|
||||
low: {sin: number, cos: number}
|
||||
name: string
|
||||
}
|
||||
utc: number
|
||||
wavelength: number
|
||||
yesterday: number
|
||||
}>}
|
||||
*/
|
||||
getSeriesData() {
|
||||
return configStore.get(this.dataStoreId) || [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {any} TODO */
|
||||
|
||||
/** @typedef {{key: string, namespace: string}} Identifier */
|
||||
|
||||
/**
|
||||
@typedef {{
|
||||
identifier: Identifier
|
||||
name: string
|
||||
unit: string
|
||||
xKey: string
|
||||
yKey: string
|
||||
markers: boolean
|
||||
markerShape: keyof typeof MARKER_SHAPES
|
||||
markerSize: number
|
||||
alarmMarkers: boolean
|
||||
limitLines: boolean
|
||||
interpolate: boolean
|
||||
stats: TODO
|
||||
}} PlotSeriesModelType
|
||||
*/
|
||||
|
||||
/**
|
||||
@typedef {{
|
||||
model: PlotSeriesModelType
|
||||
collection: import('./SeriesCollection').default
|
||||
persistedConfig: PlotSeriesModelType
|
||||
filters: TODO
|
||||
}} PlotSeriesModelOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
@typedef {import('@/api/time/TimeContext').Bounds} Bounds
|
||||
*/
|
||||
|
||||
@@ -26,8 +26,14 @@ import Collection from "./Collection";
|
||||
import Color from "@/ui/color/Color";
|
||||
import ColorPalette from "@/ui/color/ColorPalette";
|
||||
|
||||
/**
|
||||
* @extends {Collection<SeriesCollectionModelType, SeriesCollectionOptions>}
|
||||
*/
|
||||
export default class SeriesCollection extends Collection {
|
||||
|
||||
/**
|
||||
@override
|
||||
@param {import('./Model').ModelOptions<SeriesCollectionModelType, SeriesCollectionOptions>} options
|
||||
*/
|
||||
initialize(options) {
|
||||
super.initialize(options);
|
||||
this.modelClass = PlotSeries;
|
||||
@@ -83,11 +89,15 @@ export default class SeriesCollection extends Collection {
|
||||
// Clone to prevent accidental mutation by ref.
|
||||
seriesConfig = JSON.parse(JSON.stringify(seriesConfig));
|
||||
|
||||
if (!seriesConfig) {
|
||||
throw "not possible";
|
||||
}
|
||||
|
||||
this.add(new PlotSeries({
|
||||
model: seriesConfig,
|
||||
domainObject: domainObject,
|
||||
collection: this,
|
||||
openmct: this.openmct,
|
||||
collection: this,
|
||||
persistedConfig: this.plot
|
||||
.getPersistedSeriesConfig(domainObject.identifier),
|
||||
filters: filters
|
||||
@@ -163,3 +173,13 @@ export default class SeriesCollection extends Collection {
|
||||
})[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@typedef {PlotSeries} SeriesCollectionModelType
|
||||
*/
|
||||
|
||||
/**
|
||||
@typedef {{
|
||||
plot: import('./PlotConfigurationModel').default
|
||||
}} SeriesCollectionOptions
|
||||
*/
|
||||
|
||||
@@ -19,25 +19,41 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
import Model from "./Model";
|
||||
import Model from './Model';
|
||||
|
||||
/**
|
||||
* TODO: doc strings.
|
||||
*/
|
||||
* @extends {Model<XAxisModelType, XAxisModelOptions>}
|
||||
*/
|
||||
export default class XAxisModel extends Model {
|
||||
// Despite providing template types to the Model class, we still need to
|
||||
// re-define the type of the following initialize() method's options arg. Tracking
|
||||
// issue for this: https://github.com/microsoft/TypeScript/issues/32082
|
||||
// When they fix it, we can remove the `@param` we have here.
|
||||
/**
|
||||
* @override
|
||||
* @param {import('./Model').ModelOptions<XAxisModelType, XAxisModelOptions>} options
|
||||
*/
|
||||
initialize(options) {
|
||||
this.plot = options.plot;
|
||||
|
||||
// This is a type assertion for TypeScript, this error is not thrown in practice.
|
||||
if (!options.model) {
|
||||
throw new Error('Not a collection model.');
|
||||
}
|
||||
|
||||
this.set('label', options.model.name || '');
|
||||
this.on('change:range', function (newValue, oldValue, model) {
|
||||
if (!model.get('frozen')) {
|
||||
model.set('displayRange', newValue);
|
||||
|
||||
this.on('change:range', (newValue) => {
|
||||
if (!this.get('frozen')) {
|
||||
this.set('displayRange', newValue);
|
||||
}
|
||||
});
|
||||
|
||||
this.on('change:frozen', ((frozen, oldValue, model) => {
|
||||
this.on('change:frozen', (frozen) => {
|
||||
if (!frozen) {
|
||||
model.set('range', this.get('range'));
|
||||
this.set('range', this.get('range'));
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
if (this.get('range')) {
|
||||
this.set('range', this.get('range'));
|
||||
@@ -45,6 +61,10 @@ export default class XAxisModel extends Model {
|
||||
|
||||
this.listenTo(this, 'change:key', this.changeKey, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} newKey
|
||||
*/
|
||||
changeKey(newKey) {
|
||||
const series = this.plot.series.first();
|
||||
if (series) {
|
||||
@@ -68,12 +88,17 @@ export default class XAxisModel extends Model {
|
||||
plotSeries.reset();
|
||||
});
|
||||
}
|
||||
defaults(options) {
|
||||
/**
|
||||
* @param {import('./Model').ModelOptions<XAxisModelType, XAxisModelOptions>} options
|
||||
* @override
|
||||
*/
|
||||
defaultModel(options) {
|
||||
const bounds = options.openmct.time.bounds();
|
||||
const timeSystem = options.openmct.time.timeSystem();
|
||||
const format = options.openmct.telemetry.getFormatter(timeSystem.timeFormat);
|
||||
|
||||
return {
|
||||
/** @type {XAxisModelType} */
|
||||
const defaultModel = {
|
||||
name: timeSystem.name,
|
||||
key: timeSystem.key,
|
||||
format: format.format.bind(format),
|
||||
@@ -83,5 +108,42 @@ export default class XAxisModel extends Model {
|
||||
},
|
||||
frozen: false
|
||||
};
|
||||
|
||||
return defaultModel;
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {any} TODO */
|
||||
|
||||
/** @typedef {TODO} OpenMCT */
|
||||
|
||||
/**
|
||||
@typedef {{
|
||||
min: number
|
||||
max: number
|
||||
}} NumberRange
|
||||
*/
|
||||
|
||||
/**
|
||||
@typedef {import("./Model").ModelType<{
|
||||
range?: NumberRange
|
||||
displayRange: NumberRange
|
||||
frozen: boolean
|
||||
label: string
|
||||
format: (n: number) => string
|
||||
values: Array<TODO>
|
||||
}>} AxisModelType
|
||||
*/
|
||||
|
||||
/**
|
||||
@typedef {AxisModelType & {
|
||||
name: string
|
||||
key: string
|
||||
}} XAxisModelType
|
||||
*/
|
||||
|
||||
/**
|
||||
@typedef {{
|
||||
plot: import('./PlotConfigurationModel').default
|
||||
}} XAxisModelOptions
|
||||
*/
|
||||
|
||||
@@ -19,58 +19,62 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
import _ from 'lodash';
|
||||
import { antisymlog, symlog } from '../mathUtils';
|
||||
import Model from './Model';
|
||||
|
||||
/**
|
||||
* YAxis model
|
||||
*
|
||||
* TODO: docstrings.
|
||||
*
|
||||
* has the following Model properties:
|
||||
*
|
||||
* `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.
|
||||
* `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.
|
||||
* `label`: label to display on axis.
|
||||
* `stats`: Min and Max Values of data, automatically updated by observing
|
||||
* plot series.
|
||||
* `values`: for enumerated types, an array of possible display values.
|
||||
* `range`: the user-configured range to use for display, when autoscale is
|
||||
* disabled.
|
||||
*
|
||||
*/
|
||||
* YAxis model
|
||||
*
|
||||
* TODO: docstrings.
|
||||
*
|
||||
* has the following Model properties:
|
||||
*
|
||||
* `autoscale`: boolean, whether or not to autoscale.
|
||||
* `autoscalePadding`: float, percent of padding to display in plots.
|
||||
* `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.
|
||||
* `label`: label to display on axis.
|
||||
* `stats`: Min and Max Values of data, automatically updated by observing
|
||||
* plot series.
|
||||
* `values`: for enumerated types, an array of possible display values.
|
||||
* `range`: the user-configured range to use for display, when autoscale is
|
||||
* disabled.
|
||||
*
|
||||
* @extends {Model<YAxisModelType, YAxisModelOptions>}
|
||||
*/
|
||||
export default class YAxisModel extends Model {
|
||||
/**
|
||||
* @override
|
||||
* @param {import('./Model').ModelOptions<YAxisModelType, YAxisModelOptions>} options
|
||||
*/
|
||||
initialize(options) {
|
||||
this.plot = options.plot;
|
||||
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'));
|
||||
}
|
||||
/**
|
||||
* @param {import('./SeriesCollection').default} seriesCollection
|
||||
*/
|
||||
listenToSeriesCollection(seriesCollection) {
|
||||
this.seriesCollection = seriesCollection;
|
||||
this.listenTo(this.seriesCollection, 'add', (series => {
|
||||
this.listenTo(this.seriesCollection, 'add', series => {
|
||||
this.trackSeries(series);
|
||||
this.updateFromSeries(this.seriesCollection);
|
||||
}), this);
|
||||
this.listenTo(this.seriesCollection, 'remove', (series => {
|
||||
}, this);
|
||||
this.listenTo(this.seriesCollection, 'remove', series => {
|
||||
this.untrackSeries(series);
|
||||
this.updateFromSeries(this.seriesCollection);
|
||||
}), this);
|
||||
}, this);
|
||||
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'));
|
||||
@@ -132,12 +136,15 @@ export default class YAxisModel extends Model {
|
||||
}
|
||||
resetStats() {
|
||||
this.unset('stats');
|
||||
this.seriesCollection.forEach(function (series) {
|
||||
this.seriesCollection.forEach(series => {
|
||||
if (series.has('stats')) {
|
||||
this.updateStats(series.get('stats'));
|
||||
}
|
||||
}, this);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @param {import('./PlotSeries').default} series
|
||||
*/
|
||||
trackSeries(series) {
|
||||
this.listenTo(series, 'change:stats', seriesStats => {
|
||||
if (!seriesStats) {
|
||||
@@ -155,20 +162,100 @@ 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')));
|
||||
|
||||
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 {
|
||||
this.set('displayRange', this.get('range'));
|
||||
// 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 (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.
|
||||
*/
|
||||
updateFromSeries(series) {
|
||||
* Update yAxis format, values, and label from known series.
|
||||
* @param {import('./SeriesCollection').default} seriesCollection
|
||||
*/
|
||||
updateFromSeries(seriesCollection) {
|
||||
const plotModel = this.plot.get('domainObject');
|
||||
const label = _.get(plotModel, 'configuration.yAxis.label');
|
||||
const sampleSeries = series.first();
|
||||
const label = plotModel.configuration?.yAxis?.label;
|
||||
const sampleSeries = seriesCollection.first();
|
||||
if (!sampleSeries) {
|
||||
if (!label) {
|
||||
this.unset('label');
|
||||
@@ -180,22 +267,28 @@ 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 = series.map(function (s) {
|
||||
return s.metadata ? s.metadata.value(s.get('yKey')).name : '';
|
||||
}).reduce(function (a, b) {
|
||||
if (a === undefined) {
|
||||
return b;
|
||||
}
|
||||
const labelName = seriesCollection
|
||||
.map(s => (s.metadata ? s.metadata.value(s.get('yKey')).name : ''))
|
||||
.reduce((a, b) => {
|
||||
if (a === undefined) {
|
||||
return b;
|
||||
}
|
||||
|
||||
if (a === b) {
|
||||
return a;
|
||||
}
|
||||
if (a === b) {
|
||||
return a;
|
||||
}
|
||||
|
||||
return '';
|
||||
}, undefined);
|
||||
return '';
|
||||
}, undefined);
|
||||
|
||||
if (labelName) {
|
||||
this.set('label', labelName);
|
||||
@@ -203,19 +296,19 @@ export default class YAxisModel extends Model {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelUnits = series.map(function (s) {
|
||||
return s.metadata ? s.metadata.value(s.get('yKey')).units : '';
|
||||
}).reduce(function (a, b) {
|
||||
if (a === undefined) {
|
||||
return b;
|
||||
}
|
||||
const labelUnits = seriesCollection
|
||||
.map(s => (s.metadata ? s.metadata.value(s.get('yKey')).units : ''))
|
||||
.reduce((a, b) => {
|
||||
if (a === undefined) {
|
||||
return b;
|
||||
}
|
||||
|
||||
if (a === b) {
|
||||
return a;
|
||||
}
|
||||
if (a === b) {
|
||||
return a;
|
||||
}
|
||||
|
||||
return '';
|
||||
}, undefined);
|
||||
return '';
|
||||
}, undefined);
|
||||
|
||||
if (labelUnits) {
|
||||
this.set('label', labelUnits);
|
||||
@@ -224,11 +317,39 @@ export default class YAxisModel extends Model {
|
||||
}
|
||||
}
|
||||
}
|
||||
defaults(options) {
|
||||
/**
|
||||
* @override
|
||||
* @param {import('./Model').ModelOptions<YAxisModelType, YAxisModelOptions>} options
|
||||
* @returns {Partial<YAxisModelType>}
|
||||
*/
|
||||
defaultModel(options) {
|
||||
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
|
||||
// user turns off autoscale, the current 'displayRange' is used for
|
||||
// the initial value of 'range'.
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {any} TODO */
|
||||
|
||||
/**
|
||||
@typedef {import('./XAxisModel').AxisModelType & {
|
||||
autoscale: boolean
|
||||
logMode: boolean
|
||||
autoscalePadding: number
|
||||
stats?: import('./XAxisModel').NumberRange
|
||||
values: Array<TODO>
|
||||
}} YAxisModelType
|
||||
*/
|
||||
|
||||
/**
|
||||
@typedef {{
|
||||
plot: import('./PlotConfigurationModel').default
|
||||
}} YAxisModelOptions
|
||||
*/
|
||||
|
||||
@@ -110,6 +110,7 @@ DrawWebGL.prototype.onContextLost = function (event) {
|
||||
this.emit('error');
|
||||
this.isContextLost = true;
|
||||
this.destroy();
|
||||
// TODO re-initialize and re-draw on context restored
|
||||
};
|
||||
|
||||
DrawWebGL.prototype.initContext = function () {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
@@ -52,10 +65,10 @@
|
||||
class="l-inspector-part"
|
||||
>
|
||||
<div
|
||||
v-show="!autoscale && validation.range"
|
||||
v-show="!autoscale && validationErrors.range"
|
||||
class="grid-span-all form-error"
|
||||
>
|
||||
{{ validation.range }}
|
||||
{{ validationErrors.range }}
|
||||
</div>
|
||||
<li class="grid-row force-border">
|
||||
<div
|
||||
@@ -88,7 +101,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { objectPath, validate, coerce } from "./formUtil";
|
||||
import { objectPath } from "./formUtil";
|
||||
import _ from "lodash";
|
||||
|
||||
export default {
|
||||
@@ -105,10 +118,11 @@ export default {
|
||||
return {
|
||||
label: '',
|
||||
autoscale: '',
|
||||
logMode: false,
|
||||
autoscalePadding: '',
|
||||
rangeMin: '',
|
||||
rangeMax: '',
|
||||
validation: {}
|
||||
validationErrors: {}
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -178,28 +189,18 @@ export default {
|
||||
if (Number(range.min) > Number(range.max)) {
|
||||
return 'Minimum must be less than Maximum.';
|
||||
}
|
||||
|
||||
if (model.get('autoscale')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
},
|
||||
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');
|
||||
if (!range) {
|
||||
this.rangeMin = undefined;
|
||||
this.rangeMax = undefined;
|
||||
} else {
|
||||
this.rangeMin = range.min;
|
||||
this.rangeMax = range.max;
|
||||
}
|
||||
const range = this.yAxis.get('range') ?? this.yAxis.get('displayRange');
|
||||
this.rangeMin = range?.min;
|
||||
this.rangeMax = range?.max;
|
||||
},
|
||||
updateForm(formKey) {
|
||||
let newVal;
|
||||
@@ -212,26 +213,27 @@ export default {
|
||||
newVal = this[formKey];
|
||||
}
|
||||
|
||||
const oldVal = this.yAxis.get(formKey);
|
||||
const formField = this.fields.find((field) => field.modelProp === formKey);
|
||||
|
||||
const path = objectPath(formField.objectPath);
|
||||
const validationResult = validate(newVal, this.yAxis, formField.validate);
|
||||
if (validationResult === true) {
|
||||
delete this.validation[formKey];
|
||||
} else {
|
||||
this.validation[formKey] = validationResult;
|
||||
let oldVal = this.yAxis.get(formKey);
|
||||
const formField = this.fields[formKey];
|
||||
|
||||
const validationError = formField.validate?.(newVal, this.yAxis);
|
||||
this.validationErrors[formKey] = validationError;
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_.isEqual(coerce(newVal, formField.coerce), coerce(oldVal, formField.coerce))) {
|
||||
this.yAxis.set(formKey, coerce(newVal, formField.coerce));
|
||||
newVal = formField.coerce?.(newVal) ?? newVal;
|
||||
oldVal = formField.coerce?.(oldVal) ?? oldVal;
|
||||
|
||||
const path = objectPath(formField.objectPath);
|
||||
if (!_.isEqual(newVal, oldVal)) {
|
||||
// TODO: Why do we mutate yAxis twice, once directly, once via objects.mutate? Or are they different objects?
|
||||
this.yAxis.set(formKey, newVal);
|
||||
if (path) {
|
||||
this.openmct.objects.mutate(
|
||||
this.domainObject,
|
||||
path(this.domainObject, this.yAxis),
|
||||
coerce(newVal, formField.coerce)
|
||||
newVal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,5 @@ export function validate(value, model, validateFunc) {
|
||||
}
|
||||
|
||||
export function objectPath(path) {
|
||||
if (path) {
|
||||
if (typeof path !== "function") {
|
||||
const staticObjectPath = path;
|
||||
|
||||
return function (object, model) {
|
||||
return staticObjectPath;
|
||||
};
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
return path && typeof path !== 'function' ? () => path : path;
|
||||
}
|
||||
|
||||
@@ -90,3 +90,10 @@ const helperFunctions = {
|
||||
};
|
||||
|
||||
export default helperFunctions;
|
||||
|
||||
/**
|
||||
@typedef {{
|
||||
listenTo: (object: any, event: any, callback: any, context: any) => void
|
||||
stopListening: (object: any, event: any, callback: any, context: any) => void
|
||||
}} EventHelpers
|
||||
*/
|
||||
|
||||
44
src/plugins/plot/mathUtils.js
Normal file
44
src/plugins/plot/mathUtils.js
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -571,6 +571,34 @@ describe("the plugin", function () {
|
||||
range: 2
|
||||
}
|
||||
}]
|
||||
},
|
||||
configuration: {
|
||||
objectStyles: {
|
||||
staticStyle: {
|
||||
style: {
|
||||
backgroundColor: 'rgb(0, 200, 0)',
|
||||
color: '',
|
||||
border: ''
|
||||
}
|
||||
},
|
||||
conditionSetIdentifier: {
|
||||
namespace: '',
|
||||
key: 'testConditionSetId'
|
||||
},
|
||||
selectedConditionId: 'conditionId1',
|
||||
defaultConditionId: 'conditionId1',
|
||||
styles: [
|
||||
{
|
||||
conditionId: 'conditionId1',
|
||||
style: {
|
||||
backgroundColor: 'rgb(0, 155, 0)',
|
||||
color: '',
|
||||
output: '',
|
||||
border: ''
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -666,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();
|
||||
});
|
||||
@@ -815,6 +843,20 @@ describe("the plugin", function () {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows styles for telemetry objects if available", (done) => {
|
||||
Vue.nextTick(() => {
|
||||
let conditionalStylesContainer = element.querySelectorAll(".c-plot--stacked-container .js-style-receiver");
|
||||
let hasStyles = 0;
|
||||
conditionalStylesContainer.forEach(el => {
|
||||
if (el.style.backgroundColor !== '') {
|
||||
hasStyles++;
|
||||
}
|
||||
});
|
||||
expect(hasStyles).toBe(1);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('limits', () => {
|
||||
|
||||
it('lines are not displayed by default', () => {
|
||||
@@ -1044,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', () => {
|
||||
|
||||
@@ -26,8 +26,10 @@
|
||||
|
||||
import MctPlot from '../MctPlot.vue';
|
||||
import Vue from "vue";
|
||||
import conditionalStylesMixin from "./mixins/objectStyles-mixin";
|
||||
|
||||
export default {
|
||||
mixins: [conditionalStylesMixin],
|
||||
inject: ['openmct', 'domainObject', 'path'],
|
||||
props: {
|
||||
object: {
|
||||
|
||||
143
src/plugins/plot/stackedPlot/mixins/objectStyles-mixin.js
Normal file
143
src/plugins/plot/stackedPlot/mixins/objectStyles-mixin.js
Normal file
@@ -0,0 +1,143 @@
|
||||
/*****************************************************************************
|
||||
* 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 StyleRuleManager from "@/plugins/condition/StyleRuleManager";
|
||||
import {STYLE_CONSTANTS} from "@/plugins/condition/utils/constants";
|
||||
|
||||
export default {
|
||||
inject: ['openmct', 'domainObject', 'path'],
|
||||
data() {
|
||||
return {
|
||||
objectStyle: undefined
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.objectStyles = this.getObjectStyleForItem(this.object.configuration);
|
||||
this.initObjectStyles();
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.stopListeningStyles) {
|
||||
this.stopListeningStyles();
|
||||
}
|
||||
|
||||
if (this.styleRuleManager) {
|
||||
this.styleRuleManager.destroy();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getObjectStyleForItem(config) {
|
||||
if (config && config.objectStyles) {
|
||||
return config.objectStyles ? Object.assign({}, config.objectStyles) : undefined;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
initObjectStyles() {
|
||||
if (!this.styleRuleManager) {
|
||||
this.styleRuleManager = new StyleRuleManager(this.objectStyles, this.openmct, this.updateStyle.bind(this), true);
|
||||
} else {
|
||||
this.styleRuleManager.updateObjectStyleConfig(this.objectStyles);
|
||||
}
|
||||
|
||||
if (this.stopListeningStyles) {
|
||||
this.stopListeningStyles();
|
||||
}
|
||||
|
||||
this.stopListeningStyles = this.openmct.objects.observe(this.object, 'configuration.objectStyles', (newObjectStyle) => {
|
||||
//Updating styles in the inspector view will trigger this so that the changes are reflected immediately
|
||||
this.styleRuleManager.updateObjectStyleConfig(newObjectStyle);
|
||||
});
|
||||
|
||||
if (this.object && this.object.configuration && this.object.configuration.fontStyle) {
|
||||
const { fontSize, font } = this.object.configuration.fontStyle;
|
||||
this.setFontSize(fontSize);
|
||||
this.setFont(font);
|
||||
}
|
||||
|
||||
this.stopListeningFontStyles = this.openmct.objects.observe(this.object, 'configuration.fontStyle', (newFontStyle) => {
|
||||
this.setFontSize(newFontStyle.fontSize);
|
||||
this.setFont(newFontStyle.font);
|
||||
});
|
||||
},
|
||||
getStyleReceiver() {
|
||||
let styleReceiver;
|
||||
|
||||
if (this.$el !== undefined) {
|
||||
styleReceiver = this.$el.querySelector('.js-style-receiver')
|
||||
|| this.$el.querySelector(':first-child');
|
||||
|
||||
if (styleReceiver === null) {
|
||||
styleReceiver = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return styleReceiver;
|
||||
},
|
||||
setFontSize(newSize) {
|
||||
let elemToStyle = this.getStyleReceiver();
|
||||
|
||||
if (elemToStyle !== undefined) {
|
||||
elemToStyle.dataset.fontSize = newSize;
|
||||
}
|
||||
},
|
||||
setFont(newFont) {
|
||||
let elemToStyle = this.getStyleReceiver();
|
||||
|
||||
if (elemToStyle !== undefined) {
|
||||
elemToStyle.dataset.font = newFont;
|
||||
}
|
||||
},
|
||||
updateStyle(styleObj) {
|
||||
let elemToStyle = this.getStyleReceiver();
|
||||
|
||||
if (!styleObj || elemToStyle === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let keys = Object.keys(styleObj);
|
||||
|
||||
keys.forEach(key => {
|
||||
if (elemToStyle) {
|
||||
if ((typeof styleObj[key] === 'string') && (styleObj[key].indexOf('__no_value') > -1)) {
|
||||
if (elemToStyle.style[key]) {
|
||||
elemToStyle.style[key] = '';
|
||||
}
|
||||
} else {
|
||||
if (!styleObj.isStyleInvisible && elemToStyle.classList.contains(STYLE_CONSTANTS.isStyleInvisible)) {
|
||||
elemToStyle.classList.remove(STYLE_CONSTANTS.isStyleInvisible);
|
||||
} else if (styleObj.isStyleInvisible && !elemToStyle.classList.contains(styleObj.isStyleInvisible)) {
|
||||
elemToStyle.classList.add(styleObj.isStyleInvisible);
|
||||
}
|
||||
|
||||
elemToStyle.style[key] = styleObj[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,47 @@ 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;
|
||||
}
|
||||
|
||||
export function getLogTicks2(start, stop, count = 8) {
|
||||
return ticks(antisymlog(start, 10), antisymlog(stop, 10), count)
|
||||
.map(n => symlog(n, 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear tick generation from d3-array.
|
||||
*/
|
||||
|
||||
@@ -76,7 +76,9 @@ define([
|
||||
'./timer/plugin',
|
||||
'./userIndicator/plugin',
|
||||
'../../example/exampleUser/plugin',
|
||||
'./localStorage/plugin'
|
||||
'./localStorage/plugin',
|
||||
'./gauge/GaugePlugin',
|
||||
'./timelist/plugin'
|
||||
], function (
|
||||
_,
|
||||
UTCTimeSystem,
|
||||
@@ -133,7 +135,9 @@ define([
|
||||
Timer,
|
||||
UserIndicator,
|
||||
ExampleUser,
|
||||
LocalStorage
|
||||
LocalStorage,
|
||||
GaugePlugin,
|
||||
TimeList
|
||||
) {
|
||||
const plugins = {};
|
||||
|
||||
@@ -210,6 +214,8 @@ define([
|
||||
plugins.DeviceClassifier = DeviceClassifier.default;
|
||||
plugins.UserIndicator = UserIndicator.default;
|
||||
plugins.LocalStorage = LocalStorage.default;
|
||||
plugins.Gauge = GaugePlugin.default;
|
||||
plugins.Timelist = TimeList.default;
|
||||
|
||||
return plugins;
|
||||
});
|
||||
|
||||
@@ -9,7 +9,11 @@ export default class TelemetryTableView {
|
||||
this.objectPath = objectPath;
|
||||
this.component = undefined;
|
||||
|
||||
this.table = new TelemetryTable(domainObject, openmct);
|
||||
Object.defineProperty(this, 'table', {
|
||||
value: new TelemetryTable(domainObject, openmct),
|
||||
enumerable: false,
|
||||
configurable: false
|
||||
});
|
||||
}
|
||||
|
||||
getViewContext() {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
394
src/plugins/timelist/Timelist.vue
Normal file
394
src/plugins/timelist/Timelist.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<!--
|
||||
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
|
||||
ref="timelistHolder"
|
||||
class="c-timelist"
|
||||
>
|
||||
<list-view
|
||||
:items="planActivities"
|
||||
:header-items="headerItems"
|
||||
:default-sort="defaultSort"
|
||||
class="sticky"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {getValidatedPlan} from "../plan/util";
|
||||
import ListView from '../../ui/components/List/ListView.vue';
|
||||
import {getPreciseDuration} from "../../utils/duration";
|
||||
import ticker from 'utils/clock/Ticker';
|
||||
import {SORT_ORDER_OPTIONS} from "./constants";
|
||||
|
||||
import moment from "moment";
|
||||
import uuid from "uuid";
|
||||
|
||||
const SCROLL_TIMEOUT = 10000;
|
||||
const ROW_HEIGHT = 30;
|
||||
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss:SSS';
|
||||
const headerItems = [
|
||||
{
|
||||
defaultDirection: true,
|
||||
isSortable: true,
|
||||
property: 'start',
|
||||
name: 'Start Time',
|
||||
format: function (value, object) {
|
||||
return `${moment(value).format(TIME_FORMAT)}Z`;
|
||||
}
|
||||
}, {
|
||||
defaultDirection: true,
|
||||
isSortable: true,
|
||||
property: 'end',
|
||||
name: 'End Time',
|
||||
format: function (value, object) {
|
||||
return `${moment(value).format(TIME_FORMAT)}Z`;
|
||||
}
|
||||
}, {
|
||||
defaultDirection: false,
|
||||
property: 'duration',
|
||||
name: 'Time To/From',
|
||||
format: function (value) {
|
||||
let result;
|
||||
if (value < 0) {
|
||||
result = `-${getPreciseDuration(Math.abs(value))}`;
|
||||
} else if (value > 0) {
|
||||
result = `+${getPreciseDuration(value)}`;
|
||||
} else {
|
||||
result = 'Now';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}, {
|
||||
defaultDirection: true,
|
||||
property: 'name',
|
||||
name: 'Activity'
|
||||
}
|
||||
];
|
||||
|
||||
const defaultSort = {
|
||||
property: 'start',
|
||||
defaultDirection: true
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ListView
|
||||
},
|
||||
inject: ['openmct', 'domainObject', 'path'],
|
||||
data() {
|
||||
return {
|
||||
viewBounds: undefined,
|
||||
height: 0,
|
||||
planActivities: [],
|
||||
headerItems: headerItems,
|
||||
defaultSort: defaultSort
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.isEditing = this.openmct.editor.isEditing();
|
||||
this.timestamp = Date.now();
|
||||
this.getPlanDataAndSetConfig(this.domainObject);
|
||||
|
||||
this.unlisten = this.openmct.objects.observe(this.domainObject, 'selectFile', this.getPlanDataAndSetConfig);
|
||||
this.unlistenConfig = this.openmct.objects.observe(this.domainObject, 'configuration', this.setViewFromConfig);
|
||||
this.removeStatusListener = this.openmct.status.observe(this.domainObject.identifier, this.setStatus);
|
||||
this.status = this.openmct.status.get(this.domainObject.identifier);
|
||||
this.unlistenTicker = ticker.listen(this.clearPreviousActivities);
|
||||
this.openmct.editor.on('isEditing', this.setEditState);
|
||||
|
||||
this.deferAutoScroll = _.debounce(this.deferAutoScroll, 500);
|
||||
this.$el.parentElement.addEventListener('scroll', this.deferAutoScroll, true);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.unlisten) {
|
||||
this.unlisten();
|
||||
}
|
||||
|
||||
if (this.unlistenConfig) {
|
||||
this.unlistenConfig();
|
||||
}
|
||||
|
||||
if (this.unlistenTicker) {
|
||||
this.unlistenTicker();
|
||||
}
|
||||
|
||||
if (this.removeStatusListener) {
|
||||
this.removeStatusListener();
|
||||
}
|
||||
|
||||
this.openmct.editor.off('isEditing', this.setEditState);
|
||||
|
||||
this.$el.parentElement.removeEventListener('scroll', this.deferAutoScroll, true);
|
||||
if (this.clearAutoScrollDisabledTimer) {
|
||||
clearTimeout(this.clearAutoScrollDisabledTimer);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getPlanDataAndSetConfig(mutatedObject) {
|
||||
this.getPlanData(mutatedObject);
|
||||
this.setViewFromConfig(mutatedObject.configuration);
|
||||
},
|
||||
setViewFromConfig(configuration) {
|
||||
if (this.isEditing) {
|
||||
this.filterValue = configuration.filter;
|
||||
this.hideAll = false;
|
||||
this.showAll = true;
|
||||
this.listActivities();
|
||||
} else {
|
||||
this.filterValue = configuration.filter;
|
||||
this.setSort();
|
||||
this.setViewBounds();
|
||||
this.listActivities();
|
||||
}
|
||||
},
|
||||
getPlanData(domainObject) {
|
||||
this.planData = getValidatedPlan(domainObject);
|
||||
},
|
||||
setViewBounds() {
|
||||
const pastEventsIndex = this.domainObject.configuration.pastEventsIndex;
|
||||
const currentEventsIndex = this.domainObject.configuration.currentEventsIndex;
|
||||
const futureEventsIndex = this.domainObject.configuration.futureEventsIndex;
|
||||
const pastEventsDuration = this.domainObject.configuration.pastEventsDuration;
|
||||
const pastEventsDurationIndex = this.domainObject.configuration.pastEventsDurationIndex;
|
||||
const futureEventsDuration = this.domainObject.configuration.futureEventsDuration;
|
||||
const futureEventsDurationIndex = this.domainObject.configuration.futureEventsDurationIndex;
|
||||
|
||||
if (pastEventsIndex === 0 && futureEventsIndex === 0 && currentEventsIndex === 0) {
|
||||
//show all events
|
||||
this.showAll = false;
|
||||
this.viewBounds = undefined;
|
||||
this.hideAll = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideAll = false;
|
||||
|
||||
if (pastEventsIndex === 1 && futureEventsIndex === 1 && currentEventsIndex === 1) {
|
||||
//show all events
|
||||
this.showAll = true;
|
||||
this.viewBounds = undefined;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.showAll = false;
|
||||
|
||||
this.viewBounds = {};
|
||||
|
||||
this.noCurrent = currentEventsIndex === 0;
|
||||
|
||||
if (pastEventsIndex !== 1) {
|
||||
const pastDurationInMS = this.getDurationInMilliSeconds(pastEventsDuration, pastEventsDurationIndex);
|
||||
this.viewBounds.pastEnd = (timestamp) => {
|
||||
if (pastEventsIndex === 2) {
|
||||
return timestamp - pastDurationInMS;
|
||||
} else if (pastEventsIndex === 0) {
|
||||
return timestamp + 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (futureEventsIndex !== 1) {
|
||||
const futureDurationInMS = this.getDurationInMilliSeconds(futureEventsDuration, futureEventsDurationIndex);
|
||||
this.viewBounds.futureStart = (timestamp) => {
|
||||
if (futureEventsIndex === 2) {
|
||||
return timestamp + futureDurationInMS;
|
||||
} else if (futureEventsIndex === 0) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
getDurationInMilliSeconds(duration, durationIndex) {
|
||||
if (duration > 0) {
|
||||
if (durationIndex === 0) {
|
||||
return duration * 1000;
|
||||
} else if (durationIndex === 1) {
|
||||
return duration * 60 * 1000;
|
||||
} else if (durationIndex === 2) {
|
||||
return duration * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
},
|
||||
listActivities() {
|
||||
let groups = Object.keys(this.planData);
|
||||
let activities = [];
|
||||
|
||||
groups.forEach((key) => {
|
||||
activities = activities.concat(this.planData[key]);
|
||||
});
|
||||
activities = activities.filter(this.filterActivities);
|
||||
activities = this.applyStyles(activities);
|
||||
this.setScrollTop();
|
||||
// sort by start time
|
||||
this.planActivities = activities.sort(this.sortByStartTime);
|
||||
},
|
||||
clearPreviousActivities(time) {
|
||||
if (time instanceof Date) {
|
||||
this.timestamp = time.getTime();
|
||||
} else {
|
||||
this.timestamp = time;
|
||||
}
|
||||
|
||||
this.listActivities();
|
||||
},
|
||||
filterActivities(activity, index) {
|
||||
|
||||
const hasFilterMatch = this.filterByName(activity.name);
|
||||
|
||||
if (hasFilterMatch === false || this.hideAll === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.showAll === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//current event or future start event or past end event
|
||||
const isCurrent = (this.noCurrent === false && this.timestamp >= activity.start && this.timestamp <= activity.end);
|
||||
const isPast = (this.timestamp > activity.end && (this.viewBounds.pastEnd === undefined || activity.end >= this.viewBounds.pastEnd(this.timestamp)));
|
||||
const isFuture = (this.timestamp < activity.start && (this.viewBounds.futureStart === undefined || activity.start <= this.viewBounds.futureStart(this.timestamp)));
|
||||
|
||||
return isCurrent || isPast || isFuture;
|
||||
},
|
||||
filterByName(name) {
|
||||
const filters = this.filterValue.split(',');
|
||||
|
||||
return filters.some((search => {
|
||||
const normalized = search.trim().toLowerCase();
|
||||
const regex = new RegExp(normalized);
|
||||
|
||||
return regex.test(name.toLowerCase());
|
||||
}));
|
||||
},
|
||||
applyStyles(activities) {
|
||||
let firstCurrentActivityIndex = -1;
|
||||
let currentActivitiesCount = 0;
|
||||
const styledActivities = activities.map((activity, index) => {
|
||||
if (this.timestamp >= activity.start && this.timestamp <= activity.end) {
|
||||
activity.cssClass = '--is-current';
|
||||
if (firstCurrentActivityIndex < 0) {
|
||||
firstCurrentActivityIndex = index;
|
||||
}
|
||||
|
||||
currentActivitiesCount = currentActivitiesCount + 1;
|
||||
} else if (this.timestamp < activity.start) {
|
||||
activity.cssClass = '--is-future';
|
||||
} else {
|
||||
activity.cssClass = '--is-past';
|
||||
}
|
||||
|
||||
if (!activity.key) {
|
||||
activity.key = uuid();
|
||||
}
|
||||
|
||||
activity.duration = activity.start - this.timestamp;
|
||||
|
||||
return activity;
|
||||
});
|
||||
|
||||
this.firstCurrentActivityIndex = firstCurrentActivityIndex;
|
||||
this.currentActivitiesCount = currentActivitiesCount;
|
||||
|
||||
return styledActivities;
|
||||
},
|
||||
canAutoScroll() {
|
||||
//this distinguishes between programmatic vs user-triggered scroll events
|
||||
this.autoScrolled = (this.dontAutoScroll !== true);
|
||||
|
||||
return this.autoScrolled;
|
||||
},
|
||||
resetScroll() {
|
||||
if (this.canAutoScroll() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.firstCurrentActivityIndex = -1;
|
||||
this.currentActivitiesCount = 0;
|
||||
this.$el.parentElement.scrollTo({top: 0});
|
||||
this.autoScrolled = false;
|
||||
},
|
||||
setScrollTop() {
|
||||
//scroll to somewhere mid-way of the current activities
|
||||
if (this.firstCurrentActivityIndex > -1) {
|
||||
if (this.canAutoScroll() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollOffset = this.currentActivitiesCount > 0 ? Math.floor(this.currentActivitiesCount / 2) : 0;
|
||||
this.$el.parentElement.scrollTo({
|
||||
top: ROW_HEIGHT * (this.firstCurrentActivityIndex + scrollOffset),
|
||||
behavior: "smooth"
|
||||
});
|
||||
this.autoScrolled = false;
|
||||
} else {
|
||||
this.resetScroll();
|
||||
}
|
||||
},
|
||||
deferAutoScroll() {
|
||||
//if this is not a user-triggered event, don't defer auto scrolling
|
||||
if (this.autoScrolled) {
|
||||
this.autoScrolled = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.dontAutoScroll = true;
|
||||
const self = this;
|
||||
if (this.clearAutoScrollDisabledTimer) {
|
||||
clearTimeout(this.clearAutoScrollDisabledTimer);
|
||||
}
|
||||
|
||||
this.clearAutoScrollDisabledTimer = setTimeout(() => {
|
||||
self.dontAutoScroll = false;
|
||||
self.setScrollTop();
|
||||
}, SCROLL_TIMEOUT);
|
||||
},
|
||||
setSort() {
|
||||
const sortOrder = SORT_ORDER_OPTIONS[this.domainObject.configuration.sortOrderIndex];
|
||||
const property = sortOrder.property;
|
||||
const direction = sortOrder.direction.toLowerCase() === 'asc';
|
||||
this.defaultSort = {
|
||||
property,
|
||||
defaultDirection: direction
|
||||
};
|
||||
},
|
||||
sortByStartTime(a, b) {
|
||||
const numA = parseInt(a.start, 10);
|
||||
const numB = parseInt(b.start, 10);
|
||||
|
||||
return numA - numB;
|
||||
},
|
||||
setStatus(status) {
|
||||
this.status = status;
|
||||
},
|
||||
setEditState(isEditing) {
|
||||
this.isEditing = isEditing;
|
||||
this.setViewFromConfig(this.domainObject.configuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
67
src/plugins/timelist/TimelistViewProvider.js
Normal file
67
src/plugins/timelist/TimelistViewProvider.js
Normal 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 Timelist from './Timelist.vue';
|
||||
import { TIMELIST_TYPE } from './constants';
|
||||
import Vue from 'vue';
|
||||
|
||||
export default function TimelistViewProvider(openmct) {
|
||||
|
||||
return {
|
||||
key: 'timelist.view',
|
||||
name: 'Time List',
|
||||
cssClass: 'icon-timelist',
|
||||
canView(domainObject) {
|
||||
return domainObject.type === TIMELIST_TYPE;
|
||||
},
|
||||
|
||||
canEdit(domainObject) {
|
||||
return domainObject.type === TIMELIST_TYPE;
|
||||
},
|
||||
|
||||
view: function (domainObject, objectPath) {
|
||||
let component;
|
||||
|
||||
return {
|
||||
show: function (element) {
|
||||
|
||||
component = new Vue({
|
||||
el: element,
|
||||
components: {
|
||||
Timelist
|
||||
},
|
||||
provide: {
|
||||
openmct,
|
||||
domainObject,
|
||||
path: objectPath
|
||||
},
|
||||
template: '<timelist></timelist>'
|
||||
});
|
||||
},
|
||||
destroy: function () {
|
||||
component.$destroy();
|
||||
component = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
24
src/plugins/timelist/constants.js
Normal file
24
src/plugins/timelist/constants.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export const SORT_ORDER_OPTIONS = [
|
||||
{
|
||||
label: 'Start ascending',
|
||||
property: 'start',
|
||||
direction: 'ASC'
|
||||
},
|
||||
{
|
||||
label: 'Start descending',
|
||||
property: 'start',
|
||||
direction: 'DESC'
|
||||
},
|
||||
{
|
||||
label: 'End ascending',
|
||||
property: 'end',
|
||||
direction: 'ASC'
|
||||
},
|
||||
{
|
||||
label: 'End descending',
|
||||
property: 'end',
|
||||
direction: 'DESC'
|
||||
}
|
||||
];
|
||||
|
||||
export const TIMELIST_TYPE = 'timelist';
|
||||
124
src/plugins/timelist/inspector/EventProperties.vue
Normal file
124
src/plugins/timelist/inspector/EventProperties.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<li class="c-inspect-properties__row">
|
||||
<div
|
||||
class="c-inspect-properties__label"
|
||||
title="Options for future events."
|
||||
>{{ label }}</div>
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="c-inspect-properties__value"
|
||||
>
|
||||
<select
|
||||
v-model="index"
|
||||
@change="updateForm('index')"
|
||||
>
|
||||
<option
|
||||
v-for="(activityOption, activityKey) in activitiesOptions"
|
||||
:key="activityKey"
|
||||
:value="activityKey"
|
||||
>{{ activityOption }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-if="index === 2"
|
||||
v-model="duration"
|
||||
class="c-input c-input--sm"
|
||||
type="text"
|
||||
@change="updateForm('duration')"
|
||||
>
|
||||
<select
|
||||
v-if="index === 2"
|
||||
v-model="durationIndex"
|
||||
@change="updateForm('durationIndex')"
|
||||
>
|
||||
<option
|
||||
v-for="(durationOption, durationKey) in durationOptions"
|
||||
:key="durationKey"
|
||||
:value="durationKey"
|
||||
>{{ durationOption }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="c-inspect-properties__value"
|
||||
>
|
||||
{{ activitiesOptions[index] }} <span v-if="index > 1">{{ duration }} {{ durationOptions[durationIndex] }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const ACTIVITIES_OPTIONS = [
|
||||
'Don\'t show',
|
||||
'Show all',
|
||||
'Show starts within',
|
||||
'Show after end for'
|
||||
];
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
'seconds',
|
||||
'minutes',
|
||||
'hours'
|
||||
];
|
||||
|
||||
export default {
|
||||
inject: ['openmct', 'domainObject'],
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
index: this.domainObject.configuration[`${this.prefix}Index`],
|
||||
durationIndex: this.domainObject.configuration[`${this.prefix}DurationIndex`],
|
||||
duration: this.domainObject.configuration[`${this.prefix}Duration`],
|
||||
activitiesOptions: ACTIVITIES_OPTIONS,
|
||||
durationOptions: DURATION_OPTIONS,
|
||||
isEditing: this.openmct.editor.isEditing()
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canEdit() {
|
||||
return this.isEditing && !this.domainObject.locked;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.prefix === 'futureEvents') {
|
||||
this.activitiesOptions = ACTIVITIES_OPTIONS.slice(0, 3);
|
||||
} else if (this.prefix === 'pastEvents') {
|
||||
this.activitiesOptions = ACTIVITIES_OPTIONS.filter((item, index) => index !== 2);
|
||||
} else if (this.prefix === 'currentEvents') {
|
||||
this.activitiesOptions = ACTIVITIES_OPTIONS.slice(0, 2);
|
||||
}
|
||||
|
||||
this.openmct.editor.on('isEditing', this.setEditState);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.openmct.editor.off('isEditing', this.setEditState);
|
||||
},
|
||||
methods: {
|
||||
updateForm(property) {
|
||||
if (!this.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capitalized = property.charAt(0).toUpperCase() + property.substr(1);
|
||||
this.$emit('updated', {
|
||||
property: `${this.prefix}${capitalized}`,
|
||||
value: this[property]
|
||||
});
|
||||
},
|
||||
isValid() {
|
||||
return this.index < 2 || (this.durationIndex >= 0 && this.duration > 0);
|
||||
},
|
||||
setEditState(isEditing) {
|
||||
this.isEditing = isEditing;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
91
src/plugins/timelist/inspector/Filtering.vue
Normal file
91
src/plugins/timelist/inspector/Filtering.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<li class="c-inspect-properties__row">
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="c-inspect-properties__hint span-all"
|
||||
>Filter this view by comma-separated keywords.</div>
|
||||
<div
|
||||
class="c-inspect-properties__label"
|
||||
title="Filter by keyword."
|
||||
>Filters</div>
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="c-inspect-properties__value"
|
||||
:class="{'form-error': hasError}"
|
||||
>
|
||||
<textarea
|
||||
v-model="filterValue"
|
||||
class="c-input--flex"
|
||||
type="text"
|
||||
@keydown.enter.exact.stop="forceBlur($event)"
|
||||
@keyup="updateForm($event, 'filter')"
|
||||
></textarea>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="c-inspect-properties__value"
|
||||
>
|
||||
{{ filterValue }}
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
inject: ['openmct', 'domainObject'],
|
||||
data() {
|
||||
return {
|
||||
isEditing: this.openmct.editor.isEditing(),
|
||||
filterValue: this.domainObject.configuration.filter,
|
||||
hasError: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canEdit() {
|
||||
return this.isEditing && !this.domainObject.locked;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.openmct.editor.on('isEditing', this.setEditState);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.openmct.editor.off('isEditing', this.setEditState);
|
||||
},
|
||||
methods: {
|
||||
setEditState(isEditing) {
|
||||
this.isEditing = isEditing;
|
||||
if (!this.isEditing && this.hasError) {
|
||||
this.filterValue = this.domainObject.configuration.filter;
|
||||
this.hasError = false;
|
||||
}
|
||||
},
|
||||
forceBlur(event) {
|
||||
event.target.blur();
|
||||
},
|
||||
updateForm(event, property) {
|
||||
if (!this.isValid()) {
|
||||
this.hasError = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasError = false;
|
||||
|
||||
this.$emit('updated', {
|
||||
property,
|
||||
value: this.filterValue.replace(/,(\s)*$/, '')
|
||||
});
|
||||
},
|
||||
isValid() {
|
||||
// Test for any word character, any whitespace character or comma
|
||||
if (this.filterValue === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const regex = new RegExp(/^([a-zA-Z0-9_\-\s,])+$/g);
|
||||
|
||||
return regex.test(this.filterValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
/*****************************************************************************
|
||||
* 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 TimelistPropertiesView from "./TimelistPropertiesView.vue";
|
||||
import { TIMELIST_TYPE } from '../constants';
|
||||
import Vue from 'vue';
|
||||
|
||||
export default function TimeListInspectorViewProvider(openmct) {
|
||||
return {
|
||||
key: 'timelist-inspector',
|
||||
name: 'Timelist Inspector View',
|
||||
canView: function (selection) {
|
||||
if (selection.length === 0 || selection[0].length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let context = selection[0][0].context;
|
||||
|
||||
return context && context.item
|
||||
&& context.item.type === TIMELIST_TYPE;
|
||||
},
|
||||
view: function (selection) {
|
||||
let component;
|
||||
|
||||
return {
|
||||
show: function (element) {
|
||||
component = new Vue({
|
||||
el: element,
|
||||
components: {
|
||||
TimelistPropertiesView: TimelistPropertiesView
|
||||
},
|
||||
provide: {
|
||||
openmct,
|
||||
domainObject: selection[0][0].context.item
|
||||
},
|
||||
template: '<timelist-properties-view></timelist-properties-view>'
|
||||
});
|
||||
},
|
||||
destroy: function () {
|
||||
if (component) {
|
||||
component.$destroy();
|
||||
component = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
priority: function () {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
146
src/plugins/timelist/inspector/TimelistPropertiesView.vue
Normal file
146
src/plugins/timelist/inspector/TimelistPropertiesView.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<!--
|
||||
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-timelist-properties">
|
||||
<div class="c-inspect-properties">
|
||||
<ul class="c-inspect-properties__section">
|
||||
<div
|
||||
class="c-inspect-properties_header"
|
||||
title="'Timeframe options'"
|
||||
>Timeframe</div>
|
||||
<li class="c-inspect-properties__row">
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="c-inspect-properties__hint span-all"
|
||||
>These settings are not previewed and will be applied after editing is completed.</div>
|
||||
<div
|
||||
class="c-inspect-properties__label"
|
||||
title="Sort order of the timelist."
|
||||
>Sort Order</div>
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="c-inspect-properties__value"
|
||||
>
|
||||
<select
|
||||
v-model="sortOrderIndex"
|
||||
@change="updateSortOrder()"
|
||||
>
|
||||
<option
|
||||
v-for="(sortOrderOption, index) in sortOrderOptions"
|
||||
:key="index"
|
||||
:value="index"
|
||||
>{{ sortOrderOption.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="c-inspect-properties__value"
|
||||
>
|
||||
{{ sortOrderOptions[sortOrderIndex].label }}
|
||||
</div>
|
||||
</li>
|
||||
<event-properties
|
||||
v-for="type in eventTypes"
|
||||
:key="type.prefix"
|
||||
:label="type.label"
|
||||
:prefix="type.prefix"
|
||||
@updated="eventPropertiesUpdated"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="c-inspect-properties">
|
||||
<ul class="c-inspect-properties__section">
|
||||
<div
|
||||
class="c-inspect-properties_header"
|
||||
title="'Filters'"
|
||||
>Filtering</div>
|
||||
<filtering @updated="eventPropertiesUpdated" />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import EventProperties from './EventProperties.vue';
|
||||
import { SORT_ORDER_OPTIONS } from '../constants';
|
||||
import Filtering from './Filtering.vue';
|
||||
|
||||
const EVENT_TYPES = [
|
||||
{
|
||||
label: 'Future Events',
|
||||
prefix: 'futureEvents'
|
||||
},
|
||||
{
|
||||
label: 'Current Events',
|
||||
prefix: 'currentEvents'
|
||||
},
|
||||
{
|
||||
label: 'Past Events',
|
||||
prefix: 'pastEvents'
|
||||
}
|
||||
];
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Filtering,
|
||||
EventProperties
|
||||
},
|
||||
inject: ['openmct', 'domainObject'],
|
||||
data() {
|
||||
return {
|
||||
sortOrderIndex: this.domainObject.configuration.sortOrderIndex,
|
||||
sortOrderOptions: SORT_ORDER_OPTIONS,
|
||||
eventTypes: EVENT_TYPES,
|
||||
isEditing: this.openmct.editor.isEditing()
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canEdit() {
|
||||
return this.isEditing && !this.domainObject.locked;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.openmct.editor.on('isEditing', this.setEditState);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.openmct.editor.off('isEditing', this.setEditState);
|
||||
},
|
||||
methods: {
|
||||
setEditState(isEditing) {
|
||||
this.isEditing = isEditing;
|
||||
},
|
||||
updateSortOrder() {
|
||||
this.updateProperty('sortOrderIndex', this.sortOrderIndex);
|
||||
},
|
||||
updateProperty(key, value) {
|
||||
this.openmct.objects.mutate(this.domainObject, `configuration.${key}`, value);
|
||||
},
|
||||
eventPropertiesUpdated(data) {
|
||||
const key = data.property;
|
||||
const value = data.value;
|
||||
this.updateProperty(key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
68
src/plugins/timelist/plugin.js
Normal file
68
src/plugins/timelist/plugin.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/*****************************************************************************
|
||||
* 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 TimelistViewProvider from './TimelistViewProvider';
|
||||
import { TIMELIST_TYPE } from './constants';
|
||||
import TimeListInspectorViewProvider from "./inspector/TimeListInspectorViewProvider";
|
||||
|
||||
export default function () {
|
||||
return function install(openmct) {
|
||||
openmct.types.addType(TIMELIST_TYPE, {
|
||||
name: 'Time List',
|
||||
key: TIMELIST_TYPE,
|
||||
description: 'A configurable, time-ordered list view of activities for a compatible mission plan file.',
|
||||
creatable: true,
|
||||
cssClass: 'icon-timelist',
|
||||
form: [
|
||||
{
|
||||
name: 'Upload Plan (JSON File)',
|
||||
key: 'selectFile',
|
||||
control: 'file-input',
|
||||
required: true,
|
||||
text: 'Select File...',
|
||||
type: 'application/json',
|
||||
property: [
|
||||
"selectFile"
|
||||
]
|
||||
}
|
||||
],
|
||||
initialize: function (domainObject) {
|
||||
domainObject.configuration = {
|
||||
sortOrderIndex: 0,
|
||||
futureEventsIndex: 0,
|
||||
futureEventsDurationIndex: 0,
|
||||
futureEventsDuration: 20,
|
||||
currentEventsIndex: 1,
|
||||
currentEventsDurationIndex: 0,
|
||||
currentEventsDuration: 20,
|
||||
pastEventsIndex: 0,
|
||||
pastEventsDurationIndex: 0,
|
||||
pastEventsDuration: 20,
|
||||
filter: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
openmct.objectViews.addProvider(new TimelistViewProvider(openmct));
|
||||
openmct.inspectorViews.addProvider(new TimeListInspectorViewProvider(openmct));
|
||||
|
||||
};
|
||||
}
|
||||
181
src/plugins/timelist/pluginSpec.js
Normal file
181
src/plugins/timelist/pluginSpec.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/*****************************************************************************
|
||||
* 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 TimelistPlugin from "./plugin";
|
||||
import { TIMELIST_TYPE } from "./constants";
|
||||
import Vue from 'vue';
|
||||
import moment from "moment";
|
||||
|
||||
const LIST_ITEM_CLASS = '.js-table__body .js-list-item';
|
||||
const LIST_ITEM_VALUE_CLASS = '.js-list-item__value';
|
||||
const LIST_ITEM_BODY_CLASS = '.js-table__body th';
|
||||
|
||||
describe('the plugin', function () {
|
||||
let timelistDefinition;
|
||||
let element;
|
||||
let child;
|
||||
let openmct;
|
||||
let appHolder;
|
||||
let originalRouterPath;
|
||||
|
||||
beforeEach((done) => {
|
||||
appHolder = document.createElement('div');
|
||||
appHolder.style.width = '640px';
|
||||
appHolder.style.height = '480px';
|
||||
|
||||
openmct = createOpenMct();
|
||||
openmct.install(new TimelistPlugin());
|
||||
|
||||
timelistDefinition = openmct.types.get(TIMELIST_TYPE).definition;
|
||||
|
||||
element = document.createElement('div');
|
||||
element.style.width = '640px';
|
||||
element.style.height = '480px';
|
||||
child = document.createElement('div');
|
||||
child.style.width = '640px';
|
||||
child.style.height = '480px';
|
||||
element.appendChild(child);
|
||||
|
||||
originalRouterPath = openmct.router.path;
|
||||
|
||||
openmct.on('start', done);
|
||||
openmct.start(appHolder);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
openmct.router.path = originalRouterPath;
|
||||
|
||||
return resetApplicationState(openmct);
|
||||
});
|
||||
|
||||
let mockTimelistObject = {
|
||||
name: 'Timelist',
|
||||
key: TIMELIST_TYPE,
|
||||
creatable: true
|
||||
};
|
||||
|
||||
it('defines a timelist object type with the correct key', () => {
|
||||
expect(timelistDefinition.key).toEqual(mockTimelistObject.key);
|
||||
});
|
||||
|
||||
it('is creatable', () => {
|
||||
expect(timelistDefinition.creatable).toEqual(mockTimelistObject.creatable);
|
||||
});
|
||||
|
||||
describe('the timelist view', () => {
|
||||
it('provides a timelist view', () => {
|
||||
const testViewObject = {
|
||||
id: "test-object",
|
||||
type: TIMELIST_TYPE
|
||||
};
|
||||
openmct.router.path = [testViewObject];
|
||||
|
||||
const applicableViews = openmct.objectViews.get(testViewObject, [testViewObject]);
|
||||
let timelistView = applicableViews.find((viewProvider) => viewProvider.key === 'timelist.view');
|
||||
expect(timelistView).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the timelist view displays activities', () => {
|
||||
let timelistDomainObject;
|
||||
let timelistView;
|
||||
|
||||
beforeEach(() => {
|
||||
timelistDomainObject = {
|
||||
identifier: {
|
||||
key: 'test-object',
|
||||
namespace: ''
|
||||
},
|
||||
type: TIMELIST_TYPE,
|
||||
id: "test-object",
|
||||
configuration: {
|
||||
sortOrderIndex: 0,
|
||||
futureEventsIndex: 1,
|
||||
futureEventsDurationIndex: 0,
|
||||
futureEventsDuration: 20,
|
||||
currentEventsIndex: 1,
|
||||
currentEventsDurationIndex: 0,
|
||||
currentEventsDuration: 20,
|
||||
pastEventsIndex: 1,
|
||||
pastEventsDurationIndex: 0,
|
||||
pastEventsDuration: 20,
|
||||
filter: ''
|
||||
},
|
||||
selectFile: {
|
||||
body: JSON.stringify({
|
||||
"TEST-GROUP": [
|
||||
{
|
||||
"name": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
|
||||
"start": 1597170002854,
|
||||
"end": 1597171032854,
|
||||
"type": "TEST-GROUP",
|
||||
"color": "fuchsia",
|
||||
"textColor": "black"
|
||||
},
|
||||
{
|
||||
"name": "Sed ut perspiciatis",
|
||||
"start": 1597171132854,
|
||||
"end": 1597171232854,
|
||||
"type": "TEST-GROUP",
|
||||
"color": "fuchsia",
|
||||
"textColor": "black"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
openmct.router.path = [timelistDomainObject];
|
||||
|
||||
const applicableViews = openmct.objectViews.get(timelistDomainObject, [timelistDomainObject]);
|
||||
timelistView = applicableViews.find((viewProvider) => viewProvider.key === 'timelist.view');
|
||||
let view = timelistView.view(timelistDomainObject, []);
|
||||
view.show(child, true);
|
||||
|
||||
return Vue.nextTick();
|
||||
});
|
||||
|
||||
it('displays the activities', () => {
|
||||
const items = element.querySelectorAll(LIST_ITEM_CLASS);
|
||||
expect(items.length).toEqual(2);
|
||||
});
|
||||
|
||||
it('displays the activity headers', () => {
|
||||
const headers = element.querySelectorAll(LIST_ITEM_BODY_CLASS);
|
||||
expect(headers.length).toEqual(4);
|
||||
});
|
||||
|
||||
it('displays activity details', (done) => {
|
||||
Vue.nextTick(() => {
|
||||
const itemEls = element.querySelectorAll(LIST_ITEM_CLASS);
|
||||
const itemValues = itemEls[0].querySelectorAll(LIST_ITEM_VALUE_CLASS);
|
||||
expect(itemValues.length).toEqual(4);
|
||||
expect(itemValues[3].innerHTML.trim()).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua');
|
||||
expect(itemValues[0].innerHTML.trim()).toEqual(`${moment(1597170002854).format('YYYY-MM-DD HH:mm:ss:SSS')}Z`);
|
||||
expect(itemValues[1].innerHTML.trim()).toEqual(`${moment(1597171032854).format('YYYY-MM-DD HH:mm:ss:SSS')}Z`);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
55
src/plugins/timelist/timelist.scss
Normal file
55
src/plugins/timelist/timelist.scss
Normal 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.
|
||||
*****************************************************************************/
|
||||
|
||||
.c-timelist {
|
||||
& .nowMarker.hasCurrent {
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
background: cyan;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.c-list-item {
|
||||
/* Time Lists */
|
||||
|
||||
&.--is-current {
|
||||
background-color: $colorCurrentBg;
|
||||
border-top: 1px solid $colorCurrentBorder !important;
|
||||
color: $colorCurrentFg;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.--is-future {
|
||||
background-color: $colorFutureBg;
|
||||
border-top-color: $colorFutureBorder !important;
|
||||
color: $colorFutureFg;
|
||||
}
|
||||
|
||||
&__value {
|
||||
&.--duration {
|
||||
width: 5%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -38,41 +38,41 @@ export default class ViewLargeAction {
|
||||
}
|
||||
|
||||
invoke(objectPath, view) {
|
||||
const parentElement = view.parentElement;
|
||||
let childElement = parentElement && parentElement.firstChild;
|
||||
performance.mark('viewlarge.start');
|
||||
const childElement = view?.parentElement?.firstChild;
|
||||
if (!childElement) {
|
||||
const message = "ViewLargeAction: missing element";
|
||||
this.openmct.notifications.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
this._expand(objectPath, childElement);
|
||||
this._expand(objectPath, view);
|
||||
}
|
||||
|
||||
appliesTo(objectPath, view = {}) {
|
||||
const parentElement = view.parentElement;
|
||||
const element = parentElement && parentElement.firstChild;
|
||||
const viewLargeAction = element && !element.classList.contains('js-main-container')
|
||||
appliesTo(objectPath, view) {
|
||||
const childElement = view?.parentElement?.firstChild;
|
||||
|
||||
return childElement && !childElement.classList.contains('js-main-container')
|
||||
&& !this.openmct.router.isNavigatedObject(objectPath);
|
||||
|
||||
return viewLargeAction;
|
||||
}
|
||||
|
||||
_expand(objectPath, childElement) {
|
||||
const parentElement = childElement.parentElement;
|
||||
_expand(objectPath, view) {
|
||||
const element = this._getPreview(objectPath, view);
|
||||
|
||||
this.overlay = this.openmct.overlays.overlay({
|
||||
element: this._getPreview(objectPath),
|
||||
element,
|
||||
size: 'large',
|
||||
autoHide: false,
|
||||
onDestroy() {
|
||||
parentElement.append(childElement);
|
||||
onDestroy: () => {
|
||||
this.preview.$destroy();
|
||||
this.preview = undefined;
|
||||
delete this.preview;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_getPreview(objectPath) {
|
||||
const preview = new Vue({
|
||||
_getPreview(objectPath, view) {
|
||||
this.preview = new Vue({
|
||||
components: {
|
||||
Preview
|
||||
},
|
||||
@@ -80,9 +80,14 @@ export default class ViewLargeAction {
|
||||
openmct: this.openmct,
|
||||
objectPath
|
||||
},
|
||||
template: '<Preview></Preview>'
|
||||
data() {
|
||||
return {
|
||||
view
|
||||
};
|
||||
},
|
||||
template: '<Preview :existing-view="view"></Preview>'
|
||||
});
|
||||
|
||||
return preview.$mount().$el;
|
||||
return this.preview.$mount().$el;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ $shdwItemText: none;
|
||||
$colorTabBorder: pullForward($colorBodyBg, 10%);
|
||||
$colorTabBodyBg: $colorBodyBg;
|
||||
$colorTabBodyFg: pullForward($colorBodyFg, 20%);
|
||||
$colorTabHeaderBg: rgba($colorBodyFg, 0.15);
|
||||
$colorTabHeaderBg: #575757;
|
||||
$colorTabHeaderFg: $colorBodyFg;
|
||||
$colorTabHeaderBorder: $colorBodyBg;
|
||||
$colorTabGroupHeaderBg: pullForward($colorBodyBg, 5%);
|
||||
@@ -366,6 +366,24 @@ $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%);
|
||||
$colorCurrentBorder: $colorBodyBg;
|
||||
$colorFutureBg: rgba($colorKey, 0.2);
|
||||
$colorFutureFg: $colorCurrentFg;
|
||||
$colorFutureBorder: $colorCurrentBorder;
|
||||
|
||||
// Tree
|
||||
$colorTreeBg: transparent;
|
||||
$colorItemTreeHoverBg: rgba(#fff, 0.1);
|
||||
|
||||
@@ -349,7 +349,7 @@ $shdwItemText: none;
|
||||
$colorTabBorder: pullForward($colorBodyBg, 10%);
|
||||
$colorTabBodyBg: $colorBodyBg;
|
||||
$colorTabBodyFg: pullForward($colorBodyFg, 20%);
|
||||
$colorTabHeaderBg: rgba($colorBodyFg, 0.15);
|
||||
$colorTabHeaderBg: #575757;
|
||||
$colorTabHeaderFg: $colorBodyFg;
|
||||
$colorTabHeaderBorder: $colorBodyBg;
|
||||
$colorTabGroupHeaderBg: pullForward($colorBodyBg, 5%);
|
||||
@@ -370,6 +370,24 @@ $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%);
|
||||
$colorCurrentBorder: #fff;
|
||||
$colorFutureBg: rgba($colorKey, 0.2);
|
||||
$colorFutureFg: $colorCurrentFg;
|
||||
$colorFutureBorder: $colorCurrentBorder;
|
||||
|
||||
// Tree
|
||||
$colorTreeBg: transparent;
|
||||
$colorItemTreeHoverBg: rgba(#fff, 0.03);
|
||||
|
||||
@@ -345,7 +345,7 @@ $shdwItemText: none;
|
||||
$colorTabBorder: pullForward($colorBodyBg, 10%);
|
||||
$colorTabBodyBg: $colorBodyBg;
|
||||
$colorTabBodyFg: pullForward($colorBodyFg, 20%);
|
||||
$colorTabHeaderBg: rgba($colorBodyFg, 0.2);
|
||||
$colorTabHeaderBg: #e2e2e2;
|
||||
$colorTabHeaderFg: $colorBodyFg;
|
||||
$colorTabHeaderBorder: $colorBodyBg;
|
||||
$colorTabGroupHeaderBg: pullForward($colorBodyBg, 5%);
|
||||
@@ -366,6 +366,24 @@ $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%);
|
||||
$colorCurrentBorder: #fff;
|
||||
$colorFutureBg: rgba($colorKey, 0.2);
|
||||
$colorFutureFg: $colorCurrentFg;
|
||||
$colorFutureBorder: $colorCurrentBorder;
|
||||
|
||||
// Tree
|
||||
$colorTreeBg: transparent;
|
||||
$colorItemTreeHoverBg: rgba(black, 0.07);
|
||||
|
||||
@@ -263,6 +263,7 @@ $glyph-icon-telemetry-aggregate: '\eb2b';
|
||||
$glyph-icon-bar-chart: '\eb2c';
|
||||
$glyph-icon-map: '\eb2d';
|
||||
$glyph-icon-plan: '\eb2e';
|
||||
$glyph-icon-timelist: '\eb2f';
|
||||
|
||||
/************************** GLYPHS AS DATA URI */
|
||||
// Only objects have been converted, for use in Create menu and folder views
|
||||
@@ -317,3 +318,4 @@ $bg-icon-condition-widget: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='h
|
||||
$bg-icon-bar-chart: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3e%3cpath fill='%23000000' d='M416 0H96C43.2 0 0 43.2 0 96v320c0 52.8 43.2 96 96 96h320c52.8 0 96-43.2 96-96V96c0-52.8-43.2-96-96-96ZM133.82 448H64V224h69.82Zm104.73 0h-69.82V64h69.82Zm104.72 0h-69.82V288h69.82ZM448 448h-69.82V128H448Z'/%3e%3c/svg%3e");
|
||||
$bg-icon-map: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3e%3cpath fill='%23000000' d='M448 32.7 384 64v448l64-31.3c35.2-17.21 64-60.1 64-95.3v-320c0-35.2-28.8-49.91-64-32.7ZM160 456l193.6 48.4v-448L160 8v448zM129.6.4 128 0 64 31.3C28.8 48.51 0 91.4 0 126.6v320c0 35.2 28.8 49.91 64 32.7l64-31.3 1.6.4Z'/%3e%3c/svg%3e");
|
||||
$bg-icon-plan: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3e%3cg data-name='Layer 2'%3e%3cg data-name='Layer 1'%3e%3cpath fill='%23000000' d='M128 96V64a64.19 64.19 0 0 1 64-64h128a64.19 64.19 0 0 1 64 64v32Z'/%3e%3cpath fill='%23000000' d='M416 64v64H96V64c-52.8 0-96 43.2-96 96v256c0 52.8 43.2 96 96 96h320c52.8 0 96-43.2 96-96V160c0-52.8-43.2-96-96-96ZM64 288v-64h128v64Zm256 128H128v-64h192Zm128 0h-64v-64h64Zm0-128H256v-64h192Z'/%3e%3c/g%3e%3c/g%3e%3c/svg%3e");
|
||||
$bg-icon-timelist: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3e%3cg data-name='Layer 2'%3e%3cpath d='M448 0H64A64.19 64.19 0 0 0 0 64v384a64.19 64.19 0 0 0 64 64h384a64.19 64.19 0 0 0 64-64V64a64.19 64.19 0 0 0-64-64ZM213.47 266.73a24 24 0 0 1-32.2 10.74L104 238.83V128a24 24 0 0 1 48 0v81.17l50.73 25.36a24 24 0 0 1 10.74 32.2ZM448 448H288v-64h160Zm0-96H288v-64h160Zm0-96H288v-64h160Zm0-96H288V96h160Z' data-name='Layer 1'/%3e%3c/g%3e%3c/svg%3e");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
.icon-bar-chart { @include glyphBefore($glyph-icon-bar-chart); }
|
||||
.icon-map { @include glyphBefore($glyph-icon-map); }
|
||||
.icon-plan { @include glyphBefore($glyph-icon-plan); }
|
||||
.icon-timelist { @include glyphBefore($glyph-icon-timelist); }
|
||||
|
||||
/************************** 12 PX CLASSES */
|
||||
// TODO: sync with 16px redo as of 10/25/18
|
||||
@@ -256,3 +257,4 @@
|
||||
.bg-icon-bar-chart { @include glyphBg($bg-icon-bar-chart); }
|
||||
.bg-icon-map { @include glyphBg($bg-icon-map); }
|
||||
.bg-icon-plan { @include glyphBg($bg-icon-plan); }
|
||||
.bg-icon-timelist { @include glyphBg($bg-icon-timelist); }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user