diff --git a/example/generator/src/SinewaveTelemetryProvider.js b/example/generator/src/SinewaveTelemetryProvider.js index 014510f67c..c4062e659c 100644 --- a/example/generator/src/SinewaveTelemetryProvider.js +++ b/example/generator/src/SinewaveTelemetryProvider.js @@ -25,8 +25,8 @@ * Module defining SinewaveTelemetryProvider. Created by vwoeltje on 11/12/14. */ define( - ["./SinewaveTelemetry"], - function (SinewaveTelemetry) { + ["./SinewaveTelemetrySeries"], + function (SinewaveTelemetrySeries) { "use strict"; /** @@ -45,7 +45,7 @@ define( function generateData(request) { return { key: request.key, - telemetry: new SinewaveTelemetry(request) + telemetry: new SinewaveTelemetrySeries(request) }; } @@ -112,4 +112,4 @@ define( return SinewaveTelemetryProvider; } -); \ No newline at end of file +); diff --git a/example/generator/src/SinewaveTelemetry.js b/example/generator/src/SinewaveTelemetrySeries.js similarity index 63% rename from example/generator/src/SinewaveTelemetry.js rename to example/generator/src/SinewaveTelemetrySeries.js index 6c255bf56a..5b7914a867 100644 --- a/example/generator/src/SinewaveTelemetry.js +++ b/example/generator/src/SinewaveTelemetrySeries.js @@ -29,35 +29,45 @@ define( function () { "use strict"; - var firstObservedTime = Date.now(); + var firstObservedTime = Math.floor(Date.now() / 1000); /** * * @constructor */ - function SinewaveTelemetry(request) { - var latestObservedTime = Date.now(), - count = Math.floor((latestObservedTime - firstObservedTime) / 1000), + function SinewaveTelemetrySeries(request) { + var latestObservedTime = Math.floor(Date.now() / 1000), + endTime = (request.end !== undefined) ? + Math.floor(request.end / 1000) : latestObservedTime, + count = + Math.min(endTime, latestObservedTime) - firstObservedTime, period = request.period || 30, - generatorData = {}; + generatorData = {}, + offset = (request.start !== undefined) ? + Math.floor(request.start / 1000) - firstObservedTime : + 0; + + if (request.size !== undefined) { + offset = Math.max(offset, count - request.size); + } generatorData.getPointCount = function () { - return count; + return count - offset; }; generatorData.getDomainValue = function (i, domain) { - return i * 1000 + - (domain !== 'delta' ? firstObservedTime : 0); + return (i + offset) * 1000 + + (domain !== 'delta' ? (firstObservedTime * 1000) : 0); }; generatorData.getRangeValue = function (i, range) { range = range || "sin"; - return Math[range](i * Math.PI * 2 / period); + return Math[range]((i + offset) * Math.PI * 2 / period); }; return generatorData; } - return SinewaveTelemetry; + return SinewaveTelemetrySeries; } -); \ No newline at end of file +); diff --git a/platform/commonUI/general/bundle.json b/platform/commonUI/general/bundle.json index b55ce03b32..d6614697a0 100644 --- a/platform/commonUI/general/bundle.json +++ b/platform/commonUI/general/bundle.json @@ -45,6 +45,16 @@ } ], "controllers": [ + { + "key": "TimeRangeController", + "implementation": "controllers/TimeRangeController.js", + "depends": [ "$scope", "now" ] + }, + { + "key": "DateTimePickerController", + "implementation": "controllers/DateTimePickerController.js", + "depends": [ "$scope", "now" ] + }, { "key": "TreeNodeController", "implementation": "controllers/TreeNodeController.js", @@ -105,11 +115,21 @@ "implementation": "directives/MCTDrag.js", "depends": [ "$document" ] }, + { + "key": "mctClickElsewhere", + "implementation": "directives/MCTClickElsewhere.js", + "depends": [ "$document" ] + }, { "key": "mctResize", "implementation": "directives/MCTResize.js", "depends": [ "$timeout" ] }, + { + "key": "mctPopup", + "implementation": "directives/MCTPopup.js", + "depends": [ "$window", "$document", "$compile", "$interval" ] + }, { "key": "mctScrollX", "implementation": "directives/MCTScroll.js", @@ -213,6 +233,10 @@ { "key": "selector", "templateUrl": "templates/controls/selector.html" + }, + { + "key": "datetime-picker", + "templateUrl": "templates/controls/datetime-picker.html" } ], "licenses": [ diff --git a/platform/commonUI/general/res/templates/controls/datetime-picker.html b/platform/commonUI/general/res/templates/controls/datetime-picker.html new file mode 100644 index 0000000000..dd774588d6 --- /dev/null +++ b/platform/commonUI/general/res/templates/controls/datetime-picker.html @@ -0,0 +1,61 @@ + + +
+
+
+ < + {{month}} {{year}} + > +
+
+ + + + + + + +
+ {{day}} +
+
{{cell.day}}
+
{{cell.dayOfYear}}
+
+
+
+
+
{{nameFor(key)}}
+ +
+
diff --git a/platform/commonUI/general/res/templates/controls/time-controller.html b/platform/commonUI/general/res/templates/controls/time-controller.html index fe1b6ff14a..b223513859 100644 --- a/platform/commonUI/general/res/templates/controls/time-controller.html +++ b/platform/commonUI/general/res/templates/controls/time-controller.html @@ -1,69 +1,96 @@ -
+
+
+ Start: {{startOuterText}} + + p + +
+ + +
+
+
-
-
- Start: - End: -
+ End: {{endOuterText}} + + p + +
+ + +
+
+
-
-
-
-
-
-
-
-
05/22 14:46
-
-
-
07/22 01:21
-
-
-
-
-
-
-
- {{tick}} -
-
-
-
\ No newline at end of file +
+ +
+
+
+
+
+
+
+
+
{{startInnerText}}
+
+
+
{{endInnerText}}
+
+
+
+
+ +
+
+
+ {{tick}} +
+
+
+
diff --git a/platform/commonUI/general/src/controllers/DateTimePickerController.js b/platform/commonUI/general/src/controllers/DateTimePickerController.js new file mode 100644 index 0000000000..11214d9357 --- /dev/null +++ b/platform/commonUI/general/src/controllers/DateTimePickerController.js @@ -0,0 +1,202 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,Promise*/ + +define( + [ 'moment' ], + function (moment) { + 'use strict'; + + var TIME_NAMES = { + 'hours': "Hour", + 'minutes': "Minute", + 'seconds': "Second" + }, + MONTHS = moment.months(), + TIME_OPTIONS = (function makeRanges() { + var arr = []; + while (arr.length < 60) { + arr.push(arr.length); + } + return { + hours: arr.slice(0, 24), + minutes: arr, + seconds: arr + }; + }()); + + /** + * Controller to support the date-time picker. + * + * Adds/uses the following properties in scope: + * * `year`: Year being displayed in picker + * * `month`: Month being displayed + * * `table`: Table being displayed; array of arrays of + * * `day`: Day of month + * * `dayOfYear`: Day of year + * * `month`: Month associated with the day + * * `year`: Year associated with the day. + * * `date`: Date chosen + * * `year`: Year selected + * * `month`: Month selected (0-indexed) + * * `day`: Day of month selected + * * `time`: Chosen time (hours/minutes/seconds) + * * `hours`: Hours chosen + * * `minutes`: Minutes chosen + * * `seconds`: Seconds chosen + * + * Months are zero-indexed, day-of-months are one-indexed. + */ + function DateTimePickerController($scope, now) { + var year, + month, // For picker state, not model state + interacted = false; + + function generateTable() { + var m = moment.utc({ year: year, month: month }).day(0), + table = [], + row, + col; + + for (row = 0; row < 6; row += 1) { + table.push([]); + for (col = 0; col < 7; col += 1) { + table[row].push({ + year: m.year(), + month: m.month(), + day: m.date(), + dayOfYear: m.dayOfYear() + }); + m.add(1, 'days'); // Next day! + } + } + + return table; + } + + function updateScopeForMonth() { + $scope.month = MONTHS[month]; + $scope.year = year; + $scope.table = generateTable(); + } + + function updateFromModel(ngModel) { + var m; + + m = moment.utc(ngModel); + + $scope.date = { + year: m.year(), + month: m.month(), + day: m.date() + }; + $scope.time = { + hours: m.hour(), + minutes: m.minute(), + seconds: m.second() + }; + + //window.alert($scope.date.day + " " + ngModel); + + // Zoom to that date in the picker, but + // only if the user hasn't interacted with it yet. + if (!interacted) { + year = m.year(); + month = m.month(); + updateScopeForMonth(); + } + } + + function updateFromView() { + var m = moment.utc({ + year: $scope.date.year, + month: $scope.date.month, + day: $scope.date.day, + hour: $scope.time.hours, + minute: $scope.time.minutes, + second: $scope.time.seconds + }); + $scope.ngModel[$scope.field] = m.valueOf(); + } + + $scope.isSelectable = function (cell) { + return cell.month === month; + }; + + $scope.isSelected = function (cell) { + var date = $scope.date || {}; + return cell.day === date.day && + cell.month === date.month && + cell.year === date.year; + }; + + $scope.select = function (cell) { + $scope.date = $scope.date || {}; + $scope.date.month = cell.month; + $scope.date.year = cell.year; + $scope.date.day = cell.day; + updateFromView(); + }; + + $scope.dateEquals = function (d1, d2) { + return d1.year === d2.year && + d1.month === d2.month && + d1.day === d2.day; + }; + + $scope.changeMonth = function (delta) { + month += delta; + if (month > 11) { + month = 0; + year += 1; + } + if (month < 0) { + month = 11; + year -= 1; + } + interacted = true; + updateScopeForMonth(); + }; + + $scope.nameFor = function (key) { + return TIME_NAMES[key]; + }; + + $scope.optionsFor = function (key) { + return TIME_OPTIONS[key]; + }; + + updateScopeForMonth(); + + // Ensure some useful default + $scope.ngModel[$scope.field] = + $scope.ngModel[$scope.field] === undefined ? + now() : $scope.ngModel[$scope.field]; + + $scope.$watch('ngModel[field]', updateFromModel); + $scope.$watchCollection('date', updateFromView); + $scope.$watchCollection('time', updateFromView); + } + + return DateTimePickerController; + } +); diff --git a/platform/commonUI/general/src/controllers/TimeRangeController.js b/platform/commonUI/general/src/controllers/TimeRangeController.js new file mode 100644 index 0000000000..ac111ab1a9 --- /dev/null +++ b/platform/commonUI/general/src/controllers/TimeRangeController.js @@ -0,0 +1,224 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,Promise*/ + +define( + ['moment'], + function (moment) { + "use strict"; + + var DATE_FORMAT = "YYYY-MM-DD HH:mm:ss"; + + /** + * @memberof platform/commonUI/general + * @constructor + */ + function TimeConductorController($scope, now) { + var tickCount = 2, + initialDragValue; + + function formatTimestamp(ts) { + return moment.utc(ts).format(DATE_FORMAT); + } + + // From 0.0-1.0 to "0%"-"1%" + function toPercent(p) { + return (100 * p) + "%"; + } + + function updateTicks() { + var i, p, ts, start, end, span; + end = $scope.ngModel.outer.end; + start = $scope.ngModel.outer.start; + span = end - start; + $scope.ticks = []; + for (i = 0; i < tickCount; i += 1) { + p = i / (tickCount - 1); + ts = p * span + start; + $scope.ticks.push(formatTimestamp(ts)); + } + } + + function updateSpanWidth(w) { + // Space about 100px apart + tickCount = Math.max(Math.floor(w / 100), 2); + updateTicks(); + } + + function updateViewForInnerSpanFromModel(ngModel) { + var span = ngModel.outer.end - ngModel.outer.start; + + // Expose readable dates for the knobs + $scope.startInnerText = formatTimestamp(ngModel.inner.start); + $scope.endInnerText = formatTimestamp(ngModel.inner.end); + + // And positions for the knobs + $scope.startInnerPct = + toPercent((ngModel.inner.start - ngModel.outer.start) / span); + $scope.endInnerPct = + toPercent((ngModel.outer.end - ngModel.inner.end) / span); + } + + function defaultBounds() { + var t = now(); + return { + start: t - 24 * 3600 * 1000, // One day + end: t + }; + } + + function copyBounds(bounds) { + return { start: bounds.start, end: bounds.end }; + } + + function updateViewFromModel(ngModel) { + var t = now(); + + ngModel = ngModel || {}; + ngModel.outer = ngModel.outer || defaultBounds(); + ngModel.inner = ngModel.inner || copyBounds(ngModel.outer); + + // First, dates for the date pickers for outer bounds + $scope.startOuterDate = new Date(ngModel.outer.start); + $scope.endOuterDate = new Date(ngModel.outer.end); + + // Then various updates for the inner span + updateViewForInnerSpanFromModel(ngModel); + + // Stick it back is scope (in case we just set defaults) + $scope.ngModel = ngModel; + + updateTicks(); + } + + function startLeftDrag() { + initialDragValue = $scope.ngModel.inner.start; + } + + function startRightDrag() { + initialDragValue = $scope.ngModel.inner.end; + } + + function startMiddleDrag() { + initialDragValue = { + start: $scope.ngModel.inner.start, + end: $scope.ngModel.inner.end + }; + } + + function toMillis(pixels) { + var span = $scope.ngModel.outer.end - $scope.ngModel.outer.start; + return (pixels / $scope.spanWidth) * span; + } + + function clamp(value, low, high) { + return Math.max(low, Math.min(high, value)); + } + + function leftDrag(pixels) { + var delta = toMillis(pixels); + $scope.ngModel.inner.start = clamp( + initialDragValue + delta, + $scope.ngModel.outer.start, + $scope.ngModel.inner.end + ); + updateViewFromModel($scope.ngModel); + } + + function rightDrag(pixels) { + var delta = toMillis(pixels); + $scope.ngModel.inner.end = clamp( + initialDragValue + delta, + $scope.ngModel.inner.start, + $scope.ngModel.outer.end + ); + updateViewFromModel($scope.ngModel); + } + + function middleDrag(pixels) { + var delta = toMillis(pixels), + edge = delta < 0 ? 'start' : 'end', + opposite = delta < 0 ? 'end' : 'start'; + + // Adjust the position of the edge in the direction of drag + $scope.ngModel.inner[edge] = clamp( + initialDragValue[edge] + delta, + $scope.ngModel.outer.start, + $scope.ngModel.outer.end + ); + // Adjust opposite knob to maintain span + $scope.ngModel.inner[opposite] = $scope.ngModel.inner[edge] + + initialDragValue[opposite] - initialDragValue[edge]; + + updateViewFromModel($scope.ngModel); + } + + function updateOuterStart(t) { + var ngModel = $scope.ngModel; + ngModel.outer.end = + Math.max(ngModel.outer.start, ngModel.outer.end); + ngModel.inner.start = + Math.max(ngModel.outer.start, ngModel.inner.start); + ngModel.inner.end = + Math.max(ngModel.outer.start, ngModel.inner.end); + + $scope.startOuterText = formatTimestamp(t); + + updateViewForInnerSpanFromModel(ngModel); + } + + function updateOuterEnd(t) { + var ngModel = $scope.ngModel; + ngModel.outer.start = + Math.min(ngModel.outer.end, ngModel.outer.start); + ngModel.inner.start = + Math.min(ngModel.outer.end, ngModel.inner.start); + ngModel.inner.end = + Math.min(ngModel.outer.end, ngModel.inner.end); + + $scope.endOuterText = formatTimestamp(t); + + updateViewForInnerSpanFromModel(ngModel); + } + + $scope.startLeftDrag = startLeftDrag; + $scope.startRightDrag = startRightDrag; + $scope.startMiddleDrag = startMiddleDrag; + $scope.leftDrag = leftDrag; + $scope.rightDrag = rightDrag; + $scope.middleDrag = middleDrag; + + $scope.state = false; + $scope.ticks = []; + + // Initialize scope to defaults + updateViewFromModel($scope.ngModel); + + $scope.$watchCollection("ngModel", updateViewFromModel); + $scope.$watch("spanWidth", updateSpanWidth); + $scope.$watch("ngModel.outer.start", updateOuterStart); + $scope.$watch("ngModel.outer.end", updateOuterEnd); + } + + return TimeConductorController; + } +); diff --git a/platform/commonUI/general/src/directives/MCTClickElsewhere.js b/platform/commonUI/general/src/directives/MCTClickElsewhere.js new file mode 100644 index 0000000000..1bcdbbe6b5 --- /dev/null +++ b/platform/commonUI/general/src/directives/MCTClickElsewhere.js @@ -0,0 +1,77 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * The `mct-click-elsewhere` directive will evaluate its + * associated expression whenever a `mousedown` occurs anywhere + * outside of the element that has the `mct-click-elsewhere` + * directive attached. This is useful for dismissing popups + * and the like. + */ + function MCTClickElsewhere($document) { + + // Link; install event handlers. + function link(scope, element, attrs) { + // Keep a reference to the body, to attach/detach + // mouse event handlers; mousedown and mouseup cannot + // only be attached to the element being linked, as the + // mouse may leave this element during the drag. + var body = $document.find('body'); + + function clickBody(event) { + var x = event.clientX, + y = event.clientY, + rect = element[0].getBoundingClientRect(), + xMin = rect.left, + xMax = xMin + rect.width, + yMin = rect.top, + yMax = yMin + rect.height; + + if (x < xMin || x > xMax || y < yMin || y > yMax) { + scope.$eval(attrs.mctClickElsewhere); + } + } + + body.on("mousedown", clickBody); + scope.$on("$destroy", function () { + body.off("mousedown", clickBody); + }); + } + + return { + // mct-drag only makes sense as an attribute + restrict: "A", + // Link function, to install event handlers + link: link + }; + } + + return MCTClickElsewhere; + } +); + diff --git a/platform/commonUI/general/src/directives/MCTPopup.js b/platform/commonUI/general/src/directives/MCTPopup.js new file mode 100644 index 0000000000..33860204c6 --- /dev/null +++ b/platform/commonUI/general/src/directives/MCTPopup.js @@ -0,0 +1,70 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define*/ + +define( + function () { + 'use strict'; + + var TEMPLATE = "
"; + + function MCTPopup($window, $document, $compile) { + function link(scope, element, attrs, ctrl, transclude) { + var body = $document.find('body'), + popup = $compile(TEMPLATE)(scope), + winDim = [$window.innerWidth, $window.innerHeight], + rect = element.parent()[0].getBoundingClientRect(), + position = [ rect.left, rect.top ], + isLeft = position[0] <= (winDim[0] / 2), + isTop = position[1] <= (winDim[1] / 2); + + popup.css('position', 'absolute'); + popup.css( + isLeft ? 'left' : 'right', + (isLeft ? position[0] : (winDim[0] - position[0])) + 'px' + ); + popup.css( + isTop ? 'top' : 'bottom', + (isTop ? position[1] : (winDim[1] - position[1])) + 'px' + ); + body.append(popup); + + transclude(function (clone) { + popup.append(clone); + }); + + scope.$on('$destroy', function () { + popup.remove(); + }); + } + + return { + restrict: "E", + transclude: true, + link: link, + scope: {} + }; + } + + return MCTPopup; + } +); diff --git a/platform/commonUI/general/test/controllers/DateTimePickerControllerSpec.js b/platform/commonUI/general/test/controllers/DateTimePickerControllerSpec.js new file mode 100644 index 0000000000..957df1b36d --- /dev/null +++ b/platform/commonUI/general/test/controllers/DateTimePickerControllerSpec.js @@ -0,0 +1,63 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/controllers/DateTimePickerController"], + function (DateTimePickerController) { + "use strict"; + + describe("The DateTimePickerController", function () { + var mockScope, + mockNow, + controller; + + function fireWatch(expr, value) { + mockScope.$watch.calls.forEach(function (call) { + if (call.args[0] === expr) { + call.args[1](value); + } + }); + } + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + "$scope", + [ "$apply", "$watch", "$watchCollection" ] + ); + mockScope.ngModel = {}; + mockScope.field = "testField"; + mockNow = jasmine.createSpy('now'); + controller = new DateTimePickerController(mockScope, mockNow); + }); + + it("watches the model that was passed in", function () { + expect(mockScope.$watch).toHaveBeenCalledWith( + "ngModel[field]", + jasmine.any(Function) + ); + }); + + + }); + } +); diff --git a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js new file mode 100644 index 0000000000..48411756b0 --- /dev/null +++ b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js @@ -0,0 +1,67 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/controllers/TimeRangeController"], + function (TimeRangeController) { + "use strict"; + + describe("The TimeRangeController", function () { + var mockScope, + mockNow, + controller; + + function fireWatch(expr, value) { + mockScope.$watch.calls.forEach(function (call) { + if (call.args[0] === expr) { + call.args[1](value); + } + }); + } + + function fireWatchCollection(expr, value) { + mockScope.$watchCollection.calls.forEach(function (call) { + if (call.args[0] === expr) { + call.args[1](value); + } + }); + } + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + "$scope", + [ "$apply", "$watch", "$watchCollection" ] + ); + mockNow = jasmine.createSpy('now'); + controller = new TimeRangeController(mockScope, mockNow); + }); + + it("watches the model that was passed in", function () { + expect(mockScope.$watchCollection) + .toHaveBeenCalledWith("ngModel", jasmine.any(Function)); + }); + + + }); + } +); diff --git a/platform/commonUI/general/test/directives/MCTClickElsewhereSpec.js b/platform/commonUI/general/test/directives/MCTClickElsewhereSpec.js new file mode 100644 index 0000000000..9fa17763fe --- /dev/null +++ b/platform/commonUI/general/test/directives/MCTClickElsewhereSpec.js @@ -0,0 +1,84 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ["../../src/directives/MCTClickElsewhere"], + function (MCTClickElsewhere) { + "use strict"; + + var JQLITE_METHODS = [ "on", "off", "find", "parent" ]; + + describe("The mct-click-elsewhere directive", function () { + var mockDocument, + mockScope, + mockElement, + testAttrs, + mockBody, + mockParentEl, + testRect, + mctClickElsewhere; + + function testEvent(x, y) { + return { + pageX: x, + pageY: y, + preventDefault: jasmine.createSpy("preventDefault") + }; + } + + beforeEach(function () { + mockDocument = + jasmine.createSpyObj("$document", JQLITE_METHODS); + mockScope = + jasmine.createSpyObj("$scope", [ "$eval", "$apply", "$on" ]); + mockElement = + jasmine.createSpyObj("element", JQLITE_METHODS); + mockBody = + jasmine.createSpyObj("body", JQLITE_METHODS); + mockParentEl = + jasmine.createSpyObj("parent", ["getBoundingClientRect"]); + + testAttrs = { + mctClickElsewhere: "some Angular expression" + }; + testRect = { + left: 20, + top: 42, + width: 60, + height: 75 + }; + + mockDocument.find.andReturn(mockBody); + + mctClickElsewhere = new MCTClickElsewhere(mockDocument); + mctClickElsewhere.link(mockScope, mockElement, testAttrs); + }); + + it("is valid as an attribute", function () { + expect(mctClickElsewhere.restrict).toEqual("A"); + }); + + + }); + } +); diff --git a/platform/commonUI/general/test/directives/MCTPopupSpec.js b/platform/commonUI/general/test/directives/MCTPopupSpec.js new file mode 100644 index 0000000000..94fd3f0d0e --- /dev/null +++ b/platform/commonUI/general/test/directives/MCTPopupSpec.js @@ -0,0 +1,105 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ["../../src/directives/MCTPopup"], + function (MCTPopup) { + "use strict"; + + var JQLITE_METHODS = [ "on", "off", "find", "parent", "css", "append" ]; + + describe("The mct-popup directive", function () { + var testWindow, + mockDocument, + mockCompile, + mockScope, + mockElement, + testAttrs, + mockBody, + mockTransclude, + mockParentEl, + testRect, + mctPopup; + + function testEvent(x, y) { + return { + pageX: x, + pageY: y, + preventDefault: jasmine.createSpy("preventDefault") + }; + } + + beforeEach(function () { + testWindow = + { innerWidth: 600, innerHeight: 300 }; + mockDocument = + jasmine.createSpyObj("$document", JQLITE_METHODS); + mockCompile = + jasmine.createSpy("$compile"); + mockScope = + jasmine.createSpyObj("$scope", [ "$eval", "$apply", "$on" ]); + mockElement = + jasmine.createSpyObj("element", JQLITE_METHODS); + mockBody = + jasmine.createSpyObj("body", JQLITE_METHODS); + mockTransclude = + jasmine.createSpy("transclude"); + mockParentEl = + jasmine.createSpyObj("parent", ["getBoundingClientRect"]); + + testAttrs = { + mctClickElsewhere: "some Angular expression" + }; + testRect = { + left: 20, + top: 42, + width: 60, + height: 75 + }; + + mockDocument.find.andReturn(mockBody); + mockCompile.andReturn(jasmine.createSpy()); + mockCompile().andCallFake(function () { + return jasmine.createSpyObj("newElement", JQLITE_METHODS); + }); + mockElement.parent.andReturn([mockParentEl]); + mockParentEl.getBoundingClientRect.andReturn(testRect); + + mctPopup = new MCTPopup(testWindow, mockDocument, mockCompile); + mctPopup.link( + mockScope, + mockElement, + testAttrs, + null, + mockTransclude + ); + }); + + it("is valid as an element", function () { + expect(mctPopup.restrict).toEqual("E"); + }); + + + }); + } +); diff --git a/platform/commonUI/general/test/suite.json b/platform/commonUI/general/test/suite.json index 45c554d2c2..1427d70f3a 100644 --- a/platform/commonUI/general/test/suite.json +++ b/platform/commonUI/general/test/suite.json @@ -3,14 +3,18 @@ "controllers/BottomBarController", "controllers/ClickAwayController", "controllers/ContextMenuController", + "controllers/DateTimePickerController", "controllers/GetterSetterController", "controllers/SelectorController", "controllers/SplitPaneController", + "controllers/TimeRangeController", "controllers/ToggleController", "controllers/TreeNodeController", "controllers/ViewSwitcherController", + "directives/MCTClickElsewhere", "directives/MCTContainer", "directives/MCTDrag", + "directives/MCTPopup", "directives/MCTResize", "directives/MCTScroll", "services/UrlService", diff --git a/platform/core/src/services/Throttle.js b/platform/core/src/services/Throttle.js index 3d68988d6b..60444ad6c4 100644 --- a/platform/core/src/services/Throttle.js +++ b/platform/core/src/services/Throttle.js @@ -36,11 +36,16 @@ define( * * Returns a function that, when invoked, will invoke `fn` after * `delay` milliseconds, only if no other invocations are pending. - * The optional argument `apply` determines whether. + * The optional argument `apply` determines whether or not a + * digest cycle should be triggered. * * The returned function will itself return a `Promise` which will * resolve to the returned value of `fn` whenever that is invoked. * + * In cases where arguments are provided, only the most recent + * set of arguments will be passed on to the throttled function + * at the time it is executed. + * * @returns {Function} * @memberof platform/core */ @@ -56,12 +61,14 @@ define( * @memberof platform/core.Throttle# */ return function (fn, delay, apply) { - var activeTimeout; + var promise, // Promise for the result of throttled function + args = []; - // Clear active timeout, so that next invocation starts - // a new one. - function clearActiveTimeout() { - activeTimeout = undefined; + function invoke() { + // Clear the active timeout so a new one starts next time. + promise = undefined; + // Invoke the function with the latest supplied arguments. + return fn.apply(null, args); } // Defaults @@ -69,14 +76,13 @@ define( apply = apply || false; return function () { + // Store arguments from this invocation + args = Array.prototype.slice.apply(arguments, [0]); // Start a timeout if needed - if (!activeTimeout) { - activeTimeout = $timeout(fn, delay, apply); - activeTimeout.then(clearActiveTimeout); - } + promise = promise || $timeout(invoke, delay, apply); // Return whichever timeout is active (to get // a promise for the results of fn) - return activeTimeout; + return promise; }; }; } diff --git a/platform/core/test/services/ThrottleSpec.js b/platform/core/test/services/ThrottleSpec.js index bcaf2af363..3b361f70bb 100644 --- a/platform/core/test/services/ThrottleSpec.js +++ b/platform/core/test/services/ThrottleSpec.js @@ -45,7 +45,9 @@ define( // Verify precondition: Not called at throttle-time expect(mockTimeout).not.toHaveBeenCalled(); expect(throttled()).toEqual(mockPromise); - expect(mockTimeout).toHaveBeenCalledWith(mockFn, 0, false); + expect(mockFn).not.toHaveBeenCalled(); + expect(mockTimeout) + .toHaveBeenCalledWith(jasmine.any(Function), 0, false); }); it("schedules only one timeout at a time", function () { @@ -59,10 +61,11 @@ define( it("schedules additional invocations after resolution", function () { var throttled = throttle(mockFn); throttled(); - mockPromise.then.mostRecentCall.args[0](); // Resolve timeout + mockTimeout.mostRecentCall.args[0](); // Resolve timeout throttled(); - mockPromise.then.mostRecentCall.args[0](); + mockTimeout.mostRecentCall.args[0](); throttled(); + mockTimeout.mostRecentCall.args[0](); expect(mockTimeout.calls.length).toEqual(3); }); }); diff --git a/platform/features/conductor/README.md b/platform/features/conductor/README.md new file mode 100644 index 0000000000..196093ae1f --- /dev/null +++ b/platform/features/conductor/README.md @@ -0,0 +1,9 @@ +Provides the time conductor, a control which appears at the +bottom of the screen allowing telemetry start and end times +to be modified. + +Note that the term "time controller" is generally preferred +outside of the code base (e.g. in UI documents, issues, etc.); +the term "time conductor" is being used in code to avoid +confusion with "controllers" in the Model-View-Controller +sense. diff --git a/platform/features/conductor/bundle.json b/platform/features/conductor/bundle.json new file mode 100644 index 0000000000..b230f9d370 --- /dev/null +++ b/platform/features/conductor/bundle.json @@ -0,0 +1,25 @@ +{ + "extensions": { + "representers": [ + { + "implementation": "ConductorRepresenter.js", + "depends": [ "conductorService", "$compile", "views[]" ] + } + ], + "components": [ + { + "type": "decorator", + "provides": "telemetryService", + "implementation": "ConductorTelemetryDecorator.js", + "depends": [ "conductorService" ] + } + ], + "services": [ + { + "key": "conductorService", + "implementation": "ConductorService.js", + "depends": [ "now" ] + } + ] + } +} diff --git a/platform/features/conductor/src/ConductorRepresenter.js b/platform/features/conductor/src/ConductorRepresenter.js new file mode 100644 index 0000000000..b1b35477b6 --- /dev/null +++ b/platform/features/conductor/src/ConductorRepresenter.js @@ -0,0 +1,167 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define*/ + +define( + [], + function () { + "use strict"; + + var CONDUCTOR_HEIGHT = "100px", + TEMPLATE = [ + '
', + "", + "", + '
' + ].join(''), + GLOBAL_SHOWING = false; + + /** + * The ConductorRepresenter attaches the universal time conductor + * to views. + * + * @implements {Representer} + * @constructor + * @memberof platform/features/conductor + * @param {platform/features/conductor.ConductorService} conductorService + * service which provides the active time conductor + * @param $compile Angular's $compile + * @param {ViewDefinition[]} views all defined views + * @param {Scope} the scope of the representation + * @param element the jqLite-wrapped representation element + */ + function ConductorRepresenter(conductorService, $compile, views, scope, element) { + this.scope = scope; + this.conductorService = conductorService; + this.element = element; + this.views = views; + this.$compile = $compile; + } + + // Combine start/end times into a single object + function bounds(start, end) { + return { start: start, end: end }; + } + + // Update the time conductor from the scope + function wireScope(conductor, conductorScope, repScope) { + function updateConductorOuter() { + conductor.queryStart(conductorScope.conductor.outer.start); + conductor.queryEnd(conductorScope.conductor.outer.end); + repScope.$broadcast( + 'telemetry:query:bounds', + bounds(conductor.queryStart(), conductor.queryEnd()) + ); + } + + function updateConductorInner() { + conductor.displayStart(conductorScope.conductor.inner.start); + conductor.displayEnd(conductorScope.conductor.inner.end); + repScope.$broadcast( + 'telemetry:display:bounds', + bounds(conductor.displayStart(), conductor.displayEnd()) + ); + } + + conductorScope.conductor = { + outer: bounds(conductor.queryStart(), conductor.queryEnd()), + inner: bounds(conductor.displayStart(), conductor.displayEnd()) + }; + + conductorScope + .$watch('conductor.outer.start', updateConductorOuter); + conductorScope + .$watch('conductor.outer.end', updateConductorOuter); + conductorScope + .$watch('conductor.inner.start', updateConductorInner); + conductorScope + .$watch('conductor.inner.end', updateConductorInner); + + repScope.$on('telemetry:view', updateConductorInner); + } + + ConductorRepresenter.prototype.conductorScope = function (s) { + return (this.cScope = arguments.length > 0 ? + s : this.cScope); + }; + + // Handle a specific representation of a specific domain object + ConductorRepresenter.prototype.represent = function represent(representation, representedObject) { + this.destroy(); + + if (this.views.indexOf(representation) !== -1 && !GLOBAL_SHOWING) { + // Track original states + this.originalHeight = this.element.css('height'); + this.hadAbs = this.element.hasClass('abs'); + + // Create a new scope for the conductor + this.conductorScope(this.scope.$new()); + wireScope( + this.conductorService.getConductor(), + this.conductorScope(), + this.scope + ); + this.conductorElement = + this.$compile(TEMPLATE)(this.conductorScope()); + this.element.after(this.conductorElement[0]); + this.element.addClass('abs'); + this.element.css('bottom', CONDUCTOR_HEIGHT); + GLOBAL_SHOWING = true; + } + }; + + // Respond to the destruction of the current representation. + ConductorRepresenter.prototype.destroy = function destroy() { + // We may not have decided to show in the first place, + // so circumvent any unnecessary cleanup + if (!this.conductorElement) { + return; + } + + // Restore the original size of the mct-representation + if (!this.hadAbs) { + this.element.removeClass('abs'); + } + this.element.css('height', this.originalHeight); + + // ...and remove the conductor + if (this.conductorElement) { + this.conductorElement.remove(); + this.conductorElement = undefined; + } + + // Finally, destroy its scope + if (this.conductorScope()) { + this.conductorScope().$destroy(); + this.conductorScope(undefined); + } + + GLOBAL_SHOWING = false; + }; + + return ConductorRepresenter; + } +); + diff --git a/platform/features/conductor/src/ConductorService.js b/platform/features/conductor/src/ConductorService.js new file mode 100644 index 0000000000..59cfa95e3c --- /dev/null +++ b/platform/features/conductor/src/ConductorService.js @@ -0,0 +1,61 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define*/ + +define( + ['./TimeConductor'], + function (TimeConductor) { + 'use strict'; + + var ONE_DAY_IN_MS = 1000 * 60 * 60 * 24, + SIX_HOURS_IN_MS = ONE_DAY_IN_MS / 4; + + /** + * Provides a single global instance of the time conductor, which + * controls both query ranges and displayed ranges for telemetry + * data. + * + * @constructor + * @memberof platform/features/conductor + * @param {Function} now a function which returns the current time + * as a UNIX timestamp, in milliseconds + */ + function ConductorService(now) { + var initialEnd = + Math.ceil(now() / SIX_HOURS_IN_MS) * SIX_HOURS_IN_MS; + + this.conductor = + new TimeConductor(initialEnd - ONE_DAY_IN_MS, initialEnd); + } + + /** + * Get the global instance of the time conductor. + * @returns {platform/features/conductor.TimeConductor} the + * time conductor + */ + ConductorService.prototype.getConductor = function () { + return this.conductor; + }; + + return ConductorService; + } +); diff --git a/platform/features/conductor/src/ConductorTelemetryDecorator.js b/platform/features/conductor/src/ConductorTelemetryDecorator.js new file mode 100644 index 0000000000..9c8126c33f --- /dev/null +++ b/platform/features/conductor/src/ConductorTelemetryDecorator.js @@ -0,0 +1,108 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define*/ + +define( + ['./ConductorTelemetrySeries'], + function (ConductorTelemetrySeries) { + 'use strict'; + + /** + * Decorates the `telemetryService` such that requests are + * mediated by the time conductor. + * + * @constructor + * @memberof platform/features/conductor + * @implements {TelemetryService} + * @param {platform/features/conductor.ConductorService} conductorServe + * the service which exposes the global time conductor + * @param {TelemetryService} telemetryService the decorated service + */ + function ConductorTelemetryDecorator(conductorService, telemetryService) { + this.conductorService = conductorService; + this.telemetryService = telemetryService; + } + + // Strip out any realtime data series that is outside of the conductor's + // bounds. + ConductorTelemetryDecorator.prototype.pruneNonDisplayable = function (packaged) { + var conductor = this.conductorService.getConductor(), + repackaged = {}; + + function filterSource(packagedBySource) { + var repackagedBySource = {}; + + Object.keys(packagedBySource).forEach(function (k) { + repackagedBySource[k] = new ConductorTelemetrySeries( + packagedBySource[k], + conductor + ); + }); + + return repackagedBySource; + } + + Object.keys(packaged).forEach(function (source) { + repackaged[source] = filterSource(packaged[source]); + }); + + return repackaged; + }; + + ConductorTelemetryDecorator.prototype.amendRequests = function (requests) { + var conductor = this.conductorService.getConductor(), + start = conductor.displayStart(), + end = conductor.displayEnd(); + + function amendRequest(request) { + request = request || {}; + request.start = start; + request.end = end; + return request; + } + + return (requests || []).map(amendRequest); + }; + + ConductorTelemetryDecorator.prototype.requestTelemetry = function (requests) { + var self = this; + return this.telemetryService + .requestTelemetry(this.amendRequests(requests)) + .then(function (packaged) { + return self.pruneNonDisplayable(packaged); + }); + }; + + ConductorTelemetryDecorator.prototype.subscribe = function (callback, requests) { + var self = this; + + function internalCallback(packagedSeries) { + return callback(self.pruneNonDisplayable(packagedSeries)); + } + + return this.telemetryService + .subscribe(internalCallback, this.amendRequests(requests)); + }; + + return ConductorTelemetryDecorator; + } +); diff --git a/platform/features/conductor/src/ConductorTelemetrySeries.js b/platform/features/conductor/src/ConductorTelemetrySeries.js new file mode 100644 index 0000000000..aa6ec0ec63 --- /dev/null +++ b/platform/features/conductor/src/ConductorTelemetrySeries.js @@ -0,0 +1,71 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/*global define*/ + +define( + function () { + 'use strict'; + + + /** + * Bound a series of telemetry such that it only includes + * points from within the time conductor's displayable window. + * + * @param {TelemetrySeries} series the telemetry series + * @param {platform/features/conductor.TimeConductor} the + * time conductor instance which bounds this series + * @constructor + * @implements {TelemetrySeries} + */ + function ConductorTelemetrySeries(series, conductor) { + var max = series.getPointCount() - 1; + + function binSearch(min, max, value) { + var mid = Math.floor((min + max) / 2); + + return min > max ? min : + series.getDomainValue(mid) < value ? + binSearch(mid + 1, max, value) : + binSearch(min, mid - 1, value); + } + + this.startIndex = binSearch(0, max, conductor.displayStart()); + this.endIndex = binSearch(0, max, conductor.displayEnd()); + this.series = series; + } + + ConductorTelemetrySeries.prototype.getPointCount = function () { + return Math.max(0, this.endIndex - this.startIndex); + }; + + ConductorTelemetrySeries.prototype.getDomainValue = function (i, d) { + return this.series.getDomainValue(i + this.startIndex, d); + }; + + ConductorTelemetrySeries.prototype.getRangeValue = function (i, r) { + return this.series.getRangeValue(i + this.startIndex, r); + }; + + return ConductorTelemetrySeries; + } +); diff --git a/platform/features/conductor/src/TimeConductor.js b/platform/features/conductor/src/TimeConductor.js new file mode 100644 index 0000000000..fcf8dbae04 --- /dev/null +++ b/platform/features/conductor/src/TimeConductor.js @@ -0,0 +1,99 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define*/ + +/** + * The time conductor bundle adds a global control to the bottom of the + * outermost viewing area. This controls both the range for time-based + * queries and for time-based displays. + * + * @namespace platform/features/conductor + */ +define( + function () { + 'use strict'; + + /** + * Tracks the current state of the time conductor. + * + * @memberof platform/features/conductor + * @constructor + * @param {number} start the initial start time + * @param {number} end the initial end time + */ + function TimeConductor(start, end) { + this.inner = { start: start, end: end }; + this.outer = { start: start, end: end }; + } + + /** + * Get or set (if called with an argument) the start time for queries. + * @param {number} [value] the start time to set + * @returns {number} the start time + */ + TimeConductor.prototype.queryStart = function (value) { + if (arguments.length > 0) { + this.outer.start = value; + } + return this.outer.start; + }; + + /** + * Get or set (if called with an argument) the end time for queries. + * @param {number} [value] the end time to set + * @returns {number} the end time + */ + TimeConductor.prototype.queryEnd = function (value) { + if (arguments.length > 0) { + this.outer.end = value; + } + return this.outer.end; + }; + + + /** + * Get or set (if called with an argument) the start time for displays. + * @param {number} [value] the start time to set + * @returns {number} the start time + */ + TimeConductor.prototype.displayStart = function (value) { + if (arguments.length > 0) { + this.inner.start = value; + } + return this.inner.start; + }; + + /** + * Get or set (if called with an argument) the end time for displays. + * @param {number} [value] the end time to set + * @returns {number} the end time + */ + TimeConductor.prototype.displayEnd = function (value) { + if (arguments.length > 0) { + this.inner.end = value; + } + return this.inner.end; + }; + + return TimeConductor; + } +); diff --git a/platform/features/conductor/test/ConductorRepresenterSpec.js b/platform/features/conductor/test/ConductorRepresenterSpec.js new file mode 100644 index 0000000000..2b1003f3c7 --- /dev/null +++ b/platform/features/conductor/test/ConductorRepresenterSpec.js @@ -0,0 +1,167 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,describe,it,expect,beforeEach,waitsFor,afterEach,jasmine*/ + +/** + * EventSpec. Created by vwoeltje on 11/6/14. Modified by shale on 06/23/2015. + */ +define( + ["../src/ConductorRepresenter"], + function (ConductorRepresenter) { + "use strict"; + + var SCOPE_METHODS = [ + '$on', + '$watch', + '$broadcast', + '$emit', + '$new', + '$destroy' + ], + ELEMENT_METHODS = [ + 'hasClass', + 'addClass', + 'removeClass', + 'css', + 'after', + 'remove' + ]; + + describe("ConductorRepresenter", function () { + var mockConductorService, + mockCompile, + testViews, + mockScope, + mockElement, + mockConductor, + mockCompiledTemplate, + mockNewScope, + mockNewElement, + representer; + + function fireWatch(scope, watch, value) { + scope.$watch.calls.forEach(function (call) { + if (call.args[0] === watch) { + call.args[1](value); + } + }); + } + + beforeEach(function () { + mockConductorService = jasmine.createSpyObj( + 'conductorService', + ['getConductor'] + ); + mockCompile = jasmine.createSpy('$compile'); + testViews = [ { someKey: "some value" } ]; + mockScope = jasmine.createSpyObj('scope', SCOPE_METHODS); + mockElement = jasmine.createSpyObj('element', ELEMENT_METHODS); + mockConductor = jasmine.createSpyObj( + 'conductor', + [ 'queryStart', 'queryEnd', 'displayStart', 'displayEnd' ] + ); + mockCompiledTemplate = jasmine.createSpy('template'); + mockNewScope = jasmine.createSpyObj('newScope', SCOPE_METHODS); + mockNewElement = jasmine.createSpyObj('newElement', ELEMENT_METHODS); + mockNewElement[0] = mockNewElement; + + mockConductorService.getConductor.andReturn(mockConductor); + mockCompile.andReturn(mockCompiledTemplate); + mockCompiledTemplate.andReturn(mockNewElement); + mockScope.$new.andReturn(mockNewScope); + + representer = new ConductorRepresenter( + mockConductorService, + mockCompile, + testViews, + mockScope, + mockElement + ); + }); + + afterEach(function () { + representer.destroy(); + }); + + it("adds a conductor to views", function () { + representer.represent(testViews[0], {}); + expect(mockElement.after).toHaveBeenCalledWith(mockNewElement); + }); + + it("adds nothing to non-view representations", function () { + representer.represent({ someKey: "something else" }, {}); + expect(mockElement.after).not.toHaveBeenCalled(); + }); + + it("removes the conductor when destroyed", function () { + representer.represent(testViews[0], {}); + expect(mockNewElement.remove).not.toHaveBeenCalled(); + representer.destroy(); + expect(mockNewElement.remove).toHaveBeenCalled(); + }); + + it("destroys any new scope created", function () { + representer.represent(testViews[0], {}); + representer.destroy(); + expect(mockNewScope.$destroy.calls.length) + .toEqual(mockScope.$new.calls.length); + }); + + it("exposes conductor state in scope", function () { + mockConductor.queryStart.andReturn(42); + mockConductor.queryEnd.andReturn(12321); + mockConductor.displayStart.andReturn(1977); + mockConductor.displayEnd.andReturn(1984); + representer.represent(testViews[0], {}); + + expect(mockNewScope.conductor).toEqual({ + inner: { start: 1977, end: 1984 }, + outer: { start: 42, end: 12321 } + }); + }); + + it("updates conductor state from scope", function () { + var testState = { + inner: { start: 42, end: 1984 }, + outer: { start: -1977, end: 12321 } + }; + + representer.represent(testViews[0], {}); + + mockNewScope.conductor = testState; + + fireWatch(mockNewScope, 'conductor.inner.start', testState.inner.start); + expect(mockConductor.displayStart).toHaveBeenCalledWith(42); + + fireWatch(mockNewScope, 'conductor.inner.end', testState.inner.end); + expect(mockConductor.displayEnd).toHaveBeenCalledWith(1984); + + fireWatch(mockNewScope, 'conductor.outer.start', testState.outer.start); + expect(mockConductor.queryStart).toHaveBeenCalledWith(-1977); + + fireWatch(mockNewScope, 'conductor.outer.end', testState.outer.end); + expect(mockConductor.queryEnd).toHaveBeenCalledWith(12321); + }); + + }); + } +); diff --git a/platform/features/conductor/test/ConductorServiceSpec.js b/platform/features/conductor/test/ConductorServiceSpec.js new file mode 100644 index 0000000000..5146ca5f42 --- /dev/null +++ b/platform/features/conductor/test/ConductorServiceSpec.js @@ -0,0 +1,58 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +/** + * EventSpec. Created by vwoeltje on 11/6/14. Modified by shale on 06/23/2015. + */ +define( + ["../src/ConductorService"], + function (ConductorService) { + "use strict"; + + var TEST_NOW = 1020304050; + + describe("ConductorService", function () { + var mockNow, + conductorService; + + beforeEach(function () { + mockNow = jasmine.createSpy('now'); + mockNow.andReturn(TEST_NOW); + conductorService = new ConductorService(mockNow); + }); + + it("initializes a time conductor around the current time", function () { + var conductor = conductorService.getConductor(); + expect(conductor.queryStart() <= TEST_NOW).toBeTruthy(); + expect(conductor.queryEnd() >= TEST_NOW).toBeTruthy(); + expect(conductor.queryEnd() > conductor.queryStart()) + .toBeTruthy(); + }); + + it("provides a single shared time conductor instance", function () { + expect(conductorService.getConductor()) + .toBe(conductorService.getConductor()); + }); + }); + } +); diff --git a/platform/features/conductor/test/ConductorTelemetryDecoratorSpec.js b/platform/features/conductor/test/ConductorTelemetryDecoratorSpec.js new file mode 100644 index 0000000000..84812b541a --- /dev/null +++ b/platform/features/conductor/test/ConductorTelemetryDecoratorSpec.js @@ -0,0 +1,137 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + + +define( + ["../src/ConductorTelemetryDecorator"], + function (ConductorTelemetryDecorator) { + "use strict"; + + describe("ConductorTelemetryDecorator", function () { + var mockTelemetryService, + mockConductorService, + mockConductor, + mockPromise, + mockSeries, + decorator; + + function seriesIsInWindow(series) { + var i, v, inWindow = true; + for (i = 0; i < series.getPointCount(); i += 1) { + v = series.getDomainValue(i); + inWindow = inWindow && (v >= mockConductor.displayStart()); + inWindow = inWindow && (v <= mockConductor.displayEnd()); + } + return inWindow; + } + + beforeEach(function () { + mockTelemetryService = jasmine.createSpyObj( + 'telemetryService', + [ 'requestTelemetry', 'subscribe' ] + ); + mockConductorService = jasmine.createSpyObj( + 'conductorService', + ['getConductor'] + ); + mockConductor = jasmine.createSpyObj( + 'conductor', + [ 'queryStart', 'queryEnd', 'displayStart', 'displayEnd' ] + ); + mockPromise = jasmine.createSpyObj( + 'promise', + ['then'] + ); + mockSeries = jasmine.createSpyObj( + 'series', + [ 'getPointCount', 'getDomainValue', 'getRangeValue' ] + ); + + mockTelemetryService.requestTelemetry.andReturn(mockPromise); + mockConductorService.getConductor.andReturn(mockConductor); + + // Prepare test series; make sure it has a broad range of + // domain values, with at least some in the query-able range + mockSeries.getPointCount.andReturn(1000); + mockSeries.getDomainValue.andCallFake(function (i) { + var j = i - 500; + return j * j * j; + }); + + mockConductor.queryStart.andReturn(-12321); + mockConductor.queryEnd.andReturn(-12321); + mockConductor.displayStart.andReturn(42); + mockConductor.displayEnd.andReturn(1977); + + decorator = new ConductorTelemetryDecorator( + mockConductorService, + mockTelemetryService + ); + }); + + it("adds display start/end times to historical requests", function () { + decorator.requestTelemetry([{ someKey: "some value" }]); + expect(mockTelemetryService.requestTelemetry) + .toHaveBeenCalledWith([{ + someKey: "some value", + start: mockConductor.displayStart(), + end: mockConductor.displayEnd() + }]); + }); + + it("adds display start/end times to subscription requests", function () { + var mockCallback = jasmine.createSpy('callback'); + decorator.subscribe(mockCallback, [{ someKey: "some value" }]); + expect(mockTelemetryService.subscribe) + .toHaveBeenCalledWith(jasmine.any(Function), [{ + someKey: "some value", + start: mockConductor.displayStart(), + end: mockConductor.displayEnd() + }]); + }); + + it("prunes historical values to the displayable range", function () { + var packagedTelemetry; + decorator.requestTelemetry([{ source: "abc", key: "xyz" }]); + packagedTelemetry = mockPromise.then.mostRecentCall.args[0]({ + "abc": { "xyz": mockSeries } + }); + expect(seriesIsInWindow(packagedTelemetry.abc.xyz)) + .toBeTruthy(); + }); + + it("prunes subscribed values to the displayable range", function () { + var mockCallback = jasmine.createSpy('callback'), + packagedTelemetry; + decorator.subscribe(mockCallback, [{ source: "abc", key: "xyz" }]); + mockTelemetryService.subscribe.mostRecentCall.args[0]({ + "abc": { "xyz": mockSeries } + }); + packagedTelemetry = mockCallback.mostRecentCall.args[0]; + expect(seriesIsInWindow(packagedTelemetry.abc.xyz)) + .toBeTruthy(); + }); + + }); + } +); diff --git a/platform/features/conductor/test/ConductorTelemetrySeriesSpec.js b/platform/features/conductor/test/ConductorTelemetrySeriesSpec.js new file mode 100644 index 0000000000..ea884f74f3 --- /dev/null +++ b/platform/features/conductor/test/ConductorTelemetrySeriesSpec.js @@ -0,0 +1,86 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/ConductorTelemetrySeries"], + function (ConductorTelemetrySeries) { + "use strict"; + + describe("ConductorTelemetrySeries", function () { + var mockSeries, + mockConductor, + testArray, + series; + + beforeEach(function () { + testArray = [ -10, 0, 42, 1977, 12321 ]; + + mockSeries = jasmine.createSpyObj( + 'series', + [ 'getPointCount', 'getDomainValue', 'getRangeValue' ] + ); + mockConductor = jasmine.createSpyObj( + 'conductor', + [ 'queryStart', 'queryEnd', 'displayStart', 'displayEnd' ] + ); + + mockSeries.getPointCount.andCallFake(function () { + return testArray.length; + }); + mockSeries.getDomainValue.andCallFake(function (i) { + return testArray[i]; + }); + mockSeries.getRangeValue.andCallFake(function (i) { + return testArray[i] * 2; + }); + + mockConductor.displayStart.andReturn(0); + mockConductor.displayEnd.andReturn(2000); + + series = new ConductorTelemetrySeries( + mockSeries, + mockConductor + ); + }); + + it("reduces the apparent size of a series", function () { + expect(series.getPointCount()).toEqual(3); + }); + + it("maps domain value indexes to the displayable range", function () { + [0, 1, 2].forEach(function (i) { + expect(series.getDomainValue(i)) + .toEqual(mockSeries.getDomainValue(i + 1)); + }); + }); + + it("maps range value indexes to the displayable range", function () { + [0, 1, 2].forEach(function (i) { + expect(series.getRangeValue(i)) + .toEqual(mockSeries.getRangeValue(i + 1)); + }); + }); + + }); + } +); diff --git a/platform/features/conductor/test/TimeConductorSpec.js b/platform/features/conductor/test/TimeConductorSpec.js new file mode 100644 index 0000000000..558322329e --- /dev/null +++ b/platform/features/conductor/test/TimeConductorSpec.js @@ -0,0 +1,63 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +/** + * EventSpec. Created by vwoeltje on 11/6/14. Modified by shale on 06/23/2015. + */ +define( + ["../src/TimeConductor"], + function (TimeConductor) { + "use strict"; + + describe("TimeConductor", function () { + var testStart, + testEnd, + conductor; + + beforeEach(function () { + testStart = 42; + testEnd = 12321; + conductor = new TimeConductor(testStart, testEnd); + }); + + it("provides accessors for query/display start/end times", function () { + expect(conductor.queryStart()).toEqual(testStart); + expect(conductor.queryEnd()).toEqual(testEnd); + expect(conductor.displayStart()).toEqual(testStart); + expect(conductor.displayEnd()).toEqual(testEnd); + }); + + it("provides setters for query/display start/end times", function () { + expect(conductor.queryStart(1)).toEqual(1); + expect(conductor.queryEnd(2)).toEqual(2); + expect(conductor.displayStart(3)).toEqual(3); + expect(conductor.displayEnd(4)).toEqual(4); + expect(conductor.queryStart()).toEqual(1); + expect(conductor.queryEnd()).toEqual(2); + expect(conductor.displayStart()).toEqual(3); + expect(conductor.displayEnd()).toEqual(4); + }); + + }); + } +); diff --git a/platform/features/conductor/test/suite.json b/platform/features/conductor/test/suite.json new file mode 100644 index 0000000000..0c469617de --- /dev/null +++ b/platform/features/conductor/test/suite.json @@ -0,0 +1,7 @@ +[ + "ConductorRepresenter", + "ConductorService", + "ConductorTelemetryDecorator", + "ConductorTelemetrySeries", + "TimeConductor" +] diff --git a/platform/features/layout/bundle.json b/platform/features/layout/bundle.json index c32ac5eeed..df5bd9413c 100644 --- a/platform/features/layout/bundle.json +++ b/platform/features/layout/bundle.json @@ -167,8 +167,9 @@ "$scope", "$q", "dialogService", - "telemetrySubscriber", - "telemetryFormatter" + "telemetryHandler", + "telemetryFormatter", + "throttle" ] } ], diff --git a/platform/features/layout/src/FixedController.js b/platform/features/layout/src/FixedController.js index 2bece35391..f608f490f8 100644 --- a/platform/features/layout/src/FixedController.js +++ b/platform/features/layout/src/FixedController.js @@ -38,12 +38,13 @@ define( * @constructor * @param {Scope} $scope the controller's Angular scope */ - function FixedController($scope, $q, dialogService, telemetrySubscriber, telemetryFormatter) { + function FixedController($scope, $q, dialogService, telemetryHandler, telemetryFormatter, throttle) { var self = this, - subscription, + handle, names = {}, // Cache names by ID values = {}, // Cache values by ID - elementProxiesById = {}; + elementProxiesById = {}, + maxDomainValue = Number.POSITIVE_INFINITY; // Convert from element x/y/width/height to an // appropriate ng-style argument, to position elements. @@ -81,25 +82,50 @@ define( return element.handles().map(generateDragHandle); } - // Update the displayed value for this object - function updateValue(telemetryObject) { - var id = telemetryObject && telemetryObject.getId(), + // Update the value displayed in elements of this telemetry object + function setDisplayedValue(telemetryObject, value, alarm) { + var id = telemetryObject.getId(); + (elementProxiesById[id] || []).forEach(function (element) { + names[id] = telemetryObject.getModel().name; + values[id] = telemetryFormatter.formatRangeValue(value); + element.name = names[id]; + element.value = values[id]; + element.cssClass = alarm && alarm.cssClass; + }); + } + + // Update the displayed value for this object, from a specific + // telemetry series + function updateValueFromSeries(telemetryObject, telemetrySeries) { + var index = telemetrySeries.getPointCount() - 1, limit = telemetryObject && telemetryObject.getCapability('limit'), - datum = telemetryObject && - subscription.getDatum(telemetryObject), - alarm = limit && datum && limit.evaluate(datum); + datum = telemetryObject && handle.getDatum( + telemetryObject, + index + ); - if (id) { - (elementProxiesById[id] || []).forEach(function (element) { - names[id] = telemetryObject.getModel().name; - values[id] = telemetryFormatter.formatRangeValue( - subscription.getRangeValue(telemetryObject) - ); - element.name = names[id]; - element.value = values[id]; - element.cssClass = alarm && alarm.cssClass; - }); + setDisplayedValue( + telemetryObject, + telemetrySeries.getRangeValue(index), + limit && datum && limit.evaluate(datum) + ); + } + + // Update the displayed value for this object + function updateValue(telemetryObject) { + var limit = telemetryObject && + telemetryObject.getCapability('limit'), + datum = telemetryObject && + handle.getDatum(telemetryObject); + + if (telemetryObject && + (handle.getDomainValue(telemetryObject) < maxDomainValue)) { + setDisplayedValue( + telemetryObject, + handle.getRangeValue(telemetryObject), + limit && datum && limit.evaluate(datum) + ); } } @@ -115,8 +141,8 @@ define( // Update telemetry values based on new data available function updateValues() { - if (subscription) { - subscription.getTelemetryObjects().forEach(updateValue); + if (handle) { + handle.getTelemetryObjects().forEach(updateValue); } } @@ -178,22 +204,29 @@ define( // Free up subscription to telemetry function releaseSubscription() { - if (subscription) { - subscription.unsubscribe(); - subscription = undefined; + if (handle) { + handle.unsubscribe(); + handle = undefined; } } // Subscribe to telemetry updates for this domain object function subscribe(domainObject) { // Release existing subscription (if any) - if (subscription) { - subscription.unsubscribe(); + if (handle) { + handle.unsubscribe(); } // Make a new subscription - subscription = domainObject && - telemetrySubscriber.subscribe(domainObject, updateValues); + handle = domainObject && telemetryHandler.handle( + domainObject, + updateValues + ); + // Request an initial historical telemetry value + handle.request( + { size: 1 }, // Only need a single data point + updateValueFromSeries + ); } // Handle changes in the object's composition @@ -204,6 +237,17 @@ define( subscribe($scope.domainObject); } + // Trigger a new query for telemetry data + function updateDisplayBounds(event, bounds) { + maxDomainValue = bounds.end; + if (handle) { + handle.request( + { size: 1 }, // Only need a single data point + updateValueFromSeries + ); + } + } + // Add an element to this view function addElement(element) { // Ensure that configuration field is populated @@ -278,6 +322,9 @@ define( // Position panes where they are dropped $scope.$on("mctDrop", handleDrop); + + // Respond to external bounds changes + $scope.$on("telemetry:display:bounds", updateDisplayBounds); } /** diff --git a/platform/features/layout/test/FixedControllerSpec.js b/platform/features/layout/test/FixedControllerSpec.js index b95bbc9eb5..31d5e06659 100644 --- a/platform/features/layout/test/FixedControllerSpec.js +++ b/platform/features/layout/test/FixedControllerSpec.js @@ -30,10 +30,10 @@ define( var mockScope, mockQ, mockDialogService, - mockSubscriber, + mockHandler, mockFormatter, mockDomainObject, - mockSubscription, + mockHandle, mockEvent, testGrid, testModel, @@ -78,9 +78,9 @@ define( '$scope', [ "$on", "$watch", "commit" ] ); - mockSubscriber = jasmine.createSpyObj( - 'telemetrySubscriber', - [ 'subscribe' ] + mockHandler = jasmine.createSpyObj( + 'telemetryHandler', + [ 'handle' ] ); mockQ = jasmine.createSpyObj('$q', ['when']); mockDialogService = jasmine.createSpyObj( @@ -95,9 +95,16 @@ define( 'domainObject', [ 'getId', 'getModel', 'getCapability' ] ); - mockSubscription = jasmine.createSpyObj( + mockHandle = jasmine.createSpyObj( 'subscription', - [ 'unsubscribe', 'getTelemetryObjects', 'getRangeValue', 'getDatum' ] + [ + 'unsubscribe', + 'getDomainValue', + 'getTelemetryObjects', + 'getRangeValue', + 'getDatum', + 'request' + ] ); mockEvent = jasmine.createSpyObj( 'event', @@ -116,13 +123,14 @@ define( { type: "fixed.telemetry", id: 'c', x: 1, y: 1 } ]}; - mockSubscriber.subscribe.andReturn(mockSubscription); - mockSubscription.getTelemetryObjects.andReturn( + mockHandler.handle.andReturn(mockHandle); + mockHandle.getTelemetryObjects.andReturn( testModel.composition.map(makeMockDomainObject) ); - mockSubscription.getRangeValue.andCallFake(function (o) { + mockHandle.getRangeValue.andCallFake(function (o) { return testValues[o.getId()]; }); + mockHandle.getDomainValue.andReturn(12321); mockFormatter.formatRangeValue.andCallFake(function (v) { return "Formatted " + v; }); @@ -137,7 +145,7 @@ define( mockScope, mockQ, mockDialogService, - mockSubscriber, + mockHandler, mockFormatter ); }); @@ -145,7 +153,7 @@ define( it("subscribes when a domain object is available", function () { mockScope.domainObject = mockDomainObject; findWatch("domainObject")(mockDomainObject); - expect(mockSubscriber.subscribe).toHaveBeenCalledWith( + expect(mockHandler.handle).toHaveBeenCalledWith( mockDomainObject, jasmine.any(Function) ); @@ -156,13 +164,13 @@ define( // First pass - should simply should subscribe findWatch("domainObject")(mockDomainObject); - expect(mockSubscription.unsubscribe).not.toHaveBeenCalled(); - expect(mockSubscriber.subscribe.calls.length).toEqual(1); + expect(mockHandle.unsubscribe).not.toHaveBeenCalled(); + expect(mockHandler.handle.calls.length).toEqual(1); // Object changes - should unsubscribe then resubscribe findWatch("domainObject")(mockDomainObject); - expect(mockSubscription.unsubscribe).toHaveBeenCalled(); - expect(mockSubscriber.subscribe.calls.length).toEqual(2); + expect(mockHandle.unsubscribe).toHaveBeenCalled(); + expect(mockHandler.handle.calls.length).toEqual(2); }); it("exposes visible elements based on configuration", function () { @@ -255,7 +263,7 @@ define( findWatch("model.composition")(mockScope.model.composition); // Invoke the subscription callback - mockSubscriber.subscribe.mostRecentCall.args[1](); + mockHandler.handle.mostRecentCall.args[1](); // Get elements that controller is now exposing elements = controller.getElements(); @@ -333,11 +341,11 @@ define( // Make an object available findWatch('domainObject')(mockDomainObject); // Also verify precondition - expect(mockSubscription.unsubscribe).not.toHaveBeenCalled(); + expect(mockHandle.unsubscribe).not.toHaveBeenCalled(); // Destroy the scope findOn('$destroy')(); // Should have unsubscribed - expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + expect(mockHandle.unsubscribe).toHaveBeenCalled(); }); it("exposes its grid size", function () { diff --git a/platform/features/plot/src/PlotController.js b/platform/features/plot/src/PlotController.js index a54fff83dd..5555d93659 100644 --- a/platform/features/plot/src/PlotController.js +++ b/platform/features/plot/src/PlotController.js @@ -65,6 +65,8 @@ define( subPlotFactory = new SubPlotFactory(telemetryFormatter), cachedObjects = [], updater, + lastBounds, + throttledRequery, handle; // Populate the scope with axis information (specifically, options @@ -94,6 +96,17 @@ define( } } + // Change the displayable bounds + function setBasePanZoom(bounds) { + var start = bounds.start, + end = bounds.end; + if (updater) { + updater.setDomainBounds(start, end); + self.update(); + } + lastBounds = bounds; + } + // Reinstantiate the plot updater (e.g. because we have a // new subscription.) This will clear the plot. function recreateUpdater() { @@ -107,10 +120,15 @@ define( handle, ($scope.axes[1].active || {}).key ); + // Keep any externally-provided bounds + if (lastBounds) { + setBasePanZoom(lastBounds); + } } // Handle new telemetry data in this plot function updateValues() { + self.pending = false; if (handle) { setupModes(handle.getTelemetryObjects()); } @@ -126,6 +144,7 @@ define( // Display new historical data as it becomes available function addHistoricalData(domainObject, series) { + self.pending = false; updater.addHistorical(domainObject, series); self.modeOptions.getModeHandler().plotTelemetry(updater); self.update(); @@ -165,6 +184,19 @@ define( } } + // Respond to a display bounds change (requery for data) + function changeDisplayBounds(event, bounds) { + self.pending = true; + releaseSubscription(); + throttledRequery(); + setBasePanZoom(bounds); + } + + // Reestablish/reissue request for telemetry + throttledRequery = throttle(function () { + subscribe($scope.domainObject); + }, 250); + this.modeOptions = new PlotModeOptions([], subPlotFactory); this.updateValues = updateValues; @@ -174,12 +206,19 @@ define( .forEach(updateSubplot); }); + self.pending = true; + // Subscribe to telemetry when a domain object becomes available $scope.$watch('domainObject', subscribe); + // Respond to external bounds changes + $scope.$on("telemetry:display:bounds", changeDisplayBounds); + // Unsubscribe when the plot is destroyed $scope.$on("$destroy", releaseSubscription); + // Notify any external observers that a new telemetry view is here + $scope.$emit("telemetry:view"); } /** @@ -275,7 +314,7 @@ define( PlotController.prototype.isRequestPending = function () { // Placeholder; this should reflect request state // when requesting historical telemetry - return false; + return this.pending; }; return PlotController; diff --git a/platform/features/plot/src/elements/PlotPanZoomStackGroup.js b/platform/features/plot/src/elements/PlotPanZoomStackGroup.js index 3746958ecb..e1b61a06eb 100644 --- a/platform/features/plot/src/elements/PlotPanZoomStackGroup.js +++ b/platform/features/plot/src/elements/PlotPanZoomStackGroup.js @@ -143,8 +143,7 @@ define( PlotPanZoomStackGroup.prototype.getDepth = function () { // All stacks are kept in sync, so look up depth // from the first one. - return this.stacks.length > 0 ? - this.stacks[0].getDepth() : 0; + return this.stacks.length > 0 ? this.stacks[0].getDepth() : 0; }; /** diff --git a/platform/features/plot/src/elements/PlotUpdater.js b/platform/features/plot/src/elements/PlotUpdater.js index 851fa56096..d4b4ad3eec 100644 --- a/platform/features/plot/src/elements/PlotUpdater.js +++ b/platform/features/plot/src/elements/PlotUpdater.js @@ -141,10 +141,10 @@ define( PlotUpdater.prototype.initializeDomainOffset = function (values) { this.domainOffset = ((this.domainOffset === undefined) && (values.length > 0)) ? - (values.reduce(function (a, b) { - return (a || 0) + (b || 0); - }, 0) / values.length) : - this.domainOffset; + (values.reduce(function (a, b) { + return (a || 0) + (b || 0); + }, 0) / values.length) : + this.domainOffset; }; // Expand range slightly so points near edges are visible @@ -159,7 +159,10 @@ define( // Update dimensions and origin based on extrema of plots PlotUpdater.prototype.updateBounds = function () { - var bufferArray = this.bufferArray; + var bufferArray = this.bufferArray, + priorDomainOrigin = this.origin[0], + priorDomainDimensions = this.dimensions[0]; + if (bufferArray.length > 0) { this.domainExtrema = bufferArray.map(function (lineBuffer) { return lineBuffer.getDomainExtrema(); @@ -178,6 +181,18 @@ define( // Enforce some minimum visible area this.expandRange(); + // Suppress domain changes when pinned + if (this.hasSpecificDomainBounds) { + this.origin[0] = priorDomainOrigin; + this.dimensions[0] = priorDomainDimensions; + if (this.following) { + this.origin[0] = Math.max( + this.domainExtrema[1] - this.dimensions[0], + this.origin[0] + ); + } + } + // ...then enforce a fixed duration if needed if (this.fixedDuration !== undefined) { this.origin[0] = this.origin[0] + this.dimensions[0] - @@ -281,6 +296,21 @@ define( return this.bufferArray; }; + /** + * Set the start and end boundaries (usually time) for the + * domain axis of this updater. + */ + PlotUpdater.prototype.setDomainBounds = function (start, end) { + this.fixedDuration = end - start; + this.origin[0] = start; + this.dimensions[0] = this.fixedDuration; + + // Suppress follow behavior if we have windowed in on the past + this.hasSpecificDomainBounds = true; + this.following = + !this.domainExtrema || (end >= this.domainExtrema[1]); + }; + /** * Fill in historical data. */ diff --git a/platform/features/plot/test/PlotControllerSpec.js b/platform/features/plot/test/PlotControllerSpec.js index e6c79b4e54..dcae177920 100644 --- a/platform/features/plot/test/PlotControllerSpec.js +++ b/platform/features/plot/test/PlotControllerSpec.js @@ -45,11 +45,19 @@ define( }; } + function fireEvent(name, args) { + mockScope.$on.calls.forEach(function (call) { + if (call.args[0] === name) { + call.args[1].apply(null, args || []); + } + }); + } + beforeEach(function () { mockScope = jasmine.createSpyObj( "$scope", - [ "$watch", "$on" ] + [ "$watch", "$on", "$emit" ] ); mockFormatter = jasmine.createSpyObj( "formatter", @@ -87,6 +95,7 @@ define( mockHandle.getMetadata.andReturn([{}]); mockHandle.getDomainValue.andReturn(123); mockHandle.getRangeValue.andReturn(42); + mockScope.domainObject = mockDomainObject; controller = new PlotController( mockScope, @@ -212,7 +221,12 @@ define( }); it("indicates if a request is pending", function () { - // Placeholder; need to support requesting telemetry + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + expect(controller.isRequestPending()).toBeTruthy(); + mockHandle.request.mostRecentCall.args[1]( + mockDomainObject, + mockSeries + ); expect(controller.isRequestPending()).toBeFalsy(); }); @@ -233,10 +247,20 @@ define( // Also verify precondition expect(mockHandle.unsubscribe).not.toHaveBeenCalled(); // Destroy the scope - mockScope.$on.mostRecentCall.args[1](); + fireEvent("$destroy"); // Should have unsubscribed expect(mockHandle.unsubscribe).toHaveBeenCalled(); }); + + it("requeries when displayable bounds change", function () { + mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + expect(mockHandle.request.calls.length).toEqual(1); + fireEvent("telemetry:display:bounds", [ + {}, + { start: 10, end: 100 } + ]); + expect(mockHandle.request.calls.length).toEqual(2); + }); }); } ); diff --git a/platform/features/scrolling/src/RangeColumn.js b/platform/features/scrolling/src/RangeColumn.js index 637a68517d..0b76fbfda0 100644 --- a/platform/features/scrolling/src/RangeColumn.js +++ b/platform/features/scrolling/src/RangeColumn.js @@ -55,7 +55,7 @@ define( var range = this.rangeMetadata.key, limit = domainObject.getCapability('limit'), value = datum[range], - alarm = limit.evaluate(datum, range); + alarm = limit && limit.evaluate(datum, range); return { cssClass: alarm && alarm.cssClass, diff --git a/platform/representation/src/MCTRepresentation.js b/platform/representation/src/MCTRepresentation.js index 49b2ae0f57..98a814c362 100644 --- a/platform/representation/src/MCTRepresentation.js +++ b/platform/representation/src/MCTRepresentation.js @@ -136,6 +136,14 @@ define( } } + // Destroy (deallocate any resources associated with) any + // active representers. + function destroyRepresenters() { + activeRepresenters.forEach(function (activeRepresenter) { + activeRepresenter.destroy(); + }); + } + // General-purpose refresh mechanism; should set up the scope // as appropriate for current representation key and // domain object. @@ -152,10 +160,8 @@ define( // via the "inclusion" field $scope.inclusion = representation && getPath(representation); - // Any existing gestures are no longer valid; release them. - activeRepresenters.forEach(function (activeRepresenter) { - activeRepresenter.destroy(); - }); + // Any existing representers are no longer valid; release them. + destroyRepresenters(); // Log if a key was given, but no matching representation // was found. @@ -209,6 +215,10 @@ define( // model's "modified" field, by the mutation capability. $scope.$watch("domainObject.getModel().modified", refreshCapabilities); + // Make sure any resources allocated by representers also get + // released. + $scope.$on("$destroy", destroyRepresenters); + // Do one initial refresh, so that we don't need another // digest iteration just to populate the scope. Failure to // do this can result in unstable digest cycles, which diff --git a/platform/representation/test/MCTRepresentationSpec.js b/platform/representation/test/MCTRepresentationSpec.js index 337f214a86..a50347df70 100644 --- a/platform/representation/test/MCTRepresentationSpec.js +++ b/platform/representation/test/MCTRepresentationSpec.js @@ -106,7 +106,7 @@ define( mockSce.trustAsResourceUrl.andCallFake(function (url) { return url; }); - mockScope = jasmine.createSpyObj("scope", [ "$watch" ]); + mockScope = jasmine.createSpyObj("scope", [ "$watch", "$on" ]); mockElement = jasmine.createSpyObj("element", JQLITE_FUNCTIONS); mockDomainObject = jasmine.createSpyObj("domainObject", DOMAIN_OBJECT_METHODS); diff --git a/platform/telemetry/src/TelemetryAggregator.js b/platform/telemetry/src/TelemetryAggregator.js index fb4cf81fe0..86257befb7 100644 --- a/platform/telemetry/src/TelemetryAggregator.js +++ b/platform/telemetry/src/TelemetryAggregator.js @@ -31,6 +31,30 @@ define( function () { "use strict"; + /** + * Describes a request for telemetry data. Note that responses + * may contain either a sub- or superset of the requested data. + * @typedef TelemetryRequest + * @property {string} source an identifier for the relevant + * source of telemetry data + * @property {string} key an identifier for the specific + * series of telemetry data provided by that source + * @property {number} [start] the earliest domain value of + * interest for that telemetry data; for time-based + * domains, this is in milliseconds since the start + * of 1970 + * @property {number} [end] the latest domain value of interest + * for that telemetry data; for time-based domains, + * this is in milliseconds since 1970 + * @property {string} [domain] the domain for the query; if + * omitted, this will be whatever the "normal" + * domain is for a given telemetry series (the + * first domain from its metadata) + * @property {number} [size] if set, indicates the maximum number + * of data points of interest for this request (more + * recent domain values will be preferred) + */ + /** * Request telemetry data. * @param {TelemetryRequest[]} requests and array of diff --git a/platform/telemetry/src/TelemetryHandle.js b/platform/telemetry/src/TelemetryHandle.js index 145edfc5d7..ae25fd9bfa 100644 --- a/platform/telemetry/src/TelemetryHandle.js +++ b/platform/telemetry/src/TelemetryHandle.js @@ -79,8 +79,7 @@ define( /** * Change the request duration. - * @param {object|number} request the duration of historical - * data to look at; or, the request to issue + * @param {TelemetryRequest} request 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. @@ -107,6 +106,26 @@ define( .then(issueRequests); }; + /** + * Get the latest telemetry datum for this domain object. This + * will be from real-time telemetry, unless an index is specified, + * in which case it will be pulled from the historical telemetry + * series at the specified index. + * + * @param {DomainObject} domainObject the object of interest + * @param {number} [index] the index of the data of interest + * @returns {TelemetryDatum} the most recent datum + */ + self.getDatum = function (telemetryObject, index) { + return typeof index !== 'number' ? + subscription.getDatum(telemetryObject) : + subscription.makeDatum( + telemetryObject, + this.getSeries(telemetryObject), + index + ); + }; + return self; } diff --git a/platform/telemetry/src/TelemetrySubscription.js b/platform/telemetry/src/TelemetrySubscription.js index 8b4d7d7a9c..5dcab54b94 100644 --- a/platform/telemetry/src/TelemetrySubscription.js +++ b/platform/telemetry/src/TelemetrySubscription.js @@ -123,25 +123,6 @@ define( telemetryCapability.getMetadata(); } - // From a telemetry series, retrieve a single data point - // containing all fields for domains/ranges - function makeDatum(domainObject, series, index) { - var metadata = lookupMetadata(domainObject), - result = {}; - - (metadata.domains || []).forEach(function (domain) { - result[domain.key] = - series.getDomainValue(index, domain.key); - }); - - (metadata.ranges || []).forEach(function (range) { - result[range.key] = - series.getRangeValue(index, range.key); - }); - - return result; - } - // Update the latest telemetry data for a specific // domain object. This will notify listeners. function update(domainObject, series) { @@ -160,7 +141,7 @@ define( pool.put(domainObject.getId(), { domain: series.getDomainValue(count - 1), range: series.getRangeValue(count - 1), - datum: makeDatum(domainObject, series, count - 1) + datum: self.makeDatum(domainObject, series, count - 1) }); } } @@ -188,6 +169,11 @@ define( function cacheObjectReferences(objects) { self.telemetryObjects = objects; self.metadatas = objects.map(lookupMetadata); + + self.metadataById = {}; + objects.forEach(function (obj, i) { + self.metadataById[obj.getId()] = self.metadatas[i]; + }); // Fire callback, as this will be the first time that // telemetry objects are available, or these objects // will have changed. @@ -241,6 +227,34 @@ define( this.unlistenToMutation = addMutationListener(); } + + /** + * From a telemetry series, retrieve a single data point + * containing all fields for domains/ranges + * @private + */ + TelemetrySubscription.prototype.makeDatum = function (domainObject, series, index) { + var id = domainObject && domainObject.getId(), + metadata = (id && this.metadataById[id]) || {}, + result = {}; + + (metadata.domains || []).forEach(function (domain) { + result[domain.key] = + series.getDomainValue(index, domain.key); + }); + + (metadata.ranges || []).forEach(function (range) { + result[range.key] = + series.getRangeValue(index, range.key); + }); + + return result; + }; + + /** + * Terminate all underlying subscriptions. + * @private + */ TelemetrySubscription.prototype.unsubscribeAll = function () { var $q = this.$q; return this.unsubscribePromise.then(function (unsubscribes) {