diff --git a/API.md b/API.md index e607deeb3c1..09acdbd4f2a 100644 --- a/API.md +++ b/API.md @@ -126,18 +126,25 @@ script loaders are also supported. - Open MCT - + Open MCT + + - +
+ ``` The Open MCT library included above requires certain assets such as html @@ -149,7 +156,7 @@ There are some plugins bundled with the application that provide UI, persistence, and other default configuration which are necessary to be able to do anything with the application initially. Any of these plugins can, in principle, be replaced with a custom plugin. The included plugins are -documented in the [Included Plugins](#included-plugins) section. +documented in the [Included Plugins](#plugins) section. ## Types @@ -424,24 +431,24 @@ var domainObject = { ## Telemetry API -The Open MCT telemetry API provides two main sets of interfaces-- one for -integrating telemetry data into Open MCT, and another for developing Open MCT -visualization plugins utilizing the telemetry API. +The Open MCT telemetry API provides two main sets of interfaces +1. For integrating telemetry data into Open MCT, and +2. For developing Open MCT visualization plugins utilizing the telemetry API. -The APIs for visualization plugins are still a work in progress and docs may -change at any time. However, the APIs for integrating telemetry metadata into -Open MCT are stable and documentation is included below. +The APIs for integrating telemetry metadata into Open MCT are stable and documentation is included below. However, the APIs for visualization plugins are still a work in progress and docs may change at any time. ### Integrating Telemetry Sources -There are two main tasks for integrating telemetry sources-- describing telemetry objects with relevant metadata, and then providing telemetry data for those objects. You'll use an [Object Provider](#object-providers) to provide objects with the necessary [Telemetry Metadata](#telemetry-metadata), and then register a [Telemetry Provider](#telemetry-providers) to retrieve telemetry data for those objects. Alternatively, you can register a telemetry metadata provider to provide the necessary telemetry metadata. +There are two main tasks for integrating telemetry sources +* Describing telemetry objects with relevant metadata. You'll use an [Object Provider](#object-providers) to provide objects with the necessary [Telemetry Metadata](#telemetry-metadata). Alternatively, you can register a telemetry metadata provider to provide the necessary telemetry metadata. +* Providing telemetry data for those objects. You'll register a [Telemetry Provider](#telemetry-providers) to retrieve telemetry data for those objects. For a step-by-step guide to building a telemetry adapter, please see the [Open MCT Tutorials](https://github.com/nasa/openmct-tutorial). #### Telemetry Metadata -A telemetry object is a domain object with a telemetry property. To take an example from the tutorial, here is the telemetry object for the "fuel" measurement of the spacecraft: +A telemetry object is a domain object with a `telemetry` property. To take an example from the tutorial, here is the telemetry object for the "fuel" measurement of the spacecraft: ```json { @@ -478,23 +485,24 @@ A telemetry object is a domain object with a telemetry property. To take an exa } ``` -The most important part of the telemetry metadata is the `values` property-- this describes the attributes of telemetry datums (objects) that a telemetry provider returns. These descriptions must be provided for telemetry views to work properly. +The most important part of the telemetry metadata is the `values` property. This describes the attributes of telemetry datums (objects) that a telemetry provider returns. These descriptions must be provided for telemetry views to work properly. ##### Values `telemetry.values` is an array of value description objects, which have the following fields: -attribute | type | flags | notes ---- | --- | --- | --- -`key` | string | required | unique identifier for this field. -`hints` | object | required | Hints allow views to intelligently select relevant attributes for display, and are required for most views to function. See section on "Value Hints" below. -`name` | string | optional | a human readable label for this field. If omitted, defaults to `key`. -`source` | string | optional | identifies the property of a datum where this value is stored. If omitted, defaults to `key`. -`format` | string | optional | a specific format identifier, mapping to a formatter. If omitted, uses a default formatter. For enumerations, use `enum`. For timestamps, use `utc` if you are using utc dates, otherwise use a key mapping to your custom date format. -`unit` | string | optional | the unit of this value, e.g. `km`, `seconds`, `parsecs` -`min` | number | optional | the minimum possible value of this measurement. Will be used by plots, gauges, etc to automatically set a min value. -`max` | number | optional | the maximum possible value of this measurement. Will be used by plots, gauges, etc to automatically set a max value. -`enumerations` | array | optional | for objects where `format` is `"enum"`, this array tracks all possible enumerations of the value. Each entry in this array is an object, with a `value` property that is the numerical value of the enumeration, and a `string` property that is the text value of the enumeration. ex: `{"value": 0, "string": "OFF"}`. If you use an enumerations array, `min` and `max` will be set automatically for you. +attribute | type | flags | notes +--- |---------|----------| --- +`key` | string | required | unique identifier for this field. +`hints` | object | required | Hints allow views to intelligently select relevant attributes for display, and are required for most views to function. See section on "Value Hints" below. +`name` | string | optional | a human readable label for this field. If omitted, defaults to `key`. +`source` | string | optional | identifies the property of a datum where this value is stored. If omitted, defaults to `key`. +`format` | string | optional | a specific format identifier, mapping to a formatter. If omitted, uses a default formatter. For enumerations, use `enum`. For timestamps, use `utc` if you are using utc dates, otherwise use a key mapping to your custom date format. For arrays use `number[]` or `string[]` See arrays below in the this table. +`unit` | string | optional | the unit of this value, e.g. `km`, `seconds`, `parsecs` +`min` | number | optional | the minimum possible value of this measurement. Will be used by plots, gauges, etc to automatically set a min value. +`max` | number | optional | the maximum possible value of this measurement. Will be used by plots, gauges, etc to automatically set a max value. +`enumerations` | array | optional | for objects where `format` is `"enum"`, this array tracks all possible enumerations of the value. Each entry in this array is an object, with a `value` property that is the numerical value of the enumeration, and a `string` property that is the text value of the enumeration. ex: `{"value": 0, "string": "OFF"}`. If you use an enumerations array, `min` and `max` will be set automatically for you. +`arrays` | string | optional | for objects where `format` is `"number[]" or "string[]"`. Will be used by plots, gauges, etc to automatically interpret values as arrays. ###### Value Hints diff --git a/e2e/tests/functional/plugins/plot/timeTicks.e2e.spec.js b/e2e/tests/functional/plugins/plot/timeTicks.e2e.spec.js new file mode 100644 index 00000000000..c4ffba5e08d --- /dev/null +++ b/e2e/tests/functional/plugins/plot/timeTicks.e2e.spec.js @@ -0,0 +1,153 @@ +import { expect, test } from '@playwright/test'; + +import { createDomainObjectWithDefaults, setTimeConductorBounds } from '../../../../appActions.js'; + +test.describe('Time Tick Generation', () => { + // Test cases will go here + let sineWaveGeneratorObject; + + test.beforeEach(async ({ page }) => { + // Open a browser, navigate to the main page, and wait until all networkevents to resolve + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + sineWaveGeneratorObject = await createDomainObjectWithDefaults(page, { + type: 'Sine Wave Generator' + }); + + // Navigate to Sine Wave Generator + await page.goto(sineWaveGeneratorObject.url); + }); + + test('Plot time-series ticks are functionally correct over a period of 6 months, between two years', async ({ + page + }) => { + const startDate = '2022-09-01'; + const startTime = '22:00:00'; + const endDate = '2023-03-01'; + const endTime = '22:00:30'; + await setTimeConductorBounds(page, { startDate, startTime, endDate, endTime }); + + await testYearTimeSeriesTicks(page); + }); + + test('Plot time-series ticks are functionally correct over a period of days', async ({ + page + }) => { + const startDate = '2023-03-22'; + const startTime = '00:00:00'; + const endDate = '2023-04-20'; + const endTime = '12:00:00'; + await setTimeConductorBounds(page, { startDate, startTime, endDate, endTime }); + + await testDaysTimeSeriesTicks(page); + }); + + test('Plot time-series ticks are functionally correct over a period of hours', async ({ + page + }) => { + const startDate = '2023-03-22'; + const startTime = '01:15:00'; + const endDate = '2023-03-22'; + const endTime = '09:15:00'; + await setTimeConductorBounds(page, { startDate, startTime, endDate, endTime }); + + await testHoursTimeSeriesTicks(page); + }); + + test('Plot time-series ticks are functionally correct over a period of minutes', async ({ + page + }) => { + const startDate = '2023-03-22'; + const startTime = '01:15:00'; + const endDate = '2023-03-22'; + const endTime = '01:35:00'; + await setTimeConductorBounds(page, { startDate, startTime, endDate, endTime }); + + await testMinutesTimeSeriesTicks(page); + }); + + test('Plot time-series ticks are functionally correct over a period of seconds', async ({ + page + }) => { + const startDate = '2023-03-22'; + const startTime = '01:22:00'; + const endDate = '2023-03-22'; + const endTime = '01:23:00'; + await setTimeConductorBounds(page, { startDate, startTime, endDate, endTime }); + + await testSecondsTimeSeriesTicks(page); + }); +}); + +/** + * @param {import('@playwright/test').Page} page + */ +async function testYearTimeSeriesTicks(page) { + const xTicks = page.locator('.gl-plot-x-tick-label'); + await expect(xTicks).toHaveCount(6); + await expect(xTicks.nth(0)).toHaveText('2022-09-01 22:00:00'); + await expect(xTicks.nth(1)).toHaveText('2022-10-01 22:00:00'); + await expect(xTicks.nth(2)).toHaveText('2022-11-01 22:00:00'); + await expect(xTicks.nth(3)).toHaveText('2022-12-01 23:00:00'); + await expect(xTicks.nth(4)).toHaveText('2023-01-01 23:00:00'); + await expect(xTicks.nth(5)).toHaveText('2023-02-01 23:00:00'); +} + +async function testDaysTimeSeriesTicks(page) { + const xTicks = page.locator('.gl-plot-x-tick-label'); + await expect(xTicks).toHaveCount(10); + await expect(xTicks.nth(0)).toHaveText('2023-03-24'); + await expect(xTicks.nth(1)).toHaveText('2023-03-27'); + await expect(xTicks.nth(2)).toHaveText('2023-03-30'); + await expect(xTicks.nth(3)).toHaveText('2023-04-02'); + await expect(xTicks.nth(4)).toHaveText('2023-04-05'); + await expect(xTicks.nth(5)).toHaveText('2023-04-08'); + await expect(xTicks.nth(6)).toHaveText('2023-04-11'); + await expect(xTicks.nth(7)).toHaveText('2023-04-14'); + await expect(xTicks.nth(8)).toHaveText('2023-04-17'); + await expect(xTicks.nth(9)).toHaveText('2023-04-20'); +} + +async function testHoursTimeSeriesTicks(page) { + const xTicks = page.locator('.gl-plot-x-tick-label'); + await expect(xTicks).toHaveCount(8); + await expect(xTicks.nth(0)).toHaveText('02:00:00'); + await expect(xTicks.nth(1)).toHaveText('03:00:00'); + await expect(xTicks.nth(2)).toHaveText('04:00:00'); + await expect(xTicks.nth(3)).toHaveText('05:00:00'); + await expect(xTicks.nth(4)).toHaveText('06:00:00'); + await expect(xTicks.nth(5)).toHaveText('07:00:00'); + await expect(xTicks.nth(6)).toHaveText('08:00:00'); + await expect(xTicks.nth(7)).toHaveText('09:00:00'); +} + +async function testMinutesTimeSeriesTicks(page) { + const xTicks = page.locator('.gl-plot-x-tick-label'); + await expect(xTicks).toHaveCount(10); + await expect(xTicks.nth(0)).toHaveText('01:16:00'); + await expect(xTicks.nth(1)).toHaveText('01:18:00'); + await expect(xTicks.nth(2)).toHaveText('01:20:00'); + await expect(xTicks.nth(3)).toHaveText('01:22:00'); + await expect(xTicks.nth(4)).toHaveText('01:24:00'); + await expect(xTicks.nth(5)).toHaveText('01:26:00'); + await expect(xTicks.nth(6)).toHaveText('01:28:00'); + await expect(xTicks.nth(7)).toHaveText('01:30:00'); + await expect(xTicks.nth(8)).toHaveText('01:32:00'); + await expect(xTicks.nth(9)).toHaveText('01:34:00'); +} + +async function testSecondsTimeSeriesTicks(page) { + const xTicks = page.locator('.gl-plot-x-tick-label'); + await expect(xTicks).toHaveCount(11); + await expect(xTicks.nth(0)).toHaveText('01:22:00'); + await expect(xTicks.nth(1)).toHaveText('01:22:06'); + await expect(xTicks.nth(2)).toHaveText('01:22:12'); + await expect(xTicks.nth(3)).toHaveText('01:22:18'); + await expect(xTicks.nth(4)).toHaveText('01:22:24'); + await expect(xTicks.nth(5)).toHaveText('01:22:30'); + await expect(xTicks.nth(6)).toHaveText('01:22:36'); + await expect(xTicks.nth(7)).toHaveText('01:22:42'); + await expect(xTicks.nth(8)).toHaveText('01:22:48'); + await expect(xTicks.nth(9)).toHaveText('01:22:54'); + await expect(xTicks.nth(10)).toHaveText('01:23:00'); +} diff --git a/src/plugins/plot/MctPlot.vue b/src/plugins/plot/MctPlot.vue index cd395974b8d..cd53e441c8d 100644 --- a/src/plugins/plot/MctPlot.vue +++ b/src/plugins/plot/MctPlot.vue @@ -52,6 +52,7 @@ v-show="gridLines && !options.compact" :axis-type="'xAxis'" :position="'right'" + :is-utc="isUtc" />
- +
{{ xAxisLabel }} @@ -65,7 +65,8 @@ export default { xKeyOptions: [], xAxis: {}, loaded: false, - xAxisLabel: '' + xAxisLabel: '', + isUtc: this.openmct.time.getTimeSystem().isUTCBased }; }, mounted() { @@ -119,6 +120,7 @@ export default { this.xAxis.resetSeries(); this.setUpXAxisOptions(); } + this.isUtc = timeSystem.isUTCBased; }, setUpXAxisOptions() { const xAxisKey = this.xAxis.get('key'); diff --git a/src/plugins/plot/chart/MCTChartSeriesElement.js b/src/plugins/plot/chart/MCTChartSeriesElement.js index 7a318433ecb..002c8b48095 100644 --- a/src/plugins/plot/chart/MCTChartSeriesElement.js +++ b/src/plugins/plot/chart/MCTChartSeriesElement.js @@ -97,6 +97,7 @@ export default class MCTChartSeriesElement { this.chart.setOffset(point, undefined, series); } + // Here x,y are the offsets of the current point from the first data point. return { x: this.offset.xVal(point, series), y: this.offset.yVal(point, series) @@ -130,6 +131,7 @@ export default class MCTChartSeriesElement { reset() { this.buffer = new Float32Array(20000); this.count = 0; + //TODO: Should we call resetYOffsetAndSeriesDataForYAxis here? if (this.offset.x) { this.series.getSeriesData().forEach(function (point, index) { this.append(point, index, this.series); diff --git a/src/plugins/plot/draw/DrawWebGL.js b/src/plugins/plot/draw/DrawWebGL.js index 17784198f46..45161d69ff0 100644 --- a/src/plugins/plot/draw/DrawWebGL.js +++ b/src/plugins/plot/draw/DrawWebGL.js @@ -26,6 +26,7 @@ import eventHelpers from '../lib/eventHelpers.js'; import { MARKER_SHAPES } from './MarkerShapes.js'; // WebGL shader sources (for drawing plain colors) +// discard; stops the pixel from being drawn const FRAGMENT_SHADER = ` precision mediump float; uniform vec4 uColor; @@ -64,6 +65,12 @@ const FRAGMENT_SHADER = ` } `; +/* This code is taking a 2D vertex position (aVertexPosition) and transforming it into a clip-space coordinate system (where x and y range from -1 to 1) + 1. (aVertexPosition - uOrigin): effectively translates the aVertexPosition so that the uOrigin becomes the new (0,0). It shifts the coordinate system. + 2. (aVertexPosition - uOrigin) / uDimensions: performs a normalization step. If uDimensions represents the full width and height, this scales the coordinates so that they fall within the range [0, 1] for both x and y, relative to the uOrigin and uDimensions bounding box + 3. 2.0 * ((aVertexPosition - uOrigin) / uDimensions): scales the coordinates to be in the range [0, 2] + 4. 2.0 * ((aVertexPosition - uOrigin) / uDimensions) - vec2(1,1): shifts the range from [0, 2] to [-1, 1] +*/ const VERTEX_SHADER = ` attribute vec2 aVertexPosition; uniform vec2 uDimensions; @@ -132,20 +139,27 @@ class DrawWebGL extends EventEmitter { // Get locations for attribs/uniforms from the // shader programs (to pass values into shaders at draw-time) + + // only available to the vertex shader - this returns an INDEX into the list of attributes maintained by the GPU this.aVertexPosition = this.gl.getAttribLocation(this.program, 'aVertexPosition'); + + // available to both vertex and fragment shaders this.uColor = this.gl.getUniformLocation(this.program, 'uColor'); this.uMarkerShape = this.gl.getUniformLocation(this.program, 'uMarkerShape'); this.uDimensions = this.gl.getUniformLocation(this.program, 'uDimensions'); this.uOrigin = this.gl.getUniformLocation(this.program, 'uOrigin'); this.uPointSize = this.gl.getUniformLocation(this.program, 'uPointSize'); + // enable the attribute to that it can be used / accessed this.gl.enableVertexAttribArray(this.aVertexPosition); - // Create a buffer to holds points which will be drawn + // Create a buffer to hold points which will be drawn this.buffer = this.gl.createBuffer(); // Enable blending, for smoothness this.gl.enable(this.gl.BLEND); + // sfactor is the source alpha value, dfactor is one minus source alpha value + // conceptually, color(RGBA) = (sourceColor * sfactor) + (destinationColor * dfactor) this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA); } destroy() { diff --git a/src/plugins/plot/pluginSpec.js b/src/plugins/plot/pluginSpec.js index a7e33ce2d68..23b1980cba7 100644 --- a/src/plugins/plot/pluginSpec.js +++ b/src/plugins/plot/pluginSpec.js @@ -422,7 +422,7 @@ describe('the plugin', function () { expect(xAxisElement.length).toBe(1); let ticks = xAxisElement[0].querySelectorAll('.gl-plot-tick'); - expect(ticks.length).toBe(9); + expect(ticks.length).toBe(5); done(); }); diff --git a/src/plugins/plot/stackedPlot/pluginSpec.js b/src/plugins/plot/stackedPlot/pluginSpec.js index e8455880013..77a92c548d5 100644 --- a/src/plugins/plot/stackedPlot/pluginSpec.js +++ b/src/plugins/plot/stackedPlot/pluginSpec.js @@ -400,7 +400,7 @@ describe('the plugin', function () { ); expect(yAxisElement.length).toBe(1); let ticks = yAxisElement[0].querySelectorAll('.gl-plot-tick'); - expect(ticks.length).toBe(6); + expect(ticks.length).toBe(11); }); it('Renders Y-axis options for the telemetry object', () => { diff --git a/src/plugins/plot/tickUtils.js b/src/plugins/plot/tickUtils.js index fff48cb546b..8282d982bd1 100644 --- a/src/plugins/plot/tickUtils.js +++ b/src/plugins/plot/tickUtils.js @@ -4,6 +4,18 @@ const e10 = Math.sqrt(50); const e5 = Math.sqrt(10); const e2 = Math.sqrt(2); +// A complete list of time units and their duration in milliseconds - UTC +const TIME_UNITS_UTC = [ + { unit: 'millisecond', duration: 1 }, + { unit: 'second', duration: 1000 }, + { unit: 'minute', duration: 1000 * 60 }, + { unit: 'hour', duration: 1000 * 60 * 60 }, + { unit: 'day', duration: 1000 * 60 * 60 * 24 }, + { unit: 'week', duration: 1000 * 60 * 60 * 24 * 7 }, + { unit: 'month', duration: 1000 * 60 * 60 * 24 * 30.4375 }, // Average month + { unit: 'year', duration: 1000 * 60 * 60 * 24 * 365.25 } // Average year +]; + /** * Nicely formatted tick steps from d3-array. */ @@ -22,6 +34,91 @@ function tickStep(start, stop, count) { return stop < start ? -step1 : step1; } +/** + * Generate time ticks based on a start and stop time, and a desired count of ticks. + * @param start beginning timestamp in Ms + * @param stop ending timestamp in Ms + * @param count desired number of ticks + * @returns {*[]} Array of timestamps in Ms + */ +export function getTimeTicks(start, stop, count) { + const duration = stop - start; + let bestUnit = TIME_UNITS_UTC[0]; + let bestStepSize = 1; + + // Find the most appropriate time unit + for (const unit of TIME_UNITS_UTC) { + const numTicks = duration / unit.duration; + if (numTicks >= count / 2) { + // Find the unit that gives at least half the desired ticks + bestUnit = unit; + bestStepSize = Math.ceil(numTicks / count) || 1; + } else { + break; // Stop when the unit is too large + } + } + + // Handle month/year to avoid incorrect step sizes due to varying durations + if (bestUnit.unit === 'month' || bestUnit.unit === 'year') { + return generateMonthYearTicks(start, stop, bestUnit.unit, bestStepSize); + } else { + // For smaller, fixed-duration units + return generateFixedIntervalTicks(start, stop, bestUnit.duration * bestStepSize); + } +} + +// Helper for variable-duration units (months, years) +/** + * Generate ticks for month/year intervals - these are variable due to leap years etc. + * @param start beginning timestamp in Ms + * @param stop ending timestamp in Ms + * @param unit 'month' or 'year' + * @param stepSize number of months/years to step + * @returns {*[]} Array of timestamps in Ms + */ +function generateMonthYearTicks(start, stop, unit, stepSize) { + const resultingTicks = []; + let currentDate = new Date(start); + + // Set to the beginning of the interval (e.g., beginning of the month/year) + if (unit === 'month') { + currentDate.setDate(1); + } else if (unit === 'year') { + currentDate.setMonth(0, 1); + } + + while (currentDate.getTime() <= stop) { + resultingTicks.push(currentDate.getTime()); + if (unit === 'month') { + currentDate.setMonth(currentDate.getMonth() + stepSize); + } else { + // unit is 'year' + currentDate.setFullYear(currentDate.getFullYear() + stepSize); + } + } + + return resultingTicks; +} + +// Helper for fixed-duration units (seconds, days) +/** + * Generate ticks for fixed-duration intervals (seconds, minutes, hours, etc.) + * @param start beginning timestamp in Ms + * @param stop ending timestamp in Ms + * @param interval duration of each tick in Ms + * @returns {*[]} Array of timestamps in Ms + */ +function generateFixedIntervalTicks(start, stop, interval) { + const fixedIntervalTicks = []; + const firstTick = Math.ceil(start / interval) * interval; + + for (let i = firstTick; i <= stop; i += interval) { + fixedIntervalTicks.push(i); + } + + return fixedIntervalTicks; +} + /** * Find the precision (number of decimals) of a step. Used to round * ticks to precise values.