diff --git a/platform/features/plot/bundle.json b/platform/features/plot/bundle.json index b9d21894bb..b32a237d10 100644 --- a/platform/features/plot/bundle.json +++ b/platform/features/plot/bundle.json @@ -21,7 +21,7 @@ { "key": "PlotController", "implementation": "PlotController.js", - "depends": [ "$scope", "telemetryFormatter" ] + "depends": [ "$scope", "telemetryFormatter", "telemetrySubscriber" ] } ] } diff --git a/platform/features/plot/res/templates/plot.html b/platform/features/plot/res/templates/plot.html index 4d95150cab..1f88ff9dd6 100644 --- a/platform/features/plot/res/templates/plot.html +++ b/platform/features/plot/res/templates/plot.html @@ -1,4 +1,3 @@ - @@ -114,7 +113,7 @@ - +
@@ -143,9 +142,6 @@
-
- - +
- diff --git a/platform/features/plot/src/PlotController.js b/platform/features/plot/src/PlotController.js index 9b8c60b351..ef53efd87f 100644 --- a/platform/features/plot/src/PlotController.js +++ b/platform/features/plot/src/PlotController.js @@ -5,13 +5,13 @@ */ define( [ - "./elements/PlotPreparer", + "./elements/PlotUpdater", "./elements/PlotPalette", "./elements/PlotAxis", "./modes/PlotModeOptions", "./SubPlotFactory" ], - function (PlotPreparer, PlotPalette, PlotAxis, PlotModeOptions, SubPlotFactory) { + function (PlotUpdater, PlotPalette, PlotAxis, PlotModeOptions, SubPlotFactory) { "use strict"; var AXIS_DEFAULTS = [ @@ -30,10 +30,13 @@ define( * * @constructor */ - function PlotController($scope, telemetryFormatter) { + function PlotController($scope, telemetryFormatter, telemetrySubscriber) { var subPlotFactory = new SubPlotFactory(telemetryFormatter), modeOptions = new PlotModeOptions([], subPlotFactory), subplots = [], + cachedObjects = [], + updater, + subscription, domainOffset; // Populate the scope with axis information (specifically, options @@ -45,38 +48,6 @@ define( ]; } - // Respond to newly-available telemetry data; update the - // drawing area accordingly. - function plotTelemetry() { - var prepared, datas, telemetry; - - // Get a reference to the TelemetryController - telemetry = $scope.telemetry; - - // Nothing to plot without TelemetryController - if (!telemetry) { - return; - } - - // Ensure axes have been initialized (we will want to - // get the active axis below) - if (!$scope.axes) { - setupAxes(telemetry.getMetadata()); - } - - // Get data sets - datas = telemetry.getResponse(); - - // Prepare data sets for rendering - prepared = new PlotPreparer( - datas, - ($scope.axes[0].active || {}).key, - ($scope.axes[1].active || {}).key - ); - - modeOptions.getModeHandler().plotTelemetry(prepared); - } - // Trigger an update of a specific subplot; // used in a loop to update all subplots. function updateSubplot(subplot) { @@ -86,10 +57,13 @@ define( // Set up available modes (stacked/overlaid), based on the // set of telemetry objects in this plot view. function setupModes(telemetryObjects) { - modeOptions = new PlotModeOptions( - telemetryObjects || [], - subPlotFactory - ); + if (cachedObjects !== telemetryObjects) { + cachedObjects = telemetryObjects; + modeOptions = new PlotModeOptions( + telemetryObjects || [], + subPlotFactory + ); + } } // Update all sub-plots @@ -99,9 +73,47 @@ define( .forEach(updateSubplot); } - $scope.$watch("telemetry.getTelemetryObjects()", setupModes); - $scope.$watch("telemetry.getMetadata()", setupAxes); - $scope.$on("telemetryUpdate", plotTelemetry); + // Reinstantiate the plot updater (e.g. because we have a + // new subscription.) This will clear the plot. + function recreateUpdater() { + updater = new PlotUpdater( + subscription, + ($scope.axes[0].active || {}).key, + ($scope.axes[1].active || {}).key + ); + } + + // Handle new telemetry data in this plot + function updateValues() { + if (subscription) { + setupModes(subscription.getTelemetryObjects()); + } + if (updater) { + updater.update(); + modeOptions.getModeHandler().plotTelemetry(updater); + } + update(); + } + + // Create a new subscription; telemetrySubscriber gets + // to do the meaningful work here. + function subscribe(domainObject) { + if (subscription) { + subscription.unsubscribe(); + } + subscription = domainObject && telemetrySubscriber.subscribe( + domainObject, + updateValues, + true // Lossless + ); + if (subscription) { + setupModes(subscription.getTelemetryObjects()); + setupAxes(subscription.getMetadata()); + recreateUpdater(); + } + } + + $scope.$watch('domainObject', subscribe); return { /** @@ -156,7 +168,7 @@ define( */ setMode: function (mode) { modeOptions.setMode(mode); - plotTelemetry(); + updateValues(); }, /** * Get all individual plots contained within this Plot view. @@ -169,7 +181,15 @@ define( /** * Explicitly update all plots. */ - update: update + update: update, + /** + * Check if a request is pending (to show the wait spinner) + */ + isRequestPending: function () { + // Placeholder; this should reflect request state + // when requesting historical telemetry + return false; + } }; } diff --git a/platform/features/plot/src/elements/PlotUpdater.js b/platform/features/plot/src/elements/PlotUpdater.js new file mode 100644 index 0000000000..ed81035b6b --- /dev/null +++ b/platform/features/plot/src/elements/PlotUpdater.js @@ -0,0 +1,215 @@ +/*global define,Float32Array*/ + +/** + * Prepares data to be rendered in a GL Plot. Handles + * the conversion from data API to displayable buffers. + */ +define( + function () { + 'use strict'; + + var MAX_POINTS = 86400, + INITIAL_SIZE = 675; // 1/128 of MAX_POINTS + + /** + * The PlotPreparer is responsible for handling data sets and + * preparing them to be rendered. It creates a WebGL-plottable + * Float32Array for each trace, and tracks the boundaries of the + * data sets (since this is convenient to do during the same pass). + * @constructor + * @param {Telemetry[]} datas telemetry data objects + * @param {string} domain the key to use when looking up domain values + * @param {string} range the key to use when looking up range values + */ + function PlotUpdater(subscription, domain, range, maxPoints) { + var max = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], + min = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], + x, + y, + domainOffset, + buffers = {}, + lengths = {}, + lengthArray = [], + bufferArray = []; + + // Double the size of a Float32Array + function doubleSize(buffer) { + var doubled = new Float32Array(buffer.length * 2); + doubled.set(buffer); // Copy contents of original + return doubled; + } + + // Make sure there is enough space in a buffer to accomodate a + // new point at the specified index. This will updates buffers[id] + // if necessary. + function ensureBufferSize(buffer, id, index) { + // Check if we don't have enough room + if (index > (buffer.length / 2 - 1)) { + // If we don't, can we expand? + if (index < maxPoints) { + // Double the buffer size + buffer = buffers[id] = doubleSize(buffer); + } else { + // Just shift the existing buffer + buffer.set(buffer.subarray(2)); + } + } + + return buffer; + } + + // Add data to the plot. + function addData(obj) { + var id = obj.getId(), + index = lengths[id] || 0, + buffer = buffers[id], + domainValue = subscription.getDomainValue(obj, domain), + rangeValue = subscription.getRangeValue(obj, range); + + // If we don't already have a data buffer for that ID, + // make one. + if (!buffer) { + buffer = new Float32Array(INITIAL_SIZE); + buffers[id] = buffer; + } + + // Make sure there's data to add, and then add it + if (domainValue !== undefined && rangeValue !== undefined && + (index < 1 || domainValue !== buffer[index * 2 - 2])) { + // Use the first observed domain value as a domainOffset + domainOffset = domainOffset !== undefined ? + domainOffset : domainValue; + // Ensure there is space for the new buffer + buffer = ensureBufferSize(buffer, id, index); + // Account for shifting that may have occurred + index = Math.min(index, maxPoints - 1); + // Update the buffer + buffer[index * 2] = domainValue - domainOffset; + buffer[index * 2 + 1] = rangeValue; + // Update length + lengths[id] = Math.min(index + 1, maxPoints); + // Observe max/min range values + max[1] = Math.max(max[1], rangeValue); + min[1] = Math.min(min[1], rangeValue); + } + + return buffer; + } + + // Update min/max domain values for these objects + function updateDomainExtrema(objects) { + max[0] = Number.NEGATIVE_INFINITY; + min[0] = Number.POSITIVE_INFINITY; + objects.forEach(function (obj) { + var id = obj.getId(), + buffer = buffers[id], + length = lengths[id], + low = buffer[0] + domainOffset, + high = buffer[length * 2 - 2] + domainOffset; + max[0] = Math.max(high, max[0]); + min[0] = Math.min(low, min[0]); + }); + } + + // Handle new telemetry data + function update() { + var objects = subscription.getTelemetryObjects(); + bufferArray = objects.map(addData); + lengthArray = objects.map(function (obj) { + return lengths[obj.getId()]; + }); + updateDomainExtrema(objects); + } + + // Prepare buffers and related state for this object + function prepare(telemetryObject) { + var id = telemetryObject.getId(); + lengths[id] = 0; + buffers[id] = new Float32Array(INITIAL_SIZE); + lengthArray.push(lengths[id]); + bufferArray.push(buffers[id]); + } + + // Use a default MAX_POINTS if none is provided + maxPoints = maxPoints || MAX_POINTS; + + // Initially prepare state for these objects. + // Note that this may be an empty array at this time, + // so we also need to check during update cycles. + subscription.getTelemetryObjects().forEach(prepare); + + return { + /** + * Get the dimensions which bound all data in the provided + * data sets. This is given as a two-element array where the + * first element is domain, and second is range. + * @returns {number[]} the dimensions which bound this data set + */ + getDimensions: function () { + // Pad range if necessary + return (max[1] === min[1]) ? + [max[0] - min[0], 2.0 ] : + [max[0] - min[0], max[1] - min[1]]; + }, + /** + * Get the origin of this data set's boundary. + * This is given as a two-element array where the + * first element is domain, and second is range. + * The domain value here is not adjusted by the domain offset. + * @returns {number[]} the origin of this data set's boundary + */ + getOrigin: function () { + // Pad range if necessary + return (max[1] === min[1]) ? [ min[0], min[1] - 1.0 ] : min; + }, + /** + * Get the domain offset; this offset will have been subtracted + * from all domain values in all buffers returned by this + * preparer, in order to minimize loss-of-precision due to + * conversion to the 32-bit float format needed by WebGL. + * @returns {number} the domain offset + */ + getDomainOffset: function () { + return domainOffset; + }, + /** + * Get all renderable buffers for this data set. This will + * be returned as an array which can be correlated back to + * the provided telemetry data objects (from the constructor + * call) by index. + * + * Internally, these are flattened; each buffer contains a + * sequence of alternating domain and range values. + * + * All domain values in all buffers will have been adjusted + * from their original values by subtraction of the domain + * offset; this minimizes loss-of-precision resulting from + * the conversion to 32-bit floats, which may otherwise + * cause aliasing artifacts (particularly for timestamps) + * + * @returns {Float32Array[]} the buffers for these traces + */ + getBuffers: function () { + return bufferArray; + }, + /** + * Get the number of points in the buffer with the specified + * index. Buffers are padded to minimize memory allocations, + * so user code will need this information to know how much + * data to plot. + * @returns {number} the number of points in this buffer + */ + getLength: function (index) { + return lengthArray[index] || 0; + }, + /** + * Update with latest data. + */ + update: update + }; + } + + return PlotUpdater; + + } +); \ No newline at end of file diff --git a/platform/features/plot/src/modes/PlotOverlayMode.js b/platform/features/plot/src/modes/PlotOverlayMode.js index de4445961e..342bbcc1c3 100644 --- a/platform/features/plot/src/modes/PlotOverlayMode.js +++ b/platform/features/plot/src/modes/PlotOverlayMode.js @@ -38,7 +38,7 @@ define( return { buffer: buf, color: PlotPalette.getFloatColor(i), - points: buf.length / 2 + points: prepared.getLength(i) }; }); diff --git a/platform/features/plot/src/modes/PlotStackMode.js b/platform/features/plot/src/modes/PlotStackMode.js index 25ad2e0304..5fe3895a61 100644 --- a/platform/features/plot/src/modes/PlotStackMode.js +++ b/platform/features/plot/src/modes/PlotStackMode.js @@ -35,7 +35,7 @@ define( subplot.getDrawingObject().lines = [{ buffer: buffer, color: PlotPalette.getFloatColor(0), - points: buffer.length / 2 + points: prepared.getLength(index) }]; subplot.update(); diff --git a/platform/features/plot/test/PlotControllerSpec.js b/platform/features/plot/test/PlotControllerSpec.js index c5c7d45005..a8523502ee 100644 --- a/platform/features/plot/test/PlotControllerSpec.js +++ b/platform/features/plot/test/PlotControllerSpec.js @@ -1,4 +1,4 @@ -/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ +/*global define,Promise,describe,it,expect,xit,beforeEach,waitsFor,jasmine*/ /** * MergeModelsSpec. Created by vwoeltje on 11/6/14. @@ -11,12 +11,11 @@ define( describe("The plot controller", function () { var mockScope, mockFormatter, - mockTelemetry, // mock telemetry controller - mockData, + mockSubscriber, + mockSubscription, mockDomainObject, controller; - function echo(i) { return i; } beforeEach(function () { mockScope = jasmine.createSpyObj( @@ -27,33 +26,32 @@ define( "formatter", [ "formatDomainValue", "formatRangeValue" ] ); - mockTelemetry = jasmine.createSpyObj( - "telemetry", - [ "getResponse", "getMetadata" ] - ); - mockData = jasmine.createSpyObj( - "data", - [ "getPointCount", "getDomainValue", "getRangeValue" ] - ); mockDomainObject = jasmine.createSpyObj( "domainObject", [ "getId", "getModel", "getCapability" ] ); - - mockScope.telemetry = mockTelemetry; - mockTelemetry.getResponse.andReturn([mockData]); - mockData.getPointCount.andReturn(2); - mockData.getDomainValue.andCallFake(echo); - mockData.getRangeValue.andCallFake(echo); - - controller = new PlotController(mockScope, mockFormatter); - }); - - it("listens for telemetry updates", function () { - expect(mockScope.$on).toHaveBeenCalledWith( - "telemetryUpdate", - jasmine.any(Function) + mockSubscriber = jasmine.createSpyObj( + "telemetrySubscriber", + ["subscribe"] ); + mockSubscription = jasmine.createSpyObj( + "subscription", + [ + "unsubscribe", + "getTelemetryObjects", + "getMetadata", + "getDomainValue", + "getRangeValue" + ] + ); + + mockSubscriber.subscribe.andReturn(mockSubscription); + mockSubscription.getTelemetryObjects.andReturn([mockDomainObject]); + mockSubscription.getMetadata.andReturn([{}]); + mockSubscription.getDomainValue.andReturn(123); + mockSubscription.getRangeValue.andReturn(42); + + controller = new PlotController(mockScope, mockFormatter, mockSubscriber); }); it("provides plot colors", function () { @@ -66,16 +64,24 @@ define( .not.toEqual(controller.getColor(1)); }); - it("does not fail if telemetry controller is not in scope", function () { - mockScope.telemetry = undefined; - - // Broadcast data - mockScope.$on.mostRecentCall.args[1](); - - // Just want to not have an exception + it("subscribes to telemetry when a domain object appears in scope", function () { + // Make sure we're using the right watch here + expect(mockScope.$watch.mostRecentCall.args[0]) + .toEqual("domainObject"); + // Make an object available + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + // Should have subscribed + expect(mockSubscriber.subscribe).toHaveBeenCalledWith( + mockDomainObject, + jasmine.any(Function), + true // Lossless + ); }); it("draws lines when data becomes available", function () { + // Make an object available + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + // Verify precondition controller.getSubPlots().forEach(function (subplot) { expect(subplot.getDrawingObject().lines) @@ -83,11 +89,10 @@ define( }); // Make sure there actually are subplots being verified - expect(controller.getSubPlots().length > 0) - .toBeTruthy(); + expect(controller.getSubPlots().length > 0).toBeTruthy(); // Broadcast data - mockScope.$on.mostRecentCall.args[1](); + mockSubscriber.subscribe.mostRecentCall.args[1](); controller.getSubPlots().forEach(function (subplot) { expect(subplot.getDrawingObject().lines) @@ -95,27 +100,39 @@ define( }); }); + it("unsubscribes when domain object changes", function () { + // Make an object available + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + // Verify precondition - shouldn't unsubscribe yet + expect(mockSubscription.unsubscribe).not.toHaveBeenCalled(); + // Remove the domain object + mockScope.$watch.mostRecentCall.args[1](undefined); + // Should have unsubscribed + expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + }); + it("changes modes depending on number of objects", function () { - var expectedWatch = "telemetry.getTelemetryObjects()", - watchFunction; + // Act like one object is available + mockSubscription.getTelemetryObjects.andReturn([ + mockDomainObject + ]); - // Find the watch for telemetry objects, which - // should change plot mode options - mockScope.$watch.calls.forEach(function (call) { - if (call.args[0] === expectedWatch) { - watchFunction = call.args[1]; - } - }); + // Make an object available + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); - watchFunction([mockDomainObject]); expect(controller.getModeOptions().length).toEqual(1); - watchFunction([ + // Act like one object is available + mockSubscription.getTelemetryObjects.andReturn([ mockDomainObject, mockDomainObject, mockDomainObject ]); + + // Make an object available + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + expect(controller.getModeOptions().length).toEqual(2); }); @@ -151,6 +168,11 @@ define( expect(controller.stepBackPanZoom).not.toThrow(); expect(controller.unzoom).not.toThrow(); }); + + it("indicates if a request is pending", function () { + // Placeholder; need to support requesting telemetry + expect(controller.isRequestPending()).toBeFalsy(); + }); }); } ); \ No newline at end of file diff --git a/platform/features/plot/test/SubPlotSpec.js b/platform/features/plot/test/SubPlotSpec.js index 0cd81681c0..4eb9b6342b 100644 --- a/platform/features/plot/test/SubPlotSpec.js +++ b/platform/features/plot/test/SubPlotSpec.js @@ -135,6 +135,22 @@ define( ] ); }); + + it("provides access to a drawable object", function () { + expect(typeof subplot.getDrawingObject()).toEqual('object'); + }); + + it("allows a domain offset to be provided", function () { + // Domain object is needed to adjust canvas coordinates + // to avoid loss-of-precision associated with converting + // to 32 bit floats. + subplot.setDomainOffset(3); + subplot.update(); + // Should have adjusted the origin accordingly + expect(subplot.getDrawingObject().origin[0]) + .toEqual(2); + }); + }); } ); \ No newline at end of file diff --git a/platform/features/plot/test/elements/PlotPreparerSpec.js b/platform/features/plot/test/elements/PlotPreparerSpec.js index eb3d999397..8f72349312 100644 --- a/platform/features/plot/test/elements/PlotPreparerSpec.js +++ b/platform/features/plot/test/elements/PlotPreparerSpec.js @@ -1,4 +1,4 @@ -/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine,Float32Array*/ /** * MergeModelsSpec. Created by vwoeltje on 11/6/14. @@ -60,6 +60,13 @@ define( expect(preparer.getDimensions[1]).not.toEqual(0); }); + it("provides buffers", function () { + var datas = [makeMockData(0)], + preparer = new PlotPreparer(datas); + expect(preparer.getBuffers()[0] instanceof Float32Array) + .toBeTruthy(); + }); + }); } ); \ No newline at end of file diff --git a/platform/features/plot/test/elements/PlotUpdaterSpec.js b/platform/features/plot/test/elements/PlotUpdaterSpec.js new file mode 100644 index 0000000000..41be699578 --- /dev/null +++ b/platform/features/plot/test/elements/PlotUpdaterSpec.js @@ -0,0 +1,161 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine,Float32Array*/ + +/** + * MergeModelsSpec. Created by vwoeltje on 11/6/14. + */ +define( + ["../../src/elements/PlotUpdater"], + function (PlotUpdater) { + "use strict"; + + describe("A plot updater", function () { + var mockSubscription, + testDomain, + testRange, + testDomainValues, + testRangeValues, + updater; + + function makeMockDomainObject(id) { + var mockDomainObject = jasmine.createSpyObj( + "object-" + id, + [ "getId", "getCapability", "getModel" ] + ); + mockDomainObject.getId.andReturn(id); + return mockDomainObject; + } + + beforeEach(function () { + var ids = [ 'a', 'b', 'c' ], + mockObjects = ids.map(makeMockDomainObject); + + mockSubscription = jasmine.createSpyObj( + "subscription", + [ "getDomainValue", "getRangeValue", "getTelemetryObjects" ] + ); + testDomain = "testDomain"; + testRange = "testRange"; + testDomainValues = { a: 3, b: 7, c: 13 }; + testRangeValues = { a: 123, b: 456, c: 789 }; + + mockSubscription.getTelemetryObjects.andReturn(mockObjects); + mockSubscription.getDomainValue.andCallFake(function (mockObject) { + return testDomainValues[mockObject.getId()]; + }); + mockSubscription.getRangeValue.andCallFake(function (mockObject) { + return testRangeValues[mockObject.getId()]; + }); + + updater = new PlotUpdater( + mockSubscription, + testDomain, + testRange, + 1350 // Smaller max size for easier testing + ); + }); + + it("provides one buffer per telemetry object", function () { + expect(updater.getBuffers().length).toEqual(3); + }); + + it("changes buffer count if telemetry object counts change", function () { + mockSubscription.getTelemetryObjects + .andReturn([makeMockDomainObject('a')]); + updater.update(); + expect(updater.getBuffers().length).toEqual(1); + }); + + it("maintains a buffer of received telemetry", function () { + // Count should be large enough to trigger a buffer resize + var count = 750, + i; + + // Increment values exposed by subscription + function increment() { + Object.keys(testDomainValues).forEach(function (k) { + testDomainValues[k] += 1; + testRangeValues[k] += 1; + }); + } + + // Simulate a lot of telemetry updates + for (i = 0; i < count; i += 1) { + updater.update(); + expect(updater.getLength(0)).toEqual(i + 1); + expect(updater.getLength(1)).toEqual(i + 1); + expect(updater.getLength(2)).toEqual(i + 1); + increment(); + } + + // Domain offset should be lowest domain value + expect(updater.getDomainOffset()).toEqual(3); + + // Test against initial values, offset by count, + // as was the case during each update + for (i = 0; i < count; i += 1) { + expect(updater.getBuffers()[0][i * 2]) + .toEqual(3 + i - 3); + expect(updater.getBuffers()[0][i * 2 + 1]) + .toEqual(123 + i); + expect(updater.getBuffers()[1][i * 2]) + .toEqual(7 + i - 3); + expect(updater.getBuffers()[1][i * 2 + 1]) + .toEqual(456 + i); + expect(updater.getBuffers()[2][i * 2]) + .toEqual(13 + i - 3); + expect(updater.getBuffers()[2][i * 2 + 1]) + .toEqual(789 + i); + } + }); + + it("can handle delayed telemetry object availability", function () { + // The case can occur where getTelemetryObjects() returns an + // empty array - specifically, while objects are still being + // loaded. The updater needs to be able to cope with that + // case. + var tmp = mockSubscription.getTelemetryObjects(); + mockSubscription.getTelemetryObjects.andReturn([]); + + // Reinstantiate with the empty subscription + updater = new PlotUpdater( + mockSubscription, + testDomain, + testRange + ); + + // Should have 0 buffers for 0 objects + expect(updater.getBuffers().length).toEqual(0); + + // Restore the three objects the test subscription would + // normally have. + mockSubscription.getTelemetryObjects.andReturn(tmp); + updater.update(); + + // Should have 3 buffers for 3 objects + expect(updater.getBuffers().length).toEqual(3); + }); + + + it("shifts buffer upon expansion", function () { + // Count should be large enough to hit buffer's max size + var count = 1400, + i; + + // Initial update; should have 3 in first position + // (a's initial domain value) + updater.update(); + expect(updater.getBuffers()[0][1]).toEqual(123); + + // Simulate a lot of telemetry updates + for (i = 0; i < count; i += 1) { + testDomainValues.a += 1; + testRangeValues.a += 1; + updater.update(); + } + + // Value at front of the buffer should have been pushed out + expect(updater.getBuffers()[0][1]).not.toEqual(123); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/plot/test/modes/PlotOverlayModeSpec.js b/platform/features/plot/test/modes/PlotOverlayModeSpec.js index 30a29ab1e3..8c2922cf22 100644 --- a/platform/features/plot/test/modes/PlotOverlayModeSpec.js +++ b/platform/features/plot/test/modes/PlotOverlayModeSpec.js @@ -57,7 +57,7 @@ define( // Prepared telemetry data mockPrepared = jasmine.createSpyObj( "prepared", - [ "getDomainOffset", "getOrigin", "getDimensions", "getBuffers" ] + [ "getDomainOffset", "getOrigin", "getDimensions", "getBuffers", "getLength" ] ); mockSubPlotFactory.createSubPlot.andCallFake(createMockSubPlot); @@ -68,6 +68,7 @@ define( mockPrepared.getDomainOffset.andReturn(1234); mockPrepared.getOrigin.andReturn([10, 10]); mockPrepared.getDimensions.andReturn([500, 500]); + mockPrepared.getLength.andReturn(3); // Clear out drawing objects testDrawingObjects = []; diff --git a/platform/features/plot/test/modes/PlotStackModeSpec.js b/platform/features/plot/test/modes/PlotStackModeSpec.js index c44cd8c8a3..4c2c11a308 100644 --- a/platform/features/plot/test/modes/PlotStackModeSpec.js +++ b/platform/features/plot/test/modes/PlotStackModeSpec.js @@ -57,7 +57,7 @@ define( // Prepared telemetry data mockPrepared = jasmine.createSpyObj( "prepared", - [ "getDomainOffset", "getOrigin", "getDimensions", "getBuffers" ] + [ "getDomainOffset", "getOrigin", "getDimensions", "getBuffers", "getLength" ] ); mockSubPlotFactory.createSubPlot.andCallFake(createMockSubPlot); @@ -68,6 +68,7 @@ define( mockPrepared.getDomainOffset.andReturn(1234); mockPrepared.getOrigin.andReturn([10, 10]); mockPrepared.getDimensions.andReturn([500, 500]); + mockPrepared.getLength.andReturn(3); // Objects that will be drawn to in sub-plots testDrawingObjects = []; diff --git a/platform/features/plot/test/suite.json b/platform/features/plot/test/suite.json index 7528ce7869..2df3badfef 100644 --- a/platform/features/plot/test/suite.json +++ b/platform/features/plot/test/suite.json @@ -11,6 +11,7 @@ "elements/PlotPosition", "elements/PlotPreparer", "elements/PlotTickGenerator", + "elements/PlotUpdater", "modes/PlotModeOptions", "modes/PlotOverlayMode", "modes/PlotStackMode" diff --git a/platform/telemetry/src/TelemetryQueue.js b/platform/telemetry/src/TelemetryQueue.js new file mode 100644 index 0000000000..e255e01206 --- /dev/null +++ b/platform/telemetry/src/TelemetryQueue.js @@ -0,0 +1,71 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Supports TelemetrySubscription. Provides a simple data structure + * (with a pool-like interface) that aggregates key-value pairs into + * a queued series of large objects, ensuring that no value is + * overwritten (but consolidated non-overlapping keys into single + * objects.) + * @constructor + */ + function TelemetryQueue() { + var queue = []; + + // Look up an object in the queue that does not have a value + // assigned to this key (or, add a new one) + function getFreeObject(key) { + var index = 0, object; + + // Look for an existing queue position where we can store + // a value to this key without overwriting an existing value. + for (index = 0; index < queue.length; index += 1) { + if (queue[index][key] === undefined) { + return queue[index]; + } + } + + // If we made it through the loop, values have been assigned + // to that key in all queued containers, so we need to queue + // up a new container for key-value pairs. + object = {}; + queue.push(object); + return object; + } + + return { + /** + * Check if any value groups remain in this pool. + * @return {boolean} true if value groups remain + */ + isEmpty: function () { + return queue.length < 1; + }, + /** + * Retrieve the next value group from this pool. + * This gives an object containing key-value pairs, + * where keys and values correspond to the arguments + * given to previous put functions. + * @return {object} key-value pairs + */ + poll: function () { + return queue.shift(); + }, + /** + * Put a key-value pair into the pool. + * @param {string} key the key to store the value under + * @param {*} value the value to store + */ + put: function (key, value) { + getFreeObject(key)[key] = value; + } + }; + } + + return TelemetryQueue; + } +); \ No newline at end of file diff --git a/platform/telemetry/src/TelemetrySubscriber.js b/platform/telemetry/src/TelemetrySubscriber.js index d422a3af31..3881b1f84a 100644 --- a/platform/telemetry/src/TelemetrySubscriber.js +++ b/platform/telemetry/src/TelemetrySubscriber.js @@ -32,18 +32,23 @@ define( * associated telemetry data is of interest * @param {Function} callback a function to invoke * when new data has become available. + * @param {boolean} lossless flag to indicate whether the + * callback should be notified for all values + * (otherwise, multiple values in quick succession + * will call back with only the latest value.) * @returns {TelemetrySubscription} the subscription, * which will provide access to latest values. * * @method * @memberof TelemetrySubscriber */ - subscribe: function (domainObject, callback) { + subscribe: function (domainObject, callback, lossless) { return new TelemetrySubscription( $q, $timeout, domainObject, - callback + callback, + lossless ); } }; diff --git a/platform/telemetry/src/TelemetrySubscription.js b/platform/telemetry/src/TelemetrySubscription.js index 5e929d8ee4..046d00dc80 100644 --- a/platform/telemetry/src/TelemetrySubscription.js +++ b/platform/telemetry/src/TelemetrySubscription.js @@ -1,8 +1,8 @@ /*global define*/ define( - [], - function () { + ['./TelemetryQueue', './TelemetryTable'], + function (TelemetryQueue, TelemetryTable) { "use strict"; @@ -25,11 +25,17 @@ define( * associated telemetry data is of interest * @param {Function} callback a function to invoke * when new data has become available. + * @param {boolean} lossless true if callback should be invoked + * once with every data point available; otherwise, multiple + * data events in a short period of time will only invoke + * the callback once, with access to the latest data */ - function TelemetrySubscription($q, $timeout, domainObject, callback) { + function TelemetrySubscription($q, $timeout, domainObject, callback, lossless) { var unsubscribePromise, latestValues = {}, telemetryObjects = [], + pool = lossless ? new TelemetryQueue() : new TelemetryTable(), + metadatas, updatePending; // Look up domain objects which have telemetry capabilities. @@ -55,10 +61,22 @@ define( }); } + function updateValuesFromPool() { + var values = pool.poll(); + Object.keys(values).forEach(function (k) { + latestValues[k] = values[k]; + }); + } + // Invoke the observer callback to notify that new streaming // data has become available. function fireCallback() { - callback(); + // Play back from queue if we are lossless + while (!pool.isEmpty()) { + updateValuesFromPool(); + callback(); + } + // Clear the pending flag so that future updates will // schedule this callback. updatePending = false; @@ -79,10 +97,10 @@ define( // Update the latest-value table if (count > 0) { - latestValues[domainObject.getId()] = { + pool.put(domainObject.getId(), { domain: telemetry.getDomainValue(count - 1), range: telemetry.getRangeValue(count - 1) - }; + }); } } @@ -96,6 +114,14 @@ define( }); } + // Look up metadata associated with an object's telemetry + function lookupMetadata(domainObject) { + var telemetryCapability = + domainObject.getCapability("telemetry"); + return telemetryCapability && + telemetryCapability.getMetadata(); + } + // Prepare subscriptions to all relevant telemetry-providing // domain objects. function subscribeAll(domainObjects) { @@ -108,6 +134,7 @@ define( // to return a non-Promise to simplify usage elsewhere. function cacheObjectReferences(objects) { telemetryObjects = objects; + metadatas = objects.map(lookupMetadata); return objects; } @@ -189,6 +216,21 @@ define( */ getTelemetryObjects: function () { return telemetryObjects; + }, + /** + * Get all telemetry metadata associated with + * telemetry-providing domain objects managed by + * this controller. + * + * This will ordered in the + * same manner as `getTelemetryObjects()` or + * `getResponse()`; that is, the metadata at a + * given index will correspond to the telemetry-providing + * domain object at the same index. + * @returns {Array} an array of metadata objects + */ + getMetadata: function () { + return metadatas; } }; } diff --git a/platform/telemetry/src/TelemetryTable.js b/platform/telemetry/src/TelemetryTable.js new file mode 100644 index 0000000000..1b02887a86 --- /dev/null +++ b/platform/telemetry/src/TelemetryTable.js @@ -0,0 +1,53 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Supports TelemetrySubscription. Provides a simple data structure + * (with a pool-like interface) that aggregates key-value pairs into + * one large object, overwriting new values as necessary. Stands + * in contrast to the TelemetryQueue, which will avoid overwriting + * values. + * @constructor + */ + function TelemetryTable() { + var table; + + return { + /** + * Check if any value groups remain in this pool. + * @return {boolean} true if value groups remain + */ + isEmpty: function () { + return !table; + }, + /** + * Retrieve the next value group from this pool. + * This gives an object containing key-value pairs, + * where keys and values correspond to the arguments + * given to previous put functions. + * @return {object} key-value pairs + */ + poll: function () { + var t = table; + table = undefined; + return t; + }, + /** + * Put a key-value pair into the pool. + * @param {string} key the key to store the value under + * @param {*} value the value to store + */ + put: function (key, value) { + table = table || {}; + table[key] = value; + } + }; + } + + return TelemetryTable; + } +); \ No newline at end of file diff --git a/platform/telemetry/test/TelemetryQueueSpec.js b/platform/telemetry/test/TelemetryQueueSpec.js new file mode 100644 index 0000000000..ecdf9c376f --- /dev/null +++ b/platform/telemetry/test/TelemetryQueueSpec.js @@ -0,0 +1,55 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/TelemetryQueue"], + function (TelemetryQueue) { + "use strict"; + + describe("The telemetry queue", function () { + var queue; + + beforeEach(function () { + // put, isEmpty, dequeue + queue = new TelemetryQueue(); + }); + + it("stores elements by key", function () { + queue.put("a", { someKey: "some value" }); + expect(queue.poll()) + .toEqual({ a: { someKey: "some value" }}); + }); + + it("merges non-overlapping keys", function () { + queue.put("a", { someKey: "some value" }); + queue.put("b", 42); + expect(queue.poll()) + .toEqual({ a: { someKey: "some value" }, b: 42 }); + }); + + it("adds new objects for repeated keys", function () { + queue.put("a", { someKey: "some value" }); + queue.put("a", { someKey: "some other value" }); + queue.put("b", 42); + expect(queue.poll()) + .toEqual({ a: { someKey: "some value" }, b: 42 }); + expect(queue.poll()) + .toEqual({ a: { someKey: "some other value" } }); + }); + + it("reports emptiness", function () { + expect(queue.isEmpty()).toBeTruthy(); + queue.put("a", { someKey: "some value" }); + queue.put("a", { someKey: "some other value" }); + queue.put("b", 42); + expect(queue.isEmpty()).toBeFalsy(); + queue.poll(); + expect(queue.isEmpty()).toBeFalsy(); + queue.poll(); + expect(queue.isEmpty()).toBeTruthy(); + }); + + + }); + + } +); \ No newline at end of file diff --git a/platform/telemetry/test/TelemetrySubscriptionSpec.js b/platform/telemetry/test/TelemetrySubscriptionSpec.js index 44bd6c4d20..469015ee93 100644 --- a/platform/telemetry/test/TelemetrySubscriptionSpec.js +++ b/platform/telemetry/test/TelemetrySubscriptionSpec.js @@ -13,6 +13,7 @@ define( mockTelemetry, mockUnsubscribe, mockSeries, + testMetadata, subscription; function mockPromise(value) { @@ -24,6 +25,8 @@ define( } beforeEach(function () { + testMetadata = { someKey: "some value" }; + mockQ = jasmine.createSpyObj("$q", ["when", "all"]); mockTimeout = jasmine.createSpy("$timeout"); mockDomainObject = jasmine.createSpyObj( @@ -33,7 +36,7 @@ define( mockCallback = jasmine.createSpy("callback"); mockTelemetry = jasmine.createSpyObj( "telemetry", - ["subscribe"] + ["subscribe", "getMetadata"] ); mockUnsubscribe = jasmine.createSpy("unsubscribe"); mockSeries = jasmine.createSpyObj( @@ -48,6 +51,7 @@ define( mockDomainObject.getId.andReturn('test-id'); mockTelemetry.subscribe.andReturn(mockUnsubscribe); + mockTelemetry.getMetadata.andReturn(testMetadata); mockSeries.getPointCount.andReturn(42); mockSeries.getDomainValue.andReturn(123456); @@ -120,6 +124,52 @@ define( // Should have no objects expect(subscription.getTelemetryObjects()).toEqual([]); }); + + // This test case corresponds to plot usage of + // telemetrySubscription, where failure to callback + // once-per-update results in loss of data, WTD-784 + it("fires one event per update if requested", function () { + var i, domains = [], ranges = [], lastCall; + + // Clear out the subscription from beforeEach + subscription.unsubscribe(); + // Create a subscription which does not drop events + subscription = new TelemetrySubscription( + mockQ, + mockTimeout, + mockDomainObject, + mockCallback, + true // Don't drop updates! + ); + + // Snapshot getDomainValue, getRangeValue at time of callback + mockCallback.andCallFake(function () { + domains.push(subscription.getDomainValue(mockDomainObject)); + ranges.push(subscription.getRangeValue(mockDomainObject)); + }); + + // Send 100 updates + for (i = 0; i < 100; i += 1) { + // Return different values to verify later + mockSeries.getDomainValue.andReturn(i); + mockSeries.getRangeValue.andReturn(i * 2); + mockTelemetry.subscribe.mostRecentCall.args[0](mockSeries); + } + + // Fire all timeouts that get scheduled + while (mockTimeout.mostRecentCall !== lastCall) { + lastCall = mockTimeout.mostRecentCall; + lastCall.args[0](); + } + + // Should have only triggered the + expect(mockCallback.calls.length).toEqual(100); + }); + + it("provides domain object metadata", function () { + expect(subscription.getMetadata()[0]) + .toEqual(testMetadata); + }); }); } ); \ No newline at end of file diff --git a/platform/telemetry/test/TelemetryTableSpec.js b/platform/telemetry/test/TelemetryTableSpec.js new file mode 100644 index 0000000000..53da765aaa --- /dev/null +++ b/platform/telemetry/test/TelemetryTableSpec.js @@ -0,0 +1,53 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/TelemetryTable"], + function (TelemetryTable) { + "use strict"; + + describe("The telemetry table", function () { + var queue; + + beforeEach(function () { + // put, isEmpty, dequeue + queue = new TelemetryTable(); + }); + + it("stores elements by key", function () { + queue.put("a", { someKey: "some value" }); + expect(queue.poll()) + .toEqual({ a: { someKey: "some value" }}); + }); + + it("merges non-overlapping keys", function () { + queue.put("a", { someKey: "some value" }); + queue.put("b", 42); + expect(queue.poll()) + .toEqual({ a: { someKey: "some value" }, b: 42 }); + }); + + it("overwrites repeated keys", function () { + queue.put("a", { someKey: "some value" }); + queue.put("a", { someKey: "some other value" }); + queue.put("b", 42); + expect(queue.poll()) + .toEqual({ a: { someKey: "some other value" }, b: 42 }); + expect(queue.poll()) + .toBeUndefined(); + }); + + it("reports emptiness", function () { + expect(queue.isEmpty()).toBeTruthy(); + queue.put("a", { someKey: "some value" }); + queue.put("a", { someKey: "some other value" }); + queue.put("b", 42); + expect(queue.isEmpty()).toBeFalsy(); + queue.poll(); + expect(queue.isEmpty()).toBeTruthy(); + }); + + + }); + + } +); \ No newline at end of file diff --git a/platform/telemetry/test/suite.json b/platform/telemetry/test/suite.json index 68110d0a6e..0ed5887ac5 100644 --- a/platform/telemetry/test/suite.json +++ b/platform/telemetry/test/suite.json @@ -3,6 +3,8 @@ "TelemetryCapability", "TelemetryController", "TelemetryFormatter", + "TelemetryQueue", "TelemetrySubscriber", - "TelemetrySubscription" + "TelemetrySubscription", + "TelemetryTable" ] \ No newline at end of file