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/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/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/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..c0f1132223d 100644 --- a/src/api/time/IndependentTimeContext.js +++ b/src/api/time/IndependentTimeContext.js @@ -321,8 +321,16 @@ 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(); + } + + 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 @@