diff --git a/platform/containment/bundle.json b/platform/containment/bundle.json index a31eb60c4a..e61085f66f 100644 --- a/platform/containment/bundle.json +++ b/platform/containment/bundle.json @@ -7,6 +7,11 @@ "depends": [ "$injector" ], "message": "Objects of this type cannot contain objects of that type." }, + { + "category": "composition", + "implementation": "CompositionMutabilityPolicy.js", + "message": "Objects of this type cannot be modified." + }, { "category": "action", "implementation": "ComposeActionPolicy.js", diff --git a/platform/containment/src/CompositionMutabilityPolicy.js b/platform/containment/src/CompositionMutabilityPolicy.js new file mode 100644 index 0000000000..21dc02fafc --- /dev/null +++ b/platform/containment/src/CompositionMutabilityPolicy.js @@ -0,0 +1,30 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Disallow composition changes to objects which are not mutable. + * @constructor + */ + function CompositionMutabilityPolicy() { + return { + /** + * Is the type identified by the candidate allowed to + * contain the type described by the context? + * @param {Type} candidate the type of domain object + */ + allow: function (candidate) { + // Equate creatability with mutability; that is, users + // can only modify objects of types they can create, and + // vice versa. + return candidate.hasFeature('creation'); + } + }; + } + + return CompositionMutabilityPolicy; + } +); \ No newline at end of file diff --git a/platform/containment/test/CompositionMutabilityPolicySpec.js b/platform/containment/test/CompositionMutabilityPolicySpec.js new file mode 100644 index 0000000000..460f8b6870 --- /dev/null +++ b/platform/containment/test/CompositionMutabilityPolicySpec.js @@ -0,0 +1,26 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/CompositionMutabilityPolicy"], + function (CompositionMutabilityPolicy) { + "use strict"; + + describe("The composition mutability policy", function () { + var mockType, + policy; + + beforeEach(function () { + mockType = jasmine.createSpyObj('type', ['hasFeature']); + policy = new CompositionMutabilityPolicy(); + }); + + it("only allows composition for types which will have a composition capability", function () { + expect(policy.allow(mockType)).toBeFalsy(); + mockType.hasFeature.andReturn(true); + expect(policy.allow(mockType)).toBeTruthy(); + expect(mockType.hasFeature).toHaveBeenCalledWith('creation'); + }); + }); + + } +); \ No newline at end of file diff --git a/platform/containment/test/suite.json b/platform/containment/test/suite.json index a82d203c53..987ef9a86c 100644 --- a/platform/containment/test/suite.json +++ b/platform/containment/test/suite.json @@ -1,6 +1,7 @@ [ "CapabilityTable", "ComposeActionPolicy", + "CompositionMutabilityPolicy", "CompositionPolicy", "ContainmentTable" ] \ No newline at end of file diff --git a/platform/features/plot/bundle.json b/platform/features/plot/bundle.json index 67c69d9e4b..ab8cf89b7c 100644 --- a/platform/features/plot/bundle.json +++ b/platform/features/plot/bundle.json @@ -22,7 +22,7 @@ { "key": "PlotController", "implementation": "PlotController.js", - "depends": [ "$scope", "telemetryFormatter", "telemetrySubscriber" ] + "depends": [ "$scope", "telemetryFormatter", "telemetryHandler" ] } ] } diff --git a/platform/features/plot/src/PlotController.js b/platform/features/plot/src/PlotController.js index 6449e0f3a1..046a9f4fb9 100644 --- a/platform/features/plot/src/PlotController.js +++ b/platform/features/plot/src/PlotController.js @@ -30,13 +30,13 @@ define( * * @constructor */ - function PlotController($scope, telemetryFormatter, telemetrySubscriber) { + function PlotController($scope, telemetryFormatter, telemetryHandler) { var subPlotFactory = new SubPlotFactory(telemetryFormatter), modeOptions = new PlotModeOptions([], subPlotFactory), subplots = [], cachedObjects = [], updater, - subscription, + handle, domainOffset; // Populate the scope with axis information (specifically, options @@ -77,7 +77,7 @@ define( // new subscription.) This will clear the plot. function recreateUpdater() { updater = new PlotUpdater( - subscription, + handle, ($scope.axes[0].active || {}).key, ($scope.axes[1].active || {}).key ); @@ -85,8 +85,8 @@ define( // Handle new telemetry data in this plot function updateValues() { - if (subscription) { - setupModes(subscription.getTelemetryObjects()); + if (handle) { + setupModes(handle.getTelemetryObjects()); } if (updater) { updater.update(); @@ -95,29 +95,44 @@ define( update(); } + // Display new historical data as it becomes available + function addHistoricalData(domainObject, series) { + updater.addHistorical(domainObject, series); + modeOptions.getModeHandler().plotTelemetry(updater); + update(); + } + + // Issue a new request for historical telemetry + function requestTelemetry() { + if (handle && updater) { + handle.request({}, addHistoricalData); + } + } + // Create a new subscription; telemetrySubscriber gets // to do the meaningful work here. function subscribe(domainObject) { - if (subscription) { - subscription.unsubscribe(); + if (handle) { + handle.unsubscribe(); } - subscription = domainObject && telemetrySubscriber.subscribe( + handle = domainObject && telemetryHandler.handle( domainObject, updateValues, true // Lossless ); - if (subscription) { - setupModes(subscription.getTelemetryObjects()); - setupAxes(subscription.getMetadata()); + if (handle) { + setupModes(handle.getTelemetryObjects()); + setupAxes(handle.getMetadata()); recreateUpdater(); + requestTelemetry(); } } // Release the current subscription (called when scope is destroyed) function releaseSubscription() { - if (subscription) { - subscription.unsubscribe(); - subscription = undefined; + if (handle) { + handle.unsubscribe(); + handle = undefined; } } diff --git a/platform/features/plot/src/elements/PlotLine.js b/platform/features/plot/src/elements/PlotLine.js new file mode 100644 index 0000000000..00a65cbfac --- /dev/null +++ b/platform/features/plot/src/elements/PlotLine.js @@ -0,0 +1,94 @@ +/*global define,Float32Array*/ + +define( + ['./PlotSeriesWindow'], + function (PlotSeriesWindow) { + "use strict"; + + + function PlotLine(buffer) { + + // Insert a time-windowed data series into the buffer + function insertSeriesWindow(seriesWindow) { + var count = seriesWindow.getPointCount(); + + function doInsert() { + var firstTimestamp = seriesWindow.getDomainValue(0), + lastTimestamp = seriesWindow.getDomainValue(count - 1), + startIndex = buffer.findInsertionIndex(firstTimestamp), + endIndex = buffer.findInsertionIndex(lastTimestamp); + + // Does the whole series fit in between two adjacent indexes? + if ((startIndex === endIndex) && startIndex > -1) { + // Insert it in between + buffer.insert(seriesWindow, startIndex); + } else { + // Split it up, and add the two halves + seriesWindow.split().forEach(insertSeriesWindow); + } + } + + // Only insert if there are points to insert + if (count > 0) { + doInsert(); + } + } + + function createWindow(series, domain, range) { + return new PlotSeriesWindow( + series, + domain, + range, + 0, + series.getPointCount() + ); + } + + return { + /** + * Add a point to this plot line. + * @param {number} domainValue the domain value + * @param {number} rangeValue the range value + */ + addPoint: function (domainValue, rangeValue) { + var index; + // Make sure we got real/useful values here... + if (domainValue !== undefined && rangeValue !== undefined) { + index = buffer.findInsertionIndex(domainValue); + + // Already in the buffer? Skip insertion + if (index < 0) { + return; + } + + // Insert the point + if (!buffer.insertPoint(domainValue, rangeValue, index)) { + // If insertion failed, trim from the beginning... + buffer.trim(1); + // ...and try again. + buffer.insertPoint(domainValue, rangeValue, index); + } + } + }, + /** + * Add a series of telemetry data to this plot line. + * @param {TelemetrySeries} series the data series + * @param {string} [domain] the key indicating which domain + * to use when looking up data from this series + * @param {string} [range] the key indicating which range + * to use when looking up data from this series + */ + addSeries: function (series, domain, range) { + // Should try to add via insertion if a + // clear insertion point is available; + // if not, should split and add each half. + // Insertion operation also needs to factor out + // redundant timestamps, for overlapping data + insertSeriesWindow(createWindow(series, domain, range)); + } + }; + } + + return PlotLine; + } +); \ No newline at end of file diff --git a/platform/features/plot/src/elements/PlotLineBuffer.js b/platform/features/plot/src/elements/PlotLineBuffer.js new file mode 100644 index 0000000000..7511ab4870 --- /dev/null +++ b/platform/features/plot/src/elements/PlotLineBuffer.js @@ -0,0 +1,240 @@ +/*global define,Float32Array*/ + +define( + [], + function () { + "use strict"; + + /** + * Contains the buffer used to draw a plot. + * @param {number} domainOffset number to subtract from domain values + * @param {number} initialSize initial buffer size + * @param {number} maxSize maximum buffer size + * @constructor + */ + function PlotLineBuffer(domainOffset, initialSize, maxSize) { + var buffer = new Float32Array(initialSize * 2), + rangeExtrema = [ Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY ], + length = 0; + + // Binary search for an insertion index + function binSearch(value, min, max) { + var mid = Math.floor((min + max) / 2), + found = buffer[mid * 2]; + + // Collisions are not wanted + if (found === value) { + return -1; + } + + // Otherwise, if we're down to a single index, + // we've found our insertion point + if (min >= max) { + // Compare the found timestamp with the search + // value to decide if we'll insert after or before. + return min + ((found < value) ? 1 : 0); + } + + // Finally, do the recursive step + if (found < value) { + return binSearch(value, mid + 1, max); + } else { + return binSearch(value, min, mid - 1); + } + } + + // Increase the size of the buffer + function doubleBufferSize() { + var sz = Math.min(maxSize * 2, buffer.length * 2), + canDouble = sz > buffer.length, + doubled = canDouble && new Float32Array(sz); + + if (canDouble) { + doubled.set(buffer); // Copy contents of original + buffer = doubled; + } + + return canDouble; + } + + // Decrease the size of the buffer + function halveBufferSize() { + var sz = Math.max(initialSize * 2, buffer.length / 2), + canHalve = sz < buffer.length; + + if (canHalve) { + buffer = new Float32Array(buffer.subarray(0, sz)); + } + + return canHalve; + } + + // Set a value in the buffer + function setValue(index, domainValue, rangeValue) { + buffer[index * 2] = domainValue - domainOffset; + buffer[index * 2 + 1] = rangeValue; + // Track min/max of range values (min/max for + // domain values can be read directly from buffer) + rangeExtrema[0] = Math.min(rangeExtrema[0], rangeValue); + rangeExtrema[1] = Math.max(rangeExtrema[1], rangeValue); + } + + return { + /** + * Get the WebGL-displayable buffer of points to plot. + * @returns {Float32Array} displayable buffer for this line + */ + getBuffer: function () { + return buffer; + }, + /** + * Get the number of points stored in this buffer. + * @returns {number} the number of points stored + */ + getLength: function () { + return length; + }, + /** + * Get the min/max range values that are currently in this + * buffer. Unlike range extrema, these will change as the + * buffer gets trimmed. + * @returns {number[]} min, max domain values + */ + getDomainExtrema: function () { + // Since these are ordered in the buffer, assume + // these are the values at the first and last index + return [ + buffer[0] + domainOffset, + buffer[length * 2 - 2] + domainOffset + ]; + }, + /** + * Get the min/max range values that have been observed for this + * buffer. Note that these values may have been trimmed out at + * some point. + * @returns {number[]} min, max range values + */ + getRangeExtrema: function () { + return rangeExtrema; + }, + /** + * Remove values from this buffer. + * Normally, values are removed from the start + * of the buffer; a truthy value in the second argument + * will cause values to be removed from the end. + * @param {number} count number of values to remove + * @param {boolean} [fromEnd] true if the most recent + * values should be removed + */ + trim: function (count, fromEnd) { + // If we're removing values from the start... + if (!fromEnd) { + // ...do so by shifting buffer contents over + buffer.set(buffer.subarray(2 * count)); + } + // Reduce used buffer size accordingly + length -= count; + // Finally, if less than half of the buffer is being + // used, free up some memory. + if (length < buffer.length / 4) { + halveBufferSize(); + } + }, + /** + * Insert data from the provided series at the specified + * index. If this would exceed the buffer's maximum capacity, + * this operation fails and the buffer is unchanged. + * @param {TelemetrySeries} series the series to insert + * @param {number} index the index at which to insert this + * series + * @returns {boolean} true if insertion succeeded; otherwise + * false + */ + insert: function (series, index) { + var sz = series.getPointCount(), + i; + + // Don't allow append after the end; that doesn't make sense + index = Math.min(index, length); + + // Resize if necessary + while (sz > ((buffer.length / 2) - length)) { + if (!doubleBufferSize()) { + // Can't make room for this, insertion fails + return false; + } + } + + // Shift data over if necessary + if (index < length) { + buffer.set( + buffer.subarray(index * 2, length * 2), + (index + sz) * 2 + ); + } + + // Insert data into the set + for (i = 0; i < sz; i += 1) { + setValue( + i + index, + series.getDomainValue(i), + series.getRangeValue(i) + ); + } + + // Increase the length + length += sz; + + // Indicate that insertion was successful + return true; + }, + /** + * Append a single data point. + */ + insertPoint: function (domainValue, rangeValue, index) { + // Don't allow + index = Math.min(length, index); + + // Ensure there is space for this point + if (length >= (buffer.length / 2)) { + if (!doubleBufferSize()) { + return false; + } + } + + // Put the data in the buffer + setValue(length, domainValue, rangeValue); + + // Update length + length += 1; + + // Indicate that this was successful + return true; + }, + /** + * Find an index for inserting data with this + * timestamp. The second argument indicates whether + * we are searching for insert-before or insert-after + * positions. + * Timestamps are meant to be unique, so if a collision + * occurs, this will return -1. + * @param {number} timestamp timestamp to insert + * @returns {number} the index for insertion (or -1) + */ + findInsertionIndex: function (timestamp) { + var value = timestamp - domainOffset; + + // Handle empty buffer case and check for an + // append opportunity (which is most common case for + // real-time data so is optimized-for) before falling + // back to a binary search for the insertion point. + return (length < 1) ? 0 : + (value > buffer[length * 2 - 2]) ? length : + binSearch(value, 0, length - 1); + } + }; + } + + return PlotLineBuffer; + } +); \ No newline at end of file diff --git a/platform/features/plot/src/elements/PlotSeriesWindow.js b/platform/features/plot/src/elements/PlotSeriesWindow.js new file mode 100644 index 0000000000..572c2c419c --- /dev/null +++ b/platform/features/plot/src/elements/PlotSeriesWindow.js @@ -0,0 +1,47 @@ +/*global define*/ +define( + [], + function () { + "use strict"; + + /** + * Provides a window on a telemetry data series, to support + * insertion into a plot line. + */ + function PlotSeriesWindow(series, domain, range, start, end) { + return { + getPointCount: function () { + return end - start; + }, + getDomainValue: function (index) { + return series.getDomainValue(index + start, domain); + }, + getRangeValue: function (index) { + return series.getRangeValue(index + start, range); + }, + split: function () { + var mid = Math.floor((end + start) / 2); + return ((end - start) > 1) ? + [ + new PlotSeriesWindow( + series, + domain, + range, + start, + mid + ), + new PlotSeriesWindow( + series, + domain, + range, + mid, + end + ) + ] : []; + } + }; + } + + return PlotSeriesWindow; + } +); \ No newline at end of file diff --git a/platform/features/plot/src/elements/PlotUpdater.js b/platform/features/plot/src/elements/PlotUpdater.js index ed81035b6b..80074870cc 100644 --- a/platform/features/plot/src/elements/PlotUpdater.js +++ b/platform/features/plot/src/elements/PlotUpdater.js @@ -5,7 +5,8 @@ * the conversion from data API to displayable buffers. */ define( - function () { + ['./PlotLine', './PlotLineBuffer'], + function (PlotLine, PlotLineBuffer) { 'use strict'; var MAX_POINTS = 86400, @@ -17,117 +18,182 @@ define( * 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 {TelemetryHandle} handle the handle to telemetry access * @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 = []; + function PlotUpdater(handle, domain, range, maxPoints) { + var ids = [], + lines = {}, + dimensions = [0, 0], + origin = [0, 0], + domainExtrema, + rangeExtrema, + bufferArray = [], + domainOffset; - // 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; + // Look up a domain object's id (for mapping, below) + function getId(domainObject) { + return domainObject.getId(); } - // 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; + // Check if this set of ids matches the current set of ids + // (used to detect if line preparation can be skipped) + function idsMatch(nextIds) { + return nextIds.map(function (id, index) { + return ids[index] === id; + }).reduce(function (a, b) { + return a && b; + }, true); } - // 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); + // Prepare plot lines for this group of telemetry objects + function prepareLines(telemetryObjects) { + var nextIds = telemetryObjects.map(getId), + next = {}; - // If we don't already have a data buffer for that ID, - // make one. - if (!buffer) { - buffer = new Float32Array(INITIAL_SIZE); - buffers[id] = buffer; + // Detect if we already have everything we need prepared + if (ids.length === nextIds.length && idsMatch(nextIds)) { + // Nothing to prepare, move on + return; } - // 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); + // Built up a set of ids. Note that we can only + // create plot lines after our domain offset has + // been determined. + if (domainOffset !== undefined) { + // Update list of ids in use + ids = nextIds; + + // Create buffers for these objects + bufferArray = ids.map(function (id) { + var buffer = new PlotLineBuffer( + domainOffset, + INITIAL_SIZE, + maxPoints + ); + next[id] = lines[id] || new PlotLine(buffer); + return buffer; + }); } - return buffer; + // If there are no more lines, clear the domain offset + if (Object.keys(next).length < 1) { + domainOffset = undefined; + } + + // Update to the current set of lines + lines = next; } - // 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]); - }); + // Initialize the domain offset, based on these observed values + function initializeDomainOffset(values) { + domainOffset = + ((domainOffset === undefined) && (values.length > 0)) ? + (values.reduce(function (a, b) { + return (a || 0) + (b || 0); + }, 0) / values.length) : + domainOffset; + } + + // Used in the reduce step of updateExtrema + function reduceExtrema(a, b) { + return [ Math.min(a[0], b[0]), Math.max(a[1], b[1]) ]; + } + + // Convert a domain/range extrema to plot dimensions + function dimensionsOf(extrema) { + return extrema[1] - extrema[0]; + } + + // Convert a domain/range extrema to a plot origin + function originOf(extrema) { + return extrema[0]; + } + + // Update dimensions and origin based on extrema of plots + function updateExtrema() { + if (bufferArray.length > 0) { + domainExtrema = bufferArray.map(function (lineBuffer) { + return lineBuffer.getDomainExtrema(); + }).reduce(reduceExtrema); + + rangeExtrema = bufferArray.map(function (lineBuffer) { + return lineBuffer.getRangeExtrema(); + }).reduce(reduceExtrema); + + dimensions = (rangeExtrema[0] === rangeExtrema[1]) ? + [dimensionsOf(domainExtrema), 2.0 ] : + [dimensionsOf(domainExtrema), dimensionsOf(rangeExtrema)]; + origin = [originOf(domainExtrema), originOf(rangeExtrema)]; + } + } + + // Add latest data for this domain object + function addPointFor(domainObject) { + var line = lines[domainObject.getId()]; + if (line) { + line.addPoint( + handle.getDomainValue(domainObject, domain), + handle.getRangeValue(domainObject, range) + ); + } } // 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); + var objects = handle.getTelemetryObjects(); + + // Initialize domain offset if necessary + if (domainOffset === undefined) { + initializeDomainOffset(objects.map(function (obj) { + return handle.getDomainValue(obj, domain); + }).filter(function (value) { + return typeof value === 'number'; + })); + } + + // Make sure lines are available + prepareLines(objects); + + // Add new data + objects.forEach(addPointFor); + + // Finally, update extrema + updateExtrema(); } - // 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]); + // Add historical data for this domain object + function setHistorical(domainObject, series) { + var count = series ? series.getPointCount() : 0, + line; + + // Nothing to do if it's an empty series + if (count < 1) { + return; + } + + // Initialize domain offset if necessary + if (domainOffset === undefined) { + initializeDomainOffset([ + series.getDomainValue(0, domain), + series.getDomainValue(count - 1, domain) + ]); + } + + // Make sure lines are available + prepareLines(handle.getTelemetryObjects()); + + // Look up the line for this domain object + line = lines[domainObject.getId()]; + + // ...and put the data into it. + if (line) { + line.addSeries(series, domain, range); + } + + // Finally, update extrema + updateExtrema(); } // Use a default MAX_POINTS if none is provided @@ -136,7 +202,7 @@ define( // 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); + update(); return { /** @@ -146,10 +212,7 @@ define( * @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]]; + return dimensions; }, /** * Get the origin of this data set's boundary. @@ -160,7 +223,7 @@ define( */ getOrigin: function () { // Pad range if necessary - return (max[1] === min[1]) ? [ min[0], min[1] - 1.0 ] : min; + return origin; }, /** * Get the domain offset; this offset will have been subtracted @@ -189,23 +252,17 @@ define( * * @returns {Float32Array[]} the buffers for these traces */ - getBuffers: function () { + getLineBuffers: 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 + update: update, + /** + * Fill in historical data. + */ + addHistorical: setHistorical }; } diff --git a/platform/features/plot/src/modes/PlotOverlayMode.js b/platform/features/plot/src/modes/PlotOverlayMode.js index 342bbcc1c3..093ae07b1b 100644 --- a/platform/features/plot/src/modes/PlotOverlayMode.js +++ b/platform/features/plot/src/modes/PlotOverlayMode.js @@ -34,11 +34,11 @@ define( subplot.setDomainOffset(prepared.getDomainOffset()); // Draw the buffers. Select color by index. - subplot.getDrawingObject().lines = prepared.getBuffers().map(function (buf, i) { + subplot.getDrawingObject().lines = prepared.getLineBuffers().map(function (buf, i) { return { - buffer: buf, + buffer: buf.getBuffer(), color: PlotPalette.getFloatColor(i), - points: prepared.getLength(i) + points: buf.getLength() }; }); diff --git a/platform/features/plot/src/modes/PlotStackMode.js b/platform/features/plot/src/modes/PlotStackMode.js index 5fe3895a61..a92179dcdb 100644 --- a/platform/features/plot/src/modes/PlotStackMode.js +++ b/platform/features/plot/src/modes/PlotStackMode.js @@ -23,7 +23,7 @@ define( }); function plotTelemetryTo(subplot, prepared, index) { - var buffer = prepared.getBuffers()[index]; + var buffer = prepared.getLineBuffers()[index]; // Track the domain offset, used to bias domain values // to minimize loss of precision when converted to 32-bit @@ -33,9 +33,9 @@ define( // Draw the buffers. Always use the 0th color, because there // is one line per plot. subplot.getDrawingObject().lines = [{ - buffer: buffer, + buffer: buffer.getBuffer(), color: PlotPalette.getFloatColor(0), - points: prepared.getLength(index) + points: buffer.getLength() }]; subplot.update(); diff --git a/platform/features/plot/test/PlotControllerSpec.js b/platform/features/plot/test/PlotControllerSpec.js index 865ad2c335..846bac452f 100644 --- a/platform/features/plot/test/PlotControllerSpec.js +++ b/platform/features/plot/test/PlotControllerSpec.js @@ -11,9 +11,10 @@ define( describe("The plot controller", function () { var mockScope, mockFormatter, - mockSubscriber, - mockSubscription, + mockHandler, + mockHandle, mockDomainObject, + mockSeries, controller; @@ -30,28 +31,33 @@ define( "domainObject", [ "getId", "getModel", "getCapability" ] ); - mockSubscriber = jasmine.createSpyObj( + mockHandler = jasmine.createSpyObj( "telemetrySubscriber", - ["subscribe"] + ["handle"] ); - mockSubscription = jasmine.createSpyObj( + mockHandle = jasmine.createSpyObj( "subscription", [ "unsubscribe", "getTelemetryObjects", "getMetadata", "getDomainValue", - "getRangeValue" + "getRangeValue", + "request" ] ); + mockSeries = jasmine.createSpyObj( + 'series', + ['getPointCount', 'getDomainValue', 'getRangeValue'] + ); - mockSubscriber.subscribe.andReturn(mockSubscription); - mockSubscription.getTelemetryObjects.andReturn([mockDomainObject]); - mockSubscription.getMetadata.andReturn([{}]); - mockSubscription.getDomainValue.andReturn(123); - mockSubscription.getRangeValue.andReturn(42); + mockHandler.handle.andReturn(mockHandle); + mockHandle.getTelemetryObjects.andReturn([mockDomainObject]); + mockHandle.getMetadata.andReturn([{}]); + mockHandle.getDomainValue.andReturn(123); + mockHandle.getRangeValue.andReturn(42); - controller = new PlotController(mockScope, mockFormatter, mockSubscriber); + controller = new PlotController(mockScope, mockFormatter, mockHandler); }); it("provides plot colors", function () { @@ -71,7 +77,7 @@ define( // Make an object available mockScope.$watch.mostRecentCall.args[1](mockDomainObject); // Should have subscribed - expect(mockSubscriber.subscribe).toHaveBeenCalledWith( + expect(mockHandler.handle).toHaveBeenCalledWith( mockDomainObject, jasmine.any(Function), true // Lossless @@ -92,7 +98,7 @@ define( expect(controller.getSubPlots().length > 0).toBeTruthy(); // Broadcast data - mockSubscriber.subscribe.mostRecentCall.args[1](); + mockHandler.handle.mostRecentCall.args[1](); controller.getSubPlots().forEach(function (subplot) { expect(subplot.getDrawingObject().lines) @@ -104,17 +110,17 @@ define( // Make an object available mockScope.$watch.mostRecentCall.args[1](mockDomainObject); // Verify precondition - shouldn't unsubscribe yet - expect(mockSubscription.unsubscribe).not.toHaveBeenCalled(); + expect(mockHandle.unsubscribe).not.toHaveBeenCalled(); // Remove the domain object mockScope.$watch.mostRecentCall.args[1](undefined); // Should have unsubscribed - expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + expect(mockHandle.unsubscribe).toHaveBeenCalled(); }); it("changes modes depending on number of objects", function () { // Act like one object is available - mockSubscription.getTelemetryObjects.andReturn([ + mockHandle.getTelemetryObjects.andReturn([ mockDomainObject ]); @@ -124,7 +130,7 @@ define( expect(controller.getModeOptions().length).toEqual(1); // Act like one object is available - mockSubscription.getTelemetryObjects.andReturn([ + mockHandle.getTelemetryObjects.andReturn([ mockDomainObject, mockDomainObject, mockDomainObject @@ -174,17 +180,26 @@ define( expect(controller.isRequestPending()).toBeFalsy(); }); + it("requests historical telemetry", function () { + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + expect(mockHandle.request).toHaveBeenCalled(); + mockHandle.request.mostRecentCall.args[1]( + mockDomainObject, + mockSeries + ); + }); + it("unsubscribes when destroyed", function () { // Make an object available mockScope.$watch.mostRecentCall.args[1](mockDomainObject); // Make sure $destroy is what's listened for expect(mockScope.$on.mostRecentCall.args[0]).toEqual('$destroy'); // Also verify precondition - expect(mockSubscription.unsubscribe).not.toHaveBeenCalled(); + expect(mockHandle.unsubscribe).not.toHaveBeenCalled(); // Destroy the scope mockScope.$on.mostRecentCall.args[1](); // Should have unsubscribed - expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + expect(mockHandle.unsubscribe).toHaveBeenCalled(); }); }); } diff --git a/platform/features/plot/test/elements/PlotLineBufferSpec.js b/platform/features/plot/test/elements/PlotLineBufferSpec.js new file mode 100644 index 0000000000..a152ba5a4c --- /dev/null +++ b/platform/features/plot/test/elements/PlotLineBufferSpec.js @@ -0,0 +1,151 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +/** + * MergeModelsSpec. Created by vwoeltje on 11/6/14. + */ +define( + ["../../src/elements/PlotLineBuffer"], + function (PlotLineBuffer) { + "use strict"; + + var TEST_INITIAL_SIZE = 10, + TEST_MAX_SIZE = 40, + TEST_DOMAIN_OFFSET = 42; + + describe("A plot line buffer", function () { + var mockSeries, + testDomainValues, + testRangeValues, + buffer; + + beforeEach(function () { + testDomainValues = [ 1, 3, 7, 9, 14, 15 ]; + testRangeValues = [ 8, 0, 3, 9, 8, 11 ]; + mockSeries = jasmine.createSpyObj( + "series", + ['getPointCount', 'getDomainValue', 'getRangeValue'] + ); + mockSeries.getPointCount.andCallFake(function () { + return testDomainValues.length; + }); + mockSeries.getDomainValue.andCallFake(function (i) { + return testDomainValues[i]; + }); + mockSeries.getRangeValue.andCallFake(function (i) { + return testRangeValues[i]; + }); + + buffer = new PlotLineBuffer( + TEST_DOMAIN_OFFSET, + TEST_INITIAL_SIZE, + TEST_MAX_SIZE + ); + + // Start with some data in there + buffer.insert(mockSeries, 0); + }); + + it("allows insertion of series data", function () { + // Convert to a regular array for checking. + // Verify that domain/ranges were interleaved and + // that domain offset was adjusted for. + expect( + Array.prototype.slice.call(buffer.getBuffer()).slice(0, 12) + ).toEqual([ -41, 8, -39, 0, -35, 3, -33, 9, -28, 8, -27, 11]); + expect(buffer.getLength()).toEqual(6); + }); + + it("finds insertion indexes", function () { + expect(buffer.findInsertionIndex(0)).toEqual(0); + expect(buffer.findInsertionIndex(2)).toEqual(1); + expect(buffer.findInsertionIndex(5)).toEqual(2); + expect(buffer.findInsertionIndex(10)).toEqual(4); + expect(buffer.findInsertionIndex(14.5)).toEqual(5); + expect(buffer.findInsertionIndex(20)).toEqual(6); + + // 9 is already in there, disallow insertion + expect(buffer.findInsertionIndex(9)).toEqual(-1); + }); + + it("allows insertion in the middle", function () { + var head = [ -41, 8, -39, 0, -35, 3 ], + tail = [ -33, 9, -28, 8, -27, 11]; + buffer.insert(mockSeries, 3); + expect( + Array.prototype.slice.call(buffer.getBuffer()).slice(0, 24) + ).toEqual(head.concat(head).concat(tail).concat(tail)); + expect(buffer.getLength()).toEqual(12); + }); + + it("allows values to be trimmed from the start", function () { + buffer.trim(2); + expect(buffer.getLength()).toEqual(4); + expect( + Array.prototype.slice.call(buffer.getBuffer()).slice(0, 8) + ).toEqual([ -35, 3, -33, 9, -28, 8, -27, 11]); + }); + + it("expands buffer when needed to accommodate more data", function () { + var i; + + // Initial underlying buffer should be twice initial size... + // (Since each pair will take up two elements) + expect(buffer.getBuffer().length).toEqual(20); + + // Should be able to insert 6 series of 6 points each + // (After that, we'll hit the test max of 40) + for (i = 1; i < 15; i += 1) { + expect(buffer.insertPoint(i * 10, Math.sin(i), i)) + .toBeTruthy(); + } + + // Buffer should have expanded in the process + expect(buffer.getBuffer().length).toEqual(40); + + // Push to maximum size just to make sure... + for (i = 1; i < 150; i += 1) { + buffer.insertPoint(i * 10, Math.sin(i), i); + } + + expect(buffer.getBuffer().length).toEqual(80); + }); + + it("ensures a maximum size", function () { + var i; + + // Should be able to insert 6 series of 6 points each + // (After that, we'll hit the test max of 40) + for (i = 1; i < 6; i += 1) { + expect(buffer.getLength()).toEqual(6 * i); + expect(buffer.insert(mockSeries, Number.POSITIVE_INFINITY)) + .toBeTruthy(); + } + + // Should be maxed out now + expect(buffer.getLength()).toEqual(36); + expect(buffer.insert(mockSeries, Number.POSITIVE_INFINITY)) + .toBeFalsy(); + expect(buffer.getLength()).toEqual(36); + + }); + + it("reduces buffer size when space is no longer needed", function () { + // Check that actual buffer is sized to the initial size + // (double TEST_INITIAL_SIZE, since two elements are needed per + // point; one for domain, one for range) + expect(buffer.getBuffer().length).toEqual(20); + // Should have 6 elements now... grow to 24 + buffer.insert(mockSeries, Number.POSITIVE_INFINITY); + buffer.insert(mockSeries, Number.POSITIVE_INFINITY); + buffer.insert(mockSeries, Number.POSITIVE_INFINITY); + // This should have doubled the actual buffer size + expect(buffer.getBuffer().length).toEqual(80); + // Remove some values + buffer.trim(20); + // Actual buffer size should have been reduced accordingly + expect(buffer.getBuffer().length).toBeLessThan(80); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/plot/test/elements/PlotLineSpec.js b/platform/features/plot/test/elements/PlotLineSpec.js new file mode 100644 index 0000000000..027e628bae --- /dev/null +++ b/platform/features/plot/test/elements/PlotLineSpec.js @@ -0,0 +1,114 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/elements/PlotLine"], + function (PlotLine) { + "use strict"; + + describe("A plot line", function () { + var mockBuffer, + mockSeries, + testDomainBuffer, + testRangeBuffer, + testSeries, + line; + + beforeEach(function () { + testDomainBuffer = []; + testRangeBuffer = []; + testSeries = []; + + mockBuffer = jasmine.createSpyObj( + 'buffer', + ['findInsertionIndex', 'insert', 'insertPoint', 'trim'] + ); + mockSeries = jasmine.createSpyObj( + 'series', + ['getPointCount', 'getDomainValue', 'getRangeValue'] + ); + + mockSeries.getPointCount.andCallFake(function () { + return testSeries.length; + }); + mockSeries.getDomainValue.andCallFake(function (i) { + return (testSeries[i] || [])[0]; + }); + mockSeries.getRangeValue.andCallFake(function (i) { + return (testSeries[i] || [])[1]; + }); + + // Function like PlotLineBuffer, to aid in testability + mockBuffer.findInsertionIndex.andCallFake(function (v) { + var index = 0; + if (testDomainBuffer.indexOf(v) !== -1) { + return -1; + } + while ((index < testDomainBuffer.length) && + (testDomainBuffer[index] < v)) { + index += 1; + } + return index; + }); + mockBuffer.insert.andCallFake(function (series, index) { + var domains = [], ranges = [], i; + for (i = 0; i < series.getPointCount(); i += 1) { + domains.push(series.getDomainValue(i)); + ranges.push(series.getRangeValue(i)); + } + testDomainBuffer = testDomainBuffer.slice(0, index) + .concat(domains) + .concat(testDomainBuffer.slice(index)); + testRangeBuffer = testRangeBuffer.slice(0, index) + .concat(ranges) + .concat(testRangeBuffer.slice(index)); + return true; + }); + mockBuffer.insertPoint.andCallFake(function (dv, rv, index) { + testDomainBuffer.splice(index, 0, dv); + testRangeBuffer.splice(index, 0, rv); + return true; + }); + + line = new PlotLine(mockBuffer); + }); + + it("allows single point insertion", function () { + line.addPoint(100, 200); + line.addPoint(50, 42); + line.addPoint(150, 12321); + // Should have managed insertion index choices to get to... + expect(testDomainBuffer).toEqual([50, 100, 150]); + expect(testRangeBuffer).toEqual([42, 200, 12321]); + }); + + it("allows series insertion", function () { + testSeries = [ [ 50, 42 ], [ 100, 200 ], [ 150, 12321 ] ]; + line.addSeries(mockSeries); + // Should have managed insertion index choices to get to... + expect(testDomainBuffer).toEqual([50, 100, 150]); + expect(testRangeBuffer).toEqual([42, 200, 12321]); + }); + + it("splits series insertion when necessary", function () { + testSeries = [ [ 50, 42 ], [ 100, 200 ], [ 150, 12321 ] ]; + line.addPoint(75, 1); + line.addSeries(mockSeries); + // Should have managed insertion index choices to get to... + expect(testDomainBuffer).toEqual([50, 75, 100, 150]); + expect(testRangeBuffer).toEqual([42, 1, 200, 12321]); + }); + + it("attempts to remove points when insertion fails", function () { + // Verify precondition - normally doesn't try to trim + line.addPoint(1, 2); + expect(mockBuffer.trim).not.toHaveBeenCalled(); + + // But if insertPoint fails, it should trim + mockBuffer.insertPoint.andReturn(false); + line.addPoint(2, 3); + expect(mockBuffer.trim).toHaveBeenCalled(); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/plot/test/elements/PlotSeriesWindowSpec.js b/platform/features/plot/test/elements/PlotSeriesWindowSpec.js new file mode 100644 index 0000000000..ad88874e9a --- /dev/null +++ b/platform/features/plot/test/elements/PlotSeriesWindowSpec.js @@ -0,0 +1,74 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/elements/PlotSeriesWindow"], + function (PlotSeriesWindow) { + "use strict"; + + describe("A plot's window on a telemetry series", function () { + var mockSeries, + testSeries, + window; + + beforeEach(function () { + testSeries = [ + [ 0, 42 ], + [ 10, 1 ], + [ 20, 4 ], + [ 30, 9 ], + [ 40, 3 ] + ]; + + mockSeries = jasmine.createSpyObj( + 'series', + ['getPointCount', 'getDomainValue', 'getRangeValue'] + ); + + mockSeries.getPointCount.andCallFake(function () { + return testSeries.length; + }); + mockSeries.getDomainValue.andCallFake(function (i) { + return testSeries[i][0]; + }); + mockSeries.getRangeValue.andCallFake(function (i) { + return testSeries[i][1]; + }); + + window = new PlotSeriesWindow( + mockSeries, + "testDomain", + "testRange", + 1, + testSeries.length + ); + }); + + it("provides a window upon a data series", function () { + expect(window.getPointCount()).toEqual(4); + expect(window.getDomainValue(0)).toEqual(10); + expect(window.getRangeValue(0)).toEqual(1); + }); + + it("looks up using specific domain/range keys", function () { + window.getDomainValue(0); + window.getRangeValue(0); + expect(mockSeries.getDomainValue) + .toHaveBeenCalledWith(1, 'testDomain'); + expect(mockSeries.getRangeValue) + .toHaveBeenCalledWith(1, 'testRange'); + }); + + it("can be split into smaller windows", function () { + var windows = window.split(); + expect(windows.length).toEqual(2); + expect(windows[0].getPointCount()).toEqual(2); + expect(windows[1].getPointCount()).toEqual(2); + expect(windows[0].getDomainValue(0)).toEqual(10); + expect(windows[1].getDomainValue(0)).toEqual(30); + expect(windows[0].getRangeValue(0)).toEqual(1); + expect(windows[1].getRangeValue(0)).toEqual(9); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/plot/test/elements/PlotUpdaterSpec.js b/platform/features/plot/test/elements/PlotUpdaterSpec.js index 41be699578..9b33f769c9 100644 --- a/platform/features/plot/test/elements/PlotUpdaterSpec.js +++ b/platform/features/plot/test/elements/PlotUpdaterSpec.js @@ -14,6 +14,7 @@ define( testRange, testDomainValues, testRangeValues, + mockSeries, updater; function makeMockDomainObject(id) { @@ -33,6 +34,10 @@ define( "subscription", [ "getDomainValue", "getRangeValue", "getTelemetryObjects" ] ); + mockSeries = jasmine.createSpyObj( + 'series', + ['getPointCount', 'getDomainValue', 'getRangeValue'] + ); testDomain = "testDomain"; testRange = "testRange"; testDomainValues = { a: 3, b: 7, c: 13 }; @@ -55,57 +60,14 @@ define( }); it("provides one buffer per telemetry object", function () { - expect(updater.getBuffers().length).toEqual(3); + expect(updater.getLineBuffers().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); - } + expect(updater.getLineBuffers().length).toEqual(1); }); it("can handle delayed telemetry object availability", function () { @@ -124,7 +86,7 @@ define( ); // Should have 0 buffers for 0 objects - expect(updater.getBuffers().length).toEqual(0); + expect(updater.getLineBuffers().length).toEqual(0); // Restore the three objects the test subscription would // normally have. @@ -132,30 +94,79 @@ define( updater.update(); // Should have 3 buffers for 3 objects - expect(updater.getBuffers().length).toEqual(3); + expect(updater.getLineBuffers().length).toEqual(3); }); + it("accepts historical telemetry updates", function () { + var mockObject = mockSubscription.getTelemetryObjects()[0]; - it("shifts buffer upon expansion", function () { - // Count should be large enough to hit buffer's max size - var count = 1400, - i; + mockSeries.getPointCount.andReturn(3); + mockSeries.getDomainValue.andCallFake(function (i) { + return 1000 + i * 1000; + }); + mockSeries.getRangeValue.andReturn(10); - // Initial update; should have 3 in first position - // (a's initial domain value) + // PlotLine & PlotLineBuffer are tested for most of the + // details here, so just check for some expected side + // effect; in this case, should see more points in the buffer + expect(updater.getLineBuffers()[0].getLength()).toEqual(1); + updater.addHistorical(mockObject, mockSeries); + expect(updater.getLineBuffers()[0].getLength()).toEqual(4); + }); + + it("clears the domain offset if no objects are present", function () { + mockSubscription.getTelemetryObjects.andReturn([]); 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); + expect(updater.getDomainOffset()).toBeUndefined(); }); + + it("handles empty historical telemetry updates", function () { + // General robustness check for when a series is empty + var mockObject = mockSubscription.getTelemetryObjects()[0]; + + mockSeries.getPointCount.andReturn(0); + mockSeries.getDomainValue.andCallFake(function (i) { + return 1000 + i * 1000; + }); + mockSeries.getRangeValue.andReturn(10); + + // PlotLine & PlotLineBuffer are tested for most of the + // details here, so just check for some expected side + // effect; in this case, should see more points in the buffer + expect(updater.getLineBuffers()[0].getLength()).toEqual(1); + updater.addHistorical(mockObject, mockSeries); + expect(updater.getLineBuffers()[0].getLength()).toEqual(1); + }); + + it("can initialize domain offset from historical telemetry", function () { + var tmp = mockSubscription.getTelemetryObjects(); + + mockSubscription.getTelemetryObjects.andReturn([]); + + // Reinstantiate with the empty subscription + updater = new PlotUpdater( + mockSubscription, + testDomain, + testRange + ); + + // Restore subscription, provide some historical data + mockSubscription.getTelemetryObjects.andReturn(tmp); + mockSeries.getPointCount.andReturn(3); + mockSeries.getDomainValue.andCallFake(function (i) { + return 1000 + i * 1000; + }); + mockSeries.getRangeValue.andReturn(10); + + // PlotLine & PlotLineBuffer are tested for most of the + // details here, so just check for some expected side + // effect; in this case, should see more points in the buffer + expect(updater.getDomainOffset()).toBeUndefined(); + updater.addHistorical(tmp[0], mockSeries); + expect(updater.getDomainOffset()).toBeDefined(); + }); + + }); } ); \ 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 8c2922cf22..ee1be40bfc 100644 --- a/platform/features/plot/test/modes/PlotOverlayModeSpec.js +++ b/platform/features/plot/test/modes/PlotOverlayModeSpec.js @@ -57,18 +57,30 @@ define( // Prepared telemetry data mockPrepared = jasmine.createSpyObj( "prepared", - [ "getDomainOffset", "getOrigin", "getDimensions", "getBuffers", "getLength" ] + [ + "getDomainOffset", + "getOrigin", + "getDimensions", + "getLineBuffers" + ] ); mockSubPlotFactory.createSubPlot.andCallFake(createMockSubPlot); // Act as if we have three buffers full of data - testBuffers = [["a"], ["b"], ["c"]]; - mockPrepared.getBuffers.andReturn(testBuffers); + testBuffers = ['a', 'b', 'c'].map(function (id) { + var mockBuffer = jasmine.createSpyObj( + 'buffer-' + id, + ['getBuffer', 'getLength'] + ); + mockBuffer.getBuffer.andReturn([id]); + mockBuffer.getLength.andReturn(3); + return mockBuffer; + }); + mockPrepared.getLineBuffers.andReturn(testBuffers); mockPrepared.getDomainOffset.andReturn(1234); mockPrepared.getOrigin.andReturn([10, 10]); mockPrepared.getDimensions.andReturn([500, 500]); - mockPrepared.getLength.andReturn(3); // Clear out drawing objects testDrawingObjects = []; @@ -104,7 +116,7 @@ define( // Make sure the right buffer was drawn to the // right subplot. testDrawingObject.lines.forEach(function (line, j) { - expect(line.buffer).toEqual(testBuffers[j]); + expect(line.buffer).toEqual(testBuffers[j].getBuffer()); }); }); }); diff --git a/platform/features/plot/test/modes/PlotStackModeSpec.js b/platform/features/plot/test/modes/PlotStackModeSpec.js index 4c2c11a308..6101d2ccc8 100644 --- a/platform/features/plot/test/modes/PlotStackModeSpec.js +++ b/platform/features/plot/test/modes/PlotStackModeSpec.js @@ -57,18 +57,25 @@ define( // Prepared telemetry data mockPrepared = jasmine.createSpyObj( "prepared", - [ "getDomainOffset", "getOrigin", "getDimensions", "getBuffers", "getLength" ] + [ "getDomainOffset", "getOrigin", "getDimensions", "getLineBuffers" ] ); mockSubPlotFactory.createSubPlot.andCallFake(createMockSubPlot); // Act as if we have three buffers full of data - testBuffers = [["a"], ["b"], ["c"]]; - mockPrepared.getBuffers.andReturn(testBuffers); + testBuffers = ['a', 'b', 'c'].map(function (id) { + var mockBuffer = jasmine.createSpyObj( + 'buffer-' + id, + ['getBuffer', 'getLength'] + ); + mockBuffer.getBuffer.andReturn([id]); + mockBuffer.getLength.andReturn(3); + return mockBuffer; + }); + mockPrepared.getLineBuffers.andReturn(testBuffers); 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 = []; @@ -104,7 +111,7 @@ define( // Make sure the right buffer was drawn to the // right subplot. expect(testDrawingObject.lines[0].buffer) - .toEqual(testBuffers[i]); + .toEqual(testBuffers[i].getBuffer()); }); }); diff --git a/platform/features/plot/test/suite.json b/platform/features/plot/test/suite.json index 92ee3b07c8..e3fa3fb796 100644 --- a/platform/features/plot/test/suite.json +++ b/platform/features/plot/test/suite.json @@ -6,11 +6,14 @@ "SubPlot", "SubPlotFactory", "elements/PlotAxis", + "elements/PlotLine", + "elements/PlotLineBuffer", "elements/PlotPalette", "elements/PlotPanZoomStack", "elements/PlotPanZoomStackGroup", "elements/PlotPosition", "elements/PlotPreparer", + "elements/PlotSeriesWindow", "elements/PlotTickGenerator", "elements/PlotUpdater", "modes/PlotModeOptions", diff --git a/platform/telemetry/bundle.json b/platform/telemetry/bundle.json index 9b02e64aec..69b32b54c7 100644 --- a/platform/telemetry/bundle.json +++ b/platform/telemetry/bundle.json @@ -43,6 +43,11 @@ "key": "telemetrySubscriber", "implementation": "TelemetrySubscriber.js", "depends": [ "$q", "$timeout" ] + }, + { + "key": "telemetryHandler", + "implementation": "TelemetryHandler.js", + "depends": [ "$q", "telemetrySubscriber" ] } ], "licenses": [ diff --git a/platform/telemetry/src/TelemetryDelegator.js b/platform/telemetry/src/TelemetryDelegator.js new file mode 100644 index 0000000000..df66e84407 --- /dev/null +++ b/platform/telemetry/src/TelemetryDelegator.js @@ -0,0 +1,45 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Used to handle telemetry delegation associated with a + * given domain object. + */ + function TelemetryDelegator($q) { + return { + /** + * Promise telemetry-providing objects associated with + * this domain object (either the domain object itself, + * or the objects it delegates) + * @returns {Promise.} domain objects with + * a telemetry capability + */ + promiseTelemetryObjects: function (domainObject) { + // If object has been cleared, there are no relevant + // telemetry-providing domain objects. + if (!domainObject) { + return $q.when([]); + } + + // Otherwise, try delegation first, and attach the + // object itself if it has a telemetry capability. + return $q.when(domainObject.useCapability( + "delegation", + "telemetry" + )).then(function (result) { + var head = domainObject.hasCapability("telemetry") ? + [ domainObject ] : [], + tail = result || []; + return head.concat(tail); + }); + } + }; + } + + return TelemetryDelegator; + } +); \ No newline at end of file diff --git a/platform/telemetry/src/TelemetryHandle.js b/platform/telemetry/src/TelemetryHandle.js new file mode 100644 index 0000000000..fd5d7582f9 --- /dev/null +++ b/platform/telemetry/src/TelemetryHandle.js @@ -0,0 +1,91 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * A telemetry handle acts as a helper in issuing requests for + * new telemetry as well as subscribing to real-time updates + * for the same telemetry series. This is exposed through the + * `telemetryHandler` service. + * @param $q Angular's $q, for promises + * @param {TelemetrySubscription} subscription a subscription + * to supplied telemetry + */ + function TelemetryHandle($q, subscription) { + var seriesMap = {}, + self = Object.create(subscription); + + // Request a telemetry series for this specific object + function requestSeries(telemetryObject, request, callback) { + var id = telemetryObject.getId(), + telemetry = telemetryObject.getCapability('telemetry'); + + function receiveSeries(series) { + // Store it for subsequent lookup + seriesMap[id] = series; + // Notify callback of new series data, if there is one + if (callback) { + callback(telemetryObject, series); + } + // Pass it along for promise-chaining + return series; + } + + // Issue the request via the object's telemetry capability + return telemetry.requestData(request).then(receiveSeries); + } + + + /** + * Get the most recently obtained telemetry data series associated + * with this domain object. + * @param {DomainObject} the domain object which has telemetry + * data associated with it + * @return {TelemetrySeries} the most recent telemetry series + * (or undefined if there is not one) + */ + self.getSeries = function (domainObject) { + var id = domainObject.getId(); + return seriesMap[id]; + }; + + + /** + * Change the request duration. + * @param {object|number} request the duration of historical + * data to look at; or, the request to issue + * @param {Function} [callback] a callback that will be + * invoked as new data becomes available, with the + * domain object for which new data is available. + */ + self.request = function (request, callback) { + // Issue (and handle) the new request from this object + function issueRequest(telemetryObject) { + return requestSeries(telemetryObject, request, callback); + } + + // Map the request to all telemetry objects + function issueRequests(telemetryObjects) { + return $q.all(telemetryObjects.map(issueRequest)); + } + + // If the request is a simple number, treat it as a duration + request = (typeof request === 'number') ? + { duration: request } : request; + + // Look up telemetry-providing objects from the subscription, + // then issue new requests. + return subscription.promiseTelemetryObjects() + .then(issueRequests); + }; + + return self; + } + + return TelemetryHandle; + + } +); \ No newline at end of file diff --git a/platform/telemetry/src/TelemetryHandler.js b/platform/telemetry/src/TelemetryHandler.js new file mode 100644 index 0000000000..2b3aa954eb --- /dev/null +++ b/platform/telemetry/src/TelemetryHandler.js @@ -0,0 +1,33 @@ +/*global define*/ + +define( + ['./TelemetryHandle'], + function (TelemetryHandle) { + "use strict"; + + + /** + * A TelemetryRequester provides an easy interface to request + * telemetry associated with a set of domain objects. + * + * @constructor + * @param $q Angular's $q + */ + function TelemetryHandler($q, telemetrySubscriber) { + return { + handle: function (domainObject, callback, lossless) { + var subscription = telemetrySubscriber.subscribe( + domainObject, + callback, + lossless + ); + + return new TelemetryHandle($q, subscription); + } + }; + } + + return TelemetryHandler; + + } +); \ No newline at end of file diff --git a/platform/telemetry/src/TelemetrySubscription.js b/platform/telemetry/src/TelemetrySubscription.js index cce24d48dd..2afae3495c 100644 --- a/platform/telemetry/src/TelemetrySubscription.js +++ b/platform/telemetry/src/TelemetrySubscription.js @@ -1,8 +1,8 @@ /*global define*/ define( - ['./TelemetryQueue', './TelemetryTable'], - function (TelemetryQueue, TelemetryTable) { + ['./TelemetryQueue', './TelemetryTable', './TelemetryDelegator'], + function (TelemetryQueue, TelemetryTable, TelemetryDelegator) { "use strict"; @@ -31,7 +31,9 @@ define( * the callback once, with access to the latest data */ function TelemetrySubscription($q, $timeout, domainObject, callback, lossless) { - var unsubscribePromise, + var delegator = new TelemetryDelegator($q), + unsubscribePromise, + telemetryObjectPromise, latestValues = {}, telemetryObjects = [], pool = lossless ? new TelemetryQueue() : new TelemetryTable(), @@ -42,23 +44,7 @@ define( // This will either be the object in view, or object that // this object delegates its telemetry capability to. function promiseRelevantObjects(domainObject) { - // If object has been cleared, there are no relevant - // telemetry-providing domain objects. - if (!domainObject) { - return $q.when([]); - } - - // Otherwise, try delegation first, and attach the - // object itself if it has a telemetry capability. - return $q.when(domainObject.useCapability( - "delegation", - "telemetry" - )).then(function (result) { - var head = domainObject.hasCapability("telemetry") ? - [ domainObject ] : [], - tail = result || []; - return head.concat(tail); - }); + return delegator.promiseTelemetryObjects(domainObject); } function updateValuesFromPool() { @@ -152,8 +138,8 @@ define( // will be unsubscribe functions. (This must be a promise // because delegation is supported, and retrieving delegate // telemetry-capable objects may be an asynchronous operation.) - unsubscribePromise = - promiseRelevantObjects(domainObject) + telemetryObjectPromise = promiseRelevantObjects(domainObject); + unsubscribePromise = telemetryObjectPromise .then(cacheObjectReferences) .then(subscribeAll); @@ -239,6 +225,17 @@ define( */ getMetadata: function () { return metadatas; + }, + /** + * Get a promise for all telemetry-providing objects + * associated with this subscription. + * @returns {Promise.} a promise for + * telemetry-providing objects + */ + promiseTelemetryObjects: function () { + // Unsubscribe promise is available after objects + // are loaded. + return telemetryObjectPromise; } }; } diff --git a/platform/telemetry/test/TelemetryDelegatorSpec.js b/platform/telemetry/test/TelemetryDelegatorSpec.js new file mode 100644 index 0000000000..9ff80d3c34 --- /dev/null +++ b/platform/telemetry/test/TelemetryDelegatorSpec.js @@ -0,0 +1,12 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/TelemetryDelegator"], + function (TelemetryDelegator) { + "use strict"; + + describe("The telemetry delegator", function () { + + }); + } +); diff --git a/platform/telemetry/test/TelemetryHandleSpec.js b/platform/telemetry/test/TelemetryHandleSpec.js new file mode 100644 index 0000000000..c7db9ac835 --- /dev/null +++ b/platform/telemetry/test/TelemetryHandleSpec.js @@ -0,0 +1,88 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/TelemetryHandle"], + function (TelemetryHandle) { + "use strict"; + + describe("A telemetry handle", function () { + var mockQ, + mockSubscription, + mockDomainObject, + mockTelemetry, + mockSeries, + mockCallback, + handle; + + function asPromise(v) { + return (v || {}).then ? v : { + then: function (callback) { + return asPromise(callback(v)); + } + }; + } + + beforeEach(function () { + mockQ = jasmine.createSpyObj('$q', ['when', 'all']); + mockSubscription = jasmine.createSpyObj( + 'subscription', + ['unsubscribe', 'getTelemetryObjects', 'promiseTelemetryObjects'] + ); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + ['getId', 'getCapability'] + ); + mockTelemetry = jasmine.createSpyObj( + 'telemetry', + ['requestData'] + ); + mockSeries = jasmine.createSpyObj( + 'series', + ['getPointCount', 'getDomainValue', 'getRangeValue'] + ); + mockCallback = jasmine.createSpy('callback'); + + // Simulate $q.all, at least for asPromise-provided promises + mockQ.all.andCallFake(function (values) { + return values.map(function (v) { + var r; + asPromise(v).then(function (value) { r = value; }); + return r; + }); + }); + mockQ.when.andCallFake(asPromise); + mockSubscription.getTelemetryObjects + .andReturn([mockDomainObject]); + mockSubscription.promiseTelemetryObjects + .andReturn(asPromise([mockDomainObject])); + mockDomainObject.getId.andReturn('testId'); + mockDomainObject.getCapability.andReturn(mockTelemetry); + mockTelemetry.requestData.andReturn(asPromise(mockSeries)); + + handle = new TelemetryHandle(mockQ, mockSubscription); + }); + + it("exposes subscription API", function () { + // Should still expose methods from the provided subscription + expect(handle.unsubscribe) + .toBe(mockSubscription.unsubscribe); + expect(handle.getTelemetryObjects) + .toBe(mockSubscription.getTelemetryObjects); + }); + + it("provides an interface for historical requests", function () { + handle.request({}, mockCallback); + expect(mockCallback).toHaveBeenCalledWith( + mockDomainObject, + mockSeries + ); + }); + + it("provides the latest series for domain objects", function () { + handle.request({}); + expect(handle.getSeries(mockDomainObject)) + .toEqual(mockSeries); + }); + }); + } +); diff --git a/platform/telemetry/test/TelemetryHandlerSpec.js b/platform/telemetry/test/TelemetryHandlerSpec.js new file mode 100644 index 0000000000..be0588a0e9 --- /dev/null +++ b/platform/telemetry/test/TelemetryHandlerSpec.js @@ -0,0 +1,64 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/TelemetryHandler"], + function (TelemetryHandler) { + "use strict"; + + describe("The telemetry handler", function () { + // TelemetryHandler just provides a factory + // for TelemetryHandle, so most real testing + // should happen there. + var mockQ, + mockSubscriber, + mockDomainObject, + mockCallback, + mockSubscription, + handler; + + beforeEach(function () { + mockQ = jasmine.createSpyObj("$q", ["when"]); + mockSubscriber = jasmine.createSpyObj( + 'telemetrySubscriber', + ['subscribe'] + ); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + ['getId', 'getCapability'] + ); + mockCallback = jasmine.createSpy('callback'); + mockSubscription = jasmine.createSpyObj( + 'subscription', + [ + 'unsubscribe', + 'getTelemetryObjects', + 'getRangeValue', + 'getDomainValue' + ] + ); + + mockSubscriber.subscribe.andReturn(mockSubscription); + + handler = new TelemetryHandler(mockQ, mockSubscriber); + }); + + it("acts as a factory for subscription objects", function () { + var handle = handler.handle( + mockDomainObject, + mockCallback + ); + // Just verify that this looks like a TelemetrySubscription + [ + "unsubscribe", + "getTelemetryObjects", + "getRangeValue", + "getDomainValue", + "request" + ].forEach(function (method) { + expect(handle[method]).toEqual(jasmine.any(Function)); + }); + }); + + }); + } +); diff --git a/platform/telemetry/test/TelemetrySubscriptionSpec.js b/platform/telemetry/test/TelemetrySubscriptionSpec.js index 6dd27320c9..057aa97748 100644 --- a/platform/telemetry/test/TelemetrySubscriptionSpec.js +++ b/platform/telemetry/test/TelemetrySubscriptionSpec.js @@ -184,6 +184,14 @@ define( it("fires callback when telemetry objects are available", function () { expect(mockCallback.calls.length).toEqual(1); }); + + it("exposes a promise for telemetry objects", function () { + var mockCallback2 = jasmine.createSpy('callback'); + subscription.promiseTelemetryObjects().then(mockCallback2); + + expect(mockCallback2) + .toHaveBeenCalledWith([ mockDomainObject ]); + }); }); } ); \ No newline at end of file diff --git a/platform/telemetry/test/suite.json b/platform/telemetry/test/suite.json index 0ed5887ac5..8881466572 100644 --- a/platform/telemetry/test/suite.json +++ b/platform/telemetry/test/suite.json @@ -2,7 +2,10 @@ "TelemetryAggregator", "TelemetryCapability", "TelemetryController", + "TelemetryDelegator", "TelemetryFormatter", + "TelemetryHandle", + "TelemetryHandler", "TelemetryQueue", "TelemetrySubscriber", "TelemetrySubscription",