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 @@
+
+
+
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