diff --git a/example/generator/bundle.js b/example/generator/bundle.js index 0eadfad5e9..076a04ac8d 100644 --- a/example/generator/bundle.js +++ b/example/generator/bundle.js @@ -99,7 +99,7 @@ define([ "source": "generator", "domains": [ { - "key": "time", + "key": "utc", "name": "Time" }, { diff --git a/example/msl/bundle.js b/example/msl/bundle.js index 5b8e6070c8..cb848d252d 100644 --- a/example/msl/bundle.js +++ b/example/msl/bundle.js @@ -59,7 +59,7 @@ define([ "domains": [ { "name": "Time", - "key": "timestamp", + "key": "utc", "format": "utc" } ] diff --git a/platform/commonUI/formats/src/UTCTimeFormat.js b/platform/commonUI/formats/src/UTCTimeFormat.js index 3faab7a620..e1366a831b 100644 --- a/platform/commonUI/formats/src/UTCTimeFormat.js +++ b/platform/commonUI/formats/src/UTCTimeFormat.js @@ -95,6 +95,45 @@ define([ })[0][0]; } + /** + * Returns a description of the current range of the time conductor's + * bounds. + * @param timeRange + * @returns {*} + */ + UTCTimeFormat.prototype.timeUnits = function (timeRange) { + var momentified = moment.duration(timeRange); + + return [ + ["Decades", function (r) { + return r.years() > 15; + }], + ["Years", function (r) { + return r.years() > 1; + }], + ["Months", function (r) { + return r.years() === 1 || r.months() > 1; + }], + ["Days", function (r) { + return r.months() === 1 || r.days() > 1; + }], + ["Hours", function (r) { + return r.days() === 1 || r.hours() > 1; + }], + ["Minutes", function (r) { + return r.hours() === 1 || r.minutes() > 1; + }], + ["Seconds", function (r) { + return r.minutes() === 1 || r.seconds() > 1; + }], + ["Milliseconds", function (r) { + return true; + }] + ].filter(function (row) { + return row[1](momentified); + })[0][0]; + }; + /** * * @param value diff --git a/platform/commonUI/formats/src/UTCTimeFormatSpec.js b/platform/commonUI/formats/src/UTCTimeFormatSpec.js index c4111709a3..1c70f85fb0 100644 --- a/platform/commonUI/formats/src/UTCTimeFormatSpec.js +++ b/platform/commonUI/formats/src/UTCTimeFormatSpec.js @@ -58,5 +58,26 @@ define([ expect(format.format(APRIL, scale)).toBe("April"); expect(format.format(TWENTY_SIXTEEN, scale)).toBe("2016"); }); + + it("Returns appropriate time units for a given time span", function () { + var ONE_DAY = 1000 * 60 * 60 * 24; + var FIVE_DAYS = 5 * ONE_DAY; + var FIVE_MONTHS = 60 * ONE_DAY; + + var ONE_YEAR = 365 * ONE_DAY; + var SEVEN_YEARS = 7 * ONE_YEAR; + var TWO_DECADES = 20 * ONE_YEAR; + + //A span of one day should show a zoom label of "Hours" + expect(format.timeUnits(ONE_DAY)).toEqual("Hours"); + //Multiple days should display "Days" + expect(format.timeUnits(FIVE_DAYS)).toEqual("Days"); + expect(format.timeUnits(FIVE_MONTHS)).toEqual("Days"); + //A span of one year should show a zoom level of "Months". + // Multiple years will show "Years" + expect(format.timeUnits(ONE_YEAR)).toEqual("Months"); + expect(format.timeUnits(SEVEN_YEARS)).toEqual("Years"); + expect(format.timeUnits(TWO_DECADES)).toEqual("Decades"); + }); }); }); diff --git a/platform/commonUI/general/res/fonts/symbols/icomoon-project-openmct-symbols-16px.json b/platform/commonUI/general/res/fonts/symbols/icomoon-project-openmct-symbols-16px.json index 986474ad5f..ebcfe8e4c3 100644 --- a/platform/commonUI/general/res/fonts/symbols/icomoon-project-openmct-symbols-16px.json +++ b/platform/commonUI/general/res/fonts/symbols/icomoon-project-openmct-symbols-16px.json @@ -1,8 +1,8 @@ { "metadata": { "name": "openmct-symbols-16px", - "lastOpened": 1479173088107, - "created": 1479173085258 + "lastOpened": 1480112601593, + "created": 1480112580248 }, "iconSets": [ { @@ -564,13 +564,21 @@ "code": 921664, "tempChar": "" }, + { + "order": 120, + "id": 105, + "name": "icon-resync", + "prevSize": 24, + "code": 921655, + "tempChar": "" + }, { "order": 37, "prevSize": 24, "name": "icon-activity", "id": 32, "code": 921856, - "tempChar": "" + "tempChar": "" }, { "order": 36, @@ -578,7 +586,7 @@ "name": "icon-activity-mode", "id": 31, "code": 921857, - "tempChar": "" + "tempChar": "" }, { "order": 52, @@ -586,7 +594,7 @@ "name": "icon-autoflow-tabular", "id": 47, "code": 921858, - "tempChar": "" + "tempChar": "" }, { "order": 55, @@ -594,7 +602,7 @@ "name": "icon-clock", "id": 50, "code": 921859, - "tempChar": "" + "tempChar": "" }, { "order": 58, @@ -602,7 +610,7 @@ "name": "icon-database", "id": 53, "code": 921860, - "tempChar": "" + "tempChar": "" }, { "order": 57, @@ -610,7 +618,7 @@ "name": "icon-database-query", "id": 52, "code": 921861, - "tempChar": "" + "tempChar": "" }, { "order": 17, @@ -618,7 +626,7 @@ "name": "icon-dataset", "id": 12, "code": 921862, - "tempChar": "" + "tempChar": "" }, { "order": 22, @@ -626,7 +634,7 @@ "name": "icon-datatable", "id": 17, "code": 921863, - "tempChar": "" + "tempChar": "" }, { "order": 59, @@ -634,7 +642,7 @@ "name": "icon-dictionary", "id": 54, "code": 921864, - "tempChar": "" + "tempChar": "" }, { "order": 62, @@ -642,7 +650,7 @@ "name": "icon-folder", "id": 57, "code": 921865, - "tempChar": "" + "tempChar": "" }, { "order": 66, @@ -650,7 +658,7 @@ "name": "icon-image", "id": 61, "code": 921872, - "tempChar": "" + "tempChar": "" }, { "order": 68, @@ -658,7 +666,7 @@ "name": "icon-layout", "id": 63, "code": 921873, - "tempChar": "" + "tempChar": "" }, { "order": 77, @@ -666,7 +674,7 @@ "name": "icon-object", "id": 72, "code": 921874, - "tempChar": "" + "tempChar": "" }, { "order": 78, @@ -674,7 +682,7 @@ "name": "icon-object-unknown", "id": 73, "code": 921875, - "tempChar": "" + "tempChar": "" }, { "order": 79, @@ -682,7 +690,7 @@ "name": "icon-packet", "id": 74, "code": 921876, - "tempChar": "" + "tempChar": "" }, { "order": 80, @@ -690,7 +698,7 @@ "name": "icon-page", "id": 75, "code": 921877, - "tempChar": "" + "tempChar": "" }, { "order": 114, @@ -698,7 +706,7 @@ "name": "icon-plot-overlay", "prevSize": 24, "code": 921878, - "tempChar": "" + "tempChar": "" }, { "order": 113, @@ -706,7 +714,7 @@ "name": "icon-plot-stacked", "prevSize": 24, "code": 921879, - "tempChar": "" + "tempChar": "" }, { "order": 10, @@ -714,7 +722,7 @@ "name": "icon-session", "id": 5, "code": 921880, - "tempChar": "" + "tempChar": "" }, { "order": 24, @@ -722,7 +730,7 @@ "name": "icon-tabular", "id": 19, "code": 921881, - "tempChar": "" + "tempChar": "" }, { "order": 7, @@ -730,7 +738,7 @@ "name": "icon-tabular-lad", "id": 2, "code": 921888, - "tempChar": "" + "tempChar": "" }, { "order": 6, @@ -738,7 +746,7 @@ "name": "icon-tabular-lad-set", "id": 1, "code": 921889, - "tempChar": "" + "tempChar": "" }, { "order": 8, @@ -746,7 +754,7 @@ "name": "icon-tabular-realtime", "id": 3, "code": 921890, - "tempChar": "" + "tempChar": "" }, { "order": 23, @@ -754,7 +762,7 @@ "name": "icon-tabular-scrolling", "id": 18, "code": 921891, - "tempChar": "" + "tempChar": "" }, { "order": 112, @@ -762,7 +770,7 @@ "name": "icon-telemetry", "id": 86, "code": 921892, - "tempChar": "" + "tempChar": "" }, { "order": 90, @@ -770,7 +778,7 @@ "name": "icon-telemetry-panel", "id": 85, "code": 921893, - "tempChar": "" + "tempChar": "" }, { "order": 93, @@ -778,7 +786,7 @@ "name": "icon-timeline", "id": 88, "code": 921894, - "tempChar": "" + "tempChar": "" }, { "order": 116, @@ -786,7 +794,7 @@ "name": "icon-timer-v1.5", "prevSize": 24, "code": 921895, - "tempChar": "" + "tempChar": "" }, { "order": 11, @@ -794,7 +802,7 @@ "name": "icon-topic", "id": 6, "code": 921896, - "tempChar": "" + "tempChar": "" }, { "order": 115, @@ -802,7 +810,7 @@ "name": "icon-box-with-dashed-lines", "id": 29, "code": 921897, - "tempChar": "" + "tempChar": "" } ], "metadata": { @@ -1997,7 +2005,7 @@ }, { "paths": [ - "M1024 460.8v-460.8l-175.8 175.8c-85.2-69.6-190.8-107.6-302-107.6-127.6 0-247.6 49.8-338 140s-140 210.4-140 338 49.8 247.6 140 338 210.4 140 338 140 247.6-49.8 338-140c74-74 120.8-167.8 135-269.6h-138.6c-32 155.4-169.8 272.8-334.6 272.8-188.2 0-341.4-153.2-341.4-341.4s153.4-341.2 341.6-341.2c76.8 0 147.6 25.4 204.8 68.2l-187.8 187.8h460.8z" + "M960 432v-432l-164.8 164.8c-79.8-65.2-178.8-100.8-283.2-100.8-119.6 0-232.2 46.6-316.8 131.2s-131.2 197.2-131.2 316.8 46.6 232.2 131.2 316.8c84.6 84.6 197.2 131.2 316.8 131.2s232.2-46.6 316.8-131.2c69.4-69.4 113.2-157.4 126.6-252.8h-130c-29.8 145.8-159 256-313.6 256-176.4 0-320-143.6-320-320s143.8-320 320.2-320c72 0 138.4 23.8 192 64l-176 176h432z" ], "grid": 16, "tags": [ @@ -2144,6 +2152,37 @@ "1161751207457516161751": [] } }, + { + "id": 105, + "paths": [ + "M795.2 164.8c-79.8-65.2-178.8-100.8-283.2-100.8-119.6 0-232.2 46.6-316.8 131.2-69.4 69.4-113.2 157.4-126.6 252.8h130c29.6-145.8 158.8-256 313.4-256 72 0 138.4 23.8 192 64l-176 176h432v-432l-164.8 164.8z", + "M512 832c-72 0-138.4-23.8-192-64l176-176h-432v432l164.8-164.8c79.8 65.2 178.8 100.8 283.2 100.8 119.6 0 232.2-46.6 316.8-131.2 69.4-69.4 113.2-157.4 126.6-252.8h-130c-29.6 145.8-158.8 256-313.4 256z" + ], + "attrs": [ + { + "fill": "rgb(0, 161, 75)" + }, + { + "fill": "rgb(0, 161, 75)" + } + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 16, + "tags": [ + "icon-resync" + ], + "colorPermutations": { + "1161751207457516161751": [ + { + "f": 1 + }, + { + "f": 1 + } + ] + } + }, { "paths": [ "M512 0c-282.8 0-512 229.2-512 512s229.2 512 512 512 512-229.2 512-512-229.2-512-512-512zM832 704l-128 128-192-192-192 192-128-128 192-192-192-192 128-128 192 192 192-192 128 128-192 192 192 192z" diff --git a/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.eot b/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.eot index d22bc7851c..0df32f10c3 100755 Binary files a/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.eot and b/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.eot differ diff --git a/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.svg b/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.svg index 68ac62d103..cf7bc1bb94 100755 --- a/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.svg +++ b/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.svg @@ -65,17 +65,18 @@ - + - - - - + + + + + diff --git a/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.ttf b/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.ttf index e6b00bcbe1..13fc518269 100755 Binary files a/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.ttf and b/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.ttf differ diff --git a/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.woff b/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.woff index 72433dabfc..daa60cb5d8 100755 Binary files a/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.woff and b/platform/commonUI/general/res/fonts/symbols/openmct-symbols-16px.woff differ diff --git a/platform/commonUI/general/res/sass/_constants.scss b/platform/commonUI/general/res/sass/_constants.scss index efa87c6a08..b9c5e9d8f8 100644 --- a/platform/commonUI/general/res/sass/_constants.scss +++ b/platform/commonUI/general/res/sass/_constants.scss @@ -78,7 +78,7 @@ $treeContextTriggerW: 20px; /*************** Tabular */ $tabularHeaderH: 22px; $tabularTdPadLR: $itemPadLR; -$tabularTdPadTB: 3px; +$tabularTdPadTB: 2px; /*************** Imagery */ $imageMainControlBarH: 25px; $imageThumbsD: 120px; @@ -99,7 +99,7 @@ $plotXBarH: 32px; $plotLegendH: 20px; $plotSwatchD: 8px; // 1: Top, 2: right, 3: bottom, 4: left -$plotDisplayArea: ($plotLegendH + $interiorMargin, 0, $plotXBarH + $interiorMargin, $plotYBarW); +$plotDisplayArea: ($plotLegendH + $interiorMargin, 0, $plotXBarH, $plotYBarW); /* min plot height is based on user testing to find minimum useful height */ $plotMinH: 95px; /*************** Bubbles */ diff --git a/platform/commonUI/general/res/sass/_global.scss b/platform/commonUI/general/res/sass/_global.scss index 7f320d9330..46dc656074 100644 --- a/platform/commonUI/general/res/sass/_global.scss +++ b/platform/commonUI/general/res/sass/_global.scss @@ -191,6 +191,19 @@ a.disabled { overflow-y: auto; } +.slidable { + cursor: move; // Fallback + cursor: grab; + cursor: -moz-grab; + cursor: -webkit-grab; + &.horz { + cursor: col-resize; + } + &.vert { + cursor: row-resize; + } +} + .no-margin { margin: 0; } diff --git a/platform/commonUI/general/res/sass/_glyphs.scss b/platform/commonUI/general/res/sass/_glyphs.scss index 977233144d..84e003bb59 100644 --- a/platform/commonUI/general/res/sass/_glyphs.scss +++ b/platform/commonUI/general/res/sass/_glyphs.scss @@ -77,6 +77,7 @@ $glyph-icon-x-in-circle: '\e1036'; $glyph-icon-brightness: '\e1038'; $glyph-icon-contrast: '\e1039'; $glyph-icon-reset: '\e1040'; +$glyph-icon-resync: '\e1037'; $glyph-icon-activity: '\e1100'; $glyph-icon-activity-mode: '\e1101'; $glyph-icon-autoflow-tabular: '\e1102'; @@ -179,6 +180,7 @@ $glyph-icon-box-with-dashed-lines: '\e1129'; .icon-brightness { @include glyph($glyph-icon-brightness); } .icon-contrast { @include glyph($glyph-icon-contrast); } .icon-reset { @include glyph($glyph-icon-reset); } +.icon-resync { @include glyph($glyph-icon-resync); } .icon-activity { @include glyph($glyph-icon-activity); } .icon-activity-mode { @include glyph($glyph-icon-activity-mode); } .icon-autoflow-tabular { @include glyph($glyph-icon-autoflow-tabular); } @@ -215,4 +217,4 @@ $glyph-icon-box-with-dashed-lines: '\e1129'; .icon-eye-open-12px { @include glyph($glyph-icon-eye-open,'symbolsfont-12px'); } .icon-collapse-pane-left-12px { @include glyph($glyph-icon-collapse-pane-left,'symbolsfont-12px'); } .icon-collapse-pane-right-12px { @include glyph($glyph-icon-collapse-pane-right,'symbolsfont-12px'); } -.icon-folder-12px { @include glyph($glyph-icon-folder,'symbolsfont-12px'); } \ No newline at end of file +.icon-folder-12px { @include glyph($glyph-icon-folder,'symbolsfont-12px'); } diff --git a/platform/commonUI/general/res/sass/_mixins.scss b/platform/commonUI/general/res/sass/_mixins.scss index 91720348b9..2f54a6ff96 100644 --- a/platform/commonUI/general/res/sass/_mixins.scss +++ b/platform/commonUI/general/res/sass/_mixins.scss @@ -363,14 +363,13 @@ } } -@mixin webkitProp($name, $val) { - #{$name}: #{$val}; - -webkit-#{$name}: #{$val}; -} - -@mixin webkitVal($name, $val) { - #{$name}: #{$val}; - #{$name}: -webkit-#{$val}; +@mixin cursorGrab() { + cursor: grab; + cursor: -webkit-grab; + &:active { + cursor: grabbing; + cursor: -webkit-grabbing; + } } @mixin verticalCenter { @@ -392,6 +391,14 @@ white-space: nowrap; } +@mixin reverseEllipsis() { + direction: rtl; + unicode-bidi:bidi-override; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + @mixin scrollH($showBar: auto) { overflow-x: $showBar; overflow-y: hidden; diff --git a/platform/commonUI/general/res/sass/controls/_controls.scss b/platform/commonUI/general/res/sass/controls/_controls.scss index e15ae019f5..f74ce22ac3 100644 --- a/platform/commonUI/general/res/sass/controls/_controls.scss +++ b/platform/commonUI/general/res/sass/controls/_controls.scss @@ -661,3 +661,4 @@ body.desktop { background: $scrollbarTrackColorBg; } } + diff --git a/platform/commonUI/general/res/sass/lists/_tabular.scss b/platform/commonUI/general/res/sass/lists/_tabular.scss index d68486547e..91be1c8d75 100644 --- a/platform/commonUI/general/res/sass/lists/_tabular.scss +++ b/platform/commonUI/general/res/sass/lists/_tabular.scss @@ -51,13 +51,9 @@ table { tbody, .tbody { display: table-row-group; - tr, .tr { - &:hover { - background: rgba($colorTabBodyFg, 0.1); - } - } } tr, .tr { + border-top: 1px solid $colorTabBorder; display: table-row; &:first-child .td { border-top: none; @@ -71,11 +67,12 @@ table { } th, .th, td, .td { display: table-cell; + font-size: 0.7rem; } th, .th { border-left: 1px solid $colorTabHeaderBorder; color: $colorTabHeaderFg; - padding: $tabularTdPadLR $tabularTdPadLR; + padding: $tabularTdPadTB $tabularTdPadLR; white-space: nowrap; vertical-align: middle; // This is crucial to hiding f**king 4px height injected by browser by default &:first-child { @@ -99,7 +96,6 @@ table { } } td, .td { - border-bottom: 1px solid $colorTabBorder; min-width: 20px; color: $colorTelemFresh; padding: $tabularTdPadTB $tabularTdPadLR; diff --git a/platform/commonUI/general/res/sass/plots/_plots-main.scss b/platform/commonUI/general/res/sass/plots/_plots-main.scss index 572b5429e3..ee5498fc40 100644 --- a/platform/commonUI/general/res/sass/plots/_plots-main.scss +++ b/platform/commonUI/general/res/sass/plots/_plots-main.scss @@ -46,18 +46,42 @@ } } + .gl-plot-wrapper-display-area-and-x-axis { + // Holds the plot area and the X-axis only + position: absolute; + top: nth($plotDisplayArea, 1); + right: nth($plotDisplayArea, 2); + bottom: 0; + left: nth($plotDisplayArea, 4); + + .gl-plot-display-area { + //@include test(yellow); + @if $colorPlotBg != none { + background-color: $colorPlotBg; + } + position: absolute; + top: 0; + right: 0; + bottom: nth($plotDisplayArea, 3); + left: 0; + cursor: crosshair; + border: 1px solid $colorPlotAreaBorder; + } + + .gl-plot-axis-area.gl-plot-x { + //@include test(green); + top: auto; + right: 0; + bottom: 0; + left: 0; + height: $plotXBarH; + width: auto; + overflow: hidden; + } + } + .gl-plot-axis-area { - //@include test(); position: absolute; - &.gl-plot-x { - top: auto; - right: 0; - bottom: $interiorMargin; - left: $plotYBarW; - height: $plotXBarH; - width: auto; - overflow: hidden; - } &.gl-plot-y { top: $plotLegendH + $interiorMargin; right: auto; @@ -84,19 +108,6 @@ } } - .gl-plot-display-area { - @if $colorPlotBg != none { - background-color: $colorPlotBg; - } - position: absolute; - top: nth($plotDisplayArea, 1); - right: nth($plotDisplayArea, 2); - bottom: nth($plotDisplayArea, 3); - left: nth($plotDisplayArea, 4); - cursor: crosshair; - border: 1px solid $colorPlotAreaBorder; - } - .gl-plot-label, .l-plot-label { color: $colorPlotLabelFg; @@ -265,13 +276,9 @@ .gl-plot-tick, .tick-label { - direction: rtl; - unicode-bidi:bidi-override; + @include reverseEllipsis(); font-size: 0.7rem; position: absolute; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; &.gl-plot-x-tick-label, &.tick-label-x { right: auto; diff --git a/platform/commonUI/themes/espresso/res/sass/_constants.scss b/platform/commonUI/themes/espresso/res/sass/_constants.scss index 4638281975..4e34d335f1 100644 --- a/platform/commonUI/themes/espresso/res/sass/_constants.scss +++ b/platform/commonUI/themes/espresso/res/sass/_constants.scss @@ -62,7 +62,7 @@ $colorCreateBtn: $colorKey; $colorGridLines: rgba(#fff, 0.05); $colorInvokeMenu: #fff; $colorObjHdrTxt: $colorBodyFg; -$colorObjHdrIc: pullForward($colorObjHdrTxt, 20%); +$colorObjHdrIc: lighten($colorObjHdrTxt, 20%); $colorTick: rgba(white, 0.2); $colorSelectableSelectedPrimary: $colorKey; $colorSelectableSelectedSecondary: pushBack($colorSelectableSelectedPrimary, 20%); @@ -158,11 +158,11 @@ $shdwItemText: rgba(black, 0.1) 0 1px 2px; $colorItemBgSelected: $colorKey; // Tabular -$colorTabBorder: pullForward($colorBodyBg, 10%); +$colorTabBorder: pullForward($colorBodyBg, 5%); $colorTabBodyBg: darken($colorBodyBg, 10%); $colorTabBodyFg: lighten($colorTabBodyBg, 40%); -$colorTabHeaderBg: rgba(white, 0.1); // lighten($colorBodyBg, 10%); -$colorTabHeaderFg: $colorBodyFg; //lighten($colorTabHeaderBg, 40%); +$colorTabHeaderBg: rgba(white, 0.1); +$colorTabHeaderFg: $colorBodyFg; $colorTabHeaderBorder: $colorBodyBg; // Plot diff --git a/platform/commonUI/themes/snow/res/sass/_constants.scss b/platform/commonUI/themes/snow/res/sass/_constants.scss index 7ebe58b9cc..cdc9aaa859 100644 --- a/platform/commonUI/themes/snow/res/sass/_constants.scss +++ b/platform/commonUI/themes/snow/res/sass/_constants.scss @@ -62,7 +62,7 @@ $colorCreateBtn: $colorKey; $colorGridLines: rgba(#000, 0.05); $colorInvokeMenu: #fff; $colorObjHdrTxt: $colorBodyFg; -$colorObjHdrIc: pushBack($colorObjHdrTxt, 30%); +$colorObjHdrIc: lighten($colorObjHdrTxt, 30%); $colorTick: rgba(black, 0.2); $colorSelectableSelectedPrimary: $colorKey; $colorSelectableSelectedSecondary: pushBack($colorSelectableSelectedPrimary, 20%); diff --git a/platform/features/conductor-v2/compatibility/bundle.js b/platform/features/conductor-v2/compatibility/bundle.js index b91ab7454c..54876a6918 100644 --- a/platform/features/conductor-v2/compatibility/bundle.js +++ b/platform/features/conductor-v2/compatibility/bundle.js @@ -23,31 +23,20 @@ define([ "./src/ConductorTelemetryDecorator", "./src/ConductorRepresenter", - "./src/ConductorService", 'legacyRegistry' ], function ( ConductorTelemetryDecorator, ConductorRepresenter, - ConductorService, legacyRegistry ) { legacyRegistry.register("platform/features/conductor-v2/compatibility", { "extensions": { - "services": [ - { - "key": "conductorService", - "implementation": ConductorService, - "depends": [ - "timeConductor" - ] - } - ], "representers": [ { "implementation": ConductorRepresenter, "depends": [ - "timeConductor" + "openmct" ] } ], @@ -57,7 +46,7 @@ define([ "provides": "telemetryService", "implementation": ConductorTelemetryDecorator, "depends": [ - "timeConductor" + "openmct" ] } ] diff --git a/platform/features/conductor-v2/compatibility/src/ConductorRepresenter.js b/platform/features/conductor-v2/compatibility/src/ConductorRepresenter.js index 7013906ef9..e267c9d2f4 100644 --- a/platform/features/conductor-v2/compatibility/src/ConductorRepresenter.js +++ b/platform/features/conductor-v2/compatibility/src/ConductorRepresenter.js @@ -37,11 +37,11 @@ define( * @constructor */ function ConductorRepresenter( - timeConductor, + openmct, scope, element ) { - this.conductor = timeConductor; + this.conductor = openmct.conductor; this.scope = scope; this.element = element; diff --git a/platform/features/conductor-v2/compatibility/src/ConductorService.js b/platform/features/conductor-v2/compatibility/src/ConductorService.js deleted file mode 100644 index df2c119793..0000000000 --- a/platform/features/conductor-v2/compatibility/src/ConductorService.js +++ /dev/null @@ -1,72 +0,0 @@ -/***************************************************************************** - * 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. - *****************************************************************************/ - -define([ - -], function ( - -) { - - function Conductor(timeConductorService) { - this.timeConductor = timeConductorService.conductor(); - } - - Conductor.prototype.displayStart = function () { - return this.timeConductor.bounds().start; - }; - - Conductor.prototype.displayEnd = function () { - return this.timeConductor.bounds().end; - }; - - Conductor.prototype.domainOptions = function () { - throw new Error([ - 'domainOptions not implemented in compatibility layer,', - ' you must be using some crazy unknown code' - ].join('')); - }; - - Conductor.prototype.domain = function () { - var system = this.timeConductor.timeSystem(); - return { - key: system.metadata.key, - name: system.metadata.name, - format: system.formats()[0] - }; - }; - - /** - * Small compatibility layer that implements old conductor service by - * wrapping new time conductor. This allows views that previously depended - * directly on the conductor service to continue to do so without - * modification. - */ - function ConductorService(timeConductor) { - this.tc = new Conductor(timeConductor); - } - - ConductorService.prototype.getConductor = function () { - return this.tc; - }; - - return ConductorService; -}); diff --git a/platform/features/conductor-v2/compatibility/src/ConductorTelemetryDecorator.js b/platform/features/conductor-v2/compatibility/src/ConductorTelemetryDecorator.js index 10d8c4b8c1..137da59268 100644 --- a/platform/features/conductor-v2/compatibility/src/ConductorTelemetryDecorator.js +++ b/platform/features/conductor-v2/compatibility/src/ConductorTelemetryDecorator.js @@ -36,8 +36,8 @@ define( * the service which exposes the global time conductor * @param {TelemetryService} telemetryService the decorated service */ - function ConductorTelemetryDecorator(timeConductor, telemetryService) { - this.conductor = timeConductor; + function ConductorTelemetryDecorator(openmct, telemetryService) { + this.conductor = openmct.conductor; this.telemetryService = telemetryService; this.amendRequests = ConductorTelemetryDecorator.prototype.amendRequests.bind(this); diff --git a/platform/features/conductor-v2/conductor/bundle.js b/platform/features/conductor-v2/conductor/bundle.js index e50ecd3793..506c0f52ba 100644 --- a/platform/features/conductor-v2/conductor/bundle.js +++ b/platform/features/conductor-v2/conductor/bundle.js @@ -23,37 +23,39 @@ define([ "./src/ui/TimeConductorViewService", "./src/ui/TimeConductorController", - "./src/TimeConductor", + "./src/ui/ConductorAxisController", + "./src/ui/ConductorTOIController", + "./src/ui/TimeOfInterestController", "./src/ui/MctConductorAxis", "./src/ui/NumberFormat", "text!./res/templates/time-conductor.html", "text!./res/templates/mode-selector/mode-selector.html", "text!./res/templates/mode-selector/mode-menu.html", - 'legacyRegistry' + "text!./res/templates/time-of-interest.html", + "legacyRegistry" ], function ( TimeConductorViewService, TimeConductorController, - TimeConductor, + ConductorAxisController, + ConductorTOIController, + TimeOfInterestController, MCTConductorAxis, NumberFormat, timeConductorTemplate, modeSelectorTemplate, modeMenuTemplate, + timeOfInterest, legacyRegistry ) { legacyRegistry.register("platform/features/conductor-v2/conductor", { "extensions": { "services": [ - { - "key": "timeConductor", - "implementation": TimeConductor - }, { "key": "timeConductorViewService", "implementation": TimeConductorViewService, "depends": [ - "timeConductor", + "openmct", "timeSystems[]" ] } @@ -65,9 +67,29 @@ define([ "depends": [ "$scope", "$window", - "timeConductor", + "openmct", "timeConductorViewService", - "timeSystems[]" + "timeSystems[]", + "formatService" + ] + }, + { + "key": "ConductorTOIController", + "implementation": ConductorTOIController, + "depends": [ + "$scope", + "openmct", + "timeConductorViewService", + "formatService" + ] + }, + { + "key": "TimeOfInterestController", + "implementation": TimeOfInterestController, + "depends": [ + "$scope", + "openmct", + "formatService" ] } ], @@ -76,7 +98,7 @@ define([ "key": "mctConductorAxis", "implementation": MCTConductorAxis, "depends": [ - "timeConductor", + "openmct", "formatService" ] } @@ -103,6 +125,10 @@ define([ { "key": "mode-selector", "template": modeSelectorTemplate + }, + { + "key": "time-of-interest", + "template": timeOfInterest } ], "representations": [ diff --git a/platform/features/conductor-v2/conductor/res/sass/_constants.scss b/platform/features/conductor-v2/conductor/res/sass/_constants.scss index 0bf4c2a024..693576db5c 100644 --- a/platform/features/conductor-v2/conductor/res/sass/_constants.scss +++ b/platform/features/conductor-v2/conductor/res/sass/_constants.scss @@ -1,3 +1,11 @@ -$ueTimeConductorH: (25px, 3px, 20px); +$ueTimeConductorH: (25px, 16px, 20px); // Heights for Ticks, Data Visualization, Controls elements +$ueTimeConductorRtH: (25px, 3px, 20px); // Heights for elements in Real-time mode $timeCondInputTimeSysDefW: 165px; // Default width for datetime value inputs -$timeCondInputDeltaDefW: 60px; // Default width for delta value inputs, typically 00:00:00 \ No newline at end of file +$timeCondInputDeltaDefW: 60px; // Default width for delta value inputs, typically 00:00:00 +$timeCondTOIIconD: 12px; // height and width of icon used for TOI indicator +$timeCondTOIValOffset: 0px; +$ticksBlockerFadeW: 50px; +$toiBlockerFadeW: 10px; +$toiH: 12px; // Needs to be an even number to avoid sub-pixel antialiasing of the vertical line +$toiPad: 4px; +$timeCondAxisLROffset: (($toiH / 2) + $toiPad, ($toiH / 2) + $toiPad); // Margin to left, right of tick axis and vis bar. For paging, use 15, 20px \ No newline at end of file diff --git a/platform/features/conductor-v2/conductor/res/sass/_time-conductor-base.scss b/platform/features/conductor-v2/conductor/res/sass/_time-conductor-base.scss index e20c0ef4cd..629c17f726 100644 --- a/platform/features/conductor-v2/conductor/res/sass/_time-conductor-base.scss +++ b/platform/features/conductor-v2/conductor/res/sass/_time-conductor-base.scss @@ -54,7 +54,7 @@ // Clock hands div[class*="hand"] { $handW: 2px; - $handH: $d * 0.4; //8px; + $handH: $d * 0.4; @include transform(translate(-50%, -50%)); @include animation-iteration-count(infinite); @include animation-timing-function(linear); @@ -125,7 +125,6 @@ } .l-time-conductor-inputs-holder { - $ticksBlockerFadeW: 50px; $iconCalendarW: 16px; $wBgColor: $colorBodyBg; @@ -136,6 +135,7 @@ bottom: 0; left: 0; z-index: 1; + pointer-events: none; .l-time-range-w { // Wraps a datetime text input field height: 100%; @@ -159,6 +159,9 @@ content: 'End'; } } + .l-time-conductor-inputs { + pointer-events: auto; + } input[type="text"] { @include trans-prop-nice(padding, 250ms); } @@ -175,7 +178,7 @@ } .l-time-conductor-inputs-and-ticks { - $c: $colorTimeCondTicks; //$colorTick; + $c: $colorTimeCondTicks; height: $r1H; mct-conductor-axis { display: block; @@ -184,8 +187,9 @@ } .l-axis-holder { height: $r1H; - position: relative; - width: 100%; + position: absolute; + left: nth($timeCondAxisLROffset, 1); + right: nth($timeCondAxisLROffset, 2); svg { text-rendering: geometricPrecision; width: 100%; @@ -208,9 +212,49 @@ } } } - .l-data-visualization { - background: $colorTimeCondDataVisBg; + .l-data-visualization-holder { height: $r2H; + z-index: 2; // Must lift above ticks and inputs + + .l-page-button, + .l-data-visualization { + position: absolute; + top: 0; + bottom: 0; + } + + .l-page-button { + @if nth($timeCondAxisLROffset, 1) + nth($timeCondAxisLROffset, 2) > 30 { + left: 0; + width: nth($timeCondAxisLROffset, 1); + &.align-right { + left: auto; + right: 0; + width: nth($timeCondAxisLROffset, 2); + } + } @else { + // Hide these if the offsets aren't enough + display: none; + } + } + + .l-data-visualization { + background: $colorTimeCondDataVisBg; + left: nth($timeCondAxisLROffset, 1); + right: nth($timeCondAxisLROffset, 2); + &:hover { + .l-toi-holder.hover { + opacity: 1; + } + .l-toi-holder.pinned.active { + opacity: 0.4; + .l-toi-val { + pointer-events: none; + opacity: 0; + } + } + } + } } .l-time-conductor-controls { @@ -219,13 +263,11 @@ .l-time-conductor-zoom-w { @include justify-content(flex-end); .time-conductor-zoom { - display: none; // TEMP per request from Andrew 8/1/16 height: $r3H; min-width: 100px; width: 20%; } .time-conductor-zoom-current-range { - display: none; // TEMP per request from Andrew 8/1/16 color: $colorTick; } } @@ -235,7 +277,9 @@ &.realtime-mode, &.lad-mode { .time-conductor-icon { - &:before { color: $colorTimeCondKeyBg; } + &:before { + color: $colorTimeCondKeyBg; + } div[class*="hand"] { @include animation-name(clock-hands); &:before { @@ -276,13 +320,16 @@ } .l-data-visualization { - background: $colorTimeCondDataVisRtBg !important + background: $colorTimeCondDataVisRtBg !important; } .mode-selector .s-menu-button { $fg: $colorTimeCondKeyFg; @include btnSubtle($bg: $colorTimeCondKeyBg, $bgHov: pullForward($colorTimeCondKeyBg, $ltGamma), $fg: $colorTimeCondKeyFg); - &:before { color: $fg !important; }; + &:before { + color: $fg !important; + } + ; color: $fg !important; } } @@ -298,6 +345,9 @@ .mode-selector .s-menu-button:before { content: $i; } + .l-axis-holder { + @include cursorGrab(); + } } // Realtime mode @@ -344,7 +394,9 @@ /******************************************************************** MOBILE */ @include phoneandtablet { - .l-time-conductor-holder { min-width: 0 !important; } + .l-time-conductor-holder { + min-width: 0 !important; + } .super-menu.mini { width: 200px; height: 100px; diff --git a/platform/features/conductor-v2/conductor/res/sass/_time-of-interest.scss b/platform/features/conductor-v2/conductor/res/sass/_time-of-interest.scss new file mode 100644 index 0000000000..17c2855f35 --- /dev/null +++ b/platform/features/conductor-v2/conductor/res/sass/_time-of-interest.scss @@ -0,0 +1,271 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2016, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT 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 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. + *****************************************************************************/ + +mct-include.l-toi-holder, +.l-toi-holder:after, +.l-toi-holder:before { + display: block; + position: absolute; +} + +mct-include.l-toi-holder { + $blockerFadeW: $toiBlockerFadeW; + @include transform(translateX(-50%)); + color: $toiColorBg; + position: absolute; + top: 0; + bottom: 0; + width: $toiH; + &:not(.pinned) { + display: none; + } + + &.pinned { + display: block; + } + + &:before, + &:after { + // Vertical lines. TC uses both; plot only uses :before + border-left: 1px dashed $toiColorBg; + box-sizing: border-box; + content: ''; + display: block; + left: 50%; + position: absolute; + top: 0; + bottom: 0; + width: 2px; + z-index: 2; + } + + .l-toi { + // Holds buttons and val. Acts as a blocking element. + @include background-image(linear-gradient(90deg, transparent, $toiColorBlocker 10%, $toiColorBlocker 90%, transparent 100%)); + position: absolute; + align-items: center; + box-sizing: content-box; + height: $toiH; + left: $toiPad * -2; + @include transform(translateY(-50%)); top: 50%; + padding: $toiPad; + z-index: 1; + + .l-toi-buttons { + @include trans-prop-nice($props: (width, padding), $dur: 250ms); + border-radius: $controlCr; + box-sizing: content-box; + font-size: $toiH; + height: 100%; + line-height: $toiH; + padding: $toiPad; + overflow: hidden; + white-space: nowrap; + justify-content: space-between; + width: $toiH; + + &:hover { + // Expand and display controls; clock icon changes to resync + background-color: $toiColorBg; + cursor: pointer; + width: 30px; + + .icon-button { + color: rgba($toiColorCtrlFg, 0.5); + opacity: 1; + &:hover { + color: $toiColorCtrlFg; + } + } + + .t-button-resync { + order: 1; + &:before { + content: $glyph-icon-resync; + } + } + .t-button-unpin { + order: 2; + &:hover { + color: $toiColorBgAlert; + } + } + + & + .l-toi-val { + // Dim the value to emphasize the controls + opacity: 0.5; + } + } + } + + .icon-button { + color: $toiColorBg; + } + + .t-button-resync { + @extend .icon-clock; + &:hover { color: $toiColorCtrlFg; } + } + + .t-button-unpin { + @include trans-prop-nice($props: opacity, $dur: 150ms); + @extend .icon-x-in-circle; + float: right; + opacity: 0; + } + } + + .l-toi-val { + display: none; // Hide by default; see .show-val below + } + + // TOI is showing value as well + &.show-val { + .l-toi { + .l-toi-buttons { + order: 1; + &:hover { + margin-right: $interiorMarginSm; + } + } + .l-toi-val { + @include trans-prop-nice($props: opacity, $dur: 150ms); + background-color: $toiColorBg; + border-radius: $controlCr; + box-sizing: content-box; + color: $toiColorFg; + display: inline-block; + font-size: 0.7rem; + font-weight: 400; + height: $toiH; + line-height: $toiH; + order: 2; + padding: 1px 3px; + white-space: nowrap; + } + } + + &.val-to-left { + .l-toi { + left: auto; + right: $toiPad * -2; + + .l-toi-buttons { + order: 2; + &:hover { + .t-button-resync { order: 2; } + .t-button-unpin { order: 1; } + margin-left: $interiorMarginSm; + } + } + + .l-toi-val { + order: 1; + } + } + } + } +} + + +// TOI in tables +.tabular, +table { + tbody, .tbody { + tr, .tr { + &.l-toi-tablerow { + border-top: 1px dashed $toiColorBg; + z-index: 1; + td, .td { + .l-toi-holder { + left: 50% !important; + &:before, + &:after { + display: none; + } + .l-toi { + background: rgba($toiColorBlocker, 0.9); + border-radius: 20%; + height: auto; + padding: $toiPad; + @include transform(translate(-50%, -50%)); + left: 50%; right: auto; top: 0; + .l-toi-buttons { + padding: 1px; + &:hover { + padding: $toiPad; + } + } + } + } + } + } + } + } +} + +// TOI in plots +.gl-plot { + .gl-plot-wrapper-display-area-and-x-axis { + right: nth($plotDisplayArea, 2) + ($toiH / 2) + $toiPad; // Make room for TOI element + + .l-toi-holder { + bottom: nth($plotDisplayArea, 3) - $interiorMargin; + z-index: 3; + + .l-toi { + @include transform(translateY(100%)); + + } + + .l-toi { + top: auto; + bottom: $toiPad; + } + } + } +} + +// TOI in Time Conductor +.l-time-conductor { + .l-toi-holder { + $linesVOffset: 2px; + &:before, + &:after { + // Vertical lines + border-left-style: solid; + height: ((nth($ueTimeConductorH, 2) - $timeCondTOIIconD)/2) + $linesVOffset; + } + + &:before { + @include transform(translate(-50%, $linesVOffset * -1)); + top: 0px; + bottom: auto; + } + + &:after { + @include transform(translate(-50%, $linesVOffset)); + top: auto; + bottom: 0px; + } + } +} diff --git a/platform/features/conductor-v2/conductor/res/sass/time-conductor-espresso.scss b/platform/features/conductor-v2/conductor/res/sass/time-conductor-espresso.scss index a47f4c07a7..03bb41dc7e 100644 --- a/platform/features/conductor-v2/conductor/res/sass/time-conductor-espresso.scss +++ b/platform/features/conductor-v2/conductor/res/sass/time-conductor-espresso.scss @@ -34,6 +34,17 @@ $colorTimeCondTicks: pullForward($colorBodyBg, 30%); $colorTimeCondKeyBg: #4e70dc; $colorTimeCondKeyFg: #fff; -$colorTimeCondDataVisBg: pullForward($colorBodyBg, 10%); +$colorTimeCondDataVisBg: pullForward($colorBodyBg, 5%); $colorTimeCondDataVisRtBg: pushBack($colorTimeCondKeyBg, 10%); -@import "time-conductor-base"; \ No newline at end of file + +// Time of Interest +$toiColorBg: #6b93c6; +$toiColorBlocker: $colorBodyBg; // Color of blocker element beneath the TOI icons +$toiColorFg: #000; // Used by value display +$toiColorCtrlFg: #fff; +$toiColorBgAlert: #cf2a12; // Used by unpin button on hover +$colorTimeCondTOIBg: darken($toiColorBg, 20%); +$colorTimeCondTOIBgHov: $toiColorBg; + +@import "time-conductor-base"; +@import "time-of-interest"; \ No newline at end of file diff --git a/platform/features/conductor-v2/conductor/res/sass/time-conductor-snow.scss b/platform/features/conductor-v2/conductor/res/sass/time-conductor-snow.scss index 626ceb0e51..a927791b5c 100644 --- a/platform/features/conductor-v2/conductor/res/sass/time-conductor-snow.scss +++ b/platform/features/conductor-v2/conductor/res/sass/time-conductor-snow.scss @@ -36,4 +36,15 @@ $colorTimeCondKeyBg: #6178dc; $colorTimeCondKeyFg: #fff; $colorTimeCondDataVisBg: pullForward($colorBodyBg, 10%); $colorTimeCondDataVisRtBg: pushBack($colorTimeCondKeyBg, 30%); -@import "time-conductor-base"; \ No newline at end of file + +// Time of Interest +$toiColorBg: #6b93c6; +$toiColorBlocker: $colorBodyBg; // Color of blocker element beneath the TOI icons +$toiColorFg: #fff; // Used by value display +$toiColorCtrlFg: #fff; +$toiColorBgAlert: #ff9540; // Used by unpin button on hover +$colorTimeCondTOIBg: darken($toiColorBg, 20%); +$colorTimeCondTOIBgHov: $toiColorBg; + +@import "time-conductor-base"; +@import "time-of-interest"; \ No newline at end of file diff --git a/platform/features/conductor-v2/conductor/res/templates/time-conductor.html b/platform/features/conductor-v2/conductor/res/templates/time-conductor.html index 19c86b2b74..0556584760 100644 --- a/platform/features/conductor-v2/conductor/res/templates/time-conductor.html +++ b/platform/features/conductor-v2/conductor/res/templates/time-conductor.html @@ -1,6 +1,7 @@
+ class="holder grows flex-elem l-flex-row l-time-conductor {{modeModel.selectedKey}}-mode {{timeSystemModel.selected.metadata.key}}-time-system" + ng-class="{'status-panning': tcController.panning}">
@@ -13,63 +14,67 @@
- - - - - - - - - - + + + + + + + + - + + + - - - - - - - + - - + + + + + + + + + + + + @@ -78,8 +83,18 @@
- -
+ +
+ +
+ +
+ +
@@ -98,9 +113,18 @@ }"> -
- - +
+ {{currentZoom}} + {{timeUnits}} +
diff --git a/platform/features/conductor-v2/conductor/res/templates/time-of-interest.html b/platform/features/conductor-v2/conductor/res/templates/time-of-interest.html new file mode 100644 index 0000000000..6335b7a103 --- /dev/null +++ b/platform/features/conductor-v2/conductor/res/templates/time-of-interest.html @@ -0,0 +1,12 @@ +
+
+ + + + + {{toi.toiText}} +
+
\ No newline at end of file diff --git a/platform/features/conductor-v2/conductor/src/TimeConductor.js b/platform/features/conductor-v2/conductor/src/TimeConductor.js deleted file mode 100644 index 2c2194b5a0..0000000000 --- a/platform/features/conductor-v2/conductor/src/TimeConductor.js +++ /dev/null @@ -1,179 +0,0 @@ -/***************************************************************************** - * 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. - *****************************************************************************/ - -define(['EventEmitter'], function (EventEmitter) { - - /** - * The public API for setting and querying time conductor state. The - * time conductor is the means by which the temporal bounds of a view - * are controlled. Time-sensitive views will typically respond to - * changes to bounds or other properties of the time conductor and - * update the data displayed based on the time conductor state. - * - * The TimeConductor extends the EventEmitter class. A number of events are - * fired when properties of the time conductor change, which are - * documented below. - * @constructor - */ - function TimeConductor() { - EventEmitter.call(this); - - //The Time System - this.system = undefined; - //The Time Of Interest - this.toi = undefined; - - this.boundsVal = { - start: undefined, - end: undefined - }; - - //Default to fixed mode - this.followMode = false; - } - - TimeConductor.prototype = Object.create(EventEmitter.prototype); - - /** - * Validate the given bounds. This can be used for pre-validation of - * bounds, for example by views validating user inputs. - * @param bounds The start and end time of the conductor. - * @returns {string | true} A validation error, or true if valid - */ - TimeConductor.prototype.validateBounds = function (bounds) { - if ((bounds.start === undefined) || - (bounds.end === undefined) || - isNaN(bounds.start) || - isNaN(bounds.end) - ) { - return "Start and end must be specified as integer values"; - } else if (bounds.start > bounds.end) { - return "Specified start date exceeds end bound"; - } - return true; - }; - - /** - * Get or set the follow mode of the time conductor. In follow mode the - * time conductor ticks, regularly updating the bounds from a timing - * source appropriate to the selected time system and mode of the time - * conductor. - * @fires TimeConductor#follow - * @param {boolean} followMode - * @returns {boolean} - */ - TimeConductor.prototype.follow = function (followMode) { - if (arguments.length > 0) { - this.followMode = followMode; - /** - * @event TimeConductor#follow The TimeConductor has toggled - * into or out of follow mode. - * @property {boolean} followMode true if follow mode is - * enabled, otherwise false. - */ - this.emit('follow', this.followMode); - } - return this.followMode; - }; - - /** - * @typedef {Object} TimeConductorBounds - * @property {number} start The start time displayed by the time conductor in ms since epoch. Epoch determined by current time system - * @property {number} end The end time displayed by the time conductor in ms since epoch. - */ - /** - * Get or set the start and end time of the time conductor. Basic validation - * of bounds is performed. - * - * @param {TimeConductorBounds} newBounds - * @throws {Error} Validation error - * @fires TimeConductor#bounds - * @returns {TimeConductorBounds} - */ - TimeConductor.prototype.bounds = function (newBounds) { - if (arguments.length > 0) { - var validationResult = this.validateBounds(newBounds); - if (validationResult !== true) { - throw new Error(validationResult); - } - //Create a copy to avoid direct mutation of conductor bounds - this.boundsVal = JSON.parse(JSON.stringify(newBounds)); - /** - * @event TimeConductor#bounds The start time, end time, or - * both have been updated - * @property {TimeConductorBounds} bounds - */ - this.emit('bounds', this.boundsVal); - } - //Return a copy to prevent direct mutation of time conductor bounds. - return JSON.parse(JSON.stringify(this.boundsVal)); - }; - - /** - * Get or set the time system of the TimeConductor. Time systems determine - * units, epoch, and other aspects of time representation. When changing - * the time system in use, new valid bounds must also be provided. - * @param {TimeSystem} newTimeSystem - * @param {TimeConductorBounds} bounds - * @fires TimeConductor#timeSystem - * @returns {TimeSystem} The currently applied time system - */ - TimeConductor.prototype.timeSystem = function (newTimeSystem, bounds) { - if (arguments.length >= 2) { - this.system = newTimeSystem; - /** - * @event TimeConductor#timeSystem The time system used by the time - * conductor has changed. A change in Time System will always be - * followed by a bounds event specifying new query bounds - * @property {TimeSystem} The value of the currently applied - * Time System - * */ - this.emit('timeSystem', this.system); - this.bounds(bounds); - } else if (arguments.length === 1) { - throw new Error('Must set bounds when changing time system'); - } - return this.system; - }; - - /** - * Get or set the Time of Interest. The Time of Interest is the temporal - * focus of the current view. It can be manipulated by the user from the - * time conductor or from other views. - * @fires TimeConductor#timeOfInterest - * @param newTOI - * @returns {number} the current time of interest - */ - TimeConductor.prototype.timeOfInterest = function (newTOI) { - if (arguments.length > 0) { - this.toi = newTOI; - /** - * @event TimeConductor#timeOfInterest The Time of Interest has moved. - * @property {number} Current time of interest - */ - this.emit('timeOfInterest', this.toi); - } - return this.toi; - }; - - return TimeConductor; -}); diff --git a/platform/features/conductor-v2/conductor/src/TimeConductorSpec.js b/platform/features/conductor-v2/conductor/src/TimeConductorSpec.js deleted file mode 100644 index 7701f5e9b4..0000000000 --- a/platform/features/conductor-v2/conductor/src/TimeConductorSpec.js +++ /dev/null @@ -1,110 +0,0 @@ -/***************************************************************************** - * 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. - *****************************************************************************/ - -define(['./TimeConductor'], function (TimeConductor) { - describe("The Time Conductor", function () { - var tc, - timeSystem, - bounds, - eventListener, - toi, - follow; - - beforeEach(function () { - tc = new TimeConductor(); - timeSystem = {}; - bounds = {start: 0, end: 0}; - eventListener = jasmine.createSpy("eventListener"); - toi = 111; - follow = true; - }); - - it("Supports setting and querying of time of interest and and follow mode", function () { - expect(tc.timeOfInterest()).not.toBe(toi); - tc.timeOfInterest(toi); - expect(tc.timeOfInterest()).toBe(toi); - - expect(tc.follow()).not.toBe(follow); - tc.follow(follow); - expect(tc.follow()).toBe(follow); - }); - - it("Allows setting of valid bounds", function () { - bounds = {start: 0, end: 1}; - expect(tc.bounds()).not.toEqual(bounds); - expect(tc.bounds.bind(tc, bounds)).not.toThrow(); - expect(tc.bounds()).toEqual(bounds); - }); - - it("Disallows setting of invalid bounds", function () { - bounds = {start: 1, end: 0}; - expect(tc.bounds()).not.toEqual(bounds); - expect(tc.bounds.bind(tc, bounds)).toThrow(); - expect(tc.bounds()).not.toEqual(bounds); - - bounds = {start: 1}; - expect(tc.bounds()).not.toEqual(bounds); - expect(tc.bounds.bind(tc, bounds)).toThrow(); - expect(tc.bounds()).not.toEqual(bounds); - }); - - it("Allows setting of time system with bounds", function () { - expect(tc.timeSystem()).not.toBe(timeSystem); - expect(tc.timeSystem.bind(tc, timeSystem, bounds)).not.toThrow(); - expect(tc.timeSystem()).toBe(timeSystem); - }); - - it("Disallows setting of time system without bounds", function () { - expect(tc.timeSystem()).not.toBe(timeSystem); - expect(tc.timeSystem.bind(tc, timeSystem)).toThrow(); - expect(tc.timeSystem()).not.toBe(timeSystem); - }); - - it("Emits an event when time system changes", function () { - expect(eventListener).not.toHaveBeenCalled(); - tc.on("timeSystem", eventListener); - tc.timeSystem(timeSystem, bounds); - expect(eventListener).toHaveBeenCalledWith(timeSystem); - }); - - it("Emits an event when time of interest changes", function () { - expect(eventListener).not.toHaveBeenCalled(); - tc.on("timeOfInterest", eventListener); - tc.timeOfInterest(toi); - expect(eventListener).toHaveBeenCalledWith(toi); - }); - - it("Emits an event when bounds change", function () { - expect(eventListener).not.toHaveBeenCalled(); - tc.on("bounds", eventListener); - tc.bounds(bounds); - expect(eventListener).toHaveBeenCalledWith(bounds); - }); - - it("Emits an event when follow mode changes", function () { - expect(eventListener).not.toHaveBeenCalled(); - tc.on("follow", eventListener); - tc.follow(follow); - expect(eventListener).toHaveBeenCalledWith(follow); - }); - }); -}); diff --git a/platform/features/conductor-v2/conductor/src/timeSystems/TimeSystem.js b/platform/features/conductor-v2/conductor/src/timeSystems/TimeSystem.js index 652ea9ed0f..1a945f4c80 100644 --- a/platform/features/conductor-v2/conductor/src/timeSystems/TimeSystem.js +++ b/platform/features/conductor-v2/conductor/src/timeSystems/TimeSystem.js @@ -73,8 +73,22 @@ define([], function () { throw new Error('Not implemented'); }; - /** + /*** * + * @typedef {object} TimeConductorZoom + * @property {number} min The largest time span that the time + * conductor can display in this time system. ie. the span of the time + * conductor in its most zoomed out state. + * @property {number} max The smallest time span that the time + * conductor can display in this time system. ie. the span of the time + * conductor bounds in its most zoomed in state. + * + * @typedef {object} TimeSystemDefault + * @property {TimeConductorDeltas} deltas The deltas to apply by default + * when this time system is active. Applies to real-time modes only + * @property {TimeConductorBounds} bounds The bounds to apply by default + * when this time system is active + * @property {TimeConductorZoom} zoom Default min and max zoom levels * @returns {TimeSystemDefault[]} At least one set of default values for * this time system. */ diff --git a/platform/features/conductor-v2/conductor/src/ui/ConductorAxisController.js b/platform/features/conductor-v2/conductor/src/ui/ConductorAxisController.js new file mode 100644 index 0000000000..b99eaa0e6e --- /dev/null +++ b/platform/features/conductor-v2/conductor/src/ui/ConductorAxisController.js @@ -0,0 +1,240 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +define( + [ + "d3" + ], + function (d3) { + var PADDING = 1; + + /** + * Controller that renders a horizontal time scale spanning the current bounds defined in the time conductor. + * Used by the mct-conductor-axis directive + * @constructor + */ + function ConductorAxisController(openmct, formatService, conductorViewService, scope, element) { + // Dependencies + this.formatService = formatService; + this.conductor = openmct.conductor; + this.conductorViewService = conductorViewService; + + this.scope = scope; + this.initialized = false; + + this.bounds = this.conductor.bounds(); + this.timeSystem = this.conductor.timeSystem(); + + //Bind all class functions to 'this' + Object.keys(ConductorAxisController.prototype).filter(function (key) { + return typeof ConductorAxisController.prototype[key] === 'function'; + }).forEach(function (key) { + this[key] = ConductorAxisController.prototype[key].bind(this); + }.bind(this)); + + this.initialize(element); + } + + /** + * @private + */ + ConductorAxisController.prototype.destroy = function () { + this.conductor.off('timeSystem', this.changeTimeSystem); + this.conductor.off('bounds', this.changeBounds); + this.conductorViewService.off("zoom", this.onZoom); + this.conductorViewService.off("zoom-stop", this.onZoomStop); + }; + + /** + * @private + */ + ConductorAxisController.prototype.initialize = function (element) { + this.target = element[0].firstChild; + var height = this.target.offsetHeight; + var vis = d3.select(this.target) + .append("svg:svg") + .attr("width", "100%") + .attr("height", height); + + this.xAxis = d3.axisTop(); + + // draw x axis with labels and move to the bottom of the chart area + this.axisElement = vis.append("g") + .attr("transform", "translate(0," + (height - PADDING) + ")"); + + if (this.timeSystem !== undefined) { + this.changeTimeSystem(this.timeSystem); + this.setScale(); + } + + //Respond to changes in conductor + this.conductor.on("timeSystem", this.changeTimeSystem); + this.conductor.on("bounds", this.changeBounds); + + this.scope.$on("$destroy", this.destroy); + + this.conductorViewService.on("zoom", this.onZoom); + this.conductorViewService.on("zoom-stop", this.onZoomStop); + }; + + /** + * @private + */ + ConductorAxisController.prototype.changeBounds = function (bounds) { + this.bounds = bounds; + if (!this.zooming) { + this.setScale(); + } + }; + + /** + * Set the scale of the axis, based on current conductor bounds. + */ + ConductorAxisController.prototype.setScale = function () { + var width = this.target.offsetWidth; + var timeSystem = this.conductor.timeSystem(); + var bounds = this.bounds; + + if (timeSystem.isUTCBased()) { + this.xScale = this.xScale || d3.scaleUtc(); + this.xScale.domain([new Date(bounds.start), new Date(bounds.end)]); + } else { + this.xScale = this.xScale || d3.scaleLinear(); + this.xScale.domain([bounds.start, bounds.end]); + } + + this.xAxis.scale(this.xScale); + + this.xScale.range([PADDING, width - PADDING * 2]); + this.axisElement.call(this.xAxis); + + this.msPerPixel = (bounds.end - bounds.start) / width; + }; + + /** + * When the time system changes, update the scale and formatter used for showing times. + * @param timeSystem + */ + ConductorAxisController.prototype.changeTimeSystem = function (timeSystem) { + this.timeSystem = timeSystem; + + var key = timeSystem.formats()[0]; + if (key !== undefined) { + var format = this.formatService.getFormat(key); + var bounds = this.conductor.bounds(); + + //The D3 scale used depends on the type of time system as d3 + // supports UTC out of the box. + if (timeSystem.isUTCBased()) { + this.xScale = d3.scaleUtc(); + } else { + this.xScale = d3.scaleLinear(); + } + + this.xAxis.scale(this.xScale); + + //Define a custom format function + this.xAxis.tickFormat(function (tickValue) { + // Normalize date representations to numbers + if (tickValue instanceof Date) { + tickValue = tickValue.getTime(); + } + return format.format(tickValue, { + min: bounds.start, + max: bounds.end + }); + }); + this.axisElement.call(this.xAxis); + } + }; + + /** + * The user has stopped panning the time conductor scale element. + * @event panStop + */ + /** + * Called on release of mouse button after dragging the scale left or right. + * @fires platform.features.conductor.ConductorAxisController~panStop + */ + ConductorAxisController.prototype.panStop = function () { + //resync view bounds with time conductor bounds + this.conductorViewService.emit("pan-stop"); + this.conductor.bounds(this.bounds); + }; + + /** + * Rescales the axis when the user zooms. Although zoom ultimately results in a bounds change once the user + * releases the zoom slider, dragging the slider will not immediately change the conductor bounds. It will + * however immediately update the scale and the bounds displayed in the UI. + * @private + * @param {ZoomLevel} + */ + ConductorAxisController.prototype.onZoom = function (zoom) { + this.zooming = true; + + this.bounds = zoom.bounds; + this.setScale(); + }; + + /** + * @private + */ + ConductorAxisController.prototype.onZoomStop = function (zoom) { + this.zooming = false; + }; + + /** + * @event platform.features.conductor.ConductorAxisController~pan + * Fired when the time conductor is panned + */ + /** + * Initiate panning via a click + drag gesture on the time conductor + * scale. Panning triggers a "pan" event + * @param {number} delta the offset from the original click event + * @see TimeConductorViewService# + * @fires platform.features.conductor.ConductorAxisController~pan + */ + ConductorAxisController.prototype.pan = function (delta) { + if (!this.conductor.follow()) { + var deltaInMs = delta[0] * this.msPerPixel; + var bounds = this.conductor.bounds(); + var start = Math.floor((bounds.start - deltaInMs) / 1000) * 1000; + var end = Math.floor((bounds.end - deltaInMs) / 1000) * 1000; + this.bounds = { + start: start, + end: end + }; + this.setScale(); + this.conductorViewService.emit("pan", this.bounds); + } + }; + + /** + * Invoked on element resize. Will rebuild the scale based on the new dimensions of the element. + */ + ConductorAxisController.prototype.resize = function () { + this.setScale(); + }; + + return ConductorAxisController; + } +); diff --git a/platform/features/conductor-v2/conductor/src/ui/MctConductorAxisSpec.js b/platform/features/conductor-v2/conductor/src/ui/ConductorAxisControllerSpec.js similarity index 51% rename from platform/features/conductor-v2/conductor/src/ui/MctConductorAxisSpec.js rename to platform/features/conductor-v2/conductor/src/ui/ConductorAxisControllerSpec.js index 0fe3c4cf3f..2dbbb42c4b 100644 --- a/platform/features/conductor-v2/conductor/src/ui/MctConductorAxisSpec.js +++ b/platform/features/conductor-v2/conductor/src/ui/ConductorAxisControllerSpec.js @@ -20,16 +20,33 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -define(['./MctConductorAxis'], function (MctConductorAxis) { - describe("The MctConductorAxis directive", function () { - var directive, +define([ + './ConductorAxisController', + 'zepto', + 'd3' +], function ( + ConductorAxisController, + $, + d3 +) { + describe("The ConductorAxisController", function () { + var controller, mockConductor, + mockConductorViewService, mockFormatService, mockScope, mockElement, mockTarget, mockBounds, - d3; + element, + mockTimeSystem, + mockFormat; + + function getCallback(target, name) { + return target.calls.filter(function (call) { + return call.args[0] === name; + })[0].args[1]; + } beforeEach(function () { mockScope = jasmine.createSpyObj("scope", [ @@ -52,7 +69,8 @@ define(['./MctConductorAxis'], function (MctConductorAxis) { "timeSystem", "bounds", "on", - "off" + "off", + "follow" ]); mockConductor.bounds.andReturn(mockBounds); @@ -60,87 +78,101 @@ define(['./MctConductorAxis'], function (MctConductorAxis) { "getFormat" ]); - var d3Functions = [ - "scale", - "scaleUtc", - "scaleLinear", - "select", - "append", - "attr", - "axisTop", - "call", - "tickFormat", - "domain", - "range" - ]; - d3 = jasmine.createSpyObj("d3", d3Functions); - d3Functions.forEach(function (func) { - d3[func].andReturn(d3); - }); + mockConductorViewService = jasmine.createSpyObj("conductorViewService", [ + "on", + "off", + "emit" + ]); - directive = new MctConductorAxis(mockConductor, mockFormatService); - directive.d3 = d3; - directive.link(mockScope, [mockElement]); + spyOn(d3, 'scaleUtc').andCallThrough(); + spyOn(d3, 'scaleLinear').andCallThrough(); + + element = $('
'); + $(document).find('body').append(element); + controller = new ConductorAxisController({conductor: mockConductor}, mockFormatService, mockConductorViewService, mockScope, element); + + mockTimeSystem = jasmine.createSpyObj("timeSystem", [ + "formats", + "isUTCBased" + ]); + mockFormat = jasmine.createSpyObj("format", [ + "format" + ]); + + mockTimeSystem.formats.andReturn(["mockFormat"]); + mockFormatService.getFormat.andReturn(mockFormat); + mockConductor.timeSystem.andReturn(mockTimeSystem); + mockTimeSystem.isUTCBased.andReturn(false); }); it("listens for changes to time system and bounds", function () { - expect(mockConductor.on).toHaveBeenCalledWith("timeSystem", directive.changeTimeSystem); - expect(mockConductor.on).toHaveBeenCalledWith("bounds", directive.setScale); + expect(mockConductor.on).toHaveBeenCalledWith("timeSystem", controller.changeTimeSystem); + expect(mockConductor.on).toHaveBeenCalledWith("bounds", controller.changeBounds); }); it("on scope destruction, deregisters listeners", function () { - expect(mockScope.$on).toHaveBeenCalledWith("$destroy", directive.destroy); - directive.destroy(); - expect(mockConductor.off).toHaveBeenCalledWith("timeSystem", directive.changeTimeSystem); - expect(mockConductor.off).toHaveBeenCalledWith("bounds", directive.setScale); + expect(mockScope.$on).toHaveBeenCalledWith("$destroy", controller.destroy); + controller.destroy(); + expect(mockConductor.off).toHaveBeenCalledWith("timeSystem", controller.changeTimeSystem); + expect(mockConductor.off).toHaveBeenCalledWith("bounds", controller.changeBounds); }); describe("when the time system changes", function () { - var mockTimeSystem; - var mockFormat; - - beforeEach(function () { - mockTimeSystem = jasmine.createSpyObj("timeSystem", [ - "formats", - "isUTCBased" - ]); - mockFormat = jasmine.createSpyObj("format", [ - "format" - ]); - - mockTimeSystem.formats.andReturn(["mockFormat"]); - mockFormatService.getFormat.andReturn(mockFormat); - }); - it("uses a UTC scale for UTC time systems", function () { mockTimeSystem.isUTCBased.andReturn(true); - directive.changeTimeSystem(mockTimeSystem); + controller.changeTimeSystem(mockTimeSystem); + expect(d3.scaleUtc).toHaveBeenCalled(); expect(d3.scaleLinear).not.toHaveBeenCalled(); }); it("uses a linear scale for non-UTC time systems", function () { mockTimeSystem.isUTCBased.andReturn(false); - directive.changeTimeSystem(mockTimeSystem); + controller.changeTimeSystem(mockTimeSystem); expect(d3.scaleLinear).toHaveBeenCalled(); expect(d3.scaleUtc).not.toHaveBeenCalled(); }); it("sets axis domain to time conductor bounds", function () { mockTimeSystem.isUTCBased.andReturn(false); - mockConductor.timeSystem.andReturn(mockTimeSystem); - - directive.setScale(); - expect(d3.domain).toHaveBeenCalledWith([mockBounds.start, mockBounds.end]); + controller.setScale(); + expect(controller.xScale.domain()).toEqual([mockBounds.start, mockBounds.end]); }); it("uses the format specified by the time system to format tick" + " labels", function () { - directive.changeTimeSystem(mockTimeSystem); - expect(d3.tickFormat).toHaveBeenCalled(); - d3.tickFormat.mostRecentCall.args[0](); + controller.changeTimeSystem(mockTimeSystem); expect(mockFormat.format).toHaveBeenCalled(); }); + + it('responds to zoom events', function () { + expect(mockConductorViewService.on).toHaveBeenCalledWith("zoom", controller.onZoom); + var cb = getCallback(mockConductorViewService.on, "zoom"); + spyOn(controller, 'setScale').andCallThrough(); + cb({bounds: {start: 0, end: 100}}); + expect(controller.setScale).toHaveBeenCalled(); + }); + + it('adjusts scale on pan', function () { + spyOn(controller, 'setScale').andCallThrough(); + controller.pan(100); + expect(controller.setScale).toHaveBeenCalled(); + }); + + it('emits event on pan', function () { + spyOn(controller, 'setScale').andCallThrough(); + controller.pan(100); + expect(mockConductorViewService.emit).toHaveBeenCalledWith("pan", jasmine.any(Object)); + }); + + it('cleans up listeners on destruction', function () { + controller.destroy(); + expect(mockConductor.off).toHaveBeenCalledWith("bounds", controller.changeBounds); + expect(mockConductor.off).toHaveBeenCalledWith("timeSystem", controller.changeTimeSystem); + + expect(mockConductorViewService.off).toHaveBeenCalledWith("zoom", controller.onZoom); + }); + }); }); }); diff --git a/platform/features/conductor-v2/conductor/src/ui/ConductorTOIController.js b/platform/features/conductor-v2/conductor/src/ui/ConductorTOIController.js new file mode 100644 index 0000000000..0308800d55 --- /dev/null +++ b/platform/features/conductor-v2/conductor/src/ui/ConductorTOIController.js @@ -0,0 +1,124 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +define( + ["zepto"], + function ($) { + + /** + * Controller for the Time of Interest indicator in the conductor itself. Sets the horizontal position of the + * TOI indicator based on the current value of the TOI, and the width of the TOI conductor. + * @memberof platform.features.conductor + */ + function ConductorTOIController($scope, openmct, conductorViewService) { + this.conductor = openmct.conductor; + this.conductorViewService = conductorViewService; + + //Bind all class functions to 'this' + Object.keys(ConductorTOIController.prototype).filter(function (key) { + return typeof ConductorTOIController.prototype[key] === 'function'; + }).forEach(function (key) { + this[key] = ConductorTOIController.prototype[key].bind(this); + }.bind(this)); + + this.conductor.on('timeOfInterest', this.changeTimeOfInterest); + this.conductorViewService.on('zoom', this.setOffsetFromZoom); + this.conductorViewService.on('pan', this.setOffsetFromBounds); + + var timeOfInterest = this.conductor.timeOfInterest(); + if (timeOfInterest) { + this.changeTimeOfInterest(timeOfInterest); + } + + $scope.$on('$destroy', this.destroy); + } + + /** + * @private + */ + ConductorTOIController.prototype.destroy = function () { + this.conductor.off('timeOfInterest', this.changeTimeOfInterest); + this.conductorViewService.off('zoom', this.setOffsetFromZoom); + this.conductorViewService.off('pan', this.setOffsetFromBounds); + }; + + /** + * Given some bounds, set horizontal position of TOI indicator based + * on current conductor TOI value. Bounds are provided so that + * ephemeral bounds from zoom and pan events can be used as well + * as current conductor bounds, allowing TOI to be updated in + * realtime during scroll and zoom. + * @param {TimeConductorBounds} bounds + */ + ConductorTOIController.prototype.setOffsetFromBounds = function (bounds) { + var toi = this.conductor.timeOfInterest(); + if (toi !== undefined) { + var offset = toi - bounds.start; + var duration = bounds.end - bounds.start; + this.left = offset / duration * 100; + this.pinned = true; + } else { + this.left = 0; + this.pinned = false; + } + }; + + /** + * @private + */ + ConductorTOIController.prototype.setOffsetFromZoom = function (zoom) { + return this.setOffsetFromBounds(zoom.bounds); + }; + + /** + * Invoked when time of interest changes. Will set the horizontal offset of the TOI indicator. + * @private + */ + ConductorTOIController.prototype.changeTimeOfInterest = function () { + var bounds = this.conductor.bounds(); + if (bounds) { + this.setOffsetFromBounds(bounds); + } + }; + + /** + * On a mouse click event within the TOI element, convert position within element to a time of interest, and + * set the time of interest on the conductor. + * @param e The angular $event object + */ + ConductorTOIController.prototype.setTOIFromPosition = function (e) { + //TOI is set using the alt key modified + primary click + if (e.altKey) { + var element = $(e.currentTarget); + var width = element.width(); + var relativeX = e.pageX - element.offset().left; + var percX = relativeX / width; + var bounds = this.conductor.bounds(); + var timeRange = bounds.end - bounds.start; + + this.conductor.timeOfInterest(timeRange * percX + bounds.start); + } + }; + + return ConductorTOIController; + } +); diff --git a/platform/features/conductor-v2/conductor/src/ui/ConductorTOIControllerSpec.js b/platform/features/conductor-v2/conductor/src/ui/ConductorTOIControllerSpec.js new file mode 100644 index 0000000000..31fb3d6365 --- /dev/null +++ b/platform/features/conductor-v2/conductor/src/ui/ConductorTOIControllerSpec.js @@ -0,0 +1,153 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +define([ + './ConductorTOIController' +], function ( + ConductorTOIController +) { + var mockConductor; + var mockConductorViewService; + var mockScope; + var mockAPI; + var conductorTOIController; + + function getNamedCallback(thing, name) { + return thing.calls.filter(function (call) { + return call.args[0] === name; + }).map(function (call) { + return call.args; + })[0][1]; + } + + describe("The ConductorTOIController", function () { + beforeEach(function () { + mockConductor = jasmine.createSpyObj("conductor", [ + "bounds", + "timeOfInterest", + "on", + "off" + ]); + mockAPI = {conductor: mockConductor}; + + mockConductorViewService = jasmine.createSpyObj("conductorViewService", [ + "on", + "off" + ]); + + mockScope = jasmine.createSpyObj("openMCT", [ + "$on" + ]); + + conductorTOIController = new ConductorTOIController(mockScope, mockAPI, mockConductorViewService); + }); + + it("listens to changes in the time of interest on the conductor", function () { + expect(mockConductor.on).toHaveBeenCalledWith("timeOfInterest", jasmine.any(Function)); + }); + + describe("when responding to changes in the time of interest", function () { + var toiCallback; + beforeEach(function () { + var bounds = { + start: 0, + end: 200 + }; + mockConductor.bounds.andReturn(bounds); + toiCallback = getNamedCallback(mockConductor.on, "timeOfInterest"); + }); + + it("calculates the correct horizontal offset based on bounds and current TOI", function () { + //Expect time of interest position to be 50% of element width + mockConductor.timeOfInterest.andReturn(100); + toiCallback(); + expect(conductorTOIController.left).toBe(50); + + //Expect time of interest position to be 25% of element width + mockConductor.timeOfInterest.andReturn(50); + toiCallback(); + expect(conductorTOIController.left).toBe(25); + + //Expect time of interest position to be 0% of element width + mockConductor.timeOfInterest.andReturn(0); + toiCallback(); + expect(conductorTOIController.left).toBe(0); + + //Expect time of interest position to be 100% of element width + mockConductor.timeOfInterest.andReturn(200); + toiCallback(); + expect(conductorTOIController.left).toBe(100); + }); + + it("renders the TOI indicator visible", function () { + expect(conductorTOIController.pinned).toBeFalsy(); + mockConductor.timeOfInterest.andReturn(100); + toiCallback(); + expect(conductorTOIController.pinned).toBe(true); + }); + }); + + it("responds to zoom events", function () { + var mockZoom = { + bounds: { + start: 500, + end: 1000 + } + }; + expect(mockConductorViewService.on).toHaveBeenCalledWith("zoom", jasmine.any(Function)); + + // Should correspond to horizontal offset of 50% + mockConductor.timeOfInterest.andReturn(750); + var zoomCallback = getNamedCallback(mockConductorViewService.on, "zoom"); + zoomCallback(mockZoom); + expect(conductorTOIController.left).toBe(50); + }); + + it("responds to pan events", function () { + var mockPanBounds = { + start: 1000, + end: 3000 + }; + + expect(mockConductorViewService.on).toHaveBeenCalledWith("pan", jasmine.any(Function)); + + // Should correspond to horizontal offset of 25% + mockConductor.timeOfInterest.andReturn(1500); + var panCallback = getNamedCallback(mockConductorViewService.on, "pan"); + panCallback(mockPanBounds); + expect(conductorTOIController.left).toBe(25); + }); + + + it("Cleans up all listeners when controller destroyed", function () { + var zoomCB = getNamedCallback(mockConductorViewService.on, "zoom"); + var panCB = getNamedCallback(mockConductorViewService.on, "pan"); + var toiCB = getNamedCallback(mockConductor.on, "timeOfInterest"); + + expect(mockScope.$on).toHaveBeenCalledWith("$destroy", jasmine.any(Function)); + getNamedCallback(mockScope.$on, "$destroy")(); + expect(mockConductorViewService.off).toHaveBeenCalledWith("zoom", zoomCB); + expect(mockConductorViewService.off).toHaveBeenCalledWith("pan", panCB); + expect(mockConductor.off).toHaveBeenCalledWith("timeOfInterest", toiCB); + }); + }); +}); diff --git a/platform/features/conductor-v2/conductor/src/ui/MctConductorAxis.js b/platform/features/conductor-v2/conductor/src/ui/MctConductorAxis.js index 58cb60befc..7484d6b35f 100644 --- a/platform/features/conductor-v2/conductor/src/ui/MctConductorAxis.js +++ b/platform/features/conductor-v2/conductor/src/ui/MctConductorAxis.js @@ -20,127 +20,35 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -define( - [ - "d3" - ], - function (d3) { - var PADDING = 1; - +define(['./ConductorAxisController'], function (ConductorAxisController) { + function MctConductorAxis() { /** * The mct-conductor-axis renders a horizontal axis with regular * labelled 'ticks'. It requires 'start' and 'end' integer values to * be specified as attributes. */ - function MCTConductorAxis(conductor, formatService) { - // Dependencies - this.d3 = d3; - this.conductor = conductor; - this.formatService = formatService; - // Runtime properties (set by 'link' function) - this.target = undefined; - this.xScale = undefined; - this.xAxis = undefined; - this.axisElement = undefined; + return { + controller: [ + 'openmct', + 'formatService', + 'timeConductorViewService', + '$scope', + '$element', + ConductorAxisController + ], + controllerAs: 'axis', - // Angular Directive interface - this.link = this.link.bind(this); - this.restrict = "E"; - this.template = - "
"; - this.priority = 1000; + restrict: 'E', + priority: 1000, - //Bind all class functions to 'this' - Object.keys(MCTConductorAxis.prototype).filter(function (key) { - return typeof MCTConductorAxis.prototype[key] === 'function'; - }).forEach(function (key) { - this[key] = this[key].bind(this); - }.bind(this)); - } - - MCTConductorAxis.prototype.setScale = function () { - var width = this.target.offsetWidth; - var timeSystem = this.conductor.timeSystem(); - var bounds = this.conductor.bounds(); - - if (timeSystem.isUTCBased()) { - this.xScale = this.xScale || this.d3.scaleUtc(); - this.xScale.domain([new Date(bounds.start), new Date(bounds.end)]); - } else { - this.xScale = this.xScale || this.d3.scaleLinear(); - this.xScale.domain([bounds.start, bounds.end]); - } - - this.xScale.range([PADDING, width - PADDING * 2]); - this.axisElement.call(this.xAxis); - }; - - MCTConductorAxis.prototype.changeTimeSystem = function (timeSystem) { - var key = timeSystem.formats()[0]; - if (key !== undefined) { - var format = this.formatService.getFormat(key); - var bounds = this.conductor.bounds(); - - if (timeSystem.isUTCBased()) { - this.xScale = this.d3.scaleUtc(); - } else { - this.xScale = this.d3.scaleLinear(); - } - - this.xAxis.scale(this.xScale); - //Define a custom format function - this.xAxis.tickFormat(function (tickValue) { - // Normalize date representations to numbers - if (tickValue instanceof Date) { - tickValue = tickValue.getTime(); - } - return format.format(tickValue, { - min: bounds.start, - max: bounds.end - }); - }); - this.axisElement.call(this.xAxis); - } - }; - - MCTConductorAxis.prototype.destroy = function () { - this.conductor.off('timeSystem', this.changeTimeSystem); - this.conductor.off('bounds', this.setScale); - }; - - MCTConductorAxis.prototype.link = function (scope, element) { - var conductor = this.conductor; - this.target = element[0].firstChild; - var height = this.target.offsetHeight; - var vis = this.d3.select(this.target) - .append('svg:svg') - .attr('width', '100%') - .attr('height', height); - - this.xAxis = this.d3.axisTop(); - - // draw x axis with labels and move to the bottom of the chart area - this.axisElement = vis.append("g") - .attr("transform", "translate(0," + (height - PADDING) + ")"); - - scope.resize = this.setScale; - - conductor.on('timeSystem', this.changeTimeSystem); - - //On conductor bounds changes, redraw ticks - conductor.on('bounds', this.setScale); - - scope.$on("$destroy", this.destroy); - - if (conductor.timeSystem() !== undefined) { - this.changeTimeSystem(conductor.timeSystem()); - this.setScale(); - } - }; - - return function (conductor, formatService) { - return new MCTConductorAxis(conductor, formatService); + template: '
' }; } -); + + return MctConductorAxis; +}); diff --git a/platform/features/conductor-v2/conductor/src/ui/TimeConductorController.js b/platform/features/conductor-v2/conductor/src/ui/TimeConductorController.js index 83bc0558c6..d95632391c 100644 --- a/platform/features/conductor-v2/conductor/src/ui/TimeConductorController.js +++ b/platform/features/conductor-v2/conductor/src/ui/TimeConductorController.js @@ -26,7 +26,13 @@ define( ], function (TimeConductorValidation) { - function TimeConductorController($scope, $window, timeConductor, conductorViewService, timeSystems) { + /** + * Controller for the Time Conductor UI element. The Time Conductor includes form fields for specifying time + * bounds and relative time deltas for queries, as well as controls for selection mode, time systems, and zooming. + * @memberof platform.features.conductor + * @constructor + */ + function TimeConductorController($scope, $window, openmct, conductorViewService, timeSystems, formatService) { var self = this; @@ -40,9 +46,10 @@ define( this.$scope = $scope; this.$window = $window; this.conductorViewService = conductorViewService; - this.conductor = timeConductor; + this.conductor = openmct.conductor; this.modes = conductorViewService.availableModes(); this.validation = new TimeConductorValidation(this.conductor); + this.formatService = formatService; // Construct the provided time system definitions this.timeSystems = timeSystems.map(function (timeSystemConstructor) { @@ -52,7 +59,7 @@ define( //Set the initial state of the view based on current time conductor this.initializeScope(); - this.conductor.on('bounds', this.setFormFromBounds); + this.conductor.on('bounds', this.changeBounds); this.conductor.on('timeSystem', this.changeTimeSystem); // If no mode selected, select fixed as the default @@ -71,8 +78,9 @@ define( //If conductor has a time system selected already, populate the //form from it this.$scope.timeSystemModel = {}; - if (this.conductor.timeSystem()) { - this.setFormFromTimeSystem(this.conductor.timeSystem()); + var timeSystem = this.conductor.timeSystem(); + if (timeSystem) { + this.setFormFromTimeSystem(timeSystem); } //Represents the various modes, and the currently selected mode @@ -98,34 +106,80 @@ define( // Watch scope for selection of mode or time system by user this.$scope.$watch('modeModel.selectedKey', this.setMode); + this.conductorViewService.on('pan', this.onPan); + this.conductorViewService.on('pan-stop', this.onPanStop); + this.$scope.$on('$destroy', this.destroy); }; + /** + * @private + */ TimeConductorController.prototype.destroy = function () { - this.conductor.off('bounds', this.setFormFromBounds); + this.conductor.off('bounds', this.changeBounds); this.conductor.off('timeSystem', this.changeTimeSystem); + + this.conductorViewService.off('pan', this.onPan); + this.conductorViewService.off('pan-stop', this.onPanStop); + }; + + /** + * When the conductor bounds change, set the bounds in the form. + * @private + * @param {TimeConductorBounds} bounds + */ + TimeConductorController.prototype.changeBounds = function (bounds) { + //If a zoom or pan is currently in progress, do not override form values. + if (!this.zooming && !this.panning) { + this.setFormFromBounds(bounds); + } + }; + + /** + * Does the currently selected time system support zooming? To + * support zooming a time system must, at a minimum, define some + * values for maximum and minimum zoom levels. Additionally + * TimeFormats, a related concept, may also support providing time + * unit feedback for the zoom level label, eg "seconds, minutes, + * hours, etc..." + * @returns {boolean} + */ + TimeConductorController.prototype.supportsZoom = function () { + var timeSystem = this.conductor.timeSystem(); + return timeSystem && + timeSystem.defaults() && + timeSystem.defaults().zoom; }; /** * Called when the bounds change in the time conductor. Synchronizes * the bounds values in the time conductor with those in the form - * - * @private + * @param {TimeConductorBounds} */ TimeConductorController.prototype.setFormFromBounds = function (bounds) { - this.$scope.boundsModel.start = bounds.start; - this.$scope.boundsModel.end = bounds.end; - if (!this.pendingUpdate) { - this.pendingUpdate = true; - this.$window.requestAnimationFrame(function () { - this.pendingUpdate = false; - this.$scope.$digest(); - }.bind(this)); + if (!this.zooming && !this.panning) { + this.$scope.boundsModel.start = bounds.start; + this.$scope.boundsModel.end = bounds.end; + + if (this.supportsZoom()) { + this.currentZoom = this.toSliderValue(bounds.end - bounds.start); + this.toTimeUnits(bounds.end - bounds.start); + } + + if (!this.pendingUpdate) { + this.pendingUpdate = true; + this.$window.requestAnimationFrame(function () { + this.pendingUpdate = false; + this.$scope.$digest(); + }.bind(this)); + } } }; /** - * @private + * On mode change, populate form based on time systems available + * from the selected mode. + * @param mode */ TimeConductorController.prototype.setFormFromMode = function (mode) { this.$scope.modeModel.selectedKey = mode; @@ -138,6 +192,7 @@ define( }; /** + * When the deltas change, update the values in the UI * @private */ TimeConductorController.prototype.setFormFromDeltas = function (deltas) { @@ -146,14 +201,20 @@ define( }; /** - * @private + * Initialize the form when time system changes. + * @param {TimeSystem} timeSystem */ TimeConductorController.prototype.setFormFromTimeSystem = function (timeSystem) { - this.$scope.timeSystemModel.selected = timeSystem; - this.$scope.timeSystemModel.format = timeSystem.formats()[0]; - this.$scope.timeSystemModel.deltaFormat = timeSystem.deltaFormat(); - }; + var timeSystemModel = this.$scope.timeSystemModel; + timeSystemModel.selected = timeSystem; + timeSystemModel.format = timeSystem.formats()[0]; + timeSystemModel.deltaFormat = timeSystem.deltaFormat(); + if (this.supportsZoom()) { + timeSystemModel.minZoom = timeSystem.defaults().zoom.min; + timeSystemModel.maxZoom = timeSystem.defaults().zoom.max; + } + }; /** * Called when form values are changed. Synchronizes the form with @@ -222,11 +283,11 @@ define( * Sets the selected time system. Will populate form with the default * bounds and deltas defined in the selected time system. * - * @private * @param newTimeSystem */ TimeConductorController.prototype.changeTimeSystem = function (newTimeSystem) { if (newTimeSystem && (newTimeSystem !== this.$scope.timeSystemModel.selected)) { + this.setFormFromTimeSystem(newTimeSystem); if (newTimeSystem.defaults()) { var deltas = newTimeSystem.defaults().deltas || {start: 0, end: 0}; var bounds = newTimeSystem.defaults().bounds || {start: 0, end: 0}; @@ -234,10 +295,96 @@ define( this.setFormFromDeltas(deltas); this.setFormFromBounds(bounds); } - this.setFormFromTimeSystem(newTimeSystem); } }; + /** + * Takes a time span and calculates a slider increment value, used + * to set the horizontal offset of the slider. + * @param {number} timeSpan a duration of time, in ms + * @returns {number} a value between 0.01 and 0.99, in increments of .01 + */ + TimeConductorController.prototype.toSliderValue = function (timeSpan) { + var timeSystem = this.conductor.timeSystem(); + if (timeSystem) { + var zoomDefaults = this.conductor.timeSystem().defaults().zoom; + var perc = timeSpan / (zoomDefaults.min - zoomDefaults.max); + return 1 - Math.pow(perc, 1 / 4); + } + }; + + /** + * Given a time span, set a label for the units of time that it, + * roughly, represents. Leverages + * @param {TimeSpan} timeSpan + */ + TimeConductorController.prototype.toTimeUnits = function (timeSpan) { + if (this.conductor.timeSystem()) { + var timeFormat = this.formatService.getFormat(this.conductor.timeSystem().formats()[0]); + this.$scope.timeUnits = timeFormat.timeUnits && timeFormat.timeUnits(timeSpan); + } + }; + + /** + * Zooming occurs when the user manipulates the zoom slider. + * Zooming updates the scale and bounds fields immediately, but does + * not trigger a bounds change to other views until the mouse button + * is released. + * @param bounds + */ + TimeConductorController.prototype.onZoom = function (sliderValue) { + var zoomDefaults = this.conductor.timeSystem().defaults().zoom; + var timeSpan = Math.pow((1 - sliderValue), 4) * (zoomDefaults.min - zoomDefaults.max); + + var zoom = this.conductorViewService.zoom(timeSpan); + + this.$scope.boundsModel.start = zoom.bounds.start; + this.$scope.boundsModel.end = zoom.bounds.end; + this.toTimeUnits(zoom.bounds.end - zoom.bounds.start); + + if (zoom.deltas) { + this.setFormFromDeltas(zoom.deltas); + } + }; + + /** + * Fired when user has released the zoom slider + * @event platform.features.conductor.TimeConductorController~zoomStop + */ + /** + * Invoked when zoom slider is released by user. Will update the time conductor with the new bounds, triggering + * a global bounds change event. + * @fires platform.features.conductor.TimeConductorController~zoomStop + */ + TimeConductorController.prototype.onZoomStop = function () { + this.updateBoundsFromForm(this.$scope.boundsModel); + this.updateDeltasFromForm(this.$scope.boundsModel); + this.zooming = false; + + this.conductorViewService.emit('zoom-stop'); + }; + + /** + * Panning occurs when the user grabs the conductor scale and drags + * it left or right to slide the window of time represented by the + * conductor. Panning updates the scale and bounds fields + * immediately, but does not trigger a bounds change to other views + * until the mouse button is released. + * @param {TimeConductorBounds} bounds + */ + TimeConductorController.prototype.onPan = function (bounds) { + this.panning = true; + this.$scope.boundsModel.start = bounds.start; + this.$scope.boundsModel.end = bounds.end; + }; + + /** + * Called when the user releases the mouse button after panning. + */ + TimeConductorController.prototype.onPanStop = function () { + this.panning = false; + }; + return TimeConductorController; } ); diff --git a/platform/features/conductor-v2/conductor/src/ui/TimeConductorControllerSpec.js b/platform/features/conductor-v2/conductor/src/ui/TimeConductorControllerSpec.js index 39759c60f5..8495336972 100644 --- a/platform/features/conductor-v2/conductor/src/ui/TimeConductorControllerSpec.js +++ b/platform/features/conductor-v2/conductor/src/ui/TimeConductorControllerSpec.js @@ -28,6 +28,8 @@ define(['./TimeConductorController'], function (TimeConductorController) { var mockConductorViewService; var mockTimeSystems; var controller; + var mockFormatService; + var mockFormat; beforeEach(function () { mockScope = jasmine.createSpyObj("$scope", [ @@ -52,36 +54,33 @@ define(['./TimeConductorController'], function (TimeConductorController) { "availableModes", "mode", "availableTimeSystems", - "deltas" + "deltas", + "deltas", + "on", + "off" ] ); mockConductorViewService.availableModes.andReturn([]); mockConductorViewService.availableTimeSystems.andReturn([]); + mockFormatService = jasmine.createSpyObj('formatService', [ + 'getFormat' + ]); + mockFormat = jasmine.createSpyObj('format', [ + 'format' + ]); + mockFormatService.getFormat.andReturn(mockFormat); + mockTimeSystems = []; }); - function getListener(name) { - return mockTimeConductor.on.calls.filter(function (call) { - return call.args[0] === name; + function getListener(target, event) { + return target.calls.filter(function (call) { + return call.args[0] === event; })[0].args[1]; } - describe("", function () { - beforeEach(function () { - controller = new TimeConductorController( - mockScope, - mockWindow, - mockTimeConductor, - mockConductorViewService, - mockTimeSystems - ); - }); - - }); - describe("when time conductor state changes", function () { - var mockFormat; var mockDeltaFormat; var defaultBounds; var defaultDeltas; @@ -119,17 +118,18 @@ define(['./TimeConductorController'], function (TimeConductorController) { controller = new TimeConductorController( mockScope, mockWindow, - mockTimeConductor, + {conductor: mockTimeConductor}, mockConductorViewService, - mockTimeSystems + mockTimeSystems, + mockFormatService ); - tsListener = getListener("timeSystem"); + tsListener = getListener(mockTimeConductor.on, "timeSystem"); }); it("listens for changes to conductor state", function () { expect(mockTimeConductor.on).toHaveBeenCalledWith("timeSystem", controller.changeTimeSystem); - expect(mockTimeConductor.on).toHaveBeenCalledWith("bounds", controller.setFormFromBounds); + expect(mockTimeConductor.on).toHaveBeenCalledWith("bounds", controller.changeBounds); }); it("deregisters conductor listens when scope is destroyed", function () { @@ -137,7 +137,7 @@ define(['./TimeConductorController'], function (TimeConductorController) { controller.destroy(); expect(mockTimeConductor.off).toHaveBeenCalledWith("timeSystem", controller.changeTimeSystem); - expect(mockTimeConductor.off).toHaveBeenCalledWith("bounds", controller.setFormFromBounds); + expect(mockTimeConductor.off).toHaveBeenCalledWith("bounds", controller.changeBounds); }); it("when time system changes, sets time system on scope", function () { @@ -151,7 +151,11 @@ define(['./TimeConductorController'], function (TimeConductorController) { }); it("when time system changes, sets defaults on scope", function () { - expect(tsListener).toBeDefined(); + mockDefaults.zoom = { + min: 100, + max: 10 + }; + mockTimeConductor.timeSystem.andReturn(timeSystem); tsListener(timeSystem); expect(mockScope.boundsModel.start).toEqual(defaultBounds.start); @@ -159,6 +163,32 @@ define(['./TimeConductorController'], function (TimeConductorController) { expect(mockScope.boundsModel.startDelta).toEqual(defaultDeltas.start); expect(mockScope.boundsModel.endDelta).toEqual(defaultDeltas.end); + + expect(mockScope.timeSystemModel.minZoom).toBe(mockDefaults.zoom.min); + expect(mockScope.timeSystemModel.maxZoom).toBe(mockDefaults.zoom.max); + }); + + it("when bounds change, sets the correct zoom slider value", function () { + var bounds = { + start: 0, + end: 50 + }; + mockDefaults.zoom = { + min: 100, + max: 0 + }; + + function exponentializer(rawValue) { + return 1 - Math.pow(rawValue, 1 / 4); + } + + mockTimeConductor.timeSystem.andReturn(timeSystem); + //Set zoom defaults + tsListener(timeSystem); + + controller.changeBounds(bounds); + expect(controller.currentZoom).toEqual(exponentializer(0.5)); + }); it("when bounds change, sets them on scope", function () { @@ -167,7 +197,7 @@ define(['./TimeConductorController'], function (TimeConductorController) { end: 2 }; - var boundsListener = getListener("bounds"); + var boundsListener = getListener(mockTimeConductor.on, "bounds"); expect(boundsListener).toBeDefined(); boundsListener(bounds); @@ -225,9 +255,10 @@ define(['./TimeConductorController'], function (TimeConductorController) { controller = new TimeConductorController( mockScope, mockWindow, - mockTimeConductor, + {conductor: mockTimeConductor}, mockConductorViewService, - mockTimeSystemConstructors + mockTimeSystemConstructors, + mockFormatService ); mockConductorViewService.availableTimeSystems.andReturn(mockTimeSystems); @@ -240,9 +271,10 @@ define(['./TimeConductorController'], function (TimeConductorController) { controller = new TimeConductorController( mockScope, mockWindow, - mockTimeConductor, + {conductor: mockTimeConductor}, mockConductorViewService, - mockTimeSystemConstructors + mockTimeSystemConstructors, + mockFormatService ); mockConductorViewService.availableTimeSystems.andReturn(mockTimeSystems); @@ -264,9 +296,10 @@ define(['./TimeConductorController'], function (TimeConductorController) { controller = new TimeConductorController( mockScope, mockWindow, - mockTimeConductor, + {conductor: mockTimeConductor}, mockConductorViewService, - mockTimeSystemConstructors + mockTimeSystemConstructors, + mockFormatService ); controller.updateBoundsFromForm(formModel); @@ -286,9 +319,10 @@ define(['./TimeConductorController'], function (TimeConductorController) { controller = new TimeConductorController( mockScope, mockWindow, - mockTimeConductor, + {conductor: mockTimeConductor}, mockConductorViewService, - mockTimeSystemConstructors + mockTimeSystemConstructors, + mockFormatService ); controller.updateDeltasFromForm(formModel); @@ -321,14 +355,33 @@ define(['./TimeConductorController'], function (TimeConductorController) { controller = new TimeConductorController( mockScope, mockWindow, - mockTimeConductor, + {conductor: mockTimeConductor}, mockConductorViewService, - mockTimeSystems + mockTimeSystems, + mockFormatService ); controller.selectTimeSystemByKey('testTimeSystem'); expect(mockTimeConductor.timeSystem).toHaveBeenCalledWith(timeSystem, defaultBounds); }); + + it("updates form bounds during pan events", function () { + var testBounds = { + start: 10, + end: 20 + }; + + expect(controller.$scope.boundsModel.start).not.toBe(testBounds.start); + expect(controller.$scope.boundsModel.end).not.toBe(testBounds.end); + + expect(controller.conductorViewService.on).toHaveBeenCalledWith("pan", + controller.onPan); + + getListener(controller.conductorViewService.on, "pan")(testBounds); + + expect(controller.$scope.boundsModel.start).toBe(testBounds.start); + expect(controller.$scope.boundsModel.end).toBe(testBounds.end); + }); }); }); diff --git a/platform/features/conductor-v2/conductor/src/ui/TimeConductorMode.js b/platform/features/conductor-v2/conductor/src/ui/TimeConductorMode.js index 1f9d3656af..ef13106107 100644 --- a/platform/features/conductor-v2/conductor/src/ui/TimeConductorMode.js +++ b/platform/features/conductor-v2/conductor/src/ui/TimeConductorMode.js @@ -28,13 +28,14 @@ define( * Supports mode-specific time conductor behavior. * * @constructor + * @memberof platform.features.conductor * @param {TimeConductorMetadata} metadata */ function TimeConductorMode(metadata, conductor, timeSystems) { this.conductor = conductor; this.mdata = metadata; - this.dlts = undefined; + this.deltasVal = undefined; this.source = undefined; this.sourceUnlisten = undefined; this.systems = timeSystems; @@ -141,6 +142,9 @@ define( return this.source; }; + /** + * @private + */ TimeConductorMode.prototype.destroy = function () { this.conductor.off('timeSystem', this.changeTimeSystem); @@ -177,23 +181,66 @@ define( */ TimeConductorMode.prototype.deltas = function (deltas) { if (arguments.length !== 0) { - var oldEnd = this.conductor.bounds().end; - - if (this.dlts && this.dlts.end !== undefined) { - //Calculate the previous raw end value (without delta) - oldEnd = oldEnd - this.dlts.end; + var bounds = this.calculateBoundsFromDeltas(deltas); + this.deltasVal = deltas; + if (this.metadata().key !== 'fixed') { + this.conductor.bounds(bounds); } - - this.dlts = deltas; - - var newBounds = { - start: oldEnd - this.dlts.start, - end: oldEnd + this.dlts.end - }; - - this.conductor.bounds(newBounds); } - return this.dlts; + return this.deltasVal; + }; + + /** + * @param deltas + * @returns {TimeConductorBounds} + */ + TimeConductorMode.prototype.calculateBoundsFromDeltas = function (deltas) { + var oldEnd = this.conductor.bounds().end; + + if (this.deltasVal && this.deltasVal.end !== undefined) { + //Calculate the previous raw end value (without delta) + oldEnd = oldEnd - this.deltasVal.end; + } + + var bounds = { + start: oldEnd - deltas.start, + end: oldEnd + deltas.end + }; + return bounds; + }; + + /** + * @typedef {Object} ZoomLevel + * @property {TimeConductorBounds} bounds The calculated bounds based on the zoom level + * @property {TimeConductorDeltas} deltas The calculated deltas based on the zoom level + */ + /** + * Calculates bounds and deltas based on provided timeSpan. Collectively + * the bounds and deltas will constitute the new zoom level. + * @param {number} timeSpan time duration in ms. + * @return {ZoomLevel} The new zoom bounds and delta calculated for the provided time span + */ + TimeConductorMode.prototype.calculateZoom = function (timeSpan) { + var zoom = {}; + + // If a tick source is defined, then the concept of 'now' is + // important. Calculate zoom based on 'now'. + if (this.tickSource()) { + zoom.deltas = { + start: timeSpan, + end: this.deltasVal.end + }; + zoom.bounds = this.calculateBoundsFromDeltas(zoom.deltas); + // Calculate bounds based on deltas; + } else { + var bounds = this.conductor.bounds(); + var center = bounds.start + ((bounds.end - bounds.start)) / 2; + bounds.start = center - timeSpan / 2; + bounds.end = center + timeSpan / 2; + zoom.bounds = bounds; + } + + return zoom; }; return TimeConductorMode; diff --git a/platform/features/conductor-v2/conductor/src/ui/TimeConductorViewService.js b/platform/features/conductor-v2/conductor/src/ui/TimeConductorViewService.js index 8cbc349520..a0e86ac6d8 100644 --- a/platform/features/conductor-v2/conductor/src/ui/TimeConductorViewService.js +++ b/platform/features/conductor-v2/conductor/src/ui/TimeConductorViewService.js @@ -22,25 +22,30 @@ define( [ + 'EventEmitter', './TimeConductorMode' ], - function (TimeConductorMode) { + function (EventEmitter, TimeConductorMode) { /** * A class representing the state of the time conductor view. This * exposes details of the UI that are not represented on the * TimeConductor API itself such as modes and deltas. * + * @memberof platform.features.conductor * @param conductor * @param timeSystems * @constructor */ - function TimeConductorViewService(conductor, timeSystems) { + function TimeConductorViewService(openmct, timeSystems) { + + EventEmitter.call(this); + this.systems = timeSystems.map(function (timeSystemConstructor) { return timeSystemConstructor(); }); - this.conductor = conductor; + this.conductor = openmct.conductor; this.currentMode = undefined; /** @@ -97,6 +102,8 @@ define( } } + TimeConductorViewService.prototype = Object.create(EventEmitter.prototype); + /** * Getter/Setter for the Time Conductor Mode. Modes determine the * behavior of the time conductor, especially with regards to the @@ -144,7 +151,7 @@ define( }; /** - * @typedef {object} Delta + * @typedef {object} TimeConductorDeltas * @property {number} start Used to set the start bound of the * TimeConductor on tick. A positive value that will be subtracted * from the value provided by a tick source to determine the start @@ -171,7 +178,7 @@ define( * tick * - end: A time in ms after the timestamp of the last data received * which will be used to determine the 'end' bound on tick - * @returns {Delta} current value of the deltas + * @returns {TimeConductorDeltas} current value of the deltas */ TimeConductorViewService.prototype.deltas = function () { //Deltas stored on mode. Use .apply to preserve arguments @@ -197,6 +204,26 @@ define( return this.currentMode.availableTimeSystems(); }; + /** + * An event to indicate that zooming is taking place + * @event platform.features.conductor.TimeConductorViewService~zoom + * @property {ZoomLevel} zoom the new zoom level. + */ + /** + * Zoom to given time span. Will fire a zoom event with new zoom + * bounds. Zoom bounds emitted in this way are considered ephemeral + * and should be overridden by any time conductor bounds events. Does + * not set bounds globally. + * @param {number} zoom A time duration in ms + * @fires platform.features.conductor.TimeConductorViewService~zoom + * @see module:openmct.TimeConductor#bounds + */ + TimeConductorViewService.prototype.zoom = function (timeSpan) { + var zoom = this.currentMode.calculateZoom(timeSpan); + this.emit("zoom", zoom); + return zoom; + }; + return TimeConductorViewService; } ); diff --git a/platform/features/conductor-v2/conductor/src/ui/TimeConductorViewServiceSpec.js b/platform/features/conductor-v2/conductor/src/ui/TimeConductorViewServiceSpec.js index 39e7810320..a096394ee2 100644 --- a/platform/features/conductor-v2/conductor/src/ui/TimeConductorViewServiceSpec.js +++ b/platform/features/conductor-v2/conductor/src/ui/TimeConductorViewServiceSpec.js @@ -87,7 +87,7 @@ define(['./TimeConductorViewService'], function (TimeConductorViewService) { it("At a minimum supports fixed mode", function () { var mockTimeSystems = [mockConstructor(basicTimeSystem)]; - viewService = new TimeConductorViewService(mockTimeConductor, mockTimeSystems); + viewService = new TimeConductorViewService({conductor: mockTimeConductor}, mockTimeSystems); var availableModes = viewService.availableModes(); expect(availableModes.fixed).toBeDefined(); @@ -102,7 +102,7 @@ define(['./TimeConductorViewService'], function (TimeConductorViewService) { }; tickingTimeSystem.tickSources.andReturn([mockRealtimeTickSource]); - viewService = new TimeConductorViewService(mockTimeConductor, mockTimeSystems); + viewService = new TimeConductorViewService({conductor: mockTimeConductor}, mockTimeSystems); var availableModes = viewService.availableModes(); expect(availableModes.realtime).toBeDefined(); @@ -117,7 +117,7 @@ define(['./TimeConductorViewService'], function (TimeConductorViewService) { }; tickingTimeSystem.tickSources.andReturn([mockLADTickSource]); - viewService = new TimeConductorViewService(mockTimeConductor, mockTimeSystems); + viewService = new TimeConductorViewService({conductor: mockTimeConductor}, mockTimeSystems); var availableModes = viewService.availableModes(); expect(availableModes.lad).toBeDefined(); @@ -132,7 +132,7 @@ define(['./TimeConductorViewService'], function (TimeConductorViewService) { "destroy" ]); - viewService = new TimeConductorViewService(mockTimeConductor, mockTimeSystems); + viewService = new TimeConductorViewService({conductor: mockTimeConductor}, mockTimeSystems); viewService.currentMode = oldMode; viewService.mode('fixed'); expect(oldMode.destroy).toHaveBeenCalled(); @@ -149,7 +149,7 @@ define(['./TimeConductorViewService'], function (TimeConductorViewService) { }; tickingTimeSystem.tickSources.andReturn([mockRealtimeTickSource]); - viewService = new TimeConductorViewService(mockTimeConductor, mockTimeSystems); + viewService = new TimeConductorViewService({conductor: mockTimeConductor}, mockTimeSystems); //Set time system to one known to support realtime mode mockTimeConductor.timeSystem.andReturn(tickingTimeSystem); @@ -169,7 +169,7 @@ define(['./TimeConductorViewService'], function (TimeConductorViewService) { }; tickingTimeSystem.tickSources.andReturn([mockRealtimeTickSource]); - viewService = new TimeConductorViewService(mockTimeConductor, mockTimeSystems); + viewService = new TimeConductorViewService({conductor: mockTimeConductor}, mockTimeSystems); //Set time system to one known to not support realtime mode mockTimeConductor.timeSystem.andReturn(basicTimeSystem); diff --git a/platform/features/conductor-v2/conductor/src/ui/TimeOfInterestController.js b/platform/features/conductor-v2/conductor/src/ui/TimeOfInterestController.js new file mode 100644 index 0000000000..39a4b5be6c --- /dev/null +++ b/platform/features/conductor-v2/conductor/src/ui/TimeOfInterestController.js @@ -0,0 +1,109 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +define( + [], + function () { + + /** + * Controller for the Time of Interest element used in various views to display the TOI. Responsible for setting + * the text label for the current TOI, and for toggling the (un)pinned state which determines whether the TOI + * indicator is visible. + * @constructor + */ + function TimeOfInterestController($scope, openmct, formatService) { + this.conductor = openmct.conductor; + this.formatService = formatService; + this.format = undefined; + this.toiText = undefined; + this.$scope = $scope; + + //Bind all class functions to 'this' + Object.keys(TimeOfInterestController.prototype).filter(function (key) { + return typeof TimeOfInterestController.prototype[key] === 'function'; + }).forEach(function (key) { + this[key] = TimeOfInterestController.prototype[key].bind(this); + }.bind(this)); + + this.conductor.on('timeOfInterest', this.changeTimeOfInterest); + this.conductor.on('timeSystem', this.changeTimeSystem); + if (this.conductor.timeSystem()) { + this.changeTimeSystem(this.conductor.timeSystem()); + var toi = this.conductor.timeOfInterest(); + if (toi) { + this.changeTimeOfInterest(toi); + } + } + + $scope.$on('$destroy', this.destroy); + } + + /** + * Called when the time of interest changes on the conductor. Will pin (display) the TOI indicator, and set the + * text using the default formatter of the currently active Time System. + * @private + * @param {integer} toi Current time of interest in ms + */ + TimeOfInterestController.prototype.changeTimeOfInterest = function (toi) { + if (toi !== undefined) { + this.$scope.pinned = true; + this.toiText = this.format.format(toi); + } else { + this.$scope.pinned = false; + } + }; + + /** + * When time system is changed, update the formatter used to + * display the current TOI label + */ + TimeOfInterestController.prototype.changeTimeSystem = function (timeSystem) { + this.format = this.formatService.getFormat(timeSystem.formats()[0]); + }; + + /** + * @private + */ + TimeOfInterestController.prototype.destroy = function () { + this.conductor.off('timeOfInterest', this.changeTimeOfInterest); + this.conductor.off('timeSystem', this.changeTimeSystem); + }; + + /** + * Will unpin (hide) the TOI indicator. Has the effect of setting the time of interest to `undefined` on the + * Time Conductor + */ + TimeOfInterestController.prototype.dismiss = function () { + this.conductor.timeOfInterest(undefined); + }; + + /** + * Sends out a time of interest event with the effect of resetting + * the TOI displayed in views. + */ + TimeOfInterestController.prototype.resync = function () { + this.conductor.timeOfInterest(this.conductor.timeOfInterest()); + }; + + return TimeOfInterestController; + } +); diff --git a/platform/features/conductor-v2/conductor/src/ui/TimeOfInterestControllerSpec.js b/platform/features/conductor-v2/conductor/src/ui/TimeOfInterestControllerSpec.js new file mode 100644 index 0000000000..0062ec79b8 --- /dev/null +++ b/platform/features/conductor-v2/conductor/src/ui/TimeOfInterestControllerSpec.js @@ -0,0 +1,115 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +define(['./TimeOfInterestController'], function (TimeOfInterestController) { + + describe("The time of interest controller", function () { + var controller; + var mockScope; + var mockConductor; + var mockFormatService; + var mockTimeSystem; + var mockFormat; + + beforeEach(function () { + mockConductor = jasmine.createSpyObj("conductor", [ + "on", + "timeSystem" + ]); + mockScope = jasmine.createSpyObj("scope", [ + "$on" + ]); + + mockFormat = jasmine.createSpyObj("format", [ + "format" + ]); + + mockFormatService = jasmine.createSpyObj("formatService", [ + "getFormat" + ]); + + mockFormatService.getFormat.andReturn(mockFormat); + + mockTimeSystem = { + formats: function () { + return ["mockFormat"]; + } + }; + + controller = new TimeOfInterestController(mockScope, {conductor: mockConductor}, mockFormatService); + }); + + function getCallback(target, event) { + return target.calls.filter(function (call) { + return call.args[0] === event; + })[0].args[1]; + } + + it("Listens for changes to TOI", function () { + expect(mockConductor.on).toHaveBeenCalledWith("timeOfInterest", controller.changeTimeOfInterest); + }); + + it("updates format when time system changes", function () { + expect(mockConductor.on).toHaveBeenCalledWith("timeSystem", controller.changeTimeSystem); + getCallback(mockConductor.on, "timeSystem")(mockTimeSystem); + expect(controller.format).toBe(mockFormat); + }); + + describe("When TOI changes", function () { + var toi; + var toiCallback; + var formattedTOI; + + beforeEach(function () { + var timeSystemCallback = getCallback(mockConductor.on, "timeSystem"); + toi = 1; + mockConductor.timeSystem.andReturn(mockTimeSystem); + + //Set time system + timeSystemCallback(mockTimeSystem); + + toiCallback = getCallback(mockConductor.on, "timeOfInterest"); + formattedTOI = "formatted TOI"; + + mockFormatService.getFormat.andReturn("mockFormat"); + mockFormat.format.andReturn(formattedTOI); + }); + it("Uses the time system formatter to produce TOI text", function () { + toiCallback = getCallback(mockConductor.on, "timeOfInterest"); + //Set TOI + toiCallback(toi); + expect(mockFormat.format).toHaveBeenCalled(); + }); + it("Sets the time of interest text", function () { + //Set TOI + toiCallback(toi); + expect(controller.toiText).toBe(formattedTOI); + }); + it("Pins the time of interest", function () { + //Set TOI + toiCallback(toi); + expect(mockScope.pinned).toBe(true); + }); + }); + + }); +}); diff --git a/platform/features/conductor-v2/utcTimeSystem/src/UTCTimeSystem.js b/platform/features/conductor-v2/utcTimeSystem/src/UTCTimeSystem.js index b6e969c3eb..780bfa8db1 100644 --- a/platform/features/conductor-v2/utcTimeSystem/src/UTCTimeSystem.js +++ b/platform/features/conductor-v2/utcTimeSystem/src/UTCTimeSystem.js @@ -64,13 +64,17 @@ define([ return this.sources; }; - UTCTimeSystem.prototype.defaults = function (key) { + UTCTimeSystem.prototype.defaults = function () { var now = Math.ceil(Date.now() / 1000) * 1000; + var ONE_MINUTE = 60 * 1 * 1000; + var FIFTY_YEARS = 50 * 365 * 24 * 60 * 60 * 1000; + return { key: 'utc-default', name: 'UTC time system defaults', deltas: {start: FIFTEEN_MINUTES, end: 0}, - bounds: {start: now - FIFTEEN_MINUTES, end: now} + bounds: {start: now - FIFTEEN_MINUTES, end: now}, + zoom: {min: FIFTY_YEARS, max: ONE_MINUTE} }; }; diff --git a/platform/features/conductor/res/sass/time-conductor.scss b/platform/features/conductor/res/sass/time-conductor.scss index bc50b42da5..9ec9ff1dc1 100644 --- a/platform/features/conductor/res/sass/time-conductor.scss +++ b/platform/features/conductor/res/sass/time-conductor.scss @@ -164,7 +164,7 @@ $ueTimeConductorH: (33px, 18px, 20px); margin-left: 0; } .l-time-range-tick-label { - @include webkitProp(transform, translateX(-50%)); + @include transform(translateX(-50%)); color: $colorPlotLabelFg; display: inline-block; font-size: 0.7rem; diff --git a/platform/features/plot/bundle.js b/platform/features/plot/bundle.js index ec4e6f375a..e23b4add82 100644 --- a/platform/features/plot/bundle.js +++ b/platform/features/plot/bundle.js @@ -77,7 +77,8 @@ define([ "telemetryFormatter", "telemetryHandler", "throttle", - "PLOT_FIXED_DURATION" + "PLOT_FIXED_DURATION", + "openmct" ] }, { diff --git a/platform/features/plot/res/templates/plot.html b/platform/features/plot/res/templates/plot.html index 53b5d348b0..bcd1d1899b 100644 --- a/platform/features/plot/res/templates/plot.html +++ b/platform/features/plot/res/templates/plot.html @@ -40,21 +40,16 @@ ng-style="{ height: 100 / plot.getSubPlots().length + '%'}" ng-repeat="subplot in plot.getSubPlots()">
- - - + + + + {{telemetryObject.getModel().name}} - {{telemetryObject.getModel().name}} - -
-
- {{subplot.getHoverCoordinates()}}
+
{{axes[1].active.name}} @@ -74,81 +69,96 @@
-
- - -
-
-
+ +
+ + +
+ {{subplot.getHoverCoordinates()}}
-
-
- - - -
- - - - - diff --git a/platform/features/plot/src/PlotController.js b/platform/features/plot/src/PlotController.js index 0c56b48f22..6089358684 100644 --- a/platform/features/plot/src/PlotController.js +++ b/platform/features/plot/src/PlotController.js @@ -68,7 +68,8 @@ define( telemetryFormatter, telemetryHandler, throttle, - PLOT_FIXED_DURATION + PLOT_FIXED_DURATION, + openmct ) { var self = this, plotTelemetryFormatter = @@ -81,6 +82,7 @@ define( lastRange, lastDomain, handle; + var conductor = openmct.conductor; // Populate the scope with axis information (specifically, options // available for each axis.) @@ -181,6 +183,18 @@ define( } } + function changeTimeOfInterest(timeOfInterest) { + if (timeOfInterest !== undefined) { + var bounds = conductor.bounds(); + var range = bounds.end - bounds.start; + $scope.toiPerc = ((timeOfInterest - bounds.start) / range) * 100; + $scope.toiPinned = true; + } else { + $scope.toiPerc = undefined; + $scope.toiPinned = false; + } + } + // Create a new subscription; telemetrySubscriber gets // to do the meaningful work here. function subscribe(domainObject) { @@ -193,6 +207,9 @@ define( true // Lossless ); replot(); + + changeTimeOfInterest(conductor.timeOfInterest()); + conductor.on("timeOfInterest", changeTimeOfInterest); } // Release the current subscription (called when scope is destroyed) @@ -200,6 +217,7 @@ define( if (handle) { handle.unsubscribe(); handle = undefined; + conductor.off("timeOfInterest", changeTimeOfInterest); } } @@ -242,6 +260,7 @@ define( requery(); } self.setUnsynchedStatus($scope.domainObject, follow && self.isZoomed()); + changeTimeOfInterest(conductor.timeOfInterest()); } this.modeOptions = new PlotModeOptions([], subPlotFactory); diff --git a/platform/features/plot/test/PlotControllerSpec.js b/platform/features/plot/test/PlotControllerSpec.js index 10b0610bf1..46d906cb41 100644 --- a/platform/features/plot/test/PlotControllerSpec.js +++ b/platform/features/plot/test/PlotControllerSpec.js @@ -40,7 +40,8 @@ define( mockDomainObject, mockSeries, mockStatusCapability, - controller; + controller, + mockConductor; function bind(method, thisObj) { return function () { @@ -120,13 +121,23 @@ define( mockHandle.getRangeValue.andReturn(42); mockScope.domainObject = mockDomainObject; + mockConductor = jasmine.createSpyObj('conductor', [ + 'on', + 'off', + 'bounds', + 'timeSystem', + 'timeOfInterest' + ]); + controller = new PlotController( mockScope, mockElement, mockExportImageService, mockFormatter, mockHandler, - mockThrottle + mockThrottle, + undefined, + {conductor: mockConductor} ); }); diff --git a/platform/features/table/bundle.js b/platform/features/table/bundle.js index 6f7d285a50..02b78f847f 100644 --- a/platform/features/table/bundle.js +++ b/platform/features/table/bundle.js @@ -115,12 +115,12 @@ define([ { "key": "HistoricalTableController", "implementation": HistoricalTableController, - "depends": ["$scope", "telemetryHandler", "telemetryFormatter", "$timeout"] + "depends": ["$scope", "telemetryHandler", "telemetryFormatter", "$timeout", "openmct"] }, { "key": "RealtimeTableController", "implementation": RealtimeTableController, - "depends": ["$scope", "telemetryHandler", "telemetryFormatter"] + "depends": ["$scope", "telemetryHandler", "telemetryFormatter", "openmct"] }, { "key": "TableOptionsController", diff --git a/platform/features/table/res/templates/historical-table.html b/platform/features/table/res/templates/historical-table.html index d306601daf..c2abbf5708 100644 --- a/platform/features/table/res/templates/historical-table.html +++ b/platform/features/table/res/templates/historical-table.html @@ -1,9 +1,12 @@ -
+
\ No newline at end of file diff --git a/platform/features/table/res/templates/mct-table.html b/platform/features/table/res/templates/mct-table.html index ac06f53e28..7e24be2c43 100644 --- a/platform/features/table/res/templates/mct-table.html +++ b/platform/features/table/res/templates/mct-table.html @@ -48,12 +48,19 @@ - - + + + + + + +
\ No newline at end of file diff --git a/platform/features/table/src/controllers/HistoricalTableController.js b/platform/features/table/src/controllers/HistoricalTableController.js index d8af8060de..0f56f6b4ee 100644 --- a/platform/features/table/src/controllers/HistoricalTableController.js +++ b/platform/features/table/src/controllers/HistoricalTableController.js @@ -36,7 +36,7 @@ define( * @param telemetryFormatter * @constructor */ - function HistoricalTableController($scope, telemetryHandler, telemetryFormatter, $timeout) { + function HistoricalTableController($scope, telemetryHandler, telemetryFormatter, $timeout, openmct) { var self = this; this.$timeout = $timeout; @@ -49,7 +49,7 @@ define( } }); - TableController.call(this, $scope, telemetryHandler, telemetryFormatter); + TableController.call(this, $scope, telemetryHandler, telemetryFormatter, openmct); } HistoricalTableController.prototype = Object.create(TableController.prototype); diff --git a/platform/features/table/src/controllers/MCTTableController.js b/platform/features/table/src/controllers/MCTTableController.js index d78140f67e..3ab1887c29 100644 --- a/platform/features/table/src/controllers/MCTTableController.js +++ b/platform/features/table/src/controllers/MCTTableController.js @@ -12,7 +12,7 @@ define( * @param element * @constructor */ - function MCTTableController($scope, $timeout, element, exportService) { + function MCTTableController($scope, $timeout, element, exportService, formatService, openmct) { var self = this; this.$scope = $scope; @@ -24,6 +24,16 @@ define( this.resultsHeader = this.element.find('.mct-table>thead').first(); this.sizingTableBody = this.element.find('.sizing-table>tbody').first(); this.$scope.sizingRow = {}; + this.conductor = openmct.conductor; + this.toiFormatter = undefined; + this.formatService = formatService; + + //Bind all class functions to 'this' + Object.keys(MCTTableController.prototype).filter(function (key) { + return typeof MCTTableController.prototype[key] === 'function'; + }).forEach(function (key) { + this[key] = MCTTableController.prototype[key].bind(this); + }.bind(this)); this.scrollable.on('scroll', this.onScroll.bind(this)); @@ -42,6 +52,9 @@ define( scope.sortColumn = undefined; scope.sortDirection = undefined; } + if (scope.sortColumn !== undefined) { + scope.sortDirection = "asc"; + } } setDefaults($scope); @@ -68,8 +81,12 @@ define( } else if ($scope.sortDirection === 'desc') { $scope.sortColumn = undefined; $scope.sortDirection = undefined; + } else if ($scope.sortColumn !== undefined && + $scope.sortDirection === undefined) { + $scope.sortDirection = 'asc'; } self.setRows($scope.rows); + self.setTimeOfInterest(self.conductor.timeOfInterest()); }; /* @@ -78,22 +95,60 @@ define( $scope.$watchCollection('filters', function () { self.setRows($scope.rows); }); - $scope.$watch('headers', this.setHeaders.bind(this)); - $scope.$watch('rows', this.setRows.bind(this)); + $scope.$watch('headers', this.setHeaders); + $scope.$watch('rows', this.setRows); /* * Listen for rows added individually (eg. for real-time tables) */ - $scope.$on('add:row', this.addRow.bind(this)); - $scope.$on('remove:row', this.removeRow.bind(this)); + $scope.$on('add:row', this.addRow); + $scope.$on('remove:row', this.removeRow); + + /** + * Populated from the default-sort attribute on MctTable + * directive tag. + */ + $scope.$watch('sortColumn', $scope.toggleSort); /* * Listen for resize events to trigger recalculation of table width */ - $scope.resize = this.setElementSizes.bind(this); + $scope.resize = this.setElementSizes; + /** + * Scope variable that is populated from the 'time-columns' + * attribute on the MctTable tag. Indicates which columns, while + * sorted, can be used for indicated time of interest. + */ + $scope.$watch("timeColumns", function (timeColumns) { + if (timeColumns) { + this.destroyConductorListeners(); + + this.conductor.on('timeSystem', this.changeTimeSystem); + this.conductor.on('timeOfInterest', this.setTimeOfInterest); + this.conductor.on('bounds', this.changeBounds); + + // If time system defined, set initially + if (this.conductor.timeSystem()) { + this.changeTimeSystem(this.conductor.timeSystem()); + } + } + }.bind(this)); + + $scope.$on('$destroy', this.destroyConductorListeners); } + MCTTableController.prototype.destroyConductorListeners = function () { + this.conductor.off('timeSystem', this.changeTimeSystem); + this.conductor.off('timeOfInterest', this.setTimeOfInterest); + this.conductor.off('bounds', this.changeBounds); + }; + + MCTTableController.prototype.changeTimeSystem = function () { + var format = this.conductor.timeSystem().formats()[0]; + this.toiFormatter = this.formatService.getFormat(format); + }; + /** * If auto-scroll is enabled, this function will scroll to the * bottom of the page @@ -163,19 +218,54 @@ define( this.$scope.$digest(); }; + /** + * Return first visible row, based on current scroll state. + * @private + */ + MCTTableController.prototype.firstVisible = function () { + var target = this.scrollable[0], + topScroll = target.scrollTop, + firstVisible; + + if (topScroll < this.$scope.headerHeight) { + firstVisible = 0; + } else { + firstVisible = Math.floor( + (topScroll - this.$scope.headerHeight) / + this.$scope.rowHeight + ); + } + + return firstVisible; + }; + + /** + * Return last visible row, based on current scroll state. + * @private + */ + MCTTableController.prototype.lastVisible = function () { + var target = this.scrollable[0], + topScroll = target.scrollTop, + bottomScroll = topScroll + target.offsetHeight, + lastVisible; + + lastVisible = Math.ceil( + (bottomScroll - this.$scope.headerHeight) / + this.$scope.rowHeight + ); + return lastVisible; + }; + /** * Sets visible rows based on array * content and current scroll state. */ MCTTableController.prototype.setVisibleRows = function () { var self = this, - target = this.scrollable[0], - topScroll = target.scrollTop, - bottomScroll = topScroll + target.offsetHeight, - firstVisible, - lastVisible, totalVisible, numberOffscreen, + firstVisible, + lastVisible, start, end; @@ -184,21 +274,8 @@ define( start = 0; end = this.$scope.displayRows.length; } else { - //rows has exceeded display maximum, so may be necessary to - // scroll - if (topScroll < this.$scope.headerHeight) { - firstVisible = 0; - } else { - firstVisible = Math.floor( - (topScroll - this.$scope.headerHeight) / - this.$scope.rowHeight - ); - } - lastVisible = Math.ceil( - (bottomScroll - this.$scope.headerHeight) / - this.$scope.rowHeight - ); - + firstVisible = this.firstVisible(); + lastVisible = this.lastVisible(); totalVisible = lastVisible - firstVisible; numberOffscreen = this.maxDisplayRows - totalVisible; start = firstVisible - Math.floor(numberOffscreen / 2); @@ -294,37 +371,37 @@ define( /** * @private */ - MCTTableController.prototype.insertSorted = function (array, element) { - var index = -1, - self = this, - sortKey = this.$scope.sortColumn; + MCTTableController.prototype.binarySearch = function (searchArray, searchElement, min, max) { + var sampleAt = Math.floor((max - min) / 2) + min; - function binarySearch(searchArray, searchElement, min, max) { - var sampleAt = Math.floor((max - min) / 2) + min; - - if (max < min) { - return min; // Element is not in array, min gives direction - } - - switch (self.sortComparator(searchElement[sortKey].text, - searchArray[sampleAt][sortKey].text)) { - case -1: - return binarySearch(searchArray, searchElement, min, - sampleAt - 1); - case 0 : - return sampleAt; - case 1 : - return binarySearch(searchArray, searchElement, - sampleAt + 1, max); - } + if (max < min) { + return min; // Element is not in array, min gives direction } + switch (this.sortComparator(searchElement, + searchArray[sampleAt][this.$scope.sortColumn].text)) { + case -1: + return this.binarySearch(searchArray, searchElement, min, + sampleAt - 1); + case 0 : + return sampleAt; + case 1 : + return this.binarySearch(searchArray, searchElement, + sampleAt + 1, max); + } + }; + + /** + * @private + */ + MCTTableController.prototype.insertSorted = function (array, element) { + var index = -1; if (!this.$scope.sortColumn || !this.$scope.sortDirection) { //No sorting applied, push it on the end. index = array.length; } else { //Sort is enabled, perform binary search to find insertion point - index = binarySearch(array, element, 0, array.length - 1); + index = this.binarySearch(array, element[this.$scope.sortColumn].text, 0, array.length - 1); } if (index === -1) { array.unshift(element); @@ -485,7 +562,19 @@ define( } this.$scope.displayRows = this.filterAndSort(newRows || []); - this.resize(newRows).then(this.setVisibleRows.bind(this)); + this.resize(newRows) + .then(this.setVisibleRows) + //Timeout following setVisibleRows to allow digest to + // perform DOM changes, otherwise scrollTo won't work. + .then(this.$timeout) + .then(function () { + //If TOI specified, scroll to it + var timeOfInterest = this.conductor.timeOfInterest(); + if (timeOfInterest) { + this.setTimeOfInterest(timeOfInterest); + } + }.bind(this)); + }; /** @@ -525,6 +614,70 @@ define( return rowsToFilter.filter(matchRow.bind(null, filters)); }; + /** + * @param displayRowIndex {number} The index in the displayed rows + * to scroll to. + */ + MCTTableController.prototype.scrollToRow = function (displayRowIndex) { + + var visible = displayRowIndex > this.firstVisible() && displayRowIndex < this.lastVisible(); + + if (!visible) { + var scrollTop = displayRowIndex * this.$scope.rowHeight + + this.$scope.headerHeight - + (this.scrollable[0].offsetHeight / 2); + this.scrollable[0].scrollTop = scrollTop; + this.setVisibleRows(); + } + }; + + /** + * Update rows with new data. If filtering is enabled, rows + * will be sorted before display. + */ + MCTTableController.prototype.setTimeOfInterest = function (newTOI) { + var isSortedByTime = + this.$scope.timeColumns && + this.$scope.timeColumns.indexOf(this.$scope.sortColumn) !== -1; + + this.$scope.toiRowIndex = -1; + + if (newTOI && isSortedByTime) { + var formattedTOI = this.toiFormatter.format(newTOI); + var rowIndex = this.binarySearch( + this.$scope.displayRows, + formattedTOI, + 0, + this.$scope.displayRows.length - 1); + + if (rowIndex > 0 && rowIndex < this.$scope.displayRows.length) { + this.$scope.toiRowIndex = rowIndex; + this.scrollToRow(this.$scope.toiRowIndex); + } + } + }; + + /** + * On zoom, pan, etc. reset TOI + * @param bounds + */ + MCTTableController.prototype.changeBounds = function (bounds) { + this.setTimeOfInterest(this.conductor.timeOfInterest()); + }; + + /** + * @private + */ + MCTTableController.prototype.onRowClick = function (event, rowIndex) { + if (this.$scope.timeColumns.indexOf(this.$scope.sortColumn) !== -1) { + var selectedTime = this.$scope.displayRows[rowIndex][this.$scope.sortColumn].text; + if (selectedTime && + this.toiFormatter.validate(selectedTime) && + event.altKey) { + this.conductor.timeOfInterest(this.toiFormatter.parse(selectedTime)); + } + } + }; return MCTTableController; } diff --git a/platform/features/table/src/controllers/RealtimeTableController.js b/platform/features/table/src/controllers/RealtimeTableController.js index 0b0227dff1..c6ff7b8aee 100644 --- a/platform/features/table/src/controllers/RealtimeTableController.js +++ b/platform/features/table/src/controllers/RealtimeTableController.js @@ -35,8 +35,8 @@ define( * @param telemetryFormatter * @constructor */ - function RealtimeTableController($scope, telemetryHandler, telemetryFormatter) { - TableController.call(this, $scope, telemetryHandler, telemetryFormatter); + function RealtimeTableController($scope, telemetryHandler, telemetryFormatter, openmct) { + TableController.call(this, $scope, telemetryHandler, telemetryFormatter, openmct); this.maxRows = 100000; } diff --git a/platform/features/table/src/controllers/TelemetryTableController.js b/platform/features/table/src/controllers/TelemetryTableController.js index 1e77e16278..7d6cbc2bec 100644 --- a/platform/features/table/src/controllers/TelemetryTableController.js +++ b/platform/features/table/src/controllers/TelemetryTableController.js @@ -43,7 +43,8 @@ define( function TelemetryTableController( $scope, telemetryHandler, - telemetryFormatter + telemetryFormatter, + openmct ) { var self = this; @@ -54,6 +55,7 @@ define( this.table = new TableConfiguration($scope.domainObject, telemetryFormatter); this.changeListeners = []; + this.conductor = openmct.conductor; $scope.rows = []; @@ -63,13 +65,35 @@ define( self.registerChangeListeners(); }); + this.destroy = this.destroy.bind(this); + // Unsubscribe when the plot is destroyed - this.$scope.$on("$destroy", this.destroy.bind(this)); + this.$scope.$on("$destroy", this.destroy); + this.timeColumns = []; + + + this.sortByTimeSystem = this.sortByTimeSystem.bind(this); + this.conductor.on('timeSystem', this.sortByTimeSystem); + this.conductor.off('timeSystem', this.sortByTimeSystem); } /** - * @private + * Based on the selected time system, find a matching domain column + * to sort by. By default will just match on key. + * @param timeSystem */ + TelemetryTableController.prototype.sortByTimeSystem = function (timeSystem) { + var scope = this.$scope; + scope.defaultSort = undefined; + if (timeSystem) { + this.table.columns.forEach(function (column) { + if (column.domainMetadata && column.domainMetadata.key === timeSystem.metadata.key) { + scope.defaultSort = column.getTitle(); + } + }); + } + }; + TelemetryTableController.prototype.unregisterChangeListeners = function () { this.changeListeners.forEach(function (listener) { return listener && listener(); @@ -148,20 +172,37 @@ define( this.setup(); }; + TelemetryTableController.prototype.populateColumns = function (telemetryMetadata) { + this.table.populateColumns(telemetryMetadata); + + //Identify time columns + telemetryMetadata.forEach(function (metadatum) { + //Push domains first + (metadatum.domains || []).forEach(function (domainMetadata) { + this.timeColumns.push(domainMetadata.name); + }.bind(this)); + }.bind(this)); + + var timeSystem = this.conductor.timeSystem(); + if (timeSystem) { + this.sortByTimeSystem(timeSystem); + } + }; + /** * Setup table columns based on domain object metadata */ TelemetryTableController.prototype.setup = function () { var handle = this.handle, - table = this.table, self = this; if (handle) { + this.timeColumns = []; handle.promiseTelemetryObjects().then(function () { self.$scope.headers = []; self.$scope.rows = []; - table.populateColumns(handle.getMetadata()); + self.populateColumns(handle.getMetadata()); self.filterColumns(); // When table column configuration changes, (due to being diff --git a/platform/features/table/src/directives/MCTTable.js b/platform/features/table/src/directives/MCTTable.js index 12407e7598..dad23c2eb5 100644 --- a/platform/features/table/src/directives/MCTTable.js +++ b/platform/features/table/src/directives/MCTTable.js @@ -86,14 +86,25 @@ define( '$timeout', '$element', 'exportService', + 'formatService', + 'openmct', MCTTableController ], + controllerAs: "table", scope: { headers: "=", rows: "=", enableFilter: "=?", enableSort: "=?", - autoScroll: "=?" + autoScroll: "=?", + // Used to indicate which columns contain time data. This + // will be used for determining when the table is sorted + // by the column that can be used for time conductor + // time of interest. + timeColumns: "=?", + // Indicate a column to sort on. Allows control of sort + // via configuration (eg. for default sort column). + sortColumn: "=?" } }; } diff --git a/platform/features/table/test/controllers/HistoricalTableControllerSpec.js b/platform/features/table/test/controllers/HistoricalTableControllerSpec.js index 46475b8dbc..39f7d1a8f5 100644 --- a/platform/features/table/test/controllers/HistoricalTableControllerSpec.js +++ b/platform/features/table/test/controllers/HistoricalTableControllerSpec.js @@ -37,6 +37,7 @@ define( mockAngularTimeout, mockTimeoutHandle, watches, + mockConductor, controller; function promise(value) { @@ -47,6 +48,12 @@ define( }; } + function getCallback(target, event) { + return target.calls.filter(function (call) { + return call.args[0] === event; + })[0].args[1]; + } + beforeEach(function () { watches = {}; mockScope = jasmine.createSpyObj('scope', [ @@ -108,13 +115,22 @@ define( mockTelemetryHandle.promiseTelemetryObjects.andReturn(promise(undefined)); mockTelemetryHandle.request.andReturn(promise(undefined)); mockTelemetryHandle.getTelemetryObjects.andReturn([]); + mockTelemetryHandle.getMetadata.andReturn([]); mockTelemetryHandler = jasmine.createSpyObj('telemetryHandler', [ 'handle' ]); mockTelemetryHandler.handle.andReturn(mockTelemetryHandle); - controller = new TableController(mockScope, mockTelemetryHandler, mockTelemetryFormatter, mockAngularTimeout); + mockConductor = jasmine.createSpyObj("conductor", [ + "timeSystem", + "on", + "off" + ]); + + controller = new TableController(mockScope, mockTelemetryHandler, + mockTelemetryFormatter, mockAngularTimeout, {conductor: mockConductor}); + controller.table = mockTable; controller.handle = mockTelemetryHandle; }); @@ -233,6 +249,60 @@ define( }); }); + describe('After populating columns', function () { + var metadata; + beforeEach(function () { + metadata = [{domains: [{name: 'time domain 1'}, {name: 'time domain 2'}]}, {domains: [{name: 'time domain 3'}, {name: 'time domain 4'}]}]; + controller.populateColumns(metadata); + }); + + it('Automatically identifies time columns', function () { + expect(controller.timeColumns.length).toBe(4); + expect(controller.timeColumns[0]).toBe('time domain 1'); + }); + + it('Automatically sorts by time column that matches current' + + ' time system', function () { + var key = 'time_domain_1', + name = 'time domain 1', + mockTimeSystem = { + metadata: { + key: key + } + }; + + mockTable.columns = [ + { + domainMetadata: { + key: key + }, + getTitle: function () { + return name; + } + }, + { + domainMetadata: { + key: 'anotherColumn' + }, + getTitle: function () { + return 'some other column'; + } + }, + { + domainMetadata: { + key: 'thirdColumn' + }, + getTitle: function () { + return 'a third column'; + } + } + ]; + + expect(mockConductor.on).toHaveBeenCalledWith('timeSystem', jasmine.any(Function)); + getCallback(mockConductor.on, 'timeSystem')(mockTimeSystem); + expect(controller.$scope.defaultSort).toBe(name); + }); + }); describe('Yields thread', function () { var mockSeries, mockRow; diff --git a/platform/features/table/test/controllers/MCTTableControllerSpec.js b/platform/features/table/test/controllers/MCTTableControllerSpec.js index 285403423f..61e4a2eece 100644 --- a/platform/features/table/test/controllers/MCTTableControllerSpec.js +++ b/platform/features/table/test/controllers/MCTTableControllerSpec.js @@ -23,9 +23,10 @@ define( [ "zepto", + "moment", "../../src/controllers/MCTTableController" ], - function ($, MCTTableController) { + function ($, moment, MCTTableController) { var MOCK_ELEMENT_TEMPLATE = '
' + @@ -40,7 +41,10 @@ define( watches, mockTimeout, mockElement, - mockExportService; + mockExportService, + mockConductor, + mockFormatService, + mockFormat; function promise(value) { return { @@ -50,6 +54,12 @@ define( }; } + function getCallback(target, event) { + return target.calls.filter(function (call) { + return call.args[0] === event; + })[0].args[1]; + } + beforeEach(function () { watches = {}; @@ -67,15 +77,33 @@ define( 'exportCSV' ]); + mockConductor = jasmine.createSpyObj('conductor', [ + 'bounds', + 'timeOfInterest', + 'timeSystem', + 'on', + 'off' + ]); + mockScope.displayHeaders = true; mockTimeout = jasmine.createSpy('$timeout'); mockTimeout.andReturn(promise(undefined)); + mockFormat = jasmine.createSpyObj('formatter', [ + 'parse', + 'format' + ]); + mockFormatService = jasmine.createSpyObj('formatService', [ + 'getFormat' + ]); + mockFormatService.getFormat.andReturn(mockFormat); controller = new MCTTableController( mockScope, mockTimeout, mockElement, - mockExportService + mockExportService, + mockFormatService, + {conductor: mockConductor} ); spyOn(controller, 'setVisibleRows').andCallThrough(); }); @@ -86,6 +114,133 @@ define( expect(mockScope.$watch).toHaveBeenCalledWith('rows', jasmine.any(Function)); }); + it('destroys listeners on destruction', function () { + expect(mockScope.$on).toHaveBeenCalledWith('$destroy', controller.destroyConductorListeners); + getCallback(mockScope.$on, '$destroy')(); + + expect(mockConductor.off).toHaveBeenCalledWith('timeSystem', controller.changeTimeSystem); + expect(mockConductor.off).toHaveBeenCalledWith('timeOfInterest', controller.setTimeOfInterest); + expect(mockConductor.off).toHaveBeenCalledWith('bounds', controller.changeBounds); + }); + + describe('The time of interest', function () { + var rowsAsc = []; + var rowsDesc = []; + beforeEach(function () { + rowsAsc = [ + { + 'col1': {'text': 'row1 col1 match'}, + 'col2': {'text': '2012-10-31 00:00:00.000Z'}, + 'col3': {'text': 'row1 col3'} + }, + { + 'col1': {'text': 'row2 col1 match'}, + 'col2': {'text': '2012-11-01 00:00:00.000Z'}, + 'col3': {'text': 'row2 col3'} + }, + { + 'col1': {'text': 'row3 col1'}, + 'col2': {'text': '2012-11-03 00:00:00.000Z'}, + 'col3': {'text': 'row3 col3'} + }, + { + 'col1': {'text': 'row3 col1'}, + 'col2': {'text': '2012-11-04 00:00:00.000Z'}, + 'col3': {'text': 'row3 col3'} + } + ]; + rowsDesc = [ + { + 'col1': {'text': 'row1 col1 match'}, + 'col2': {'text': '2012-11-02 00:00:00.000Z'}, + 'col3': {'text': 'row1 col3'} + }, + { + 'col1': {'text': 'row2 col1 match'}, + 'col2': {'text': '2012-11-01 00:00:00.000Z'}, + 'col3': {'text': 'row2 col3'} + }, + { + 'col1': {'text': 'row3 col1'}, + 'col2': {'text': '2012-10-30 00:00:00.000Z'}, + 'col3': {'text': 'row3 col3'} + }, + { + 'col1': {'text': 'row3 col1'}, + 'col2': {'text': '2012-10-29 00:00:00.000Z'}, + 'col3': {'text': 'row3 col3'} + } + ]; + mockScope.timeColumns = ['col2']; + mockScope.sortColumn = 'col2'; + controller.toiFormatter = mockFormat; + }); + it("is observed for changes", function () { + //Mock setting time columns + getCallback(mockScope.$watch, 'timeColumns')(['col2']); + + expect(mockConductor.on).toHaveBeenCalledWith('timeOfInterest', + jasmine.any(Function)); + }); + describe("causes corresponding row to be highlighted", function () { + it("when changed and rows sorted ascending", function () { + var testDate = "2012-11-02 00:00:00.000Z"; + mockScope.rows = rowsAsc; + mockScope.displayRows = rowsAsc; + mockScope.sortDirection = 'asc'; + + var toi = moment.utc(testDate).valueOf(); + mockFormat.parse.andReturn(toi); + mockFormat.format.andReturn(testDate); + + //mock setting the timeColumns parameter + getCallback(mockScope.$watch, 'timeColumns')(['col2']); + + var toiCallback = getCallback(mockConductor.on, 'timeOfInterest'); + toiCallback(toi); + + expect(mockScope.toiRowIndex).toBe(2); + }); + it("when changed and rows sorted descending", function () { + var testDate = "2012-10-31 00:00:00.000Z"; + mockScope.rows = rowsDesc; + mockScope.displayRows = rowsDesc; + mockScope.sortDirection = 'desc'; + + var toi = moment.utc(testDate).valueOf(); + mockFormat.parse.andReturn(toi); + mockFormat.format.andReturn(testDate); + + //mock setting the timeColumns parameter + getCallback(mockScope.$watch, 'timeColumns')(['col2']); + + var toiCallback = getCallback(mockConductor.on, 'timeOfInterest'); + toiCallback(toi); + + expect(mockScope.toiRowIndex).toBe(2); + }); + it("when rows are set and sorted ascending", function () { + var testDate = "2012-11-02 00:00:00.000Z"; + mockScope.sortDirection = 'asc'; + + var toi = moment.utc(testDate).valueOf(); + mockFormat.parse.andReturn(toi); + mockFormat.format.andReturn(testDate); + mockConductor.timeOfInterest.andReturn(toi); + + //mock setting the timeColumns parameter + getCallback(mockScope.$watch, 'timeColumns')(['col2']); + + //Mock setting the rows on scope + var rowsCallback = getCallback(mockScope.$watch, 'rows'); + rowsCallback(rowsAsc); + + expect(mockScope.toiRowIndex).toBe(2); + }); + + }); + }); + describe('rows', function () { var testRows = []; beforeEach(function () { @@ -132,7 +287,7 @@ define( }); it('Supports adding rows individually', function () { - var addRowFunc = mockScope.$on.calls[mockScope.$on.calls.length - 2].args[1], + var addRowFunc = getCallback(mockScope.$on, 'add:row'), row4 = { 'col1': {'text': 'row3 col1'}, 'col2': {'text': 'ghi'}, @@ -146,7 +301,7 @@ define( }); it('Supports removing rows individually', function () { - var removeRowFunc = mockScope.$on.calls[mockScope.$on.calls.length - 1].args[1]; + var removeRowFunc = getCallback(mockScope.$on, 'remove:row'); controller.setRows(testRows); expect(mockScope.displayRows.length).toBe(3); removeRowFunc(undefined, 2); @@ -173,6 +328,10 @@ define( describe('sorting', function () { var sortedRows; + beforeEach(function () { + sortedRows = []; + }); + it('Sorts rows ascending', function () { mockScope.sortColumn = 'col1'; mockScope.sortDirection = 'asc'; @@ -204,6 +363,20 @@ define( expect(sortedRows[2].col2.text).toEqual('abc'); }); + it('Allows sort column to be changed externally by ' + + 'setting or changing sortBy attribute', function () { + mockScope.displayRows = testRows; + var sortByCB = getCallback(mockScope.$watch, 'sortColumn'); + sortByCB('col2'); + + expect(mockScope.sortDirection).toEqual('asc'); + + expect(mockScope.displayRows[0].col2.text).toEqual('abc'); + expect(mockScope.displayRows[1].col2.text).toEqual('def'); + expect(mockScope.displayRows[2].col2.text).toEqual('ghi'); + + }); + // https://github.com/nasa/openmct/issues/910 it('updates visible rows in scope', function () { var oldRows; @@ -369,8 +542,6 @@ define( }); }); - - }); }); }); diff --git a/platform/features/table/test/controllers/RealtimeTableControllerSpec.js b/platform/features/table/test/controllers/RealtimeTableControllerSpec.js index 5064c1f6a6..bf29c3d7bd 100644 --- a/platform/features/table/test/controllers/RealtimeTableControllerSpec.js +++ b/platform/features/table/test/controllers/RealtimeTableControllerSpec.js @@ -36,6 +36,7 @@ define( mockConfiguration, watches, mockTableRow, + mockConductor, controller; function promise(value) { @@ -106,7 +107,8 @@ define( 'getDatum', 'promiseTelemetryObjects', 'getTelemetryObjects', - 'request' + 'request', + 'getMetadata' ]); // Arbitrary array with non-zero length, contents are not @@ -115,13 +117,22 @@ define( mockTelemetryHandle.promiseTelemetryObjects.andReturn(promise(undefined)); mockTelemetryHandle.getDatum.andReturn({}); mockTelemetryHandle.request.andReturn(promise(undefined)); + mockTelemetryHandle.getMetadata.andReturn([]); mockTelemetryHandler = jasmine.createSpyObj('telemetryHandler', [ 'handle' ]); mockTelemetryHandler.handle.andReturn(mockTelemetryHandle); - controller = new TableController(mockScope, mockTelemetryHandler, mockTelemetryFormatter); + mockConductor = jasmine.createSpyObj('conductor', [ + 'on', + 'off', + 'bounds', + 'timeSystem', + 'timeOfInterest' + ]); + + controller = new TableController(mockScope, mockTelemetryHandler, mockTelemetryFormatter, {conductor: mockConductor}); controller.table = mockTable; controller.handle = mockTelemetryHandle; }); diff --git a/src/api/TimeConductor.js b/src/api/TimeConductor.js index 135e29f1a5..470d16b4e2 100644 --- a/src/api/TimeConductor.js +++ b/src/api/TimeConductor.js @@ -75,12 +75,6 @@ define(['EventEmitter'], function (EventEmitter) { return true; }; - function throwOnError(validationResult) { - if (validationResult !== true) { - throw new Error(validationResult); - } - } - /** * Get or set the follow mode of the time conductor. In follow mode the * time conductor ticks, regularly updating the bounds from a timing @@ -127,8 +121,12 @@ define(['EventEmitter'], function (EventEmitter) { */ TimeConductor.prototype.bounds = function (newBounds) { if (arguments.length > 0) { - throwOnError(this.validateBounds(newBounds)); - this.boundsVal = newBounds; + var validationResult = this.validateBounds(newBounds); + if (validationResult !== true) { + throw new Error(validationResult); + } + //Create a copy to avoid direct mutation of conductor bounds + this.boundsVal = JSON.parse(JSON.stringify(newBounds)); /** * The start time, end time, or both have been updated. * @event bounds @@ -136,8 +134,15 @@ define(['EventEmitter'], function (EventEmitter) { * @property {TimeConductorBounds} bounds */ this.emit('bounds', this.boundsVal); + + // If a bounds change results in a TOI outside of the current + // bounds, unset it + if (this.toi < newBounds.start || this.toi > newBounds.end) { + this.timeOfInterest(undefined); + } } - return this.boundsVal; + //Return a copy to prevent direct mutation of time conductor bounds. + return JSON.parse(JSON.stringify(this.boundsVal)); }; /** @@ -164,9 +169,6 @@ define(['EventEmitter'], function (EventEmitter) { * Time System * */ this.emit('timeSystem', this.system); - // Do something with bounds here. Try and convert between - // time systems? Or just set defaults when time system changes? - // eg. this.bounds(bounds); } else if (arguments.length === 1) { throw new Error('Must set bounds when changing time system'); @@ -177,7 +179,8 @@ define(['EventEmitter'], function (EventEmitter) { /** * Get or set the Time of Interest. The Time of Interest is the temporal * focus of the current view. It can be manipulated by the user from the - * time conductor or from other views. + * time conductor or from other views.The time of interest can + * effectively be unset by assigning a value of 'undefined'. * @fires module:openmct.TimeConductor~timeOfInterest * @param newTOI * @returns {number} the current time of interest diff --git a/src/api/TimeConductorSpec.js b/src/api/TimeConductorSpec.js index 536667a648..26d66fdcf2 100644 --- a/src/api/TimeConductorSpec.js +++ b/src/api/TimeConductorSpec.js @@ -52,19 +52,19 @@ define(['./TimeConductor'], function (TimeConductor) { bounds = {start: 0, end: 1}; expect(tc.bounds()).not.toBe(bounds); expect(tc.bounds.bind(tc, bounds)).not.toThrow(); - expect(tc.bounds()).toBe(bounds); + expect(tc.bounds()).toEqual(bounds); }); it("Disallows setting of invalid bounds", function () { bounds = {start: 1, end: 0}; - expect(tc.bounds()).not.toBe(bounds); + expect(tc.bounds()).not.toEqual(bounds); expect(tc.bounds.bind(tc, bounds)).toThrow(); - expect(tc.bounds()).not.toBe(bounds); + expect(tc.bounds()).not.toEqual(bounds); bounds = {start: 1}; - expect(tc.bounds()).not.toBe(bounds); + expect(tc.bounds()).not.toEqual(bounds); expect(tc.bounds.bind(tc, bounds)).toThrow(); - expect(tc.bounds()).not.toBe(bounds); + expect(tc.bounds()).not.toEqual(bounds); }); it("Allows setting of time system with bounds", function () { @@ -106,5 +106,17 @@ define(['./TimeConductor'], function (TimeConductor) { tc.follow(follow); expect(eventListener).toHaveBeenCalledWith(follow); }); + + it("If bounds are set and TOI lies inside them, do not change TOI", function () { + tc.timeOfInterest(6); + tc.bounds({start: 1, end: 10}); + expect(tc.timeOfInterest()).toEqual(6); + }); + + it("If bounds are set and TOI lies outside them, reset TOI", function () { + tc.timeOfInterest(11); + tc.bounds({start: 1, end: 10}); + expect(tc.timeOfInterest()).toBeUndefined(); + }); }); });