Compare commits
	
		
			27 Commits
		
	
	
		
			imagery-en
			...
			notebook-d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 262110ad55 | ||
|   | 325f2c4860 | ||
|   | 74a516aa9e | ||
|   | 28e26461cc | ||
|   | cfaaf6b1fe | ||
|   | bffe79ecbd | ||
|   | 94d9852339 | ||
|   | 905e397d3f | ||
|   | e70a636073 | ||
|   | 03abb5e5de | ||
|   | ac20c01233 | ||
|   | b8ded0a16e | ||
|   | b68f79f427 | ||
|   | 221d10d3e6 | ||
|   | 22d32eed1d | ||
|   | 5d656f0963 | ||
|   | 201d622b85 | ||
|   | 3571004f5c | ||
|   | 16249c3790 | ||
|   | 5377f0d0b3 | ||
|   | 15778b00a0 | ||
|   | 169eec0a51 | ||
|   | f789775b1c | ||
|   | fc59a4dce4 | ||
|   | 29128a891d | ||
|   | dd3d4c8c3a | ||
|   | 4047c888be | 
| @@ -86,7 +86,9 @@ | ||||
|         openmct.install(openmct.plugins.MyItems()); | ||||
|         openmct.install(openmct.plugins.Generator()); | ||||
|         openmct.install(openmct.plugins.ExampleImagery()); | ||||
|         openmct.install(openmct.plugins.PlanLayout()); | ||||
|         openmct.install(openmct.plugins.Timeline()); | ||||
|         openmct.install(openmct.plugins.PlotVue()); | ||||
|         openmct.install(openmct.plugins.UTCTimeSystem()); | ||||
|         openmct.install(openmct.plugins.AutoflowView({ | ||||
|             type: "telemetry.panel" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "openmct", | ||||
|   "version": "1.6.2-SNAPSHOT", | ||||
|   "version": "1.6.3-SNAPSHOT", | ||||
|   "description": "The Open MCT core platform", | ||||
|   "dependencies": {}, | ||||
|   "devDependencies": { | ||||
|   | ||||
| @@ -71,10 +71,10 @@ define( | ||||
|                 openmct.editor.cancel(); | ||||
|             } | ||||
|  | ||||
|             function isFirstViewEditable(domainObject) { | ||||
|                 let firstView = openmct.objectViews.get(domainObject)[0]; | ||||
|             function isFirstViewEditable(domainObject, objectPath) { | ||||
|                 let firstView = openmct.objectViews.get(domainObject, objectPath)[0]; | ||||
|  | ||||
|                 return firstView && firstView.canEdit && firstView.canEdit(domainObject); | ||||
|                 return firstView && firstView.canEdit && firstView.canEdit(domainObject, objectPath); | ||||
|             } | ||||
|  | ||||
|             function navigateAndEdit(object) { | ||||
| @@ -88,7 +88,7 @@ define( | ||||
|  | ||||
|                 window.location.href = url; | ||||
|  | ||||
|                 if (isFirstViewEditable(object.useCapability('adapter'))) { | ||||
|                 if (isFirstViewEditable(object.useCapability('adapter'), objectPath)) { | ||||
|                     openmct.editor.edit(); | ||||
|                 } | ||||
|             } | ||||
|   | ||||
| @@ -37,7 +37,7 @@ define( | ||||
|             this.$q = $q; | ||||
|         } | ||||
|  | ||||
|         LocatingObjectDecorator.prototype.getObjects = function (ids) { | ||||
|         LocatingObjectDecorator.prototype.getObjects = function (ids, abortSignal) { | ||||
|             var $q = this.$q, | ||||
|                 $log = this.$log, | ||||
|                 objectService = this.objectService, | ||||
| @@ -79,7 +79,7 @@ define( | ||||
|                         }); | ||||
|                 } | ||||
|  | ||||
|                 return objectService.getObjects([id]).then(attachContext); | ||||
|                 return objectService.getObjects([id], abortSignal).then(attachContext); | ||||
|             } | ||||
|  | ||||
|             ids.forEach(function (id) { | ||||
|   | ||||
| @@ -80,12 +80,15 @@ define([ | ||||
|      * @param {Function} [filter] if provided, will be called for every | ||||
|      *   potential modelResult.  If it returns false, the model result will be | ||||
|      *   excluded from the search results. | ||||
|      * @param {AbortController.signal} abortSignal (optional) can pass in an abortSignal to cancel any | ||||
|      *   downstream fetch requests. | ||||
|      * @returns {Promise} A Promise for a search result object. | ||||
|      */ | ||||
|     SearchAggregator.prototype.query = function ( | ||||
|         inputText, | ||||
|         maxResults, | ||||
|         filter | ||||
|         filter, | ||||
|         abortSignal | ||||
|     ) { | ||||
|  | ||||
|         var aggregator = this, | ||||
| @@ -120,7 +123,7 @@ define([ | ||||
|                 modelResults = aggregator.applyFilter(modelResults, filter); | ||||
|                 modelResults = aggregator.removeDuplicates(modelResults); | ||||
|  | ||||
|                 return aggregator.asObjectResults(modelResults); | ||||
|                 return aggregator.asObjectResults(modelResults, abortSignal); | ||||
|             }); | ||||
|     }; | ||||
|  | ||||
| @@ -193,16 +196,19 @@ define([ | ||||
|      * Convert modelResults to objectResults by fetching them from the object | ||||
|      * service. | ||||
|      * | ||||
|      * @param {Object} modelResults an object containing the results from the search | ||||
|      * @param {AbortController.signal} abortSignal (optional) abort signal to cancel any | ||||
|      *   downstream fetch requests | ||||
|      * @returns {Promise} for an objectResults object. | ||||
|      */ | ||||
|     SearchAggregator.prototype.asObjectResults = function (modelResults) { | ||||
|     SearchAggregator.prototype.asObjectResults = function (modelResults, abortSignal) { | ||||
|         var objectIds = modelResults.hits.map(function (modelResult) { | ||||
|             return modelResult.id; | ||||
|         }); | ||||
|  | ||||
|         return this | ||||
|             .objectService | ||||
|             .getObjects(objectIds) | ||||
|             .getObjects(objectIds, abortSignal) | ||||
|             .then(function (objects) { | ||||
|  | ||||
|                 var objectResults = { | ||||
|   | ||||
| @@ -219,7 +219,7 @@ define([ | ||||
|          * @memberof module:openmct.MCT# | ||||
|          * @name objects | ||||
|          */ | ||||
|         this.objects = new api.ObjectAPI.default(this.types); | ||||
|         this.objects = new api.ObjectAPI.default(this.types, this); | ||||
|  | ||||
|         /** | ||||
|          * An interface for retrieving and interpreting telemetry data associated | ||||
|   | ||||
| @@ -37,7 +37,7 @@ define([ | ||||
|                     context.domainObject.getModel(), | ||||
|                     objectUtils.parseKeyString(context.domainObject.getId()) | ||||
|                 ); | ||||
|                 const providers = mct.propertyEditors.get(domainObject); | ||||
|                 const providers = mct.propertyEditors.get(domainObject, mct.router.path); | ||||
|  | ||||
|                 if (providers.length > 0) { | ||||
|                     action.dialogService = Object.create(action.dialogService); | ||||
|   | ||||
| @@ -32,7 +32,7 @@ define([], function () { | ||||
|         if (Object.prototype.hasOwnProperty.call(view, 'provider')) { | ||||
|             const domainObject = legacyObject.useCapability('adapter'); | ||||
|  | ||||
|             return view.provider.canView(domainObject); | ||||
|             return view.provider.canView(domainObject, this.openmct.router.path); | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|   | ||||
| @@ -139,10 +139,12 @@ define([ | ||||
|             }); | ||||
|     }; | ||||
|  | ||||
|     ObjectServiceProvider.prototype.superSecretFallbackSearch = function (query, options) { | ||||
|     ObjectServiceProvider.prototype.superSecretFallbackSearch = function (query, abortSignal) { | ||||
|         const searchService = this.$injector.get('searchService'); | ||||
|  | ||||
|         return searchService.query(query); | ||||
|         // need to pass the abortSignal down, so need to | ||||
|         // pass in undefined for maxResults and filter on query | ||||
|         return searchService.query(query, undefined, undefined, abortSignal); | ||||
|     }; | ||||
|  | ||||
|     // Injects new object API as a decorator so that it hijacks all requests. | ||||
| @@ -150,13 +152,13 @@ define([ | ||||
|     function LegacyObjectAPIInterceptor(openmct, ROOTS, instantiate, topic, objectService) { | ||||
|         const eventEmitter = openmct.objects.eventEmitter; | ||||
|  | ||||
|         this.getObjects = function (keys) { | ||||
|         this.getObjects = function (keys, abortSignal) { | ||||
|             const results = {}; | ||||
|  | ||||
|             const promises = keys.map(function (keyString) { | ||||
|                 const key = utils.parseKeyString(keyString); | ||||
|  | ||||
|                 return openmct.objects.get(key) | ||||
|                 return openmct.objects.get(key, abortSignal) | ||||
|                     .then(function (object) { | ||||
|                         object = utils.toOldFormat(object); | ||||
|                         results[keyString] = instantiate(object, keyString); | ||||
|   | ||||
| @@ -29,9 +29,22 @@ describe('The ActionCollection', () => { | ||||
|     let mockApplicableActions; | ||||
|     let mockObjectPath; | ||||
|     let mockView; | ||||
|     let mockIdentifierService; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|         openmct = createOpenMct(); | ||||
|         openmct.$injector = jasmine.createSpyObj('$injector', ['get']); | ||||
|         mockIdentifierService = jasmine.createSpyObj( | ||||
|             'identifierService', | ||||
|             ['parse'] | ||||
|         ); | ||||
|         mockIdentifierService.parse.and.returnValue({ | ||||
|             getSpace: () => { | ||||
|                 return ''; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         openmct.$injector.get.and.returnValue(mockIdentifierService); | ||||
|         mockObjectPath = [ | ||||
|             { | ||||
|                 name: 'mock folder', | ||||
|   | ||||
| @@ -196,7 +196,7 @@ define([ | ||||
|  | ||||
|             this.provider.add(this.domainObject, child.identifier); | ||||
|         } else { | ||||
|             if (this.returnMutables && this.publicAPI.objects.supportsMutation(child)) { | ||||
|             if (this.returnMutables && this.publicAPI.objects.supportsMutation(child.identifier)) { | ||||
|                 let keyString = this.publicAPI.objects.makeKeyString(child.identifier); | ||||
|  | ||||
|                 child = this.publicAPI.objects._toMutable(child); | ||||
|   | ||||
| @@ -96,6 +96,16 @@ class MutableDomainObject { | ||||
|         //TODO: Emit events for listeners of child properties when parent changes. | ||||
|         // Do it at observer time - also register observers for parent attribute path. | ||||
|     } | ||||
|  | ||||
|     $refresh(model) { | ||||
|         //TODO: Currently we are updating the entire object. | ||||
|         // In the future we could update a specific property of the object using the 'path' parameter. | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), model); | ||||
|  | ||||
|         //Emit wildcard event, with path so that callback knows what changed | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this); | ||||
|     } | ||||
|  | ||||
|     $on(event, callback) { | ||||
|         this._instanceEventEmitter.on(event, callback); | ||||
|  | ||||
|   | ||||
| @@ -33,11 +33,15 @@ import InterceptorRegistry from './InterceptorRegistry'; | ||||
|  * @memberof module:openmct | ||||
|  */ | ||||
|  | ||||
| function ObjectAPI(typeRegistry) { | ||||
| function ObjectAPI(typeRegistry, openmct) { | ||||
|     this.typeRegistry = typeRegistry; | ||||
|     this.eventEmitter = new EventEmitter(); | ||||
|     this.providers = {}; | ||||
|     this.rootRegistry = new RootRegistry(); | ||||
|     this.injectIdentifierService = function () { | ||||
|         this.identifierService = openmct.$injector.get("identifierService"); | ||||
|     }; | ||||
|  | ||||
|     this.rootProvider = new RootObjectProvider(this.rootRegistry); | ||||
|     this.cache = {}; | ||||
|     this.interceptorRegistry = new InterceptorRegistry(); | ||||
| @@ -51,16 +55,33 @@ ObjectAPI.prototype.supersecretSetFallbackProvider = function (p) { | ||||
|     this.fallbackProvider = p; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * @private | ||||
|  */ | ||||
| ObjectAPI.prototype.getIdentifierService = function () { | ||||
|     // Lazily acquire identifier service | ||||
|     if (!this.identifierService) { | ||||
|         this.injectIdentifierService(); | ||||
|     } | ||||
|  | ||||
|     return this.identifierService; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Retrieve the provider for a given identifier. | ||||
|  * @private | ||||
|  */ | ||||
| ObjectAPI.prototype.getProvider = function (identifier) { | ||||
|     //handles the '' vs 'mct' namespace issue | ||||
|     const keyString = utils.makeKeyString(identifier); | ||||
|     const identifierService = this.getIdentifierService(); | ||||
|     const namespace = identifierService.parse(keyString).getSpace(); | ||||
|  | ||||
|     if (identifier.key === 'ROOT') { | ||||
|         return this.rootProvider; | ||||
|     } | ||||
|  | ||||
|     return this.providers[identifier.namespace] || this.fallbackProvider; | ||||
|     return this.providers[namespace] || this.fallbackProvider; | ||||
| }; | ||||
|  | ||||
| /** | ||||
| @@ -133,11 +154,12 @@ ObjectAPI.prototype.addProvider = function (namespace, provider) { | ||||
|  * @method get | ||||
|  * @memberof module:openmct.ObjectProvider# | ||||
|  * @param {string} key the key for the domain object to load | ||||
|  * @param {AbortController.signal} abortSignal (optional) signal to abort fetch requests | ||||
|  * @returns {Promise} a promise which will resolve when the domain object | ||||
|  *          has been saved, or be rejected if it cannot be saved | ||||
|  */ | ||||
|  | ||||
| ObjectAPI.prototype.get = function (identifier) { | ||||
| ObjectAPI.prototype.get = function (identifier, abortSignal) { | ||||
|     let keystring = this.makeKeyString(identifier); | ||||
|     if (this.cache[keystring] !== undefined) { | ||||
|         return this.cache[keystring]; | ||||
| @@ -154,15 +176,12 @@ ObjectAPI.prototype.get = function (identifier) { | ||||
|         throw new Error('Provider does not support get!'); | ||||
|     } | ||||
|  | ||||
|     let objectPromise = provider.get(identifier); | ||||
|     let objectPromise = provider.get(identifier, abortSignal); | ||||
|     this.cache[keystring] = objectPromise; | ||||
|  | ||||
|     return objectPromise.then(result => { | ||||
|         delete this.cache[keystring]; | ||||
|         const interceptors = this.listGetInterceptors(identifier, result); | ||||
|         interceptors.forEach(interceptor => { | ||||
|             result = interceptor.invoke(identifier, result); | ||||
|         }); | ||||
|         result = this.applyGetInterceptors(identifier, result); | ||||
|  | ||||
|         return result; | ||||
|     }); | ||||
| @@ -179,19 +198,24 @@ ObjectAPI.prototype.get = function (identifier) { | ||||
|  * @method search | ||||
|  * @memberof module:openmct.ObjectAPI# | ||||
|  * @param {string} query the term to search for | ||||
|  * @param {Object} options search options | ||||
|  * @param {AbortController.signal} abortSignal (optional) signal to cancel downstream fetch requests | ||||
|  * @returns {Array.<Promise.<module:openmct.DomainObject>>} | ||||
|  *          an array of promises returned from each object provider's search function | ||||
|  *          each resolving to domain objects matching provided search query and options. | ||||
|  */ | ||||
| ObjectAPI.prototype.search = function (query, options) { | ||||
| ObjectAPI.prototype.search = function (query, abortSignal) { | ||||
|     const searchPromises = Object.values(this.providers) | ||||
|         .filter(provider => provider.search !== undefined) | ||||
|         .map(provider => provider.search(query, options)); | ||||
|         .map(provider => provider.search(query, abortSignal)); | ||||
|  | ||||
|     searchPromises.push(this.fallbackProvider.superSecretFallbackSearch(query, options) | ||||
|     searchPromises.push(this.fallbackProvider.superSecretFallbackSearch(query, abortSignal) | ||||
|         .then(results => results.hits | ||||
|             .map(hit => utils.toNewFormat(hit.object.getModel(), hit.object.getId())))); | ||||
|             .map(hit => { | ||||
|                 let domainObject = utils.toNewFormat(hit.object.getModel(), hit.object.getId()); | ||||
|                 domainObject = this.applyGetInterceptors(domainObject.identifier, domainObject); | ||||
|  | ||||
|                 return domainObject; | ||||
|             }))); | ||||
|  | ||||
|     return searchPromises; | ||||
| }; | ||||
| @@ -317,6 +341,19 @@ ObjectAPI.prototype.listGetInterceptors = function (identifier, object) { | ||||
|     return this.interceptorRegistry.getInterceptors(identifier, object); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Inovke interceptors if applicable for a given domain object. | ||||
|  * @private | ||||
|  */ | ||||
| ObjectAPI.prototype.applyGetInterceptors = function (identifier, domainObject) { | ||||
|     const interceptors = this.listGetInterceptors(identifier, domainObject); | ||||
|     interceptors.forEach(interceptor => { | ||||
|         domainObject = interceptor.invoke(identifier, domainObject); | ||||
|     }); | ||||
|  | ||||
|     return domainObject; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Modify a domain object. | ||||
|  * @param {module:openmct.DomainObject} object the object to mutate | ||||
| @@ -352,11 +389,29 @@ ObjectAPI.prototype.mutate = function (domainObject, path, value) { | ||||
|  * @private | ||||
|  */ | ||||
| ObjectAPI.prototype._toMutable = function (object) { | ||||
|     let mutableObject; | ||||
|  | ||||
|     if (object.isMutable) { | ||||
|         return object; | ||||
|         mutableObject = object; | ||||
|     } else { | ||||
|         return MutableDomainObject.createMutable(object, this.eventEmitter); | ||||
|         mutableObject = MutableDomainObject.createMutable(object, this.eventEmitter); | ||||
|     } | ||||
|  | ||||
|     // Check if provider supports realtime updates | ||||
|     let identifier = utils.parseKeyString(mutableObject.identifier); | ||||
|     let provider = this.getProvider(identifier); | ||||
|  | ||||
|     if (provider !== undefined | ||||
|         && provider.observe !== undefined) { | ||||
|         let unobserve = provider.observe(identifier, (updatedModel) => { | ||||
|             mutableObject.$refresh(updatedModel); | ||||
|         }); | ||||
|         mutableObject.$on('$destroy', () => { | ||||
|             unobserve(); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     return mutableObject; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -3,6 +3,8 @@ import ObjectAPI from './ObjectAPI.js'; | ||||
| describe("The Object API", () => { | ||||
|     let objectAPI; | ||||
|     let typeRegistry; | ||||
|     let openmct = {}; | ||||
|     let mockIdentifierService; | ||||
|     let mockDomainObject; | ||||
|     const TEST_NAMESPACE = "test-namespace"; | ||||
|     const FIFTEEN_MINUTES = 15 * 60 * 1000; | ||||
| @@ -11,7 +13,19 @@ describe("The Object API", () => { | ||||
|         typeRegistry = jasmine.createSpyObj('typeRegistry', [ | ||||
|             'get' | ||||
|         ]); | ||||
|         objectAPI = new ObjectAPI(typeRegistry); | ||||
|         openmct.$injector = jasmine.createSpyObj('$injector', ['get']); | ||||
|         mockIdentifierService = jasmine.createSpyObj( | ||||
|             'identifierService', | ||||
|             ['parse'] | ||||
|         ); | ||||
|         mockIdentifierService.parse.and.returnValue({ | ||||
|             getSpace: () => { | ||||
|                 return TEST_NAMESPACE; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         openmct.$injector.get.and.returnValue(mockIdentifierService); | ||||
|         objectAPI = new ObjectAPI(typeRegistry, openmct); | ||||
|         mockDomainObject = { | ||||
|             identifier: { | ||||
|                 namespace: TEST_NAMESPACE, | ||||
| @@ -136,11 +150,13 @@ describe("The Object API", () => { | ||||
|  | ||||
|     describe("the mutation API", () => { | ||||
|         let testObject; | ||||
|         let updatedTestObject; | ||||
|         let mutable; | ||||
|         let mockProvider; | ||||
|         let callbacks = []; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|             objectAPI = new ObjectAPI(typeRegistry); | ||||
|             objectAPI = new ObjectAPI(typeRegistry, openmct); | ||||
|             testObject = { | ||||
|                 identifier: { | ||||
|                     namespace: TEST_NAMESPACE, | ||||
| @@ -154,12 +170,27 @@ describe("The Object API", () => { | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
|             updatedTestObject = Object.assign({otherAttribute: 'changed-attribute-value'}, testObject); | ||||
|             mockProvider = jasmine.createSpyObj("mock provider", [ | ||||
|                 "get", | ||||
|                 "create", | ||||
|                 "update" | ||||
|                 "update", | ||||
|                 "observe", | ||||
|                 "observeObjectChanges" | ||||
|             ]); | ||||
|             mockProvider.get.and.returnValue(Promise.resolve(testObject)); | ||||
|             mockProvider.observeObjectChanges.and.callFake(() => { | ||||
|                 callbacks[0](updatedTestObject); | ||||
|                 callbacks.splice(0, 1); | ||||
|             }); | ||||
|             mockProvider.observe.and.callFake((id, callback) => { | ||||
|                 if (callbacks.length === 0) { | ||||
|                     callbacks.push(callback); | ||||
|                 } else { | ||||
|                     callbacks[0] = callback; | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             objectAPI.addProvider(TEST_NAMESPACE, mockProvider); | ||||
|  | ||||
|             return objectAPI.getMutable(testObject.identifier) | ||||
| @@ -191,6 +222,13 @@ describe("The Object API", () => { | ||||
|             it('that is identical to original object when serialized', function () { | ||||
|                 expect(JSON.stringify(mutable)).toEqual(JSON.stringify(testObject)); | ||||
|             }); | ||||
|  | ||||
|             it('that observes for object changes', function () { | ||||
|                 let mockListener = jasmine.createSpy('mockListener'); | ||||
|                 objectAPI.observe(testObject, '*', mockListener); | ||||
|                 mockProvider.observeObjectChanges(); | ||||
|                 expect(mockListener).toHaveBeenCalled(); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         describe('uses events', function () { | ||||
|   | ||||
							
								
								
									
										37
									
								
								src/plugins/CouchDBSearchFolder/plugin.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/plugins/CouchDBSearchFolder/plugin.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| export default function (folderName, couchPlugin, searchFilter) { | ||||
|     return function install(openmct) { | ||||
|         const couchProvider = couchPlugin.couchProvider; | ||||
|  | ||||
|         openmct.objects.addRoot({ | ||||
|             namespace: 'couch-search', | ||||
|             key: 'couch-search' | ||||
|         }); | ||||
|  | ||||
|         openmct.objects.addProvider('couch-search', { | ||||
|             get(identifier) { | ||||
|                 if (identifier.key !== 'couch-search') { | ||||
|                     return undefined; | ||||
|                 } else { | ||||
|                     return Promise.resolve({ | ||||
|                         identifier, | ||||
|                         type: 'folder', | ||||
|                         name: folderName || "CouchDB Documents" | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         openmct.composition.addProvider({ | ||||
|             appliesTo(domainObject) { | ||||
|                 return domainObject.identifier.namespace === 'couch-search' | ||||
|                     && domainObject.identifier.key === 'couch-search'; | ||||
|             }, | ||||
|             load() { | ||||
|                 return couchProvider.getObjectsByFilter(searchFilter).then(objects => { | ||||
|                     return objects.map(object => object.identifier); | ||||
|                 }); | ||||
|             } | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
| } | ||||
							
								
								
									
										91
									
								
								src/plugins/CouchDBSearchFolder/pluginSpec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/plugins/CouchDBSearchFolder/pluginSpec.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2020, 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 {createOpenMct, resetApplicationState} from "utils/testing"; | ||||
| import CouchDBSearchFolderPlugin from './plugin'; | ||||
|  | ||||
| describe('the plugin', function () { | ||||
|     let identifier = { | ||||
|         namespace: 'couch-search', | ||||
|         key: "couch-search" | ||||
|     }; | ||||
|     let testPath = '/test/db'; | ||||
|     let openmct; | ||||
|     let composition; | ||||
|  | ||||
|     beforeEach((done) => { | ||||
|  | ||||
|         openmct = createOpenMct(); | ||||
|  | ||||
|         let couchPlugin = openmct.plugins.CouchDB(testPath); | ||||
|         openmct.install(couchPlugin); | ||||
|  | ||||
|         openmct.install(new CouchDBSearchFolderPlugin('CouchDB Documents', couchPlugin, { | ||||
|             "selector": { | ||||
|                 "model": { | ||||
|                     "type": "plan" | ||||
|                 } | ||||
|             } | ||||
|         })); | ||||
|  | ||||
|         openmct.on('start', done); | ||||
|         openmct.startHeadless(); | ||||
|  | ||||
|         composition = openmct.composition.get({identifier}); | ||||
|  | ||||
|         spyOn(couchPlugin.couchProvider, 'getObjectsByFilter').and.returnValue(Promise.resolve([ | ||||
|             { | ||||
|                 identifier: { | ||||
|                     key: "1", | ||||
|                     namespace: "mct" | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 identifier: { | ||||
|                     key: "2", | ||||
|                     namespace: "mct" | ||||
|                 } | ||||
|             } | ||||
|         ])); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|         return resetApplicationState(openmct); | ||||
|     }); | ||||
|  | ||||
|     it('provides a folder to hold plans', () => { | ||||
|         openmct.objects.get(identifier).then((object) => { | ||||
|             expect(object).toEqual({ | ||||
|                 identifier, | ||||
|                 type: 'folder', | ||||
|                 name: "CouchDB Documents" | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('provides composition for couch search folders', () => { | ||||
|         composition.load().then((objects) => { | ||||
|             expect(objects.length).toEqual(2); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
| }); | ||||
| @@ -98,7 +98,7 @@ describe("The LAD Table", () => { | ||||
|     }); | ||||
|  | ||||
|     it("should provide a table view only for lad table objects", () => { | ||||
|         let applicableViews = openmct.objectViews.get(mockObj.ladTable); | ||||
|         let applicableViews = openmct.objectViews.get(mockObj.ladTable, []); | ||||
|  | ||||
|         let ladTableView = applicableViews.find( | ||||
|             (viewProvider) => viewProvider.key === ladTableKey | ||||
| @@ -185,7 +185,7 @@ describe("The LAD Table", () => { | ||||
|                 end: bounds.end | ||||
|             }); | ||||
|  | ||||
|             applicableViews = openmct.objectViews.get(mockObj.ladTable); | ||||
|             applicableViews = openmct.objectViews.get(mockObj.ladTable, []); | ||||
|             ladTableViewProvider = applicableViews.find((viewProvider) => viewProvider.key === ladTableKey); | ||||
|             ladTableView = ladTableViewProvider.view(mockObj.ladTable, [mockObj.ladTable]); | ||||
|             ladTableView.show(child, true); | ||||
| @@ -296,7 +296,7 @@ describe("The LAD Table Set", () => { | ||||
|     }); | ||||
|  | ||||
|     it("should provide a lad table set view only for lad table set objects", () => { | ||||
|         let applicableViews = openmct.objectViews.get(mockObj.ladTableSet); | ||||
|         let applicableViews = openmct.objectViews.get(mockObj.ladTableSet, []); | ||||
|  | ||||
|         let ladTableSetView = applicableViews.find( | ||||
|             (viewProvider) => viewProvider.key === ladTableSetKey | ||||
| @@ -391,7 +391,7 @@ describe("The LAD Table Set", () => { | ||||
|                 end: bounds.end | ||||
|             }); | ||||
|  | ||||
|             applicableViews = openmct.objectViews.get(mockObj.ladTableSet); | ||||
|             applicableViews = openmct.objectViews.get(mockObj.ladTableSet, []); | ||||
|             ladTableSetViewProvider = applicableViews.find((viewProvider) => viewProvider.key === ladTableSetKey); | ||||
|             ladTableSetView = ladTableSetViewProvider.view(mockObj.ladTableSet, [mockObj.ladTableSet]); | ||||
|             ladTableSetView.show(child, true); | ||||
|   | ||||
| @@ -67,11 +67,11 @@ describe("AutoflowTabularPlugin", () => { | ||||
|         }); | ||||
|  | ||||
|         it("applies its view to the type from options", () => { | ||||
|             expect(provider.canView(testObject)).toBe(true); | ||||
|             expect(provider.canView(testObject, [])).toBe(true); | ||||
|         }); | ||||
|  | ||||
|         it("does not apply to other types", () => { | ||||
|             expect(provider.canView({ type: 'foo' })).toBe(false); | ||||
|             expect(provider.canView({ type: 'foo' }, [])).toBe(false); | ||||
|         }); | ||||
|  | ||||
|         describe("provides a view which", () => { | ||||
|   | ||||
| @@ -34,6 +34,9 @@ export default class ConditionManager extends EventEmitter { | ||||
|         this.composition = this.openmct.composition.get(conditionSetDomainObject); | ||||
|         this.composition.on('add', this.subscribeToTelemetry, this); | ||||
|         this.composition.on('remove', this.unsubscribeFromTelemetry, this); | ||||
|  | ||||
|         this.shouldEvaluateNewTelemetry = this.shouldEvaluateNewTelemetry.bind(this); | ||||
|  | ||||
|         this.compositionLoad = this.composition.load(); | ||||
|         this.subscriptions = {}; | ||||
|         this.telemetryObjects = {}; | ||||
| @@ -337,6 +340,10 @@ export default class ConditionManager extends EventEmitter { | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     shouldEvaluateNewTelemetry(currentTimestamp) { | ||||
|         return this.openmct.time.bounds().end >= currentTimestamp; | ||||
|     } | ||||
|  | ||||
|     telemetryReceived(endpoint, datum) { | ||||
|         if (!this.isTelemetryUsed(endpoint)) { | ||||
|             return; | ||||
| @@ -345,10 +352,12 @@ export default class ConditionManager extends EventEmitter { | ||||
|         const normalizedDatum = this.createNormalizedDatum(datum, endpoint); | ||||
|         const timeSystemKey = this.openmct.time.timeSystem().key; | ||||
|         let timestamp = {}; | ||||
|         timestamp[timeSystemKey] = normalizedDatum[timeSystemKey]; | ||||
|  | ||||
|         this.updateConditionResults(normalizedDatum); | ||||
|         this.updateCurrentCondition(timestamp); | ||||
|         const currentTimestamp = normalizedDatum[timeSystemKey]; | ||||
|         timestamp[timeSystemKey] = currentTimestamp; | ||||
|         if (this.shouldEvaluateNewTelemetry(currentTimestamp)) { | ||||
|             this.updateConditionResults(normalizedDatum); | ||||
|             this.updateCurrentCondition(timestamp); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     updateConditionResults(normalizedDatum) { | ||||
|   | ||||
| @@ -136,7 +136,7 @@ describe('the plugin', function () { | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             const applicableViews = openmct.objectViews.get(testViewObject); | ||||
|             const applicableViews = openmct.objectViews.get(testViewObject, []); | ||||
|             let conditionSetView = applicableViews.find((viewProvider) => viewProvider.key === 'conditionSet.view'); | ||||
|             expect(conditionSetView).toBeDefined(); | ||||
|         }); | ||||
| @@ -543,7 +543,6 @@ describe('the plugin', function () { | ||||
|         }); | ||||
|  | ||||
|         it('should evaluate as stale when telemetry is not received in the allotted time', (done) => { | ||||
|  | ||||
|             let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct); | ||||
|             conditionMgr.on('conditionSetResultUpdated', mockListener); | ||||
|             conditionMgr.telemetryObjects = { | ||||
| @@ -565,7 +564,7 @@ describe('the plugin', function () { | ||||
|         }); | ||||
|  | ||||
|         it('should not evaluate as stale when telemetry is received in the allotted time', (done) => { | ||||
|             const date = Date.now(); | ||||
|             const date = 1; | ||||
|             conditionSetDomainObject.configuration.conditionCollection[0].configuration.criteria[0].input = ["0.4"]; | ||||
|             let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct); | ||||
|             conditionMgr.on('conditionSetResultUpdated', mockListener); | ||||
|   | ||||
| @@ -109,7 +109,8 @@ export default { | ||||
|     data() { | ||||
|         return { | ||||
|             domainObject: undefined, | ||||
|             currentObjectPath: [] | ||||
|             currentObjectPath: [], | ||||
|             mutablePromise: undefined | ||||
|         }; | ||||
|     }, | ||||
|     watch: { | ||||
| @@ -130,7 +131,7 @@ export default { | ||||
|     }, | ||||
|     mounted() { | ||||
|         if (this.openmct.objects.supportsMutation(this.item.identifier)) { | ||||
|             this.openmct.objects.getMutable(this.item.identifier) | ||||
|             this.mutablePromise = this.openmct.objects.getMutable(this.item.identifier) | ||||
|                 .then(this.setObject); | ||||
|         } else { | ||||
|             this.openmct.objects.get(this.item.identifier) | ||||
| @@ -142,13 +143,18 @@ export default { | ||||
|             this.removeSelectable(); | ||||
|         } | ||||
|  | ||||
|         if (this.domainObject.isMutable) { | ||||
|         if (this.mutablePromise) { | ||||
|             this.mutablePromise.then(() => { | ||||
|                 this.openmct.objects.destroyMutable(this.domainObject); | ||||
|             }); | ||||
|         } else { | ||||
|             this.openmct.objects.destroyMutable(this.domainObject); | ||||
|         } | ||||
|     }, | ||||
|     methods: { | ||||
|         setObject(domainObject) { | ||||
|             this.domainObject = domainObject; | ||||
|             this.mutablePromise = undefined; | ||||
|             this.currentObjectPath = [this.domainObject].concat(this.objectPath.slice()); | ||||
|             this.$nextTick(() => { | ||||
|                 let reference = this.$refs.objectFrame; | ||||
|   | ||||
| @@ -131,7 +131,8 @@ export default { | ||||
|             domainObject: undefined, | ||||
|             formats: undefined, | ||||
|             viewKey: `alphanumeric-format-${Math.random()}`, | ||||
|             status: '' | ||||
|             status: '', | ||||
|             mutablePromise: undefined | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
| @@ -213,7 +214,7 @@ export default { | ||||
|     }, | ||||
|     mounted() { | ||||
|         if (this.openmct.objects.supportsMutation(this.item.identifier)) { | ||||
|             this.openmct.objects.getMutable(this.item.identifier) | ||||
|             this.mutablePromise = this.openmct.objects.getMutable(this.item.identifier) | ||||
|                 .then(this.setObject); | ||||
|         } else { | ||||
|             this.openmct.objects.get(this.item.identifier) | ||||
| @@ -235,7 +236,11 @@ export default { | ||||
|  | ||||
|         this.openmct.time.off("bounds", this.refreshData); | ||||
|  | ||||
|         if (this.domainObject.isMutable) { | ||||
|         if (this.mutablePromise) { | ||||
|             this.mutablePromise.then(() => { | ||||
|                 this.openmct.objects.destroyMutable(this.domainObject); | ||||
|             }); | ||||
|         } else { | ||||
|             this.openmct.objects.destroyMutable(this.domainObject); | ||||
|         } | ||||
|     }, | ||||
| @@ -296,6 +301,7 @@ export default { | ||||
|         }, | ||||
|         setObject(domainObject) { | ||||
|             this.domainObject = domainObject; | ||||
|             this.mutablePromise = undefined; | ||||
|             this.keyString = this.openmct.objects.makeKeyString(domainObject.identifier); | ||||
|             this.metadata = this.openmct.telemetry.getMetadata(this.domainObject); | ||||
|             this.limitEvaluator = this.openmct.telemetry.limitEvaluator(this.domainObject); | ||||
|   | ||||
| @@ -83,7 +83,7 @@ describe('the plugin', function () { | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         const applicableViews = openmct.objectViews.get(testViewObject); | ||||
|         const applicableViews = openmct.objectViews.get(testViewObject, []); | ||||
|         let displayLayoutViewProvider = applicableViews.find((viewProvider) => viewProvider.key === 'layout.view'); | ||||
|         expect(displayLayoutViewProvider).toBeDefined(); | ||||
|     }); | ||||
|   | ||||
| @@ -271,11 +271,6 @@ export default { | ||||
|             }); | ||||
|         }, | ||||
|         removeFromComposition(identifier) { | ||||
|             let keystring = this.openmct.objects.makeKeyString(identifier); | ||||
|  | ||||
|             this.identifierMap[keystring] = undefined; | ||||
|             delete this.identifierMap[keystring]; | ||||
|  | ||||
|             this.composition.remove({identifier}); | ||||
|         }, | ||||
|         setSelectionToParent() { | ||||
| @@ -355,6 +350,9 @@ export default { | ||||
|         removeChildObject(identifier) { | ||||
|             let removeIdentifier = this.openmct.objects.makeKeyString(identifier); | ||||
|  | ||||
|             this.identifierMap[removeIdentifier] = undefined; | ||||
|             delete this.identifierMap[removeIdentifier]; | ||||
|  | ||||
|             this.containers.forEach(container => { | ||||
|                 container.frames = container.frames.filter(frame => { | ||||
|                     let frameIdentifier = this.openmct.objects.makeKeyString(frame.domainObjectIdentifier); | ||||
|   | ||||
| @@ -26,19 +26,19 @@ | ||||
|     :style="compassDimensionsStyle" | ||||
| > | ||||
|     <CompassHUD | ||||
|         v-if="shouldDisplayCompassHUD" | ||||
|         :heading="heading" | ||||
|         :roll="roll" | ||||
|         v-if="hasCameraFieldOfView" | ||||
|         :sun-heading="sunHeading" | ||||
|         :camera-field-of-view="cameraFieldOfView" | ||||
|         :camera-angle-of-view="cameraAngleOfView" | ||||
|         :camera-pan="cameraPan" | ||||
|     /> | ||||
|     <CompassRose | ||||
|         v-if="shouldDisplayCompassRose" | ||||
|         v-if="hasCameraFieldOfView" | ||||
|         :heading="heading" | ||||
|         :sun-heading="sunHeading" | ||||
|         :camera-field-of-view="cameraFieldOfView" | ||||
|         :camera-angle-of-view="cameraAngleOfView" | ||||
|         :camera-pan="cameraPan" | ||||
|         :lock-compass="lockCompass" | ||||
|         @toggle-lock-compass="toggleLockCompass" | ||||
|     /> | ||||
| </div> | ||||
| </template> | ||||
| @@ -47,7 +47,7 @@ | ||||
| import CompassHUD from './CompassHUD.vue'; | ||||
| import CompassRose from './CompassRose.vue'; | ||||
|  | ||||
| const CAM_FIELD_OF_VIEW = 70; | ||||
| const CAMERA_ANGLE_OF_VIEW = 70; | ||||
|  | ||||
| export default { | ||||
|     components: { | ||||
| @@ -70,38 +70,30 @@ export default { | ||||
|         image: { | ||||
|             type: Object, | ||||
|             required: true | ||||
|         }, | ||||
|         lockCompass: { | ||||
|             type: Boolean, | ||||
|             required: true | ||||
|         } | ||||
|     }, | ||||
|     computed: { | ||||
|         shouldDisplayCompassRose() { | ||||
|             return this.heading !== undefined; | ||||
|         hasCameraFieldOfView() { | ||||
|             return this.cameraPan !== undefined && this.cameraAngleOfView > 0; | ||||
|         }, | ||||
|         shouldDisplayCompassHUD() { | ||||
|             return this.heading !== undefined; | ||||
|         }, | ||||
|         // degrees from north heading | ||||
|         // horizontal rotation from north in degrees | ||||
|         heading() { | ||||
|             return this.image.heading; | ||||
|         }, | ||||
|         roll() { | ||||
|             return this.image.roll; | ||||
|         }, | ||||
|         pitch() { | ||||
|             return this.image.pitch; | ||||
|         }, | ||||
|         // degrees from north heading | ||||
|         // horizontal rotation from north in degrees | ||||
|         sunHeading() { | ||||
|             return this.image.sunOrientation; | ||||
|         }, | ||||
|         // degrees from spacecraft heading | ||||
|         // horizontal rotation from north in degrees | ||||
|         cameraPan() { | ||||
|             return this.image.cameraPan; | ||||
|         }, | ||||
|         cameraTilt() { | ||||
|             return this.image.cameraTilt; | ||||
|         }, | ||||
|         cameraFieldOfView() { | ||||
|             return CAM_FIELD_OF_VIEW; | ||||
|         cameraAngleOfView() { | ||||
|             return CAMERA_ANGLE_OF_VIEW; | ||||
|         }, | ||||
|         compassDimensionsStyle() { | ||||
|             const containerAspectRatio = this.containerWidth / this.containerHeight; | ||||
| @@ -122,6 +114,11 @@ export default { | ||||
|                 height: height | ||||
|             }; | ||||
|         } | ||||
|     }, | ||||
|     methods: { | ||||
|         toggleLockCompass() { | ||||
|             this.$emit('toggle-lock-compass'); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| </script> | ||||
|   | ||||
| @@ -23,7 +23,6 @@ | ||||
| <template> | ||||
| <div | ||||
|     class="c-compass__hud c-hud" | ||||
|     :style="skewCompassHUDStyle" | ||||
| > | ||||
|     <div | ||||
|         v-for="point in visibleCompassPoints" | ||||
| @@ -45,7 +44,7 @@ | ||||
|  | ||||
| <script> | ||||
| import { | ||||
|     normalizeDegrees, | ||||
|     rotate, | ||||
|     inRange, | ||||
|     percentOfRange | ||||
| } from './utils'; | ||||
| @@ -95,40 +94,20 @@ const COMPASS_POINTS = [ | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
|         heading: { | ||||
|             type: Number, | ||||
|             required: true | ||||
|         }, | ||||
|         roll: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         sunHeading: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         cameraFieldOfView: { | ||||
|         cameraAngleOfView: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         cameraPan: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|             required: true | ||||
|         } | ||||
|     }, | ||||
|     computed: { | ||||
|         skewCompassHUDStyle() { | ||||
|             if (this.roll === undefined || this.roll === 0) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const origin = this.roll > 0 ? 'left bottom' : 'right top'; | ||||
|  | ||||
|             return { | ||||
|                 'transform-origin': origin, | ||||
|                 transform: `skew(0, ${ this.roll }deg` | ||||
|             }; | ||||
|         }, | ||||
|         visibleCompassPoints() { | ||||
|             return COMPASS_POINTS | ||||
|                 .filter(point => inRange(point.degrees, this.visibleRange)) | ||||
| @@ -142,28 +121,19 @@ export default { | ||||
|                 }); | ||||
|         }, | ||||
|         isSunInRange() { | ||||
|             return inRange(this.normalizedSunHeading, this.visibleRange); | ||||
|             return inRange(this.sunHeading, this.visibleRange); | ||||
|         }, | ||||
|         sunPositionStyle() { | ||||
|             const percentage = percentOfRange(this.normalizedSunHeading, this.visibleRange); | ||||
|             const percentage = percentOfRange(this.sunHeading, this.visibleRange); | ||||
|  | ||||
|             return { | ||||
|                 left: `${ percentage * 100 }%` | ||||
|             }; | ||||
|         }, | ||||
|         normalizedSunHeading() { | ||||
|             return normalizeDegrees(this.sunHeading); | ||||
|         }, | ||||
|         normalizedHeading() { | ||||
|             return normalizeDegrees(this.heading); | ||||
|         }, | ||||
|         visibleRange() { | ||||
|             const min = normalizeDegrees(this.normalizedHeading + this.cameraPan - this.cameraFieldOfView / 2); | ||||
|             const max = normalizeDegrees(this.normalizedHeading + this.cameraPan + this.cameraFieldOfView / 2); | ||||
|  | ||||
|             return [ | ||||
|                 min, | ||||
|                 max | ||||
|                 rotate(this.cameraPan, -this.cameraAngleOfView / 2), | ||||
|                 rotate(this.cameraPan, this.cameraAngleOfView / 2) | ||||
|             ]; | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -23,11 +23,11 @@ | ||||
| <template> | ||||
| <div | ||||
|     class="c-direction-rose" | ||||
|     @click="toggleBezelLock" | ||||
|     @click="toggleLockCompass" | ||||
| > | ||||
|     <div | ||||
|         class="c-nsew" | ||||
|         :style="rotateFrameStyle" | ||||
|         :style="compassRoseStyle" | ||||
|     > | ||||
|         <svg | ||||
|             class="c-nsew__minor-ticks" | ||||
| @@ -118,20 +118,21 @@ | ||||
|     </div> | ||||
|  | ||||
|     <div | ||||
|         v-if="hasHeading" | ||||
|         class="c-spacecraft-body" | ||||
|         :style="headingStyle" | ||||
|     > | ||||
|     </div> | ||||
|  | ||||
|     <div | ||||
|         v-if="hasSunHeading" | ||||
|         class="c-sun" | ||||
|         :style="sunHeadingStyle" | ||||
|     ></div> | ||||
|  | ||||
|     <div | ||||
|         v-if="showCameraFOV" | ||||
|         class="c-cam-field" | ||||
|         :style="cameraFOVHeadingStyle" | ||||
|         :style="cameraPanStyle" | ||||
|     > | ||||
|         <div class="cam-field-half cam-field-half-l"> | ||||
|             <div | ||||
| @@ -150,7 +151,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { normalizeDegrees } from './utils'; | ||||
| import { rotate } from './utils'; | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
| @@ -162,30 +163,24 @@ export default { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         cameraFieldOfView: { | ||||
|         cameraAngleOfView: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         cameraPan: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|             required: true | ||||
|         }, | ||||
|         lockCompass: { | ||||
|             type: Boolean, | ||||
|             required: true | ||||
|         } | ||||
|     }, | ||||
|  | ||||
|     data() { | ||||
|         return { | ||||
|             lockBezel: true | ||||
|         }; | ||||
|     }, | ||||
|  | ||||
|     computed: { | ||||
|         compassHeading() { | ||||
|             return this.lockBezel ? normalizeDegrees(this.heading) : 0; | ||||
|         }, | ||||
|         north() { | ||||
|             return normalizeDegrees(this.compassHeading - this.heading); | ||||
|             return this.lockCompass ? rotate(-this.cameraPan) : 0; | ||||
|         }, | ||||
|         rotateFrameStyle() { | ||||
|         compassRoseStyle() { | ||||
|             return { transform: `rotate(${ this.north }deg)` }; | ||||
|         }, | ||||
|         northTextTransform() { | ||||
| @@ -215,47 +210,51 @@ export default { | ||||
|                 west: `translate(50,87) ${ rotation }` | ||||
|             }; | ||||
|         }, | ||||
|         hasHeading() { | ||||
|             return this.heading !== undefined; | ||||
|         }, | ||||
|         headingStyle() { | ||||
|             const rotation = rotate(this.north, this.heading); | ||||
|  | ||||
|             return { | ||||
|                 transform: `translateX(-50%) rotate(${ this.compassHeading }deg)` | ||||
|                 transform: `translateX(-50%) rotate(${ rotation }deg)` | ||||
|             }; | ||||
|         }, | ||||
|         cameraFOVHeading() { | ||||
|             return this.compassHeading + this.cameraPan; | ||||
|         }, | ||||
|         cameraFOVHeadingStyle() { | ||||
|             return { | ||||
|                 transform: `rotate(${ this.cameraFOVHeading }deg)` | ||||
|             }; | ||||
|         hasSunHeading() { | ||||
|             return this.sunHeading !== undefined; | ||||
|         }, | ||||
|         sunHeadingStyle() { | ||||
|             const rotation = normalizeDegrees(this.north + this.sunHeading); | ||||
|             const rotation = rotate(this.north, this.sunHeading); | ||||
|  | ||||
|             return { | ||||
|                 transform: `rotate(${ rotation }deg)` | ||||
|             }; | ||||
|         }, | ||||
|         showCameraFOV() { | ||||
|             return this.cameraPan !== undefined && this.cameraFieldOfView > 0; | ||||
|         cameraPanStyle() { | ||||
|             const rotation = rotate(this.north, this.cameraPan); | ||||
|  | ||||
|             return { | ||||
|                 transform: `rotate(${ rotation }deg)` | ||||
|             }; | ||||
|         }, | ||||
|         // left half of camera field of view | ||||
|         // rotated counter-clockwise from camera field of view heading | ||||
|         // rotated counter-clockwise from camera pan angle | ||||
|         cameraFOVStyleLeftHalf() { | ||||
|             return { | ||||
|                 transform: `translateX(50%) rotate(${ -this.cameraFieldOfView / 2 }deg)` | ||||
|                 transform: `translateX(50%) rotate(${ -this.cameraAngleOfView / 2 }deg)` | ||||
|             }; | ||||
|         }, | ||||
|         // right half of camera field of view | ||||
|         // rotated clockwise from camera field of view heading | ||||
|         // rotated clockwise from camera pan angle | ||||
|         cameraFOVStyleRightHalf() { | ||||
|             return { | ||||
|                 transform: `translateX(-50%) rotate(${ this.cameraFieldOfView / 2 }deg)` | ||||
|                 transform: `translateX(-50%) rotate(${ this.cameraAngleOfView / 2 }deg)` | ||||
|             }; | ||||
|         } | ||||
|     }, | ||||
|     methods: { | ||||
|         toggleBezelLock() { | ||||
|             this.lockBezel = !this.lockBezel; | ||||
|         toggleLockCompass() { | ||||
|             this.$emit('toggle-lock-compass'); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -5,7 +5,7 @@ $elemBg: rgba(black, 0.7); | ||||
| @mixin sun($position: 'circle closest-side') { | ||||
|     $color: #ff9900; | ||||
|     $gradEdgePerc: 60%; | ||||
|   background: radial-gradient(#{$position}, $color, $color $gradEdgePerc, rgba($color, 0.4) $gradEdgePerc + 5%, transparent); | ||||
|     background: radial-gradient(#{$position}, $color, $color $gradEdgePerc, rgba($color, 0.4) $gradEdgePerc + 5%, transparent); | ||||
|  | ||||
| } | ||||
|  | ||||
| @@ -24,8 +24,6 @@ $elemBg: rgba(black, 0.7); | ||||
|   $m: 1px; | ||||
|   $padTB: 2px; | ||||
|   $padLR: $padTB; | ||||
|   background: $elemBg; | ||||
|   border-radius: 3px; | ||||
|   color: $interfaceKeyColor; | ||||
|   font-size: 0.8em; | ||||
|   position: absolute; | ||||
| @@ -56,7 +54,7 @@ $elemBg: rgba(black, 0.7); | ||||
|     // NSEW | ||||
|     display: inline-block; | ||||
|     font-weight: bold; | ||||
|     text-shadow: black 0 0 3px; | ||||
|     text-shadow: 0 1px 2px black; | ||||
|     top: 50%; | ||||
|     transform: translate(-50%,-50%); | ||||
|     z-index: 2; | ||||
|   | ||||
							
								
								
									
										84
									
								
								src/plugins/imagery/components/Compass/pluginSpec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/plugins/imagery/components/Compass/pluginSpec.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2020, 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 Compass from './Compass.vue'; | ||||
| import Vue from 'vue'; | ||||
|  | ||||
| const COMPASS_ROSE_CLASS = '.c-direction-rose'; | ||||
| const COMPASS_HUD_CLASS = '.c-compass__hud'; | ||||
|  | ||||
| describe("The Compass component", () => { | ||||
|     let app; | ||||
|     let instance; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|         let imageDatum = { | ||||
|             heading: 100, | ||||
|             roll: 90, | ||||
|             pitch: 90, | ||||
|             cameraTilt: 100, | ||||
|             cameraPan: 90, | ||||
|             sunAngle: 30 | ||||
|         }; | ||||
|         let propsData = { | ||||
|             containerWidth: 600, | ||||
|             containerHeight: 600, | ||||
|             naturalAspectRatio: 0.9, | ||||
|             image: imageDatum | ||||
|         }; | ||||
|  | ||||
|         app = new Vue({ | ||||
|             components: { Compass }, | ||||
|             data() { | ||||
|                 return propsData; | ||||
|             }, | ||||
|             template: `<Compass | ||||
|                 :container-width="containerWidth" | ||||
|                 :container-height="containerHeight" | ||||
|                 :natural-aspect-ratio="naturalAspectRatio" | ||||
|                 :image="image" />` | ||||
|         }); | ||||
|         instance = app.$mount(); | ||||
|  | ||||
|     }); | ||||
|  | ||||
|     afterAll(() => { | ||||
|         app.$destroy(); | ||||
|     }); | ||||
|  | ||||
|     describe("when a heading value exists on the image", () => { | ||||
|  | ||||
|         it("should display a compass rose", () => { | ||||
|             let compassRoseElement = instance.$el.querySelector(COMPASS_ROSE_CLASS | ||||
|             ); | ||||
|  | ||||
|             expect(compassRoseElement).toBeDefined(); | ||||
|         }); | ||||
|  | ||||
|         it("should display a compass HUD", () => { | ||||
|             let compassHUDElement = instance.$el.querySelector(COMPASS_HUD_CLASS); | ||||
|  | ||||
|             expect(compassHUDElement).toBeDefined(); | ||||
|         }); | ||||
|  | ||||
|     }); | ||||
|  | ||||
| }); | ||||
| @@ -1,17 +1,28 @@ | ||||
| export function normalizeDegrees(degrees) { | ||||
|     const base = degrees % 360; | ||||
| /** | ||||
|  * | ||||
|  * sums an arbitrary number of absolute rotations | ||||
|  * (meaning rotations relative to one common direction 0) | ||||
|  * normalizes the rotation to the range [0, 360) | ||||
|  * | ||||
|  * @param  {...number} rotations in degrees | ||||
|  * @returns {number} normalized sum of all rotations - [0, 360) degrees | ||||
|  */ | ||||
| export function rotate(...rotations) { | ||||
|     const rotation = rotations.reduce((a, b) => a + b, 0); | ||||
|  | ||||
|     return base >= 0 ? base : 360 + base; | ||||
|     return normalizeCompassDirection(rotation); | ||||
| } | ||||
|  | ||||
| export function inRange(degrees, [min, max]) { | ||||
|     const point = rotate(degrees); | ||||
|  | ||||
|     return min > max | ||||
|         ? (degrees >= min && degrees < 360) || (degrees <= max && degrees >= 0) | ||||
|         : degrees >= min && degrees <= max; | ||||
|         ? (point >= min && point < 360) || (point <= max && point >= 0) | ||||
|         : point >= min && point <= max; | ||||
| } | ||||
|  | ||||
| export function percentOfRange(degrees, [min, max]) { | ||||
|     let distance = degrees; | ||||
|     let distance = rotate(degrees); | ||||
|     let minRange = min; | ||||
|     let maxRange = max; | ||||
|  | ||||
| @@ -26,19 +37,8 @@ export function percentOfRange(degrees, [min, max]) { | ||||
|     return (distance - minRange) / (maxRange - minRange); | ||||
| } | ||||
|  | ||||
| export function normalizeSemiCircleDegrees(rawDegrees) { | ||||
|     // in case tony hawk is providing us degrees | ||||
|     let degrees = rawDegrees % 360; | ||||
| function normalizeCompassDirection(degrees) { | ||||
|     const base = degrees % 360; | ||||
|  | ||||
|     // westward degrees are between 0 and -180 exclusively | ||||
|     if (degrees > 180) { | ||||
|         degrees = degrees - 360; | ||||
|     } | ||||
|  | ||||
|     // eastward degrees are between 0 and 180 inclusively | ||||
|     if (degrees <= -180) { | ||||
|         degrees = 360 - degrees; | ||||
|     } | ||||
|  | ||||
|     return degrees; | ||||
|     return base >= 0 ? base : 360 + base; | ||||
| } | ||||
|   | ||||
| @@ -74,6 +74,8 @@ | ||||
|                 :container-height="imageContainerHeight" | ||||
|                 :natural-aspect-ratio="focusedImageNaturalAspectRatio" | ||||
|                 :image="focusedImage" | ||||
|                 :lock-compass="lockCompass" | ||||
|                 @toggle-lock-compass="toggleLockCompass" | ||||
|             /> | ||||
|         </div> | ||||
|         <div class="c-local-controls c-local-controls--show-on-hover c-imagery__prev-next-buttons"> | ||||
| @@ -127,16 +129,16 @@ | ||||
|         :class="{'is-paused': isPaused}" | ||||
|         @scroll="handleScroll" | ||||
|     > | ||||
|         <div v-for="(datum, index) in imageHistory" | ||||
|              :key="datum.url + datum[timeKey]" | ||||
|         <div v-for="(image, index) in imageHistory" | ||||
|              :key="image.url + image.time" | ||||
|              class="c-imagery__thumb c-thumb" | ||||
|              :class="{ selected: focusedImageIndex === index && isPaused }" | ||||
|              @click="setFocusedImage(index, thumbnailClick)" | ||||
|         > | ||||
|             <img class="c-thumb__image" | ||||
|                  :src="formatImageUrl(datum)" | ||||
|                  :src="image.url" | ||||
|             > | ||||
|             <div class="c-thumb__timestamp">{{ formatTime(datum) }}</div> | ||||
|             <div class="c-thumb__timestamp">{{ image.formattedTime }}</div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| @@ -196,7 +198,8 @@ export default { | ||||
|             latestRelatedTelemetry: {}, | ||||
|             focusedImageNaturalAspectRatio: undefined, | ||||
|             imageContainerWidth: undefined, | ||||
|             imageContainerHeight: undefined | ||||
|             imageContainerHeight: undefined, | ||||
|             lockCompass: true | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
| @@ -268,11 +271,27 @@ export default { | ||||
|  | ||||
|             if (this.relatedTelemetry.hasRelatedTelemetry) { | ||||
|                 isFresh = true; | ||||
|                 for (let key of this.spacecraftKeys) { | ||||
|                 for (let key of this.spacecraftPositionKeys) { | ||||
|                     if (this.relatedTelemetry[key] && latest[key] && focused[key]) { | ||||
|                         if (!this.relatedTelemetry[key].comparisonFunction(latest[key], focused[key])) { | ||||
|                             isFresh = false; | ||||
|                         } | ||||
|                         isFresh = isFresh && Boolean(this.relatedTelemetry[key].comparisonFunction(latest[key], focused[key])); | ||||
|                     } else { | ||||
|                         isFresh = false; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return isFresh; | ||||
|         }, | ||||
|         isSpacecraftOrientationFresh() { | ||||
|             let isFresh = undefined; | ||||
|             let latest = this.latestRelatedTelemetry; | ||||
|             let focused = this.focusedImageRelatedTelemetry; | ||||
|  | ||||
|             if (this.relatedTelemetry.hasRelatedTelemetry) { | ||||
|                 isFresh = true; | ||||
|                 for (let key of this.spacecraftOrientationKeys) { | ||||
|                     if (this.relatedTelemetry[key] && latest[key] && focused[key]) { | ||||
|                         isFresh = isFresh && Boolean(this.relatedTelemetry[key].comparisonFunction(latest[key], focused[key])); | ||||
|                     } else { | ||||
|                         isFresh = false; | ||||
|                     } | ||||
| @@ -290,12 +309,10 @@ export default { | ||||
|                 isFresh = true; | ||||
|  | ||||
|                 // camera freshness relies on spacecraft position freshness | ||||
|                 if (this.isSpacecraftPositionFresh) { | ||||
|                 if (this.isSpacecraftPositionFresh && this.isSpacecraftOrientationFresh) { | ||||
|                     for (let key of this.cameraKeys) { | ||||
|                         if (this.relatedTelemetry[key] && latest[key] && focused[key]) { | ||||
|                             if (!this.relatedTelemetry[key].comparisonFunction(latest[key], focused[key])) { | ||||
|                                 isFresh = false; | ||||
|                             } | ||||
|                             isFresh = isFresh && Boolean(this.relatedTelemetry[key].comparisonFunction(latest[key], focused[key])); | ||||
|                         } else { | ||||
|                             isFresh = false; | ||||
|                         } | ||||
| @@ -330,7 +347,8 @@ export default { | ||||
|         this.imageFormatter = this.openmct.telemetry.getValueFormatter(this.imageHints); | ||||
|  | ||||
|         // related telemetry keys | ||||
|         this.spacecraftKeys = ['heading', 'roll', 'pitch']; | ||||
|         this.spacecraftPositionKeys = ['positionX', 'positionY', 'positionZ']; | ||||
|         this.spacecraftOrientationKeys = ['heading']; | ||||
|         this.cameraKeys = ['cameraPan', 'cameraTilt']; | ||||
|         this.sunKeys = ['sunOrientation']; | ||||
|  | ||||
| @@ -377,7 +395,7 @@ export default { | ||||
|         // unsubscribe from related telemetry | ||||
|         if (this.relatedTelemetry.hasRelatedTelemetry) { | ||||
|             for (let key of this.relatedTelemetry.keys) { | ||||
|                 if (this.relatedTelemetry[key].unsubscribe) { | ||||
|                 if (this.relatedTelemetry[key] && this.relatedTelemetry[key].unsubscribe) { | ||||
|                     this.relatedTelemetry[key].unsubscribe(); | ||||
|                 } | ||||
|             } | ||||
| @@ -388,7 +406,7 @@ export default { | ||||
|             this.relatedTelemetry = new RelatedTelemetry( | ||||
|                 this.openmct, | ||||
|                 this.domainObject, | ||||
|                 [...this.spacecraftKeys, ...this.cameraKeys, ...this.sunKeys] | ||||
|                 [...this.spacecraftPositionKeys, ...this.spacecraftOrientationKeys, ...this.cameraKeys, ...this.sunKeys] | ||||
|             ); | ||||
|  | ||||
|             if (this.relatedTelemetry.hasRelatedTelemetry) { | ||||
| @@ -450,7 +468,12 @@ export default { | ||||
|  | ||||
|             // set data ON image telemetry as well as in focusedImageRelatedTelemetry | ||||
|             for (let key of this.relatedTelemetry.keys) { | ||||
|                 if (this.relatedTelemetry[key] && this.relatedTelemetry[key].historical) { | ||||
|                 if ( | ||||
|                     this.relatedTelemetry[key] | ||||
|                     && this.relatedTelemetry[key].historical | ||||
|                     && this.relatedTelemetry[key].requestLatestFor | ||||
|  | ||||
|                 ) { | ||||
|                     let valuesOnTelemetry = this.relatedTelemetry[key].hasTelemetryOnDatum; | ||||
|                     let value = await this.getMostRecentRelatedTelemetry(key, this.focusedImage); | ||||
|  | ||||
| @@ -463,7 +486,7 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|         trackLatestRelatedTelemetry() { | ||||
|             [...this.spacecraftKeys, ...this.cameraKeys, ...this.sunKeys].forEach(key => { | ||||
|             [...this.spacecraftPositionKeys, ...this.spacecraftOrientationKeys, ...this.cameraKeys, ...this.sunKeys].forEach(key => { | ||||
|                 if (this.relatedTelemetry[key] && this.relatedTelemetry[key].subscribe) { | ||||
|                     this.relatedTelemetry[key].subscribe((datum) => { | ||||
|                         let valueKey = this.relatedTelemetry[key].realtime.valueKey; | ||||
| @@ -628,7 +651,12 @@ export default { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             this.imageHistory.push(datum); | ||||
|             let image = { ...datum }; | ||||
|             image.formattedTime = this.formatTime(datum); | ||||
|             image.url = this.formatImageUrl(datum); | ||||
|             image.time = datum[this.timeKey]; | ||||
|  | ||||
|             this.imageHistory.push(image); | ||||
|  | ||||
|             if (setFocused) { | ||||
|                 this.setFocusedImage(this.imageHistory.length - 1); | ||||
| @@ -763,6 +791,9 @@ export default { | ||||
|             if (this.$refs.focusedImage.clientHeight !== this.imageContainerHeight) { | ||||
|                 this.imageContainerHeight = this.$refs.focusedImage.clientHeight; | ||||
|             } | ||||
|         }, | ||||
|         toggleLockCompass() { | ||||
|             this.lockCompass = !this.lockCompass; | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -68,19 +68,23 @@ export default class RelatedTelemetry { | ||||
|  | ||||
|         await Promise.all( | ||||
|             this.keys.map(async (key) => { | ||||
|                 if (this[key].historical) { | ||||
|                     await this._initializeHistorical(key); | ||||
|                 } | ||||
|                 if (this[key]) { | ||||
|                     if (this[key].historical) { | ||||
|                         await this._initializeHistorical(key); | ||||
|                     } | ||||
|  | ||||
|                 if (this[key].realtime && this[key].realtime.telemetryObjectId) { | ||||
|                     await this._intializeRealtime(key); | ||||
|                     if (this[key].realtime && this[key].realtime.telemetryObjectId && this[key].realtime.telemetryObjectId !== '') { | ||||
|                         await this._intializeRealtime(key); | ||||
|                     } | ||||
|                 } | ||||
|             }) | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     async _initializeHistorical(key) { | ||||
|         if (this[key].historical.telemetryObjectId) { | ||||
|         if (!this[key].historical.telemetryObjectId) { | ||||
|             this[key].historical.hasTelemetryOnDatum = true; | ||||
|         } else if (this[key].historical.telemetryObjectId !== '') { | ||||
|             this[key].historicalDomainObject = await this._openmct.objects.get(this[key].historical.telemetryObjectId); | ||||
|  | ||||
|             this[key].requestLatestFor = async (datum) => { | ||||
| @@ -94,8 +98,6 @@ export default class RelatedTelemetry { | ||||
|  | ||||
|                 return results[results.length - 1]; | ||||
|             }; | ||||
|         } else { | ||||
|             this[key].historical.hasTelemetryOnDatum = true; | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -153,7 +155,7 @@ export default class RelatedTelemetry { | ||||
|     destroy() { | ||||
|         this._openmct.time.off('timeSystem', this._timeSystemChange); | ||||
|         for (let key of this.keys) { | ||||
|             if (this[key].unsubscribe) { | ||||
|             if (this[key] && this[key].unsubscribe) { | ||||
|                 this[key].unsubscribe(); | ||||
|             } | ||||
|         } | ||||
|   | ||||
| @@ -32,6 +32,19 @@ const TEN_MINUTES = ONE_MINUTE * 10; | ||||
| const MAIN_IMAGE_CLASS = '.js-imageryView-image'; | ||||
| const NEW_IMAGE_CLASS = '.c-imagery__age.c-imagery--new'; | ||||
| const REFRESH_CSS_MS = 500; | ||||
| const TOLERANCE = 0.50; | ||||
|  | ||||
| function comparisonFunction(valueOne, valueTwo) { | ||||
|     let larger = valueOne; | ||||
|     let smaller = valueTwo; | ||||
|  | ||||
|     if (larger < smaller) { | ||||
|         larger = valueTwo; | ||||
|         smaller = valueOne; | ||||
|     } | ||||
|  | ||||
|     return (larger - smaller) < TOLERANCE; | ||||
| } | ||||
|  | ||||
| function getImageInfo(doc) { | ||||
|     let imageElement = doc.querySelectorAll(MAIN_IMAGE_CLASS)[0]; | ||||
| @@ -63,14 +76,15 @@ function generateTelemetry(start, count) { | ||||
|             "name": stringRep + " Imagery", | ||||
|             "utc": start + (i * ONE_MINUTE), | ||||
|             "url": location.host + '/' + logo + '?time=' + stringRep, | ||||
|             "timeId": stringRep | ||||
|             "timeId": stringRep, | ||||
|             "value": 100 | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     return telemetry; | ||||
| } | ||||
|  | ||||
| fdescribe("The Imagery View Layout", () => { | ||||
| describe("The Imagery View Layout", () => { | ||||
|     const imageryKey = 'example.imagery'; | ||||
|     const START = Date.now(); | ||||
|     const COUNT = 10; | ||||
| @@ -105,7 +119,51 @@ fdescribe("The Imagery View Layout", () => { | ||||
|                         "image": 1, | ||||
|                         "priority": 3 | ||||
|                     }, | ||||
|                     "source": "url" | ||||
|                     "source": "url", | ||||
|                     "relatedTelemetry": { | ||||
|                         "heading": { | ||||
|                             "comparisonFunction": comparisonFunction, | ||||
|                             "historical": { | ||||
|                                 "telemetryObjectId": "heading", | ||||
|                                 "valueKey": "value" | ||||
|                             } | ||||
|                         }, | ||||
|                         "roll": { | ||||
|                             "comparisonFunction": comparisonFunction, | ||||
|                             "historical": { | ||||
|                                 "telemetryObjectId": "roll", | ||||
|                                 "valueKey": "value" | ||||
|                             } | ||||
|                         }, | ||||
|                         "pitch": { | ||||
|                             "comparisonFunction": comparisonFunction, | ||||
|                             "historical": { | ||||
|                                 "telemetryObjectId": "pitch", | ||||
|                                 "valueKey": "value" | ||||
|                             } | ||||
|                         }, | ||||
|                         "cameraPan": { | ||||
|                             "comparisonFunction": comparisonFunction, | ||||
|                             "historical": { | ||||
|                                 "telemetryObjectId": "cameraPan", | ||||
|                                 "valueKey": "value" | ||||
|                             } | ||||
|                         }, | ||||
|                         "cameraTilt": { | ||||
|                             "comparisonFunction": comparisonFunction, | ||||
|                             "historical": { | ||||
|                                 "telemetryObjectId": "cameraTilt", | ||||
|                                 "valueKey": "value" | ||||
|                             } | ||||
|                         }, | ||||
|                         "sunOrientation": { | ||||
|                             "comparisonFunction": comparisonFunction, | ||||
|                             "historical": { | ||||
|                                 "telemetryObjectId": "sunOrientation", | ||||
|                                 "valueKey": "value" | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 }, | ||||
|                 { | ||||
|                     "name": "Name", | ||||
| @@ -151,6 +209,11 @@ fdescribe("The Imagery View Layout", () => { | ||||
|         child = document.createElement('div'); | ||||
|         parent.appendChild(child); | ||||
|  | ||||
|         spyOn(window, 'ResizeObserver').and.returnValue({ | ||||
|             observe() {}, | ||||
|             disconnect() {} | ||||
|         }); | ||||
|  | ||||
|         spyOn(openmct.telemetry, 'request').and.returnValue(Promise.resolve([])); | ||||
|  | ||||
|         imageryPlugin = new ImageryPlugin(); | ||||
| @@ -172,7 +235,7 @@ fdescribe("The Imagery View Layout", () => { | ||||
|     }); | ||||
|  | ||||
|     it("should provide an imagery view only for imagery producing objects", () => { | ||||
|         let applicableViews = openmct.objectViews.get(imageryObject); | ||||
|         let applicableViews = openmct.objectViews.get(imageryObject, []); | ||||
|         let imageryView = applicableViews.find( | ||||
|             viewProvider => viewProvider.key === imageryKey | ||||
|         ); | ||||
| @@ -202,7 +265,7 @@ fdescribe("The Imagery View Layout", () => { | ||||
|                 end: bounds.end + 100 | ||||
|             }); | ||||
|  | ||||
|             applicableViews = openmct.objectViews.get(imageryObject); | ||||
|             applicableViews = openmct.objectViews.get(imageryObject, []); | ||||
|             imageryViewProvider = applicableViews.find(viewProvider => viewProvider.key === imageryKey); | ||||
|             imageryView = imageryViewProvider.view(imageryObject); | ||||
|             imageryView.show(child); | ||||
| @@ -213,6 +276,10 @@ fdescribe("The Imagery View Layout", () => { | ||||
|             return done(); | ||||
|         }); | ||||
|  | ||||
|         afterEach(() => { | ||||
|             imageryView.destroy(); | ||||
|         }); | ||||
|  | ||||
|         it("on mount should show the the most recent image", () => { | ||||
|             const imageInfo = getImageInfo(parent); | ||||
|  | ||||
|   | ||||
| @@ -69,7 +69,7 @@ export default { | ||||
|     methods: { | ||||
|         deletePage(id) { | ||||
|             const selectedSection = this.sections.find(s => s.isSelected); | ||||
|             const page = this.pages.find(p => p.id !== id); | ||||
|             const page = this.pages.find(p => p.id === id); | ||||
|             deleteNotebookEntries(this.openmct, this.domainObject, selectedSection, page); | ||||
|  | ||||
|             const selectedPage = this.pages.find(p => p.isSelected); | ||||
|   | ||||
| @@ -101,7 +101,7 @@ describe("Notebook plugin:", () => { | ||||
|                 creatable: true | ||||
|             }; | ||||
|  | ||||
|             const applicableViews = openmct.objectViews.get(notebookViewObject); | ||||
|             const applicableViews = openmct.objectViews.get(notebookViewObject, []); | ||||
|             notebookViewProvider = applicableViews.find(viewProvider => viewProvider.key === notebookObject.key); | ||||
|             notebookView = notebookViewProvider.view(notebookViewObject); | ||||
|  | ||||
|   | ||||
| @@ -109,10 +109,23 @@ const selectedPage = { | ||||
| }; | ||||
|  | ||||
| let openmct; | ||||
| let mockIdentifierService; | ||||
|  | ||||
| describe('Notebook Entries:', () => { | ||||
|     beforeEach(done => { | ||||
|         openmct = createOpenMct(); | ||||
|         openmct.$injector = jasmine.createSpyObj('$injector', ['get']); | ||||
|         mockIdentifierService = jasmine.createSpyObj( | ||||
|             'identifierService', | ||||
|             ['parse'] | ||||
|         ); | ||||
|         mockIdentifierService.parse.and.returnValue({ | ||||
|             getSpace: () => { | ||||
|                 return ''; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         openmct.$injector.get.and.returnValue(mockIdentifierService); | ||||
|         openmct.types.addType('notebook', { | ||||
|             creatable: true | ||||
|         }); | ||||
|   | ||||
| @@ -56,11 +56,24 @@ const notebookStorage = { | ||||
|     } | ||||
| }; | ||||
|  | ||||
| let openmct = createOpenMct(); | ||||
| let openmct; | ||||
| let mockIdentifierService; | ||||
|  | ||||
| describe('Notebook Storage:', () => { | ||||
|     beforeEach((done) => { | ||||
|         openmct = createOpenMct(); | ||||
|         openmct.$injector = jasmine.createSpyObj('$injector', ['get']); | ||||
|         mockIdentifierService = jasmine.createSpyObj( | ||||
|             'identifierService', | ||||
|             ['parse'] | ||||
|         ); | ||||
|         mockIdentifierService.parse.and.returnValue({ | ||||
|             getSpace: () => { | ||||
|                 return ''; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         openmct.$injector.get.and.returnValue(mockIdentifierService); | ||||
|         window.localStorage.setItem('notebook-storage', null); | ||||
|         openmct.objects.addProvider('', jasmine.createSpyObj('mockNotebookProvider', [ | ||||
|             'create', | ||||
|   | ||||
| @@ -25,20 +25,52 @@ import CouchObjectQueue from "./CouchObjectQueue"; | ||||
|  | ||||
| const REV = "_rev"; | ||||
| const ID = "_id"; | ||||
| const HEARTBEAT = 50000; | ||||
|  | ||||
| export default class CouchObjectProvider { | ||||
|     constructor(openmct, url, namespace) { | ||||
|     // options { | ||||
|     //      url: couchdb url, | ||||
|     //      disableObserve: disable auto feed from couchdb to keep objects in sync, | ||||
|     //      filter: selector to find objects to sync in couchdb | ||||
|     //      } | ||||
|     constructor(openmct, options, namespace) { | ||||
|         options = this._normalize(options); | ||||
|         this.openmct = openmct; | ||||
|         this.url = url; | ||||
|         this.url = options.url; | ||||
|         this.namespace = namespace; | ||||
|         this.objectQueue = {}; | ||||
|         this.observeEnabled = options.disableObserve !== true; | ||||
|         this.observers = {}; | ||||
|         if (this.observeEnabled) { | ||||
|             this.observeObjectChanges(options.filter); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     request(subPath, method, value) { | ||||
|         return fetch(this.url + '/' + subPath, { | ||||
|             method: method, | ||||
|             body: JSON.stringify(value) | ||||
|         }).then(response => response.json()) | ||||
|     //backwards compatibility, options used to be a url. Now it's an object | ||||
|     _normalize(options) { | ||||
|         if (typeof options === 'string') { | ||||
|             return { | ||||
|                 url: options | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         return options; | ||||
|     } | ||||
|  | ||||
|     request(subPath, method, body, signal) { | ||||
|         let fetchOptions = { | ||||
|             method, | ||||
|             body, | ||||
|             signal | ||||
|         }; | ||||
|  | ||||
|         // stringify body if needed | ||||
|         if (fetchOptions.body) { | ||||
|             fetchOptions.body = JSON.stringify(fetchOptions.body); | ||||
|         } | ||||
|  | ||||
|         return fetch(this.url + '/' + subPath, fetchOptions) | ||||
|             .then(response => response.json()) | ||||
|             .then(function (response) { | ||||
|                 return response; | ||||
|             }, function () { | ||||
| @@ -98,8 +130,166 @@ export default class CouchObjectProvider { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     get(identifier) { | ||||
|         return this.request(identifier.key, "GET").then(this.getModel.bind(this)); | ||||
|     get(identifier, abortSignal) { | ||||
|         return this.request(identifier.key, "GET", undefined, abortSignal).then(this.getModel.bind(this)); | ||||
|     } | ||||
|  | ||||
|     async getObjectsByFilter(filter) { | ||||
|         let objects = []; | ||||
|  | ||||
|         let url = `${this.url}/_find`; | ||||
|         let body = {}; | ||||
|  | ||||
|         if (filter) { | ||||
|             body = JSON.stringify(filter); | ||||
|         } | ||||
|  | ||||
|         const response = await fetch(url, { | ||||
|             method: "POST", | ||||
|             headers: { | ||||
|                 "Content-Type": "application/json" | ||||
|             }, | ||||
|             body | ||||
|         }); | ||||
|  | ||||
|         const reader = response.body.getReader(); | ||||
|         let completed = false; | ||||
|         let decoder = new TextDecoder("utf-8"); | ||||
|         let decodedChunk = ''; | ||||
|         while (!completed) { | ||||
|             const {done, value} = await reader.read(); | ||||
|             //done is true when we lose connection with the provider | ||||
|             if (done) { | ||||
|                 completed = true; | ||||
|             } | ||||
|  | ||||
|             if (value) { | ||||
|                 let chunk = new Uint8Array(value.length); | ||||
|                 chunk.set(value, 0); | ||||
|                 const partial = decoder.decode(chunk, {stream: !completed}); | ||||
|                 decodedChunk = decodedChunk + partial; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             const json = JSON.parse(decodedChunk); | ||||
|             if (json) { | ||||
|                 let docs = json.docs; | ||||
|                 docs.forEach(doc => { | ||||
|                     let object = this.getModel(doc); | ||||
|                     if (object) { | ||||
|                         objects.push(object); | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         } catch (e) { | ||||
|             //do nothing | ||||
|         } | ||||
|  | ||||
|         return objects; | ||||
|     } | ||||
|  | ||||
|     observe(identifier, callback) { | ||||
|         if (!this.observeEnabled) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const keyString = this.openmct.objects.makeKeyString(identifier); | ||||
|         this.observers[keyString] = this.observers[keyString] || []; | ||||
|         this.observers[keyString].push(callback); | ||||
|  | ||||
|         return () => { | ||||
|             this.observers[keyString] = this.observers[keyString].filter(observer => observer !== callback); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     abortGetChanges() { | ||||
|         if (this.controller) { | ||||
|             this.controller.abort(); | ||||
|             this.controller = undefined; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     async observeObjectChanges(filter) { | ||||
|         let intermediateResponse = this.getIntermediateResponse(); | ||||
|  | ||||
|         if (!this.observeEnabled) { | ||||
|             intermediateResponse.reject('Observe for changes is disabled'); | ||||
|         } | ||||
|  | ||||
|         const controller = new AbortController(); | ||||
|         const signal = controller.signal; | ||||
|  | ||||
|         if (this.controller) { | ||||
|             this.abortGetChanges(); | ||||
|         } | ||||
|  | ||||
|         this.controller = controller; | ||||
|         // feed=continuous maintains an indefinitely open connection with a keep-alive of HEARTBEAT milliseconds until this client closes the connection | ||||
|         // style=main_only returns only the current winning revision of the document | ||||
|         let url = `${this.url}/_changes?feed=continuous&style=main_only&heartbeat=${HEARTBEAT}`; | ||||
|  | ||||
|         let body = {}; | ||||
|         if (filter) { | ||||
|             url = `${url}&filter=_selector`; | ||||
|             body = JSON.stringify(filter); | ||||
|         } | ||||
|  | ||||
|         const response = await fetch(url, { | ||||
|             method: 'POST', | ||||
|             signal, | ||||
|             headers: { | ||||
|                 "Content-Type": 'application/json' | ||||
|             }, | ||||
|             body | ||||
|         }); | ||||
|         const reader = response.body.getReader(); | ||||
|         let completed = false; | ||||
|  | ||||
|         while (!completed) { | ||||
|             const {done, value} = await reader.read(); | ||||
|             //done is true when we lose connection with the provider | ||||
|             if (done) { | ||||
|                 completed = true; | ||||
|             } | ||||
|  | ||||
|             if (value) { | ||||
|                 let chunk = new Uint8Array(value.length); | ||||
|                 chunk.set(value, 0); | ||||
|                 const decodedChunk = new TextDecoder("utf-8").decode(chunk).split('\n'); | ||||
|                 if (decodedChunk.length && decodedChunk[decodedChunk.length - 1] === '') { | ||||
|                     decodedChunk.forEach((doc, index) => { | ||||
|                         try { | ||||
|                             const object = JSON.parse(doc); | ||||
|                             object.identifier = { | ||||
|                                 namespace: this.namespace, | ||||
|                                 key: object.id | ||||
|                             }; | ||||
|                             let keyString = this.openmct.objects.makeKeyString(object.identifier); | ||||
|                             let observersForObject = this.observers[keyString]; | ||||
|  | ||||
|                             if (observersForObject) { | ||||
|                                 observersForObject.forEach(async (observer) => { | ||||
|                                     const updatedObject = await this.get(object.identifier); | ||||
|                                     observer(updatedObject); | ||||
|                                 }); | ||||
|                             } | ||||
|                         } catch (e) { | ||||
|                             //do nothing; | ||||
|                         } | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         } | ||||
|  | ||||
|         //We're done receiving from the provider. No more chunks. | ||||
|         intermediateResponse.resolve(true); | ||||
|  | ||||
|         return intermediateResponse.promise; | ||||
|  | ||||
|     } | ||||
|  | ||||
|     getIntermediateResponse() { | ||||
| @@ -132,7 +322,8 @@ export default class CouchObjectProvider { | ||||
|         this.enqueueObject(key, model, intermediateResponse); | ||||
|         this.objectQueue[key].pending = true; | ||||
|         const queued = this.objectQueue[key].dequeue(); | ||||
|         this.request(key, "PUT", new CouchDocument(key, queued.model)).then((response) => { | ||||
|         let document = new CouchDocument(key, queued.model); | ||||
|         this.request(key, "PUT", document).then((response) => { | ||||
|             this.checkResponse(response, queued.intermediateResponse); | ||||
|         }); | ||||
|  | ||||
| @@ -143,7 +334,8 @@ export default class CouchObjectProvider { | ||||
|         if (!this.objectQueue[key].pending) { | ||||
|             this.objectQueue[key].pending = true; | ||||
|             const queued = this.objectQueue[key].dequeue(); | ||||
|             this.request(key, "PUT", new CouchDocument(key, queued.model, this.objectQueue[key].rev)).then((response) => { | ||||
|             let document = new CouchDocument(key, queued.model, this.objectQueue[key].rev); | ||||
|             this.request(key, "PUT", document).then((response) => { | ||||
|                 this.checkResponse(response, queued.intermediateResponse); | ||||
|             }); | ||||
|         } | ||||
|   | ||||
| @@ -24,8 +24,9 @@ import CouchObjectProvider from './CouchObjectProvider'; | ||||
| const NAMESPACE = ''; | ||||
| const PERSISTENCE_SPACE = 'mct'; | ||||
|  | ||||
| export default function CouchPlugin(url) { | ||||
| export default function CouchPlugin(options) { | ||||
|     return function install(openmct) { | ||||
|         openmct.objects.addProvider(PERSISTENCE_SPACE, new CouchObjectProvider(openmct, url, NAMESPACE)); | ||||
|         install.couchProvider = new CouchObjectProvider(openmct, options, NAMESPACE); | ||||
|         openmct.objects.addProvider(PERSISTENCE_SPACE, install.couchProvider); | ||||
|     }; | ||||
| } | ||||
|   | ||||
| @@ -32,18 +32,42 @@ describe('the plugin', () => { | ||||
|     let child; | ||||
|     let provider; | ||||
|     let testPath = '/test/db'; | ||||
|     let options; | ||||
|     let mockIdentifierService; | ||||
|     let mockDomainObject; | ||||
|  | ||||
|     beforeEach((done) => { | ||||
|         mockDomainObject = { | ||||
|             identifier: { | ||||
|                 namespace: 'mct', | ||||
|                 namespace: '', | ||||
|                 key: 'some-value' | ||||
|             }, | ||||
|             type: 'mock-type' | ||||
|         }; | ||||
|         options = { | ||||
|             url: testPath, | ||||
|             filter: {}, | ||||
|             disableObserve: true | ||||
|         }; | ||||
|         openmct = createOpenMct(false); | ||||
|         openmct.install(new CouchPlugin(testPath)); | ||||
|  | ||||
|         spyOnBuiltins(['fetch'], window); | ||||
|  | ||||
|         openmct.$injector = jasmine.createSpyObj('$injector', ['get']); | ||||
|         mockIdentifierService = jasmine.createSpyObj( | ||||
|             'identifierService', | ||||
|             ['parse'] | ||||
|         ); | ||||
|         mockIdentifierService.parse.and.returnValue({ | ||||
|             getSpace: () => { | ||||
|                 return 'mct'; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         openmct.$injector.get.and.returnValue(mockIdentifierService); | ||||
|  | ||||
|         openmct.install(new CouchPlugin(options)); | ||||
|  | ||||
|         openmct.types.addType('mock-type', {creatable: true}); | ||||
|  | ||||
|         element = document.createElement('div'); | ||||
| @@ -57,62 +81,67 @@ describe('the plugin', () => { | ||||
|         spyOn(provider, 'get').and.callThrough(); | ||||
|         spyOn(provider, 'create').and.callThrough(); | ||||
|         spyOn(provider, 'update').and.callThrough(); | ||||
|  | ||||
|         spyOnBuiltins(['fetch'], window); | ||||
|         fetch.and.returnValue(Promise.resolve({ | ||||
|             json: () => { | ||||
|                 return { | ||||
|                     ok: true, | ||||
|                     _id: 'some-value', | ||||
|                     _rev: 1, | ||||
|                     model: {} | ||||
|                 }; | ||||
|             } | ||||
|         })); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|         return resetApplicationState(openmct); | ||||
|     }); | ||||
|  | ||||
|     it('gets an object', () => { | ||||
|         openmct.objects.get(mockDomainObject.identifier).then((result) => { | ||||
|             expect(result.identifier.key).toEqual(mockDomainObject.identifier.key); | ||||
|     describe('the provider', () => { | ||||
|         let mockPromise; | ||||
|         beforeEach(() => { | ||||
|             mockPromise = Promise.resolve({ | ||||
|                 json: () => { | ||||
|                     return { | ||||
|                         ok: true, | ||||
|                         _id: 'some-value', | ||||
|                         _rev: 1, | ||||
|                         model: {} | ||||
|                     }; | ||||
|                 } | ||||
|             }); | ||||
|             fetch.and.returnValue(mockPromise); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('creates an object', () => { | ||||
|         openmct.objects.save(mockDomainObject).then((result) => { | ||||
|             expect(provider.create).toHaveBeenCalled(); | ||||
|             expect(result).toBeTrue(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('updates an object', () => { | ||||
|         openmct.objects.save(mockDomainObject).then((result) => { | ||||
|             expect(result).toBeTrue(); | ||||
|             expect(provider.create).toHaveBeenCalled(); | ||||
|             openmct.objects.save(mockDomainObject).then((updatedResult) => { | ||||
|                 expect(updatedResult).toBeTrue(); | ||||
|                 expect(provider.update).toHaveBeenCalled(); | ||||
|         it('gets an object', () => { | ||||
|             openmct.objects.get(mockDomainObject.identifier).then((result) => { | ||||
|                 expect(result.identifier.key).toEqual(mockDomainObject.identifier.key); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('updates queued objects', () => { | ||||
|         let couchProvider = new CouchObjectProvider(openmct, 'http://localhost', ''); | ||||
|         let intermediateResponse = couchProvider.getIntermediateResponse(); | ||||
|         spyOn(couchProvider, 'updateQueued'); | ||||
|         couchProvider.enqueueObject(mockDomainObject.identifier.key, mockDomainObject, intermediateResponse); | ||||
|         couchProvider.objectQueue[mockDomainObject.identifier.key].updateRevision(1); | ||||
|         couchProvider.update(mockDomainObject); | ||||
|         expect(couchProvider.objectQueue[mockDomainObject.identifier.key].hasNext()).toBe(2); | ||||
|         couchProvider.checkResponse({ | ||||
|             ok: true, | ||||
|             rev: 2, | ||||
|             id: mockDomainObject.identifier.key | ||||
|         }, intermediateResponse); | ||||
|         it('creates an object', () => { | ||||
|             openmct.objects.save(mockDomainObject).then((result) => { | ||||
|                 expect(provider.create).toHaveBeenCalled(); | ||||
|                 expect(result).toBeTrue(); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         expect(couchProvider.updateQueued).toHaveBeenCalledTimes(2); | ||||
|         it('updates an object', () => { | ||||
|             openmct.objects.save(mockDomainObject).then((result) => { | ||||
|                 expect(result).toBeTrue(); | ||||
|                 expect(provider.create).toHaveBeenCalled(); | ||||
|                 openmct.objects.save(mockDomainObject).then((updatedResult) => { | ||||
|                     expect(updatedResult).toBeTrue(); | ||||
|                     expect(provider.update).toHaveBeenCalled(); | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         it('updates queued objects', () => { | ||||
|             let couchProvider = new CouchObjectProvider(openmct, options, ''); | ||||
|             let intermediateResponse = couchProvider.getIntermediateResponse(); | ||||
|             spyOn(couchProvider, 'updateQueued'); | ||||
|             couchProvider.enqueueObject(mockDomainObject.identifier.key, mockDomainObject, intermediateResponse); | ||||
|             couchProvider.objectQueue[mockDomainObject.identifier.key].updateRevision(1); | ||||
|             couchProvider.update(mockDomainObject); | ||||
|             expect(couchProvider.objectQueue[mockDomainObject.identifier.key].hasNext()).toBe(2); | ||||
|             couchProvider.checkResponse({ | ||||
|                 ok: true, | ||||
|                 rev: 2, | ||||
|                 id: mockDomainObject.identifier.key | ||||
|             }, intermediateResponse); | ||||
|  | ||||
|             expect(couchProvider.updateQueued).toHaveBeenCalledTimes(2); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										483
									
								
								src/plugins/plan/Plan.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										483
									
								
								src/plugins/plan/Plan.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,483 @@ | ||||
| <template> | ||||
| <div ref="plan" | ||||
|      class="c-plan c-timeline-holder" | ||||
| > | ||||
|     <template v-if="viewBounds && !options.compact"> | ||||
|         <swim-lane> | ||||
|             <template slot="label">{{ timeSystem.name }}</template> | ||||
|             <timeline-axis | ||||
|                 slot="object" | ||||
|                 :bounds="viewBounds" | ||||
|                 :time-system="timeSystem" | ||||
|                 :content-height="height" | ||||
|                 :rendering-engine="renderingEngine" | ||||
|             /> | ||||
|         </swim-lane> | ||||
|     </template> | ||||
|     <div ref="planHolder" | ||||
|          class="c-plan__contents u-contents" | ||||
|     > | ||||
|     </div> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import * as d3Scale from 'd3-scale'; | ||||
| import TimelineAxis from "../../ui/components/TimeSystemAxis.vue"; | ||||
| import SwimLane from "@/ui/components/swim-lane/SwimLane.vue"; | ||||
| import { getValidatedPlan } from "./util"; | ||||
| import Vue from "vue"; | ||||
|  | ||||
| //TODO: UI direction needed for the following property values | ||||
| const PADDING = 1; | ||||
| const OUTER_TEXT_PADDING = 12; | ||||
| const INNER_TEXT_PADDING = 17; | ||||
| const TEXT_LEFT_PADDING = 5; | ||||
| const ROW_PADDING = 12; | ||||
| const RESIZE_POLL_INTERVAL = 200; | ||||
| const ROW_HEIGHT = 25; | ||||
| const LINE_HEIGHT = 12; | ||||
| const MAX_TEXT_WIDTH = 300; | ||||
| const EDGE_ROUNDING = 5; | ||||
| const DEFAULT_COLOR = '#cc9922'; | ||||
|  | ||||
| export default { | ||||
|     components: { | ||||
|         TimelineAxis, | ||||
|         SwimLane | ||||
|     }, | ||||
|     inject: ['openmct', 'domainObject'], | ||||
|     props: { | ||||
|         options: { | ||||
|             type: Object, | ||||
|             default() { | ||||
|                 return { | ||||
|                     compact: false | ||||
|                 }; | ||||
|             } | ||||
|         }, | ||||
|         renderingEngine: { | ||||
|             type: String, | ||||
|             default() { | ||||
|                 return 'svg'; | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
|         return { | ||||
|             viewBounds: undefined, | ||||
|             timeSystem: undefined, | ||||
|             height: 0 | ||||
|         }; | ||||
|     }, | ||||
|     mounted() { | ||||
|         this.getPlanData(this.domainObject); | ||||
|  | ||||
|         this.canvas = this.$refs.plan.appendChild(document.createElement('canvas')); | ||||
|         this.canvas.height = 0; | ||||
|         this.canvasContext = this.canvas.getContext('2d'); | ||||
|  | ||||
|         this.setDimensions(); | ||||
|         this.updateViewBounds(); | ||||
|         this.openmct.time.on("timeSystem", this.setScaleAndPlotActivities); | ||||
|         this.openmct.time.on("bounds", this.updateViewBounds); | ||||
|         this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL); | ||||
|         this.unlisten = this.openmct.objects.observe(this.domainObject, '*', this.observeForChanges); | ||||
|     }, | ||||
|     beforeDestroy() { | ||||
|         clearInterval(this.resizeTimer); | ||||
|         this.openmct.time.off("timeSystem", this.setScaleAndPlotActivities); | ||||
|         this.openmct.time.off("bounds", this.updateViewBounds); | ||||
|         if (this.unlisten) { | ||||
|             this.unlisten(); | ||||
|         } | ||||
|     }, | ||||
|     methods: { | ||||
|         observeForChanges(mutatedObject) { | ||||
|             this.getPlanData(mutatedObject); | ||||
|             this.setScaleAndPlotActivities(); | ||||
|         }, | ||||
|         resize() { | ||||
|             let clientWidth = this.getClientWidth(); | ||||
|             if (clientWidth !== this.width) { | ||||
|                 this.setDimensions(); | ||||
|                 this.updateViewBounds(); | ||||
|             } | ||||
|         }, | ||||
|         getClientWidth() { | ||||
|             let clientWidth = this.$refs.plan.clientWidth; | ||||
|  | ||||
|             if (!clientWidth) { | ||||
|                 //this is a hack - need a better way to find the parent of this component | ||||
|                 let parent = this.openmct.layout.$refs.browseObject.$el; | ||||
|                 if (parent) { | ||||
|                     clientWidth = parent.getBoundingClientRect().width; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return clientWidth - 200; | ||||
|         }, | ||||
|         getPlanData(domainObject) { | ||||
|             this.planData = getValidatedPlan(domainObject); | ||||
|         }, | ||||
|         updateViewBounds() { | ||||
|             this.viewBounds = this.openmct.time.bounds(); | ||||
|             if (this.timeSystem === undefined) { | ||||
|                 this.timeSystem = this.openmct.time.timeSystem(); | ||||
|             } | ||||
|  | ||||
|             this.setScaleAndPlotActivities(); | ||||
|         }, | ||||
|         setScaleAndPlotActivities(timeSystem) { | ||||
|             if (timeSystem !== undefined) { | ||||
|                 this.timeSystem = timeSystem; | ||||
|             } | ||||
|  | ||||
|             this.setScale(this.timeSystem); | ||||
|             this.clearPreviousActivities(); | ||||
|             if (this.xScale) { | ||||
|                 this.calculatePlanLayout(); | ||||
|                 this.drawPlan(); | ||||
|             } | ||||
|         }, | ||||
|         clearPreviousActivities() { | ||||
|             let activities = this.$el.querySelectorAll(".c-plan__contents > div"); | ||||
|             activities.forEach(activity => activity.remove()); | ||||
|         }, | ||||
|         setDimensions() { | ||||
|             const planHolder = this.$refs.plan; | ||||
|             this.width = this.getClientWidth(); | ||||
|  | ||||
|             this.height = Math.round(planHolder.getBoundingClientRect().height); | ||||
|         }, | ||||
|         setScale(timeSystem) { | ||||
|             if (!this.width) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (timeSystem === undefined) { | ||||
|                 timeSystem = this.openmct.time.timeSystem(); | ||||
|             } | ||||
|  | ||||
|             if (timeSystem.isUTCBased) { | ||||
|                 this.xScale = d3Scale.scaleUtc(); | ||||
|                 this.xScale.domain( | ||||
|                     [new Date(this.viewBounds.start), new Date(this.viewBounds.end)] | ||||
|                 ); | ||||
|             } else { | ||||
|                 this.xScale = d3Scale.scaleLinear(); | ||||
|                 this.xScale.domain( | ||||
|                     [this.viewBounds.start, this.viewBounds.end] | ||||
|                 ); | ||||
|             } | ||||
|  | ||||
|             this.xScale.range([PADDING, this.width - PADDING * 2]); | ||||
|         }, | ||||
|         isActivityInBounds(activity) { | ||||
|             return (activity.start < this.viewBounds.end) && (activity.end > this.viewBounds.start); | ||||
|         }, | ||||
|         getTextWidth(name) { | ||||
|             let metrics = this.canvasContext.measureText(name); | ||||
|  | ||||
|             return parseInt(metrics.width, 10); | ||||
|         }, | ||||
|         sortFn(a, b) { | ||||
|             const numA = parseInt(a, 10); | ||||
|             const numB = parseInt(b, 10); | ||||
|             if (numA > numB) { | ||||
|                 return 1; | ||||
|             } | ||||
|  | ||||
|             if (numA < numB) { | ||||
|                 return -1; | ||||
|             } | ||||
|  | ||||
|             return 0; | ||||
|         }, | ||||
|         // Get the row where the next activity will land. | ||||
|         getRowForActivity(rectX, width, activitiesByRow) { | ||||
|             let currentRow; | ||||
|             let sortedActivityRows = Object.keys(activitiesByRow).sort(this.sortFn); | ||||
|  | ||||
|             function getOverlap(rects) { | ||||
|                 return rects.every(rect => { | ||||
|                     const { start, end } = rect; | ||||
|                     const calculatedEnd = rectX + width; | ||||
|                     const hasOverlap = (rectX >= start && rectX <= end) || (calculatedEnd >= start && calculatedEnd <= end) || (rectX <= start && calculatedEnd >= end); | ||||
|  | ||||
|                     return !hasOverlap; | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             for (let i = 0; i < sortedActivityRows.length; i++) { | ||||
|                 let row = sortedActivityRows[i]; | ||||
|                 if (getOverlap(activitiesByRow[row])) { | ||||
|                     currentRow = row; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (currentRow === undefined && sortedActivityRows.length) { | ||||
|                 let row = parseInt(sortedActivityRows[sortedActivityRows.length - 1], 10); | ||||
|                 currentRow = row + ROW_HEIGHT + ROW_PADDING; | ||||
|             } | ||||
|  | ||||
|             return (currentRow || 0); | ||||
|         }, | ||||
|         calculatePlanLayout() { | ||||
|             let groups = Object.keys(this.planData); | ||||
|             this.groupActivities = {}; | ||||
|  | ||||
|             groups.forEach((key, index) => { | ||||
|                 let activitiesByRow = {}; | ||||
|                 let currentRow = 0; | ||||
|  | ||||
|                 let activities = this.planData[key]; | ||||
|                 activities.forEach((activity) => { | ||||
|                     if (this.isActivityInBounds(activity)) { | ||||
|                         const currentStart = Math.max(this.viewBounds.start, activity.start); | ||||
|                         const currentEnd = Math.min(this.viewBounds.end, activity.end); | ||||
|                         const rectX = this.xScale(currentStart); | ||||
|                         const rectY = this.xScale(currentEnd); | ||||
|                         const rectWidth = rectY - rectX; | ||||
|  | ||||
|                         const activityNameWidth = this.getTextWidth(activity.name) + TEXT_LEFT_PADDING; | ||||
|                         //TODO: Fix bug for SVG where the rectWidth is not proportional to the canvas measuredWidth of the text | ||||
|                         const activityNameFitsRect = (rectWidth >= activityNameWidth); | ||||
|                         const textStart = (activityNameFitsRect ? rectX : rectY) + TEXT_LEFT_PADDING; | ||||
|                         const color = activity.color || DEFAULT_COLOR; | ||||
|                         let textColor = ''; | ||||
|                         if (activity.textColor) { | ||||
|                             textColor = activity.textColor; | ||||
|                         } else if (activityNameFitsRect) { | ||||
|                             textColor = this.getContrastingColor(color); | ||||
|                         } | ||||
|  | ||||
|                         let textLines = this.getActivityDisplayText(this.canvasContext, activity.name, activityNameFitsRect); | ||||
|                         const textWidth = textStart + this.getTextWidth(textLines[0]) + TEXT_LEFT_PADDING; | ||||
|  | ||||
|                         if (activityNameFitsRect) { | ||||
|                             currentRow = this.getRowForActivity(rectX, rectWidth, activitiesByRow); | ||||
|                         } else { | ||||
|                             currentRow = this.getRowForActivity(rectX, textWidth, activitiesByRow); | ||||
|                         } | ||||
|  | ||||
|                         let textY = parseInt(currentRow, 10) + (activityNameFitsRect ? INNER_TEXT_PADDING : OUTER_TEXT_PADDING); | ||||
|  | ||||
|                         if (!activitiesByRow[currentRow]) { | ||||
|                             activitiesByRow[currentRow] = []; | ||||
|                         } | ||||
|  | ||||
|                         activitiesByRow[currentRow].push({ | ||||
|                             activity: { | ||||
|                                 color: color, | ||||
|                                 textColor: textColor, | ||||
|                                 name: activity.name, | ||||
|                                 exceeds: { | ||||
|                                     start: this.xScale(this.viewBounds.start) > this.xScale(activity.start), | ||||
|                                     end: this.xScale(this.viewBounds.end) < this.xScale(activity.end) | ||||
|                                 } | ||||
|                             }, | ||||
|                             textLines: textLines, | ||||
|                             textStart: textStart, | ||||
|                             textClass: activityNameFitsRect ? "" : "activity-label--outside-rect", | ||||
|                             textY: textY, | ||||
|                             start: rectX, | ||||
|                             end: activityNameFitsRect ? rectY : textStart + textWidth, | ||||
|                             rectWidth: rectWidth | ||||
|                         }); | ||||
|                     } | ||||
|                 }); | ||||
|                 this.groupActivities[key] = { | ||||
|                     heading: key, | ||||
|                     activitiesByRow | ||||
|                 }; | ||||
|             }); | ||||
|         }, | ||||
|         getActivityDisplayText(context, text, activityNameFitsRect) { | ||||
|         //TODO: If the activity start is less than viewBounds.start then the text should be cropped on the left/should be off-screen) | ||||
|             let words = text.split(' '); | ||||
|             let line = ''; | ||||
|             let activityText = []; | ||||
|             let rows = 1; | ||||
|  | ||||
|             for (let n = 0; (n < words.length) && (rows <= 2); n++) { | ||||
|                 let testLine = line + words[n] + ' '; | ||||
|                 let metrics = context.measureText(testLine); | ||||
|                 let testWidth = metrics.width; | ||||
|                 if (!activityNameFitsRect && (testWidth > MAX_TEXT_WIDTH && n > 0)) { | ||||
|                     activityText.push(line); | ||||
|                     line = words[n] + ' '; | ||||
|                     testLine = line + words[n] + ' '; | ||||
|                     rows = rows + 1; | ||||
|                 } | ||||
|  | ||||
|                 line = testLine; | ||||
|             } | ||||
|  | ||||
|             return activityText.length ? activityText : [line]; | ||||
|         }, | ||||
|         getGroupContainer(activityRows, heading) { | ||||
|             let svgHeight = 30; | ||||
|             let svgWidth = 200; | ||||
|  | ||||
|             const rows = Object.keys(activityRows); | ||||
|             const isNested = this.options.isChildObject; | ||||
|  | ||||
|             if (rows.length) { | ||||
|                 const lastActivityRow = rows[rows.length - 1]; | ||||
|                 svgHeight = parseInt(lastActivityRow, 10) + ROW_HEIGHT; | ||||
|                 svgWidth = this.width; | ||||
|             } | ||||
|  | ||||
|             let component = new Vue({ | ||||
|                 components: { | ||||
|                     SwimLane | ||||
|                 }, | ||||
|                 data() { | ||||
|                     return { | ||||
|                         heading, | ||||
|                         isNested, | ||||
|                         height: svgHeight, | ||||
|                         width: svgWidth | ||||
|                     }; | ||||
|                 }, | ||||
|                 template: `<swim-lane :is-nested="isNested"><template slot="label">{{heading}}</template><template slot="object"><svg :height="height" :width="width"></svg></template></swim-lane>` | ||||
|             }); | ||||
|  | ||||
|             this.$refs.planHolder.appendChild(component.$mount().$el); | ||||
|  | ||||
|             let groupLabel = component.$el.querySelector('div:nth-child(1)'); | ||||
|             let groupSVG = component.$el.querySelector('svg'); | ||||
|  | ||||
|             return { | ||||
|                 groupLabel, | ||||
|                 groupSVG | ||||
|             }; | ||||
|         }, | ||||
|         drawPlan() { | ||||
|  | ||||
|             Object.keys(this.groupActivities).forEach((group, index) => { | ||||
|                 const activitiesByRow = this.groupActivities[group].activitiesByRow; | ||||
|                 const heading = this.groupActivities[group].heading; | ||||
|                 const groupElements = this.getGroupContainer(activitiesByRow, heading); | ||||
|                 let groupSVG = groupElements.groupSVG; | ||||
|  | ||||
|                 let activityRows = Object.keys(activitiesByRow); | ||||
|                 if (activityRows.length <= 0) { | ||||
|                     this.plotNoItems(groupSVG); | ||||
|                 } | ||||
|  | ||||
|                 activityRows.forEach((row) => { | ||||
|                     const items = activitiesByRow[row]; | ||||
|                     items.forEach(item => { | ||||
|                     //TODO: Don't draw the left-border of the rectangle if the activity started before viewBounds.start | ||||
|                         this.plotActivity(item, parseInt(row, 10), groupSVG); | ||||
|                     }); | ||||
|                 }); | ||||
|  | ||||
|             }); | ||||
|         }, | ||||
|         plotNoItems(svgElement) { | ||||
|             let textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text'); | ||||
|             this.setNSAttributesForElement(textElement, { | ||||
|                 x: "10", | ||||
|                 y: "20", | ||||
|                 class: "activity-label--outside-rect" | ||||
|             }); | ||||
|             textElement.innerHTML = 'No activities within timeframe'; | ||||
|  | ||||
|             svgElement.appendChild(textElement); | ||||
|         }, | ||||
|         setNSAttributesForElement(element, attributes) { | ||||
|             Object.keys(attributes).forEach((key) => { | ||||
|                 element.setAttributeNS(null, key, attributes[key]); | ||||
|             }); | ||||
|         }, | ||||
|         // Experimental for now - unused | ||||
|         addForeignElement(svgElement, label, x, y) { | ||||
|             let foreign = document.createElementNS('http://www.w3.org/2000/svg', "foreignObject"); | ||||
|             this.setNSAttributesForElement(foreign, { | ||||
|                 width: String(MAX_TEXT_WIDTH), | ||||
|                 height: String(LINE_HEIGHT * 2), | ||||
|                 x: x, | ||||
|                 y: y | ||||
|             }); | ||||
|  | ||||
|             let textEl = document.createElement('div'); | ||||
|             let textNode = document.createTextNode(label); | ||||
|             textEl.appendChild(textNode); | ||||
|  | ||||
|             foreign.appendChild(textEl); | ||||
|  | ||||
|             svgElement.appendChild(foreign); | ||||
|         }, | ||||
|         plotActivity(item, row, svgElement) { | ||||
|             const activity = item.activity; | ||||
|             let width = item.rectWidth; | ||||
|             let rectElement = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); | ||||
|  | ||||
|             if (item.activity.exceeds.start) { | ||||
|                 width = width + EDGE_ROUNDING; | ||||
|             } | ||||
|  | ||||
|             if (item.activity.exceeds.end) { | ||||
|                 width = width + EDGE_ROUNDING; | ||||
|             } | ||||
|  | ||||
|             width = Math.max(width, 1); // Set width to a minimum of 1 | ||||
|  | ||||
|             // rx: don't round corners if the width of the rect is smaller than the rounding radius | ||||
|             this.setNSAttributesForElement(rectElement, { | ||||
|                 class: 'activity-bounds', | ||||
|                 x: item.activity.exceeds.start ? item.start - EDGE_ROUNDING : item.start, | ||||
|                 y: row, | ||||
|                 rx: (width < EDGE_ROUNDING * 2) ? 0 : EDGE_ROUNDING, | ||||
|                 width: width, | ||||
|                 height: String(ROW_HEIGHT), | ||||
|                 fill: activity.color | ||||
|             }); | ||||
|  | ||||
|             svgElement.appendChild(rectElement); | ||||
|  | ||||
|             item.textLines.forEach((line, index) => { | ||||
|                 let textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text'); | ||||
|                 this.setNSAttributesForElement(textElement, { | ||||
|                     class: `activity-label ${item.textClass}`, | ||||
|                     x: item.textStart, | ||||
|                     y: item.textY + (index * LINE_HEIGHT), | ||||
|                     fill: activity.textColor | ||||
|                 }); | ||||
|  | ||||
|                 const textNode = document.createTextNode(line); | ||||
|                 textElement.appendChild(textNode); | ||||
|                 svgElement.appendChild(textElement); | ||||
|             }); | ||||
|             // this.addForeignElement(svgElement, activity.name, item.textStart, item.textY - LINE_HEIGHT); | ||||
|         }, | ||||
|         cutHex(h, start, end) { | ||||
|             const hStr = (h.charAt(0) === '#') ? h.substring(1, 7) : h; | ||||
|  | ||||
|             return parseInt(hStr.substring(start, end), 16); | ||||
|         }, | ||||
|         getContrastingColor(hexColor) { | ||||
|             // https://codepen.io/davidhalford/pen/ywEva/ | ||||
|             // TODO: move this into a general utility function? | ||||
|             const cThreshold = 130; | ||||
|  | ||||
|             if (hexColor.indexOf('#') === -1) { | ||||
|                 // We weren't given a hex color | ||||
|                 return "#ff0000"; | ||||
|             } | ||||
|  | ||||
|             const hR = this.cutHex(hexColor, 0, 2); | ||||
|             const hG = this.cutHex(hexColor, 2, 4); | ||||
|             const hB = this.cutHex(hexColor, 4, 6); | ||||
|  | ||||
|             const cBrightness = ((hR * 299) + (hG * 587) + (hB * 114)) / 1000; | ||||
|  | ||||
|             return cBrightness > cThreshold ? "#000000" : "#ffffff"; | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| </script> | ||||
							
								
								
									
										77
									
								
								src/plugins/plan/PlanViewProvider.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								src/plugins/plan/PlanViewProvider.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2020, 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 Plan from './Plan.vue'; | ||||
| import Vue from 'vue'; | ||||
|  | ||||
| export default function PlanViewProvider(openmct) { | ||||
|     function isCompactView(objectPath) { | ||||
|         return objectPath.find(object => object.type === 'time-strip') !== undefined; | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|         key: 'plan.view', | ||||
|         name: 'Plan', | ||||
|         cssClass: 'icon-calendar', | ||||
|         canView(domainObject) { | ||||
|             return domainObject.type === 'plan'; | ||||
|         }, | ||||
|  | ||||
|         canEdit(domainObject) { | ||||
|             return domainObject.type === 'plan'; | ||||
|         }, | ||||
|  | ||||
|         view: function (domainObject, objectPath) { | ||||
|             let component; | ||||
|  | ||||
|             return { | ||||
|                 show: function (element) { | ||||
|                     let isCompact = isCompactView(objectPath); | ||||
|  | ||||
|                     component = new Vue({ | ||||
|                         el: element, | ||||
|                         components: { | ||||
|                             Plan | ||||
|                         }, | ||||
|                         provide: { | ||||
|                             openmct, | ||||
|                             domainObject | ||||
|                         }, | ||||
|                         data() { | ||||
|                             return { | ||||
|                                 options: { | ||||
|                                     compact: isCompact, | ||||
|                                     isChildObject: isCompact | ||||
|                                 } | ||||
|                             }; | ||||
|                         }, | ||||
|                         template: '<plan :options="options"></plan>' | ||||
|                     }); | ||||
|                 }, | ||||
|                 destroy: function () { | ||||
|                     component.$destroy(); | ||||
|                     component = undefined; | ||||
|                 } | ||||
|             }; | ||||
|         } | ||||
|     }; | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/plugins/plan/plan.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/plugins/plan/plan.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| .c-plan { | ||||
|   svg { | ||||
|     text-rendering: geometricPrecision; | ||||
|  | ||||
|     text { | ||||
|       stroke: none; | ||||
|     } | ||||
|  | ||||
|       .activity-label { | ||||
|           &--outside-rect { | ||||
|               fill: $colorBodyFg !important; | ||||
|           } | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   canvas { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										49
									
								
								src/plugins/plan/plugin.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/plugins/plan/plugin.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2020, 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 PlanViewProvider from './PlanViewProvider'; | ||||
|  | ||||
| export default function () { | ||||
|     return function install(openmct) { | ||||
|         openmct.types.addType('plan', { | ||||
|             name: 'Plan', | ||||
|             key: 'plan', | ||||
|             description: 'A plan', | ||||
|             creatable: true, | ||||
|             cssClass: 'icon-calendar', | ||||
|             form: [ | ||||
|                 { | ||||
|                     name: 'Upload Plan (JSON File)', | ||||
|                     key: 'selectFile', | ||||
|                     control: 'file-input', | ||||
|                     required: true, | ||||
|                     text: 'Select File', | ||||
|                     type: 'application/json' | ||||
|                 } | ||||
|             ], | ||||
|             initialize: function (domainObject) { | ||||
|             } | ||||
|         }); | ||||
|         openmct.objectViews.addProvider(new PlanViewProvider(openmct)); | ||||
|     }; | ||||
| } | ||||
|  | ||||
							
								
								
									
										166
									
								
								src/plugins/plan/pluginSpec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								src/plugins/plan/pluginSpec.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,166 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2020, 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 {createOpenMct, resetApplicationState} from "utils/testing"; | ||||
| import PlanPlugin from "../plan/plugin"; | ||||
| import Vue from 'vue'; | ||||
|  | ||||
| describe('the plugin', function () { | ||||
|     let planDefinition; | ||||
|     let element; | ||||
|     let child; | ||||
|     let openmct; | ||||
|  | ||||
|     beforeEach((done) => { | ||||
|         const appHolder = document.createElement('div'); | ||||
|         appHolder.style.width = '640px'; | ||||
|         appHolder.style.height = '480px'; | ||||
|  | ||||
|         openmct = createOpenMct(); | ||||
|         openmct.install(new PlanPlugin()); | ||||
|  | ||||
|         planDefinition = openmct.types.get('plan').definition; | ||||
|  | ||||
|         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.time.timeSystem('utc', { | ||||
|             start: 1597160002854, | ||||
|             end: 1597181232854 | ||||
|         }); | ||||
|         openmct.on('start', done); | ||||
|         openmct.start(appHolder); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|         return resetApplicationState(openmct); | ||||
|     }); | ||||
|  | ||||
|     let mockPlanObject = { | ||||
|         name: 'Plan', | ||||
|         key: 'plan', | ||||
|         creatable: true | ||||
|     }; | ||||
|  | ||||
|     it('defines a plan object type with the correct key', () => { | ||||
|         expect(planDefinition.key).toEqual(mockPlanObject.key); | ||||
|     }); | ||||
|  | ||||
|     it('is creatable', () => { | ||||
|         expect(planDefinition.creatable).toEqual(mockPlanObject.creatable); | ||||
|     }); | ||||
|  | ||||
|     describe('the plan view', () => { | ||||
|  | ||||
|         it('provides a plan view', () => { | ||||
|             const testViewObject = { | ||||
|                 id: "test-object", | ||||
|                 type: "plan" | ||||
|             }; | ||||
|  | ||||
|             const applicableViews = openmct.objectViews.get(testViewObject, []); | ||||
|             let planView = applicableViews.find((viewProvider) => viewProvider.key === 'plan.view'); | ||||
|             expect(planView).toBeDefined(); | ||||
|         }); | ||||
|  | ||||
|     }); | ||||
|  | ||||
|     describe('the plan view displays activities', () => { | ||||
|         let planDomainObject; | ||||
|         let mockObjectPath = [ | ||||
|             { | ||||
|                 identifier: { | ||||
|                     key: 'test', | ||||
|                     namespace: '' | ||||
|                 }, | ||||
|                 type: 'time-strip', | ||||
|                 name: 'Test Parent Object' | ||||
|             } | ||||
|         ]; | ||||
|         let planView; | ||||
|  | ||||
|         beforeEach((done) => { | ||||
|             planDomainObject = { | ||||
|                 identifier: { | ||||
|                     key: 'test-object', | ||||
|                     namespace: '' | ||||
|                 }, | ||||
|                 type: 'plan', | ||||
|                 id: "test-object", | ||||
|                 selectFile: { | ||||
|                     body: JSON.stringify({ | ||||
|                         "TEST-GROUP": [ | ||||
|                             { | ||||
|                                 "name": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", | ||||
|                                 "start": 1597170002854, | ||||
|                                 "end": 1597171032854, | ||||
|                                 "type": "TEST-GROUP", | ||||
|                                 "color": "fuchsia", | ||||
|                                 "textColor": "black" | ||||
|                             }, | ||||
|                             { | ||||
|                                 "name": "Sed ut perspiciatis", | ||||
|                                 "start": 1597171132854, | ||||
|                                 "end": 1597171232854, | ||||
|                                 "type": "TEST-GROUP", | ||||
|                                 "color": "fuchsia", | ||||
|                                 "textColor": "black" | ||||
|                             } | ||||
|                         ] | ||||
|                     }) | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             const applicableViews = openmct.objectViews.get(planDomainObject, []); | ||||
|             planView = applicableViews.find((viewProvider) => viewProvider.key === 'plan.view'); | ||||
|             let view = planView.view(planDomainObject, mockObjectPath); | ||||
|             view.show(child, true); | ||||
|  | ||||
|             return Vue.nextTick().then(() => { | ||||
|                 done(); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         it('loads activities into the view', () => { | ||||
|             const svgEls = element.querySelectorAll('.c-plan__contents svg'); | ||||
|             expect(svgEls.length).toEqual(1); | ||||
|         }); | ||||
|  | ||||
|         it('displays the group label', () => { | ||||
|             const labelEl = element.querySelector('.c-plan__contents .c-object-label .c-object-label__name'); | ||||
|             expect(labelEl.innerHTML).toEqual('TEST-GROUP'); | ||||
|         }); | ||||
|  | ||||
|         it('displays the activities and their labels', () => { | ||||
|             const rectEls = element.querySelectorAll('.c-plan__contents rect'); | ||||
|             expect(rectEls.length).toEqual(2); | ||||
|             const textEls = element.querySelectorAll('.c-plan__contents text'); | ||||
|             expect(textEls.length).toEqual(3); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
| }); | ||||
							
								
								
									
										15
									
								
								src/plugins/plan/util.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/plugins/plan/util.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| export function getValidatedPlan(domainObject) { | ||||
|     let body = domainObject.selectFile.body; | ||||
|     let json = {}; | ||||
|     if (typeof body === 'string') { | ||||
|         try { | ||||
|             json = JSON.parse(body); | ||||
|         } catch (e) { | ||||
|             return json; | ||||
|         } | ||||
|     } else { | ||||
|         json = body; | ||||
|     } | ||||
|  | ||||
|     return json; | ||||
| } | ||||
| @@ -413,6 +413,21 @@ define([ | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const isPinchToZoom = event.ctrlKey === true; | ||||
|         let isZoomIn = event.wheelDelta < 0; | ||||
|         let isZoomOut = event.wheelDelta >= 0; | ||||
|  | ||||
|         //Flip the zoom direction if this is pinch to zoom | ||||
|         if (isPinchToZoom) { | ||||
|             if (isZoomIn === true) { | ||||
|                 isZoomOut = true; | ||||
|                 isZoomIn = false; | ||||
|             } else if (isZoomOut === true) { | ||||
|                 isZoomIn = true; | ||||
|                 isZoomOut = false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         let xDisplayRange = this.$scope.xAxis.get('displayRange'); | ||||
|         let yDisplayRange = this.$scope.yAxis.get('displayRange'); | ||||
|  | ||||
| @@ -445,7 +460,7 @@ define([ | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         if (event.wheelDelta < 0) { | ||||
|         if (isZoomIn) { | ||||
|  | ||||
|             this.$scope.xAxis.set('displayRange', { | ||||
|                 min: xDisplayRange.min + ((xAxisDist * ZOOM_AMT) * xAxisMinDist), | ||||
| @@ -456,7 +471,7 @@ define([ | ||||
|                 min: yDisplayRange.min + ((yAxisDist * ZOOM_AMT) * yAxisMinDist), | ||||
|                 max: yDisplayRange.max - ((yAxisDist * ZOOM_AMT) * yAxisMaxDist) | ||||
|             }); | ||||
|         } else if (event.wheelDelta >= 0) { | ||||
|         } else if (isZoomOut) { | ||||
|  | ||||
|             this.$scope.xAxis.set('displayRange', { | ||||
|                 min: xDisplayRange.min - ((xAxisDist * ZOOM_AMT) * xAxisMinDist), | ||||
|   | ||||
| @@ -24,23 +24,28 @@ import Plot from '../single/Plot.vue'; | ||||
| import Vue from 'vue'; | ||||
|  | ||||
| export default function OverlayPlotViewProvider(openmct) { | ||||
|     function isCompactView(objectPath) { | ||||
|         return objectPath.find(object => object.type === 'time-strip'); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|         key: 'plot-overlay', | ||||
|         name: 'Overlay Plot', | ||||
|         cssClass: 'icon-telemetry', | ||||
|         canView(domainObject) { | ||||
|             return domainObject.type === 'telemetry.plot.overlay'; | ||||
|         canView(domainObject, objectPath) { | ||||
|             return isCompactView(objectPath) && domainObject.type === 'telemetry.plot.overlay'; | ||||
|         }, | ||||
|  | ||||
|         canEdit(domainObject) { | ||||
|             return domainObject.type === 'telemetry.plot.overlay'; | ||||
|         canEdit(domainObject, objectPath) { | ||||
|             return isCompactView(objectPath) && domainObject.type === 'telemetry.plot.overlay'; | ||||
|         }, | ||||
|  | ||||
|         view: function (domainObject) { | ||||
|         view: function (domainObject, objectPath) { | ||||
|             let component; | ||||
|  | ||||
|             return { | ||||
|                 show: function (element) { | ||||
|                     let isCompact = isCompactView(objectPath); | ||||
|                     component = new Vue({ | ||||
|                         el: element, | ||||
|                         components: { | ||||
| @@ -50,7 +55,14 @@ export default function OverlayPlotViewProvider(openmct) { | ||||
|                             openmct, | ||||
|                             domainObject | ||||
|                         }, | ||||
|                         template: '<plot></plot>' | ||||
|                         data() { | ||||
|                             return { | ||||
|                                 options: { | ||||
|                                     compact: isCompact | ||||
|                                 } | ||||
|                             }; | ||||
|                         }, | ||||
|                         template: '<plot :options="options"></plot>' | ||||
|                     }); | ||||
|                 }, | ||||
|                 destroy: function () { | ||||
|   | ||||
| @@ -50,7 +50,7 @@ | ||||
|                     ></span> | ||||
|                 </div> | ||||
|  | ||||
|                 <mct-ticks v-show="gridLines" | ||||
|                 <mct-ticks v-show="gridLines && !options.compact" | ||||
|                            :axis-type="'xAxis'" | ||||
|                            :position="'right'" | ||||
|                            @plotTickWidth="onTickWidthChange" | ||||
| @@ -113,7 +113,7 @@ | ||||
|                 > | ||||
|                 </div> | ||||
|             </div> | ||||
|             <x-axis v-if="config.series.models.length > 0" | ||||
|             <x-axis v-if="config.series.models.length > 0 && !options.compact" | ||||
|                     :series-model="config.series.models[0]" | ||||
|             /> | ||||
|  | ||||
| @@ -146,6 +146,14 @@ export default { | ||||
|     }, | ||||
|     inject: ['openmct', 'domainObject'], | ||||
|     props: { | ||||
|         options: { | ||||
|             type: Object, | ||||
|             default() { | ||||
|                 return { | ||||
|                     compact: false | ||||
|                 }; | ||||
|             } | ||||
|         }, | ||||
|         gridLines: { | ||||
|             type: Boolean, | ||||
|             default() { | ||||
| @@ -885,6 +893,9 @@ export default { | ||||
|             if (this.filterObserver) { | ||||
|                 this.filterObserver(); | ||||
|             } | ||||
|  | ||||
|             this.openmct.time.off('bounds', this.updateDisplayBounds); | ||||
|             this.openmct.objectViews.off('clearData', this.clearData); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -76,7 +76,7 @@ | ||||
|  | ||||
| <script> | ||||
| import eventHelpers from "./lib/eventHelpers"; | ||||
| import { ticks, commonPrefix, commonSuffix } from "./tickUtils"; | ||||
| import { ticks, getFormattedTicks } from "./tickUtils"; | ||||
| import configStore from "./configuration/configStore"; | ||||
|  | ||||
| export default { | ||||
| @@ -208,29 +208,7 @@ export default { | ||||
|                     step: newTicks[1] - newTicks[0] | ||||
|                 }; | ||||
|  | ||||
|                 newTicks = newTicks | ||||
|                     .map(function (tickValue) { | ||||
|                         return { | ||||
|                             value: tickValue, | ||||
|                             text: format(tickValue) | ||||
|                         }; | ||||
|                     }, this); | ||||
|  | ||||
|                 if (newTicks.length && typeof newTicks[0].text === 'string') { | ||||
|                     const tickText = newTicks.map(function (t) { | ||||
|                         return t.text; | ||||
|                     }); | ||||
|                     const prefix = tickText.reduce(commonPrefix); | ||||
|                     const suffix = tickText.reduce(commonSuffix); | ||||
|                     newTicks.forEach(function (t) { | ||||
|                         t.fullText = t.text; | ||||
|                         if (suffix.length) { | ||||
|                             t.text = t.text.slice(prefix.length, -suffix.length); | ||||
|                         } else { | ||||
|                             t.text = t.text.slice(prefix.length); | ||||
|                         } | ||||
|                     }); | ||||
|                 } | ||||
|                 newTicks = getFormattedTicks(newTicks, format); | ||||
|  | ||||
|                 this.ticks = newTicks; | ||||
|                 this.shouldCheckWidth = true; | ||||
|   | ||||
| @@ -23,7 +23,9 @@ | ||||
| <div ref="plotWrapper" | ||||
|      class="c-plot holder holder-plot has-control-bar" | ||||
| > | ||||
|     <div class="c-control-bar"> | ||||
|     <div v-if="!options.compact" | ||||
|          class="c-control-bar" | ||||
|     > | ||||
|         <span class="c-button-set c-button-set--strip-h"> | ||||
|             <button class="c-button icon-download" | ||||
|                     title="Export This View's Data as PNG" | ||||
| @@ -60,6 +62,7 @@ | ||||
|         ></div> | ||||
|         <mct-plot :grid-lines="gridLines" | ||||
|                   :cursor-guide="cursorGuide" | ||||
|                   :options="options" | ||||
|                   @loadingUpdated="loadingUpdated" | ||||
|         /> | ||||
|     </div> | ||||
| @@ -75,12 +78,22 @@ export default { | ||||
|         MctPlot | ||||
|     }, | ||||
|     inject: ['openmct', 'domainObject'], | ||||
|     props: { | ||||
|         options: { | ||||
|             type: Object, | ||||
|             default() { | ||||
|                 return { | ||||
|                     compact: false | ||||
|                 }; | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
|         return { | ||||
|             //Don't think we need this as it appears to be stacked plot specific | ||||
|             // hideExportButtons: false | ||||
|             cursorGuide: false, | ||||
|             gridLines: true, | ||||
|             gridLines: !this.options.compact, | ||||
|             loading: false | ||||
|         }; | ||||
|     }, | ||||
|   | ||||
| @@ -39,19 +39,24 @@ export default function PlotViewProvider(openmct) { | ||||
|             && metadata.valuesForHints(['domain']).length > 0); | ||||
|     } | ||||
|  | ||||
|     function isCompactView(objectPath) { | ||||
|         return objectPath.find(object => object.type === 'time-strip'); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|         key: 'plot-single', | ||||
|         key: 'plot-simple', | ||||
|         name: 'Plot', | ||||
|         cssClass: 'icon-telemetry', | ||||
|         canView(domainObject) { | ||||
|             return domainObject.type === 'plot-single' || hasTelemetry(domainObject); | ||||
|         canView(domainObject, objectPath) { | ||||
|             return isCompactView(objectPath) && hasTelemetry(domainObject, openmct); | ||||
|         }, | ||||
|  | ||||
|         view: function (domainObject) { | ||||
|         view: function (domainObject, objectPath) { | ||||
|             let component; | ||||
|  | ||||
|             return { | ||||
|                 show: function (element) { | ||||
|                     let isCompact = isCompactView(objectPath); | ||||
|                     component = new Vue({ | ||||
|                         el: element, | ||||
|                         components: { | ||||
| @@ -61,7 +66,14 @@ export default function PlotViewProvider(openmct) { | ||||
|                             openmct, | ||||
|                             domainObject | ||||
|                         }, | ||||
|                         template: '<plot></plot>' | ||||
|                         data() { | ||||
|                             return { | ||||
|                                 options: { | ||||
|                                     compact: isCompact | ||||
|                                 } | ||||
|                             }; | ||||
|                         }, | ||||
|                         template: '<plot :options="options"></plot>' | ||||
|                     }); | ||||
|                 }, | ||||
|                 destroy: function () { | ||||
|   | ||||
| @@ -33,8 +33,27 @@ describe("the plugin", function () { | ||||
|     let openmct; | ||||
|     let telemetryPromise; | ||||
|     let cleanupFirst; | ||||
|     let mockObjectPath; | ||||
|  | ||||
|     beforeEach((done) => { | ||||
|         mockObjectPath = [ | ||||
|             { | ||||
|                 name: 'mock folder', | ||||
|                 type: 'fake-folder', | ||||
|                 identifier: { | ||||
|                     key: 'mock-folder', | ||||
|                     namespace: '' | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 name: 'mock parent folder', | ||||
|                 type: 'time-strip', | ||||
|                 identifier: { | ||||
|                     key: 'mock-parent-folder', | ||||
|                     namespace: '' | ||||
|                 } | ||||
|             } | ||||
|         ]; | ||||
|         const testTelemetry = [ | ||||
|             { | ||||
|                 'utc': 1, | ||||
| @@ -134,8 +153,8 @@ describe("the plugin", function () { | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             const applicableViews = openmct.objectViews.get(testTelemetryObject); | ||||
|             let plotView = applicableViews.find((viewProvider) => viewProvider.key === "plot-single"); | ||||
|             const applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath); | ||||
|             let plotView = applicableViews.find((viewProvider) => viewProvider.key === "plot-simple"); | ||||
|             expect(plotView).toBeDefined(); | ||||
|         }); | ||||
|  | ||||
| @@ -150,7 +169,7 @@ describe("the plugin", function () { | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             const applicableViews = openmct.objectViews.get(testTelemetryObject); | ||||
|             const applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath); | ||||
|             let plotView = applicableViews.find((viewProvider) => viewProvider.key === "plot-overlay"); | ||||
|             expect(plotView).toBeDefined(); | ||||
|         }); | ||||
| @@ -166,7 +185,7 @@ describe("the plugin", function () { | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             const applicableViews = openmct.objectViews.get(testTelemetryObject); | ||||
|             const applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath); | ||||
|             let plotView = applicableViews.find((viewProvider) => viewProvider.key === "plot-stacked"); | ||||
|             expect(plotView).toBeDefined(); | ||||
|         }); | ||||
| @@ -218,8 +237,8 @@ describe("the plugin", function () { | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             applicableViews = openmct.objectViews.get(testTelemetryObject); | ||||
|             plotViewProvider = applicableViews.find((viewProvider) => viewProvider.key === "plot-single"); | ||||
|             applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath); | ||||
|             plotViewProvider = applicableViews.find((viewProvider) => viewProvider.key === "plot-simple"); | ||||
|             plotView = plotViewProvider.view(testTelemetryObject, [testTelemetryObject]); | ||||
|             plotView.show(child, true); | ||||
|  | ||||
|   | ||||
| @@ -87,3 +87,31 @@ export function commonSuffix(a, b) { | ||||
|  | ||||
|     return a.slice(a.length - breakpoint); | ||||
| } | ||||
|  | ||||
| export function getFormattedTicks(newTicks, format) { | ||||
|     newTicks = newTicks | ||||
|         .map(function (tickValue) { | ||||
|             return { | ||||
|                 value: tickValue, | ||||
|                 text: format(tickValue) | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|     if (newTicks.length && typeof newTicks[0].text === 'string') { | ||||
|         const tickText = newTicks.map(function (t) { | ||||
|             return t.text; | ||||
|         }); | ||||
|         const prefix = tickText.reduce(commonPrefix); | ||||
|         const suffix = tickText.reduce(commonSuffix); | ||||
|         newTicks.forEach(function (t) { | ||||
|             t.fullText = t.text; | ||||
|             if (suffix.length) { | ||||
|                 t.text = t.text.slice(prefix.length, -suffix.length); | ||||
|             } else { | ||||
|                 t.text = t.text.slice(prefix.length); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     return newTicks; | ||||
| } | ||||
|   | ||||
| @@ -22,7 +22,7 @@ | ||||
|  | ||||
| <template> | ||||
| <div class="c-plot c-plot--stacked holder holder-plot has-control-bar"> | ||||
|     <div v-show="!hideExportButtons" | ||||
|     <div v-show="!hideExportButtons && !options.compact" | ||||
|          class="c-control-bar" | ||||
|     > | ||||
|         <span class="c-button-set c-button-set--strip-h"> | ||||
| @@ -56,6 +56,7 @@ | ||||
|                        :key="object.id" | ||||
|                        class="c-plot--stacked-container" | ||||
|                        :object="object" | ||||
|                        :options="options" | ||||
|                        :grid-lines="gridLines" | ||||
|                        :cursor-guide="cursorGuide" | ||||
|                        :plot-tick-width="maxTickWidth" | ||||
| @@ -74,6 +75,14 @@ export default { | ||||
|         StackedPlotItem | ||||
|     }, | ||||
|     inject: ['openmct', 'domainObject', 'composition'], | ||||
|     props: { | ||||
|         options: { | ||||
|             type: Object, | ||||
|             default() { | ||||
|                 return {}; | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
|         return { | ||||
|             hideExportButtons: false, | ||||
|   | ||||
| @@ -36,6 +36,12 @@ export default { | ||||
|                 return {}; | ||||
|             } | ||||
|         }, | ||||
|         options: { | ||||
|             type: Object, | ||||
|             default() { | ||||
|                 return {}; | ||||
|             } | ||||
|         }, | ||||
|         gridLines: { | ||||
|             type: Boolean, | ||||
|             default() { | ||||
| @@ -108,7 +114,7 @@ export default { | ||||
|                         loadingUpdated | ||||
|                     }; | ||||
|                 }, | ||||
|                 template: '<div ref="plotWrapper" class="l-view-section u-style-receiver js-style-receiver"><div v-show="!!loading" class="c-loading--overlay loading"></div><mct-plot :grid-lines="gridLines" :cursor-guide="cursorGuide" :plot-tick-width="plotTickWidth" @plotTickWidth="onTickWidthChange" @loadingUpdated="loadingUpdated"/></div>' | ||||
|                 template: '<div ref="plotWrapper" class="l-view-section u-style-receiver js-style-receiver"><div v-show="!!loading" class="c-loading--overlay loading"></div><mct-plot :grid-lines="gridLines" :cursor-guide="cursorGuide" :plot-tick-width="plotTickWidth" :options="options" @plotTickWidth="onTickWidthChange" @loadingUpdated="loadingUpdated"/></div>' | ||||
|             }); | ||||
|         }, | ||||
|         onTickWidthChange() { | ||||
| @@ -122,7 +128,8 @@ export default { | ||||
|                 gridLines: this.gridLines, | ||||
|                 cursorGuide: this.cursorGuide, | ||||
|                 plotTickWidth: this.plotTickWidth, | ||||
|                 loading: this.loading | ||||
|                 loading: this.loading, | ||||
|                 options: this.options | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -24,23 +24,29 @@ import StackedPlot from './StackedPlot.vue'; | ||||
| import Vue from 'vue'; | ||||
|  | ||||
| export default function StackedPlotViewProvider(openmct) { | ||||
|     function isCompactView(objectPath) { | ||||
|         return objectPath.find(object => object.type === 'time-strip'); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|         key: 'plot-stacked', | ||||
|         name: 'Stacked Plot', | ||||
|         cssClass: 'icon-telemetry', | ||||
|         canView(domainObject) { | ||||
|             return domainObject.type === 'telemetry.plot.stacked'; | ||||
|         canView(domainObject, objectPath) { | ||||
|             return isCompactView(objectPath) && domainObject.type === 'telemetry.plot.stacked'; | ||||
|         }, | ||||
|  | ||||
|         canEdit(domainObject) { | ||||
|             return domainObject.type === 'telemetry.plot.stacked'; | ||||
|         canEdit(domainObject, objectPath) { | ||||
|             return isCompactView(objectPath) && domainObject.type === 'telemetry.plot.stacked'; | ||||
|         }, | ||||
|  | ||||
|         view: function (domainObject) { | ||||
|         view: function (domainObject, objectPath) { | ||||
|             let component; | ||||
|  | ||||
|             return { | ||||
|                 show: function (element) { | ||||
|                     let isCompact = isCompactView(objectPath); | ||||
|  | ||||
|                     component = new Vue({ | ||||
|                         el: element, | ||||
|                         components: { | ||||
| @@ -51,7 +57,14 @@ export default function StackedPlotViewProvider(openmct) { | ||||
|                             domainObject, | ||||
|                             composition: openmct.composition.get(domainObject) | ||||
|                         }, | ||||
|                         template: '<stacked-plot></stacked-plot>' | ||||
|                         data() { | ||||
|                             return { | ||||
|                                 options: { | ||||
|                                     compact: isCompact | ||||
|                                 } | ||||
|                             }; | ||||
|                         }, | ||||
|                         template: '<stacked-plot :options="options"></stacked-plot>' | ||||
|                     }); | ||||
|                 }, | ||||
|                 destroy: function () { | ||||
|   | ||||
| @@ -60,10 +60,12 @@ define([ | ||||
|     './nonEditableFolder/plugin', | ||||
|     './persistence/couch/plugin', | ||||
|     './defaultRootName/plugin', | ||||
|     './timeline/plugin', | ||||
|     './plan/plugin', | ||||
|     './viewDatumAction/plugin', | ||||
|     './interceptors/plugin', | ||||
|     './performanceIndicator/plugin' | ||||
|     './performanceIndicator/plugin', | ||||
|     './CouchDBSearchFolder/plugin', | ||||
|     './timeline/plugin' | ||||
| ], function ( | ||||
|     _, | ||||
|     UTCTimeSystem, | ||||
| @@ -104,10 +106,12 @@ define([ | ||||
|     NonEditableFolder, | ||||
|     CouchDBPlugin, | ||||
|     DefaultRootName, | ||||
|     Timeline, | ||||
|     PlanLayout, | ||||
|     ViewDatumAction, | ||||
|     ObjectInterceptors, | ||||
|     PerformanceIndicator | ||||
|     PerformanceIndicator, | ||||
|     CouchDBSearchFolder, | ||||
|     Timeline | ||||
| ) { | ||||
|     const bundleMap = { | ||||
|         LocalStorage: 'platform/persistence/local', | ||||
| @@ -202,10 +206,12 @@ define([ | ||||
|     plugins.NonEditableFolder = NonEditableFolder.default; | ||||
|     plugins.ISOTimeFormat = ISOTimeFormat.default; | ||||
|     plugins.DefaultRootName = DefaultRootName.default; | ||||
|     plugins.Timeline = Timeline.default; | ||||
|     plugins.PlanLayout = PlanLayout.default; | ||||
|     plugins.ViewDatumAction = ViewDatumAction.default; | ||||
|     plugins.ObjectInterceptors = ObjectInterceptors.default; | ||||
|     plugins.PerformanceIndicator = PerformanceIndicator.default; | ||||
|     plugins.CouchDBSearchFolder = CouchDBSearchFolder.default; | ||||
|     plugins.Timeline = Timeline.default; | ||||
|  | ||||
|     return plugins; | ||||
| }); | ||||
|   | ||||
| @@ -103,7 +103,7 @@ describe("the plugin", () => { | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         const applicableViews = openmct.objectViews.get(testTelemetryObject); | ||||
|         const applicableViews = openmct.objectViews.get(testTelemetryObject, []); | ||||
|         let tableView = applicableViews.find((viewProvider) => viewProvider.key === 'table'); | ||||
|         expect(tableView).toBeDefined(); | ||||
|     }); | ||||
| @@ -174,7 +174,7 @@ describe("the plugin", () => { | ||||
|  | ||||
|             openmct.router.path = [testTelemetryObject]; | ||||
|  | ||||
|             applicableViews = openmct.objectViews.get(testTelemetryObject); | ||||
|             applicableViews = openmct.objectViews.get(testTelemetryObject, []); | ||||
|             tableViewProvider = applicableViews.find((viewProvider) => viewProvider.key === 'table'); | ||||
|             tableView = tableViewProvider.view(testTelemetryObject, [testTelemetryObject]); | ||||
|             tableView.show(child, true); | ||||
|   | ||||
| @@ -67,6 +67,10 @@ | ||||
|         &.is-in-month { | ||||
|             background: $colorMenuElementHilite; | ||||
|         } | ||||
|  | ||||
|         &.selected { | ||||
|             background: #1ac6ff; // this should be a variable... CHARLESSSSSS | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     &__day { | ||||
|   | ||||
| @@ -1,447 +0,0 @@ | ||||
| <template> | ||||
| <div ref="axisHolder" | ||||
|      class="c-timeline-plan" | ||||
| > | ||||
|     <div class="nowMarker"><span class="icon-arrow-down"></span></div> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import * as d3Selection from 'd3-selection'; | ||||
| import * as d3Axis from 'd3-axis'; | ||||
| import * as d3Scale from 'd3-scale'; | ||||
| import utcMultiTimeFormat from "@/plugins/timeConductor/utcMultiTimeFormat"; | ||||
|  | ||||
| //TODO: UI direction needed for the following property values | ||||
| const PADDING = 1; | ||||
| const OUTER_TEXT_PADDING = 12; | ||||
| const INNER_TEXT_PADDING = 17; | ||||
| const TEXT_LEFT_PADDING = 5; | ||||
| const ROW_PADDING = 12; | ||||
| // const DEFAULT_DURATION_FORMATTER = 'duration'; | ||||
| const RESIZE_POLL_INTERVAL = 200; | ||||
| const PIXELS_PER_TICK = 100; | ||||
| const PIXELS_PER_TICK_WIDE = 200; | ||||
| const ROW_HEIGHT = 30; | ||||
| const LINE_HEIGHT = 12; | ||||
| const MAX_TEXT_WIDTH = 300; | ||||
| const TIMELINE_HEIGHT = 30; | ||||
| //This offset needs to be re-considered | ||||
| const TIMELINE_OFFSET_HEIGHT = 70; | ||||
| const GROUP_OFFSET = 100; | ||||
|  | ||||
| export default { | ||||
|     inject: ['openmct', 'domainObject'], | ||||
|     props: { | ||||
|         "renderingEngine": { | ||||
|             type: String, | ||||
|             default() { | ||||
|                 return 'canvas'; | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     mounted() { | ||||
|         this.validateJSON(this.domainObject.selectFile.body); | ||||
|         if (this.renderingEngine === 'svg') { | ||||
|             this.useSVG = true; | ||||
|         } | ||||
|  | ||||
|         this.container = d3Selection.select(this.$refs.axisHolder); | ||||
|         this.svgElement = this.container.append("svg:svg"); | ||||
|         // draw x axis with labels. CSS is used to position them. | ||||
|         this.axisElement = this.svgElement.append("g") | ||||
|             .attr("class", "axis"); | ||||
|         this.xAxis = d3Axis.axisTop(); | ||||
|  | ||||
|         this.canvas = this.container.append('canvas').node(); | ||||
|         this.canvasContext = this.canvas.getContext('2d'); | ||||
|  | ||||
|         this.setDimensions(); | ||||
|         this.updateViewBounds(); | ||||
|         this.openmct.time.on("timeSystem", this.setScaleAndPlotActivities); | ||||
|         this.openmct.time.on("bounds", this.updateViewBounds); | ||||
|         this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL); | ||||
|         this.unlisten = this.openmct.objects.observe(this.domainObject, '*', this.observeForChanges); | ||||
|     }, | ||||
|     destroyed() { | ||||
|         clearInterval(this.resizeTimer); | ||||
|         this.openmct.time.off("timeSystem", this.setScaleAndPlotActivities); | ||||
|         this.openmct.time.off("bounds", this.updateViewBounds); | ||||
|         if (this.unlisten) { | ||||
|             this.unlisten(); | ||||
|         } | ||||
|     }, | ||||
|     methods: { | ||||
|         observeForChanges(mutatedObject) { | ||||
|             this.validateJSON(mutatedObject.selectFile.body); | ||||
|             this.setScaleAndPlotActivities(); | ||||
|         }, | ||||
|         resize() { | ||||
|             if (this.$refs.axisHolder.clientWidth !== this.width) { | ||||
|                 this.setDimensions(); | ||||
|                 this.updateViewBounds(); | ||||
|             } | ||||
|         }, | ||||
|         validateJSON(jsonString) { | ||||
|             try { | ||||
|                 this.json = JSON.parse(jsonString); | ||||
|             } catch (e) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             return true; | ||||
|         }, | ||||
|         updateViewBounds() { | ||||
|             this.viewBounds = this.openmct.time.bounds(); | ||||
|             // this.viewBounds.end = this.viewBounds.end + (30 * 60 * 1000); | ||||
|             this.setScaleAndPlotActivities(); | ||||
|         }, | ||||
|         updateNowMarker() { | ||||
|             if (this.openmct.time.clock() === undefined) { | ||||
|                 let nowMarker = document.querySelector('.nowMarker'); | ||||
|                 if (nowMarker) { | ||||
|                     nowMarker.parentNode.removeChild(nowMarker); | ||||
|                 } | ||||
|             } else { | ||||
|                 let nowMarker = document.querySelector('.nowMarker'); | ||||
|                 if (nowMarker) { | ||||
|                     const svgEl = d3Selection.select(this.svgElement).node(); | ||||
|                     const height = this.useSVG ? svgEl.style('height') : this.canvas.height + 'px'; | ||||
|                     nowMarker.style.height = height; | ||||
|                     const now = this.xScale(Date.now()); | ||||
|                     nowMarker.style.left = now + GROUP_OFFSET + 'px'; | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         setScaleAndPlotActivities() { | ||||
|             this.setScale(); | ||||
|             this.clearPreviousActivities(); | ||||
|             if (this.xScale) { | ||||
|                 this.calculatePlanLayout(); | ||||
|                 this.drawPlan(); | ||||
|                 this.updateNowMarker(); | ||||
|             } | ||||
|         }, | ||||
|         clearPreviousActivities() { | ||||
|             if (this.useSVG) { | ||||
|                 d3Selection.selectAll("svg > :not(g)").remove(); | ||||
|             } else { | ||||
|                 this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height); | ||||
|             } | ||||
|         }, | ||||
|         setDimensions() { | ||||
|             const axisHolder = this.$refs.axisHolder; | ||||
|             const rect = axisHolder.getBoundingClientRect(); | ||||
|             this.left = Math.round(rect.left); | ||||
|             this.top = Math.round(rect.top); | ||||
|             this.width = axisHolder.clientWidth; | ||||
|             this.offsetWidth = this.width - GROUP_OFFSET; | ||||
|  | ||||
|             const axisHolderParent = this.$parent.$refs.planHolder; | ||||
|             this.height = Math.round(axisHolderParent.getBoundingClientRect().height); | ||||
|  | ||||
|             if (this.useSVG) { | ||||
|                 this.svgElement.attr("width", this.width); | ||||
|                 this.svgElement.attr("height", this.height); | ||||
|             } else { | ||||
|                 this.svgElement.attr("height", 50); | ||||
|                 this.canvas.width = this.width; | ||||
|                 this.canvas.height = this.height; | ||||
|             } | ||||
|  | ||||
|             this.canvasContext.font = "normal normal 12px sans-serif"; | ||||
|         }, | ||||
|         setScale(timeSystem) { | ||||
|             if (!this.width) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (timeSystem === undefined) { | ||||
|                 timeSystem = this.openmct.time.timeSystem(); | ||||
|             } | ||||
|  | ||||
|             if (timeSystem.isUTCBased) { | ||||
|                 this.xScale = d3Scale.scaleUtc(); | ||||
|                 this.xScale.domain( | ||||
|                     [new Date(this.viewBounds.start), new Date(this.viewBounds.end)] | ||||
|                 ); | ||||
|             } else { | ||||
|                 this.xScale = d3Scale.scaleLinear(); | ||||
|                 this.xScale.domain( | ||||
|                     [this.viewBounds.start, this.viewBounds.end] | ||||
|                 ); | ||||
|             } | ||||
|  | ||||
|             this.xScale.range([PADDING, this.offsetWidth - PADDING * 2]); | ||||
|  | ||||
|             this.xAxis.scale(this.xScale); | ||||
|             this.xAxis.tickFormat(utcMultiTimeFormat); | ||||
|  | ||||
|             this.axisElement.call(this.xAxis); | ||||
|  | ||||
|             if (this.width > 1800) { | ||||
|                 this.xAxis.ticks(this.offsetWidth / PIXELS_PER_TICK_WIDE); | ||||
|             } else { | ||||
|                 this.xAxis.ticks(this.offsetWidth / PIXELS_PER_TICK); | ||||
|             } | ||||
|         }, | ||||
|         isActivityInBounds(activity) { | ||||
|             return (activity.start < this.viewBounds.end) && (activity.end > this.viewBounds.start); | ||||
|         }, | ||||
|         getTextWidth(name) { | ||||
|             // canvasContext.font = font; | ||||
|             let metrics = this.canvasContext.measureText(name); | ||||
|  | ||||
|             return parseInt(metrics.width, 10); | ||||
|         }, | ||||
|         sortFn(a, b) { | ||||
|             const numA = parseInt(a, 10); | ||||
|             const numB = parseInt(b, 10); | ||||
|             if (numA > numB) { | ||||
|                 return 1; | ||||
|             } | ||||
|  | ||||
|             if (numA < numB) { | ||||
|                 return -1; | ||||
|             } | ||||
|  | ||||
|             return 0; | ||||
|         }, | ||||
|         // Get the row where the next activity will land. | ||||
|         getRowForActivity(rectX, width, minimumActivityRow = 0) { | ||||
|             let currentRow; | ||||
|             let sortedActivityRows = Object.keys(this.activitiesByRow).sort(this.sortFn); | ||||
|  | ||||
|             function getOverlap(rects) { | ||||
|                 return rects.every(rect => { | ||||
|                     const { start, end } = rect; | ||||
|                     const calculatedEnd = rectX + width; | ||||
|                     const hasOverlap = (rectX >= start && rectX <= end) || (calculatedEnd >= start && calculatedEnd <= end) || (rectX <= start && calculatedEnd >= end); | ||||
|  | ||||
|                     return !hasOverlap; | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             for (let i = 0; i < sortedActivityRows.length; i++) { | ||||
|                 let row = sortedActivityRows[i]; | ||||
|                 if (row >= minimumActivityRow && getOverlap(this.activitiesByRow[row])) { | ||||
|                     currentRow = row; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (currentRow === undefined && sortedActivityRows.length) { | ||||
|                 let row = Math.max(parseInt(sortedActivityRows[sortedActivityRows.length - 1], 10), minimumActivityRow); | ||||
|                 currentRow = row + ROW_HEIGHT + ROW_PADDING; | ||||
|             } | ||||
|  | ||||
|             return (currentRow || minimumActivityRow); | ||||
|         }, | ||||
|         calculatePlanLayout() { | ||||
|             this.activitiesByRow = {}; | ||||
|  | ||||
|             let currentRow = 0; | ||||
|  | ||||
|             let groups = Object.keys(this.json); | ||||
|             groups.forEach((key, index) => { | ||||
|                 let activities = this.json[key]; | ||||
|                 //set the new group's first row. It should be greater than the largest row of the last group | ||||
|                 let sortedActivityRows = Object.keys(this.activitiesByRow).sort(this.sortFn); | ||||
|                 const groupRowStart = sortedActivityRows.length ? parseInt(sortedActivityRows[sortedActivityRows.length - 1], 10) + 1 : 0; | ||||
|                 let newGroup = true; | ||||
|                 activities.forEach((activity) => { | ||||
|                     if (this.isActivityInBounds(activity)) { | ||||
|                         const currentStart = Math.max(this.viewBounds.start, activity.start); | ||||
|                         const currentEnd = Math.min(this.viewBounds.end, activity.end); | ||||
|                         const rectX = this.xScale(currentStart); | ||||
|                         const rectY = this.xScale(currentEnd); | ||||
|                         const rectWidth = rectY - rectX; | ||||
|  | ||||
|                         const activityNameWidth = this.getTextWidth(activity.name) + TEXT_LEFT_PADDING; | ||||
|                         //TODO: Fix bug for SVG where the rectWidth is not proportional to the canvas measuredWidth of the text | ||||
|                         const activityNameFitsRect = (rectWidth >= activityNameWidth); | ||||
|                         const textStart = (activityNameFitsRect ? rectX : (rectX + rectWidth)) + TEXT_LEFT_PADDING; | ||||
|  | ||||
|                         let textLines = this.getActivityDisplayText(this.canvasContext, activity.name, activityNameFitsRect); | ||||
|                         const textWidth = textStart + this.getTextWidth(textLines[0]) + TEXT_LEFT_PADDING; | ||||
|  | ||||
|                         if (activityNameFitsRect) { | ||||
|                             currentRow = this.getRowForActivity(rectX, rectWidth, groupRowStart); | ||||
|                         } else { | ||||
|                             currentRow = this.getRowForActivity(rectX, textWidth, groupRowStart); | ||||
|                         } | ||||
|  | ||||
|                         let textY = parseInt(currentRow, 10) + (activityNameFitsRect ? INNER_TEXT_PADDING : OUTER_TEXT_PADDING); | ||||
|  | ||||
|                         if (!this.activitiesByRow[currentRow]) { | ||||
|                             this.activitiesByRow[currentRow] = []; | ||||
|                         } | ||||
|  | ||||
|                         this.activitiesByRow[currentRow].push({ | ||||
|                             heading: newGroup ? key : '', | ||||
|                             activity: { | ||||
|                                 color: activity.color, | ||||
|                                 textColor: activity.textColor | ||||
|                             }, | ||||
|                             textLines: textLines, | ||||
|                             textStart: textStart, | ||||
|                             textY: textY, | ||||
|                             start: rectX, | ||||
|                             end: activityNameFitsRect ? rectX + rectWidth : textStart + textWidth, | ||||
|                             rectWidth: rectWidth | ||||
|                         }); | ||||
|                         newGroup = false; | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|         }, | ||||
|         getActivityDisplayText(context, text, activityNameFitsRect) { | ||||
|         //TODO: If the activity start is less than viewBounds.start then the text should be cropped on the left/should be off-screen) | ||||
|             let words = text.split(' '); | ||||
|             let line = ''; | ||||
|             let activityText = []; | ||||
|             let rows = 1; | ||||
|  | ||||
|             for (let n = 0; (n < words.length) && (rows <= 2); n++) { | ||||
|                 let testLine = line + words[n] + ' '; | ||||
|                 let metrics = context.measureText(testLine); | ||||
|                 let testWidth = metrics.width; | ||||
|                 if (!activityNameFitsRect && (testWidth > MAX_TEXT_WIDTH && n > 0)) { | ||||
|                     activityText.push(line); | ||||
|                     line = words[n] + ' '; | ||||
|                     testLine = line + words[n] + ' '; | ||||
|                     rows = rows + 1; | ||||
|                 } | ||||
|  | ||||
|                 line = testLine; | ||||
|             } | ||||
|  | ||||
|             return activityText.length ? activityText : [line]; | ||||
|         }, | ||||
|         getGroupHeading(row) { | ||||
|             let groupHeadingRow; | ||||
|             let groupHeadingBorder; | ||||
|  | ||||
|             if (row) { | ||||
|                 groupHeadingBorder = row + ROW_PADDING + OUTER_TEXT_PADDING; | ||||
|                 groupHeadingRow = groupHeadingBorder + OUTER_TEXT_PADDING; | ||||
|             } else { | ||||
|                 groupHeadingRow = TIMELINE_HEIGHT + OUTER_TEXT_PADDING; | ||||
|             } | ||||
|  | ||||
|             return { | ||||
|                 groupHeadingRow, | ||||
|                 groupHeadingBorder | ||||
|             }; | ||||
|         }, | ||||
|         getPlanHeight(activityRows) { | ||||
|             return parseInt(activityRows[activityRows.length - 1], 10) + TIMELINE_OFFSET_HEIGHT; | ||||
|         }, | ||||
|         drawPlan() { | ||||
|             const activityRows = Object.keys(this.activitiesByRow); | ||||
|             if (activityRows.length) { | ||||
|  | ||||
|                 let planHeight = this.getPlanHeight(activityRows); | ||||
|                 planHeight = Math.max(this.height, planHeight); | ||||
|                 if (this.useSVG) { | ||||
|                     this.svgElement.attr("height", planHeight); | ||||
|                 } else { | ||||
|                     // This needs to happen before we draw on the canvas or the canvas will get wiped out when height is set | ||||
|                     this.canvas.height = planHeight; | ||||
|                 } | ||||
|  | ||||
|                 activityRows.forEach((key) => { | ||||
|                     const items = this.activitiesByRow[key]; | ||||
|                     const row = parseInt(key, 10); | ||||
|                     items.forEach((item) => { | ||||
|  | ||||
|                         //TODO: Don't draw the left-border of the rectangle if the activity started before viewBounds.start | ||||
|                         if (this.useSVG) { | ||||
|                             this.plotSVG(item, row); | ||||
|                         } else { | ||||
|                             this.plotCanvas(item, row); | ||||
|                         } | ||||
|  | ||||
|                     }); | ||||
|  | ||||
|                 }); | ||||
|             } | ||||
|         }, | ||||
|         plotSVG(item, row) { | ||||
|             const headingText = item.heading; | ||||
|             const { groupHeadingRow, groupHeadingBorder } = this.getGroupHeading(row); | ||||
|  | ||||
|             if (headingText) { | ||||
|                 if (groupHeadingBorder) { | ||||
|                     this.svgElement.append("line") | ||||
|                         .attr("class", "activity") | ||||
|                         .attr("x1", 0) | ||||
|                         .attr("y1", groupHeadingBorder) | ||||
|                         .attr("x2", this.width) | ||||
|                         .attr("y2", groupHeadingBorder) | ||||
|                         .attr('stroke', "white"); | ||||
|                 } | ||||
|  | ||||
|                 this.svgElement.append("text").text(headingText) | ||||
|                     .attr("class", "activity") | ||||
|                     .attr("x", 0) | ||||
|                     .attr("y", groupHeadingRow) | ||||
|                     .attr('fill', "white"); | ||||
|             } | ||||
|  | ||||
|             const activity = item.activity; | ||||
|             const rectY = row + TIMELINE_HEIGHT; | ||||
|             this.svgElement.append("rect") | ||||
|                 .attr("class", "activity") | ||||
|                 .attr("x", item.start + GROUP_OFFSET) | ||||
|                 .attr("y", rectY + TIMELINE_HEIGHT) | ||||
|                 .attr("width", item.rectWidth) | ||||
|                 .attr("height", ROW_HEIGHT) | ||||
|                 .attr('fill', activity.color) | ||||
|                 .attr('stroke', "lightgray"); | ||||
|  | ||||
|             item.textLines.forEach((line, index) => { | ||||
|                 this.svgElement.append("text").text(line) | ||||
|                     .attr("class", "activity") | ||||
|                     .attr("x", item.textStart + GROUP_OFFSET) | ||||
|                     .attr("y", item.textY + TIMELINE_HEIGHT + (index * LINE_HEIGHT)) | ||||
|                     .attr('fill', activity.textColor); | ||||
|             }); | ||||
|             //TODO: Ending border | ||||
|         }, | ||||
|         plotCanvas(item, row) { | ||||
|             const headingText = item.heading; | ||||
|             const { groupHeadingRow, groupHeadingBorder } = this.getGroupHeading(row); | ||||
|  | ||||
|             if (headingText) { | ||||
|                 if (groupHeadingBorder) { | ||||
|                     this.canvasContext.strokeStyle = "white"; | ||||
|                     this.canvasContext.beginPath(); | ||||
|                     this.canvasContext.moveTo(0, groupHeadingBorder); | ||||
|                     this.canvasContext.lineTo(this.width, groupHeadingBorder); | ||||
|                     this.canvasContext.stroke(); | ||||
|                 } | ||||
|  | ||||
|                 this.canvasContext.fillStyle = "white"; | ||||
|                 this.canvasContext.fillText(headingText, 0, groupHeadingRow); | ||||
|             } | ||||
|  | ||||
|             const activity = item.activity; | ||||
|             const rectX = item.start; | ||||
|             const rectY = row + TIMELINE_HEIGHT; | ||||
|             const rectWidth = item.rectWidth; | ||||
|             this.canvasContext.fillStyle = activity.color; | ||||
|             this.canvasContext.strokeStyle = "lightgray"; | ||||
|             this.canvasContext.fillRect(rectX + GROUP_OFFSET, rectY, rectWidth, ROW_HEIGHT); | ||||
|             this.canvasContext.strokeRect(rectX + GROUP_OFFSET, rectY, rectWidth, ROW_HEIGHT); | ||||
|  | ||||
|             this.canvasContext.fillStyle = activity.textColor; | ||||
|  | ||||
|             item.textLines.forEach((line, index) => { | ||||
|                 this.canvasContext.fillText(line, item.textStart + GROUP_OFFSET, item.textY + TIMELINE_HEIGHT + (index * LINE_HEIGHT)); | ||||
|             }); | ||||
|             //TODO: Ending border | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| </script> | ||||
|   | ||||
| @@ -21,25 +21,175 @@ | ||||
| *****************************************************************************/ | ||||
|  | ||||
| <template> | ||||
| <div ref="planHolder" | ||||
|      class="c-timeline" | ||||
| <div ref="timelineHolder" | ||||
|      class="c-timeline-holder" | ||||
| > | ||||
|     <plan :rendering-engine="'canvas'" /> | ||||
|     <div class="c-timeline"> | ||||
|         <div v-for="timeSystemItem in timeSystems" | ||||
|              :key="timeSystemItem.timeSystem.key" | ||||
|              class="u-contents" | ||||
|         > | ||||
|             <swim-lane> | ||||
|                 <template slot="label"> | ||||
|                     {{ timeSystemItem.timeSystem.name }} | ||||
|                 </template> | ||||
|                 <template slot="object"> | ||||
|                     <timeline-axis :bounds="timeSystemItem.bounds" | ||||
|                                    :time-system="timeSystemItem.timeSystem" | ||||
|                                    :content-height="height" | ||||
|                                    :rendering-engine="'svg'" | ||||
|                     /> | ||||
|                 </template> | ||||
|  | ||||
|             </swim-lane> | ||||
|         </div> | ||||
|  | ||||
|         <div ref="contentHolder" | ||||
|              class="u-contents c-timeline__objects c-timeline__content-holder" | ||||
|         > | ||||
|             <div | ||||
|                 v-for="item in items" | ||||
|                 :key="item.keyString" | ||||
|                 class="u-contents c-timeline__content" | ||||
|             > | ||||
|                 <swim-lane :icon-class="item.type.definition.cssClass" | ||||
|                            :min-height="item.height" | ||||
|                            :show-ucontents="item.domainObject.type === 'plan'" | ||||
|                            :span-rows-count="item.rowCount" | ||||
|                 > | ||||
|                     <template slot="label"> | ||||
|                         {{ item.domainObject.name }} | ||||
|                     </template> | ||||
|                     <object-view | ||||
|                         slot="object" | ||||
|                         class="u-contents" | ||||
|                         :default-object="item.domainObject" | ||||
|                         :object-view-key="item.viewKey" | ||||
|                         :object-path="item.objectPath" | ||||
|                     /> | ||||
|                 </swim-lane> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import Plan from './Plan.vue'; | ||||
| import ObjectView from '@/ui/components/ObjectView.vue'; | ||||
| import TimelineAxis from '../../ui/components/TimeSystemAxis.vue'; | ||||
| import SwimLane from "@/ui/components/swim-lane/SwimLane.vue"; | ||||
| import { getValidatedPlan } from "../plan/util"; | ||||
|  | ||||
| const unknownObjectType = { | ||||
|     definition: { | ||||
|         cssClass: 'icon-object-unknown', | ||||
|         name: 'Unknown Type' | ||||
|     } | ||||
| }; | ||||
|  | ||||
| function getViewKey(domainObject, objectPath, openmct) { | ||||
|     let viewKey = ''; | ||||
|     const plotView = openmct.objectViews.get(domainObject, objectPath).find((view) => { | ||||
|         return view.key.startsWith('plot-') && view.key !== 'plot-single'; | ||||
|     }); | ||||
|     if (plotView) { | ||||
|         viewKey = plotView.key; | ||||
|     } | ||||
|  | ||||
|     return viewKey; | ||||
| } | ||||
|  | ||||
| export default { | ||||
|     components: { | ||||
|         Plan | ||||
|         ObjectView, | ||||
|         TimelineAxis, | ||||
|         SwimLane | ||||
|     }, | ||||
|     inject: ['openmct', 'domainObject'], | ||||
|     inject: ['openmct', 'domainObject', 'composition', 'objectPath'], | ||||
|     data() { | ||||
|         return { | ||||
|             plans: [] | ||||
|             items: [], | ||||
|             timeSystems: [], | ||||
|             height: 0 | ||||
|         }; | ||||
|     }, | ||||
|     beforeDestroy() { | ||||
|         this.composition.off('add', this.addItem); | ||||
|         this.composition.off('remove', this.removeItem); | ||||
|         this.composition.off('reorder', this.reorder); | ||||
|         this.openmct.time.off("bounds", this.updateViewBounds); | ||||
|  | ||||
|     }, | ||||
|     mounted() { | ||||
|         if (this.composition) { | ||||
|             this.composition.on('add', this.addItem); | ||||
|             this.composition.on('remove', this.removeItem); | ||||
|             this.composition.on('reorder', this.reorder); | ||||
|             this.composition.load(); | ||||
|         } | ||||
|  | ||||
|         this.getTimeSystems(); | ||||
|         this.openmct.time.on("bounds", this.updateViewBounds); | ||||
|     }, | ||||
|     methods: { | ||||
|         addItem(domainObject) { | ||||
|             let type = this.openmct.types.get(domainObject.type) || unknownObjectType; | ||||
|             let keyString = this.openmct.objects.makeKeyString(domainObject.identifier); | ||||
|             let objectPath = [domainObject].concat(this.objectPath.slice()); | ||||
|             let viewKey = getViewKey(domainObject, objectPath, this.openmct); | ||||
|             let rowCount = 0; | ||||
|             if (domainObject.type === 'plan') { | ||||
|                 rowCount = Object.keys(getValidatedPlan(domainObject)).length; | ||||
|             } | ||||
|  | ||||
|             let height = domainObject.type === 'telemetry.plot.stacked' ? `${domainObject.composition.length * 100}px` : '100px'; | ||||
|             let item = { | ||||
|                 domainObject, | ||||
|                 objectPath, | ||||
|                 type, | ||||
|                 keyString, | ||||
|                 viewKey, | ||||
|                 rowCount, | ||||
|                 height | ||||
|             }; | ||||
|  | ||||
|             this.items.push(item); | ||||
|             this.updateContentHeight(); | ||||
|         }, | ||||
|         removeItem(identifier) { | ||||
|             let index = this.items.findIndex(item => this.openmct.objects.areIdsEqual(identifier, item.domainObject.identifier)); | ||||
|             this.items.splice(index, 1); | ||||
|         }, | ||||
|         reorder(reorderPlan) { | ||||
|             let oldItems = this.items.slice(); | ||||
|             reorderPlan.forEach((reorderEvent) => { | ||||
|                 this.$set(this.items, reorderEvent.newIndex, oldItems[reorderEvent.oldIndex]); | ||||
|             }); | ||||
|         }, | ||||
|         updateContentHeight() { | ||||
|             this.height = Math.round(this.$refs.contentHolder.getBoundingClientRect().height); | ||||
|         }, | ||||
|         getTimeSystems() { | ||||
|             const timeSystems = this.openmct.time.getAllTimeSystems(); | ||||
|             timeSystems.forEach(timeSystem => { | ||||
|                 this.timeSystems.push({ | ||||
|                     timeSystem, | ||||
|                     bounds: this.getBoundsForTimeSystem(timeSystem) | ||||
|                 }); | ||||
|             }); | ||||
|         }, | ||||
|         getBoundsForTimeSystem(timeSystem) { | ||||
|             const currentBounds = this.openmct.time.bounds(); | ||||
|  | ||||
|             //TODO: Some kind of translation via an offset? of current bounds to target timeSystem | ||||
|             return currentBounds; | ||||
|         }, | ||||
|         updateViewBounds(bounds) { | ||||
|             let currentTimeSystem = this.timeSystems.find(item => item.timeSystem.key === this.openmct.time.timeSystem().key); | ||||
|             if (currentTimeSystem) { | ||||
|                 currentTimeSystem.bounds = bounds; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| </script> | ||||
|   | ||||
| @@ -26,18 +26,18 @@ import Vue from 'vue'; | ||||
| export default function TimelineViewProvider(openmct) { | ||||
|  | ||||
|     return { | ||||
|         key: 'timeline.view', | ||||
|         name: 'Timeline', | ||||
|         key: 'time-strip.view', | ||||
|         name: 'TimeStrip', | ||||
|         cssClass: 'icon-clock', | ||||
|         canView(domainObject) { | ||||
|             return domainObject.type === 'plan'; | ||||
|             return domainObject.type === 'time-strip'; | ||||
|         }, | ||||
|  | ||||
|         canEdit(domainObject) { | ||||
|             return domainObject.type === 'plan'; | ||||
|             return domainObject.type === 'time-strip'; | ||||
|         }, | ||||
|  | ||||
|         view: function (domainObject) { | ||||
|         view: function (domainObject, objectPath) { | ||||
|             let component; | ||||
|  | ||||
|             return { | ||||
| @@ -49,7 +49,9 @@ export default function TimelineViewProvider(openmct) { | ||||
|                         }, | ||||
|                         provide: { | ||||
|                             openmct, | ||||
|                             domainObject | ||||
|                             domainObject, | ||||
|                             composition: openmct.composition.get(domainObject), | ||||
|                             objectPath | ||||
|                         }, | ||||
|                         template: '<timeline-view-layout></timeline-view-layout>' | ||||
|                     }); | ||||
|   | ||||
| @@ -20,27 +20,18 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| import TimelineViewProvider from './TimelineViewProvider'; | ||||
| import TimelineViewProvider from '../timeline/TimelineViewProvider'; | ||||
|  | ||||
| export default function () { | ||||
|     return function install(openmct) { | ||||
|         openmct.types.addType('plan', { | ||||
|             name: 'Plan', | ||||
|             key: 'plan', | ||||
|         openmct.types.addType('time-strip', { | ||||
|             name: 'Time Strip', | ||||
|             key: 'time-strip', | ||||
|             description: 'An activity timeline', | ||||
|             creatable: true, | ||||
|             cssClass: 'icon-timeline', | ||||
|             form: [ | ||||
|                 { | ||||
|                     name: 'Upload Plan (JSON File)', | ||||
|                     key: 'selectFile', | ||||
|                     control: 'file-input', | ||||
|                     required: true, | ||||
|                     text: 'Select File', | ||||
|                     type: 'application/json' | ||||
|                 } | ||||
|             ], | ||||
|             initialize: function (domainObject) { | ||||
|                 domainObject.composition = []; | ||||
|             } | ||||
|         }); | ||||
|         openmct.objectViews.addProvider(new TimelineViewProvider(openmct)); | ||||
|   | ||||
| @@ -23,15 +23,33 @@ | ||||
| import { createOpenMct, resetApplicationState } from "utils/testing"; | ||||
| import TimelinePlugin from "./plugin"; | ||||
| import Vue from 'vue'; | ||||
| import TimelineViewLayout from "./TimelineViewLayout.vue"; | ||||
|  | ||||
| describe('the plugin', function () { | ||||
|     let planDefinition; | ||||
|     let objectDef; | ||||
|     let element; | ||||
|     let child; | ||||
|     let openmct; | ||||
|     let mockObjectPath; | ||||
|  | ||||
|     beforeEach((done) => { | ||||
|         mockObjectPath = [ | ||||
|             { | ||||
|                 name: 'mock folder', | ||||
|                 type: 'fake-folder', | ||||
|                 identifier: { | ||||
|                     key: 'mock-folder', | ||||
|                     namespace: '' | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 name: 'mock parent folder', | ||||
|                 type: 'time-strip', | ||||
|                 identifier: { | ||||
|                     key: 'mock-parent-folder', | ||||
|                     namespace: '' | ||||
|                 } | ||||
|             } | ||||
|         ]; | ||||
|         const appHolder = document.createElement('div'); | ||||
|         appHolder.style.width = '640px'; | ||||
|         appHolder.style.height = '480px'; | ||||
| @@ -39,7 +57,7 @@ describe('the plugin', function () { | ||||
|         openmct = createOpenMct(); | ||||
|         openmct.install(new TimelinePlugin()); | ||||
|  | ||||
|         planDefinition = openmct.types.get('plan').definition; | ||||
|         objectDef = openmct.types.get('time-strip').definition; | ||||
|  | ||||
|         element = document.createElement('div'); | ||||
|         element.style.width = '640px'; | ||||
| @@ -49,7 +67,7 @@ describe('the plugin', function () { | ||||
|         child.style.height = '480px'; | ||||
|         element.appendChild(child); | ||||
|  | ||||
|         openmct.time.bounds({ | ||||
|         openmct.time.timeSystem('utc', { | ||||
|             start: 1597160002854, | ||||
|             end: 1597181232854 | ||||
|         }); | ||||
| @@ -62,147 +80,46 @@ describe('the plugin', function () { | ||||
|         return resetApplicationState(openmct); | ||||
|     }); | ||||
|  | ||||
|     let mockPlanObject = { | ||||
|         name: 'Plan', | ||||
|         key: 'plan', | ||||
|     let mockObject = { | ||||
|         name: 'Time Strip', | ||||
|         key: 'time-strip', | ||||
|         creatable: true | ||||
|     }; | ||||
|  | ||||
|     it('defines a plan object type with the correct key', () => { | ||||
|         expect(planDefinition.key).toEqual(mockPlanObject.key); | ||||
|     it('defines a time-strip object type with the correct key', () => { | ||||
|         expect(objectDef.key).toEqual(mockObject.key); | ||||
|     }); | ||||
|  | ||||
|     describe('the plan object', () => { | ||||
|     describe('the time-strip object', () => { | ||||
|  | ||||
|         it('is creatable', () => { | ||||
|             expect(planDefinition.creatable).toEqual(mockPlanObject.creatable); | ||||
|             expect(objectDef.creatable).toEqual(mockObject.creatable); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|         it('provides a timeline view', () => { | ||||
|     describe('the view', () => { | ||||
|         let timelineView; | ||||
|  | ||||
|         beforeEach((done) => { | ||||
|             const testViewObject = { | ||||
|                 id: "test-object", | ||||
|                 type: "plan" | ||||
|                 type: "time-strip" | ||||
|             }; | ||||
|  | ||||
|             const applicableViews = openmct.objectViews.get(testViewObject); | ||||
|             let timelineView = applicableViews.find((viewProvider) => viewProvider.key === 'timeline.view'); | ||||
|             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('provides a view', () => { | ||||
|             expect(timelineView).toBeDefined(); | ||||
|         }); | ||||
|  | ||||
|     }); | ||||
|  | ||||
|     describe('the timeline view displays activities', () => { | ||||
|         let planDomainObject; | ||||
|         let component; | ||||
|         let planViewComponent; | ||||
|  | ||||
|         beforeEach((done) => { | ||||
|             planDomainObject = { | ||||
|                 identifier: { | ||||
|                     key: 'test-object', | ||||
|                     namespace: '' | ||||
|                 }, | ||||
|                 type: 'plan', | ||||
|                 id: "test-object", | ||||
|                 selectFile: { | ||||
|                     body: JSON.stringify({ | ||||
|                         "TEST-GROUP": [ | ||||
|                             { | ||||
|                                 "name": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", | ||||
|                                 "start": 1597170002854, | ||||
|                                 "end": 1597171032854, | ||||
|                                 "type": "TEST-GROUP", | ||||
|                                 "color": "fuchsia", | ||||
|                                 "textColor": "black" | ||||
|                             }, | ||||
|                             { | ||||
|                                 "name": "Sed ut perspiciatis", | ||||
|                                 "start": 1597171132854, | ||||
|                                 "end": 1597171232854, | ||||
|                                 "type": "TEST-GROUP", | ||||
|                                 "color": "fuchsia", | ||||
|                                 "textColor": "black" | ||||
|                             } | ||||
|                         ] | ||||
|                     }) | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             let viewContainer = document.createElement('div'); | ||||
|             child.append(viewContainer); | ||||
|             component = new Vue({ | ||||
|                 el: viewContainer, | ||||
|                 components: { | ||||
|                     TimelineViewLayout | ||||
|                 }, | ||||
|                 provide: { | ||||
|                     openmct: openmct, | ||||
|                     domainObject: planDomainObject | ||||
|                 }, | ||||
|                 template: '<timeline-view-layout/>' | ||||
|             }); | ||||
|  | ||||
|             return Vue.nextTick().then(() => { | ||||
|                 planViewComponent = component.$root.$children[0].$children[0]; | ||||
|                 setTimeout(() => { | ||||
|                     clearInterval(planViewComponent.resizeTimer); | ||||
|                     //TODO: this is a hack to ensure the canvas has a width - maybe there's a better way to set the width of the plan div | ||||
|                     planViewComponent.width = 1200; | ||||
|                     planViewComponent.setScaleAndPlotActivities(); | ||||
|                     done(); | ||||
|                 }, 300); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         it('loads activities into the view', () => { | ||||
|             expect(planViewComponent.json).toBeDefined(); | ||||
|             expect(planViewComponent.json["TEST-GROUP"].length).toEqual(2); | ||||
|         }); | ||||
|  | ||||
|         it('loads a time axis into the view', () => { | ||||
|             let ticks = planViewComponent.axisElement.node().querySelectorAll('g.tick'); | ||||
|             expect(ticks.length).toEqual(11); | ||||
|         }); | ||||
|  | ||||
|         it('calculates the activity layout', () => { | ||||
|             const expectedActivitiesByRow = { | ||||
|                 "0": [ | ||||
|                     { | ||||
|                         "heading": "TEST-GROUP", | ||||
|                         "activity": { | ||||
|                             "color": "fuchsia", | ||||
|                             "textColor": "black" | ||||
|                         }, | ||||
|                         "textLines": [ | ||||
|                             "Lorem ipsum dolor sit amet, consectetur adipiscing elit, ", | ||||
|                             "sed sed do eiusmod tempor incididunt ut labore et " | ||||
|                         ], | ||||
|                         "textStart": -47.51342439943476, | ||||
|                         "textY": 12, | ||||
|                         "start": -47.51625058878945, | ||||
|                         "end": 204.97315120113046, | ||||
|                         "rectWidth": -4.9971738106453145 | ||||
|                     } | ||||
|                 ], | ||||
|                 "42": [ | ||||
|                     { | ||||
|                         "heading": "", | ||||
|                         "activity": { | ||||
|                             "color": "fuchsia", | ||||
|                             "textColor": "black" | ||||
|                         }, | ||||
|                         "textLines": [ | ||||
|                             "Sed ut perspiciatis " | ||||
|                         ], | ||||
|                         "textStart": -48.483749411210546, | ||||
|                         "textY": 54, | ||||
|                         "start": -52.99858690532266, | ||||
|                         "end": 9.032501177578908, | ||||
|                         "rectWidth": -0.48516250588788523 | ||||
|                     } | ||||
|                 ] | ||||
|             }; | ||||
|             expect(Object.keys(planViewComponent.activitiesByRow)).toEqual(Object.keys(expectedActivitiesByRow)); | ||||
|         it('displays a time axis', () => { | ||||
|             const el = element.querySelector('.c-timesystem-axis'); | ||||
|             expect(el).toBeDefined(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -1,57 +0,0 @@ | ||||
| .c-timeline { | ||||
|   $h: 18px; | ||||
|   $tickYPos: ($h / 2) + 12px + 10px; | ||||
|   $tickXPos: 100px; | ||||
|  | ||||
|   height: 100%; | ||||
|  | ||||
|   svg { | ||||
|     text-rendering: geometricPrecision; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     > g.axis { | ||||
|       // Overall Tick holder | ||||
|       transform: translateY($tickYPos) translateX($tickXPos); | ||||
|  | ||||
|       g { | ||||
|          //Each tick. These move on drag. | ||||
|         line { | ||||
|           // Line beneath ticks | ||||
|           display: none; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     text:not(.activity) { | ||||
|       // Tick labels | ||||
|       fill: $colorBodyFg; | ||||
|       font-size: 1em; | ||||
|       paint-order: stroke; | ||||
|       font-weight: bold; | ||||
|       stroke: $colorBodyBg; | ||||
|       stroke-linecap: butt; | ||||
|       stroke-linejoin: bevel; | ||||
|       stroke-width: 6px; | ||||
|     } | ||||
|  | ||||
|     text.activity { | ||||
|       stroke: none; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
|  | ||||
|   .nowMarker { | ||||
|     width: 2px; | ||||
|     position: absolute; | ||||
|     z-index: 10; | ||||
|     background: gray; | ||||
|  | ||||
|     & .icon-arrow-down { | ||||
|       font-size: large; | ||||
|       position: absolute; | ||||
|       top: -8px; | ||||
|       left: -8px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										4
									
								
								src/plugins/timeline/timeline.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/plugins/timeline/timeline.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| .c-timeline-holder { | ||||
|     @include abs(); | ||||
|     overflow-x: hidden; | ||||
| } | ||||
| @@ -27,13 +27,16 @@ | ||||
| @import "../plugins/timeConductor/conductor-mode.scss"; | ||||
| @import "../plugins/timeConductor/conductor-mode-icon.scss"; | ||||
| @import "../plugins/timeConductor/date-picker.scss"; | ||||
| @import "../plugins/timeline/timeline-axis.scss"; | ||||
| @import "../plugins/timeline/timeline.scss"; | ||||
| @import "../plugins/plan/plan"; | ||||
| @import "../plugins/viewDatumAction/components/metadata-list.scss"; | ||||
| @import "../ui/components/object-frame.scss"; | ||||
| @import "../ui/components/object-label.scss"; | ||||
| @import "../ui/components/progress-bar.scss"; | ||||
| @import "../ui/components/search.scss"; | ||||
| @import "../ui/components/swim-lane/swimlane.scss"; | ||||
| @import "../ui/components/toggle-switch.scss"; | ||||
| @import "../ui/components/timesystem-axis.scss"; | ||||
| @import "../ui/inspector/elements.scss"; | ||||
| @import "../ui/inspector/inspector.scss"; | ||||
| @import "../ui/inspector/location.scss"; | ||||
|   | ||||
| @@ -28,6 +28,10 @@ export default { | ||||
|         layoutFont: { | ||||
|             type: String, | ||||
|             default: '' | ||||
|         }, | ||||
|         objectViewKey: { | ||||
|             type: String, | ||||
|             default: '' | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
| @@ -303,11 +307,21 @@ export default { | ||||
|                 event.stopPropagation(); | ||||
|             } | ||||
|         }, | ||||
|         getViewKey() { | ||||
|             let viewKey = this.viewKey; | ||||
|             if (this.objectViewKey) { | ||||
|                 viewKey = this.objectViewKey; | ||||
|             } | ||||
|  | ||||
|             return viewKey; | ||||
|         }, | ||||
|         getViewProvider() { | ||||
|             let provider = this.openmct.objectViews.getByProviderKey(this.viewKey); | ||||
|  | ||||
|             let provider = this.openmct.objectViews.getByProviderKey(this.getViewKey()); | ||||
|  | ||||
|             if (!provider) { | ||||
|                 provider = this.openmct.objectViews.get(this.domainObject)[0]; | ||||
|                 let objectPath = this.currentObjectPath || this.objectPath; | ||||
|                 provider = this.openmct.objectViews.get(this.domainObject, objectPath)[0]; | ||||
|                 if (!provider) { | ||||
|                     return; | ||||
|                 } | ||||
| @@ -316,10 +330,11 @@ export default { | ||||
|             return provider; | ||||
|         }, | ||||
|         editIfEditable(event) { | ||||
|             let objectPath = this.currentObjectPath || this.objectPath; | ||||
|             let provider = this.getViewProvider(); | ||||
|             if (provider | ||||
|                 && provider.canEdit | ||||
|                 && provider.canEdit(this.domainObject) | ||||
|                 && provider.canEdit(this.domainObject, objectPath) | ||||
|                 && this.isEditingAllowed() | ||||
|                 && !this.openmct.editor.isEditing()) { | ||||
|                 this.openmct.editor.edit(); | ||||
|   | ||||
							
								
								
									
										166
									
								
								src/ui/components/TimeSystemAxis.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								src/ui/components/TimeSystemAxis.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,166 @@ | ||||
| <template> | ||||
| <div ref="axisHolder" | ||||
|      class="c-timesystem-axis" | ||||
| > | ||||
|     <div class="nowMarker"><span class="icon-arrow-down"></span></div> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import * as d3Selection from 'd3-selection'; | ||||
| import * as d3Axis from 'd3-axis'; | ||||
| import * as d3Scale from 'd3-scale'; | ||||
| import utcMultiTimeFormat from '@/plugins/timeConductor/utcMultiTimeFormat'; | ||||
|  | ||||
| //TODO: UI direction needed for the following property values | ||||
| const PADDING = 1; | ||||
| const RESIZE_POLL_INTERVAL = 200; | ||||
| const PIXELS_PER_TICK = 100; | ||||
| const PIXELS_PER_TICK_WIDE = 200; | ||||
| //This offset needs to be re-considered | ||||
|  | ||||
| export default { | ||||
|     inject: ['openmct', 'domainObject'], | ||||
|     props: { | ||||
|         bounds: { | ||||
|             type: Object, | ||||
|             default() { | ||||
|                 return {}; | ||||
|             } | ||||
|         }, | ||||
|         timeSystem: { | ||||
|             type: Object, | ||||
|             default() { | ||||
|                 return {}; | ||||
|             } | ||||
|         }, | ||||
|         contentHeight: { | ||||
|             type: Number, | ||||
|             default() { | ||||
|                 return 0; | ||||
|             } | ||||
|         }, | ||||
|         renderingEngine: { | ||||
|             type: String, | ||||
|             default() { | ||||
|                 return 'svg'; | ||||
|             } | ||||
|         }, | ||||
|         offset: { | ||||
|             type: Number, | ||||
|             default() { | ||||
|                 return 0; | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     watch: { | ||||
|         bounds(newBounds) { | ||||
|             this.drawAxis(newBounds, this.timeSystem); | ||||
|         }, | ||||
|         timeSystem(newTimeSystem) { | ||||
|             this.drawAxis(this.bounds, newTimeSystem); | ||||
|         } | ||||
|     }, | ||||
|     mounted() { | ||||
|         if (this.renderingEngine === 'svg') { | ||||
|             this.useSVG = true; | ||||
|         } | ||||
|  | ||||
|         this.container = d3Selection.select(this.$refs.axisHolder); | ||||
|         this.svgElement = this.container.append("svg:svg"); | ||||
|         // draw x axis with labels. CSS is used to position them. | ||||
|         this.axisElement = this.svgElement.append("g") | ||||
|             .attr("class", "axis") | ||||
|             .attr('font-size', '1.3em') | ||||
|             .attr("transform", "translate(0,20)"); | ||||
|  | ||||
|         this.setDimensions(); | ||||
|         this.drawAxis(this.bounds, this.timeSystem); | ||||
|         this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL); | ||||
|     }, | ||||
|     destroyed() { | ||||
|         clearInterval(this.resizeTimer); | ||||
|     }, | ||||
|     methods: { | ||||
|         resize() { | ||||
|             if (this.$refs.axisHolder.clientWidth !== this.width) { | ||||
|                 this.setDimensions(); | ||||
|                 this.drawAxis(this.bounds, this.timeSystem); | ||||
|                 this.updateNowMarker(); | ||||
|             } | ||||
|         }, | ||||
|         updateNowMarker() { | ||||
|             if (this.openmct.time.clock() === undefined) { | ||||
|                 let nowMarker = document.querySelector('.nowMarker'); | ||||
|                 if (nowMarker) { | ||||
|                     nowMarker.parentNode.removeChild(nowMarker); | ||||
|                 } | ||||
|             } else { | ||||
|                 let nowMarker = document.querySelector('.nowMarker'); | ||||
|                 if (nowMarker) { | ||||
|                     const svgEl = d3Selection.select(this.svgElement).node(); | ||||
|                     let height = svgEl.style('height').replace('px', ''); | ||||
|                     height = Number(height) + this.contentHeight; | ||||
|                     nowMarker.style.height = height + 'px'; | ||||
|                     const now = this.xScale(Date.now()); | ||||
|                     nowMarker.style.left = now + this.offset + 'px'; | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         setDimensions() { | ||||
|             const axisHolder = this.$refs.axisHolder; | ||||
|             this.width = axisHolder.clientWidth; | ||||
|             this.offsetWidth = this.width - this.offset; | ||||
|  | ||||
|             this.height = Math.round(axisHolder.getBoundingClientRect().height); | ||||
|  | ||||
|             if (this.useSVG) { | ||||
|                 this.svgElement.attr("width", this.width); | ||||
|                 this.svgElement.attr("height", this.height); | ||||
|             } else { | ||||
|                 this.svgElement.attr("height", 50); | ||||
|             } | ||||
|         }, | ||||
|         drawAxis(bounds, timeSystem) { | ||||
|             this.setScale(bounds, timeSystem); | ||||
|             this.setAxis(bounds); | ||||
|             this.axisElement.call(this.xAxis); | ||||
|             this.updateNowMarker(); | ||||
|  | ||||
|         }, | ||||
|         setScale(bounds, timeSystem) { | ||||
|             if (!this.width) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (timeSystem === undefined) { | ||||
|                 timeSystem = this.openmct.time.timeSystem(); | ||||
|             } | ||||
|  | ||||
|             if (timeSystem.isUTCBased) { | ||||
|                 this.xScale = d3Scale.scaleUtc(); | ||||
|                 this.xScale.domain( | ||||
|                     [new Date(bounds.start), new Date(bounds.end)] | ||||
|                 ); | ||||
|             } else { | ||||
|                 this.xScale = d3Scale.scaleLinear(); | ||||
|                 this.xScale.domain( | ||||
|                     [bounds.start, bounds.end] | ||||
|                 ); | ||||
|             } | ||||
|  | ||||
|             this.xScale.range([PADDING, this.offsetWidth - PADDING * 2]); | ||||
|         }, | ||||
|         setAxis() { | ||||
|             this.xAxis = d3Axis.axisTop(this.xScale); | ||||
|             this.xAxis.tickFormat(utcMultiTimeFormat); | ||||
|  | ||||
|             if (this.width > 1800) { | ||||
|                 this.xAxis.ticks(this.offsetWidth / PIXELS_PER_TICK_WIDE); | ||||
|             } else { | ||||
|                 this.xAxis.ticks(this.offsetWidth / PIXELS_PER_TICK); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| </script> | ||||
							
								
								
									
										76
									
								
								src/ui/components/swim-lane/SwimLane.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								src/ui/components/swim-lane/SwimLane.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| <template> | ||||
| <div class="u-contents" | ||||
|      :class="{'c-swimlane': !isNested}" | ||||
| > | ||||
|  | ||||
|     <div class="c-swimlane__lane-label c-object-label" | ||||
|          :class="{'c-swimlane__lane-label--span-cols': (!spanRowsCount && !isNested)}" | ||||
|          :style="gridRowSpan" | ||||
|     > | ||||
|         <div v-if="iconClass" | ||||
|              class="c-object-label__type-icon" | ||||
|              :class="iconClass" | ||||
|         > | ||||
|         </div> | ||||
|  | ||||
|         <div class="c-object-label__name"> | ||||
|             <slot name="label"></slot> | ||||
|         </div> | ||||
|  | ||||
|     </div> | ||||
|     <div class="c-swimlane__lane-object" | ||||
|          :style="{'min-height': minHeight}" | ||||
|          :class="{'u-contents': showUcontents}" | ||||
|          data-selectable | ||||
|     > | ||||
|         <slot name="object"></slot> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|     props: { | ||||
|         iconClass: { | ||||
|             type: String, | ||||
|             default() { | ||||
|                 return ''; | ||||
|             } | ||||
|         }, | ||||
|         minHeight: { | ||||
|             type: String, | ||||
|             default() { | ||||
|                 return ''; | ||||
|             } | ||||
|         }, | ||||
|         showUcontents: { | ||||
|             type: Boolean, | ||||
|             default() { | ||||
|                 return false; | ||||
|             } | ||||
|         }, | ||||
|         isNested: { | ||||
|             type: Boolean, | ||||
|             default() { | ||||
|                 return false; | ||||
|             } | ||||
|         }, | ||||
|         spanRowsCount: { | ||||
|             type: Number, | ||||
|             default() { | ||||
|                 return 0; | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     computed: { | ||||
|         gridRowSpan() { | ||||
|             if (this.spanRowsCount) { | ||||
|                 return `grid-row: span ${this.spanRowsCount}`; | ||||
|             } else { | ||||
|                 return ''; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| </script> | ||||
							
								
								
									
										26
									
								
								src/ui/components/swim-lane/swimlane.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/ui/components/swim-lane/swimlane.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| .c-swimlane { | ||||
|     display: grid; | ||||
|     grid-template-columns: 100px 100px 1fr; | ||||
|     grid-column-gap: 1px; | ||||
|     grid-row-gap: 1px; | ||||
|     margin-bottom: 1px; | ||||
|     width: 100%; | ||||
|  | ||||
|     [class*='__lane-label'] { | ||||
|         background: rgba($colorBodyFg, 0.2); | ||||
|         color: $colorBodyFg; | ||||
|         padding: $interiorMarginSm; | ||||
|     } | ||||
|  | ||||
|     [class*='--span-cols'] { | ||||
|         grid-column: span 2; | ||||
|     } | ||||
|  | ||||
|     &__lane-object { | ||||
|         background: rgba(black, 0.1); | ||||
|  | ||||
|         .c-plan { | ||||
|             display: contents; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										42
									
								
								src/ui/components/timesystem-axis.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/ui/components/timesystem-axis.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| .c-timesystem-axis { | ||||
|     $h: 30px; | ||||
|     height: $h; | ||||
|  | ||||
|     svg { | ||||
|         $lineC: rgba($colorBodyFg, 0.3) !important; | ||||
|         text-rendering: geometricPrecision; | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
|  | ||||
|         .domain { | ||||
|             stroke: $lineC; | ||||
|         } | ||||
|  | ||||
|         .tick { | ||||
|             line { | ||||
|                 stroke: $lineC; | ||||
|             } | ||||
|  | ||||
|             text { | ||||
|                 // Tick labels | ||||
|                 fill: $colorBodyFg; | ||||
|                 paint-order: stroke; | ||||
|                 font-weight: bold; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     .nowMarker { | ||||
|         width: 2px; | ||||
|         position: absolute; | ||||
|         z-index: 10; | ||||
|         background: gray; | ||||
|  | ||||
|         & .icon-arrow-down { | ||||
|             font-size: large; | ||||
|             position: absolute; | ||||
|             top: -8px; | ||||
|             left: -8px; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										96
									
								
								src/ui/inspector/ElementItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/ui/inspector/ElementItem.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| <template> | ||||
| <li | ||||
|     draggable="true" | ||||
|     @dragstart="emitDragStartEvent" | ||||
|     @dragenter="onDragenter" | ||||
|     @dragover="onDragover" | ||||
|     @dragleave="onDragleave" | ||||
|     @drop="emitDropEvent" | ||||
| > | ||||
|     <div | ||||
|         class="c-tree__item c-elements-pool__item" | ||||
|         :class="{ | ||||
|             'is-context-clicked': contextClickActive, | ||||
|             'hover': hover | ||||
|         }" | ||||
|     > | ||||
|         <span | ||||
|             class="c-elements-pool__grippy c-grippy c-grippy--vertical-drag" | ||||
|         ></span> | ||||
|         <object-label | ||||
|             :domain-object="elementObject" | ||||
|             :object-path="[elementObject, parentObject]" | ||||
|             @context-click-active="setContextClickState" | ||||
|         /> | ||||
|     </div> | ||||
| </li> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import ObjectLabel from '../components/ObjectLabel.vue'; | ||||
|  | ||||
| export default { | ||||
|     components: { | ||||
|         ObjectLabel | ||||
|     }, | ||||
|     props: { | ||||
|         index: { | ||||
|             type: Number, | ||||
|             required: true, | ||||
|             default: () => { | ||||
|                 return 0; | ||||
|             } | ||||
|         }, | ||||
|         elementObject: { | ||||
|             type: Object, | ||||
|             required: true, | ||||
|             default: () => { | ||||
|                 return {}; | ||||
|             } | ||||
|         }, | ||||
|         parentObject: { | ||||
|             type: Object, | ||||
|             required: true, | ||||
|             default: () => { | ||||
|                 return {}; | ||||
|             } | ||||
|         }, | ||||
|         allowDrop: { | ||||
|             type: Boolean | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
|         return { | ||||
|             contextClickActive: false, | ||||
|             hover: false | ||||
|         }; | ||||
|     }, | ||||
|     methods: { | ||||
|         onDragover(event) { | ||||
|             event.preventDefault(); | ||||
|         }, | ||||
|         emitDropEvent(event) { | ||||
|             this.$emit('drop-custom', this.index); | ||||
|             this.hover = false; | ||||
|         }, | ||||
|         emitDragStartEvent(event) { | ||||
|             this.$emit('dragstart-custom', this.index); | ||||
|         }, | ||||
|         onDragenter(event) { | ||||
|             if (this.allowDrop) { | ||||
|                 this.hover = true; | ||||
|                 this.dragElement = event.target.parentElement; | ||||
|             } | ||||
|         }, | ||||
|         onDragleave(event) { | ||||
|             if (event.target.parentElement === this.dragElement) { | ||||
|                 this.hover = false; | ||||
|                 delete this.dragElement; | ||||
|             } | ||||
|         }, | ||||
|         setContextClickState(state) { | ||||
|             this.contextClickActive = state; | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| </script> | ||||
| @@ -8,34 +8,22 @@ | ||||
|     /> | ||||
|     <div | ||||
|         class="c-elements-pool__elements" | ||||
|         :class="{'is-dragging': isDragging}" | ||||
|     > | ||||
|         <ul | ||||
|             v-if="elements.length > 0" | ||||
|             id="inspector-elements-tree" | ||||
|             class="c-tree c-elements-pool__tree" | ||||
|         > | ||||
|             <li | ||||
|             <element-item | ||||
|                 v-for="(element, index) in elements" | ||||
|                 :key="element.identifier.key" | ||||
|                 @drop="moveTo(index)" | ||||
|                 @dragover="allowDrop" | ||||
|             > | ||||
|                 <div | ||||
|                     class="c-tree__item c-elements-pool__item" | ||||
|                     draggable="true" | ||||
|                     @dragstart="moveFrom(index)" | ||||
|                 > | ||||
|                     <span | ||||
|                         v-if="elements.length > 1 && isEditing" | ||||
|                         class="c-elements-pool__grippy c-grippy c-grippy--vertical-drag" | ||||
|                     ></span> | ||||
|                     <object-label | ||||
|                         :domain-object="element" | ||||
|                         :object-path="[element, parentObject]" | ||||
|                     /> | ||||
|                 </div> | ||||
|             </li> | ||||
|                 :index="index" | ||||
|                 :element-object="element" | ||||
|                 :parent-object="parentObject" | ||||
|                 :allow-drop="allowDrop" | ||||
|                 @dragstart-custom="moveFrom(index)" | ||||
|                 @drop-custom="moveTo(index)" | ||||
|             /> | ||||
|             <li | ||||
|                 class="js-last-place" | ||||
|                 @drop="moveToIndex(elements.length)" | ||||
| @@ -51,12 +39,12 @@ | ||||
| <script> | ||||
| import _ from 'lodash'; | ||||
| import Search from '../components/search.vue'; | ||||
| import ObjectLabel from '../components/ObjectLabel.vue'; | ||||
| import ElementItem from './ElementItem.vue'; | ||||
| 
 | ||||
| export default { | ||||
|     components: { | ||||
|         'Search': Search, | ||||
|         'ObjectLabel': ObjectLabel | ||||
|         'ElementItem': ElementItem | ||||
|     }, | ||||
|     inject: ['openmct'], | ||||
|     data() { | ||||
| @@ -65,8 +53,9 @@ export default { | ||||
|             isEditing: this.openmct.editor.isEditing(), | ||||
|             parentObject: undefined, | ||||
|             currentSearch: '', | ||||
|             isDragging: false, | ||||
|             selection: [] | ||||
|             selection: [], | ||||
|             contextClickTracker: {}, | ||||
|             allowDrop: false | ||||
|         }; | ||||
|     }, | ||||
|     mounted() { | ||||
| @@ -148,20 +137,15 @@ export default { | ||||
|                     && element.name.toLowerCase().search(this.currentSearch) !== -1; | ||||
|             }); | ||||
|         }, | ||||
|         allowDrop(event) { | ||||
|             event.preventDefault(); | ||||
|         }, | ||||
|         moveTo(moveToIndex) { | ||||
|             this.composition.reorder(this.moveFromIndex, moveToIndex); | ||||
|             if (this.allowDrop) { | ||||
|                 this.composition.reorder(this.moveFromIndex, moveToIndex); | ||||
|                 this.allowDrop = false; | ||||
|             } | ||||
|         }, | ||||
|         moveFrom(index) { | ||||
|             this.isDragging = true; | ||||
|             this.allowDrop = true; | ||||
|             this.moveFromIndex = index; | ||||
|             document.addEventListener('dragend', this.hideDragStyling); | ||||
|         }, | ||||
|         hideDragStyling() { | ||||
|             this.isDragging = false; | ||||
|             document.removeEventListener('dragend', this.hideDragStyling); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| @@ -29,7 +29,7 @@ | ||||
|                 handle="before" | ||||
|                 label="Elements" | ||||
|             > | ||||
|                 <elements /> | ||||
|                 <elements-pool /> | ||||
|             </pane> | ||||
|         </multipane> | ||||
|         <multipane | ||||
| @@ -55,7 +55,7 @@ | ||||
| <script> | ||||
| import multipane from '../layout/multipane.vue'; | ||||
| import pane from '../layout/pane.vue'; | ||||
| import Elements from './Elements.vue'; | ||||
| import ElementsPool from './ElementsPool.vue'; | ||||
| import Location from './Location.vue'; | ||||
| import Properties from './Properties.vue'; | ||||
| import ObjectName from './ObjectName.vue'; | ||||
| @@ -71,7 +71,7 @@ export default { | ||||
|         SavedStylesInspectorView, | ||||
|         multipane, | ||||
|         pane, | ||||
|         Elements, | ||||
|         ElementsPool, | ||||
|         Properties, | ||||
|         ObjectName, | ||||
|         Location, | ||||
|   | ||||
| @@ -15,9 +15,6 @@ | ||||
|     &__elements { | ||||
|         flex: 1 1 auto; | ||||
|         overflow: auto; | ||||
|         &.is-dragging { | ||||
|             li { opacity: 0.2; } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     .c-grippy { | ||||
| @@ -27,8 +24,16 @@ | ||||
|         transform: translateY(-2px); | ||||
|         width: $d; height: $d; | ||||
|     } | ||||
|  | ||||
|     &.is-context-clicked { | ||||
|         box-shadow: inset $colorItemTreeSelectedBg 0 0 0 1px; | ||||
|     } | ||||
|  | ||||
|     .hover { | ||||
|         background-color: $colorItemTreeSelectedBg; | ||||
|     } | ||||
| } | ||||
|  | ||||
| .js-last-place { | ||||
|     height: 10px; | ||||
| } | ||||
| } | ||||
| @@ -159,10 +159,14 @@ export default { | ||||
|             return this.views.filter(v => v.key === this.viewKey)[0] || {}; | ||||
|         }, | ||||
|         views() { | ||||
|             if (this.domainObject && (this.openmct.router.started !== true)) { | ||||
|                 return []; | ||||
|             } | ||||
|  | ||||
|             return this | ||||
|                 .openmct | ||||
|                 .objectViews | ||||
|                 .get(this.domainObject) | ||||
|                 .get(this.domainObject, this.openmct.router.path) | ||||
|                 .map((p) => { | ||||
|                     return { | ||||
|                         key: p.key, | ||||
| @@ -197,7 +201,7 @@ export default { | ||||
|             if (currentViewKey !== undefined) { | ||||
|                 let currentViewProvider = this.openmct.objectViews.getByProviderKey(currentViewKey); | ||||
|  | ||||
|                 return currentViewProvider.canEdit && currentViewProvider.canEdit(this.domainObject); | ||||
|                 return currentViewProvider.canEdit && currentViewProvider.canEdit(this.domainObject, this.openmct.router.path); | ||||
|             } | ||||
|  | ||||
|             return false; | ||||
|   | ||||
| @@ -134,8 +134,6 @@ import treeItem from './tree-item.vue'; | ||||
| import search from '../components/search.vue'; | ||||
| import uuid from 'uuid'; | ||||
|  | ||||
| const LOCAL_STORAGE_KEY__TREE_EXPANDED__OLD = 'mct-tree-expanded'; | ||||
| const LOCAL_STORAGE_KEY__EXPANDED_TREE_NODE = 'mct-expanded-tree-node'; | ||||
| const ROOT_PATH = 'browse'; | ||||
| const ITEM_BUFFER = 5; | ||||
|  | ||||
| @@ -237,6 +235,12 @@ export default { | ||||
|     }, | ||||
|     watch: { | ||||
|         syncTreeNavigation() { | ||||
|             // if there is an abort controller, then a search is in progress and will need to be canceled | ||||
|             if (this.abortController) { | ||||
|                 this.abortController.abort(); | ||||
|                 delete this.abortController; | ||||
|             } | ||||
|  | ||||
|             this.searchValue = ''; | ||||
|  | ||||
|             if (!this.openmct.router.path) { | ||||
| @@ -280,22 +284,19 @@ export default { | ||||
|  | ||||
|         await this.initialize(); | ||||
|  | ||||
|         let savedPath = this.getStoredTreePath(); | ||||
|         let rootComposition = await this.loadRoot(); | ||||
|         let path = ROOT_PATH; | ||||
|  | ||||
|         if (!rootComposition) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!savedPath) { | ||||
|             savedPath = ROOT_PATH; | ||||
|             if (!this.multipleRootChildren && rootComposition[0]) { | ||||
|                 let id = this.openmct.objects.makeKeyString(rootComposition[0].identifier); | ||||
|                 savedPath += '/' + id; | ||||
|             } | ||||
|         if (!this.multipleRootChildren && rootComposition[0]) { | ||||
|             let id = this.openmct.objects.makeKeyString(rootComposition[0].identifier); | ||||
|             path += '/' + id; | ||||
|         } | ||||
|  | ||||
|         this.beginNavigationRequest('jumpTo', savedPath); | ||||
|         this.beginNavigationRequest('jumpTo', path); | ||||
|     }, | ||||
|     created() { | ||||
|         this.getSearchResults = _.debounce(this.getSearchResults, 400); | ||||
| @@ -311,27 +312,11 @@ export default { | ||||
|             this.openmct.$injector.get('searchService'); | ||||
|  | ||||
|             window.addEventListener('resize', this.handleWindowResize); | ||||
|             this.backwardsCompatibilityCheck(); | ||||
|  | ||||
|             await this.calculateHeights(); | ||||
|  | ||||
|             return; | ||||
|         }, | ||||
|         backwardsCompatibilityCheck() { | ||||
|             let oldTreeExpanded = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY__TREE_EXPANDED__OLD)); | ||||
|  | ||||
|             if (oldTreeExpanded) { | ||||
|                 localStorage.removeItem(LOCAL_STORAGE_KEY__TREE_EXPANDED__OLD); | ||||
|             } | ||||
|  | ||||
|             let newTreeExpanded = this.getStoredTreePath(); | ||||
|  | ||||
|             if (newTreeExpanded) { | ||||
|                 // see if it's in a deprecated format | ||||
|                 if (newTreeExpanded.indexOf('mine') === 0) { | ||||
|                     localStorage.setItem(LOCAL_STORAGE_KEY__EXPANDED_TREE_NODE, JSON.stringify('browse/' + newTreeExpanded)); | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         async loadRoot() { | ||||
|             this.root = await this.openmct.objects.get('ROOT'); | ||||
|  | ||||
| @@ -364,7 +349,6 @@ export default { | ||||
|  | ||||
|             if (success && this.isLatestNavigationRequest(requestId)) { | ||||
|                 this.isLoading = false; | ||||
|                 this.storeCurrentTreePath(); | ||||
|             } | ||||
|         }, | ||||
|         isLatestNavigationRequest(requestId) { | ||||
| @@ -707,35 +691,55 @@ export default { | ||||
|             // clear any previous search results | ||||
|             this.searchResultItems = []; | ||||
|  | ||||
|             const promises = this.openmct.objects.search(this.searchValue) | ||||
|             // an abort controller will be passed in that will be used | ||||
|             // to cancel an active searches if necessary | ||||
|             this.abortController = new AbortController(); | ||||
|             const abortSignal = this.abortController.signal; | ||||
|  | ||||
|             const promises = this.openmct.objects.search(this.searchValue, abortSignal) | ||||
|                 .map(promise => promise | ||||
|                     .then(results => this.aggregateSearchResults(results))); | ||||
|                     .then(results => this.aggregateSearchResults(results, abortSignal))); | ||||
|  | ||||
|             Promise.all(promises).then(() => { | ||||
|                 this.searchLoading = false; | ||||
|             }).catch(reason => { | ||||
|                 // search aborted | ||||
|             }).finally(() => { | ||||
|                 if (this.abortController) { | ||||
|                     delete this.abortController; | ||||
|                 } | ||||
|             }); | ||||
|         }, | ||||
|         async aggregateSearchResults(results) { | ||||
|         async aggregateSearchResults(results, abortSignal) { | ||||
|             for (const result of results) { | ||||
|                 const objectPath = await this.openmct.objects.getOriginalPath(result.identifier); | ||||
|                 if (!abortSignal.aborted) { | ||||
|                     const objectPath = await this.openmct.objects.getOriginalPath(result.identifier); | ||||
|  | ||||
|                 // removing the item itself, as the path we pass to buildTreeItem is a parent path | ||||
|                 objectPath.shift(); | ||||
|                     // removing the item itself, as the path we pass to buildTreeItem is a parent path | ||||
|                     objectPath.shift(); | ||||
|  | ||||
|                 // if root, remove, we're not using in object path for tree | ||||
|                 let lastObject = objectPath.length ? objectPath[objectPath.length - 1] : false; | ||||
|                 if (lastObject && lastObject.type === 'root') { | ||||
|                     objectPath.pop(); | ||||
|                     // if root, remove, we're not using in object path for tree | ||||
|                     let lastObject = objectPath.length ? objectPath[objectPath.length - 1] : false; | ||||
|                     if (lastObject && lastObject.type === 'root') { | ||||
|                         objectPath.pop(); | ||||
|                     } | ||||
|  | ||||
|                     // we reverse the objectPath in the tree, so have to do it here first, | ||||
|                     // since this one is already in the correct direction | ||||
|                     let resultObject = this.buildTreeItem(result, objectPath.reverse()); | ||||
|  | ||||
|                     this.searchResultItems.push(resultObject); | ||||
|                 } | ||||
|  | ||||
|                 // we reverse the objectPath in the tree, so have to do it here first, | ||||
|                 // since this one is already in the correct direction | ||||
|                 let resultObject = this.buildTreeItem(result, objectPath.reverse()); | ||||
|  | ||||
|                 this.searchResultItems.push(resultObject); | ||||
|             } | ||||
|         }, | ||||
|         searchTree(value) { | ||||
|             // if an abort controller exists, regardless of the value passed in, | ||||
|             // there is an active search that should be cancled | ||||
|             if (this.abortController) { | ||||
|                 this.abortController.abort(); | ||||
|                 delete this.abortController; | ||||
|             } | ||||
|  | ||||
|             this.searchValue = value; | ||||
|             this.searchLoading = true; | ||||
|  | ||||
| @@ -745,16 +749,8 @@ export default { | ||||
|                 this.searchLoading = false; | ||||
|             } | ||||
|         }, | ||||
|         getStoredTreePath() { | ||||
|             return JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY__EXPANDED_TREE_NODE)); | ||||
|         }, | ||||
|         storeCurrentTreePath() { | ||||
|             if (!this.searchValue) { | ||||
|                 localStorage.setItem(LOCAL_STORAGE_KEY__EXPANDED_TREE_NODE, JSON.stringify(this.currentTreePath)); | ||||
|             } | ||||
|         }, | ||||
|         currentPathIsActivePath() { | ||||
|             return this.getStoredTreePath() === this.currentlyViewedObjectParentPath(); | ||||
|             return this.currentTreePath === this.currentlyViewedObjectParentPath(); | ||||
|         }, | ||||
|         currentlyViewedObjectId() { | ||||
|             let currentPath = this.openmct.router.currentLocation.path; | ||||
|   | ||||
| @@ -58,7 +58,7 @@ export default { | ||||
|         }; | ||||
|     }, | ||||
|     mounted() { | ||||
|         this.views = this.openmct.objectViews.get(this.domainObject).map((view) => { | ||||
|         this.views = this.openmct.objectViews.get(this.domainObject, this.objectPath).map((view) => { | ||||
|             view.callBack = () => { | ||||
|                 return this.setView(view); | ||||
|             }; | ||||
|   | ||||
| @@ -39,10 +39,16 @@ define(['EventEmitter'], function (EventEmitter) { | ||||
|     /** | ||||
|      * @private for platform-internal use | ||||
|      * @param {*} item the object to be viewed | ||||
|      * @param {array} objectPath - The current contextual object path of the view object | ||||
|      *                             eg current domainObject is located under MyItems which is under Root | ||||
|      * @returns {module:openmct.ViewProvider[]} any providers | ||||
|      *          which can provide views of this object | ||||
|      */ | ||||
|     ViewRegistry.prototype.get = function (item) { | ||||
|     ViewRegistry.prototype.get = function (item, objectPath) { | ||||
|         if (objectPath === undefined) { | ||||
|             throw "objectPath must be provided to get applicable views for an object"; | ||||
|         } | ||||
|  | ||||
|         function byPriority(providerA, providerB) { | ||||
|             let priorityA = providerA.priority ? providerA.priority(item) : DEFAULT_VIEW_PRIORITY; | ||||
|             let priorityB = providerB.priority ? providerB.priority(item) : DEFAULT_VIEW_PRIORITY; | ||||
| @@ -52,7 +58,7 @@ define(['EventEmitter'], function (EventEmitter) { | ||||
|  | ||||
|         return this.getAllProviders() | ||||
|             .filter(function (provider) { | ||||
|                 return provider.canView(item); | ||||
|                 return provider.canView(item, objectPath); | ||||
|             }).sort(byPriority); | ||||
|     }; | ||||
|  | ||||
| @@ -181,6 +187,8 @@ define(['EventEmitter'], function (EventEmitter) { | ||||
|      * @memberof module:openmct.ViewProvider# | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      *        to be viewed | ||||
|      * @param {array} objectPath - The current contextual object path of the view object | ||||
|      *                             eg current domainObject is located under MyItems which is under Root | ||||
|      * @returns {boolean} 'true' if the view applies to the provided object, | ||||
|      *          otherwise 'false'. | ||||
|      */ | ||||
| @@ -201,6 +209,8 @@ define(['EventEmitter'], function (EventEmitter) { | ||||
|      * @memberof module:openmct.ViewProvider# | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      *        to be edited | ||||
|      * @param {array} objectPath - The current contextual object path of the view object | ||||
|      *                             eg current domainObject is located under MyItems which is under Root | ||||
|      * @returns {boolean} 'true' if the view can be used to edit the provided object, | ||||
|      *          otherwise 'false'. | ||||
|      */ | ||||
|   | ||||
| @@ -43,7 +43,7 @@ define([ | ||||
|                 mutable = undefined; | ||||
|             } | ||||
|  | ||||
|             if (openmct.objects.supportsMutation(object)) { | ||||
|             if (openmct.objects.supportsMutation(object.identifier)) { | ||||
|                 mutable = openmct.objects._toMutable(object); | ||||
|             } | ||||
|  | ||||
| @@ -100,13 +100,13 @@ define([ | ||||
|  | ||||
|                 document.title = browseObject.name; //change document title to current object in main view | ||||
|  | ||||
|                 if (currentProvider && currentProvider.canView(browseObject)) { | ||||
|                 if (currentProvider && currentProvider.canView(browseObject, openmct.router.path)) { | ||||
|                     viewObject(browseObject, currentProvider); | ||||
|  | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 let defaultProvider = openmct.objectViews.get(browseObject)[0]; | ||||
|                 let defaultProvider = openmct.objectViews.get(browseObject, openmct.router.path)[0]; | ||||
|                 if (defaultProvider) { | ||||
|                     openmct.router.updateParams({ | ||||
|                         view: defaultProvider.key | ||||
|   | ||||
		Reference in New Issue
	
	Block a user