diff --git a/bundles.json b/bundles.json index aeeccff228..c9bd1a9484 100644 --- a/bundles.json +++ b/bundles.json @@ -7,6 +7,8 @@ "platform/commonUI/dialog", "platform/commonUI/general", "platform/telemetry", + "platform/features/plot", + "example/generator", "example/persistence" ] \ No newline at end of file diff --git a/platform/features/scrolling/bundle.json b/platform/features/scrolling/bundle.json new file mode 100644 index 0000000000..e2763b03fc --- /dev/null +++ b/platform/features/scrolling/bundle.json @@ -0,0 +1,24 @@ +{ + "name": "Scrolling Lists", + "description": "Time-ordered list of latest data.", + "extensions": { + "views": [ + { + "key": "scrolling", + "name": "Scrolling", + "description": "Scrolling list of data values.", + "templateUrl": "templates/scrolling.html", + "needs": [ "telemetry" ], + "delegation": true + } + ], + "controllers": [ + { + "key": "ScrollingListController", + "implementation": "ScrollingListController.js", + "depends": [ "$scope" ] + } + ] + } + +} \ No newline at end of file diff --git a/platform/features/scrolling/res/templates/scrolling.html b/platform/features/scrolling/res/templates/scrolling.html new file mode 100644 index 0000000000..a26fd6503e --- /dev/null +++ b/platform/features/scrolling/res/templates/scrolling.html @@ -0,0 +1,25 @@ +
+ +
+
+ +
+ +
+ {{header}}{{header}} +
+
+ +
+
+ {{cell}} +
+
+ +
+
+
+ + diff --git a/platform/features/scrolling/src/DomainColumn.js b/platform/features/scrolling/src/DomainColumn.js new file mode 100644 index 0000000000..cf4383a37a --- /dev/null +++ b/platform/features/scrolling/src/DomainColumn.js @@ -0,0 +1,49 @@ +/*global define,moment*/ + +/** + * Module defining DomainColumn. Created by vwoeltje on 11/18/14. + */ +define( + ["../../plot/lib/moment.min"], + function () { + "use strict"; + + // Date format to use for domain values; in particular, + // use day-of-year instead of month/day + var DATE_FORMAT = "YYYY-DDD HH:mm:ss"; + + /** + * A column which will report telemetry domain values + * (typically, timestamps.) Used by the ScrollingListController. + * + * @constructor + * @param domainMetadata an object with the machine- and human- + * readable names for this domain (in `key` and `name` + * fields, respectively.) + */ + function DomainColumn(domainMetadata) { + return { + /** + * Get the title to display in this column's header. + * @returns {string} the title to display + */ + getTitle: function () { + return domainMetadata.name; + }, + /** + * Get the text to display inside a row under this + * column. + * @returns {string} the text to display + */ + getValue: function (domainObject, data, index) { + return moment.utc(data.getDomainValue( + index, + domainMetadata.key + )).format(DATE_FORMAT); + } + }; + } + + return DomainColumn; + } +); \ No newline at end of file diff --git a/platform/features/scrolling/src/NameColumn.js b/platform/features/scrolling/src/NameColumn.js new file mode 100644 index 0000000000..4b72f791ed --- /dev/null +++ b/platform/features/scrolling/src/NameColumn.js @@ -0,0 +1,39 @@ +/*global define,Promise*/ + +/** + * Module defining NameColumn. Created by vwoeltje on 11/18/14. + */ +define( + [], + function () { + "use strict"; + + /** + * A column which will report the name of the domain object + * which exposed specific telemetry values. + * + * @constructor + */ + function NameColumn() { + return { + /** + * Get the title to display in this column's header. + * @returns {string} the title to display + */ + getTitle: function () { + return "Name"; + }, + /** + * Get the text to display inside a row under this + * column. This returns the domain object's name. + * @returns {string} the text to display + */ + getValue: function (domainObject) { + return domainObject.getModel().name; + } + }; + } + + return NameColumn; + } +); \ No newline at end of file diff --git a/platform/features/scrolling/src/RangeColumn.js b/platform/features/scrolling/src/RangeColumn.js new file mode 100644 index 0000000000..f710b31a05 --- /dev/null +++ b/platform/features/scrolling/src/RangeColumn.js @@ -0,0 +1,43 @@ +/*global define,Promise*/ + +/** + * Module defining DomainColumn. Created by vwoeltje on 11/18/14. + */ +define( + [], + function () { + "use strict"; + + /** + * A column which will report telemetry range values + * (typically, measurements.) Used by the ScrollingListController. + * + * @constructor + * @param rangeMetadata an object with the machine- and human- + * readable names for this range (in `key` and `name` + * fields, respectively.) + */ + function RangeColumn(rangeMetadata) { + return { + /** + * Get the title to display in this column's header. + * @returns {string} the title to display + */ + getTitle: function () { + return rangeMetadata.name; + }, + /** + * Get the text to display inside a row under this + * column. + * @returns {string} the text to display + */ + getValue: function (domainObject, data, index) { + var value = data.getRangeValue(index, rangeMetadata.key); + return value && value.toFixed(3); + } + }; + } + + return RangeColumn; + } +); \ No newline at end of file diff --git a/platform/features/scrolling/src/ScrollingListController.js b/platform/features/scrolling/src/ScrollingListController.js new file mode 100644 index 0000000000..8ef0bb4a4a --- /dev/null +++ b/platform/features/scrolling/src/ScrollingListController.js @@ -0,0 +1,113 @@ +/*global define,Promise*/ + +/** + * Module defining ListController. Created by vwoeltje on 11/18/14. + */ +define( + ["./NameColumn", "./DomainColumn", "./RangeColumn", "./ScrollingListPopulator"], + function (NameColumn, DomainColumn, RangeColumn, ScrollingListPopulator) { + "use strict"; + + var ROW_COUNT = 18; + + /** + * The ScrollingListController is responsible for populating + * the contents of the scrolling list view. + * @constructor + */ + function ScrollingListController($scope) { + var populator; + + + // Get a set of populated, ready-to-display rows for the + // latest data values. + function getRows(telemetry) { + var datas = telemetry.getResponse(), + objects = telemetry.getTelemetryObjects(); + + return populator.getRows(datas, objects, ROW_COUNT); + } + + // Update the contents + function updateRows() { + var telemetry = $scope.telemetry; + $scope.rows = telemetry ? getRows(telemetry) : []; + } + + // Set up columns based on telemetry metadata. This will + // include one column for each domain and range type, as + // well as a column for the domain object name. + function setupColumns(telemetry) { + var domainKeys = {}, + rangeKeys = {}, + columns = [], + metadata; + + // Add a domain to the set of columns, if a domain + // with the same key has not yet been inclued. + function addDomain(domain) { + var key = domain.key; + if (key && !domainKeys[key]) { + domainKeys[key] = true; + columns.push(new DomainColumn(domain)); + } + } + + // Add a range to the set of columns, if a range + // with the same key has not yet been inclued. + function addRange(range) { + var key = range.key; + if (key && !rangeKeys[key]) { + rangeKeys[key] = true; + columns.push(new RangeColumn(range)); + } + } + + // We cannot proceed if the telemetry controller + // is not available; clear all rows/columns. + if (!telemetry) { + columns = []; + $scope.rows = []; + $scope.headers = []; + return; + } + + columns = [ new NameColumn() ]; + + // Add domain, range columns + metadata = telemetry.getMetadata(); + (metadata || []).forEach(function (metadata) { + (metadata.domains || []).forEach(addDomain); + }); + (metadata || []).forEach(function (metadata) { + (metadata.ranges || []).forEach(addRange); + }); + + // Add default domain, range columns if none + // were described in metadata. + if (Object.keys(domainKeys).length < 1) { + columns.push(new DomainColumn({ name: "Time" })); + } + if (Object.keys(rangeKeys).length < 1) { + columns.push(new RangeColumn({ name: "Value" })); + } + + // We have all columns now; use them to initializer + // the populator, which will use them to generate + // actual rows and headers. + populator = new ScrollingListPopulator(columns); + + // Initialize headers + $scope.headers = populator.getHeaders(); + + // Fill in the contents of the rows. + updateRows(); + } + + $scope.$on("telemetryUpdate", updateRows); + $scope.$watch("telemetry", setupColumns); + } + + return ScrollingListController; + } +); \ No newline at end of file diff --git a/platform/features/scrolling/src/ScrollingListPopulator.js b/platform/features/scrolling/src/ScrollingListPopulator.js new file mode 100644 index 0000000000..b2d1a5da72 --- /dev/null +++ b/platform/features/scrolling/src/ScrollingListPopulator.js @@ -0,0 +1,115 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + function ScrollingListPopulator(columns) { + /** + * Look up the most recent values from a set of data objects. + * Returns an array of objects in the order in which data + * should be displayed; each element is an object with + * two properties: + * + * * objectIndex: The index of the domain object associated + * with the data point to be displayed in that + * row. + * * pointIndex: The index of the data point itself, within + * its data set. + * + * @param {Array} datas an array of the most recent + * data objects; expected to be in the same order + * as the domain objects provided at constructor + * @param {Array= 0) { + nextTime = data.getDomainValue(pointIndex); + if (nextTime > candidateTime) { + candidateTime = nextTime; + candidate = { + objectIndex: i, + pointIndex: pointIndex + }; + } + } + } + + // Assemble a list of the most recent data points + while (latest.length < count) { + // Reset variables pre-search + candidateTime = Number.NEGATIVE_INFINITY; + candidate = undefined; + + // Linear search for most recent + datas.forEach(findCandidate); + + if (candidate) { + // Record this data point - it is the most recent + latest.push(candidate); + + // Track the data points used so we can look farther back + // in the data set on the next iteration + used[candidate.objectIndex] = used[candidate.objectIndex] + 1; + } else { + // Ran out of candidates; not enough data points + // available to fill all rows. + break; + } + } + + return latest; + } + + + return { + getHeaders: function () { + return columns.map(function (column) { + return column.getTitle(); + }); + }, + getRows: function (datas, objects, count) { + var values = getLatestDataValues(datas, count); + + // Each value will become a row, which will contain + // some value in each column (rendering by the + // column object itself) + return values.map(function (value) { + return columns.map(function (column) { + return column.getValue( + objects[value.objectIndex], + datas[value.objectIndex], + value.pointIndex + ); + }); + }); + } + }; + } + + return ScrollingListPopulator; + + } +); \ No newline at end of file diff --git a/platform/features/scrolling/test/DomainColumnSpec.js b/platform/features/scrolling/test/DomainColumnSpec.js new file mode 100644 index 0000000000..9fe38779e1 --- /dev/null +++ b/platform/features/scrolling/test/DomainColumnSpec.js @@ -0,0 +1,46 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +/** + * MergeModelsSpec. Created by vwoeltje on 11/6/14. + */ +define( + ["../src/DomainColumn"], + function (DomainColumn) { + "use strict"; + + describe("A domain column", function () { + var mockDataSet, + testMetadata, + column; + + beforeEach(function () { + mockDataSet = jasmine.createSpyObj( + "data", + [ "getDomainValue" ] + ); + testMetadata = { + key: "testKey", + name: "Test Name" + }; + column = new DomainColumn(testMetadata); + }); + + it("reports a column header from domain metadata", function () { + expect(column.getTitle()).toEqual("Test Name"); + }); + + it("looks up data from a data set", function () { + column.getValue(undefined, mockDataSet, 42); + expect(mockDataSet.getDomainValue) + .toHaveBeenCalledWith(42, "testKey"); + }); + + it("formats domain values as time", function () { + mockDataSet.getDomainValue.andReturn(402513731000); + expect(column.getValue(undefined, mockDataSet, 42)) + .toEqual("1982-276 17:22:11"); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/scrolling/test/NameColumnSpec.js b/platform/features/scrolling/test/NameColumnSpec.js new file mode 100644 index 0000000000..696cc07707 --- /dev/null +++ b/platform/features/scrolling/test/NameColumnSpec.js @@ -0,0 +1,37 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +/** + * MergeModelsSpec. Created by vwoeltje on 11/6/14. + */ +define( + ["../src/NameColumn"], + function (NameColumn) { + "use strict"; + + describe("A name column", function () { + var mockDomainObject, + column; + + beforeEach(function () { + mockDomainObject = jasmine.createSpyObj( + "domainObject", + [ "getModel" ] + ); + mockDomainObject.getModel.andReturn({ + name: "Test object name" + }); + column = new NameColumn(); + }); + + it("reports a column header", function () { + expect(column.getTitle()).toEqual("Name"); + }); + + it("looks up name from an object's model", function () { + expect(column.getValue(mockDomainObject)) + .toEqual("Test object name"); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/scrolling/test/RangeColumnSpec.js b/platform/features/scrolling/test/RangeColumnSpec.js new file mode 100644 index 0000000000..efb262420a --- /dev/null +++ b/platform/features/scrolling/test/RangeColumnSpec.js @@ -0,0 +1,45 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +/** + * MergeModelsSpec. Created by vwoeltje on 11/6/14. + */ +define( + ["../src/RangeColumn"], + function (RangeColumn) { + "use strict"; + + describe("A range column", function () { + var mockDataSet, + testMetadata, + column; + + beforeEach(function () { + mockDataSet = jasmine.createSpyObj( + "data", + [ "getRangeValue" ] + ); + testMetadata = { + key: "testKey", + name: "Test Name" + }; + column = new RangeColumn(testMetadata); + }); + + it("reports a column header from range metadata", function () { + expect(column.getTitle()).toEqual("Test Name"); + }); + + it("looks up data from a data set", function () { + column.getValue(undefined, mockDataSet, 42); + expect(mockDataSet.getRangeValue) + .toHaveBeenCalledWith(42, "testKey"); + }); + + it("formats range values as time", function () { + mockDataSet.getRangeValue.andReturn(123.45678); + expect(column.getValue(undefined, mockDataSet, 42)) + .toEqual("123.457"); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/scrolling/test/ScrollingListControllerSpec.js b/platform/features/scrolling/test/ScrollingListControllerSpec.js new file mode 100644 index 0000000000..7aec4a7e40 --- /dev/null +++ b/platform/features/scrolling/test/ScrollingListControllerSpec.js @@ -0,0 +1,89 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +/** + * MergeModelsSpec. Created by vwoeltje on 11/6/14. + */ +define( + ["../src/ScrollingListController"], + function (ScrollingListController) { + "use strict"; + + describe("The scrolling list controller", function () { + var mockScope, + mockTelemetry, + testMetadata, + controller; + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + "$scope", + [ "$on", "$watch" ] + ); + mockTelemetry = jasmine.createSpyObj( + "telemetryController", + [ "getResponse", "getMetadata", "getTelemetryObjects" ] + ); + testMetadata = [ + { + domains: [ + { key: "d0", name: "D0" }, + { key: "d1", name: "D1" } + ], + ranges: [ + { key: "r0", name: "R0" }, + { key: "r1", name: "R1" } + ] + }, + { + domains: [ + { key: "d0", name: "D0" }, + { key: "d2", name: "D2" } + ], + ranges: [ + { key: "r0", name: "R0" } + ] + } + ]; + mockTelemetry.getMetadata.andReturn(testMetadata); + mockTelemetry.getResponse.andReturn([]); + mockTelemetry.getTelemetryObjects.andReturn([]); + mockScope.telemetry = mockTelemetry; + controller = new ScrollingListController(mockScope); + }); + + it("listens for telemetry data updates", function () { + expect(mockScope.$on).toHaveBeenCalledWith( + "telemetryUpdate", + jasmine.any(Function) + ); + }); + + it("watches for telemetry controller changes", function () { + expect(mockScope.$watch).toHaveBeenCalledWith( + "telemetry", + jasmine.any(Function) + ); + }); + + it("provides a column for each name and each unique domain, range", function () { + // Should have six columns based on metadata above, + // (name, d0, d1, d2, r0, r1) + mockScope.$watch.mostRecentCall.args[1](mockTelemetry); + expect(mockScope.headers).toEqual(["Name", "D0", "D1", "D2", "R0", "R1"]); + }); + + it("does not throw if telemetry controller is undefined", function () { + // Just a general robustness check + mockScope.telemetry = undefined; + expect(mockScope.$watch.mostRecentCall.args[1]) + .not.toThrow(); + }); + + it("provides default columns if domain/range metadata is unavailable", function () { + mockTelemetry.getMetadata.andReturn([]); + mockScope.$watch.mostRecentCall.args[1](mockTelemetry); + expect(mockScope.headers).toEqual(["Name", "Time", "Value"]); + }); + }); + } +); \ No newline at end of file diff --git a/platform/features/scrolling/test/ScrollingListPopulatorSpec.js b/platform/features/scrolling/test/ScrollingListPopulatorSpec.js new file mode 100644 index 0000000000..aeb006bd5c --- /dev/null +++ b/platform/features/scrolling/test/ScrollingListPopulatorSpec.js @@ -0,0 +1,84 @@ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +/** + * MergeModelsSpec. Created by vwoeltje on 11/6/14. + */ +define( + ["../src/ScrollingListPopulator"], + function (ScrollingListPopulator) { + "use strict"; + + describe("The scrolling list populator", function () { + var mockColumns, + mockDatas, + mockDomainObjects, + populator; + + function makeMockColumn(name, index) { + var mockColumn = jasmine.createSpyObj( + "column" + index, + [ "getTitle", "getValue" ] + ); + mockColumn.getTitle.andReturn(name); + mockColumn.getValue.andCallFake(function (obj, data, i) { + return data.getDomainValue(i); + }); + return mockColumn; + } + + function makeMockData(bias, index) { + var mockData = jasmine.createSpyObj( + "data" + index, + [ "getDomainValue", "getPointCount" ] + ); + mockData.getPointCount.andReturn(1000); + mockData.getDomainValue.andCallFake(function (i) { + return i + bias; + }); + return mockData; + } + + function makeMockDomainObject(name, index) { + var mockDomainObject = jasmine.createSpyObj( + "domainObject" + index, + [ "getId", "getModel" ] + ); + return mockDomainObject; + } + + beforeEach(function () { + mockColumns = ["A", "B", "C", "D"].map(makeMockColumn); + mockDatas = [ 10, 0, 3 ].map(makeMockData); + mockDomainObjects = ["A", "B", "C"].map(makeMockDomainObject); + populator = new ScrollingListPopulator(mockColumns); + }); + + it("returns column headers", function () { + expect(populator.getHeaders()).toEqual(["A", "B", "C", "D"]); + }); + + it("provides rows on request, with all columns in each row", function () { + var rows = populator.getRows(mockDatas, mockDomainObjects, 84); + expect(rows.length).toEqual(84); + rows.forEach(function (row) { + expect(row.length).toEqual(4); // number of columns + }); + }); + + it("returns rows in reverse domain order", function () { + var rows = populator.getRows(mockDatas, mockDomainObjects, 84), + previous = Number.POSITIVE_INFINITY; + + // Should always be most-recent-first; since the mockColumn + // returns the domain value, column contents should be + // non-increasing. + rows.forEach(function (row) { + expect(row[0]).not.toBeGreaterThan(previous); + previous = row[0]; + }); + + }); + + }); + } +); \ No newline at end of file diff --git a/platform/features/scrolling/test/suite.json b/platform/features/scrolling/test/suite.json new file mode 100644 index 0000000000..b36b9c8668 --- /dev/null +++ b/platform/features/scrolling/test/suite.json @@ -0,0 +1,7 @@ +[ + "DomainColumn", + "NameColumn", + "RangeColumn", + "ScrollingListController", + "ScrollingListPopulator" +] \ No newline at end of file