diff --git a/platform/features/plot/src/elements/PlotLine.js b/platform/features/plot/src/elements/PlotLine.js index 3c2257c317..3a507868d9 100644 --- a/platform/features/plot/src/elements/PlotLine.js +++ b/platform/features/plot/src/elements/PlotLine.js @@ -6,67 +6,35 @@ define( "use strict"; - function PlotLine(initialSize, maxPoints) { - var buffer, - length = 0, - timeWindow; + function PlotLine(buffer) { - // Binary search the buffer to find the index where - // a point with this timestamp should be inserted. - // After is a flag indicating whether it is preferred - // to insert after or before its nearest timestamp - function searchBuffer(timestamp, after) { - // Binary search for an appropriate insertion index. - function binSearch(min, max) { - var mid = Math.floor((min + max) / 2), - ts; + // Insert a time-windowed data series into the buffer + function insertSeriesWindow(seriesWindow) { + var count = seriesWindow.getPointCount(); - if (max < min) { - return -1; - } + function doInsert() { + var firstTimestamp = buffer.getDomainValue(0), + lastTimestamp = buffer.getDomainValue(count - 1), + startIndex = buffer.findInsertionIndex(firstTimestamp), + endIndex = buffer.findInsertionIndex(lastTimestamp); - ts = buffer[mid * 2]; - - // Check for an exact match... - if (ts === timestamp) { - // This is a case where we'll need to - // split up what we want to insert. - return mid + after ? -1 : 1; + // Does the whole series fit in between two adjacent indexes? + if ((startIndex === endIndex) && startIndex > -1) { + // Insert it in between + buffer.insert(seriesWindow, startIndex); } else { - // Found our index? - if (max === min) { - return max; - } - // Otherwise, search recursively - if (ts < timestamp) { - - } else { - - } + // Split it up, and add the two halves + seriesWindow.split().forEach(insertSeriesWindow); } - } - // Booleanize - after = !!after; - - return binSearch(0, length - 1); - } - - function insertSeriesWindow(seriesWindow) { - var startIndex = findStartIndex(), - endIndex = findEndIndex(); - - if (startIndex === endIndex) { - - } 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) { - // TODO: Enforce time window, too! return new PlotSeriesWindow( series, domain, @@ -77,8 +45,20 @@ define( } return { - addData: function (domainValue, rangeValue) { - // Should append to buffer + getLineBuffer: function () { + return buffer; + }, + addPoint: function (domainValue, rangeValue) { + var index = buffer.findInsertionIndex(domainValue); + if (index > -1) { + // 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); + } + } }, addSeries: function (series, domain, range) { // Should try to add via insertion if a @@ -87,12 +67,6 @@ define( // Insertion operation also needs to factor out // redundant timestamps, for overlapping data insertSeriesWindow(createWindow(series, domain, range)); - }, - setTimeWindow: function (start, end) { - timeWindow = [ start, end ]; - }, - clearTimeWindow: function () { - timeWindow = undefined; } }; } diff --git a/platform/features/plot/src/elements/PlotLineBuffer.js b/platform/features/plot/src/elements/PlotLineBuffer.js index 1504fe021c..724347ed0c 100644 --- a/platform/features/plot/src/elements/PlotLineBuffer.js +++ b/platform/features/plot/src/elements/PlotLineBuffer.js @@ -14,6 +14,7 @@ define( */ 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 @@ -68,6 +69,17 @@ define( 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 = [ + Math.min(rangeExtrema[0], rangeValue), + Math.max(rangeExtrema[1], rangeValue) + ]; + } return { /** @@ -84,6 +96,29 @@ define( 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 @@ -119,14 +154,13 @@ define( */ insert: function (series, index) { var sz = series.getPointCount(), - free = (buffer.length / 2) - length, i; // Don't allow append after the end; that doesn't make sense index = Math.min(index, length); // Resize if necessary - if (sz > free) { + while (sz > ((buffer.length / 2) - length)) { if (!doubleBufferSize()) { // Can't make room for this, insertion fails return false; @@ -143,10 +177,11 @@ define( // Insert data into the set for (i = 0; i < sz; i += 1) { - buffer[(i + index) * 2] = - series.getDomainValue(i) - domainOffset; - buffer[(i + index) * 2 + 1] = - series.getRangeValue(i); + setValue( + i + index, + series.getDomainValue(i), + series.getRangeValue(i) + ); } // Increase the length @@ -155,6 +190,29 @@ define( // 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 @@ -166,11 +224,15 @@ define( * @returns {number} the index for insertion (or -1) */ findInsertionIndex: function (timestamp) { - return binSearch( - timestamp - domainOffset, - 0, - length - 1 - ); + 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); } }; } diff --git a/platform/features/plot/src/elements/PlotUpdater.js b/platform/features/plot/src/elements/PlotUpdater.js index 3b9be1d7a3..f39b77437b 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,164 +18,171 @@ 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 = {}, - realtimeIndex = {}, - 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)); - // Update the realtime index accordingly - realtimeIndex[id] = Math.max(realtimeIndex[id] - 1, 0); - } + // 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); + } + + // Prepare plot lines for this group of telemetry objects + function prepareLines(telemetryObjects) { + var nextIds = telemetryObjects.map(getId), + next = {}; + + // Detect if we already have everything we need prepared + if (ids.length === nextIds.length && idsMatch(nextIds)) { + // Nothing to prepare, move on + return; } - return buffer; - } + // Update list of ids in use + ids = nextIds; - function setData(buffer, id, index, domainValue, rangeValue) { - // 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; - } - - // 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; + // Built up a set of ids. Note that we can only + // create plot lines after our domain offset has + // been determined. + if (domainOffset !== undefined) { + bufferArray = ids.map(function (id) { + var buffer = new PlotLineBuffer( + domainOffset, + INITIAL_SIZE, + maxPoints + ); + next[id] = lines[id] || new PlotLine(buffer); + return buffer; + }); } - // Update the cutoff point for when we started receiving - // realtime data, to aid in clearing historical data later - if (realtimeIndex[id] === undefined) { - realtimeIndex[id] = index; + // If there are no more lines, clear the domain offset + if (Object.keys(lines).length < 1) { + domainOffset = undefined; } - // Put the data in the buffer - return setData( - buffer, - id, - index, - domainValue, - rangeValue - ); + // 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; } - // Update historical data for this domain object - function setHistorical(domainObject) { - var id = domainObject.getId(), - // Buffer to expand - buffer = buffers[id], - // Index where historical data ends (and realtime begins) - endIndex = realtimeIndex[id] || 0, - // Look up the data series - series = subscription.getSeries(domainObject), - // Get its length - seriesLength = series ? series.getPointCount() : 0, - // Get the current buffer length... - length = lengths[id] || 0, - // As well as the length of the realtime segment - realtimeLength = length - endIndex, - // Determine the new total length of the existing - // realtime + new historical segment. - totalLength = - Math.min(seriesLength + realtimeLength, maxPoints), - seriesFit = Math.max(0, totalLength - realtimeLength); + // Used in the reduce step of updateExtrema + function reduceExtrema(a, b) { + return [ Math.min(a[0], b[0]), Math.max(a[1], b[1]) ]; + } - // Make sure the buffer is big enough + // Convert a domain/range extrema to plot dimensions + function dimensionsOf(extrema) { + return extrema[1] - extrema[0]; + } - // Move the realtime data into the correct position + // Convert a domain/range extrema to a plot origin + function originOf(extrema) { + return extrema[0]; + } - // Insert the historical data before it + // Update dimensions and origin based on extrema of plots + function updateExtrema() { + 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)]; } // Handle new telemetry data function update() { - var objects = subscription.getTelemetryObjects(); - bufferArray = objects.map(addData); - lengthArray = objects.map(function (obj) { - return lengths[obj.getId()]; + var objects = handle.getTelemetryObjects(); + + // Initialize domain offset if necessary + if (domainOffset === undefined) { + initializeDomainOffset(objects.map(function (obj) { + return handle.getDomainValue(obj, domain); + })); + } + + // Make sure lines are available + prepareLines(objects); + + // Add new data + objects.forEach(function (obj, index) { + lines[obj.getId()].addPoint( + handle.getDomainValue(obj, domain), + handle.getRangeValue(obj, range) + ); }); - updateDomainExtrema(objects); + + // 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.getPointCount(), + line; + + // Nothing to do if it's an empty series + if (count < 1) { + return; + } + + // Initialize domain offset if necessary + if (domainOffset === undefined && series) { + 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 @@ -183,7 +191,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); + prepareLines(handle.getTelemetryObjects()); return { /** @@ -193,10 +201,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. @@ -207,7 +212,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 @@ -236,19 +241,9 @@ 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. */ 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();