diff --git a/bundles.json b/bundles.json index 04c3fa0a70..291553ba11 100644 --- a/bundles.json +++ b/bundles.json @@ -6,6 +6,7 @@ "platform/commonUI/browse", "platform/commonUI/edit", "platform/commonUI/dialog", + "platform/commonUI/formats", "platform/commonUI/general", "platform/commonUI/inspect", "platform/commonUI/mobile", diff --git a/example/generator/bundle.json b/example/generator/bundle.json index cdb4736957..7cf1c7b6f2 100644 --- a/example/generator/bundle.json +++ b/example/generator/bundle.json @@ -16,6 +16,23 @@ "implementation": "SinewaveLimitCapability.js" } ], + "formats": [ + { + "key": "example.delta", + "implementation": "SinewaveDeltaFormat.js" + } + ], + "constants": [ + { + "key": "TIME_CONDUCTOR_DOMAINS", + "value": [ + { "key": "time", "name": "Time" }, + { "key": "yesterday", "name": "Yesterday" }, + { "key": "delta", "name": "Delta", "format": "example.delta" } + ], + "priority": -1 + } + ], "types": [ { "key": "generator", @@ -38,6 +55,11 @@ { "key": "yesterday", "name": "Yesterday" + }, + { + "key": "delta", + "name": "Delta", + "format": "example.delta" } ], "ranges": [ diff --git a/example/generator/src/SinewaveConstants.js b/example/generator/src/SinewaveConstants.js new file mode 100644 index 0000000000..29136ebb99 --- /dev/null +++ b/example/generator/src/SinewaveConstants.js @@ -0,0 +1,26 @@ +/***************************************************************************** + * 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({ + START_TIME: Date.now() - 24 * 60 * 60 * 1000 // Now minus a day. +}); diff --git a/example/generator/src/SinewaveDeltaFormat.js b/example/generator/src/SinewaveDeltaFormat.js new file mode 100644 index 0000000000..19f3e631f9 --- /dev/null +++ b/example/generator/src/SinewaveDeltaFormat.js @@ -0,0 +1,68 @@ +/***************************************************************************** + * 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( + ['./SinewaveConstants', 'moment'], + function (SinewaveConstants, moment) { + "use strict"; + + var START_TIME = SinewaveConstants.START_TIME, + FORMAT_REGEX = /^-?\d+:\d+:\d+$/, + SECOND = 1000, + MINUTE = SECOND * 60, + HOUR = MINUTE * 60; + + function SinewaveDeltaFormat() { + } + + function twoDigit(v) { + return v >= 10 ? String(v) : ('0' + v); + } + + SinewaveDeltaFormat.prototype.format = function (value) { + var delta = Math.abs(value - START_TIME), + negative = value < START_TIME, + seconds = Math.floor(delta / SECOND) % 60, + minutes = Math.floor(delta / MINUTE) % 60, + hours = Math.floor(delta / HOUR); + return (negative ? "-" : "") + + [ hours, minutes, seconds ].map(twoDigit).join(":"); + }; + + SinewaveDeltaFormat.prototype.validate = function (text) { + return FORMAT_REGEX.test(text); + }; + + SinewaveDeltaFormat.prototype.parse = function (text) { + var negative = text[0] === "-", + parts = text.replace("-", "").split(":"); + return [ HOUR, MINUTE, SECOND ].map(function (sz, i) { + return parseInt(parts[i], 10) * sz; + }).reduce(function (a, b) { + return a + b; + }, 0) * (negative ? -1 : 1) + START_TIME; + }; + + return SinewaveDeltaFormat; + } +); diff --git a/example/generator/src/SinewaveTelemetrySeries.js b/example/generator/src/SinewaveTelemetrySeries.js index 1e84034766..fa47f8f59a 100644 --- a/example/generator/src/SinewaveTelemetrySeries.js +++ b/example/generator/src/SinewaveTelemetrySeries.js @@ -25,12 +25,12 @@ * Module defining SinewaveTelemetry. Created by vwoeltje on 11/12/14. */ define( - [], - function () { + ['./SinewaveConstants'], + function (SinewaveConstants) { "use strict"; var ONE_DAY = 60 * 60 * 24, - firstObservedTime = Math.floor(Date.now() / 1000) - ONE_DAY; + firstObservedTime = Math.floor(SinewaveConstants.START_TIME / 1000); /** * @@ -58,6 +58,9 @@ define( }; generatorData.getDomainValue = function (i, domain) { + // delta uses the same numeric values as the default domain, + // so it's not checked for here, just formatted for display + // differently. return (i + offset) * 1000 + firstTime * 1000 - (domain === 'yesterday' ? ONE_DAY : 0); }; diff --git a/platform/commonUI/formats/bundle.json b/platform/commonUI/formats/bundle.json new file mode 100644 index 0000000000..99925657b2 --- /dev/null +++ b/platform/commonUI/formats/bundle.json @@ -0,0 +1,26 @@ +{ + "name": "Time services bundle", + "description": "Defines interfaces and provides default implementations for handling different time systems.", + "extensions": { + "components": [ + { + "provides": "formatService", + "type": "provider", + "implementation": "FormatProvider.js", + "depends": [ "formats[]" ] + } + ], + "formats": [ + { + "key": "utc", + "implementation": "UTCTimeFormat.js" + } + ], + "constants": [ + { + "key": "DEFAULT_TIME_FORMAT", + "value": "utc" + } + ] + } +} diff --git a/platform/commonUI/formats/src/FormatProvider.js b/platform/commonUI/formats/src/FormatProvider.js new file mode 100644 index 0000000000..e6d38fbcee --- /dev/null +++ b/platform/commonUI/formats/src/FormatProvider.js @@ -0,0 +1,114 @@ +/***************************************************************************** + * 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"; + + /** + * An object used to convert between numeric values and text values, + * typically used to display these values to the user and to convert + * user input to a numeric format, particularly for time formats. + * @interface {Format} + */ + + /** + * Parse text (typically user input) to a numeric value. + * Behavior is undefined when the text cannot be parsed; + * `validate` should be called first if the text may be invalid. + * @method parse + * @memberof Format# + * @param {string} text the text to parse + * @returns {number} the parsed numeric value + */ + + /** + * Determine whether or not some text (typically user input) can + * be parsed to a numeric value by this format. + * @method validate + * @memberof Format# + * @param {string} text the text to parse + * @returns {boolean} true if the text can be parsed + */ + + /** + * Convert a numeric value to a text value for display using + * this format. + * @method format + * @memberof Format# + * @param {number} value the numeric value to format + * @returns {string} the text representation of the value + */ + + /** + * Provides access to `Format` objects which can be used to + * convert values between human-readable text and numeric + * representations. + * @interface FormatService + */ + + /** + * Look up a format by its symbolic identifier. + * @method getFormat + * @memberof FormatService# + * @param {string} key the identifier for this format + * @returns {Format} the format + * @throws {Error} errors when the requested format is unrecognized + */ + + /** + * Provides formats from the `formats` extension category. + * @constructor + * @implements {FormatService} + * @memberof platform/commonUI/formats + * @param {Array.} format constructors, + * from the `formats` extension category. + */ + function FormatProvider(formats) { + var formatMap = {}; + + function addToMap(Format) { + var key = Format.key; + if (key && !formatMap[key]) { + formatMap[key] = new Format(); + } + } + + formats.forEach(addToMap); + this.formatMap = formatMap; + } + + FormatProvider.prototype.getFormat = function (key) { + var format = this.formatMap[key]; + if (!format) { + throw new Error("FormatProvider: No format found for " + key); + } + return format; + }; + + return FormatProvider; + +}); diff --git a/platform/commonUI/formats/src/UTCTimeFormat.js b/platform/commonUI/formats/src/UTCTimeFormat.js new file mode 100644 index 0000000000..b035fed99f --- /dev/null +++ b/platform/commonUI/formats/src/UTCTimeFormat.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*/ + +define([ + 'moment' +], function ( + moment +) { + "use strict"; + + var DATE_FORMAT = "YYYY-MM-DD HH:mm:ss", + DATE_FORMATS = [ + DATE_FORMAT, + "YYYY-MM-DD HH:mm", + "YYYY-MM-DD" + ]; + + + /** + * Formatter for UTC timestamps. Interprets numeric values as + * milliseconds since the start of 1970. + * + * @implements {Format} + * @constructor + * @memberof platform/commonUI/formats + */ + function UTCTimeFormat() { + } + + UTCTimeFormat.prototype.format = function (value) { + return moment.utc(value).format(DATE_FORMAT); + }; + + UTCTimeFormat.prototype.parse = function (text) { + return moment.utc(text, DATE_FORMATS).valueOf(); + }; + + UTCTimeFormat.prototype.validate = function (text) { + return moment.utc(text, DATE_FORMATS).isValid(); + }; + + return UTCTimeFormat; +}); diff --git a/platform/commonUI/formats/test/FormatProviderSpec.js b/platform/commonUI/formats/test/FormatProviderSpec.js new file mode 100644 index 0000000000..4f68c106f7 --- /dev/null +++ b/platform/commonUI/formats/test/FormatProviderSpec.js @@ -0,0 +1,68 @@ +/***************************************************************************** + * 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/FormatProvider'], + function (FormatProvider) { + 'use strict'; + + var KEYS = [ 'a', 'b', 'c' ]; + + describe("The FormatProvider", function () { + var mockFormats, + mockLog, + mockFormatInstances, + provider; + + beforeEach(function () { + mockFormatInstances = KEYS.map(function (k) { + return jasmine.createSpyObj( + 'format-' + k, + [ 'parse', 'validate', 'format' ] + ); + }); + // Return constructors + mockFormats = KEYS.map(function (k, i) { + function MockFormat() { return mockFormatInstances[i]; } + MockFormat.key = k; + return MockFormat; + }); + provider = new FormatProvider(mockFormats); + }); + + it("looks up formats by key", function () { + KEYS.forEach(function (k, i) { + expect(provider.getFormat(k)) + .toEqual(mockFormatInstances[i]); + }); + }); + + it("throws an error about unknown formats", function () { + expect(function () { + provider.getFormat('some-unknown-format'); + }).toThrow(); + }); + + }); + } +); diff --git a/platform/commonUI/formats/test/UTCTimeFormatSpec.js b/platform/commonUI/formats/test/UTCTimeFormatSpec.js new file mode 100644 index 0000000000..d55a8a9507 --- /dev/null +++ b/platform/commonUI/formats/test/UTCTimeFormatSpec.js @@ -0,0 +1,56 @@ +/***************************************************************************** + * 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/UTCTimeFormat', 'moment'], + function (UTCTimeFormat, moment) { + 'use strict'; + + describe("The UTCTimeFormat", function () { + var format; + + beforeEach(function () { + format = new UTCTimeFormat(); + }); + + it("formats UTC timestamps", function () { + var timestamp = 12345670000, + formatted = format.format(timestamp); + expect(formatted).toEqual(jasmine.any(String)); + expect(moment.utc(formatted).valueOf()).toEqual(timestamp); + }); + + it("validates time inputs", function () { + expect(format.validate("1977-05-25 11:21:22")).toBe(true); + expect(format.validate("garbage text")).toBe(false); + }); + + it("parses valid input", function () { + var text = "1977-05-25 11:21:22", + parsed = format.parse(text); + expect(parsed).toEqual(jasmine.any(Number)); + expect(parsed).toEqual(moment.utc(text).valueOf()); + }); + }); + } +); diff --git a/platform/commonUI/formats/test/suite.json b/platform/commonUI/formats/test/suite.json new file mode 100644 index 0000000000..06c88fac8b --- /dev/null +++ b/platform/commonUI/formats/test/suite.json @@ -0,0 +1,4 @@ +[ + "FormatProvider", + "UTCTimeFormat" +] diff --git a/platform/commonUI/general/bundle.json b/platform/commonUI/general/bundle.json index 2bd200b130..8e9eafc5e9 100644 --- a/platform/commonUI/general/bundle.json +++ b/platform/commonUI/general/bundle.json @@ -61,13 +61,18 @@ { "key": "TimeRangeController", "implementation": "controllers/TimeRangeController.js", - "depends": [ "$scope", "now" ] + "depends": [ "$scope", "formatService", "DEFAULT_TIME_FORMAT", "now" ] }, { "key": "DateTimePickerController", "implementation": "controllers/DateTimePickerController.js", "depends": [ "$scope", "now" ] }, + { + "key": "DateTimeFieldController", + "implementation": "controllers/DateTimeFieldController.js", + "depends": [ "$scope", "formatService", "DEFAULT_TIME_FORMAT" ] + }, { "key": "TreeNodeController", "implementation": "controllers/TreeNodeController.js", @@ -255,6 +260,10 @@ { "key": "datetime-picker", "templateUrl": "templates/controls/datetime-picker.html" + }, + { + "key": "datetime-field", + "templateUrl": "templates/controls/datetime-field.html" } ], "licenses": [ diff --git a/platform/commonUI/general/res/templates/controls/datetime-field.html b/platform/commonUI/general/res/templates/controls/datetime-field.html new file mode 100644 index 0000000000..6ba8cbf901 --- /dev/null +++ b/platform/commonUI/general/res/templates/controls/datetime-field.html @@ -0,0 +1,20 @@ + + + + + + +
+ + +
+
+
diff --git a/platform/commonUI/general/res/templates/controls/time-controller.html b/platform/commonUI/general/res/templates/controls/time-controller.html index 300e56c381..e44a9ff77c 100644 --- a/platform/commonUI/general/res/templates/controls/time-controller.html +++ b/platform/commonUI/general/res/templates/controls/time-controller.html @@ -22,47 +22,24 @@
C - - - - - - - -
- - -
-
-
+ + + to - - - - - - - -
- - -
-
-
  + +  
@@ -97,7 +74,7 @@
diff --git a/platform/commonUI/general/src/controllers/DateTimeFieldController.js b/platform/commonUI/general/src/controllers/DateTimeFieldController.js new file mode 100644 index 0000000000..b87268dc5a --- /dev/null +++ b/platform/commonUI/general/src/controllers/DateTimeFieldController.js @@ -0,0 +1,79 @@ +/***************************************************************************** + * 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( + [], + function () { + 'use strict'; + + /** + * Controller to support the date-time entry field. + * + * Accepts a `format` property in the `structure` attribute + * which allows a date/time to be specified via its symbolic + * key (as will be used to look up said format from the + * `formatService`.) + * + * {@see FormatService} + * @constructor + * @memberof platform/commonUI/general + * @param $scope the Angular scope for this controller + * @param {FormatService} formatService the service to user to format + * domain values + * @param {string} defaultFormat the format to request when no + * format has been otherwise specified + */ + function DateTimeFieldController($scope, formatService, defaultFormat) { + var formatter = formatService.getFormat(defaultFormat); + + function updateFromModel(value) { + // Only reformat if the value is different from user + // input (to avoid reformatting valid input while typing.) + if (!formatter.validate($scope.textValue) || + formatter.parse($scope.textValue) !== value) { + $scope.textValue = formatter.format(value); + $scope.textInvalid = false; + } + } + + function updateFromView(textValue) { + $scope.textInvalid = !formatter.validate(textValue); + if (!$scope.textInvalid) { + $scope.ngModel[$scope.field] = + formatter.parse(textValue); + } + } + + function setFormat(format) { + formatter = formatService.getFormat(format || defaultFormat); + updateFromModel($scope.ngModel[$scope.field]); + } + + $scope.$watch('structure.format', setFormat); + $scope.$watch('ngModel[field]', updateFromModel); + $scope.$watch('textValue', updateFromView); + } + + return DateTimeFieldController; + } +); diff --git a/platform/commonUI/general/src/controllers/TimeRangeController.js b/platform/commonUI/general/src/controllers/TimeRangeController.js index d4fb21be08..cdcdb7f8d0 100644 --- a/platform/commonUI/general/src/controllers/TimeRangeController.js +++ b/platform/commonUI/general/src/controllers/TimeRangeController.js @@ -26,33 +26,32 @@ define( function (moment) { "use strict"; - var DATE_FORMAT = "YYYY-MM-DD HH:mm:ss", - TICK_SPACING_PX = 150; + var TICK_SPACING_PX = 150; + /** + * Controller used by the `time-controller` template. * @memberof platform/commonUI/general * @constructor + * @param $scope the Angular scope for this controller + * @param {FormatService} formatService the service to user to format + * domain values + * @param {string} defaultFormat the format to request when no + * format has been otherwise specified + * @param {Function} now a function to return current system time */ - function TimeConductorController($scope, now) { + function TimeRangeController($scope, formatService, defaultFormat, now) { var tickCount = 2, innerMinimumSpan = 1000, // 1 second outerMinimumSpan = 1000 * 60 * 60, // 1 hour - initialDragValue; + initialDragValue, + formatter = formatService.getFormat(defaultFormat); function formatTimestamp(ts) { - return moment.utc(ts).format(DATE_FORMAT); + return formatter.format(ts); } - function parseTimestamp(text) { - var m = moment.utc(text, DATE_FORMAT); - if (m.isValid()) { - return m.valueOf(); - } else { - throw new Error("Could not parse " + text); - } - } - - // From 0.0-1.0 to "0%"-"1%" + // From 0.0-1.0 to "0%"-"100%" function toPercent(p) { return (100 * p) + "%"; } @@ -101,41 +100,15 @@ define( return { start: bounds.start, end: bounds.end }; } - function updateBoundsTextForProperty(ngModel, property) { - try { - if (!$scope.boundsModel[property] || - parseTimestamp($scope.boundsModel[property]) !== - ngModel.outer[property]) { - $scope.boundsModel[property] = - formatTimestamp(ngModel.outer[property]); - } - } catch (e) { - // User-entered text is invalid, so leave it be - // until they fix it. - } - } - - function updateBoundsText(ngModel) { - updateBoundsTextForProperty(ngModel, 'start'); - updateBoundsTextForProperty(ngModel, '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 - updateBoundsText(ngModel); - - // Then various updates for the inner span - updateViewForInnerSpanFromModel(ngModel); - // Stick it back is scope (in case we just set defaults) $scope.ngModel = ngModel; + updateViewForInnerSpanFromModel(ngModel); updateTicks(); } @@ -155,7 +128,8 @@ define( } function toMillis(pixels) { - var span = $scope.ngModel.outer.end - $scope.ngModel.outer.start; + var span = + $scope.ngModel.outer.end - $scope.ngModel.outer.start; return (pixels / $scope.spanWidth) * span; } @@ -243,36 +217,10 @@ define( updateTicks(); } - function updateStartFromText(value) { - try { - updateOuterStart(parseTimestamp(value)); - updateBoundsTextForProperty($scope.ngModel, 'end'); - $scope.boundsModel.startValid = true; - } catch (e) { - $scope.boundsModel.startValid = false; - return; - } - } - - function updateEndFromText(value) { - try { - updateOuterEnd(parseTimestamp(value)); - updateBoundsTextForProperty($scope.ngModel, 'start'); - $scope.boundsModel.endValid = true; - } catch (e) { - $scope.boundsModel.endValid = false; - return; - } - } - - function updateStartFromPicker(value) { - updateOuterStart(value); - updateBoundsText($scope.ngModel); - } - - function updateEndFromPicker(value) { - updateOuterEnd(value); - updateBoundsText($scope.ngModel); + function updateFormat(key) { + formatter = formatService.getFormat(key || defaultFormat); + updateViewForInnerSpanFromModel($scope.ngModel); + updateTicks(); } $scope.startLeftDrag = startLeftDrag; @@ -282,21 +230,18 @@ define( $scope.rightDrag = rightDrag; $scope.middleDrag = middleDrag; - $scope.state = false; $scope.ticks = []; - $scope.boundsModel = {}; // Initialize scope to defaults updateViewFromModel($scope.ngModel); $scope.$watchCollection("ngModel", updateViewFromModel); $scope.$watch("spanWidth", updateSpanWidth); - $scope.$watch("ngModel.outer.start", updateStartFromPicker); - $scope.$watch("ngModel.outer.end", updateEndFromPicker); - $scope.$watch("boundsModel.start", updateStartFromText); - $scope.$watch("boundsModel.end", updateEndFromText); + $scope.$watch("ngModel.outer.start", updateOuterStart); + $scope.$watch("ngModel.outer.end", updateOuterEnd); + $scope.$watch("parameters.format", updateFormat); } - return TimeConductorController; + return TimeRangeController; } ); diff --git a/platform/commonUI/general/test/controllers/DateTimeFieldControllerSpec.js b/platform/commonUI/general/test/controllers/DateTimeFieldControllerSpec.js new file mode 100644 index 0000000000..8f516ece5d --- /dev/null +++ b/platform/commonUI/general/test/controllers/DateTimeFieldControllerSpec.js @@ -0,0 +1,183 @@ +/***************************************************************************** + * 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/DateTimeFieldController", "moment"], + function (DateTimeFieldController, moment) { + 'use strict'; + + var TEST_FORMAT = "YYYY-MM-DD HH:mm:ss"; + + describe("The DateTimeFieldController", function () { + var mockScope, + mockFormatService, + mockFormat, + 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', ['$watch']); + mockFormatService = + jasmine.createSpyObj('formatService', ['getFormat']); + mockFormat = jasmine.createSpyObj('format', [ + 'parse', + 'validate', + 'format' + ]); + + mockFormatService.getFormat.andReturn(mockFormat); + + mockFormat.validate.andCallFake(function (text) { + return moment.utc(text, TEST_FORMAT).isValid(); + }); + mockFormat.parse.andCallFake(function (text) { + return moment.utc(text, TEST_FORMAT).valueOf(); + }); + mockFormat.format.andCallFake(function (value) { + return moment.utc(value).format(TEST_FORMAT); + }); + + mockScope.ngModel = { testField: 12321 }; + mockScope.field = "testField"; + mockScope.structure = { format: "someFormat" }; + + controller = new DateTimeFieldController( + mockScope, + mockFormatService + ); + }); + + it("updates models from user-entered text", function () { + var newText = "1977-05-25 17:30:00"; + + mockScope.textValue = newText; + fireWatch("textValue", newText); + expect(mockScope.ngModel.testField) + .toEqual(mockFormat.parse(newText)); + expect(mockScope.textInvalid).toBeFalsy(); + }); + + it("updates text from model values", function () { + var testTime = mockFormat.parse("1977-05-25 17:30:00"); + mockScope.ngModel.testField = testTime; + fireWatch("ngModel[field]", testTime); + expect(mockScope.textValue).toEqual("1977-05-25 17:30:00"); + }); + + describe("when user input is invalid", function () { + var newText, oldValue; + + beforeEach(function () { + newText = "Not a date"; + oldValue = mockScope.ngModel.testField; + mockScope.textValue = newText; + fireWatch("textValue", newText); + }); + + it("displays error state", function () { + expect(mockScope.textInvalid).toBeTruthy(); + }); + + it("does not modify model state", function () { + expect(mockScope.ngModel.testField).toEqual(oldValue); + }); + + it("does not modify user input", function () { + expect(mockScope.textValue).toEqual(newText); + }); + }); + + it("does not modify valid but irregular user input", function () { + // Don't want the controller "fixing" bad or + // irregularly-formatted input out from under + // the user's fingertips. + var newText = "2015-3-3 01:02:04", + oldValue = mockScope.ngModel.testField; + + mockFormat.validate.andReturn(true); + mockFormat.parse.andReturn(42); + mockScope.textValue = newText; + fireWatch("textValue", newText); + + expect(mockScope.textValue).toEqual(newText); + expect(mockScope.ngModel.testField).toEqual(42); + expect(mockScope.ngModel.testField).not.toEqual(oldValue); + }); + + it("obtains a format from the format service", function () { + fireWatch('structure.format', mockScope.structure.format); + expect(mockFormatService.getFormat) + .toHaveBeenCalledWith(mockScope.structure.format); + }); + + it("throws an error for unknown formats", function () { + mockFormatService.getFormat.andReturn(undefined); + expect(function () { + fireWatch("structure.format", "some-format"); + }).toThrow(); + }); + + describe("using the obtained format", function () { + var testValue = 1234321, + testText = "some text"; + + beforeEach(function () { + mockFormat.validate.andReturn(true); + mockFormat.parse.andReturn(testValue); + mockFormat.format.andReturn(testText); + }); + + it("parses user input", function () { + var newText = "some other new text"; + mockScope.textValue = newText; + fireWatch("textValue", newText); + expect(mockFormat.parse).toHaveBeenCalledWith(newText); + expect(mockScope.ngModel.testField).toEqual(testValue); + }); + + it("validates user input", function () { + var newText = "some other new text"; + mockScope.textValue = newText; + fireWatch("textValue", newText); + expect(mockFormat.validate).toHaveBeenCalledWith(newText); + }); + + it("formats model data for display", function () { + var newValue = 42; + mockScope.ngModel.testField = newValue; + fireWatch("ngModel[field]", newValue); + expect(mockFormat.format).toHaveBeenCalledWith(newValue); + expect(mockScope.textValue).toEqual(testText); + }); + }); + + }); + } +); diff --git a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js index 91d3ecb9db..85e77e4889 100644 --- a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js +++ b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js @@ -33,7 +33,10 @@ define( describe("The TimeRangeController", function () { var mockScope, + mockFormatService, + testDefaultFormat, mockNow, + mockFormat, controller; function fireWatch(expr, value) { @@ -57,8 +60,30 @@ define( "$scope", [ "$apply", "$watch", "$watchCollection" ] ); + mockFormatService = jasmine.createSpyObj( + "formatService", + [ "getFormat" ] + ); + testDefaultFormat = 'utc'; + mockFormat = jasmine.createSpyObj( + "format", + [ "validate", "format", "parse" ] + ); + + mockFormatService.getFormat.andReturn(mockFormat); + + mockFormat.format.andCallFake(function (value) { + return moment.utc(value).format("YYYY-MM-DD HH:mm:ss"); + }); + mockNow = jasmine.createSpy('now'); - controller = new TimeRangeController(mockScope, mockNow); + + controller = new TimeRangeController( + mockScope, + mockFormatService, + testDefaultFormat, + mockNow + ); }); it("watches the model that was passed in", function () { @@ -167,70 +192,22 @@ define( .toBeGreaterThan(mockScope.ngModel.inner.start); }); - describe("by typing", function () { - it("updates models", function () { - var newStart = "1977-05-25 17:30:00", - newEnd = "2015-12-18 03:30:00"; - - mockScope.boundsModel.start = newStart; - fireWatch("boundsModel.start", newStart); - expect(mockScope.ngModel.outer.start) - .toEqual(moment.utc(newStart).valueOf()); - expect(mockScope.boundsModel.startValid) - .toBeTruthy(); - - mockScope.boundsModel.end = newEnd; - fireWatch("boundsModel.end", newEnd); - expect(mockScope.ngModel.outer.end) - .toEqual(moment.utc(newEnd).valueOf()); - expect(mockScope.boundsModel.endValid) - .toBeTruthy(); - }); - - it("displays error state", function () { - var newStart = "Not a date", - newEnd = "Definitely not a date", - oldStart = mockScope.ngModel.outer.start, - oldEnd = mockScope.ngModel.outer.end; - - mockScope.boundsModel.start = newStart; - fireWatch("boundsModel.start", newStart); - expect(mockScope.ngModel.outer.start) - .toEqual(oldStart); - expect(mockScope.boundsModel.startValid) - .toBeFalsy(); - - mockScope.boundsModel.end = newEnd; - fireWatch("boundsModel.end", newEnd); - expect(mockScope.ngModel.outer.end) - .toEqual(oldEnd); - expect(mockScope.boundsModel.endValid) - .toBeFalsy(); - }); - - it("does not modify user input", function () { - // Don't want the controller "fixing" bad or - // irregularly-formatted input out from under - // the user's fingertips. - var newStart = "Not a date", - newEnd = "2015-3-3 01:02:04", - oldStart = mockScope.ngModel.outer.start, - oldEnd = mockScope.ngModel.outer.end; - - mockScope.boundsModel.start = newStart; - fireWatch("boundsModel.start", newStart); - expect(mockScope.boundsModel.start) - .toEqual(newStart); - - mockScope.boundsModel.end = newEnd; - fireWatch("boundsModel.end", newEnd); - expect(mockScope.boundsModel.end) - .toEqual(newEnd); - }); - }); }); + it("watches for changes in format selection", function () { + expect(mockFormatService.getFormat) + .not.toHaveBeenCalledWith('test-format'); + fireWatch("parameters.format", 'test-format'); + expect(mockFormatService.getFormat) + .toHaveBeenCalledWith('test-format'); + }); + it("throws an error for unknown formats", function () { + mockFormatService.getFormat.andReturn(undefined); + expect(function () { + fireWatch("parameters.format", "some-format"); + }).toThrow(); + }); }); } diff --git a/platform/commonUI/general/test/suite.json b/platform/commonUI/general/test/suite.json index 0d19fbb9e4..ec91a114e6 100644 --- a/platform/commonUI/general/test/suite.json +++ b/platform/commonUI/general/test/suite.json @@ -3,6 +3,7 @@ "controllers/BottomBarController", "controllers/ClickAwayController", "controllers/ContextMenuController", + "controllers/DateTimeFieldController", "controllers/DateTimePickerController", "controllers/GetterSetterController", "controllers/SelectorController", diff --git a/platform/features/conductor/bundle.json b/platform/features/conductor/bundle.json index de903cfb93..c37f15d97b 100644 --- a/platform/features/conductor/bundle.json +++ b/platform/features/conductor/bundle.json @@ -36,9 +36,9 @@ { "key": "TIME_CONDUCTOR_DOMAINS", "value": [ - { "key": "time", "name": "Time" }, - { "key": "yesterday", "name": "Yesterday" } + { "key": "time", "name": "UTC", "format": "utc" } ], + "priority": "fallback", "comment": "Placeholder; to be replaced by inspection of available domains." } ] diff --git a/platform/features/conductor/res/templates/time-conductor.html b/platform/features/conductor/res/templates/time-conductor.html index 4126652d5b..16cc6296c8 100644 --- a/platform/features/conductor/res/templates/time-conductor.html +++ b/platform/features/conductor/res/templates/time-conductor.html @@ -1,4 +1,5 @@ ", + "", "" ].join(''), THROTTLE_MS = 200, @@ -74,11 +77,11 @@ define( broadcastBounds; // Combine start/end times into a single object - function bounds(start, end) { + function bounds() { return { start: conductor.displayStart(), end: conductor.displayEnd(), - domain: conductor.domain() + domain: conductor.domain().key }; } @@ -97,12 +100,9 @@ define( } function updateDomain(value) { - conductor.domain(value); - repScope.$broadcast('telemetry:display:bounds', bounds( - conductor.displayStart(), - conductor.displayEnd(), - conductor.domain() - )); + var newDomain = conductor.domain(value); + conductorScope.parameters.format = newDomain.format; + repScope.$broadcast('telemetry:display:bounds', bounds()); } // telemetry domain metadata -> option for a select control @@ -130,7 +130,8 @@ define( { outer: bounds(), inner: bounds() }; conductorScope.ngModel.options = conductor.domainOptions().map(makeOption); - conductorScope.ngModel.domain = conductor.domain(); + conductorScope.ngModel.domain = conductor.domain().key; + conductorScope.parameters = {}; conductorScope .$watch('ngModel.conductor.inner.start', updateConductorInner); diff --git a/platform/features/conductor/src/ConductorTelemetryDecorator.js b/platform/features/conductor/src/ConductorTelemetryDecorator.js index ab2d958d7e..ce5b249eaf 100644 --- a/platform/features/conductor/src/ConductorTelemetryDecorator.js +++ b/platform/features/conductor/src/ConductorTelemetryDecorator.js @@ -51,7 +51,7 @@ define( request = request || {}; request.start = start; request.end = end; - request.domain = domain; + request.domain = domain.key; return request; } diff --git a/platform/features/conductor/src/TimeConductor.js b/platform/features/conductor/src/TimeConductor.js index 0fa0403fd9..400cb06f97 100644 --- a/platform/features/conductor/src/TimeConductor.js +++ b/platform/features/conductor/src/TimeConductor.js @@ -43,7 +43,7 @@ define( function TimeConductor(start, end, domains) { this.range = { start: start, end: end }; this.domains = domains; - this.activeDomain = domains[0].key; + this.activeDomain = domains[0]; } /** @@ -73,7 +73,7 @@ define( /** * Get available domain options which can be used to bound time * selection. - * @returns {TelemetryDomain[]} available domains + * @returns {TelemetryDomainMetadata[]} available domains */ TimeConductor.prototype.domainOptions = function () { return this.domains; @@ -82,19 +82,21 @@ define( /** * Get or set (if called with an argument) the active domain. * @param {string} [key] the key identifying the domain choice - * @returns {TelemetryDomain} the active telemetry domain + * @returns {TelemetryDomainMetadata} the active telemetry domain */ TimeConductor.prototype.domain = function (key) { - function matchesKey(domain) { - return domain.key === key; - } + var i; if (arguments.length > 0) { - if (!this.domains.some(matchesKey)) { - throw new Error("Unknown domain " + key); + for (i = 0; i < this.domains.length; i += 1) { + if (this.domains[i].key === key) { + return (this.activeDomain = this.domains[i]); + } } - this.activeDomain = key; + + throw new Error("Unknown domain " + key); } + return this.activeDomain; }; diff --git a/platform/features/conductor/test/ConductorRepresenterSpec.js b/platform/features/conductor/test/ConductorRepresenterSpec.js index 5d78c8a720..cd7d3a4f91 100644 --- a/platform/features/conductor/test/ConductorRepresenterSpec.js +++ b/platform/features/conductor/test/ConductorRepresenterSpec.js @@ -129,7 +129,7 @@ define( it("exposes conductor state in scope", function () { mockConductor.displayStart.andReturn(1977); mockConductor.displayEnd.andReturn(1984); - mockConductor.domain.andReturn('d'); + mockConductor.domain.andReturn({ key: 'd' }); representer.represent(testViews[0], {}); expect(mockNewScope.ngModel.conductor).toEqual({ @@ -219,7 +219,7 @@ define( representer.represent(testViews[0], null); expect(mockNewScope.ngModel.domain) - .toEqual(mockConductor.domain()); + .toEqual(mockConductor.domain().key); }); it("exposes domain options in scope", function () { diff --git a/platform/features/conductor/test/ConductorTelemetryDecoratorSpec.js b/platform/features/conductor/test/ConductorTelemetryDecoratorSpec.js index 6e768419c1..26cdb2677b 100644 --- a/platform/features/conductor/test/ConductorTelemetryDecoratorSpec.js +++ b/platform/features/conductor/test/ConductorTelemetryDecoratorSpec.js @@ -77,7 +77,7 @@ define( mockConductor.displayStart.andReturn(42); mockConductor.displayEnd.andReturn(1977); - mockConductor.domain.andReturn("testDomain"); + mockConductor.domain.andReturn({ key: "testDomain" }); decorator = new ConductorTelemetryDecorator( mockConductorService, @@ -104,7 +104,7 @@ define( }); it("with domain selection", function () { - expect(request.domain).toEqual(mockConductor.domain()); + expect(request.domain).toEqual(mockConductor.domain().key); }); }); @@ -127,7 +127,7 @@ define( }); it("with domain selection", function () { - expect(request.domain).toEqual(mockConductor.domain()); + expect(request.domain).toEqual(mockConductor.domain().key); }); }); diff --git a/platform/features/conductor/test/TimeConductorSpec.js b/platform/features/conductor/test/TimeConductorSpec.js index c9336a93b0..ee1d2f56b7 100644 --- a/platform/features/conductor/test/TimeConductorSpec.js +++ b/platform/features/conductor/test/TimeConductorSpec.js @@ -59,12 +59,12 @@ define( }); it("exposes the current domain choice", function () { - expect(conductor.domain()).toEqual(testDomains[0].key); + expect(conductor.domain()).toEqual(testDomains[0]); }); it("allows the domain choice to be changed", function () { conductor.domain(testDomains[1].key); - expect(conductor.domain()).toEqual(testDomains[1].key); + expect(conductor.domain()).toEqual(testDomains[1]); }); it("throws an error on attempts to set an invalid domain", function () { diff --git a/platform/features/plot/src/PlotController.js b/platform/features/plot/src/PlotController.js index 19aee9ca11..d6acdb6a5c 100644 --- a/platform/features/plot/src/PlotController.js +++ b/platform/features/plot/src/PlotController.js @@ -31,10 +31,19 @@ define( "./elements/PlotPalette", "./elements/PlotAxis", "./elements/PlotLimitTracker", + "./elements/PlotTelemetryFormatter", "./modes/PlotModeOptions", "./SubPlotFactory" ], - function (PlotUpdater, PlotPalette, PlotAxis, PlotLimitTracker, PlotModeOptions, SubPlotFactory) { + function ( + PlotUpdater, + PlotPalette, + PlotAxis, + PlotLimitTracker, + PlotTelemetryFormatter, + PlotModeOptions, + SubPlotFactory + ) { "use strict"; var AXIS_DEFAULTS = [ @@ -62,7 +71,10 @@ define( PLOT_FIXED_DURATION ) { var self = this, - subPlotFactory = new SubPlotFactory(telemetryFormatter), + plotTelemetryFormatter = + new PlotTelemetryFormatter(telemetryFormatter), + subPlotFactory = + new SubPlotFactory(plotTelemetryFormatter), cachedObjects = [], updater, lastBounds, @@ -71,10 +83,9 @@ define( // Populate the scope with axis information (specifically, options // available for each axis.) function setupAxes(metadatas) { - $scope.axes = [ - new PlotAxis("domain", metadatas, AXIS_DEFAULTS[0]), - new PlotAxis("range", metadatas, AXIS_DEFAULTS[1]) - ]; + $scope.axes.forEach(function (axis) { + axis.updateMetadata(metadatas); + }); } // Trigger an update of a specific subplot; @@ -125,37 +136,49 @@ define( } } + function getUpdater() { + if (!updater) { + recreateUpdater(); + } + return updater; + } + // Handle new telemetry data in this plot function updateValues() { self.pending = false; if (handle) { setupModes(handle.getTelemetryObjects()); - } - if (updater) { - updater.update(); + setupAxes(handle.getMetadata()); + getUpdater().update(); self.modeOptions.getModeHandler().plotTelemetry(updater); - } - if (self.limitTracker) { self.limitTracker.update(); + self.update(); } - self.update(); } // Display new historical data as it becomes available function addHistoricalData(domainObject, series) { self.pending = false; - updater.addHistorical(domainObject, series); + getUpdater().addHistorical(domainObject, series); self.modeOptions.getModeHandler().plotTelemetry(updater); self.update(); } // Issue a new request for historical telemetry function requestTelemetry() { - if (handle && updater) { + if (handle) { handle.request({}, addHistoricalData); } } + // Requery for data entirely + function replot() { + if (handle) { + updater = undefined; + requestTelemetry(); + } + } + // Create a new subscription; telemetrySubscriber gets // to do the meaningful work here. function subscribe(domainObject) { @@ -167,12 +190,7 @@ define( updateValues, true // Lossless ); - if (handle) { - setupModes(handle.getTelemetryObjects()); - setupAxes(handle.getMetadata()); - recreateUpdater(); - requestTelemetry(); - } + replot(); } // Release the current subscription (called when scope is destroyed) @@ -185,12 +203,22 @@ define( // Respond to a display bounds change (requery for data) function changeDisplayBounds(event, bounds) { + var domainAxis = $scope.axes[0]; + + domainAxis.chooseOption(bounds.domain); + plotTelemetryFormatter + .setDomainFormat(domainAxis.active.format); + self.pending = true; releaseSubscription(); subscribe($scope.domainObject); setBasePanZoom(bounds); } + function updateDomainFormat(format) { + plotTelemetryFormatter.setDomainFormat(format); + } + this.modeOptions = new PlotModeOptions([], subPlotFactory); this.updateValues = updateValues; @@ -202,6 +230,13 @@ define( self.pending = true; + // Initialize axes; will get repopulated when telemetry + // metadata becomes available. + $scope.axes = [ + new PlotAxis("domains", [], AXIS_DEFAULTS[0]), + new PlotAxis("ranges", [], AXIS_DEFAULTS[1]) + ]; + // Subscribe to telemetry when a domain object becomes available $scope.$watch('domainObject', subscribe); diff --git a/platform/features/plot/src/SubPlot.js b/platform/features/plot/src/SubPlot.js index dfcaadf352..051f95f2a8 100644 --- a/platform/features/plot/src/SubPlot.js +++ b/platform/features/plot/src/SubPlot.js @@ -121,9 +121,9 @@ define( // Utility, for map/forEach loops. Index 0 is domain, // index 1 is range. function formatValue(v, i) { - return (i ? - formatter.formatRangeValue : - formatter.formatDomainValue)(v); + return i ? + formatter.formatRangeValue(v) : + formatter.formatDomainValue(v); } this.hoverCoordinates = this.mousePosition && diff --git a/platform/features/plot/src/elements/PlotAxis.js b/platform/features/plot/src/elements/PlotAxis.js index 25795fd347..e2f7809c64 100644 --- a/platform/features/plot/src/elements/PlotAxis.js +++ b/platform/features/plot/src/elements/PlotAxis.js @@ -46,21 +46,9 @@ define( * */ function PlotAxis(axisType, metadatas, defaultValue) { - var keys = {}, - options = []; - - // Look through all metadata objects and assemble a list - // of all possible domain or range options - function buildOptionsForMetadata(m) { - (m[axisType] || []).forEach(function (option) { - if (!keys[option.key]) { - keys[option.key] = true; - options.push(option); - } - }); - } - - (metadatas || []).forEach(buildOptionsForMetadata); + this.axisType = axisType; + this.defaultValue = defaultValue; + this.optionKeys = {}; /** * The currently chosen option for this axis. An @@ -68,7 +56,7 @@ define( * directly form the plot template. * @memberof platform/features/plot.PlotAxis# */ - this.active = options[0] || defaultValue; + this.active = defaultValue; /** * The set of options applicable for this axis; @@ -77,9 +65,71 @@ define( * human-readable names respectively) * @memberof platform/features/plot.PlotAxis# */ - this.options = options; + this.options = []; + + // Initialize options from metadata objects + this.updateMetadata(metadatas); } + + /** + * Update axis options to reflect current metadata. + * @param {TelemetryMetadata[]} metadata objects describing + * applicable telemetry + */ + PlotAxis.prototype.updateMetadata = function (metadatas) { + var axisType = this.axisType, + optionKeys = this.optionKeys, + newOptions = {}, + toAdd = []; + + function isValid(option) { + return option && optionKeys[option.key]; + } + + metadatas.forEach(function (m) { + (m[axisType] || []).forEach(function (option) { + var key = option.key; + if (!optionKeys[key] && !newOptions[key]) { + toAdd.push(option); + } + newOptions[key] = true; + }); + }); + + optionKeys = this.optionKeys = newOptions; + + // General approach here is to avoid changing object + // instances unless something has really changed, since + // Angular is watching; don't want to trigger extra digests. + if (!this.options.every(isValid)) { + this.options = this.options.filter(isValid); + } + + if (toAdd.length > 0) { + this.options = this.options.concat(toAdd); + } + + if (!isValid(this.active)) { + this.active = this.options[0] || this.defaultValue; + } + }; + + /** + * Change the domain/range selection for this axis. If the + * provided `key` is not recognized as an option, no change + * will occur. + * @param {string} key the identifier for the domain/range + */ + PlotAxis.prototype.chooseOption = function (key) { + var self = this; + this.options.forEach(function (option) { + if (option.key === key) { + self.active = option; + } + }); + }; + return PlotAxis; } diff --git a/platform/features/plot/src/elements/PlotTelemetryFormatter.js b/platform/features/plot/src/elements/PlotTelemetryFormatter.js new file mode 100644 index 0000000000..de2a86c4a1 --- /dev/null +++ b/platform/features/plot/src/elements/PlotTelemetryFormatter.js @@ -0,0 +1,72 @@ +/***************************************************************************** + * 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'; + + /** + * Wraps a `TelemetryFormatter` to provide formats for domain and + * range values; provides a single place to track domain/range + * formats within a plot, allowing other plot elements to simply + * request that values be formatted. + * @constructor + * @memberof platform/features/plot + * @implements {platform/telemetry.TelemetryFormatter} + * @param {TelemetryFormatter} telemetryFormatter the formatter + * to wrap. + */ + function PlotTelemetryFormatter(telemetryFormatter) { + this.telemetryFormatter = telemetryFormatter; + } + + /** + * Specify the format to use for domain values. + * @param {string} key the format's identifier + */ + PlotTelemetryFormatter.prototype.setDomainFormat = function (key) { + this.domainFormat = key; + }; + + /** + * Specify the format to use for range values. + * @param {string} key the format's identifier + */ + PlotTelemetryFormatter.prototype.setRangeFormat = function (key) { + this.rangeFormat = key; + }; + + PlotTelemetryFormatter.prototype.formatDomainValue = function (value) { + return this.telemetryFormatter + .formatDomainValue(value, this.domainFormat); + }; + + PlotTelemetryFormatter.prototype.formatRangeValue = function (value) { + return this.telemetryFormatter + .formatRangeValue(value, this.rangeFormat); + }; + + return PlotTelemetryFormatter; + } +); diff --git a/platform/features/plot/src/elements/PlotTickGenerator.js b/platform/features/plot/src/elements/PlotTickGenerator.js index f759b6bcd6..8fa957fae7 100644 --- a/platform/features/plot/src/elements/PlotTickGenerator.js +++ b/platform/features/plot/src/elements/PlotTickGenerator.js @@ -43,6 +43,14 @@ define( this.formatter = formatter; } + // For phantomjs compatibility, for headless testing + // (Function.prototype.bind unsupported) + function bind(fn, thisObj) { + return fn.bind ? fn.bind(thisObj) : function () { + return fn.apply(thisObj, arguments); + }; + } + // Generate ticks; interpolate from start up to // start + span in count steps, using the provided // formatter to represent each value. @@ -72,7 +80,7 @@ define( panZoom.origin[0], panZoom.dimensions[0], count, - this.formatter.formatDomainValue + bind(this.formatter.formatDomainValue, this.formatter) ); }; @@ -87,7 +95,7 @@ define( panZoom.origin[1], panZoom.dimensions[1], count, - this.formatter.formatRangeValue + bind(this.formatter.formatRangeValue, this.formatter) ); }; diff --git a/platform/features/plot/test/PlotControllerSpec.js b/platform/features/plot/test/PlotControllerSpec.js index dcae177920..addbdf5032 100644 --- a/platform/features/plot/test/PlotControllerSpec.js +++ b/platform/features/plot/test/PlotControllerSpec.js @@ -169,8 +169,9 @@ define( mockDomainObject ]); - // Make an object available + // Make an object available; invoke handler's callback mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + mockHandler.handle.mostRecentCall.args[1](); expect(controller.getModeOptions().length).toEqual(1); @@ -181,8 +182,9 @@ define( mockDomainObject ]); - // Make an object available + // Make an object available; invoke handler's callback mockScope.$watch.mostRecentCall.args[1](mockDomainObject); + mockHandler.handle.mostRecentCall.args[1](); expect(controller.getModeOptions().length).toEqual(2); }); diff --git a/platform/features/plot/test/elements/PlotAxisSpec.js b/platform/features/plot/test/elements/PlotAxisSpec.js index cd4a82b3b9..f06f0c69cc 100644 --- a/platform/features/plot/test/elements/PlotAxisSpec.js +++ b/platform/features/plot/test/elements/PlotAxisSpec.js @@ -30,7 +30,12 @@ define( "use strict"; describe("A plot axis", function () { - var testMetadatas = [ + var testMetadatas, + testDefault, + axis; + + beforeEach(function () { + testMetadatas = [ { tests: [ { key: "t0", name: "T0" }, @@ -52,13 +57,14 @@ define( { key: "t6", name: "T6" } ] } - ], - testDefault = { key: "test", name: "Test" }, - controller = new PlotAxis("tests", testMetadatas, testDefault); + ]; + testDefault = { key: "test", name: "Test" }; + axis = new PlotAxis("tests", testMetadatas, testDefault); + }); it("pulls out a list of domain or range options", function () { // Should have filtered out duplicates, etc - expect(controller.options).toEqual([ + expect(axis.options).toEqual([ { key: "t0", name: "T0" }, { key: "t1", name: "T1" }, { key: "t2", name: "T2" }, @@ -70,7 +76,7 @@ define( }); it("chooses the first option as a default", function () { - expect(controller.active).toEqual({ key: "t0", name: "T0" }); + expect(axis.active).toEqual({ key: "t0", name: "T0" }); }); it("falls back to a provided default if no options are present", function () { @@ -78,6 +84,26 @@ define( .toEqual(testDefault); }); + it("allows options to be chosen by key", function () { + axis.chooseOption("t3"); + expect(axis.active).toEqual({ key: "t3", name: "T3" }); + }); + + it("reflects changes to applicable metadata", function () { + axis.updateMetadata([ testMetadatas[1] ]); + expect(axis.options).toEqual([ + { key: "t0", name: "T0" }, + { key: "t2", name: "T2" } + ]); + }); + + it("returns the same array instance for unchanged metadata", function () { + // ...to avoid triggering extra digest cycles. + var oldInstance = axis.options; + axis.updateMetadata(testMetadatas); + expect(axis.options).toBe(oldInstance); + }); + }); } -); \ No newline at end of file +); diff --git a/platform/features/plot/test/elements/PlotTelemetryFormatterSpec.js b/platform/features/plot/test/elements/PlotTelemetryFormatterSpec.js new file mode 100644 index 0000000000..0031a0c813 --- /dev/null +++ b/platform/features/plot/test/elements/PlotTelemetryFormatterSpec.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,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/elements/PlotTelemetryFormatter"], + function (PlotTelemetryFormatter) { + 'use strict'; + + describe("The PlotTelemetryFormatter", function () { + var mockFormatter, + formatter; + + beforeEach(function () { + mockFormatter = jasmine.createSpyObj( + 'telemetryFormatter', + ['formatDomainValue', 'formatRangeValue'] + ); + formatter = new PlotTelemetryFormatter(mockFormatter); + }); + + describe("using domain & range format keys", function () { + var rangeFormat = "someRangeFormat", + domainFormat = "someDomainFormat"; + + beforeEach(function () { + formatter.setRangeFormat(rangeFormat); + formatter.setDomainFormat(domainFormat); + }); + + it("includes format in formatDomainValue calls", function () { + mockFormatter.formatDomainValue.andReturn("formatted!"); + expect(formatter.formatDomainValue(12321)) + .toEqual("formatted!"); + expect(mockFormatter.formatDomainValue) + .toHaveBeenCalledWith(12321, domainFormat); + }); + + it("includes format in formatRangeValue calls", function () { + mockFormatter.formatRangeValue.andReturn("formatted!"); + expect(formatter.formatRangeValue(12321)) + .toEqual("formatted!"); + expect(mockFormatter.formatRangeValue) + .toHaveBeenCalledWith(12321, rangeFormat); + }); + + }); + + }); + } +); diff --git a/platform/features/plot/test/suite.json b/platform/features/plot/test/suite.json index 323d53b6b3..92dfcb1e8a 100644 --- a/platform/features/plot/test/suite.json +++ b/platform/features/plot/test/suite.json @@ -14,6 +14,7 @@ "elements/PlotPosition", "elements/PlotPreparer", "elements/PlotSeriesWindow", + "elements/PlotTelemetryFormatter", "elements/PlotTickGenerator", "elements/PlotUpdater", "modes/PlotModeOptions", diff --git a/platform/features/scrolling/src/DomainColumn.js b/platform/features/scrolling/src/DomainColumn.js index a55b4001d5..3824098118 100644 --- a/platform/features/scrolling/src/DomainColumn.js +++ b/platform/features/scrolling/src/DomainColumn.js @@ -54,7 +54,8 @@ define( DomainColumn.prototype.getValue = function (domainObject, datum) { return { text: this.telemetryFormatter.formatDomainValue( - datum[this.domainMetadata.key] + datum[this.domainMetadata.key], + this.domainMetadata.format ) }; }; diff --git a/platform/telemetry/bundle.json b/platform/telemetry/bundle.json index 69b32b54c7..d264a1d6c0 100644 --- a/platform/telemetry/bundle.json +++ b/platform/telemetry/bundle.json @@ -37,7 +37,8 @@ "services": [ { "key": "telemetryFormatter", - "implementation": "TelemetryFormatter.js" + "implementation": "TelemetryFormatter.js", + "depends": [ "formatService", "DEFAULT_TIME_FORMAT" ] }, { "key": "telemetrySubscriber", @@ -63,4 +64,4 @@ } ] } -} \ No newline at end of file +} diff --git a/platform/telemetry/src/TelemetryCapability.js b/platform/telemetry/src/TelemetryCapability.js index 1fbd12a691..d89b3cd3bf 100644 --- a/platform/telemetry/src/TelemetryCapability.js +++ b/platform/telemetry/src/TelemetryCapability.js @@ -36,6 +36,64 @@ define( getRangeValue: ZERO }; + /** + * Provides metadata about telemetry associated with a + * given domain object. + * + * @typedef TelemetryMetadata + * @property {string} source the machine-readable identifier for + * the source of telemetry data for this object; used by + * {@link TelemetryService} implementations to determine + * whether or not they provide data for this object. + * @property {string} key the machine-readable identifier for + * telemetry data associated with this specific object, + * within that `source`. + * @property {TelemetryDomainMetadata[]} domains supported domain + * options for telemetry data associated with this object, + * to use in interpreting a {@link TelemetrySeries} + * @property {TelemetryRangeMetadata[]} ranges supported range + * options for telemetry data associated with this object, + * to use in interpreting a {@link TelemetrySeries} + */ + + /** + * Provides metadata about range options within a telemetry series. + * Range options describe distinct properties within any given datum + * of a telemetry series; for instance, a telemetry series containing + * both raw and uncalibrated values may provide separate ranges for + * each. + * + * @typedef TelemetryRangeMetadata + * @property {string} key machine-readable identifier for this range + * @property {string} name human-readable name for this range + * @property {string} [units] human-readable units for this range + * @property {string} [format] data format for this range; usually, + * one of `number`, or `string`. If `undefined`, + * should presume to be a `number`. Custom formats + * may be indicated here. + */ + + /** + * Provides metadata about domain options within a telemetry series. + * Domain options describe distinct properties within any given datum + * of a telemtry series; for instance, a telemetry series containing + * both spacecraft event time and earth received times may provide + * separate domains for each. + * + * Domains are typically used to represent timestamps in a telemetry + * series, but more generally may express any property which will + * have unique values for each datum in a series. It is this property + * which makes domains distinct from ranges, as it makes these values + * appropriate and meaningful for use to sort and bound a series. + * + * @typedef TelemetryDomainMetadata + * @property {string} key machine-readable identifier for this range + * @property {string} name human-readable name for this range + * @property {string} [system] machine-readable identifier for the + * time/date system associated with this domain; + * used by {@link DateService} + */ + /** * A telemetry capability provides a means of requesting telemetry * for a specific object, and for unwrapping the response (to get diff --git a/platform/telemetry/src/TelemetryFormatter.js b/platform/telemetry/src/TelemetryFormatter.js index bbd4cf100c..dd434d4ac3 100644 --- a/platform/telemetry/src/TelemetryFormatter.js +++ b/platform/telemetry/src/TelemetryFormatter.js @@ -22,14 +22,13 @@ /*global define,moment*/ define( - ['moment'], - function (moment) { + [], + function () { "use strict"; // Date format to use for domain values; in particular, // use day-of-year instead of month/day - var DATE_FORMAT = "YYYY-DDD HH:mm:ss", - VALUE_FORMAT_DIGITS = 3; + var VALUE_FORMAT_DIGITS = 3; /** * The TelemetryFormatter is responsible for formatting (as text @@ -37,22 +36,31 @@ define( * the range (usually value) of a data series. * @memberof platform/telemetry * @constructor + * @param {FormatService} formatService the service to user to format + * domain values + * @param {string} defaultFormatKey the format to request when no + * format has been otherwise specified */ - function TelemetryFormatter() { + function TelemetryFormatter(formatService, defaultFormatKey) { + this.formatService = formatService; + this.defaultFormat = formatService.getFormat(defaultFormatKey); } /** * Format a domain value. - * @param {number} v the domain value; a timestamp + * @param {number} v the domain value; usually, a timestamp * in milliseconds since start of 1970 - * @param {string} [key] the key which identifies the - * domain; if unspecified or unknown, this will - * be treated as a standard timestamp. + * @param {string} [key] a key which identifies the format + * to use * @returns {string} a textual representation of the * data and time, suitable for display. */ TelemetryFormatter.prototype.formatDomainValue = function (v, key) { - return isNaN(v) ? "" : moment.utc(v).format(DATE_FORMAT); + var formatter = (key === undefined) ? + this.defaultFormat : + this.formatService.getFormat(key); + + return isNaN(v) ? "" : formatter.format(v); }; /** diff --git a/platform/telemetry/test/TelemetryFormatterSpec.js b/platform/telemetry/test/TelemetryFormatterSpec.js index 22f1579059..23c7b95fd4 100644 --- a/platform/telemetry/test/TelemetryFormatterSpec.js +++ b/platform/telemetry/test/TelemetryFormatterSpec.js @@ -27,16 +27,35 @@ define( "use strict"; describe("The telemetry formatter", function () { - var formatter; + var mockFormatService, + mockFormat, + formatter; beforeEach(function () { - formatter = new TelemetryFormatter(); + mockFormatService = + jasmine.createSpyObj("formatService", ["getFormat"]); + mockFormat = jasmine.createSpyObj("format", [ + "validate", + "parse", + "format" + ]); + mockFormatService.getFormat.andReturn(mockFormat); + formatter = new TelemetryFormatter(mockFormatService); }); - it("formats domains using YYYY-DDD style", function () { - expect(formatter.formatDomainValue(402513731000)).toEqual( - "1982-276 17:22:11" - ); + it("formats domains using the formatService", function () { + var testValue = 12321, testResult = "some result"; + mockFormat.format.andReturn(testResult); + + expect(formatter.formatDomainValue(testValue)) + .toEqual(testResult); + expect(mockFormat.format).toHaveBeenCalledWith(testValue); + }); + + it("passes format keys to the formatService", function () { + formatter.formatDomainValue(12321, "someKey"); + expect(mockFormatService.getFormat) + .toHaveBeenCalledWith("someKey"); }); it("formats ranges as values", function () { @@ -44,4 +63,4 @@ define( }); }); } -); \ No newline at end of file +);