Compare commits

...

12 Commits

Author SHA1 Message Date
Michael Rogers
30f6dbdc1f Add copy button to version information 2022-05-04 16:10:50 -05:00
Nikhil
d30c4fcb53 Add Gauge plugin #4896, add edit mode (#5118)
* Add Gauge plugin #4896, add edit mode
2022-04-26 14:32:23 -07:00
Nikhil
fff3ce0acf [Telemetry Collection] Telemetry table excluding start and end bound values #5095 (#5096) 2022-04-23 01:49:38 +00:00
John Hill
db5cb2517f Telemetry Table performance marks (#5107)
Co-authored-by: unlikelyzero <jchill2@gmail.com>
Co-authored-by: David Tsay <3614296+davetsay@users.noreply.github.com>
2022-04-22 17:49:50 -07:00
David Tsay
5236f1c796 Sort and merge incoming telemetry (#5042)
* use sort and merge sorted strategy for incoming data
* add shortcut for merging to beginning or end of existing rows

Co-authored-by: Andrew Henry <akhenry@gmail.com>
2022-04-22 23:51:47 +00:00
Michael Rogers
1ed253cb07 Show image thumbnails in layout views - 4884 (#5099)
* Only show thumbnails if image view is > 400px high

Co-authored-by: Andrew Henry <akhenry@gmail.com>
Co-authored-by: Charles Hacskaylo <charlesh88@gmail.com>
2022-04-22 16:14:59 -07:00
Andrew Henry
a6553ba010 Delete gauge.e2eSpec.js (#5105)
Deletes the erroneously committed e2e spec for gauges.

Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
2022-04-22 22:20:15 +00:00
David Tsay
cf6bc5be2d Search fix identifier (#4947)
* use identifier not key for object get calls
* re-index on composition or name changes only
* search should account for namespaces

Co-authored-by: Scott Bell <scott@traclabs.com>
Co-authored-by: Nikhil <nikhil.k.mandlik@nasa.gov>
2022-04-22 15:15:37 -07:00
Nikhil
a53a3a0297 Add gauge 4896 (#4919)
* Add new Gauge component

Co-authored-by: Charles Hacskaylo <charlesh88@gmail.com>
Co-authored-by: Jamie V <jamie.j.vigliotta@nasa.gov>
Co-authored-by: John Hill <john.c.hill@nasa.gov>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2022-04-22 14:58:08 -07:00
Joe Pea
402cd15726 Log plots 2 / custom ticks (#4931)
* add some types to XAxisModel

* Add UI to toggle log mode.

* handle autoscale toggle for logMode

* add log plot tests

* test log ticks work after refresh

* add an initial manually-created visual snapshot test of log plot chart

* update plot unit tests for log mode

* remove scale variable for now

* make v-for keys unique per template to avoid a small performance hazard of v-for markup in the same subtree of a template having clashing keys (Vue quirk)

Co-authored-by: unlikelyzero <jchill2@gmail.com>
Co-authored-by: John Hill <john.c.hill@nasa.gov>
Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
2022-04-20 14:28:46 -07:00
Joe Pea
a5580912e3 fix a couple flaky tests (#5061)
* fix: forgot to increase maxDiffPixels for one snapshot test, making it more chance to flake. let's see if this work

* hopefully fix PerformanceIndicator test flakes

* hopefully actually fix PerfIndicator test this time

* ok, *finally* fix PerfIndicator test... hopefully...

* simplify PerfIndicator test to check only for positive fps value
2022-04-20 10:41:09 -07:00
Nikhil
54d1b8991c [Build] Add support for node18 (#5091) 2022-04-19 15:05:08 -07:00
54 changed files with 3013 additions and 258 deletions

View File

@@ -149,11 +149,15 @@ workflows:
node-version: lts/fermium
browser: ChromeHeadless
post-steps:
- upload_code_covio
- upload_code_covio
- unit-test:
name: node16-chrome
node-version: lts/gallium
browser: ChromeHeadless
browser: ChromeHeadless
- unit-test:
name: node18-chrome
node-version: "18"
browser: ChromeHeadless
- e2e-test:
name: e2e-ci
node-version: lts/gallium
@@ -176,6 +180,10 @@ workflows:
name: node16-chrome-nightly
node-version: lts/gallium
browser: ChromeHeadless
- unit-test:
name: node18-chrome
node-version: "18"
browser: ChromeHeadless
- npm-audit:
node-version: lts/gallium
- e2e-test:

View File

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

View File

@@ -47,7 +47,7 @@ test.use({
});
test.describe('ExportAsJSON', () => {
test.only('autoscale off causes no error from undefined user range', async ({ page }) => {
test('autoscale off causes no error from undefined user range', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
await setTimeRange(page);
@@ -102,7 +102,7 @@ test.describe('ExportAsJSON', () => {
testYTicks(page, ['0.00', '0.50', '1.00', '1.50', '2.00']),
new Promise(r => setTimeout(r, 100))
.then(() => canvas.screenshot())
.then(shot => expect(shot).toMatchSnapshot('autoscale-canvas-panned.png', { maxDiffPixels: 20 }))
.then(shot => expect(shot).toMatchSnapshot('autoscale-canvas-panned.png', { maxDiffPixels: 40 }))
]);
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View 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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
<template>
<span class="form-control shell">
<span
class="field control"
:class="model.cssClass"
>
<input
type="checkbox"
:checked="isChecked"
@input="toggleCheckBox"
>
</span>
</span>
</template>
<script>
import toggleMixin from '../../toggle-check-box-mixin';
export default {
mixins: [toggleMixin],
props: {
model: {
type: Object,
required: true
}
},
data() {
return {
isChecked: this.model.value
};
}
};
</script>

View File

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

View File

@@ -0,0 +1,62 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
<template>
<span class="form-control shell">
<span
class="field control"
:class="model.cssClass"
>
<ToggleSwitch
id="switchId"
:checked="isChecked"
@change="toggleCheckBox"
/>
</span>
</span>
</template>
<script>
import toggleMixin from '../../toggle-check-box-mixin';
import ToggleSwitch from '@/ui/components/ToggleSwitch.vue';
import uuid from 'uuid';
export default {
components: {
ToggleSwitch
},
mixins: [toggleMixin],
props: {
model: {
type: Object,
required: true
}
},
data() {
return {
switchId: `toggleSwitch-${uuid}`,
isChecked: this.model.value
};
}
};
</script>

View File

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

View File

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

View File

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

View File

@@ -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 = [];

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,199 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import GaugeViewProvider from './GaugeViewProvider';
import GaugeFormController from './components/GaugeFormController.vue';
import Vue from 'vue';
export const GAUGE_TYPES = [
['Filled Dial', 'dial-filled'],
['Needle Dial', 'dial-needle'],
['Vertical Meter', 'meter-vertical'],
['Vertical Meter Inverted', 'meter-vertical-inverted'],
['Horizontal Meter', 'meter-horizontal']
];
export default function () {
return function install(openmct) {
openmct.objectViews.addProvider(new GaugeViewProvider(openmct));
openmct.forms.addNewFormControl('gauge-controller', getGaugeFormController(openmct));
openmct.types.addType('gauge', {
name: "Gauge",
creatable: true,
description: "Graphically visualize a telemetry element's current value between a minimum and maximum.",
cssClass: 'icon-gauge',
initialize(domainObject) {
domainObject.composition = [];
domainObject.configuration = {
gaugeController: {
gaugeType: GAUGE_TYPES[0][1],
isDisplayMinMax: true,
isDisplayCurVal: true,
isUseTelemetryLimits: true,
limitLow: 10,
limitHigh: 90,
max: 100,
min: 0,
precision: 2
}
};
},
form: [
{
name: "Display current value",
control: "toggleSwitch",
cssClass: "l-input",
key: "isDisplayCurVal",
property: [
"configuration",
"gaugeController",
"isDisplayCurVal"
]
},
{
name: "Display range values",
control: "toggleSwitch",
cssClass: "l-input",
key: "isDisplayMinMax",
property: [
"configuration",
"gaugeController",
"isDisplayMinMax"
]
},
{
name: "Float precision",
control: "numberfield",
cssClass: "l-input-sm",
key: "precision",
property: [
"configuration",
"gaugeController",
"precision"
]
},
{
name: "Gauge type",
options: GAUGE_TYPES.map(type => {
return {
name: type[0],
value: type[1]
};
}),
control: "select",
cssClass: "l-input-sm",
key: "gaugeController",
property: [
"configuration",
"gaugeController",
"gaugeType"
]
},
{
name: "Value ranges and limits",
control: "gauge-controller",
cssClass: "l-input",
key: "gaugeController",
required: false,
hideFromInspector: true,
property: [
"configuration",
"gaugeController"
],
validate: ({ value }, callback) => {
if (value.isUseTelemetryLimits) {
return true;
}
const { min, max, limitLow, limitHigh } = value;
const valid = {
min: true,
max: true,
limitLow: true,
limitHigh: true
};
if (min === '') {
valid.min = false;
}
if (max === '') {
valid.max = false;
}
if (max < min) {
valid.min = false;
valid.max = false;
}
if (limitLow !== '') {
valid.limitLow = min <= limitLow && limitLow < max;
}
if (limitHigh !== '') {
valid.limitHigh = min < limitHigh && limitHigh <= max;
}
if (valid.limitLow && valid.limitHigh
&& limitLow !== '' && limitHigh !== ''
&& limitLow > limitHigh) {
valid.limitLow = false;
valid.limitHigh = false;
}
if (callback) {
callback(valid);
}
return valid.min && valid.max && valid.limitLow && valid.limitHigh;
}
}
]
});
};
function getGaugeFormController(openmct) {
return {
show(element, model, onChange) {
const rowComponent = new Vue({
el: element,
components: {
GaugeFormController
},
provide: {
openmct
},
data() {
return {
model,
onChange
};
},
template: `<GaugeFormController :model="model" @onChange="onChange"></GaugeFormController>`
});
return rowComponent;
}
};
}
}

View File

@@ -0,0 +1,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);
});
});
});

View File

@@ -0,0 +1,67 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import GaugeComponent from './components/Gauge.vue';
import Vue from 'vue';
export default function GaugeViewProvider(openmct) {
return {
key: 'gauge',
name: 'Gauge',
cssClass: 'icon-gauge',
canView: function (domainObject) {
return domainObject.type === 'gauge';
},
canEdit: function (domainObject) {
if (domainObject.type === 'gauge') {
return true;
}
},
view: function (domainObject) {
let component;
return {
show: function (element) {
component = new Vue({
el: element,
components: {
GaugeComponent
},
provide: {
openmct,
domainObject,
composition: openmct.composition.get(domainObject)
},
template: '<gauge-component></gauge-component>'
});
},
destroy: function (element) {
component.$destroy();
component = undefined;
}
};
},
priority: function () {
return 1;
}
};
}

View File

@@ -0,0 +1,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>

View 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>

View 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;
}
}
}

View File

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

View File

@@ -163,10 +163,13 @@
</div>
</div>
<div
v-if="displayThumbnails"
class="c-imagery__thumbs-wrapper"
:class="[
{ 'is-paused': isPaused && !isFixed },
{ 'is-autoscroll-off': !resizingWindow && !autoScroll && !isPaused }
{ 'is-autoscroll-off': !resizingWindow && !autoScroll && !isPaused },
{ 'is-small-thumbs': displayThumbnailsSmall },
{ 'hide': !displayThumbnails }
]"
>
<div
@@ -179,6 +182,7 @@
:key="image.url + image.time"
class="c-imagery__thumb c-thumb"
:class="{ selected: focusedImageIndex === index && isPaused }"
:title="image.formattedTime"
@click="thumbnailClicked(index)"
>
<a
@@ -232,6 +236,8 @@ const ARROW_LEFT = 37;
const SCROLL_LATENCY = 250;
const ZOOM_SCALE_DEFAULT = 1;
const SHOW_THUMBS_THRESHOLD_HEIGHT = 200;
const SHOW_THUMBS_FULLSIZE_THRESHOLD_HEIGHT = 600;
export default {
components: {
@@ -272,6 +278,7 @@ export default {
imageContainerHeight: undefined,
sizedImageWidth: 0,
sizedImageHeight: 0,
viewHeight: 0,
lockCompass: true,
resizingWindow: false,
timeContext: undefined,
@@ -290,7 +297,8 @@ export default {
imageTranslateY: 0,
pan: undefined,
animateZoom: true,
imagePanned: false
imagePanned: false,
forceShowThumbnails: false
};
},
computed: {
@@ -306,6 +314,15 @@ export default {
return compassRoseSizingClasses;
},
displayThumbnails() {
return (
this.forceShowThumbnails
|| this.viewHeight >= SHOW_THUMBS_THRESHOLD_HEIGHT
);
},
displayThumbnailsSmall() {
return this.viewHeight > SHOW_THUMBS_THRESHOLD_HEIGHT && this.viewHeight <= SHOW_THUMBS_FULLSIZE_THRESHOLD_HEIGHT;
},
time() {
return this.formatTime(this.focusedImage);
},
@@ -583,6 +600,9 @@ export default {
},
methods: {
calculateViewHeight() {
this.viewHeight = this.$el.clientHeight;
},
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView(this.objectPath);
@@ -956,6 +976,7 @@ export default {
}
this.setSizedImageDimensions();
this.calculateViewHeight();
},
setSizedImageDimensions() {
this.focusedImageNaturalAspectRatio = this.$refs.focusedImage.naturalWidth / this.$refs.focusedImage.naturalHeight;
@@ -984,6 +1005,8 @@ export default {
this.scrollToRight('reset');
}
this.calculateViewHeight();
this.$nextTick(() => {
this.resizingWindow = false;
});

View File

@@ -137,9 +137,9 @@
animation-name: fade-out;
animation-timing-function: ease-in;
animation-iteration-count: 1;
animation-fill-mode: forwards;
animation-fill-mode: forwards;
}
&__thumbs-wrapper {
display: flex; // Uses row layout
@@ -161,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 {
@@ -184,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;
@@ -209,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;
}
}
}
@@ -222,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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

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

View File

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

View File

@@ -123,4 +123,11 @@
h2 {
font-size: 1.5em;
}
button {
color: #aaaaaa;
font-size: 0.7em;
}
button:active {
color: $colorBtnBgHov;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,8 +23,14 @@
<p>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.</p>
<p>Open MCT includes source code licensed under additional open source licenses. See the Open Source Licenses file included with this distribution or <a @click="showLicenses">click here for third party licensing information</a>.</p>
</div>
<h2>Version Information</h2>
<ul class="t-info l-info s-info">
<h2>Version Information<button
class="icon-export"
@click="copyVersionToClipboard"
></button></h2>
<ul
ref="versionInformation"
class="t-info l-info s-info"
>
<li>Version: {{ buildInfo.version || 'Unknown' }}</li>
<li>Build Date: {{ buildInfo.buildDate || 'Unknown' }}</li>
<li>Revision: {{ buildInfo.revision || 'Unknown' }}</li>
@@ -46,6 +52,11 @@ export default {
methods: {
showLicenses() {
window.open('#/licenses');
},
copyVersionToClipboard(e) {
const versionInfoElement = this.$refs.versionInformation;
const versionInfoText = versionInfoElement.innerHTML;
window.navigator.clipboard.writeText(versionInfoText);
}
}
};