diff --git a/.webpack/webpack.prod.mjs b/.webpack/webpack.prod.mjs index f0656b67e09..0b2e7921517 100644 --- a/.webpack/webpack.prod.mjs +++ b/.webpack/webpack.prod.mjs @@ -15,5 +15,5 @@ export default merge(common, { __OPENMCT_ROOT_RELATIVE__: '""' }) ], - devtool: 'source-map' + devtool: 'eval-source-map' }); diff --git a/API.md b/API.md index e607deeb3c1..d8b63d03a6e 100644 --- a/API.md +++ b/API.md @@ -704,6 +704,20 @@ interface EnumFormatter extends Formatter { validate: (value: any) => boolean; } ``` +##### Time Formats + +Time formatters are used to format and parse datetime values. See as an example the UTC time formatter provided in src/plugins/utcTimeSystem/UTCTimeFormat.js. + +If a formatDate method is provided, it will be used in conjunction with a duration formatter to provide split date and time inputs for the time conductor. + +```ts +interface TimeFormatter extends Formatter { + parse: (value: string) => number; + format: (value: number) => string; + formatDate?: (value: number) => string; + validate: (value: any) => boolean; +} +``` ##### Registering Formats diff --git a/e2e/appActions.js b/e2e/appActions.js index 105854b4cc7..6d94b1dc022 100644 --- a/e2e/appActions.js +++ b/e2e/appActions.js @@ -560,7 +560,7 @@ async function setFixedIndependentTimeConductorBounds(page, { start, end }) { await page.getByLabel('Enable Independent Time Conductor').click(); // Bring up the time conductor popup - await page.getByLabel('Independent Time Conductor Settings').click(); + await page.getByLabel('Independent Time Conductor Panel').click(); await expect(page.getByLabel('Time Conductor Options')).toBeInViewport(); await _setTimeBounds(page, start, end); diff --git a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js index cd90c76c234..73cd3b7df1c 100644 --- a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js +++ b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js @@ -122,11 +122,8 @@ test.describe('Example Imagery Object', () => { await expect(page.locator('#independentTCToggle')).toBeChecked(); await expect(page.locator('.c-compact-tc').first()).toBeVisible(); - await expect( - page.getByRole('button', { name: 'Independent Time Conductor Settings' }) - ).toBeEnabled(); - await page.getByRole('button', { name: 'Independent Time Conductor Settings' }).click(); - await expect(page.getByLabel('Time Conductor Options')).toBeVisible(); + await page.getByLabel('Independent Time Conductor Panel').click(); + await expect(page.getByLabel('Time Conductor Options')).toBeInViewport(); await page.getByLabel('Time Conductor Options').hover({ trial: true }); await page.getByRole('textbox', { name: 'Start date' }).hover({ trial: true }); @@ -140,7 +137,6 @@ test.describe('Example Imagery Object', () => { await page.keyboard.press('Tab'); await page.getByRole('textbox', { name: 'End time' }).hover({ trial: true }); await page.getByRole('textbox', { name: 'End time' }).fill('01:11:00'); - await page.getByRole('textbox', { name: 'End time' }).fill('01:11:00'); await page.getByLabel('Submit time bounds').click(); // wait for image thumbnails to stabilize @@ -170,7 +166,8 @@ test.describe('Example Imagery Object', () => { // check image date await expect(page.getByText('2021-12-30 01:11:00.000Z').first()).toBeVisible(); // change independent time to realtime - await page.getByRole('button', { name: 'Independent Time Conductor Settings' }).click(); + await page.getByLabel('Independent Time Conductor Panel').click(); + await expect(page.getByLabel('Time Conductor Options')).toBeInViewport(); await page.getByRole('button', { name: 'Independent Time Conductor Mode Menu' }).click(); await page.getByRole('menuitem', { name: /Real-Time/ }).click(); // timestamp shouldn't be in the past anymore diff --git a/e2e/tests/functional/plugins/notebook/notebookSnapshots.e2e.spec.js b/e2e/tests/functional/plugins/notebook/notebookSnapshots.e2e.spec.js index 8369012c7c5..6018b3f9b32 100644 --- a/e2e/tests/functional/plugins/notebook/notebookSnapshots.e2e.spec.js +++ b/e2e/tests/functional/plugins/notebook/notebookSnapshots.e2e.spec.js @@ -104,8 +104,9 @@ test.describe('Snapshot Container tests', () => { await page.locator('.ptro-crp-el').click(); await page.locator('.ptro-text-tool-input').fill('...is there life on mars?'); // When working with Painterro, we need to check that the Apply button is hidden after clicking - await page.getByTitle('Apply').click(); - await expect(page.getByTitle('Apply')).toBeHidden(); + const painterroApplyButton = page.locator('.ptro-text-tool-buttons').getByTitle('Apply'); + await painterroApplyButton.click(); + await expect(painterroApplyButton).toBeHidden(); // Save and exit annotation window await page.getByRole('button', { name: 'Save' }).click(); @@ -130,8 +131,9 @@ test.describe('Snapshot Container tests', () => { await page.locator('.ptro-crp-el').click(); await page.locator('.ptro-text-tool-input').fill('...is there life on mars?'); // When working with Painterro, we need to check that the Apply button is hidden after clicking - await page.getByTitle('Apply').click(); - await expect(page.getByTitle('Apply')).toBeHidden(); + const painterroApplyButton = page.locator('.ptro-text-tool-buttons').getByTitle('Apply'); + await painterroApplyButton.click(); + await expect(painterroApplyButton).toBeHidden(); // Save and exit annotation window await page.getByRole('button', { name: 'Save' }).click(); diff --git a/e2e/tests/functional/plugins/plot/plotControls.e2e.spec.js b/e2e/tests/functional/plugins/plot/plotControls.e2e.spec.js index e654ff213df..643c2df35b0 100644 --- a/e2e/tests/functional/plugins/plot/plotControls.e2e.spec.js +++ b/e2e/tests/functional/plugins/plot/plotControls.e2e.spec.js @@ -129,7 +129,8 @@ test.describe('Plot Controls', () => { await page.getByLabel('Enable Independent Time Conductor').click(); // Bring up the independent time conductor popup and switch to fixed time mode - await page.getByLabel('Independent Time Conductor Settings').click(); + await page.getByLabel('Independent Time Conductor Panel').click(); + await expect(page.getByLabel('Time Conductor Options')).toBeInViewport(); await page.getByLabel('Independent Time Conductor Mode Menu').click(); await page.getByRole('menuitem', { name: /Fixed Timespan/ }).click(); diff --git a/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js b/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js index 9fe41e588f5..e80bfdc9f48 100644 --- a/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js +++ b/e2e/tests/functional/plugins/timeConductor/timeConductor.e2e.spec.js @@ -151,7 +151,7 @@ test.describe('Time conductor operations', () => { await expect(page.getByLabel('Start date')).toHaveAttribute( 'title', - 'Specified start date exceeds end bound' + 'Start bound must be less than end bound' ); await expect(page.getByLabel('Start bounds')).not.toHaveText(`${DAY} ${TWO_O_CLOCK}.000Z`); await expect(page.getByLabel('End bounds')).not.toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`); @@ -170,10 +170,6 @@ test.describe('Time conductor operations', () => { // Open the time conductor popup await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click(); - // FIXME: https://github.com/nasa/openmct/pull/7818 - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(500); - await page.getByLabel('Start date').fill(DAY_AFTER); await page.getByLabel('Start time').fill(ONE_O_CLOCK); await page.getByLabel('End date').fill(DAY); @@ -182,7 +178,7 @@ test.describe('Time conductor operations', () => { await expect(page.getByLabel('Start date')).toHaveAttribute( 'title', - 'Specified start date exceeds end bound' + 'Start bound must be less than end bound' ); await expect(page.getByLabel('Start bounds')).not.toHaveText( `${DAY_AFTER} ${ONE_O_CLOCK}.000Z` diff --git a/src/api/forms/components/controls/LocatorField.vue b/src/api/forms/components/controls/LocatorField.vue index 7dc159a6dc2..192b42e71ac 100644 --- a/src/api/forms/components/controls/LocatorField.vue +++ b/src/api/forms/components/controls/LocatorField.vue @@ -23,7 +23,7 @@ @@ -43,6 +43,11 @@ export default { } }, emits: ['on-change'], + computed: { + initialSelection() { + return this.model.parent || this.model.value?.[0]; + } + }, methods: { handleItemSelection(item) { const data = { diff --git a/src/api/time/IndependentTimeContext.js b/src/api/time/IndependentTimeContext.js index 148bf52adf4..19915cdc34d 100644 --- a/src/api/time/IndependentTimeContext.js +++ b/src/api/time/IndependentTimeContext.js @@ -321,8 +321,19 @@ class IndependentTimeContext extends TimeContext { return this.upstreamTimeContext.setMode(...arguments); } - if (mode === MODES.realtime && this.activeClock === undefined) { - throw `Unknown clock. Has a clock been registered with 'addClock'?`; + if (mode === MODES.realtime) { + // TODO: This should probably happen up front in creating an independent time context + // TODO: not just in time every time setMode is called + if (this.activeClock === undefined) { + this.activeClock = this.globalTimeContext.getClock(); + this.emit('clock', this.activeClock); + this.emit(TIME_CONTEXT_EVENTS.clockChanged, this.activeClock); + this.activeClock.on('tick', this.tick); + } + + if (this.activeClock === undefined) { + throw `Unknown clock. Has a clock been registered with 'addClock'?`; + } } if (mode !== this.mode) { diff --git a/src/api/time/TimeAPI.js b/src/api/time/TimeAPI.js index 8b29a7c5ff8..cbec60ee0a4 100644 --- a/src/api/time/TimeAPI.js +++ b/src/api/time/TimeAPI.js @@ -135,33 +135,33 @@ class TimeAPI extends GlobalTimeContext { /** * Get or set an independent time context which follows the TimeAPI timeSystem, - * but with different offsets for a given domain object - * @param {string} key The identifier key of the domain object these offsets are set for - * @param {ClockOffsets | TimeConductorBounds} value This maintains a sliding time window of a fixed width that automatically updates - * @param {key | string} clockKey the real time clock key currently in use + * but with different bounds for a given domain object + * @param {string} keyString The keyString identifier of the domain object these offsets are set for + * @param {TimeConductorBounds | ClockOffsets} boundsOrOffsets either bounds if in fixed mode, or offsets if in realtime mode + * @param {string} clockKey the key for the real time clock to use */ - addIndependentContext(key, value, clockKey) { - let timeContext = this.getIndependentContext(key); + addIndependentContext(keyString, boundsOrOffsets, clockKey) { + let timeContext = this.getIndependentContext(keyString); //stop following upstream time context since the view has its own timeContext.resetContext(); if (clockKey) { timeContext.setClock(clockKey); - timeContext.setMode(REALTIME_MODE_KEY, value); + timeContext.setMode(REALTIME_MODE_KEY, boundsOrOffsets); } else { - timeContext.setMode(FIXED_MODE_KEY, value); + timeContext.setMode(FIXED_MODE_KEY, boundsOrOffsets); } // Also emit the mode in case it's different from the previous time context timeContext.emit(TIME_CONTEXT_EVENTS.modeChanged, structuredClone(timeContext.getMode())); // Notify any nested views to update, pass in the viewKey so that particular view can skip getting an upstream context - this.emit('refreshContext', key); + this.emit('refreshContext', keyString); return () => { //follow any upstream time context - this.emit('removeOwnContext', key); + this.emit('removeOwnContext', keyString); }; } diff --git a/src/api/time/TimeContext.js b/src/api/time/TimeContext.js index 6bdd6ec2046..c7e8726d1eb 100644 --- a/src/api/time/TimeContext.js +++ b/src/api/time/TimeContext.js @@ -193,10 +193,10 @@ class TimeContext extends EventEmitter { valid: false, message: 'Start and end must be specified as integer values' }; - } else if (bounds.start > bounds.end) { + } else if (bounds.start >= bounds.end) { return { valid: false, - message: 'Specified start date exceeds end bound' + message: 'Start bound must be less than end bound' }; } @@ -261,7 +261,7 @@ class TimeContext extends EventEmitter { } else if (offsets.start >= offsets.end) { return { valid: false, - message: 'Specified start offset must be < end offset' + message: 'Start offset must be less than end offset' }; } diff --git a/src/plugins/charts/scatter/ScatterPlotView.vue b/src/plugins/charts/scatter/ScatterPlotView.vue index 95b623ecac1..b3084c3ca62 100644 --- a/src/plugins/charts/scatter/ScatterPlotView.vue +++ b/src/plugins/charts/scatter/ScatterPlotView.vue @@ -57,8 +57,8 @@ export default { const yAxisUnit = yAxisMetadata.units ? `(${yAxisMetadata.units})` : ''; return { - xAxisTitle: `${xAxisMetadata.name || ''} ${xAxisUnit}`, - yAxisTitle: `${yAxisMetadata.name || ''} ${yAxisUnit}` + xAxisTitle: `${xAxis || ''} ${xAxisUnit}`, + yAxisTitle: `${yAxis || ''} ${yAxisUnit}` }; } }, diff --git a/src/plugins/correlationTelemetryPlugin/plugin.js b/src/plugins/correlationTelemetryPlugin/plugin.js new file mode 100644 index 00000000000..a1857fd1560 --- /dev/null +++ b/src/plugins/correlationTelemetryPlugin/plugin.js @@ -0,0 +1,228 @@ +const CORRELATOR_TYPE = 'telemetry.correlator'; + +export default function CorrelationTelemetryPlugin(openmct) { + // eslint-disable-next-line no-shadow + return function install(openmct) { + function getTelemetryObject(idString) { + return openmct.objects.get(idString); + } + + function getTelemetry(object, options) { + return openmct.telemetry.request(object, options); + } + + openmct.types.addType(CORRELATOR_TYPE, { + name: 'Correlation Telemetry', + description: `Combines telemetry from multiple sources to produce telemetry correlated by timestamp with a given time tolerance.`, + cssClass: 'icon-object', + creatable: true, + initialize: function (obj) { + obj.telemetry = {}; + }, + form: [ + { + key: 'xSource', + name: 'X Axis Source', + control: 'locator', + required: true, + cssClass: 'grows' + }, + { + key: 'ySource', + name: 'Y Axis Source', + control: 'locator', + required: true, + cssClass: 'grows' + } + ] + }); + + openmct.telemetry.addProvider({ + supportsMetadata: function (domainObject) { + return domainObject.type === CORRELATOR_TYPE; + }, + getMetadata: function (domainObject) { + let metadata = {}; + metadata.values = openmct.time.getAllTimeSystems().map(function (timeSystem, i) { + return { + name: timeSystem.name, + key: timeSystem.key, + source: timeSystem.source, + format: timeSystem.timeFormat, + hints: { domain: i } + }; + }); + metadata.values.push({ + name: 'X', + key: 'x', + source: 'x', + hints: { xSource: 1, range: 1 } + }); + metadata.values.push({ + name: 'Y', + key: 'y', + source: 'y', + hints: { ySource: 1, range: 2 } + }); + return metadata; + }, + supportsRequest: function (domainObject) { + return domainObject.type === CORRELATOR_TYPE; + }, + request: function (domainObject, options) { + let telemResults = {}; + let telemObject; + + const xSourceIdentifier = openmct.objects.makeKeyString(domainObject.xSource[0].identifier); + let xPromise = getTelemetryObject(xSourceIdentifier) + .then((object) => { + telemObject = object; + return getTelemetry(object, options); + }) + .then((data) => { + let source = 'x'; + telemResults[source] = { + object: telemObject + }; + let metadata = openmct.telemetry.getMetadata(telemObject); + let valueMeta = metadata.valuesForHints(['range'])[0]; + telemResults[source].coorelatorFormat = openmct.telemetry.getValueFormatter(valueMeta); + telemResults[source].coorelatorFormat = openmct.telemetry.getValueFormatter(valueMeta); + telemResults[source].timestampFormat = openmct.telemetry.getValueFormatter( + metadata.value(options.domain) + ); + telemResults[source].data = data; + }); + + const ySourceIdentifier = openmct.objects.makeKeyString(domainObject.ySource[0].identifier); + let yPromise = getTelemetryObject(ySourceIdentifier) + .then((object) => { + telemObject = object; + return getTelemetry(object, options); + }) + .then((data) => { + let source = 'y'; + telemResults[source] = { + object: telemObject + }; + let metadata = openmct.telemetry.getMetadata(telemObject); + let valueMeta = metadata.valuesForHints(['range'])[0]; + telemResults[source].coorelatorFormat = openmct.telemetry.getValueFormatter(valueMeta); + telemResults[source].coorelatorFormat = openmct.telemetry.getValueFormatter(valueMeta); + telemResults[source].timestampFormat = openmct.telemetry.getValueFormatter( + metadata.value(options.domain) + ); + telemResults[source].data = data; + }); + + return Promise.all([xPromise, yPromise]).then(function () { + let results = []; + let xByTime = telemResults.x.data.reduce(function (m, datum) { + m[telemResults.x.timestampFormat.parse(datum)] = + telemResults.x.coorelatorFormat.parse(datum); + return m; + }, {}); + telemResults.y.data.forEach(function (datum) { + let timestamp = telemResults.y.timestampFormat.parse(datum); + if (xByTime[timestamp] !== undefined) { + let resultDatum = { + x: xByTime[timestamp], + y: telemResults.y.coorelatorFormat.parse(datum) + }; + resultDatum[options.domain] = timestamp; + results.push(resultDatum); + } + }); + return results; + }); + }, + supportsSubscribe: function (domainObject) { + return domainObject.type === CORRELATOR_TYPE; + }, + subscribe: function (domainObject, callback) { + let telem = {}; + let done = false; + let unsubscribes = []; + + function sendUpdate() { + if (done) { + return; + } + if (!telem.y.latest || !telem.x.latest) { + return; + } + if (telem.y.latestTimestamp !== telem.x.latestTimestamp) { + return; + } + let datum = { + x: telem.x.coorelatorFormat.parse(telem.x.latest), + y: telem.y.coorelatorFormat.parse(telem.y.latest) + }; + datum[openmct.time.timeSystem().key] = Math.max( + telem.x.latestTimestamp, + telem.y.latestTimestamp + ); + delete telem.x.latest; + delete telem.y.latest; + delete telem.x.latestTimestamp; + delete telem.y.latestTimestamp; + callback(datum); + } + + const xSourceIdentifier = openmct.objects.makeKeyString(domainObject.xSource[0].identifier); + getTelemetryObject(xSourceIdentifier).then(function (xObject) { + if (done) { + return; + } + telem.x = { + object: xObject + }; + let metadata = openmct.telemetry.getMetadata(xObject); + let valueMeta = metadata.valuesForHints(['range'])[0]; + telem.x.coorelatorFormat = openmct.telemetry.getValueFormatter(valueMeta); + telem.x.timestampFormat = openmct.telemetry.getValueFormatter( + metadata.value(openmct.time.timeSystem().key) + ); + unsubscribes.push( + openmct.telemetry.subscribe(xObject, function (datum) { + telem.x.latest = datum; + telem.x.latestTimestamp = telem.x.timestampFormat.parse(datum); + requestAnimationFrame(sendUpdate); + }) + ); + }); + + const ySourceIdentifier = openmct.objects.makeKeyString(domainObject.ySource[0].identifier); + getTelemetryObject(ySourceIdentifier).then(function (yObject) { + if (done) { + return; + } + telem.y = { + object: yObject + }; + let metadata = openmct.telemetry.getMetadata(yObject); + let valueMeta = metadata.valuesForHints(['range'])[0]; + telem.y.coorelatorFormat = openmct.telemetry.getValueFormatter(valueMeta); + telem.y.timestampFormat = openmct.telemetry.getValueFormatter( + metadata.value(openmct.time.timeSystem().key) + ); + unsubscribes.push( + openmct.telemetry.subscribe(yObject, function (datum) { + telem.y.latest = datum; + telem.y.latestTimestamp = telem.y.timestampFormat.parse(datum); + requestAnimationFrame(sendUpdate); + }) + ); + }); + + return function unsubscribe() { + done = true; + unsubscribes.forEach(function (u) { + u(); + }); + unsubscribes = undefined; + }; + } + }); + }; +} diff --git a/src/plugins/plot/axis/XAxis.vue b/src/plugins/plot/axis/XAxis.vue index ceb58503312..d35bedb624b 100644 --- a/src/plugins/plot/axis/XAxis.vue +++ b/src/plugins/plot/axis/XAxis.vue @@ -132,7 +132,7 @@ export default { }; }); } - + console.log('xKeyOptions', this.xKeyOptions, 'this.xAxis', this.xAxis); this.xAxisLabel = this.xAxis.get('label'); this.selectedXKeyOptionKey = this.xKeyOptions.length > 0 ? this.getXKeyOption(xAxisKey).key : xAxisKey; diff --git a/src/plugins/plugins.js b/src/plugins/plugins.js index 76b9e590d3d..470b90d97a6 100644 --- a/src/plugins/plugins.js +++ b/src/plugins/plugins.js @@ -34,6 +34,7 @@ import ClearData from './clearData/plugin.js'; import Clock from './clock/plugin.js'; import ConditionPlugin from './condition/plugin.js'; import ConditionWidgetPlugin from './conditionWidget/plugin.js'; +import CorrelationTelemetryPlugin from './correlationTelemetryPlugin/plugin.js'; import CouchDBSearchFolder from './CouchDBSearchFolder/plugin.js'; import DefaultRootName from './defaultRootName/plugin.js'; import DeviceClassifier from './DeviceClassifier/plugin.js'; @@ -177,6 +178,7 @@ plugins.Gauge = GaugePlugin; plugins.Timelist = TimeList; plugins.InspectorViews = InspectorViews; plugins.InspectorDataVisualization = InspectorDataVisualization; +plugins.CorrelationTelemetry = CorrelationTelemetryPlugin; plugins.EventTimestripPlugin = EventTimestripPlugin; export default plugins; diff --git a/src/plugins/timeConductor/ConductorAxis.vue b/src/plugins/timeConductor/ConductorAxis.vue index bc824d08a1b..688973799af 100644 --- a/src/plugins/timeConductor/ConductorAxis.vue +++ b/src/plugins/timeConductor/ConductorAxis.vue @@ -41,21 +41,16 @@ import { TIME_CONTEXT_EVENTS } from '../../api/time/constants.js'; import utcMultiTimeFormat from './utcMultiTimeFormat.js'; const PADDING = 1; -const DEFAULT_DURATION_FORMATTER = 'duration'; const PIXELS_PER_TICK = 100; const PIXELS_PER_TICK_WIDE = 200; export default { - inject: ['openmct'], + inject: ['openmct', 'isFixedTimeMode'], props: { viewBounds: { type: Object, required: true }, - isFixed: { - type: Boolean, - required: true - }, altPressed: { type: Boolean, required: true @@ -198,22 +193,8 @@ export default { this.axisElement.call(this.xAxis); this.setScale(); }, - getActiveFormatter() { - let timeSystem = this.openmct.time.getTimeSystem(); - - if (this.isFixed) { - return this.getFormatter(timeSystem.timeFormat); - } else { - return this.getFormatter(timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER); - } - }, - getFormatter(key) { - return this.openmct.telemetry.getValueFormatter({ - format: key - }).formatter; - }, dragStart($event) { - if (this.isFixed) { + if (this.isFixedTimeMode) { this.dragStartX = $event.clientX; if (this.altPressed) { diff --git a/src/plugins/timeConductor/ConductorClock.vue b/src/plugins/timeConductor/ConductorClock.vue index 23f29d48951..f4321ff1337 100644 --- a/src/plugins/timeConductor/ConductorClock.vue +++ b/src/plugins/timeConductor/ConductorClock.vue @@ -23,8 +23,8 @@