Independent time conductor (#3988)

* Independent time API implementation
* Independent time conductor in plan view

Co-authored-by: charlesh88 <charles.f.hacskaylo@nasa.gov>
Co-authored-by: Jamie V <jamie.j.vigliotta@nasa.gov>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
This commit is contained in:
Shefali Joshi
2021-09-18 09:53:35 -07:00
committed by GitHub
parent e56c673005
commit eabdf6cd04
37 changed files with 2567 additions and 984 deletions

View File

@ -136,7 +136,7 @@ define([
* @memberof module:openmct.MCT# * @memberof module:openmct.MCT#
* @name conductor * @name conductor
*/ */
this.time = new api.TimeAPI(); this.time = new api.TimeAPI(this);
/** /**
* An interface for interacting with the composition of domain objects. * An interface for interacting with the composition of domain objects.

View File

@ -46,7 +46,7 @@ define([
StatusAPI StatusAPI
) { ) {
return { return {
TimeAPI: TimeAPI, TimeAPI: TimeAPI.default,
ObjectAPI: ObjectAPI, ObjectAPI: ObjectAPI,
CompositionAPI: CompositionAPI, CompositionAPI: CompositionAPI,
TypeRegistry: TypeRegistry, TypeRegistry: TypeRegistry,

View File

@ -0,0 +1,106 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import TimeContext from "./TimeContext";
/**
* The GlobalContext handles getting and setting time of the openmct application in general.
* Views will use this context unless they specify an alternate/independent time context
*/
class GlobalTimeContext extends TimeContext {
constructor() {
super();
//The Time Of Interest
this.toi = undefined;
}
/**
* Get or set the start and end time of the time conductor. Basic validation
* of bounds is performed.
*
* @param {module:openmct.TimeAPI~TimeConductorBounds} newBounds
* @throws {Error} Validation error
* @fires module:openmct.TimeAPI~bounds
* @returns {module:openmct.TimeAPI~TimeConductorBounds}
* @memberof module:openmct.TimeAPI#
* @method bounds
*/
bounds(newBounds) {
if (arguments.length > 0) {
super.bounds.call(this, ...arguments);
// If a bounds change results in a TOI outside of the current
// bounds, unset it
if (this.toi < newBounds.start || this.toi > newBounds.end) {
this.timeOfInterest(undefined);
}
}
//Return a copy to prevent direct mutation of time conductor bounds.
return JSON.parse(JSON.stringify(this.boundsVal));
}
/**
* Update bounds based on provided time and current offsets
* @private
* @param {number} timestamp A time from which bounds will be calculated
* using current offsets.
*/
tick(timestamp) {
super.tick.call(this, ...arguments);
// If a bounds change results in a TOI outside of the current
// bounds, unset it
if (this.toi < this.boundsVal.start || this.toi > this.boundsVal.end) {
this.timeOfInterest(undefined);
}
}
/**
* Get or set the Time of Interest. The Time of Interest is a single point
* in time, and constitutes the temporal focus of application views. It can
* be manipulated by the user from the time conductor or from other views.
* The time of interest can effectively be unset by assigning a value of
* 'undefined'.
* @fires module:openmct.TimeAPI~timeOfInterest
* @param newTOI
* @returns {number} the current time of interest
* @memberof module:openmct.TimeAPI#
* @method timeOfInterest
*/
timeOfInterest(newTOI) {
if (arguments.length > 0) {
this.toi = newTOI;
/**
* The Time of Interest has moved.
* @event timeOfInterest
* @memberof module:openmct.TimeAPI~
* @property {number} Current time of interest
*/
this.emit('timeOfInterest', this.toi);
}
return this.toi;
}
}
export default GlobalTimeContext;

View File

@ -0,0 +1,94 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import TimeContext from "./TimeContext";
/**
* The IndependentTimeContext handles getting and setting time of the openmct application in general.
* Views will use the GlobalTimeContext unless they specify an alternate/independent time context here.
*/
class IndependentTimeContext extends TimeContext {
constructor(globalTimeContext, key) {
super();
this.key = key;
this.globalTimeContext = globalTimeContext;
}
/**
* Set the active clock. Tick source will be immediately subscribed to
* and ticking will begin. Offsets from 'now' must also be provided. A clock
* can be unset by calling {@link stopClock}.
*
* @param {Clock || string} keyOrClock The clock to activate, or its key
* @param {ClockOffsets} offsets on each tick these will be used to calculate
* the start and end bounds. This maintains a sliding time window of a fixed
* width that automatically updates.
* @fires module:openmct.TimeAPI~clock
* @return {Clock} the currently active clock;
*/
clock(keyOrClock, offsets) {
if (arguments.length === 2) {
let clock;
if (typeof keyOrClock === 'string') {
clock = this.globalTimeContext.clocks.get(keyOrClock);
if (clock === undefined) {
throw "Unknown clock '" + keyOrClock + "'. Has it been registered with 'addClock'?";
}
} else if (typeof keyOrClock === 'object') {
clock = keyOrClock;
if (!this.globalTimeContext.clocks.has(clock.key)) {
throw "Unknown clock '" + keyOrClock.key + "'. Has it been registered with 'addClock'?";
}
}
const previousClock = this.activeClock;
if (previousClock !== undefined) {
previousClock.off("tick", this.tick);
}
this.activeClock = clock;
/**
* The active clock has changed. Clock can be unset by calling {@link stopClock}
* @event clock
* @memberof module:openmct.TimeAPI~
* @property {Clock} clock The newly activated clock, or undefined
* if the system is no longer following a clock source
*/
this.emit("clock", this.activeClock);
if (this.activeClock !== undefined) {
this.clockOffsets(offsets);
this.activeClock.on("tick", this.tick);
}
} else if (arguments.length === 1) {
throw "When setting the clock, clock offsets must also be provided";
}
return this.activeClock;
}
}
export default IndependentTimeContext;

View File

@ -20,51 +20,35 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
define(['EventEmitter'], function (EventEmitter) { import GlobalTimeContext from "./GlobalTimeContext";
import IndependentTimeContext from "@/api/time/IndependentTimeContext";
/**
* The public API for setting and querying the temporal state of the
* application. The concept of time is integral to Open MCT, and at least
* one {@link TimeSystem}, as well as some default time bounds must be
* registered and enabled via {@link TimeAPI.addTimeSystem} and
* {@link TimeAPI.timeSystem} respectively for Open MCT to work.
*
* Time-sensitive views will typically respond to changes to bounds or other
* properties of the time conductor and update the data displayed based on
* the temporal state of the application. The current time bounds are also
* used in queries for historical data.
*
* The TimeAPI extends the EventEmitter class. A number of events are
* fired when properties of the time conductor change, which are documented
* below.
*
* @interface
* @memberof module:openmct
*/
function TimeAPI() {
EventEmitter.call(this);
//The Time System
this.system = undefined;
//The Time Of Interest
this.toi = undefined;
this.boundsVal = {
start: undefined,
end: undefined
};
this.timeSystems = new Map();
this.clocks = new Map();
this.activeClock = undefined;
this.offsets = undefined;
this.tick = this.tick.bind(this);
/**
* The public API for setting and querying the temporal state of the
* application. The concept of time is integral to Open MCT, and at least
* one {@link TimeSystem}, as well as some default time bounds must be
* registered and enabled via {@link TimeAPI.addTimeSystem} and
* {@link TimeAPI.timeSystem} respectively for Open MCT to work.
*
* Time-sensitive views will typically respond to changes to bounds or other
* properties of the time conductor and update the data displayed based on
* the temporal state of the application. The current time bounds are also
* used in queries for historical data.
*
* The TimeAPI extends the GlobalTimeContext which in turn extends the TimeContext/EventEmitter class. A number of events are
* fired when properties of the time conductor change, which are documented
* below.
*
* @interface
* @memberof module:openmct
*/
class TimeAPI extends GlobalTimeContext {
constructor(openmct) {
super();
this.openmct = openmct;
this.independentContexts = new Map();
} }
TimeAPI.prototype = Object.create(EventEmitter.prototype);
/** /**
* A TimeSystem provides meaning to the values returned by the TimeAPI. Open * A TimeSystem provides meaning to the values returned by the TimeAPI. Open
* MCT supports multiple different types of time values, although all are * MCT supports multiple different types of time values, although all are
@ -94,16 +78,16 @@ define(['EventEmitter'], function (EventEmitter) {
* @memberof module:openmct.TimeAPI# * @memberof module:openmct.TimeAPI#
* @param {TimeSystem} timeSystem A time system object. * @param {TimeSystem} timeSystem A time system object.
*/ */
TimeAPI.prototype.addTimeSystem = function (timeSystem) { addTimeSystem(timeSystem) {
this.timeSystems.set(timeSystem.key, timeSystem); this.timeSystems.set(timeSystem.key, timeSystem);
}; }
/** /**
* @returns {TimeSystem[]} * @returns {TimeSystem[]}
*/ */
TimeAPI.prototype.getAllTimeSystems = function () { getAllTimeSystems() {
return Array.from(this.timeSystems.values()); return Array.from(this.timeSystems.values());
}; }
/** /**
* Clocks provide a timing source that is used to * Clocks provide a timing source that is used to
@ -126,340 +110,81 @@ define(['EventEmitter'], function (EventEmitter) {
* @memberof module:openmct.TimeAPI# * @memberof module:openmct.TimeAPI#
* @param {Clock} clock * @param {Clock} clock
*/ */
TimeAPI.prototype.addClock = function (clock) { addClock(clock) {
this.clocks.set(clock.key, clock); this.clocks.set(clock.key, clock);
}; }
/** /**
* @memberof module:openmct.TimeAPI# * @memberof module:openmct.TimeAPI#
* @returns {Clock[]} * @returns {Clock[]}
* @memberof module:openmct.TimeAPI# * @memberof module:openmct.TimeAPI#
*/ */
TimeAPI.prototype.getAllClocks = function () { getAllClocks() {
return Array.from(this.clocks.values()); return Array.from(this.clocks.values());
}; }
/** /**
* Validate the given bounds. This can be used for pre-validation of bounds, * Get or set an independent time context which follows the TimeAPI timeSystem,
* for example by views validating user inputs. * but with different offsets for a given domain object
* @param {TimeBounds} bounds The start and end time of the conductor. * @param {key | string} key The identifier key of the domain object these offsets are set for
* @returns {string | true} A validation error, or true if valid * @param {ClockOffsets | TimeBounds} 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
* @memberof module:openmct.TimeAPI# * @memberof module:openmct.TimeAPI#
* @method validateBounds * @method addIndependentTimeContext
*/ */
TimeAPI.prototype.validateBounds = function (bounds) { addIndependentContext(key, value, clockKey) {
if ((bounds.start === undefined) let timeContext = this.independentContexts.get(key);
|| (bounds.end === undefined) if (!timeContext) {
|| isNaN(bounds.start) timeContext = new IndependentTimeContext(this, key);
|| isNaN(bounds.end) this.independentContexts.set(key, timeContext);
) {
return "Start and end must be specified as integer values";
} else if (bounds.start > bounds.end) {
return "Specified start date exceeds end bound";
} }
return true; if (clockKey) {
}; timeContext.clock(clockKey, value);
} else {
/** timeContext.stopClock();
* Validate the given offsets. This can be used for pre-validation of timeContext.bounds(value);
* offsets, for example by views validating user inputs.
* @param {ClockOffsets} offsets The start and end offsets from a 'now' value.
* @returns {string | true} A validation error, or true if valid
* @memberof module:openmct.TimeAPI#
* @method validateBounds
*/
TimeAPI.prototype.validateOffsets = function (offsets) {
if ((offsets.start === undefined)
|| (offsets.end === undefined)
|| isNaN(offsets.start)
|| isNaN(offsets.end)
) {
return "Start and end offsets must be specified as integer values";
} else if (offsets.start >= offsets.end) {
return "Specified start offset must be < end offset";
} }
return true; this.emit('timeContext', key);
};
/** return () => {
* @typedef {Object} TimeBounds this.independentContexts.delete(key);
* @property {number} start The start time displayed by the time conductor timeContext.emit('timeContext', key);
* in ms since epoch. Epoch determined by currently active time system
* @property {number} end The end time displayed by the time conductor in ms
* since epoch.
* @memberof module:openmct.TimeAPI~
*/
/**
* Get or set the start and end time of the time conductor. Basic validation
* of bounds is performed.
*
* @param {module:openmct.TimeAPI~TimeConductorBounds} newBounds
* @throws {Error} Validation error
* @fires module:openmct.TimeAPI~bounds
* @returns {module:openmct.TimeAPI~TimeConductorBounds}
* @memberof module:openmct.TimeAPI#
* @method bounds
*/
TimeAPI.prototype.bounds = function (newBounds) {
if (arguments.length > 0) {
const validationResult = this.validateBounds(newBounds);
if (validationResult !== true) {
throw new Error(validationResult);
}
//Create a copy to avoid direct mutation of conductor bounds
this.boundsVal = JSON.parse(JSON.stringify(newBounds));
/**
* The start time, end time, or both have been updated.
* @event bounds
* @memberof module:openmct.TimeAPI~
* @property {TimeConductorBounds} bounds The newly updated bounds
* @property {boolean} [tick] `true` if the bounds update was due to
* a "tick" event (ie. was an automatic update), false otherwise.
*/
this.emit('bounds', this.boundsVal, false);
// If a bounds change results in a TOI outside of the current
// bounds, unset it
if (this.toi < newBounds.start || this.toi > newBounds.end) {
this.timeOfInterest(undefined);
}
}
//Return a copy to prevent direct mutation of time conductor bounds.
return JSON.parse(JSON.stringify(this.boundsVal));
};
/**
* Get or set the time system of the TimeAPI.
* @param {TimeSystem | string} timeSystem
* @param {module:openmct.TimeAPI~TimeConductorBounds} bounds
* @fires module:openmct.TimeAPI~timeSystem
* @returns {TimeSystem} The currently applied time system
* @memberof module:openmct.TimeAPI#
* @method timeSystem
*/
TimeAPI.prototype.timeSystem = function (timeSystemOrKey, bounds) {
if (arguments.length >= 1) {
if (arguments.length === 1 && !this.activeClock) {
throw new Error(
"Must specify bounds when changing time system without "
+ "an active clock."
);
}
let timeSystem;
if (timeSystemOrKey === undefined) {
throw "Please provide a time system";
}
if (typeof timeSystemOrKey === 'string') {
timeSystem = this.timeSystems.get(timeSystemOrKey);
if (timeSystem === undefined) {
throw "Unknown time system " + timeSystemOrKey + ". Has it been registered with 'addTimeSystem'?";
}
} else if (typeof timeSystemOrKey === 'object') {
timeSystem = timeSystemOrKey;
if (!this.timeSystems.has(timeSystem.key)) {
throw "Unknown time system " + timeSystem.key + ". Has it been registered with 'addTimeSystem'?";
}
} else {
throw "Attempt to set invalid time system in Time API. Please provide a previously registered time system object or key";
}
this.system = timeSystem;
/**
* The time system used by the time
* conductor has changed. A change in Time System will always be
* followed by a bounds event specifying new query bounds.
*
* @event module:openmct.TimeAPI~timeSystem
* @property {TimeSystem} The value of the currently applied
* Time System
* */
this.emit('timeSystem', this.system);
if (bounds) {
this.bounds(bounds);
}
}
return this.system;
};
/**
* Get or set the Time of Interest. The Time of Interest is a single point
* in time, and constitutes the temporal focus of application views. It can
* be manipulated by the user from the time conductor or from other views.
* The time of interest can effectively be unset by assigning a value of
* 'undefined'.
* @fires module:openmct.TimeAPI~timeOfInterest
* @param newTOI
* @returns {number} the current time of interest
* @memberof module:openmct.TimeAPI#
* @method timeOfInterest
*/
TimeAPI.prototype.timeOfInterest = function (newTOI) {
if (arguments.length > 0) {
this.toi = newTOI;
/**
* The Time of Interest has moved.
* @event timeOfInterest
* @memberof module:openmct.TimeAPI~
* @property {number} Current time of interest
*/
this.emit('timeOfInterest', this.toi);
}
return this.toi;
};
/**
* Update bounds based on provided time and current offsets
* @private
* @param {number} timestamp A time from which boudns will be calculated
* using current offsets.
*/
TimeAPI.prototype.tick = function (timestamp) {
const newBounds = {
start: timestamp + this.offsets.start,
end: timestamp + this.offsets.end
}; };
}
this.boundsVal = newBounds;
this.emit('bounds', this.boundsVal, true);
// If a bounds change results in a TOI outside of the current
// bounds, unset it
if (this.toi < newBounds.start || this.toi > newBounds.end) {
this.timeOfInterest(undefined);
}
};
/** /**
* Set the active clock. Tick source will be immediately subscribed to * Get the independent time context which follows the TimeAPI timeSystem,
* and ticking will begin. Offsets from 'now' must also be provided. A clock * but with different offsets.
* can be unset by calling {@link stopClock}. * @param {key | string} key The identifier key of the domain object these offsets
* * @memberof module:openmct.TimeAPI#
* @param {Clock || string} The clock to activate, or its key * @method getIndependentTimeContext
* @param {ClockOffsets} offsets on each tick these will be used to calculate
* the start and end bounds. This maintains a sliding time window of a fixed
* width that automatically updates.
* @fires module:openmct.TimeAPI~clock
* @return {Clock} the currently active clock;
*/ */
TimeAPI.prototype.clock = function (keyOrClock, offsets) { getIndependentContext(key) {
if (arguments.length === 2) { return this.independentContexts.get(key);
let clock; }
if (typeof keyOrClock === 'string') {
clock = this.clocks.get(keyOrClock);
if (clock === undefined) {
throw "Unknown clock '" + keyOrClock + "'. Has it been registered with 'addClock'?";
}
} else if (typeof keyOrClock === 'object') {
clock = keyOrClock;
if (!this.clocks.has(clock.key)) {
throw "Unknown clock '" + keyOrClock.key + "'. Has it been registered with 'addClock'?";
}
}
const previousClock = this.activeClock;
if (previousClock !== undefined) {
previousClock.off("tick", this.tick);
}
this.activeClock = clock;
/**
* The active clock has changed. Clock can be unset by calling {@link stopClock}
* @event clock
* @memberof module:openmct.TimeAPI~
* @property {Clock} clock The newly activated clock, or undefined
* if the system is no longer following a clock source
*/
this.emit("clock", this.activeClock);
if (this.activeClock !== undefined) {
this.clockOffsets(offsets);
this.activeClock.on("tick", this.tick);
}
} else if (arguments.length === 1) {
throw "When setting the clock, clock offsets must also be provided";
}
return this.activeClock;
};
/** /**
* Clock offsets are used to calculate temporal bounds when the system is * Get the a timeContext for a view based on it's objectPath. If there is any object in the objectPath with an independent time context, it will be returned.
* ticking on a clock source. * Otherwise, the global time context will be returned.
* * @param { Array } objectPath The view's objectPath
* @typedef {object} ClockOffsets * @memberof module:openmct.TimeAPI#
* @property {number} start A time span relative to the current value of the * @method getContextForView
* ticking clock, from which start bounds will be calculated. This value must
* be < 0. When a clock is active, bounds will be calculated automatically
* based on the value provided by the clock, and the defined clock offsets.
* @property {number} end A time span relative to the current value of the
* ticking clock, from which end bounds will be calculated. This value must
* be >= 0.
*/ */
/** getContextForView(objectPath) {
* Get or set the currently applied clock offsets. If no parameter is provided, let timeContext = this;
* the current value will be returned. If provided, the new value will be
* used as the new clock offsets.
* @param {ClockOffsets} offsets
* @returns {ClockOffsets}
*/
TimeAPI.prototype.clockOffsets = function (offsets) {
if (arguments.length > 0) {
const validationResult = this.validateOffsets(offsets); objectPath.forEach(item => {
if (validationResult !== true) { const key = this.openmct.objects.makeKeyString(item.identifier);
throw new Error(validationResult); if (this.independentContexts.get(key)) {
timeContext = this.independentContexts.get(key);
} }
});
this.offsets = offsets; return timeContext;
}
const currentValue = this.activeClock.currentValue(); }
const newBounds = {
start: currentValue + offsets.start,
end: currentValue + offsets.end
};
this.bounds(newBounds); export default TimeAPI;
/**
* Event that is triggered when clock offsets change.
* @event clockOffsets
* @memberof module:openmct.TimeAPI~
* @property {ClockOffsets} clockOffsets The newly activated clock
* offsets.
*/
this.emit("clockOffsets", offsets);
}
return this.offsets;
};
/**
* Stop the currently active clock from ticking, and unset it. This will
* revert all views to showing a static time frame defined by the current
* bounds.
*/
TimeAPI.prototype.stopClock = function () {
if (this.activeClock) {
this.clock(undefined, undefined);
}
};
return TimeAPI;
});

View File

@ -19,241 +19,243 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import TimeAPI from "./TimeAPI";
import {createOpenMct} from "utils/testing";
define(['./TimeAPI'], function (TimeAPI) { describe("The Time API", function () {
describe("The Time API", function () { let api;
let api; let timeSystemKey;
let timeSystemKey; let timeSystem;
let timeSystem; let clockKey;
let clockKey; let clock;
let clock; let bounds;
let bounds; let eventListener;
let eventListener; let toi;
let toi; let openmct;
beforeEach(function () {
openmct = createOpenMct();
api = new TimeAPI(openmct);
timeSystemKey = "timeSystemKey";
timeSystem = {key: timeSystemKey};
clockKey = "someClockKey";
clock = jasmine.createSpyObj("clock", [
"on",
"off",
"currentValue"
]);
clock.currentValue.and.returnValue(100);
clock.key = clockKey;
bounds = {
start: 0,
end: 1
};
eventListener = jasmine.createSpy("eventListener");
toi = 111;
});
it("Supports setting and querying of time of interest", function () {
expect(api.timeOfInterest()).not.toBe(toi);
api.timeOfInterest(toi);
expect(api.timeOfInterest()).toBe(toi);
});
it("Allows setting of valid bounds", function () {
bounds = {
start: 0,
end: 1
};
expect(api.bounds()).not.toBe(bounds);
expect(api.bounds.bind(api, bounds)).not.toThrow();
expect(api.bounds()).toEqual(bounds);
});
it("Disallows setting of invalid bounds", function () {
bounds = {
start: 1,
end: 0
};
expect(api.bounds()).not.toEqual(bounds);
expect(api.bounds.bind(api, bounds)).toThrow();
expect(api.bounds()).not.toEqual(bounds);
bounds = {start: 1};
expect(api.bounds()).not.toEqual(bounds);
expect(api.bounds.bind(api, bounds)).toThrow();
expect(api.bounds()).not.toEqual(bounds);
});
it("Allows setting of previously registered time system with bounds", function () {
api.addTimeSystem(timeSystem);
expect(api.timeSystem()).not.toBe(timeSystem);
expect(function () {
api.timeSystem(timeSystem, bounds);
}).not.toThrow();
expect(api.timeSystem()).toBe(timeSystem);
});
it("Disallows setting of time system without bounds", function () {
api.addTimeSystem(timeSystem);
expect(api.timeSystem()).not.toBe(timeSystem);
expect(function () {
api.timeSystem(timeSystemKey);
}).toThrow();
expect(api.timeSystem()).not.toBe(timeSystem);
});
it("allows setting of timesystem without bounds with clock", function () {
api.addTimeSystem(timeSystem);
api.addClock(clock);
api.clock(clockKey, {
start: 0,
end: 1
});
expect(api.timeSystem()).not.toBe(timeSystem);
expect(function () {
api.timeSystem(timeSystemKey);
}).not.toThrow();
expect(api.timeSystem()).toBe(timeSystem);
});
it("Emits an event when time system changes", function () {
api.addTimeSystem(timeSystem);
expect(eventListener).not.toHaveBeenCalled();
api.on("timeSystem", eventListener);
api.timeSystem(timeSystemKey, bounds);
expect(eventListener).toHaveBeenCalledWith(timeSystem);
});
it("Emits an event when time of interest changes", function () {
expect(eventListener).not.toHaveBeenCalled();
api.on("timeOfInterest", eventListener);
api.timeOfInterest(toi);
expect(eventListener).toHaveBeenCalledWith(toi);
});
it("Emits an event when bounds change", function () {
expect(eventListener).not.toHaveBeenCalled();
api.on("bounds", eventListener);
api.bounds(bounds);
expect(eventListener).toHaveBeenCalledWith(bounds, false);
});
it("If bounds are set and TOI lies inside them, do not change TOI", function () {
api.timeOfInterest(6);
api.bounds({
start: 1,
end: 10
});
expect(api.timeOfInterest()).toEqual(6);
});
it("If bounds are set and TOI lies outside them, reset TOI", function () {
api.timeOfInterest(11);
api.bounds({
start: 1,
end: 10
});
expect(api.timeOfInterest()).toBeUndefined();
});
it("Maintains delta during tick", function () {
});
it("Allows registered time system to be activated", function () {
});
it("Allows a registered tick source to be activated", function () {
const mockTickSource = jasmine.createSpyObj("mockTickSource", [
"on",
"off",
"currentValue"
]);
mockTickSource.key = 'mockTickSource';
});
describe(" when enabling a tick source", function () {
let mockTickSource;
let anotherMockTickSource;
const mockOffsets = {
start: 0,
end: 1
};
beforeEach(function () { beforeEach(function () {
api = new TimeAPI(); mockTickSource = jasmine.createSpyObj("clock", [
timeSystemKey = "timeSystemKey";
timeSystem = {key: timeSystemKey};
clockKey = "someClockKey";
clock = jasmine.createSpyObj("clock", [
"on", "on",
"off", "off",
"currentValue" "currentValue"
]); ]);
clock.currentValue.and.returnValue(100); mockTickSource.currentValue.and.returnValue(10);
clock.key = clockKey;
bounds = {
start: 0,
end: 1
};
eventListener = jasmine.createSpy("eventListener");
toi = 111;
});
it("Supports setting and querying of time of interest", function () {
expect(api.timeOfInterest()).not.toBe(toi);
api.timeOfInterest(toi);
expect(api.timeOfInterest()).toBe(toi);
});
it("Allows setting of valid bounds", function () {
bounds = {
start: 0,
end: 1
};
expect(api.bounds()).not.toBe(bounds);
expect(api.bounds.bind(api, bounds)).not.toThrow();
expect(api.bounds()).toEqual(bounds);
});
it("Disallows setting of invalid bounds", function () {
bounds = {
start: 1,
end: 0
};
expect(api.bounds()).not.toEqual(bounds);
expect(api.bounds.bind(api, bounds)).toThrow();
expect(api.bounds()).not.toEqual(bounds);
bounds = {start: 1};
expect(api.bounds()).not.toEqual(bounds);
expect(api.bounds.bind(api, bounds)).toThrow();
expect(api.bounds()).not.toEqual(bounds);
});
it("Allows setting of previously registered time system with bounds", function () {
api.addTimeSystem(timeSystem);
expect(api.timeSystem()).not.toBe(timeSystem);
expect(function () {
api.timeSystem(timeSystem, bounds);
}).not.toThrow();
expect(api.timeSystem()).toBe(timeSystem);
});
it("Disallows setting of time system without bounds", function () {
api.addTimeSystem(timeSystem);
expect(api.timeSystem()).not.toBe(timeSystem);
expect(function () {
api.timeSystem(timeSystemKey);
}).toThrow();
expect(api.timeSystem()).not.toBe(timeSystem);
});
it("allows setting of timesystem without bounds with clock", function () {
api.addTimeSystem(timeSystem);
api.addClock(clock);
api.clock(clockKey, {
start: 0,
end: 1
});
expect(api.timeSystem()).not.toBe(timeSystem);
expect(function () {
api.timeSystem(timeSystemKey);
}).not.toThrow();
expect(api.timeSystem()).toBe(timeSystem);
});
it("Emits an event when time system changes", function () {
api.addTimeSystem(timeSystem);
expect(eventListener).not.toHaveBeenCalled();
api.on("timeSystem", eventListener);
api.timeSystem(timeSystemKey, bounds);
expect(eventListener).toHaveBeenCalledWith(timeSystem);
});
it("Emits an event when time of interest changes", function () {
expect(eventListener).not.toHaveBeenCalled();
api.on("timeOfInterest", eventListener);
api.timeOfInterest(toi);
expect(eventListener).toHaveBeenCalledWith(toi);
});
it("Emits an event when bounds change", function () {
expect(eventListener).not.toHaveBeenCalled();
api.on("bounds", eventListener);
api.bounds(bounds);
expect(eventListener).toHaveBeenCalledWith(bounds, false);
});
it("If bounds are set and TOI lies inside them, do not change TOI", function () {
api.timeOfInterest(6);
api.bounds({
start: 1,
end: 10
});
expect(api.timeOfInterest()).toEqual(6);
});
it("If bounds are set and TOI lies outside them, reset TOI", function () {
api.timeOfInterest(11);
api.bounds({
start: 1,
end: 10
});
expect(api.timeOfInterest()).toBeUndefined();
});
it("Maintains delta during tick", function () {
});
it("Allows registered time system to be activated", function () {
});
it("Allows a registered tick source to be activated", function () {
const mockTickSource = jasmine.createSpyObj("mockTickSource", [
"on",
"off",
"currentValue"
]);
mockTickSource.key = 'mockTickSource';
});
describe(" when enabling a tick source", function () {
let mockTickSource;
let anotherMockTickSource;
const mockOffsets = {
start: 0,
end: 1
};
beforeEach(function () {
mockTickSource = jasmine.createSpyObj("clock", [
"on",
"off",
"currentValue"
]);
mockTickSource.currentValue.and.returnValue(10);
mockTickSource.key = "mts";
anotherMockTickSource = jasmine.createSpyObj("clock", [
"on",
"off",
"currentValue"
]);
anotherMockTickSource.key = "amts";
anotherMockTickSource.currentValue.and.returnValue(10);
api.addClock(mockTickSource);
api.addClock(anotherMockTickSource);
});
it("sets bounds based on current value", function () {
api.clock("mts", mockOffsets);
expect(api.bounds()).toEqual({
start: 10,
end: 11
});
});
it("a new tick listener is registered", function () {
api.clock("mts", mockOffsets);
expect(mockTickSource.on).toHaveBeenCalledWith("tick", jasmine.any(Function));
});
it("listener of existing tick source is reregistered", function () {
api.clock("mts", mockOffsets);
api.clock("amts", mockOffsets);
expect(mockTickSource.off).toHaveBeenCalledWith("tick", jasmine.any(Function));
});
it("Allows the active clock to be set and unset", function () {
expect(api.clock()).toBeUndefined();
api.clock("mts", mockOffsets);
expect(api.clock()).toBeDefined();
api.stopClock();
expect(api.clock()).toBeUndefined();
});
});
it("on tick, observes offsets, and indicates tick in bounds callback", function () {
const mockTickSource = jasmine.createSpyObj("clock", [
"on",
"off",
"currentValue"
]);
mockTickSource.currentValue.and.returnValue(100);
let tickCallback;
const boundsCallback = jasmine.createSpy("boundsCallback");
const clockOffsets = {
start: -100,
end: 100
};
mockTickSource.key = "mts"; mockTickSource.key = "mts";
anotherMockTickSource = jasmine.createSpyObj("clock", [
"on",
"off",
"currentValue"
]);
anotherMockTickSource.key = "amts";
anotherMockTickSource.currentValue.and.returnValue(10);
api.addClock(mockTickSource); api.addClock(mockTickSource);
api.clock("mts", clockOffsets); api.addClock(anotherMockTickSource);
api.on("bounds", boundsCallback);
tickCallback = mockTickSource.on.calls.mostRecent().args[1];
tickCallback(1000);
expect(boundsCallback).toHaveBeenCalledWith({
start: 900,
end: 1100
}, true);
}); });
it("sets bounds based on current value", function () {
api.clock("mts", mockOffsets);
expect(api.bounds()).toEqual({
start: 10,
end: 11
});
});
it("a new tick listener is registered", function () {
api.clock("mts", mockOffsets);
expect(mockTickSource.on).toHaveBeenCalledWith("tick", jasmine.any(Function));
});
it("listener of existing tick source is reregistered", function () {
api.clock("mts", mockOffsets);
api.clock("amts", mockOffsets);
expect(mockTickSource.off).toHaveBeenCalledWith("tick", jasmine.any(Function));
});
it("Allows the active clock to be set and unset", function () {
expect(api.clock()).toBeUndefined();
api.clock("mts", mockOffsets);
expect(api.clock()).toBeDefined();
api.stopClock();
expect(api.clock()).toBeUndefined();
});
});
it("on tick, observes offsets, and indicates tick in bounds callback", function () {
const mockTickSource = jasmine.createSpyObj("clock", [
"on",
"off",
"currentValue"
]);
mockTickSource.currentValue.and.returnValue(100);
let tickCallback;
const boundsCallback = jasmine.createSpy("boundsCallback");
const clockOffsets = {
start: -100,
end: 100
};
mockTickSource.key = "mts";
api.addClock(mockTickSource);
api.clock("mts", clockOffsets);
api.on("bounds", boundsCallback);
tickCallback = mockTickSource.on.calls.mostRecent().args[1];
tickCallback(1000);
expect(boundsCallback).toHaveBeenCalledWith({
start: 900,
end: 1100
}, true);
}); });
}); });

360
src/api/time/TimeContext.js Normal file
View File

@ -0,0 +1,360 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import EventEmitter from 'EventEmitter';
class TimeContext extends EventEmitter {
constructor() {
super();
//The Time System
this.timeSystems = new Map();
this.system = undefined;
this.clocks = new Map();
this.boundsVal = {
start: undefined,
end: undefined
};
this.activeClock = undefined;
this.offsets = undefined;
this.tick = this.tick.bind(this);
}
/**
* Get or set the time system of the TimeAPI.
* @param {TimeSystem | string} timeSystem
* @param {module:openmct.TimeAPI~TimeConductorBounds} bounds
* @fires module:openmct.TimeAPI~timeSystem
* @returns {TimeSystem} The currently applied time system
* @memberof module:openmct.TimeAPI#
* @method timeSystem
*/
timeSystem(timeSystemOrKey, bounds) {
if (arguments.length >= 1) {
if (arguments.length === 1 && !this.activeClock) {
throw new Error(
"Must specify bounds when changing time system without "
+ "an active clock."
);
}
let timeSystem;
if (timeSystemOrKey === undefined) {
throw "Please provide a time system";
}
if (typeof timeSystemOrKey === 'string') {
timeSystem = this.timeSystems.get(timeSystemOrKey);
if (timeSystem === undefined) {
throw "Unknown time system " + timeSystemOrKey + ". Has it been registered with 'addTimeSystem'?";
}
} else if (typeof timeSystemOrKey === 'object') {
timeSystem = timeSystemOrKey;
if (!this.timeSystems.has(timeSystem.key)) {
throw "Unknown time system " + timeSystem.key + ". Has it been registered with 'addTimeSystem'?";
}
} else {
throw "Attempt to set invalid time system in Time API. Please provide a previously registered time system object or key";
}
this.system = timeSystem;
/**
* The time system used by the time
* conductor has changed. A change in Time System will always be
* followed by a bounds event specifying new query bounds.
*
* @event module:openmct.TimeAPI~timeSystem
* @property {TimeSystem} The value of the currently applied
* Time System
* */
this.emit('timeSystem', this.system);
if (bounds) {
this.bounds(bounds);
}
}
return this.system;
}
/**
* Clock offsets are used to calculate temporal bounds when the system is
* ticking on a clock source.
*
* @typedef {object} ValidationResult
* @property {boolean} valid Result of the validation - true or false.
* @property {string} message An error message if valid is false.
*/
/**
* Validate the given bounds. This can be used for pre-validation of bounds,
* for example by views validating user inputs.
* @param {TimeBounds} bounds The start and end time of the conductor.
* @returns {ValidationResult} A validation error, or true if valid
* @memberof module:openmct.TimeAPI#
* @method validateBounds
*/
validateBounds(bounds) {
if ((bounds.start === undefined)
|| (bounds.end === undefined)
|| isNaN(bounds.start)
|| isNaN(bounds.end)
) {
return {
valid: false,
message: "Start and end must be specified as integer values"
};
} else if (bounds.start > bounds.end) {
return {
valid: false,
message: "Specified start date exceeds end bound"
};
}
return {
valid: true,
message: ''
};
}
/**
* Get or set the start and end time of the time conductor. Basic validation
* of bounds is performed.
*
* @param {module:openmct.TimeAPI~TimeConductorBounds} newBounds
* @throws {Error} Validation error
* @fires module:openmct.TimeAPI~bounds
* @returns {module:openmct.TimeAPI~TimeConductorBounds}
* @memberof module:openmct.TimeAPI#
* @method bounds
*/
bounds(newBounds) {
if (arguments.length > 0) {
const validationResult = this.validateBounds(newBounds);
if (validationResult.valid !== true) {
throw new Error(validationResult.message);
}
//Create a copy to avoid direct mutation of conductor bounds
this.boundsVal = JSON.parse(JSON.stringify(newBounds));
/**
* The start time, end time, or both have been updated.
* @event bounds
* @memberof module:openmct.TimeAPI~
* @property {TimeConductorBounds} bounds The newly updated bounds
* @property {boolean} [tick] `true` if the bounds update was due to
* a "tick" event (ie. was an automatic update), false otherwise.
*/
this.emit('bounds', this.boundsVal, false);
}
//Return a copy to prevent direct mutation of time conductor bounds.
return JSON.parse(JSON.stringify(this.boundsVal));
}
/**
* Validate the given offsets. This can be used for pre-validation of
* offsets, for example by views validating user inputs.
* @param {ClockOffsets} offsets The start and end offsets from a 'now' value.
* @returns { ValidationResult } A validation error, and true/false if valid or not
* @memberof module:openmct.TimeAPI#
* @method validateOffsets
*/
validateOffsets(offsets) {
if ((offsets.start === undefined)
|| (offsets.end === undefined)
|| isNaN(offsets.start)
|| isNaN(offsets.end)
) {
return {
valid: false,
message: "Start and end offsets must be specified as integer values"
};
} else if (offsets.start >= offsets.end) {
return {
valid: false,
message: "Specified start offset must be < end offset"
};
}
return {
valid: true,
message: ''
};
}
/**
* @typedef {Object} TimeBounds
* @property {number} start The start time displayed by the time conductor
* in ms since epoch. Epoch determined by currently active time system
* @property {number} end The end time displayed by the time conductor in ms
* since epoch.
* @memberof module:openmct.TimeAPI~
*/
/**
* Clock offsets are used to calculate temporal bounds when the system is
* ticking on a clock source.
*
* @typedef {object} ClockOffsets
* @property {number} start A time span relative to the current value of the
* ticking clock, from which start bounds will be calculated. This value must
* be < 0. When a clock is active, bounds will be calculated automatically
* based on the value provided by the clock, and the defined clock offsets.
* @property {number} end A time span relative to the current value of the
* ticking clock, from which end bounds will be calculated. This value must
* be >= 0.
*/
/**
* Get or set the currently applied clock offsets. If no parameter is provided,
* the current value will be returned. If provided, the new value will be
* used as the new clock offsets.
* @param {ClockOffsets} offsets
* @returns {ClockOffsets}
*/
clockOffsets(offsets) {
if (arguments.length > 0) {
const validationResult = this.validateOffsets(offsets);
if (validationResult.valid !== true) {
throw new Error(validationResult.message);
}
this.offsets = offsets;
const currentValue = this.activeClock.currentValue();
const newBounds = {
start: currentValue + offsets.start,
end: currentValue + offsets.end
};
this.bounds(newBounds);
/**
* Event that is triggered when clock offsets change.
* @event clockOffsets
* @memberof module:openmct.TimeAPI~
* @property {ClockOffsets} clockOffsets The newly activated clock
* offsets.
*/
this.emit("clockOffsets", offsets);
}
return this.offsets;
}
/**
* Stop the currently active clock from ticking, and unset it. This will
* revert all views to showing a static time frame defined by the current
* bounds.
*/
stopClock() {
if (this.activeClock) {
this.clock(undefined, undefined);
}
}
/**
* Set the active clock. Tick source will be immediately subscribed to
* and ticking will begin. Offsets from 'now' must also be provided. A clock
* can be unset by calling {@link stopClock}.
*
* @param {Clock || string} keyOrClock The clock to activate, or its key
* @param {ClockOffsets} offsets on each tick these will be used to calculate
* the start and end bounds. This maintains a sliding time window of a fixed
* width that automatically updates.
* @fires module:openmct.TimeAPI~clock
* @return {Clock} the currently active clock;
*/
clock(keyOrClock, offsets) {
if (arguments.length === 2) {
let clock;
if (typeof keyOrClock === 'string') {
clock = this.clocks.get(keyOrClock);
if (clock === undefined) {
throw "Unknown clock '" + keyOrClock + "'. Has it been registered with 'addClock'?";
}
} else if (typeof keyOrClock === 'object') {
clock = keyOrClock;
if (!this.clocks.has(clock.key)) {
throw "Unknown clock '" + keyOrClock.key + "'. Has it been registered with 'addClock'?";
}
}
const previousClock = this.activeClock;
if (previousClock !== undefined) {
previousClock.off("tick", this.tick);
}
this.activeClock = clock;
/**
* The active clock has changed. Clock can be unset by calling {@link stopClock}
* @event clock
* @memberof module:openmct.TimeAPI~
* @property {Clock} clock The newly activated clock, or undefined
* if the system is no longer following a clock source
*/
this.emit("clock", this.activeClock);
if (this.activeClock !== undefined) {
this.clockOffsets(offsets);
this.activeClock.on("tick", this.tick);
}
} else if (arguments.length === 1) {
throw "When setting the clock, clock offsets must also be provided";
}
return this.activeClock;
}
/**
* Update bounds based on provided time and current offsets
* @param {number} timestamp A time from which bounds will be calculated
* using current offsets.
*/
tick(timestamp) {
if (!this.activeClock) {
return;
}
const newBounds = {
start: timestamp + this.offsets.start,
end: timestamp + this.offsets.end
};
this.boundsVal = newBounds;
this.emit('bounds', this.boundsVal, true);
}
}
export default TimeContext;

View File

@ -0,0 +1,155 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import TimeAPI from "./TimeAPI";
import {createOpenMct} from "utils/testing";
describe("The Independent Time API", function () {
let api;
let domainObjectKey;
let clockKey;
let clock;
let bounds;
let independentBounds;
let eventListener;
let openmct;
beforeEach(function () {
openmct = createOpenMct();
api = new TimeAPI(openmct);
clockKey = "someClockKey";
clock = jasmine.createSpyObj("clock", [
"on",
"off",
"currentValue"
]);
clock.currentValue.and.returnValue(100);
clock.key = clockKey;
api.addClock(clock);
domainObjectKey = 'test-key';
bounds = {
start: 0,
end: 1
};
api.bounds(bounds);
independentBounds = {
start: 10,
end: 11
};
eventListener = jasmine.createSpy("eventListener");
});
it("Creates an independent time context", () => {
let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds);
let timeContext = api.getIndependentContext(domainObjectKey);
expect(timeContext.bounds()).toEqual(independentBounds);
destroyTimeContext();
});
it("Gets an independent time context given the objectPath", () => {
let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds);
let timeContext = api.getContextForView([{
identifier: {
namespace: '',
key: 'blah'
}
}, { identifier: domainObjectKey }]);
expect(timeContext.bounds()).toEqual(independentBounds);
destroyTimeContext();
});
it("defaults to the global time context given the objectPath", () => {
let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds);
let timeContext = api.getContextForView([{
identifier: {
namespace: '',
key: 'blah'
}
}]);
expect(timeContext.bounds()).toEqual(bounds);
destroyTimeContext();
});
it("Allows setting of valid bounds", function () {
bounds = {
start: 0,
end: 1
};
let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds);
let timeContext = api.getContextForView([{identifier: domainObjectKey}]);
expect(timeContext.bounds()).not.toEqual(bounds);
timeContext.bounds(bounds);
expect(timeContext.bounds()).toEqual(bounds);
destroyTimeContext();
});
it("Disallows setting of invalid bounds", function () {
bounds = {
start: 1,
end: 0
};
let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds);
let timeContext = api.getContextForView([{identifier: domainObjectKey}]);
expect(timeContext.bounds()).not.toBe(bounds);
expect(timeContext.bounds.bind(timeContext, bounds)).toThrow();
expect(timeContext.bounds()).not.toEqual(bounds);
bounds = {start: 1};
expect(timeContext.bounds()).not.toEqual(bounds);
expect(timeContext.bounds.bind(timeContext, bounds)).toThrow();
expect(timeContext.bounds()).not.toEqual(bounds);
destroyTimeContext();
});
it("Emits an event when bounds change", function () {
let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds);
let timeContext = api.getContextForView([{identifier: domainObjectKey}]);
expect(eventListener).not.toHaveBeenCalled();
timeContext.on('bounds', eventListener);
timeContext.bounds(bounds);
expect(eventListener).toHaveBeenCalledWith(bounds, false);
destroyTimeContext();
});
describe(" when using real time clock", function () {
const mockOffsets = {
start: 10,
end: 11
};
it("Emits an event when bounds change based on current value", function () {
let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds);
let timeContext = api.getContextForView([{identifier: domainObjectKey}]);
expect(eventListener).not.toHaveBeenCalled();
timeContext.clock('someClockKey', mockOffsets);
timeContext.on('bounds', eventListener);
timeContext.tick(10);
expect(eventListener).toHaveBeenCalledWith({
start: 20,
end: 21
}, true);
destroyTimeContext();
});
});
});

