From bde9ac775fe56352d54cc298b7d696d8eca67e8c Mon Sep 17 00:00:00 2001 From: Shefali Date: Tue, 24 Jun 2025 14:28:06 -0700 Subject: [PATCH 1/6] Initial prototype of UTC timestamp ticks --- src/plugins/plot/MctPlot.vue | 10 +- src/plugins/plot/MctTicks.vue | 16 +- src/plugins/plot/axis/XAxis.vue | 6 +- .../plot/chart/MCTChartSeriesElement.js | 2 + src/plugins/plot/draw/DrawWebGL.js | 16 +- src/plugins/plot/tickUtils.js | 143 ++++++++++++++++++ 6 files changed, 186 insertions(+), 7 deletions(-) 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/tickUtils.js b/src/plugins/plot/tickUtils.js index fff48cb546b..7820c1b244c 100644 --- a/src/plugins/plot/tickUtils.js +++ b/src/plugins/plot/tickUtils.js @@ -3,6 +3,149 @@ import { antisymlog, symlog } from './mathUtils.js'; const e10 = Math.sqrt(50); const e5 = Math.sqrt(10); const e2 = Math.sqrt(2); +/** + * Common time intervals in milliseconds for automatic tick generation. + * These are chosen to be "nice" human-readable intervals. + */ +const COMMON_INTERVALS_MS = [ + 1 * 1000, // 1 second + 5 * 1000, // 5 seconds + 10 * 1000, // 10 seconds + 15 * 1000, // 15 seconds + 30 * 1000, // 30 seconds + 45 * 1000, // 45 seconds + 1 * 60 * 1000, // 1 minute + 2 * 60 * 1000, // 2 minutes + 3 * 60 * 1000, // 3 minutes + 4 * 60 * 1000, // 4 minutes + 5 * 60 * 1000, // 5 minutes + 10 * 60 * 1000, // 10 minutes + 15 * 60 * 1000, // 15 minutes + 30 * 60 * 1000, // 30 minutes + 45 * 60 * 1000, // 45 minutes + 1 * 60 * 60 * 1000, // 1 hour + 2 * 60 * 60 * 1000, // 2 hour + 3 * 60 * 60 * 1000, // 3 hour + 4 * 60 * 60 * 1000, // 4 hours + 5 * 60 * 60 * 1000, // 5 hours + 6 * 60 * 60 * 1000, // 6 hours + 10 * 60 * 60 * 1000, // 10 hours + 12 * 60 * 60 * 1000, // 12 hours + 1 * 24 * 60 * 60 * 1000, // 1 day + 7 * 24 * 60 * 60 * 1000, // 1 week + 14 * 24 * 60 * 60 * 1000, // 2 weeks + 30 * 24 * 60 * 60 * 1000, // ~1 month (30 days approximation) + 90 * 24 * 60 * 60 * 1000, // ~3 months + 180 * 24 * 60 * 60 * 1000, // ~6 months + 365 * 24 * 60 * 60 * 1000 // ~1 year (365 days approximation) +]; + +/** + * Determines an optimal interval for time ticks based on the total duration + * and a desired number of ticks. + * + * @param {number} durationMs The total duration in milliseconds. + * @param {number} tickCount The approximate number of ticks desired. + * @returns {number} The optimal interval in milliseconds from COMMON_INTERVALS_MS. + */ +function determineOptimalInterval(durationMs, tickCount) { + if (tickCount <= 0 || durationMs <= 0) { + return COMMON_INTERVALS_MS[0]; // Default to 15 seconds if invalid input + } + + const targetInterval = durationMs / tickCount; + + // Find the smallest common interval that is greater than or equal to the target + const commonIntervalsLength = COMMON_INTERVALS_MS.length; + for (let i = 0; i < commonIntervalsLength; i++) { + if (COMMON_INTERVALS_MS[i] >= targetInterval) { + return COMMON_INTERVALS_MS[i]; + } + } + + // If the range is very large and exceeds all common intervals, return the largest one + return COMMON_INTERVALS_MS[commonIntervalsLength - 1]; +} + +/** + * Generates an array of timestamps (in milliseconds) at automatically determined intervals. + * + * @param {number} startTimestampMs The starting timestamp in milliseconds. + * @param {number} stopTimestampMs The stopping timestamp in milliseconds. + * @param {number} [tickCount=12] The approximate number of ticks desired. + * The actual number may vary based on optimal interval selection. + * @returns {number[]} An array of timestamps in milliseconds. + */ +export function generateTimestampTicks(startTimestampMs, stopTimestampMs, tickCount = 12) { + // Ensure start and stop are valid numbers + if (isNaN(startTimestampMs) || isNaN(stopTimestampMs)) { + console.error('Invalid start or stop timestamp provided.'); + return []; + } + + // Start is after stop or duration is zero/negative + if (startTimestampMs > stopTimestampMs) { + return []; + } + + const duration = stopTimestampMs - startTimestampMs; + + // Determine the optimal tick step based on the duration and desired tick count + const intervalMs = determineOptimalInterval(duration, tickCount); + + // If for some reason intervalMs becomes 0 or negative + if (intervalMs <= 0) { + console.warn('Calculated interval is invalid. Returning empty ticks.'); + return []; + } + + const resultingTicks = []; + + // Calculate the first tick timestamp that is aligned to the 'intervalMs' + // and is on or after the startTimestampMs. + let firstTickMs; + // Calculate remainder to ensure consistent alignment + const remainder = startTimestampMs % intervalMs; + + if (remainder === 0) { + firstTickMs = startTimestampMs; + } else { + // Adjust to the next full interval mark + firstTickMs = startTimestampMs - remainder + intervalMs; + } + + // Edge case - If startTimestampMs is negative, the modulo result can be negative. + // Ensure firstTickMs is always on or after startTimestampMs, + // and aligned to the interval. + if ( + startTimestampMs < 0 && + firstTickMs > startTimestampMs && + Math.abs(firstTickMs - startTimestampMs) > intervalMs + ) { + // If firstTickMs jump too far ahead for negative numbers. + // We want the first tick that is >= startTimestampMs. + firstTickMs = startTimestampMs - remainder + (remainder < 0 ? 0 : intervalMs); + if (firstTickMs < startTimestampMs) { + firstTickMs += intervalMs; + } + } + + // If the range is very small, ensure firstTickMs doesn't go beyond stopTimestampMs + if (firstTickMs > stopTimestampMs) { + return resultingTicks; // No ticks can be generated within the range + } + + // Generate ticks + for ( + let currentTimeMs = firstTickMs; + currentTimeMs <= stopTimestampMs; + currentTimeMs += intervalMs + ) { + resultingTicks.push(currentTimeMs); + } + + return resultingTicks; +} /** * Nicely formatted tick steps from d3-array. From a0aa65eeca350d75f5ccfd2c6f557321705bdc68 Mon Sep 17 00:00:00 2001 From: Shefali Date: Mon, 21 Jul 2025 11:25:29 -0700 Subject: [PATCH 2/6] draft tick changes --- API.md | 67 ++++++++++++++++++++--------------- src/plugins/plot/MctTicks.vue | 32 +++++++++++++++-- src/plugins/plot/tickUtils.js | 1 + 3 files changed, 68 insertions(+), 32 deletions(-) diff --git a/API.md b/API.md index e607deeb3c1..178e73b6a26 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,25 @@ 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 +486,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/src/plugins/plot/MctTicks.vue b/src/plugins/plot/MctTicks.vue index 24e07ff5238..d60a33e2270 100644 --- a/src/plugins/plot/MctTicks.vue +++ b/src/plugins/plot/MctTicks.vue @@ -34,6 +34,14 @@ :title="tick.fullText || tick.text" > {{ tick.text }} +
@@ -46,7 +54,9 @@ :title="tick.fullText || tick.text" style="margin-top: -0.5em; direction: ltr" > - {{ tick.text }} + + {{ tick.text }} +
@@ -81,6 +91,7 @@ import eventHelpers from './lib/eventHelpers.js'; import { generateTimestampTicks, getFormattedTicks, getLogTicks, ticks } from './tickUtils.js'; const SECONDARY_TICK_NUMBER = 2; +const GRANULAR_TICK_COUNT = 5; export default { inject: ['openmct', 'domainObject', 'objectPath'], @@ -96,7 +107,7 @@ export default { tickCount: { type: Number, default() { - return 8; + return 12; } }, axisId: { @@ -136,9 +147,24 @@ export default { return { ticks: [], interval: undefined, - min: undefined + min: undefined, + subTickCount: GRANULAR_TICK_COUNT }; }, + computed: { + subTicks() { + let sub = []; + if (this.ticks.length) { + const step = (this.ticks[1] - this.ticks[0]) / GRANULAR_TICK_COUNT; + let result = 0; + while (result + step < this.ticks[1] && result + step > this.ticks[0]) { + sub.push(result); + result = result + step; + } + } + return sub; + } + }, mounted() { eventHelpers.extend(this); diff --git a/src/plugins/plot/tickUtils.js b/src/plugins/plot/tickUtils.js index 7820c1b244c..2957e01172b 100644 --- a/src/plugins/plot/tickUtils.js +++ b/src/plugins/plot/tickUtils.js @@ -32,6 +32,7 @@ const COMMON_INTERVALS_MS = [ 10 * 60 * 60 * 1000, // 10 hours 12 * 60 * 60 * 1000, // 12 hours 1 * 24 * 60 * 60 * 1000, // 1 day + 3 * 24 * 60 * 60 * 1000, // 3 days 7 * 24 * 60 * 60 * 1000, // 1 week 14 * 24 * 60 * 60 * 1000, // 2 weeks 30 * 24 * 60 * 60 * 1000, // ~1 month (30 days approximation) From 50ff8a3fec6f543d696f25c1c29d5644f86eec9d Mon Sep 17 00:00:00 2001 From: Shefali Date: Tue, 12 Aug 2025 11:53:37 -0700 Subject: [PATCH 3/6] Refactor time-series ticks --- src/plugins/plot/MctTicks.vue | 36 +----- src/plugins/plot/tickUtils.js | 225 ++++++++++++++-------------------- 2 files changed, 94 insertions(+), 167 deletions(-) diff --git a/src/plugins/plot/MctTicks.vue b/src/plugins/plot/MctTicks.vue index d60a33e2270..9bdab3623da 100644 --- a/src/plugins/plot/MctTicks.vue +++ b/src/plugins/plot/MctTicks.vue @@ -34,14 +34,6 @@ :title="tick.fullText || tick.text" > {{ tick.text }} -
@@ -54,9 +46,7 @@ :title="tick.fullText || tick.text" style="margin-top: -0.5em; direction: ltr" > - - {{ tick.text }} - + {{ tick.text }}
@@ -88,10 +78,9 @@ import { inject } from 'vue'; import { useAlignment } from '../../ui/composables/alignmentContext.js'; import configStore from './configuration/ConfigStore.js'; import eventHelpers from './lib/eventHelpers.js'; -import { generateTimestampTicks, getFormattedTicks, getLogTicks, ticks } from './tickUtils.js'; +import { getFormattedTicks, getLogTicks, getTimeTicks, ticks } from './tickUtils.js'; const SECONDARY_TICK_NUMBER = 2; -const GRANULAR_TICK_COUNT = 5; export default { inject: ['openmct', 'domainObject', 'objectPath'], @@ -107,7 +96,7 @@ export default { tickCount: { type: Number, default() { - return 12; + return 10; } }, axisId: { @@ -147,24 +136,9 @@ export default { return { ticks: [], interval: undefined, - min: undefined, - subTickCount: GRANULAR_TICK_COUNT + min: undefined }; }, - computed: { - subTicks() { - let sub = []; - if (this.ticks.length) { - const step = (this.ticks[1] - this.ticks[0]) / GRANULAR_TICK_COUNT; - let result = 0; - while (result + step < this.ticks[1] && result + step > this.ticks[0]) { - sub.push(result); - result = result + step; - } - } - return sub; - } - }, mounted() { eventHelpers.extend(this); @@ -254,7 +228,7 @@ export default { if (this.axisType === 'yAxis' && this.axis.get('logMode')) { return getLogTicks(range.min, range.max, number, SECONDARY_TICK_NUMBER); } else if (this.isUtc) { - return generateTimestampTicks(range.min, range.max, number); + return getTimeTicks(range.min, range.max, number); } else { return ticks(range.min, range.max, number); } diff --git a/src/plugins/plot/tickUtils.js b/src/plugins/plot/tickUtils.js index 2957e01172b..be96bd00c7c 100644 --- a/src/plugins/plot/tickUtils.js +++ b/src/plugins/plot/tickUtils.js @@ -3,167 +3,120 @@ import { antisymlog, symlog } from './mathUtils.js'; const e10 = Math.sqrt(50); const e5 = Math.sqrt(10); const e2 = Math.sqrt(2); -/** - * Common time intervals in milliseconds for automatic tick generation. - * These are chosen to be "nice" human-readable intervals. - */ -const COMMON_INTERVALS_MS = [ - 1 * 1000, // 1 second - 5 * 1000, // 5 seconds - 10 * 1000, // 10 seconds - 15 * 1000, // 15 seconds - 30 * 1000, // 30 seconds - 45 * 1000, // 45 seconds - 1 * 60 * 1000, // 1 minute - 2 * 60 * 1000, // 2 minutes - 3 * 60 * 1000, // 3 minutes - 4 * 60 * 1000, // 4 minutes - 5 * 60 * 1000, // 5 minutes - 10 * 60 * 1000, // 10 minutes - 15 * 60 * 1000, // 15 minutes - 30 * 60 * 1000, // 30 minutes - 45 * 60 * 1000, // 45 minutes - 1 * 60 * 60 * 1000, // 1 hour - 2 * 60 * 60 * 1000, // 2 hour - 3 * 60 * 60 * 1000, // 3 hour - 4 * 60 * 60 * 1000, // 4 hours - 5 * 60 * 60 * 1000, // 5 hours - 6 * 60 * 60 * 1000, // 6 hours - 10 * 60 * 60 * 1000, // 10 hours - 12 * 60 * 60 * 1000, // 12 hours - 1 * 24 * 60 * 60 * 1000, // 1 day - 3 * 24 * 60 * 60 * 1000, // 3 days - 7 * 24 * 60 * 60 * 1000, // 1 week - 14 * 24 * 60 * 60 * 1000, // 2 weeks - 30 * 24 * 60 * 60 * 1000, // ~1 month (30 days approximation) - 90 * 24 * 60 * 60 * 1000, // ~3 months - 180 * 24 * 60 * 60 * 1000, // ~6 months - 365 * 24 * 60 * 60 * 1000 // ~1 year (365 days approximation) + +// A complete list of time units and their duration in milliseconds +const TIME_UNITS = [ + { 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 ]; /** - * Determines an optimal interval for time ticks based on the total duration - * and a desired number of ticks. - * - * @param {number} durationMs The total duration in milliseconds. - * @param {number} tickCount The approximate number of ticks desired. - * @returns {number} The optimal interval in milliseconds from COMMON_INTERVALS_MS. + * Nicely formatted tick steps from d3-array. */ -function determineOptimalInterval(durationMs, tickCount) { - if (tickCount <= 0 || durationMs <= 0) { - return COMMON_INTERVALS_MS[0]; // Default to 15 seconds if invalid input - } - - const targetInterval = durationMs / tickCount; - - // Find the smallest common interval that is greater than or equal to the target - const commonIntervalsLength = COMMON_INTERVALS_MS.length; - for (let i = 0; i < commonIntervalsLength; i++) { - if (COMMON_INTERVALS_MS[i] >= targetInterval) { - return COMMON_INTERVALS_MS[i]; - } +function tickStep(start, stop, count) { + const step0 = Math.abs(stop - start) / Math.max(0, count); + let step1 = Math.pow(10, Math.floor(Math.log(step0) / Math.LN10)); + const error = step0 / step1; + if (error >= e10) { + step1 *= 10; + } else if (error >= e5) { + step1 *= 5; + } else if (error >= e2) { + step1 *= 2; } - // If the range is very large and exceeds all common intervals, return the largest one - return COMMON_INTERVALS_MS[commonIntervalsLength - 1]; + return stop < start ? -step1 : step1; } /** - * Generates an array of timestamps (in milliseconds) at automatically determined intervals. - * - * @param {number} startTimestampMs The starting timestamp in milliseconds. - * @param {number} stopTimestampMs The stopping timestamp in milliseconds. - * @param {number} [tickCount=12] The approximate number of ticks desired. - * The actual number may vary based on optimal interval selection. - * @returns {number[]} An array of timestamps in milliseconds. + * 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 generateTimestampTicks(startTimestampMs, stopTimestampMs, tickCount = 12) { - // Ensure start and stop are valid numbers - if (isNaN(startTimestampMs) || isNaN(stopTimestampMs)) { - console.error('Invalid start or stop timestamp provided.'); - return []; - } - - // Start is after stop or duration is zero/negative - if (startTimestampMs > stopTimestampMs) { - return []; - } - - const duration = stopTimestampMs - startTimestampMs; - - // Determine the optimal tick step based on the duration and desired tick count - const intervalMs = determineOptimalInterval(duration, tickCount); - - // If for some reason intervalMs becomes 0 or negative - if (intervalMs <= 0) { - console.warn('Calculated interval is invalid. Returning empty ticks.'); - return []; +export function getTimeTicks(start, stop, count) { + const duration = stop - start; + let bestUnit = TIME_UNITS[0]; + let bestStepSize = 1; + + // Find the most appropriate time unit + for (const unit of TIME_UNITS) { + 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 + } } - const resultingTicks = []; - - // Calculate the first tick timestamp that is aligned to the 'intervalMs' - // and is on or after the startTimestampMs. - let firstTickMs; - // Calculate remainder to ensure consistent alignment - const remainder = startTimestampMs % intervalMs; - - if (remainder === 0) { - firstTickMs = startTimestampMs; + // 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 { - // Adjust to the next full interval mark - firstTickMs = startTimestampMs - remainder + intervalMs; + // For smaller, fixed-duration units + return generateFixedIntervalTicks(start, stop, bestUnit.duration * bestStepSize); } +} - // Edge case - If startTimestampMs is negative, the modulo result can be negative. - // Ensure firstTickMs is always on or after startTimestampMs, - // and aligned to the interval. - if ( - startTimestampMs < 0 && - firstTickMs > startTimestampMs && - Math.abs(firstTickMs - startTimestampMs) > intervalMs - ) { - // If firstTickMs jump too far ahead for negative numbers. - // We want the first tick that is >= startTimestampMs. - firstTickMs = startTimestampMs - remainder + (remainder < 0 ? 0 : intervalMs); - if (firstTickMs < startTimestampMs) { - firstTickMs += intervalMs; +// 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); } } - // If the range is very small, ensure firstTickMs doesn't go beyond stopTimestampMs - if (firstTickMs > stopTimestampMs) { - return resultingTicks; // No ticks can be generated within the range - } - - // Generate ticks - for ( - let currentTimeMs = firstTickMs; - currentTimeMs <= stopTimestampMs; - currentTimeMs += intervalMs - ) { - resultingTicks.push(currentTimeMs); - } - return resultingTicks; } +// Helper for fixed-duration units (seconds, days) /** - * Nicely formatted tick steps from d3-array. + * 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 tickStep(start, stop, count) { - const step0 = Math.abs(stop - start) / Math.max(0, count); - let step1 = Math.pow(10, Math.floor(Math.log(step0) / Math.LN10)); - const error = step0 / step1; - if (error >= e10) { - step1 *= 10; - } else if (error >= e5) { - step1 *= 5; - } else if (error >= e2) { - step1 *= 2; +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 stop < start ? -step1 : step1; + return fixedIntervalTicks; } /** From 4500817c1a0d9bd555d96eb0acaf4e7be956d088 Mon Sep 17 00:00:00 2001 From: Shefali Date: Mon, 18 Aug 2025 10:45:15 -0700 Subject: [PATCH 4/6] Add e2e test for time ticks --- .../plugins/plot/timeTicks.e2e.spec.js | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 e2e/tests/functional/plugins/plot/timeTicks.e2e.spec.js 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'); +} From 673e9291eb8be6e9568c5ebbc606c4a7e79916a7 Mon Sep 17 00:00:00 2001 From: Shefali Date: Mon, 15 Sep 2025 11:18:44 -0700 Subject: [PATCH 5/6] Better comments and variable names and some formatting --- API.md | 3 +-- src/plugins/plot/tickUtils.js | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/API.md b/API.md index 178e73b6a26..09acdbd4f2a 100644 --- a/API.md +++ b/API.md @@ -435,8 +435,7 @@ 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 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. +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 diff --git a/src/plugins/plot/tickUtils.js b/src/plugins/plot/tickUtils.js index be96bd00c7c..8282d982bd1 100644 --- a/src/plugins/plot/tickUtils.js +++ b/src/plugins/plot/tickUtils.js @@ -4,8 +4,8 @@ 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 -const TIME_UNITS = [ +// 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 }, @@ -43,11 +43,11 @@ function tickStep(start, stop, count) { */ export function getTimeTicks(start, stop, count) { const duration = stop - start; - let bestUnit = TIME_UNITS[0]; + let bestUnit = TIME_UNITS_UTC[0]; let bestStepSize = 1; // Find the most appropriate time unit - for (const unit of TIME_UNITS) { + 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 From 2b9fd487752ba9dd73c66645fac5f024b54737df Mon Sep 17 00:00:00 2001 From: Shefali Date: Tue, 16 Sep 2025 14:10:30 -0700 Subject: [PATCH 6/6] Fix tests broken due to new implementation of ticks --- src/plugins/plot/pluginSpec.js | 2 +- src/plugins/plot/stackedPlot/pluginSpec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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', () => {