diff --git a/platform/commonUI/edit/res/templates/edit-object.html b/platform/commonUI/edit/res/templates/edit-object.html index a27016a3a4..c7339d0c4e 100644 --- a/platform/commonUI/edit/res/templates/edit-object.html +++ b/platform/commonUI/edit/res/templates/edit-object.html @@ -2,13 +2,20 @@ mct-object="domainObject" ng-model="representation"> -
- +
+ + + +
+ toolbar="toolbar" + mct-object="representation.selected.key && domainObject">
diff --git a/platform/features/layout/bundle.json b/platform/features/layout/bundle.json index 67d91281fe..760cd2a686 100644 --- a/platform/features/layout/bundle.json +++ b/platform/features/layout/bundle.json @@ -10,6 +10,14 @@ "type": "layout", "templateUrl": "templates/layout.html", "uses": [ "composition" ] + }, + { + "key": "fixed", + "name": "Fixed Position", + "glyph": "3", + "type": "telemetry.panel", + "templateUrl": "templates/fixed.html", + "uses": [ "composition" ] } ], "representations": [ @@ -23,6 +31,11 @@ "key": "LayoutController", "implementation": "LayoutController.js", "depends": [ "$scope" ] + }, + { + "key": "FixedController", + "implementation": "FixedController.js", + "depends": [ "$scope", "telemetrySubscriber", "telemetryFormatter" ] } ], "types": [ diff --git a/platform/features/layout/res/templates/fixed.html b/platform/features/layout/res/templates/fixed.html new file mode 100644 index 0000000000..b91f3e524b --- /dev/null +++ b/platform/features/layout/res/templates/fixed.html @@ -0,0 +1,36 @@ + + +
+ + +
+
+ + +
+ +
+ {{childObject.getModel().name}} +
+
+ {{controller.getValue(childObject.getId())}} +
+ + + + + + + +
+ +
\ No newline at end of file diff --git a/platform/features/layout/src/FixedController.js b/platform/features/layout/src/FixedController.js new file mode 100644 index 0000000000..9dc59c1764 --- /dev/null +++ b/platform/features/layout/src/FixedController.js @@ -0,0 +1,276 @@ +/*global define*/ + +define( + ['./LayoutDrag'], + function (LayoutDrag) { + "use strict"; + + var DEFAULT_DIMENSIONS = [ 2, 1 ], + DEFAULT_GRID_SIZE = [64, 16], + DEFAULT_GRID_EXTENT = [4, 4]; + + /** + * The FixedController is responsible for supporting the + * Fixed Position view. It arranges frames according to saved + * configuration and provides methods for updating these based on + * mouse movement. + * @constructor + * @param {Scope} $scope the controller's Angular scope + */ + function FixedController($scope, telemetrySubscriber, telemetryFormatter) { + var gridSize = DEFAULT_GRID_SIZE, + gridExtent = DEFAULT_GRID_EXTENT, + activeDrag, + activeDragId, + subscription, + values = {}, + cellStyles = [], + rawPositions = {}, + positions = {}; + + // Utility function to copy raw positions from configuration, + // without writing directly to configuration (to avoid triggering + // persistence from watchers during drags). + function shallowCopy(obj, keys) { + var copy = {}; + keys.forEach(function (k) { + copy[k] = obj[k]; + }); + return copy; + } + + // Refresh cell styles (e.g. because grid extent changed) + function refreshCellStyles() { + var x, y; + + cellStyles = []; + + for (x = 0; x < gridExtent[0]; x += 1) { + for (y = 0; y < gridExtent[1]; y += 1) { + // Position blocks; subtract out border size from w/h + cellStyles.push({ + left: x * gridSize[0] + 'px', + top: y * gridSize[1] + 'px', + width: gridSize[0] - 1 + 'px', + height: gridSize[1] - 1 + 'px' + }); + } + } + } + + // Convert from { positions: ..., dimensions: ... } to an + // apropriate ng-style argument, to position frames. + function convertPosition(raw) { + // Multiply position/dimensions by grid size + return { + left: (gridSize[0] * raw.position[0]) + 'px', + top: (gridSize[1] * raw.position[1]) + 'px', + width: (gridSize[0] * raw.dimensions[0]) + 'px', + height: (gridSize[1] * raw.dimensions[1]) + 'px' + }; + } + + // Generate a default position (in its raw format) for a frame. + // Use an index to ensure that default positions are unique. + function defaultPosition(index) { + return { + position: [index, index], + dimensions: DEFAULT_DIMENSIONS + }; + } + + // Store a computed position for a contained frame by its + // domain object id. Called in a forEach loop, so arguments + // are as expected there. + function populatePosition(id, index) { + rawPositions[id] = + rawPositions[id] || defaultPosition(index || 0); + positions[id] = + convertPosition(rawPositions[id]); + } + + // Compute panel positions based on the layout's object model + function lookupPanels(model) { + var configuration = $scope.configuration || {}, + ids = (model || {}).composition || []; + + // Pull panel positions from configuration + rawPositions = shallowCopy(configuration.elements || {}, ids); + + // Clear prior computed positions + positions = {}; + + // Update width/height that we are tracking + gridSize = (model || {}).layoutGrid || DEFAULT_GRID_SIZE; + + // Compute positions and add defaults where needed + ids.forEach(populatePosition); + } + + // Update the displayed value for this object + function updateValue(telemetryObject) { + var id = telemetryObject && telemetryObject.getId(); + if (id) { + values[id] = telemetryFormatter.formatRangeValue( + subscription.getRangeValue(telemetryObject) + ); + } + } + + // Update telemetry values based on new data available + function updateValues() { + if (subscription) { + subscription.getTelemetryObjects().forEach(updateValue); + } + } + + // Free up subscription to telemetry + function releaseSubscription() { + if (subscription) { + subscription.unsubscribe(); + subscription = undefined; + } + } + + // Subscribe to telemetry updates for this domain object + function subscribe(domainObject) { + // Clear any old values + values = {}; + + // Release existing subscription (if any) + if (subscription) { + subscription.unsubscribe(); + } + + // Make a new subscription + subscription = domainObject && + telemetrySubscriber.subscribe(domainObject, updateValues); + } + + // Position panes when the model field changes + $scope.$watch("model", lookupPanels); + + // Subscribe to telemetry when an object is available + $scope.$watch("domainObject", subscribe); + + // Free up subscription on destroy + $scope.$on("$destroy", releaseSubscription); + + // Initialize styles (position etc.) for cells + refreshCellStyles(); + + return { + /** + * Get styles for all background cells, as will populate the + * ng-style tag. + * @memberof FixedController# + * @returns {Array} cell styles + */ + getCellStyles: function () { + return cellStyles; + }, + /** + * Get the current data value for the specified domain object. + * @memberof FixedController# + * @param {string} id the domain object identifier + * @returns {string} the displayable data value + */ + getValue: function (id) { + return values[id]; + }, + /** + * Set the size of the viewable fixed position area. + * @memberof FixedController# + * @param bounds the width/height, as reported by mct-resize + */ + setBounds: function (bounds) { + var w = Math.ceil(bounds.width / gridSize[0]), + h = Math.ceil(bounds.height / gridSize[1]); + if (w !== gridExtent[0] || h !== gridExtent[1]) { + gridExtent = [w, h]; + refreshCellStyles(); + } + }, + /** + * Get a style object for a frame with the specified domain + * object identifier, suitable for use in an `ng-style` + * directive to position a frame as configured for this layout. + * @param {string} id the object identifier + * @returns {Object.} an object with + * appropriate left, width, etc fields for positioning + */ + getStyle: function (id) { + // Called in a loop, so just look up; the "positions" + // object is kept up to date by a watch. + return positions[id]; + }, + /** + * Start a drag gesture to move/resize a frame. + * + * The provided position and dimensions factors will determine + * whether this is a move or a resize, and what type it + * will be. For instance, a position factor of [1, 1] + * will move a frame along with the mouse as the drag + * proceeds, while a dimension factor of [0, 0] will leave + * dimensions unchanged. Combining these in different + * ways results in different handles; a position factor of + * [1, 0] and a dimensions factor of [-1, 0] will implement + * a left-edge resize, as the horizontal position will move + * with the mouse while the horizontal dimensions shrink in + * kind (and vertical properties remain unmodified.) + * + * @param {string} id the identifier of the domain object + * in the frame being manipulated + * @param {number[]} posFactor the position factor + * @param {number[]} dimFactor the dimensions factor + */ + startDrag: function (id, posFactor, dimFactor) { + activeDragId = id; + activeDrag = new LayoutDrag( + rawPositions[id], + posFactor, + dimFactor, + gridSize + ); + }, + /** + * Continue an active drag gesture. + * @param {number[]} delta the offset, in pixels, + * of the current pointer position, relative + * to its position when the drag started + */ + continueDrag: function (delta) { + if (activeDrag) { + rawPositions[activeDragId] = + activeDrag.getAdjustedPosition(delta); + populatePosition(activeDragId); + } + }, + /** + * End the active drag gesture. This will update the + * view configuration. + */ + endDrag: function () { + // Write to configuration; this is watched and + // saved by the EditRepresenter. + $scope.configuration = + $scope.configuration || {}; + // Make sure there is a "panels" field in the + // view configuration. + $scope.configuration.elements = + $scope.configuration.elements || {}; + // Store the position of this panel. + $scope.configuration.elements[activeDragId] = + rawPositions[activeDragId]; + // Mark this object as dirty to encourage persistence + if ($scope.commit) { + $scope.commit("Moved element."); + } + } + }; + + } + + return FixedController; + } +); \ No newline at end of file diff --git a/platform/features/layout/test/FixedControllerSpec.js b/platform/features/layout/test/FixedControllerSpec.js new file mode 100644 index 0000000000..0293905909 --- /dev/null +++ b/platform/features/layout/test/FixedControllerSpec.js @@ -0,0 +1,162 @@ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ["../src/FixedController"], + function (FixedController) { + "use strict"; + + describe("The Fixed Position controller", function () { + var mockScope, + mockSubscriber, + mockFormatter, + mockDomainObject, + mockSubscription, + testGrid, + testModel, + testValues, + controller; + + // Utility function; find a watch for a given expression + function findWatch(expr) { + var watch; + mockScope.$watch.calls.forEach(function (call) { + if (call.args[0] === expr) { + watch = call.args[1]; + } + }); + return watch; + } + + function makeMockDomainObject(id) { + var mockObject = jasmine.createSpyObj( + 'domainObject-' + id, + [ 'getId' ] + ); + mockObject.getId.andReturn(id); + return mockObject; + } + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + '$scope', + [ "$on", "$watch" ] + ); + mockSubscriber = jasmine.createSpyObj( + 'telemetrySubscriber', + [ 'subscribe' ] + ); + mockFormatter = jasmine.createSpyObj( + 'telemetryFormatter', + [ 'formatDomainValue', 'formatRangeValue' ] + ); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getId', 'getModel', 'getCapability' ] + ); + mockSubscription = jasmine.createSpyObj( + 'subscription', + [ 'unsubscribe', 'getTelemetryObjects', 'getRangeValue' ] + ); + + testGrid = [ 123, 456 ]; + testModel = { + composition: ['a', 'b', 'c'], + layoutGrid: testGrid + }; + testValues = { a: 10, b: 42, c: 31.42 }; + + mockSubscriber.subscribe.andReturn(mockSubscription); + mockSubscription.getTelemetryObjects.andReturn( + testModel.composition.map(makeMockDomainObject) + ); + mockSubscription.getRangeValue.andCallFake(function (o) { + return testValues[o.getId()]; + }); + mockFormatter.formatRangeValue.andCallFake(function (v) { + return "Formatted " + v; + }); + + controller = new FixedController( + mockScope, + mockSubscriber, + mockFormatter + ); + }); + + it("provides styles for cells", function () { + expect(controller.getCellStyles()) + .toEqual(jasmine.any(Array)); + }); + + it("subscribes when a domain object is available", function () { + mockScope.domainObject = mockDomainObject; + findWatch("domainObject")(mockDomainObject); + expect(mockSubscriber.subscribe).toHaveBeenCalledWith( + mockDomainObject, + jasmine.any(Function) + ); + }); + + it("releases subscriptions when domain objects change", function () { + mockScope.domainObject = mockDomainObject; + + // First pass - should simply should subscribe + findWatch("domainObject")(mockDomainObject); + expect(mockSubscription.unsubscribe).not.toHaveBeenCalled(); + expect(mockSubscriber.subscribe.calls.length).toEqual(1); + + // Object changes - should unsubscribe then resubscribe + findWatch("domainObject")(mockDomainObject); + expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + expect(mockSubscriber.subscribe.calls.length).toEqual(2); + }); + + it("configures view based on model", function () { + mockScope.model = testModel; + findWatch("model")(mockScope.model); + // Should have styles for all elements of composition + expect(controller.getStyle('a')).toBeDefined(); + expect(controller.getStyle('b')).toBeDefined(); + expect(controller.getStyle('c')).toBeDefined(); + expect(controller.getStyle('d')).not.toBeDefined(); + }); + + it("provides values for telemetry elements", function () { + // Initialize + mockScope.domainObject = mockDomainObject; + mockScope.model = testModel; + findWatch("domainObject")(mockDomainObject); + findWatch("model")(mockScope.model); + + // Invoke the subscription callback + mockSubscriber.subscribe.mostRecentCall.args[1](); + + // Formatted values should be available + expect(controller.getValue('a')).toEqual("Formatted 10"); + expect(controller.getValue('b')).toEqual("Formatted 42"); + expect(controller.getValue('c')).toEqual("Formatted 31.42"); + }); + + it("adds grid cells to fill boundaries", function () { + var s1 = { + width: testGrid[0] * 8, + height: testGrid[1] * 4 + }, + s2 = { + width: testGrid[0] * 10, + height: testGrid[1] * 6 + }; + + mockScope.model = testModel; + findWatch("model")(mockScope.model); + + // Set first bounds + controller.setBounds(s1); + expect(controller.getCellStyles().length).toEqual(32); // 8 * 4 + // Set new bounds + controller.setBounds(s2); + expect(controller.getCellStyles().length).toEqual(60); // 10 * 6 + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/layout/test/suite.json b/platform/features/layout/test/suite.json index 9fbd76b85f..6e62994ff5 100644 --- a/platform/features/layout/test/suite.json +++ b/platform/features/layout/test/suite.json @@ -1,4 +1,5 @@ [ + "FixedController", "LayoutController", "LayoutDrag" ] \ No newline at end of file diff --git a/platform/telemetry/src/TelemetrySubscription.js b/platform/telemetry/src/TelemetrySubscription.js index 046d00dc80..b7deb5d449 100644 --- a/platform/telemetry/src/TelemetrySubscription.js +++ b/platform/telemetry/src/TelemetrySubscription.js @@ -74,7 +74,10 @@ define( // Play back from queue if we are lossless while (!pool.isEmpty()) { updateValuesFromPool(); - callback(); + // Fire callback, if one was provided + if (callback) { + callback(); + } } // Clear the pending flag so that future updates will