View File

@ -40,7 +40,6 @@ import PreviewAction from "@/ui/preview/PreviewAction";
import _ from "lodash"; import _ from "lodash";
const PADDING = 1; const PADDING = 1;
const RESIZE_POLL_INTERVAL = 200;
const ROW_HEIGHT = 100; const ROW_HEIGHT = 100;
const IMAGE_WIDTH_THRESHOLD = 40; const IMAGE_WIDTH_THRESHOLD = 40;

View File

@ -32,19 +32,19 @@ const TEN_MINUTES = ONE_MINUTE * 10;
const MAIN_IMAGE_CLASS = '.js-imageryView-image'; const MAIN_IMAGE_CLASS = '.js-imageryView-image';
const NEW_IMAGE_CLASS = '.c-imagery__age.c-imagery--new'; const NEW_IMAGE_CLASS = '.c-imagery__age.c-imagery--new';
const REFRESH_CSS_MS = 500; const REFRESH_CSS_MS = 500;
const TOLERANCE = 0.50; // const TOLERANCE = 0.50;
function comparisonFunction(valueOne, valueTwo) { // function comparisonFunction(valueOne, valueTwo) {
let larger = valueOne; // let larger = valueOne;
let smaller = valueTwo; // let smaller = valueTwo;
//
if (larger < smaller) { // if (larger < smaller) {
larger = valueTwo; // larger = valueTwo;
smaller = valueOne; // smaller = valueOne;
} // }
//
return (larger - smaller) < TOLERANCE; // return (larger - smaller) < TOLERANCE;
} // }
function getImageInfo(doc) { function getImageInfo(doc) {
let imageElement = doc.querySelectorAll(MAIN_IMAGE_CLASS)[0]; let imageElement = doc.querySelectorAll(MAIN_IMAGE_CLASS)[0];

View File

@ -67,7 +67,7 @@ export default {
TimelineAxis, TimelineAxis,
SwimLane SwimLane
}, },
inject: ['openmct', 'domainObject'], inject: ['openmct', 'domainObject', 'path'],
props: { props: {
options: { options: {
type: Object, type: Object,
@ -99,21 +99,37 @@ export default {
this.canvasContext = this.canvas.getContext('2d'); this.canvasContext = this.canvas.getContext('2d');
this.setDimensions(); this.setDimensions();
this.updateViewBounds(); this.setTimeContext();
this.openmct.time.on("timeSystem", this.setScaleAndPlotActivities);
this.openmct.time.on("bounds", this.updateViewBounds);
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL); this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
this.unlisten = this.openmct.objects.observe(this.domainObject, '*', this.observeForChanges); this.unlisten = this.openmct.objects.observe(this.domainObject, '*', this.observeForChanges);
}, },
beforeDestroy() { beforeDestroy() {
clearInterval(this.resizeTimer); clearInterval(this.resizeTimer);
this.openmct.time.off("timeSystem", this.setScaleAndPlotActivities); this.stopFollowingTimeContext();
this.openmct.time.off("bounds", this.updateViewBounds);
if (this.unlisten) { if (this.unlisten) {
this.unlisten(); this.unlisten();
} }
}, },
methods: { methods: {
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView(this.path);
this.timeContext.on("timeContext", this.setTimeContext);
this.followTimeContext();
},
followTimeContext() {
this.updateViewBounds(this.timeContext.bounds());
this.timeContext.on("timeSystem", this.setScaleAndPlotActivities);
this.timeContext.on("bounds", this.updateViewBounds);
},
stopFollowingTimeContext() {
if (this.timeContext) {
this.timeContext.off("timeSystem", this.setScaleAndPlotActivities);
this.timeContext.off("bounds", this.updateViewBounds);
this.timeContext.off("timeContext", this.setTimeContext);
}
},
observeForChanges(mutatedObject) { observeForChanges(mutatedObject) {
this.getPlanData(mutatedObject); this.getPlanData(mutatedObject);
this.setScaleAndPlotActivities(); this.setScaleAndPlotActivities();
@ -141,13 +157,9 @@ export default {
getPlanData(domainObject) { getPlanData(domainObject) {
this.planData = getValidatedPlan(domainObject); this.planData = getValidatedPlan(domainObject);
}, },
updateViewBounds() { updateViewBounds(bounds) {
this.viewBounds = this.openmct.time.bounds(); if (bounds) {
if (!this.options.compact) { this.viewBounds = Object.create(bounds);
//Add a 50% padding to the end bounds to look ahead
let timespan = (this.viewBounds.end - this.viewBounds.start);
let padding = timespan / 2;
this.viewBounds.end = this.viewBounds.end + padding;
} }
if (this.timeSystem === undefined) { if (this.timeSystem === undefined) {

View File

@ -54,7 +54,8 @@ export default function PlanViewProvider(openmct) {
}, },
provide: { provide: {
openmct, openmct,
domainObject domainObject,
path: objectPath
}, },
data() { data() {
return { return {

View File

@ -173,7 +173,7 @@ export default {
MctTicks, MctTicks,
MctChart MctChart
}, },
inject: ['openmct', 'domainObject'], inject: ['openmct', 'domainObject', 'path'],
props: { props: {
options: { options: {
type: Object, type: Object,
@ -244,6 +244,9 @@ export default {
}, },
mounted() { mounted() {
eventHelpers.extend(this); eventHelpers.extend(this);
this.updateRealTime = this.updateRealTime.bind(this);
this.updateDisplayBounds = this.updateDisplayBounds.bind(this);
this.setTimeContext = this.setTimeContext.bind(this);
this.config = this.getConfig(); this.config = this.getConfig();
this.legend = this.config.legend; this.legend = this.config.legend;
@ -261,7 +264,7 @@ export default {
this.removeStatusListener = this.openmct.status.observe(this.domainObject.identifier, this.updateStatus); this.removeStatusListener = this.openmct.status.observe(this.domainObject.identifier, this.updateStatus);
this.openmct.objectViews.on('clearData', this.clearData); this.openmct.objectViews.on('clearData', this.clearData);
this.followTimeConductor(); this.setTimeContext();
this.loaded = true; this.loaded = true;
@ -274,11 +277,27 @@ export default {
this.destroy(); this.destroy();
}, },
methods: { methods: {
followTimeConductor() { setTimeContext() {
this.openmct.time.on('clock', this.updateRealTime); this.stopFollowingTimeContext();
this.openmct.time.on('bounds', this.updateDisplayBounds);
this.timeContext = this.openmct.time.getContextForView(this.path);
this.timeContext.on('timeContext', this.setTimeContext);
this.followTimeContext();
},
followTimeContext() {
this.updateDisplayBounds(this.timeContext.bounds());
this.timeContext.on('clock', this.updateRealTime);
this.timeContext.on('bounds', this.updateDisplayBounds);
this.synchronized(true); this.synchronized(true);
}, },
stopFollowingTimeContext() {
if (this.timeContext) {
this.timeContext.off("clock", this.updateRealTime);
this.timeContext.off("bounds", this.updateDisplayBounds);
this.timeContext.off("timeContext", this.setTimeContext);
}
},
getConfig() { getConfig() {
const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier); const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
let config = configStore.get(configId); let config = configStore.get(configId);
@ -485,7 +504,7 @@ export default {
* displays can update accordingly. * displays can update accordingly.
*/ */
synchronized(value) { synchronized(value) {
const isLocalClock = this.openmct.time.clock(); const isLocalClock = this.timeContext.clock();
if (typeof value !== 'undefined') { if (typeof value !== 'undefined') {
this._synchronized = value; this._synchronized = value;
@ -958,7 +977,7 @@ export default {
}, },
showSynchronizeDialog() { showSynchronizeDialog() {
const isLocalClock = this.openmct.time.clock(); const isLocalClock = this.timeContext.clock();
if (isLocalClock !== undefined) { if (isLocalClock !== undefined) {
const message = ` const message = `
This action will change the Time Conductor to Fixed Timespan mode with this plot view's current time bounds. This action will change the Time Conductor to Fixed Timespan mode with this plot view's current time bounds.
@ -993,9 +1012,9 @@ export default {
}, },
synchronizeTimeConductor() { synchronizeTimeConductor() {
this.openmct.time.stopClock(); this.timeContext.stopClock();
const range = this.config.xAxis.get('displayRange'); const range = this.config.xAxis.get('displayRange');
this.openmct.time.bounds({ this.timeContext.bounds({
start: range.min, start: range.min,
end: range.max end: range.max
}); });
@ -1006,6 +1025,7 @@ export default {
configStore.deleteStore(this.config.id); configStore.deleteStore(this.config.id);
this.stopListening(); this.stopListening();
if (this.checkForSize) { if (this.checkForSize) {
clearInterval(this.checkForSize); clearInterval(this.checkForSize);
delete this.checkForSize; delete this.checkForSize;
@ -1021,8 +1041,7 @@ export default {
this.plotContainerResizeObserver.disconnect(); this.plotContainerResizeObserver.disconnect();
this.openmct.time.off('clock', this.updateRealTime); this.stopFollowingTimeContext();
this.openmct.time.off('bounds', this.updateDisplayBounds);
this.openmct.objectViews.off('clearData', this.clearData); this.openmct.objectViews.off('clearData', this.clearData);
}, },
updateStatus(status) { updateStatus(status) {

View File

@ -80,7 +80,7 @@ export default {
components: { components: {
MctPlot MctPlot
}, },
inject: ['openmct', 'domainObject'], inject: ['openmct', 'domainObject', 'path'],
props: { props: {
options: { options: {
type: Object, type: Object,

View File

@ -68,7 +68,8 @@ export default function PlotViewProvider(openmct) {
}, },
provide: { provide: {
openmct, openmct,
domainObject domainObject,
path: objectPath
}, },
data() { data() {
return { return {

View File

@ -53,7 +53,8 @@ export default function OverlayPlotViewProvider(openmct) {
}, },
provide: { provide: {
openmct, openmct,
domainObject domainObject,
path: objectPath
}, },
data() { data() {
return { return {

View File

@ -570,7 +570,8 @@ describe("the plugin", function () {
provide: { provide: {
openmct: openmct, openmct: openmct,
domainObject: stackedPlotObject, domainObject: stackedPlotObject,
composition: openmct.composition.get(stackedPlotObject) composition: openmct.composition.get(stackedPlotObject),
path: [stackedPlotObject]
}, },
template: "<stacked-plot></stacked-plot>" template: "<stacked-plot></stacked-plot>"
}); });

View File

@ -75,7 +75,7 @@ export default {
components: { components: {
StackedPlotItem StackedPlotItem
}, },
inject: ['openmct', 'domainObject', 'composition'], inject: ['openmct', 'domainObject', 'composition', 'path'],
props: { props: {
options: { options: {
type: Object, type: Object,

View File

@ -28,7 +28,7 @@ import MctPlot from '../MctPlot.vue';
import Vue from "vue"; import Vue from "vue";
export default { export default {
inject: ['openmct', 'domainObject'], inject: ['openmct', 'domainObject', 'path'],
props: { props: {
object: { object: {
type: Object, type: Object,
@ -94,6 +94,7 @@ export default {
const openmct = this.openmct; const openmct = this.openmct;
const object = this.object; const object = this.object;
const path = this.path;
const getProps = this.getProps; const getProps = this.getProps;
let viewContainer = document.createElement('div'); let viewContainer = document.createElement('div');
@ -106,7 +107,8 @@ export default {
}, },
provide: { provide: {
openmct, openmct,
domainObject: object domainObject: object,
path
}, },
data() { data() {
return { return {

View File

@ -55,7 +55,8 @@ export default function StackedPlotViewProvider(openmct) {
provide: { provide: {
openmct, openmct,
domainObject, domainObject,
composition: openmct.composition.get(domainObject) composition: openmct.composition.get(domainObject),
path: objectPath
}, },
data() { data() {
return { return {

View File

@ -29,144 +29,36 @@
isFixed ? 'is-fixed-mode' : 'is-realtime-mode' isFixed ? 'is-fixed-mode' : 'is-realtime-mode'
]" ]"
> >
<form <div class="c-conductor__time-bounds">
ref="conductorForm" <conductor-inputs-fixed v-if="isFixed"
class="u-contents" @updated="saveFixedOffsets"
@submit.prevent="updateTimeFromConductor" />
> <conductor-inputs-realtime v-else
<div class="c-conductor__time-bounds"> @updated="saveClockOffsets"
<button />
ref="submitButton" <ConductorModeIcon class="c-conductor__mode-icon" />
class="c-input--submit" <conductor-axis
type="submit" class="c-conductor__ticks"
></button> :view-bounds="viewBounds"
<ConductorModeIcon class="c-conductor__mode-icon" /> :is-fixed="isFixed"
:alt-pressed="altPressed"
<div @endPan="endPan"
v-if="isFixed" @endZoom="endZoom"
class="c-ctrl-wrapper c-conductor-input c-conductor__start-fixed" @panAxis="pan"
> @zoomAxis="zoom"
<!-- Fixed start --> />
<div class="c-conductor__start-fixed__label"> </div>
Start <div class="c-conductor__controls">
</div> <ConductorMode class="c-conductor__mode-select" />
<input <ConductorTimeSystem class="c-conductor__time-system-select" />
ref="startDate" <ConductorHistory
v-model="formattedBounds.start" class="c-conductor__history-select"
class="c-input--datetime" :offsets="openmct.time.clockOffsets()"
type="text" :bounds="bounds"
autocorrect="off" :time-system="timeSystem"
spellcheck="false" :mode="timeMode"
@change="validateAllBounds('startDate'); submitForm()" />
> </div>
<date-picker
v-if="isFixed && isUTCBased"
:default-date-time="formattedBounds.start"
:formatter="timeFormatter"
@date-selected="startDateSelected"
/>
</div>
<div
v-if="!isFixed"
class="c-ctrl-wrapper c-conductor-input c-conductor__start-delta"
>
<!-- RT start -->
<div class="c-direction-indicator icon-minus"></div>
<time-popup
v-if="showTCInputStart"
class="pr-tc-input-menu--start"
:type="'start'"
:offset="offsets.start"
@focus.native="$event.target.select()"
@hide="hideAllTimePopups"
@update="timePopUpdate"
/>
<button
ref="startOffset"
class="c-button c-conductor__delta-button"
@click.prevent="showTimePopupStart"
>
{{ offsets.start }}
</button>
</div>
<div class="c-ctrl-wrapper c-conductor-input c-conductor__end-fixed">
<!-- Fixed end and RT 'last update' display -->
<div class="c-conductor__end-fixed__label">
{{ isFixed ? 'End' : 'Updated' }}
</div>
<input
ref="endDate"
v-model="formattedBounds.end"
class="c-input--datetime"
type="text"
autocorrect="off"
spellcheck="false"
:disabled="!isFixed"
@change="validateAllBounds('endDate'); submitForm()"
>
<date-picker
v-if="isFixed && isUTCBased"
class="c-ctrl-wrapper--menus-left"
:default-date-time="formattedBounds.end"
:formatter="timeFormatter"
@date-selected="endDateSelected"
/>
</div>
<div
v-if="!isFixed"
class="c-ctrl-wrapper c-conductor-input c-conductor__end-delta"
>
<!-- RT end -->
<div class="c-direction-indicator icon-plus"></div>
<time-popup
v-if="showTCInputEnd"
class="pr-tc-input-menu--end"
:type="'end'"
:offset="offsets.end"
@focus.native="$event.target.select()"
@hide="hideAllTimePopups"
@update="timePopUpdate"
/>
<button
ref="endOffset"
class="c-button c-conductor__delta-button"
@click.prevent="showTimePopupEnd"
>
{{ offsets.end }}
</button>
</div>
<conductor-axis
class="c-conductor__ticks"
:view-bounds="viewBounds"
:is-fixed="isFixed"
:alt-pressed="altPressed"
@endPan="endPan"
@endZoom="endZoom"
@panAxis="pan"
@zoomAxis="zoom"
/>
</div>
<div class="c-conductor__controls">
<ConductorMode class="c-conductor__mode-select" />
<ConductorTimeSystem class="c-conductor__time-system-select" />
<ConductorHistory
class="c-conductor__history-select"
:offsets="openmct.time.clockOffsets()"
:bounds="bounds"
:time-system="timeSystem"
:mode="timeMode"
/>
</div>
<input
type="submit"
class="invisible"
>
</form>
</div> </div>
</template> </template>
@ -174,23 +66,23 @@
import _ from 'lodash'; import _ from 'lodash';
import ConductorMode from './ConductorMode.vue'; import ConductorMode from './ConductorMode.vue';
import ConductorTimeSystem from './ConductorTimeSystem.vue'; import ConductorTimeSystem from './ConductorTimeSystem.vue';
import DatePicker from './DatePicker.vue';
import ConductorAxis from './ConductorAxis.vue'; import ConductorAxis from './ConductorAxis.vue';
import ConductorModeIcon from './ConductorModeIcon.vue'; import ConductorModeIcon from './ConductorModeIcon.vue';
import ConductorHistory from './ConductorHistory.vue'; import ConductorHistory from './ConductorHistory.vue';
import TimePopup from './timePopup.vue'; import ConductorInputsFixed from "./ConductorInputsFixed.vue";
import ConductorInputsRealtime from "./ConductorInputsRealtime.vue";
const DEFAULT_DURATION_FORMATTER = 'duration'; const DEFAULT_DURATION_FORMATTER = 'duration';
export default { export default {
components: { components: {
ConductorInputsRealtime,
ConductorInputsFixed,
ConductorMode, ConductorMode,
ConductorTimeSystem, ConductorTimeSystem,
DatePicker,
ConductorAxis, ConductorAxis,
ConductorModeIcon, ConductorModeIcon,
ConductorHistory, ConductorHistory
TimePopup
}, },
inject: ['openmct', 'configuration'], inject: ['openmct', 'configuration'],
data() { data() {
@ -242,7 +134,6 @@ export default {
this.openmct.time.on('bounds', _.throttle(this.handleNewBounds, 300)); this.openmct.time.on('bounds', _.throttle(this.handleNewBounds, 300));
this.openmct.time.on('timeSystem', this.setTimeSystem); this.openmct.time.on('timeSystem', this.setTimeSystem);
this.openmct.time.on('clock', this.setViewFromClock); this.openmct.time.on('clock', this.setViewFromClock);
this.openmct.time.on('clockOffsets', this.setViewFromOffsets);
}, },
beforeDestroy() { beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyDown); document.removeEventListener('keydown', this.handleKeyDown);
@ -297,42 +188,8 @@ export default {
timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER); timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
this.isUTCBased = timeSystem.isUTCBased; this.isUTCBased = timeSystem.isUTCBased;
}, },
setOffsetsFromView($event) {
if (this.$refs.conductorForm.checkValidity()) {
let startOffset = 0 - this.durationFormatter.parse(this.offsets.start);
let endOffset = this.durationFormatter.parse(this.offsets.end);
this.openmct.time.clockOffsets({
start: startOffset,
end: endOffset
});
}
if ($event) {
$event.preventDefault();
return false;
}
},
setBoundsFromView($event) {
if (this.$refs.conductorForm.checkValidity()) {
let start = this.timeFormatter.parse(this.formattedBounds.start);
let end = this.timeFormatter.parse(this.formattedBounds.end);
this.openmct.time.bounds({
start: start,
end: end
});
}
if ($event) {
$event.preventDefault();
return false;
}
},
setViewFromClock(clock) { setViewFromClock(clock) {
this.clearAllValidation(); // this.clearAllValidation();
this.isFixed = clock === undefined; this.isFixed = clock === undefined;
}, },
setViewFromBounds(bounds) { setViewFromBounds(bounds) {
@ -341,158 +198,16 @@ export default {
this.viewBounds.start = bounds.start; this.viewBounds.start = bounds.start;
this.viewBounds.end = bounds.end; this.viewBounds.end = bounds.end;
}, },
setViewFromOffsets(offsets) {
this.offsets.start = this.durationFormatter.format(Math.abs(offsets.start));
this.offsets.end = this.durationFormatter.format(Math.abs(offsets.end));
},
updateTimeFromConductor() {
if (this.isFixed) {
this.setBoundsFromView();
} else {
this.setOffsetsFromView();
}
},
getBoundsLimit() {
const configuration = this.configuration.menuOptions
.filter(option => option.timeSystem === this.timeSystem.key)
.find(option => option.limit);
const limit = configuration ? configuration.limit : undefined;
return limit;
},
clearAllValidation() {
if (this.isFixed) {
[this.$refs.startDate, this.$refs.endDate].forEach(this.clearValidationForInput);
} else {
[this.$refs.startOffset, this.$refs.endOffset].forEach(this.clearValidationForInput);
}
},
clearValidationForInput(input) {
input.setCustomValidity('');
input.title = '';
},
validateAllBounds(ref) {
if (!this.areBoundsFormatsValid()) {
return false;
}
let validationResult = true;
const currentInput = this.$refs[ref];
return [this.$refs.startDate, this.$refs.endDate].every((input) => {
let boundsValues = {
start: this.timeFormatter.parse(this.formattedBounds.start),
end: this.timeFormatter.parse(this.formattedBounds.end)
};
const limit = this.getBoundsLimit();
if (
this.timeSystem.isUTCBased
&& limit
&& boundsValues.end - boundsValues.start > limit
) {
if (input === currentInput) {
validationResult = "Start and end difference exceeds allowable limit";
}
} else {
if (input === currentInput) {
validationResult = this.openmct.time.validateBounds(boundsValues);
}
}
return this.handleValidationResults(input, validationResult);
});
},
areBoundsFormatsValid() {
let validationResult = true;
return [this.$refs.startDate, this.$refs.endDate].every((input) => {
const formattedDate = input === this.$refs.startDate
? this.formattedBounds.start
: this.formattedBounds.end
;
if (!this.timeFormatter.validate(formattedDate)) {
validationResult = 'Invalid date';
}
return this.handleValidationResults(input, validationResult);
});
},
validateAllOffsets(event) {
return [this.$refs.startOffset, this.$refs.endOffset].every((input) => {
let validationResult = true;
let formattedOffset;
if (input === this.$refs.startOffset) {
formattedOffset = this.offsets.start;
} else {
formattedOffset = this.offsets.end;
}
if (!this.durationFormatter.validate(formattedOffset)) {
validationResult = 'Offsets must be in the format hh:mm:ss and less than 24 hours in duration';
} else {
let offsetValues = {
start: 0 - this.durationFormatter.parse(this.offsets.start),
end: this.durationFormatter.parse(this.offsets.end)
};
validationResult = this.openmct.time.validateOffsets(offsetValues);
}
return this.handleValidationResults(input, validationResult);
});
},
handleValidationResults(input, validationResult) {
if (validationResult !== true) {
input.setCustomValidity(validationResult);
input.title = validationResult;
return false;
} else {
input.setCustomValidity('');
input.title = '';
return true;
}
},
submitForm() {
// Allow Vue model to catch up to user input.
// Submitting form will cause validation messages to display (but only if triggered by button click)
this.$nextTick(() => this.$refs.submitButton.click());
},
getFormatter(key) { getFormatter(key) {
return this.openmct.telemetry.getValueFormatter({ return this.openmct.telemetry.getValueFormatter({
format: key format: key
}).formatter; }).formatter;
}, },
startDateSelected(date) { saveClockOffsets(offsets) {
this.formattedBounds.start = this.timeFormatter.format(date); this.openmct.time.clockOffsets(offsets);
this.validateAllBounds('startDate');
this.submitForm();
}, },
endDateSelected(date) { saveFixedOffsets(bounds) {
this.formattedBounds.end = this.timeFormatter.format(date); this.openmct.time.bounds(bounds);
this.validateAllBounds('endDate');
this.submitForm();
},
hideAllTimePopups() {
this.showTCInputStart = false;
this.showTCInputEnd = false;
},
showTimePopupStart() {
this.hideAllTimePopups();
this.showTCInputStart = !this.showTCInputStart;
},
showTimePopupEnd() {
this.hideAllTimePopups();
this.showTCInputEnd = !this.showTCInputEnd;
},
timePopUpdate({ type, hours, minutes, seconds }) {
this.offsets[type] = [hours, minutes, seconds].join(':');
this.setOffsetsFromView();
this.hideAllTimePopups();
} }
} }
}; };

View File

@ -0,0 +1,280 @@
<template>
<form ref="fixedDeltaInput"
class="c-conductor__inputs"
@submit.prevent="updateTimeFromConductor"
>
<button
ref="submitButton"
class="c-input--submit"
type="submit"
></button>
<div
class="c-ctrl-wrapper c-conductor-input c-conductor__start-fixed"
>
<!-- Fixed start -->
<div class="c-conductor__start-fixed__label">
Start
</div>
<input
ref="startDate"
v-model="formattedBounds.start"
class="c-input--datetime"
type="text"
autocorrect="off"
spellcheck="false"
@change="validateAllBounds('startDate'); submitForm()"
>
<date-picker
v-if="isUTCBased"
class="c-ctrl-wrapper--menus-left"
:bottom="keyString !== undefined"
:default-date-time="formattedBounds.start"
:formatter="timeFormatter"
@date-selected="startDateSelected"
/>
</div>
<div class="c-ctrl-wrapper c-conductor-input c-conductor__end-fixed">
<!-- Fixed end and RT 'last update' display -->
<div class="c-conductor__end-fixed__label">
End
</div>
<input
ref="endDate"
v-model="formattedBounds.end"
class="c-input--datetime"
type="text"
autocorrect="off"
spellcheck="false"
@change="validateAllBounds('endDate'); submitForm()"
>
<date-picker
v-if="isUTCBased"
class="c-ctrl-wrapper--menus-left"
:bottom="keyString !== undefined"
:default-date-time="formattedBounds.end"
:formatter="timeFormatter"
@date-selected="endDateSelected"
/>
</div>
<input
type="submit"
class="invisible"
>
</form>
</template>
<script>
import DatePicker from "./DatePicker.vue";
import _ from "lodash";
const DEFAULT_DURATION_FORMATTER = 'duration';
export default {
components: {
DatePicker
},
inject: ['openmct'],
props: {
keyString: {
type: String,
default() {
return undefined;
}
}
},
data() {
let timeSystem = this.openmct.time.timeSystem();
let durationFormatter = this.getFormatter(timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
let timeFormatter = this.getFormatter(timeSystem.timeFormat);
let bounds = this.bounds || this.openmct.time.bounds();
return {
showTCInputStart: true,
showTCInputEnd: true,
durationFormatter,
timeFormatter,
bounds: {
start: bounds.start,
end: bounds.end
},
formattedBounds: {
start: timeFormatter.format(bounds.start),
end: timeFormatter.format(bounds.end)
},
isUTCBased: timeSystem.isUTCBased
};
},
mounted() {
this.handleNewBounds = _.throttle(this.handleNewBounds, 300);
this.setTimeSystem(JSON.parse(JSON.stringify(this.openmct.time.timeSystem())));
this.openmct.time.on('timeSystem', this.setTimeSystem);
this.setTimeContext();
},
beforeDestroy() {
this.clearAllValidation();
this.openmct.time.off('timeSystem', this.setTimeSystem);
this.stopFollowingTimeContext();
},
methods: {
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView(this.keyString ? [{identifier: this.keyString}] : []);
this.timeContext.on('timeContext', this.setTimeContext);
this.handleNewBounds(this.timeContext.bounds());
this.timeContext.on('bounds', this.handleNewBounds);
this.timeContext.on('clock', this.clearAllValidation);
},
stopFollowingTimeContext() {
if (this.timeContext) {
this.timeContext.off('bounds', this.handleNewBounds);
this.timeContext.off('clock', this.clearAllValidation);
this.timeContext.off('timeContext', this.setTimeContext);
}
},
handleNewBounds(bounds) {
this.setBounds(bounds);
this.setViewFromBounds(bounds);
},
clearAllValidation() {
[this.$refs.startDate, this.$refs.endDate].forEach(this.clearValidationForInput);
},
clearValidationForInput(input) {
input.setCustomValidity('');
input.title = '';
},
setBounds(bounds) {
this.bounds = bounds;
},
setViewFromBounds(bounds) {
this.formattedBounds.start = this.timeFormatter.format(bounds.start);
this.formattedBounds.end = this.timeFormatter.format(bounds.end);
},
setTimeSystem(timeSystem) {
this.timeSystem = timeSystem;
this.timeFormatter = this.getFormatter(timeSystem.timeFormat);
this.durationFormatter = this.getFormatter(
timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
this.isUTCBased = timeSystem.isUTCBased;
},
getFormatter(key) {
return this.openmct.telemetry.getValueFormatter({
format: key
}).formatter;
},
setBoundsFromView($event) {
if (this.$refs.fixedDeltaInput.checkValidity()) {
let start = this.timeFormatter.parse(this.formattedBounds.start);
let end = this.timeFormatter.parse(this.formattedBounds.end);
this.$emit('updated', {
start: start,
end: end
});
}
if ($event) {
$event.preventDefault();
return false;
}
},
submitForm() {
// Allow Vue model to catch up to user input.
// Submitting form will cause validation messages to display (but only if triggered by button click)
this.$nextTick(() => this.$refs.submitButton.click());
},
updateTimeFromConductor() {
this.setBoundsFromView();
},
validateAllBounds(ref) {
if (!this.areBoundsFormatsValid()) {
return false;
}
let validationResult = {
valid: true
};
const currentInput = this.$refs[ref];
return [this.$refs.startDate, this.$refs.endDate].every((input) => {
let boundsValues = {
start: this.timeFormatter.parse(this.formattedBounds.start),
end: this.timeFormatter.parse(this.formattedBounds.end)
};
//TODO: Do we need limits here? We have conductor limits disabled right now
// const limit = this.getBoundsLimit();
const limit = false;
if (this.timeSystem.isUTCBased && limit
&& boundsValues.end - boundsValues.start > limit) {
if (input === currentInput) {
validationResult = {
valid: false,
message: "Start and end difference exceeds allowable limit"
};
}
} else {
if (input === currentInput) {
validationResult = this.openmct.time.validateBounds(boundsValues);
}
}
return this.handleValidationResults(input, validationResult);
});
},
areBoundsFormatsValid() {
let validationResult = {
valid: true
};
return [this.$refs.startDate, this.$refs.endDate].every((input) => {
const formattedDate = input === this.$refs.startDate
? this.formattedBounds.start
: this.formattedBounds.end
;
if (!this.timeFormatter.validate(formattedDate)) {
validationResult = {
valid: false,
message: 'Invalid date'
};
}
return this.handleValidationResults(input, validationResult);
});
},
getBoundsLimit() {
const configuration = this.configuration.menuOptions
.filter(option => option.timeSystem === this.timeSystem.key)
.find(option => option.limit);
const limit = configuration ? configuration.limit : undefined;
return limit;
},
handleValidationResults(input, validationResult) {
if (validationResult.valid !== true) {
input.setCustomValidity(validationResult.message);
input.title = validationResult.message;
} else {
input.setCustomValidity('');
input.title = '';
}
return validationResult.valid;
},
startDateSelected(date) {
this.formattedBounds.start = this.timeFormatter.format(date);
this.validateAllBounds('startDate');
this.submitForm();
},
endDateSelected(date) {
this.formattedBounds.end = this.timeFormatter.format(date);
this.validateAllBounds('endDate');
this.submitForm();
}
}
};
</script>

View File

@ -0,0 +1,269 @@
<template>
<form ref="deltaInput"
class="c-conductor__inputs"
>
<div
class="c-ctrl-wrapper c-conductor-input c-conductor__start-delta"
>
<!-- RT start -->
<div class="c-direction-indicator icon-minus"></div>
<time-popup
v-if="showTCInputStart"
class="pr-tc-input-menu--start"
:bottom="keyString !== undefined"
:type="'start'"
:offset="offsets.start"
@focus.native="$event.target.select()"
@hide="hideAllTimePopups"
@update="timePopUpdate"
/>
<button
ref="startOffset"
class="c-button c-conductor__delta-button"
title="Set the time offset after now"
@click.prevent="showTimePopupStart"
>
{{ offsets.start }}
</button>
</div>
<div class="c-ctrl-wrapper c-conductor-input c-conductor__end-fixed">
<!-- RT 'last update' display -->
<div class="c-conductor__end-fixed__label">
Current
</div>
<input
ref="endDate"
v-model="formattedBounds.end"
class="c-input--datetime"
type="text"
autocorrect="off"
spellcheck="false"
:disabled="true"
>
</div>
<div
class="c-ctrl-wrapper c-conductor-input c-conductor__end-delta"
>
<!-- RT end -->
<div class="c-direction-indicator icon-plus"></div>
<time-popup
v-if="showTCInputEnd"
class="pr-tc-input-menu--end"
:bottom="keyString !== undefined"
:type="'end'"
:offset="offsets.end"
@focus.native="$event.target.select()"
@hide="hideAllTimePopups"
@update="timePopUpdate"
/>
<button
ref="endOffset"
class="c-button c-conductor__delta-button"
title="Set the time offset preceding now"
@click.prevent="showTimePopupEnd"
>
{{ offsets.end }}
</button>
</div>
</form>
</template>
<script>
import timePopup from "./timePopup.vue";
import _ from "lodash";
const DEFAULT_DURATION_FORMATTER = 'duration';
export default {
components: {
timePopup
},
inject: ['openmct'],
props: {
keyString: {
type: String,
default() {
return undefined;
}
}
},
data() {
let timeSystem = this.openmct.time.timeSystem();
let durationFormatter = this.getFormatter(timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
let timeFormatter = this.getFormatter(timeSystem.timeFormat);
let bounds = this.bounds || this.openmct.time.bounds();
let offsets = this.openmct.time.clockOffsets();
return {
showTCInputStart: false,
showTCInputEnd: false,
durationFormatter,
timeFormatter,
bounds: {
start: bounds.start,
end: bounds.end
},
offsets: {
start: offsets && durationFormatter.format(Math.abs(offsets.start)),
end: offsets && durationFormatter.format(Math.abs(offsets.end))
},
formattedBounds: {
start: timeFormatter.format(bounds.start),
end: timeFormatter.format(bounds.end)
},
isUTCBased: timeSystem.isUTCBased
};
},
mounted() {
this.handleNewBounds = _.throttle(this.handleNewBounds, 300);
this.setTimeSystem(JSON.parse(JSON.stringify(this.openmct.time.timeSystem())));
this.openmct.time.on('timeSystem', this.setTimeSystem);
this.setTimeContext();
},
beforeDestroy() {
this.openmct.time.off('timeSystem', this.setTimeSystem);
this.stopFollowingTime();
},
methods: {
followTime() {
this.handleNewBounds(this.timeContext.bounds());
this.setViewFromOffsets(this.timeContext.clockOffsets());
this.timeContext.on('bounds', this.handleNewBounds);
this.timeContext.on('clock', this.clearAllValidation);
this.timeContext.on('clockOffsets', this.setViewFromOffsets);
},
stopFollowingTime() {
if (this.timeContext) {
this.timeContext.off('bounds', this.handleNewBounds);
this.timeContext.off('clock', this.clearAllValidation);
this.timeContext.off('clockOffsets', this.setViewFromOffsets);
this.timeContext.off('timeContext', this.setTimeContext);
}
},
setTimeContext() {
this.stopFollowingTime();
this.timeContext = this.openmct.time.getContextForView(this.keyString ? [{identifier: this.keyString}] : []);
this.timeContext.on('timeContext', this.setTimeContext);
this.followTime();
},
handleNewBounds(bounds) {
this.setBounds(bounds);
this.setViewFromBounds(bounds);
},
clearAllValidation() {
[this.$refs.startOffset, this.$refs.endOffset].forEach(this.clearValidationForInput);
},
clearValidationForInput(input) {
input.setCustomValidity('');
input.title = '';
},
setViewFromOffsets(offsets) {
if (offsets) {
this.offsets.start = this.durationFormatter.format(Math.abs(offsets.start));
this.offsets.end = this.durationFormatter.format(Math.abs(offsets.end));
}
},
setBounds(bounds) {
this.bounds = bounds;
},
setViewFromBounds(bounds) {
this.formattedBounds.start = this.timeFormatter.format(bounds.start);
this.formattedBounds.end = this.timeFormatter.format(bounds.end);
},
setTimeSystem(timeSystem) {
this.timeSystem = timeSystem;
this.timeFormatter = this.getFormatter(timeSystem.timeFormat);
this.durationFormatter = this.getFormatter(
timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
this.isUTCBased = timeSystem.isUTCBased;
},
getFormatter(key) {
return this.openmct.telemetry.getValueFormatter({
format: key
}).formatter;
},
hideAllTimePopups() {
this.showTCInputStart = false;
this.showTCInputEnd = false;
},
showTimePopupStart() {
this.hideAllTimePopups();
this.showTCInputStart = !this.showTCInputStart;
},
showTimePopupEnd() {
this.hideAllTimePopups();
this.showTCInputEnd = !this.showTCInputEnd;
},
timePopUpdate({ type, hours, minutes, seconds }) {
this.offsets[type] = [hours, minutes, seconds].join(':');
this.setOffsetsFromView();
this.hideAllTimePopups();
},
setOffsetsFromView($event) {
if (this.$refs.deltaInput.checkValidity()) {
let startOffset = 0 - this.durationFormatter.parse(this.offsets.start);
let endOffset = this.durationFormatter.parse(this.offsets.end);
this.$emit('updated', {
start: startOffset,
end: endOffset
});
}
if ($event) {
$event.preventDefault();
return false;
}
},
validateAllBounds(ref) {
if (!this.areBoundsFormatsValid()) {
return false;
}
let validationResult = {
valid: true
};
const currentInput = this.$refs[ref];
return [this.$refs.startDate, this.$refs.endDate].every((input) => {
let boundsValues = {
start: this.timeFormatter.parse(this.formattedBounds.start),
end: this.timeFormatter.parse(this.formattedBounds.end)
};
//TODO: Do we need limits here? We have conductor limits disabled right now
// const limit = this.getBoundsLimit();
const limit = false;
if (this.timeSystem.isUTCBased && limit
&& boundsValues.end - boundsValues.start > limit) {
if (input === currentInput) {
validationResult = {
valid: false,
message: "Start and end difference exceeds allowable limit"
};
}
} else {
if (input === currentInput) {
validationResult = this.openmct.time.validateBounds(boundsValues);
}
}
return this.handleValidationResults(input, validationResult);
});
},
handleValidationResults(input, validationResult) {
if (validationResult.valid !== true) {
input.setCustomValidity(validationResult.message);
input.title = validationResult.message;
} else {
input.setCustomValidity('');
input.title = '';
}
return validationResult.valid;
}
}
};
</script>

View File

@ -22,7 +22,8 @@
<template> <template>
<div <div
ref="calendarHolder" ref="calendarHolder"
class="c-ctrl-wrapper c-ctrl-wrapper--menus-up c-datetime-picker__wrapper" class="c-ctrl-wrapper c-datetime-picker__wrapper"
:class="{'c-ctrl-wrapper--menus-up': bottom !== true, 'c-ctrl-wrapper--menus-down': bottom === true}"
> >
<a <a
class="c-icon-button icon-calendar" class="c-icon-button icon-calendar"
@ -118,6 +119,12 @@ export default {
formatter: { formatter: {
type: Object, type: Object,
required: true required: true
},
bottom: {
type: Boolean,
default() {
return false;
}
} }
}, },
data: function () { data: function () {

View File

@ -8,6 +8,10 @@
/*********************************************** CONDUCTOR LAYOUT */ /*********************************************** CONDUCTOR LAYOUT */
.c-conductor { .c-conductor {
&__inputs {
display: contents;
}
&__time-bounds { &__time-bounds {
display: grid; display: grid;
grid-column-gap: $interiorMargin; grid-column-gap: $interiorMargin;
@ -50,13 +54,6 @@
} }
} }
[class*='-delta'] {
&:before {
content: $glyph-icon-clock;
font-family: symbolsfont;
}
}
&.is-fixed-mode { &.is-fixed-mode {
.c-conductor-axis { .c-conductor-axis {
&__zoom-indicator { &__zoom-indicator {
@ -181,6 +178,27 @@
} }
} }
.c-conductor-holder--compact {
min-height: 22px;
.c-conductor {
&__inputs,
&__time-bounds {
display: flex;
}
&__inputs {
> * + * {
margin-left: $interiorMarginSm;
}
}
}
.is-realtime-mode .c-conductor__end-fixed {
display: none !important;
}
}
.c-conductor-input { .c-conductor-input {
color: $colorInputFg; color: $colorInputFg;
display: flex; display: flex;
@ -250,18 +268,22 @@
box-shadow: $shdwMenu; box-shadow: $shdwMenu;
padding: $interiorMargin; padding: $interiorMargin;
position: absolute; position: absolute;
left: 8px;
bottom: 24px; bottom: 24px;
z-index: 99; z-index: 99;
&[class*='--start'] { &[class*='--bottom'] {
left: -25px; bottom: auto;
} top: 24px;
&[class*='--end'] {
right: 0;
} }
} }
.l-shell__time-conductor .pr-tc-input-menu--end {
left: auto;
right: 0;
}
[class^='pr-time'] { [class^='pr-time'] {
&[class*='label'] { &[class*='label'] {
font-size: 0.8em; font-size: 0.8em;

View File

@ -0,0 +1,231 @@
/*****************************************************************************
* Open MCT Web, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT Web is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT Web includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
<template>
<div
class="c-conductor"
:class="[
isFixed ? 'is-fixed-mode' : independentTCEnabled ? 'is-realtime-mode' : 'is-fixed-mode'
]"
>
<div class="c-conductor__time-bounds">
<toggle-switch
id="independentTCToggle"
:checked="independentTCEnabled"
:title="`${independentTCEnabled ? 'Disable' : 'Enable'} independent Time Conductor`"
@change="toggleIndependentTC"
/>
<ConductorModeIcon />
<div v-if="timeOptions && independentTCEnabled"
class="c-conductor__controls"
>
<Mode v-if="mode"
class="c-conductor__mode-select"
:key-string="domainObject.identifier.key"
:mode="timeOptions.mode"
:enabled="independentTCEnabled"
@modeChanged="saveMode"
/>
<conductor-inputs-fixed v-if="isFixed"
:key-string="domainObject.identifier.key"
@updated="saveFixedOffsets"
/>
<conductor-inputs-realtime v-else
:key-string="domainObject.identifier.key"
@updated="saveClockOffsets"
/>
</div>
</div>
</div>
</template>
<script>
import ConductorInputsFixed from "../ConductorInputsFixed.vue";
import ConductorInputsRealtime from "../ConductorInputsRealtime.vue";
import ConductorModeIcon from "@/plugins/timeConductor/ConductorModeIcon.vue";
import ToggleSwitch from '../../../ui/components/ToggleSwitch.vue';
import Mode from "./Mode.vue";
export default {
components: {
Mode,
ConductorModeIcon,
ConductorInputsRealtime,
ConductorInputsFixed,
ToggleSwitch
},
inject: ['openmct'],
props: {
domainObject: {
type: Object,
required: true
}
},
data() {
return {
timeOptions: this.domainObject.configuration.timeOptions || {
clockOffsets: this.openmct.time.clockOffsets(),
fixedOffsets: this.openmct.time.bounds()
},
mode: undefined,
independentTCEnabled: this.domainObject.configuration.useIndependentTime === true
};
},
computed: {
isFixed() {
if (!this.mode || !this.mode.key) {
return this.openmct.time.clock() === undefined;
} else {
return this.mode.key === 'fixed';
}
}
},
watch: {
domainObject: {
handler(domainObject) {
if (!domainObject.configuration.timeOptions || !this.independentTCEnabled) {
return;
}
if (this.timeOptions.start !== domainObject.configuration.timeOptions.start
|| this.timeOptions.end !== domainObject.configuration.timeOptions.end) {
this.timeOptions = domainObject.configuration.timeOptions;
this.destroyIndependentTime();
this.registerIndependentTimeOffsets();
}
},
deep: true
}
},
mounted() {
this.setTimeContext();
if (this.timeOptions.mode) {
this.mode = this.timeOptions.mode;
} else {
this.timeOptions.mode = this.mode = this.timeContext.clock() === undefined ? { key: 'fixed' } : { key: Object.create(this.timeContext.clock()).key};
}
if (this.independentTCEnabled) {
this.registerIndependentTimeOffsets();
}
},
beforeDestroy() {
this.stopFollowingTimeContext();
this.destroyIndependentTime();
},
methods: {
toggleIndependentTC() {
this.independentTCEnabled = !this.independentTCEnabled;
if (this.independentTCEnabled) {
this.registerIndependentTimeOffsets();
} else {
this.destroyIndependentTime();
}
this.$emit('stateChanged', this.independentTCEnabled);
},
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView([this.domainObject]);
this.timeContext.on('timeContext', this.setTimeContext);
this.timeContext.on('clock', this.setViewFromClock);
},
stopFollowingTimeContext() {
if (this.timeContext) {
this.timeContext.off('timeContext', this.setTimeContext);
this.timeContext.off('clock', this.setViewFromClock);
}
},
setViewFromClock(clock) {
if (!this.timeOptions.mode) {
this.setTimeOptions(clock);
}
},
setTimeOptions() {
if (!this.timeOptions || !this.timeOptions.mode) {
this.mode = this.timeContext.clock() === undefined ? { key: 'fixed' } : { key: Object.create(this.timeContext.clock()).key};
this.timeOptions = {
clockOffsets: this.timeContext.clockOffsets(),
fixedOffsets: this.timeContext.bounds()
};
}
this.registerIndependentTimeOffsets();
},
saveFixedOffsets(offsets) {
const newOptions = Object.assign({}, this.timeOptions, {
fixedOffsets: offsets
});
this.updateTimeOptions(newOptions);
},
saveClockOffsets(offsets) {
const newOptions = Object.assign({}, this.timeOptions, {
clockOffsets: offsets
});
this.updateTimeOptions(newOptions);
},
saveMode(mode) {
this.mode = mode;
const newOptions = Object.assign({}, this.timeOptions, {
mode: this.mode
});
this.updateTimeOptions(newOptions);
},
updateTimeOptions(options) {
this.timeOptions = options;
if (!this.timeOptions.mode) {
this.timeOptions.mode = this.mode;
}
this.registerIndependentTimeOffsets();
this.$emit('updated', this.timeOptions);
},
registerIndependentTimeOffsets() {
if (!this.timeOptions.mode) {
return;
}
let offsets;
if (this.isFixed) {
offsets = this.timeOptions.fixedOffsets;
} else {
offsets = this.timeOptions.clockOffsets;
}
const key = this.openmct.objects.makeKeyString(this.domainObject.identifier);
this.unregisterIndependentTime = this.openmct.time.addIndependentContext(key, offsets, this.isFixed ? undefined : this.mode.key);
},
destroyIndependentTime() {
if (this.unregisterIndependentTime) {
this.unregisterIndependentTime();
}
}
}
};
</script>

View File

@ -0,0 +1,222 @@
/*****************************************************************************
* Open MCT Web, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT Web is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT Web includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
<template>
<div ref="modeMenuButton"
class="c-ctrl-wrapper c-ctrl-wrapper--menus-up"
>
<div class="c-menu-button c-ctrl-wrapper c-ctrl-wrapper--menus-left">
<button v-if="selectedMode"
class="c-button--menu c-mode-button"
@click.prevent.stop="showModesMenu"
>
<span class="c-button__label">{{ selectedMode.name }}</span>
</button>
</div>
</div>
</template>
<script>
import toggleMixin from '../../../ui/mixins/toggle-mixin';
export default {
mixins: [toggleMixin],
inject: ['openmct'],
props: {
mode: {
type: Object,
default() {
return undefined;
}
},
enabled: {
type: Boolean,
default() {
return false;
}
}
},
data: function () {
let clock;
if (this.mode && this.mode.key === 'fixed') {
clock = undefined;
} else {
//We want the clock from the global time context here
clock = this.openmct.time.clock();
}
if (clock !== undefined) {
//Create copy of active clock so the time API does not get reactified.
clock = Object.create(clock);
}
return {
selectedMode: this.getModeOptionForClock(clock),
modes: []
};
},
watch: {
mode: {
deep: true,
handler(newMode) {
if (newMode) {
this.setViewFromClock(newMode.key === 'fixed' ? undefined : newMode);
}
}
},
enabled(newValue, oldValue) {
if (newValue !== undefined && (newValue !== oldValue) && (newValue === true)) {
this.setViewFromClock(this.mode.key === 'fixed' ? undefined : this.mode);
}
}
},
mounted: function () {
if (this.mode) {
this.setViewFromClock(this.mode.key === 'fixed' ? undefined : this.mode);
}
this.followTimeConductor();
},
destroyed: function () {
this.stopFollowTimeConductor();
},
methods: {
followTimeConductor() {
this.openmct.time.on('clock', this.setViewFromClock);
},
stopFollowTimeConductor() {
this.openmct.time.off('clock', this.setViewFromClock);
},
showModesMenu() {
const elementBoundingClientRect = this.$refs.modeMenuButton.getBoundingClientRect();
const x = elementBoundingClientRect.x;
const y = elementBoundingClientRect.y;
const menuOptions = {
menuClass: 'c-conductor__mode-menu',
placement: this.openmct.menus.menuPlacement.TOP_RIGHT
};
this.openmct.menus.showSuperMenu(x, y, this.modes, menuOptions);
},
getMenuOptions() {
let clocks = [{
name: 'Fixed Timespan',
timeSystem: 'utc'
}];
let currentGlobalClock = this.openmct.time.clock();
if (currentGlobalClock !== undefined) {
//Create copy of active clock so the time API does not get reactified.
currentGlobalClock = Object.assign({}, {
name: currentGlobalClock.name,
clock: currentGlobalClock.key,
timeSystem: this.openmct.time.timeSystem().key
});
clocks.push(currentGlobalClock);
}
return clocks;
},
loadClocks() {
let clocks = this.getMenuOptions()
.map(menuOption => menuOption.clock)
.filter(isDefinedAndUnique)
.map(this.getClock);
/*
* Populate the modes menu with metadata from the available clocks
* "Fixed Mode" is always first, and has no defined clock
*/
this.modes = [undefined]
.concat(clocks)
.map(this.getModeOptionForClock);
function isDefinedAndUnique(key, index, array) {
return key !== undefined && array.indexOf(key) === index;
}
},
getModeOptionForClock(clock) {
if (clock === undefined) {
const key = 'fixed';
return {
key,
name: 'Fixed Timespan',
description: 'Query and explore data that falls between two fixed datetimes.',
cssClass: 'icon-tabular',
onItemClicked: () => this.setOption(key)
};
} else {
const key = clock.key;
return {
key,
name: clock.name,
description: "Monitor streaming data in real-time. The Time "
+ "Conductor and displays will automatically advance themselves based on this clock. " + clock.description,
cssClass: clock.cssClass || 'icon-clock',
onItemClicked: () => this.setOption(key)
};
}
},
getClock(key) {
return this.openmct.time.getAllClocks().filter(function (clock) {
return clock.key === key;
})[0];
},
setOption(clockKey) {
let key = clockKey;
if (clockKey === 'fixed') {
key = undefined;
}
const matchingOptions = this.getMenuOptions().filter(option => option.clock === key);
const clock = matchingOptions.length && matchingOptions[0].clock ? Object.assign({}, matchingOptions[0], { key: matchingOptions[0].clock }) : undefined;
this.selectedMode = this.getModeOptionForClock(clock);
if (this.mode) {
this.$emit('modeChanged', { key: clockKey });
}
},
setViewFromClock(clock) {
this.loadClocks();
//retain the mode chosen by the user
if (this.mode) {
let found = this.modes.find(mode => mode.key === this.selectedMode.key);
if (!found) {
found = this.modes.find(mode => mode.key === clock && clock.key);
this.setOption(found ? this.getModeOptionForClock(clock).key : this.getModeOptionForClock().key);
} else if (this.mode.key !== this.selectedMode.key) {
this.setOption(this.selectedMode.key);
}
} else {
this.setOption(this.getModeOptionForClock(clock).key);
}
}
}
};
</script>

View File

@ -0,0 +1,128 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import {createMouseEvent, createOpenMct, resetApplicationState} from "utils/testing";
import ConductorPlugin from "./plugin";
import Vue from 'vue';
const THIRTY_SECONDS = 30 * 1000;
const ONE_MINUTE = THIRTY_SECONDS * 2;
const FIVE_MINUTES = ONE_MINUTE * 5;
const FIFTEEN_MINUTES = FIVE_MINUTES * 3;
const THIRTY_MINUTES = FIFTEEN_MINUTES * 2;
const date = new Date(Date.UTC(78, 0, 20, 0, 0, 0)).getTime();
describe('time conductor', () => {
let element;
let child;
let appHolder;
let openmct;
let config = {
menuOptions: [
{
name: "FixedTimeRange",
timeSystem: 'utc',
bounds: {
start: date - THIRTY_MINUTES,
end: date
},
presets: [],
records: 2
},
{
name: "LocalClock",
timeSystem: 'utc',
clock: 'local',
clockOffsets: {
start: -THIRTY_MINUTES,
end: THIRTY_SECONDS
},
presets: []
}
]
};
beforeEach((done) => {
openmct = createOpenMct();
openmct.install(new ConductorPlugin(config));
element = document.createElement('div');
element.style.width = '640px';
element.style.height = '480px';
child = document.createElement('div');
child.style.width = '640px';
child.style.height = '480px';
element.appendChild(child);
openmct.on('start', () => {
openmct.time.bounds({
start: config.menuOptions[0].bounds.start,
end: config.menuOptions[0].bounds.end
});
Vue.nextTick(() => {
done();
});
});
appHolder = document.createElement("div");
openmct.start(appHolder);
});
afterEach(() => {
appHolder = undefined;
openmct = undefined;
return resetApplicationState(openmct);
});
it('shows delta inputs in fixed mode', () => {
const fixedModeEl = appHolder.querySelector('.is-fixed-mode');
const dateTimeInputs = fixedModeEl.querySelectorAll('.c-input--datetime');
expect(dateTimeInputs[0].value).toEqual('1978-01-19 23:30:00.000Z');
expect(dateTimeInputs[1].value).toEqual('1978-01-20 00:00:00.000Z');
expect(fixedModeEl.querySelector('.c-mode-button .c-button__label').innerHTML).toEqual('Fixed Timespan');
});
describe('shows delta inputs in realtime mode', () => {
beforeEach((done) => {
const switcher = appHolder.querySelector('.c-mode-button');
const clickEvent = createMouseEvent("click");
switcher.dispatchEvent(clickEvent);
Vue.nextTick(() => {
const clockItem = document.querySelectorAll('.c-conductor__mode-menu li')[1];
clockItem.dispatchEvent(clickEvent);
Vue.nextTick(() => {
done();
});
});
});
it('shows clock options', () => {
const realtimeModeEl = appHolder.querySelector('.is-realtime-mode');
const dateTimeInputs = realtimeModeEl.querySelectorAll('.c-conductor__delta-button');
expect(dateTimeInputs[0].innerHTML.replace(/[^(\d|:)]/g, '')).toEqual('00:30:00');
expect(dateTimeInputs[1].innerHTML.replace(/[^(\d|:)]/g, '')).toEqual('00:00:30');
expect(realtimeModeEl.querySelector('.c-mode-button .c-button__label').innerHTML).toEqual('Local Clock');
});
});
});

View File

@ -1,6 +1,7 @@
<template> <template>
<div <div
class="pr-tc-input-menu" class="pr-tc-input-menu"
:class="{'pr-tc-input-menu--bottom' : bottom === true}"
@keydown.enter.prevent @keydown.enter.prevent
@keyup.enter.prevent="submit" @keyup.enter.prevent="submit"
@keydown.esc.prevent @keydown.esc.prevent
@ -88,6 +89,12 @@ export default {
offset: { offset: {
type: String, type: String,
required: true required: true
},
bottom: {
type: Boolean,
default() {
return false;
}
} }
}, },
data() { data() {

View File

@ -86,15 +86,16 @@ export default {
return { return {
items: [], items: [],
timeSystems: [], timeSystems: [],
height: 0 height: 0,
useIndependentTime: this.domainObject.configuration.useIndependentTime === true,
timeOptions: this.domainObject.configuration.timeOptions
}; };
}, },
beforeDestroy() { beforeDestroy() {
this.composition.off('add', this.addItem); this.composition.off('add', this.addItem);
this.composition.off('remove', this.removeItem); this.composition.off('remove', this.removeItem);
this.composition.off('reorder', this.reorder); this.composition.off('reorder', this.reorder);
this.openmct.time.off("bounds", this.updateViewBounds); this.stopFollowingTimeContext();
}, },
mounted() { mounted() {
if (this.composition) { if (this.composition) {
@ -104,8 +105,8 @@ export default {
this.composition.load(); this.composition.load();
} }
this.setTimeContext();
this.getTimeSystems(); this.getTimeSystems();
this.openmct.time.on("bounds", this.updateViewBounds);
}, },
methods: { methods: {
addItem(domainObject) { addItem(domainObject) {
@ -132,8 +133,8 @@ export default {
}, },
removeItem(identifier) { removeItem(identifier) {
let index = this.items.findIndex(item => this.openmct.objects.areIdsEqual(identifier, item.domainObject.identifier)); let index = this.items.findIndex(item => this.openmct.objects.areIdsEqual(identifier, item.domainObject.identifier));
this.removeSelectable(this.items[index]);
this.items.splice(index, 1); this.items.splice(index, 1);
this.updateContentHeight();
}, },
reorder(reorderPlan) { reorder(reorderPlan) {
let oldItems = this.items.slice(); let oldItems = this.items.slice();
@ -154,7 +155,7 @@ export default {
}); });
}, },
getBoundsForTimeSystem(timeSystem) { getBoundsForTimeSystem(timeSystem) {
const currentBounds = this.openmct.time.bounds(); const currentBounds = this.timeContext.bounds();
//TODO: Some kind of translation via an offset? of current bounds to target timeSystem //TODO: Some kind of translation via an offset? of current bounds to target timeSystem
return currentBounds; return currentBounds;
@ -164,6 +165,20 @@ export default {
if (currentTimeSystem) { if (currentTimeSystem) {
currentTimeSystem.bounds = bounds; currentTimeSystem.bounds = bounds;
} }
},
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView(this.objectPath);
this.timeContext.on('timeContext', this.setTimeContext);
this.updateViewBounds(this.timeContext.bounds());
this.timeContext.on('bounds', this.updateViewBounds);
},
stopFollowingTimeContext() {
if (this.timeContext) {
this.timeContext.off('bounds', this.updateViewBounds);
this.timeContext.off('timeContext', this.setTimeContext);
}
} }
} }
}; };

View File

@ -20,7 +20,8 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import TimelineViewProvider from '../timeline/TimelineViewProvider'; import TimelineViewProvider from './TimelineViewProvider';
import timelineInterceptor from "./timelineInterceptor";
export default function () { export default function () {
return function install(openmct) { return function install(openmct) {
@ -32,8 +33,12 @@ export default function () {
cssClass: 'icon-timeline', cssClass: 'icon-timeline',
initialize: function (domainObject) { initialize: function (domainObject) {
domainObject.composition = []; domainObject.composition = [];
domainObject.configuration = {
useIndependentTime: false
};
} }
}); });
timelineInterceptor(openmct);
openmct.objectViews.addProvider(new TimelineViewProvider(openmct)); openmct.objectViews.addProvider(new TimelineViewProvider(openmct));
}; };
} }

View File

@ -96,10 +96,15 @@ describe('the plugin', function () {
describe('the view', () => { describe('the view', () => {
let timelineView; let timelineView;
let testViewObject;
beforeEach(() => { beforeEach(() => {
const testViewObject = { testViewObject = {
id: "test-object", id: "test-object",
identifier: {
key: "test-object",
namespace: ''
},
type: "time-strip" type: "time-strip"
}; };
@ -119,6 +124,106 @@ describe('the plugin', function () {
const el = element.querySelector('.c-timesystem-axis'); const el = element.querySelector('.c-timesystem-axis');
expect(el).toBeDefined(); expect(el).toBeDefined();
}); });
it('does not show the independent time conductor based on configuration', () => {
const independentTimeConductorEl = element.querySelector('.c-timeline-holder > .c-conductor__controls');
expect(independentTimeConductorEl).toBeNull();
});
});
describe('the independent time conductor', () => {
let timelineView;
let testViewObject = {
id: "test-object",
identifier: {
key: "test-object",
namespace: ''
},
type: "time-strip",
configuration: {
useIndependentTime: true,
timeOptions: {
mode: {
key: 'local'
},
fixedOffsets: {
start: 10,
end: 11
},
clockOffsets: {
start: -(30 * 60 * 1000),
end: (30 * 60 * 1000)
}
}
}
};
beforeEach(done => {
const applicableViews = openmct.objectViews.get(testViewObject, mockObjectPath);
timelineView = applicableViews.find((viewProvider) => viewProvider.key === 'time-strip.view');
let view = timelineView.view(testViewObject, element);
view.show(child, true);
Vue.nextTick(done);
});
it('displays an independent time conductor with saved options - local clock', () => {
return Vue.nextTick(() => {
const independentTimeConductorEl = element.querySelector('.c-timeline-holder > .c-conductor__controls');
expect(independentTimeConductorEl).toBeDefined();
const independentTimeContext = openmct.time.getIndependentContext(testViewObject.identifier.key);
expect(independentTimeContext.clockOffsets()).toEqual(testViewObject.configuration.timeOptions.clockOffsets);
});
});
});
describe('the independent time conductor', () => {
let timelineView;
let testViewObject2 = {
id: "test-object2",
identifier: {
key: "test-object2",
namespace: ''
},
type: "time-strip",
configuration: {
useIndependentTime: true,
timeOptions: {
mode: {
key: 'fixed'
},
fixedOffsets: {
start: 10,
end: 11
},
clockOffsets: {
start: -(30 * 60 * 1000),
end: (30 * 60 * 1000)
}
}
}
};
beforeEach((done) => {
const applicableViews = openmct.objectViews.get(testViewObject2, mockObjectPath);
timelineView = applicableViews.find((viewProvider) => viewProvider.key === 'time-strip.view');
let view = timelineView.view(testViewObject2, element);
view.show(child, true);
Vue.nextTick(done);
});
it('displays an independent time conductor with saved options - fixed timespan', () => {
return Vue.nextTick(() => {
const independentTimeConductorEl = element.querySelector('.c-timeline-holder > .c-conductor__controls');
expect(independentTimeConductorEl).toBeDefined();
const independentTimeContext = openmct.time.getIndependentContext(testViewObject2.identifier.key);
expect(independentTimeContext.bounds()).toEqual(testViewObject2.configuration.timeOptions.fixedOffsets);
});
});
}); });
}); });

View File

@ -1,4 +1,10 @@
.c-timeline-holder { .c-timeline-holder {
@include abs(); @include abs();
display: flex;
flex-direction: column;
overflow-x: hidden; overflow-x: hidden;
}
> * + * {
margin-top: $interiorMargin;
}
}

View File

@ -0,0 +1,40 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
export default function timelineInterceptor(openmct) {
openmct.objects.addGetInterceptor({
appliesTo: (identifier, domainObject) => {
return domainObject && domainObject.type === 'time-strip';
},
invoke: (identifier, object) => {
if (object && object.configuration === undefined) {
object.configuration = {
useIndependentTime: true
};
}
return object;
}
});
}

View File

@ -582,6 +582,12 @@
} }
} }
&[class*='--menus-bottom'] {
.c-menu {
top: auto; bottom: 100%;
}
}
&[class*='--menus-left'], &[class*='--menus-left'],
&[class*='menus-to-left'] { &[class*='menus-to-left'] {
.c-menu { .c-menu {

View File

@ -1,13 +1,29 @@
<template> <template>
<div></div> <div>
<div v-if="domainObject && domainObject.type === 'time-strip'"
class="c-conductor-holder--compact l-shell__main-independent-time-conductor"
>
<independent-time-conductor :domain-object="domainObject"
@stateChanged="updateIndependentTimeState"
@updated="saveTimeOptions"
/>
</div>
<div ref="objectViewWrapper"
:class="objectViewStyle"
></div>
</div>
</template> </template>
<script> <script>
import _ from "lodash"; import _ from "lodash";
import StyleRuleManager from "@/plugins/condition/StyleRuleManager"; import StyleRuleManager from "@/plugins/condition/StyleRuleManager";
import {STYLE_CONSTANTS} from "@/plugins/condition/utils/constants"; import {STYLE_CONSTANTS} from "@/plugins/condition/utils/constants";
import IndependentTimeConductor from '@/plugins/timeConductor/independent/IndependentTimeConductor.vue';
export default { export default {
components: {
IndependentTimeConductor
},
inject: ["openmct"], inject: ["openmct"],
props: { props: {
showEditView: Boolean, showEditView: Boolean,
@ -48,6 +64,13 @@ export default {
}, },
font() { font() {
return this.objectFontStyle ? this.objectFontStyle.font : this.layoutFont; return this.objectFontStyle ? this.objectFontStyle.font : this.layoutFont;
},
objectViewStyle() {
if (this.domainObject && this.domainObject.type === 'time-strip') {
return 'l-shell__main-object-view';
} else {
return 'u-contents';
}
} }
}, },
destroyed() { destroyed() {
@ -79,13 +102,13 @@ export default {
}, },
mounted() { mounted() {
this.updateView(); this.updateView();
this.$el.addEventListener('dragover', this.onDragOver, { this.$refs.objectViewWrapper.addEventListener('dragover', this.onDragOver, {
capture: true capture: true
}); });
this.$el.addEventListener('drop', this.editIfEditable, { this.$refs.objectViewWrapper.addEventListener('drop', this.editIfEditable, {
capture: true capture: true
}); });
this.$el.addEventListener('drop', this.addObjectToParent); this.$refs.objectViewWrapper.addEventListener('drop', this.addObjectToParent);
if (this.domainObject) { if (this.domainObject) {
//This is to apply styles to subobjects in a layout //This is to apply styles to subobjects in a layout
this.initObjectStyles(); this.initObjectStyles();
@ -95,7 +118,9 @@ export default {
clear() { clear() {
if (this.currentView) { if (this.currentView) {
this.currentView.destroy(); this.currentView.destroy();
this.$el.innerHTML = ''; if (this.$refs.objectViewWrapper) {
this.$refs.objectViewWrapper.innerHTML = '';
}
if (this.releaseEditModeHandler) { if (this.releaseEditModeHandler) {
this.releaseEditModeHandler(); this.releaseEditModeHandler();
@ -118,8 +143,8 @@ export default {
this.openmct.objectViews.off('clearData', this.clearData); this.openmct.objectViews.off('clearData', this.clearData);
}, },
getStyleReceiver() { getStyleReceiver() {
let styleReceiver = this.$el.querySelector('.js-style-receiver') let styleReceiver = this.$refs.objectViewWrapper.querySelector('.js-style-receiver')
|| this.$el.querySelector(':first-child'); || this.$refs.objectViewWrapper.querySelector(':first-child');
if (styleReceiver === null) { if (styleReceiver === null) {
styleReceiver = undefined; styleReceiver = undefined;
@ -183,7 +208,7 @@ export default {
this.viewContainer = document.createElement('div'); this.viewContainer = document.createElement('div');
this.viewContainer.classList.add('l-angular-ov-wrapper'); this.viewContainer.classList.add('l-angular-ov-wrapper');
this.$el.append(this.viewContainer); this.$refs.objectViewWrapper.append(this.viewContainer);
let provider = this.getViewProvider(); let provider = this.getViewProvider();
if (!provider) { if (!provider) {
return; return;
@ -213,7 +238,7 @@ export default {
if (immediatelySelect) { if (immediatelySelect) {
this.removeSelectable = this.openmct.selection.selectable( this.removeSelectable = this.openmct.selection.selectable(
this.$el, this.getSelectionContext(), true); this.$refs.objectViewWrapper, this.getSelectionContext(), true);
} }
this.openmct.objectViews.on('clearData', this.clearData); this.openmct.objectViews.on('clearData', this.clearData);
@ -388,6 +413,13 @@ export default {
if (elemToStyle !== undefined) { if (elemToStyle !== undefined) {
elemToStyle.dataset.font = newFont; elemToStyle.dataset.font = newFont;
} }
},
//Should the domainObject be updated in the Independent Time conductor component itself?
updateIndependentTimeState(useIndependentTime) {
this.openmct.objects.mutate(this.domainObject, 'configuration.useIndependentTime', useIndependentTime);
},
saveTimeOptions(options) {
this.openmct.objects.mutate(this.domainObject, 'configuration.timeOptions', options);
} }
} }
}; };

View File

@ -240,6 +240,14 @@
overflow: auto; overflow: auto;
} }
&__main-object-view {
height: 100%;
}
&__main-independent-time-conductor {
margin-bottom: 5px;
}
&__tree { &__tree {
// Tree component within __pane-tree // Tree component within __pane-tree
flex: 1 1 auto !important; flex: 1 1 auto !important;
@ -247,7 +255,13 @@
&__time-conductor { &__time-conductor {
border-top: 1px solid $colorInteriorBorder; border-top: 1px solid $colorInteriorBorder;
display: flex;
flex-direction: column;
padding-top: $interiorMargin; padding-top: $interiorMargin;
> * + * {
margin-top: $interiorMargin;
}
} }
&__main { &__main {