diff --git a/Apps/Sandcastle/templates/bucket.css b/Apps/Sandcastle/templates/bucket.css index df23fc8ff22c..de59dfec323d 100644 --- a/Apps/Sandcastle/templates/bucket.css +++ b/Apps/Sandcastle/templates/bucket.css @@ -125,9 +125,12 @@ option:disabled { background:linear-gradient(to bottom, #dedede 5%, #f9f9f9 100%); } -button:active { - position:relative; - top:1px; +.sandcastle-button:active { + -webkit-transform: translateY(1px); + -moz-transform: translateY(1px); + -ms-transform: translateY(1px); + -o-transform: translateY(1px); + transform: translateY(1px); } .claro .dijitTitlePaneContentOuter { diff --git a/CHANGES.md b/CHANGES.md index b74c0fb0dddd..1dfc60e441da 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,7 +4,12 @@ Change Log Beta Releases ------------- +### b26 - 2014-03-03 + +* Added new `SelectionIndicator` and `InfoBox` widgets to `Viewer`, activated by `viewerDynamicObjectMixin`. + ### b25 - 2014-02-03 + * Breaking changes: * The `Viewer` constructor argument `options.fullscreenElement` now matches the `FullscreenButton` default of `document.body`, it was previously the `Viewer` container itself. * Removed `Viewer.objectTracked` event; `Viewer.trackedObject` is now an ES5 Knockout observable that can be subscribed to directly. diff --git a/Source/Widgets/InfoBox/InfoBox.css b/Source/Widgets/InfoBox/InfoBox.css new file mode 100644 index 000000000000..4ecc1a32425a --- /dev/null +++ b/Source/Widgets/InfoBox/InfoBox.css @@ -0,0 +1,109 @@ +.cesium-infoBox { + display: block; + position: absolute; + top: 50px; + right: 0; + width: 40%; + max-width: 360px; + background: rgba(38, 38, 38, 0.95); + color: #edffff; + border: 1px solid #444; + border-right: none; + border-top-left-radius: 7px; + border-bottom-left-radius: 7px; + box-shadow: 0 0 10px 1px #000; + -webkit-transform: translate(100%, 0); + -moz-transform: translate(100%, 0); + transform: translate(100%, 0); + visibility: hidden; + opacity: 0; + -webkit-transition: visibility 0s 0.2s, opacity 0.2s ease-in, -webkit-transform 0.2s ease-in; + -moz-transition: visibility 0s 0.2s, opacity 0.2s ease-in, -moz-transform 0.2s ease-in; + transition: visibility 0s 0.2s, opacity 0.2s ease-in, transform 0.2s ease-in; +} + +.cesium-infoBox-visible { + -webkit-transform: translate(0, 0); + -moz-transform: translate(0, 0); + transform: translate(0, 0); + visibility: visible; + opacity: 1; + -webkit-transition: opacity 0.2s ease-out, -webkit-transform 0.2s ease-out; + -moz-transition: opacity 0.2s ease-out, -moz-transform 0.2s ease-out; + transition: opacity 0.2s ease-out, transform 0.2s ease-out; +} + +.cesium-infoBox-title { + display: block; + height: 20px; + padding: 5px 30px 5px 25px; + background: rgba(84, 84, 84, 0.8); + border-top-left-radius: 7px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.cesium-infoBox-bodyless .cesium-infoBox-title { + border-bottom-left-radius: 7px; +} + +button.cesium-infoBox-camera { + display: block; + position: absolute; + top: 4px; + left: 4px; + width: 22px; + height: 22px; + background: transparent; + border-color: transparent; + border-radius: 3px; + padding: 0 5px; + margin: 0; +} + +button.cesium-infoBox-close { + display: block; + position: absolute; + top: 5px; + right: 5px; + height: 20px; + background: transparent; + border: none; + border-radius: 2px; + font-weight: bold; + font-size:16px; + padding: 0 5px; + margin: 0; + color: #edffff; +} + +button.cesium-infoBox-close:focus { + background: rgba(238, 136, 0, 0.44); + outline: none; +} + +button.cesium-infoBox-close:hover { + background: #888; + color: #000; +} + +button.cesium-infoBox-close:active { + background: #a00; + color: #000; +} + +.cesium-infoBox-body { + padding: 4px 10px; + margin-right: 4px; + overflow: auto; +} + +.cesium-infoBox-bodyless .cesium-infoBox-body { + display: none; +} + +.cesium-infoBox-description { + font-size: 13px; +} diff --git a/Source/Widgets/InfoBox/InfoBox.js b/Source/Widgets/InfoBox/InfoBox.js new file mode 100644 index 000000000000..822f4fd95d53 --- /dev/null +++ b/Source/Widgets/InfoBox/InfoBox.js @@ -0,0 +1,136 @@ +/*global define*/ +define([ + '../../Core/defined', + '../../Core/defineProperties', + '../../Core/DeveloperError', + '../../Core/destroyObject', + '../getElement', + './InfoBoxViewModel', + '../../ThirdParty/knockout' + ], function( + defined, + defineProperties, + DeveloperError, + destroyObject, + getElement, + InfoBoxViewModel, + knockout) { + "use strict"; + + /** + * A widget for displaying information or a description. + * + * @alias InfoBox + * @constructor + * + * @param {Element|String} container The DOM element or ID that will contain the widget. + * + * @exception {DeveloperError} container is required. + * @exception {DeveloperError} Element with id "container" does not exist in the document. + */ + var InfoBox = function(container) { + //>>includeStart('debug', pragmas.debug); + if (!defined(container)) { + throw new DeveloperError('container is required.'); + } + //>>includeEnd('debug') + + container = getElement(container); + + this._container = container; + + var infoElement = document.createElement('div'); + infoElement.className = 'cesium-infoBox'; + infoElement.setAttribute('data-bind', '\ +css: { "cesium-infoBox-visible" : showInfo, "cesium-infoBox-bodyless" : _bodyless }'); + container.appendChild(infoElement); + this._element = infoElement; + + var titleElement = document.createElement('div'); + titleElement.className = 'cesium-infoBox-title'; + titleElement.setAttribute('data-bind', 'text: titleText'); + infoElement.appendChild(titleElement); + + var cameraElement = document.createElement('button'); + cameraElement.type = 'button'; + cameraElement.className = 'cesium-button cesium-infoBox-camera'; + cameraElement.setAttribute('data-bind', '\ +attr: { title: "Focus camera on object" },\ +click: function () { cameraClicked.raiseEvent(); },\ +enable: enableCamera,\ +cesiumSvgPath: { path: cameraIconPath, width: 32, height: 32 }'); + infoElement.appendChild(cameraElement); + + var closeElement = document.createElement('button'); + closeElement.type = 'button'; + closeElement.className = 'cesium-infoBox-close'; + closeElement.setAttribute('data-bind', '\ +click: function () { closeClicked.raiseEvent(); }'); + closeElement.innerHTML = '×'; + infoElement.appendChild(closeElement); + + var infoBodyElement = document.createElement('div'); + infoBodyElement.className = 'cesium-infoBox-body'; + infoElement.appendChild(infoBodyElement); + + var descriptionElement = document.createElement('div'); + descriptionElement.className = 'cesium-infoBox-description'; + descriptionElement.setAttribute('data-bind', '\ +html: descriptionSanitizedHtml,\ +style : { maxHeight : maxHeightOffset(40) }'); + infoBodyElement.appendChild(descriptionElement); + + var viewModel = new InfoBoxViewModel(); + this._viewModel = viewModel; + + knockout.applyBindings(this._viewModel, infoElement); + }; + + defineProperties(InfoBox.prototype, { + /** + * Gets the parent container. + * @memberof InfoBox.prototype + * + * @type {Element} + */ + container : { + get : function() { + return this._container; + } + }, + + /** + * Gets the view model. + * @memberof InfoBox.prototype + * + * @type {SelectionIndicatorViewModel} + */ + viewModel : { + get : function() { + return this._viewModel; + } + } + }); + + /** + * @memberof InfoBox + * @returns {Boolean} true if the object has been destroyed, false otherwise. + */ + InfoBox.prototype.isDestroyed = function() { + return false; + }; + + /** + * Destroys the widget. Should be called if permanently + * removing the widget from layout. + * @memberof InfoBox + */ + InfoBox.prototype.destroy = function() { + var container = this._container; + knockout.cleanNode(this._element); + container.removeChild(this._element); + return destroyObject(this); + }; + + return InfoBox; +}); \ No newline at end of file diff --git a/Source/Widgets/InfoBox/InfoBoxViewModel.js b/Source/Widgets/InfoBox/InfoBoxViewModel.js new file mode 100644 index 000000000000..4dd6fe18f460 --- /dev/null +++ b/Source/Widgets/InfoBox/InfoBoxViewModel.js @@ -0,0 +1,194 @@ +/*global define*/ +define([ + '../../Core/Cartesian2', + '../../Core/defaultValue', + '../../Core/defined', + '../../Core/defineProperties', + '../../Core/Event', + '../../Core/TaskProcessor', + '../../ThirdParty/knockout', + '../../ThirdParty/when' + ], function( + Cartesian2, + defaultValue, + defined, + defineProperties, + Event, + TaskProcessor, + knockout, + when) { + "use strict"; + + var screenSpacePos = new Cartesian2(); + var cameraEnabledPath = 'M 13.84375 7.03125 C 11.412798 7.03125 9.46875 8.975298 9.46875 11.40625 L 9.46875 11.59375 L 2.53125 7.21875 L 2.53125 24.0625 L 9.46875 19.6875 C 9.4853444 22.104033 11.423165 24.0625 13.84375 24.0625 L 25.875 24.0625 C 28.305952 24.0625 30.28125 22.087202 30.28125 19.65625 L 30.28125 11.40625 C 30.28125 8.975298 28.305952 7.03125 25.875 7.03125 L 13.84375 7.03125 z'; + var cameraDisabledPath = 'M 27.34375 1.65625 L 5.28125 27.9375 L 8.09375 30.3125 L 30.15625 4.03125 L 27.34375 1.65625 z M 13.84375 7.03125 C 11.412798 7.03125 9.46875 8.975298 9.46875 11.40625 L 9.46875 11.59375 L 2.53125 7.21875 L 2.53125 24.0625 L 9.46875 19.6875 C 9.4724893 20.232036 9.5676108 20.7379 9.75 21.21875 L 21.65625 7.03125 L 13.84375 7.03125 z M 28.21875 7.71875 L 14.53125 24.0625 L 25.875 24.0625 C 28.305952 24.0625 30.28125 22.087202 30.28125 19.65625 L 30.28125 11.40625 C 30.28125 9.8371439 29.456025 8.4902779 28.21875 7.71875 z'; + + /** + * The view model for {@link InfoBox}. + * @alias InfoBoxViewModel + * @constructor + */ + var InfoBoxViewModel = function() { + this._sanitizer = undefined; + this._descriptionRawHtml = ''; + this._descriptionSanitizedHtml = ''; + this._cameraClicked = new Event(); + this._closeClicked = new Event(); + + /** + * Gets or sets the maximum height of the info box in pixels. This property is observable. + * @type {Number} + */ + this.maxHeight = 500; + + /** + * Gets or sets whether the camera tracking icon is enabled. + * @type {Boolean} + */ + this.enableCamera = false; + + /** + * Gets or sets the status of current camera tracking of the selected object. + * @type {Boolean} + */ + this.isCameraTracking = false; + + /** + * Gets or sets the visibility of the info box. + * @type {Boolean} + */ + this.showInfo = false; + + /** + * Gets or sets the title text in the info box. + * @type {String} + */ + this.titleText = ''; + + knockout.track(this, ['showInfo', 'titleText', '_descriptionRawHtml', '_descriptionSanitizedHtml', 'maxHeight', 'enableCamera', 'isCameraTracking']); + + /** + * Gets or sets the un-sanitized description HTML for the info box. + * @type {String} + */ + this.descriptionRawHtml = undefined; + knockout.defineProperty(this, 'descriptionRawHtml', { + get : function() { + return this._descriptionRawHtml; + }, + set : function(value) { + if (this._descriptionRawHtml !== value) { + this._descriptionRawHtml = value; + this._descriptionSanitizedHtml = ''; + var that = this; + when(this.sanitizer(value), function(sanitized) { + // make sure the raw HTML still matches the input we sanitized, + // in case it was changed again while we were sanitizing. + if (that._descriptionRawHtml === value) { + that._descriptionSanitizedHtml = sanitized; + } + }).otherwise(function(e) { + /*global console*/ + var message = defined(e.name) && defined(e.message) ? (e.name + ': ' + e.message) : e.toString(); + if (defined(e.stack)) { + message += '\n' + e.stack; + } + console.log('An error occurred while sanitizing HTML: ' + message); + }); + } + } + }); + + /** + * Gets the sanitized description HTML for the info box. + * @type {String} + */ + this.descriptionSanitizedHtml = undefined; + knockout.defineProperty(this, 'descriptionSanitizedHtml', { + get : function() { + return this._descriptionSanitizedHtml; + } + }); + + /** + * Gets the SVG path of the camera icon, which can change to be "crossed out" or not. + * @type {String} + */ + this.cameraIconPath = undefined; + knockout.defineProperty(this, 'cameraIconPath', { + get : function() { + return (this.enableCamera || this.isCameraTracking) ? cameraEnabledPath : cameraDisabledPath; + } + }); + + /** + * Gets the maximum height of sections within the info box, minus an offset, in CSS-ready form. + * @param {Number} offset The offset in pixels. + * @returns {String} + */ + InfoBoxViewModel.prototype.maxHeightOffset = function(offset) { + return (this.maxHeight - offset) + 'px'; + }; + + knockout.defineProperty(this, '_bodyless', { + get : function() { + return !this._descriptionSanitizedHtml; + } + }); + }; + + var sanitizerTaskProcessor; + function defaultSanitizer(html) { + if (!defined(sanitizerTaskProcessor)) { + sanitizerTaskProcessor = new TaskProcessor('sanitizeHtml', Infinity); + } + return sanitizerTaskProcessor.scheduleTask(html); + } + + /** + * Gets or sets the default HTML sanitization function to use for all instances. + * By default, the Google Caja HTML/CSS sanitizer is loaded in a worker. + * A specific instance can override this property by setting its sanitizer property. + * + * This property returns a function which takes a unsanitized HTML String and returns a + * sanitized String, or a Promise which resolves to the sanitized version. + * @memberof InfoBoxViewModel + */ + InfoBoxViewModel.defaultSanitizer = defaultSanitizer; + + defineProperties(InfoBoxViewModel.prototype, { + /** + * Gets an {@link Event} that is fired when the user clicks the camera icon. + */ + cameraClicked : { + get : function() { + return this._cameraClicked; + } + }, + /** + * Gets an {@link Event} that is fired when the user closes the info box. + */ + closeClicked : { + get : function() { + return this._closeClicked; + } + }, + /** + * Gets the HTML sanitization function to use for the selection description. + */ + sanitizer : { + get : function() { + return defaultValue(this._sanitizer, InfoBoxViewModel.defaultSanitizer); + }, + set : function(value) { + this._sanitizer = value; + //Force resanitization of existing text + var oldHtml = this._descriptionRawHtml; + this._descriptionRawHtml = ''; + this.descriptionRawHtml = oldHtml; + } + } + }); + + return InfoBoxViewModel; +}); diff --git a/Source/Widgets/SelectionIndicator/SelectionIndicator.css b/Source/Widgets/SelectionIndicator/SelectionIndicator.css new file mode 100644 index 000000000000..de9c10a5fe23 --- /dev/null +++ b/Source/Widgets/SelectionIndicator/SelectionIndicator.css @@ -0,0 +1,25 @@ +.cesium-selection-wrapper { + position: absolute; + width: 160px; + height: 160px; + pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: visibility 0s 0.2s, opacity 0.2s ease-in; + -moz-transition: visibility 0s 0.2s, opacity 0.2s ease-in; + transition: visibility 0s 0.2s, opacity 0.2s ease-in; +} + +.cesium-selection-wrapper-visible { + visibility: visible; + opacity: 1; + -webkit-transition: opacity 0.2s ease-out; + -moz-transition: opacity 0.2s ease-out; + transition: opacity 0.2s ease-out; +} + +.cesium-selection-wrapper svg { + fill: #2e2; + stroke: #000; + stroke-width: 1.1px; +} diff --git a/Source/Widgets/SelectionIndicator/SelectionIndicator.js b/Source/Widgets/SelectionIndicator/SelectionIndicator.js new file mode 100644 index 000000000000..8119f7331582 --- /dev/null +++ b/Source/Widgets/SelectionIndicator/SelectionIndicator.js @@ -0,0 +1,123 @@ +/*global define*/ +define([ + '../../Core/defined', + '../../Core/defineProperties', + '../../Core/DeveloperError', + '../../Core/destroyObject', + '../getElement', + './SelectionIndicatorViewModel', + '../../ThirdParty/knockout' + ], function( + defined, + defineProperties, + DeveloperError, + destroyObject, + getElement, + SelectionIndicatorViewModel, + knockout) { + "use strict"; + + /** + * A widget for displaying an indicator on a selected object. + * + * @alias SelectionIndicator + * @constructor + * + * @param {Element|String} container The DOM element or ID that will contain the widget. + * @param {Scene} scene The Scene instance to use. + * + * @exception {DeveloperError} container is required. + * @exception {DeveloperError} Element with id "container" does not exist in the document. + */ + var SelectionIndicator = function(container, scene) { + //>>includeStart('debug', pragmas.debug); + if (!defined(container)) { + throw new DeveloperError('container is required.'); + } + //>>includeEnd('debug') + + container = getElement(container); + + this._container = container; + + var el = document.createElement('div'); + el.className = 'cesium-selection-wrapper'; + el.setAttribute('data-bind', '\ +style: { "bottom" : _screenPositionY, "left" : _screenPositionX },\ +css: { "cesium-selection-wrapper-visible" : isVisible }'); + container.appendChild(el); + this._element = el; + + var svgNS = 'http://www.w3.org/2000/svg'; + var path = 'M -34 -34 L -34 -11.25 L -30 -15.25 L -30 -30 L -15.25 -30 L -11.25 -34 L -34 -34 z M 11.25 -34 L 15.25 -30 L 30 -30 L 30 -15.25 L 34 -11.25 L 34 -34 L 11.25 -34 z M -34 11.25 L -34 34 L -11.25 34 L -15.25 30 L -30 30 L -30 15.25 L -34 11.25 z M 34 11.25 L 30 15.25 L 30 30 L 15.25 30 L 11.25 34 L 34 34 L 34 11.25 z'; + + var svg = document.createElementNS(svgNS, 'svg:svg'); + svg.setAttribute('width', 160); + svg.setAttribute('height', 160); + svg.setAttribute('viewBox', '0 0 160 160'); + + var group = document.createElementNS(svgNS, 'g'); + group.setAttribute('transform', 'translate(80,80)'); + svg.appendChild(group); + + var pathElement = document.createElementNS(svgNS, 'path'); + pathElement.setAttribute('data-bind', 'attr: { transform: _transform }'); + pathElement.setAttribute('d', path); + group.appendChild(pathElement); + + el.appendChild(svg); + + var viewModel = new SelectionIndicatorViewModel(scene, this._element, this._container); + this._viewModel = viewModel; + + knockout.applyBindings(this._viewModel, this._element); + }; + + defineProperties(SelectionIndicator.prototype, { + /** + * Gets the parent container. + * @memberof SelectionIndicator.prototype + * + * @type {Element} + */ + container : { + get : function() { + return this._container; + } + }, + + /** + * Gets the view model. + * @memberof SelectionIndicator.prototype + * + * @type {SelectionIndicatorViewModel} + */ + viewModel : { + get : function() { + return this._viewModel; + } + } + }); + + /** + * @memberof SelectionIndicator + * @returns {Boolean} true if the object has been destroyed, false otherwise. + */ + SelectionIndicator.prototype.isDestroyed = function() { + return false; + }; + + /** + * Destroys the widget. Should be called if permanently + * removing the widget from layout. + * @memberof SelectionIndicator + */ + SelectionIndicator.prototype.destroy = function() { + var container = this._container; + knockout.cleanNode(this._element); + container.removeChild(this._element); + return destroyObject(this); + }; + + return SelectionIndicator; +}); \ No newline at end of file diff --git a/Source/Widgets/SelectionIndicator/SelectionIndicatorViewModel.js b/Source/Widgets/SelectionIndicator/SelectionIndicatorViewModel.js new file mode 100644 index 000000000000..2e4ea62063cf --- /dev/null +++ b/Source/Widgets/SelectionIndicator/SelectionIndicatorViewModel.js @@ -0,0 +1,233 @@ +/*global define*/ +define([ + '../../Core/Cartesian2', + '../../Core/defaultValue', + '../../Core/defined', + '../../Core/defineProperties', + '../../Core/DeveloperError', + '../../Scene/SceneTransforms', + '../../ThirdParty/knockout', + '../../ThirdParty/Tween' + ], function( + Cartesian2, + defaultValue, + defined, + defineProperties, + DeveloperError, + SceneTransforms, + knockout, + Tween) { + "use strict"; + + var screenSpacePos = new Cartesian2(); + + /** + * The view model for {@link SelectionIndicator}. + * @alias SelectionIndicatorViewModel + * @constructor + * + * @param {Scene} scene The scene instance to use for screen-space coordinate conversion. + * @param {Element} selectionIndicatorElement The element containing all elements that make up the selection indicator. + * @param {Element} container The DOM element that contains the widget. + * + * @exception {DeveloperError} scene is required. + * @exception {DeveloperError} selectionIndicatorElement is required. + * @exception {DeveloperError} container is required. + * + */ + var SelectionIndicatorViewModel = function(scene, selectionIndicatorElement, container) { + //>>includeStart('debug', pragmas.debug); + if (!defined(scene)) { + throw new DeveloperError('scene is required.'); + } + + if (!defined(selectionIndicatorElement)) { + throw new DeveloperError('selectionIndicatorElement is required.'); + } + + if (!defined(container)) { + throw new DeveloperError('container is required.'); + } + //>>includeEnd('debug') + + this._scene = scene; + this._screenPositionX = '-1000px'; + this._screenPositionY = '0'; + this._animationCollection = scene.getAnimations(); + this._container = defaultValue(container, document.body); + this._selectionIndicatorElement = selectionIndicatorElement; + this._computeScreenSpacePosition = function(position, result) { + return SceneTransforms.wgs84ToWindowCoordinates(scene, position, result); + }; + + /** + * Gets or sets the world position of the object for which to display the selection indicator. + * @type {Cartesian3} + */ + this.position = undefined; + + /** + * Gets or sets the scale of the indicator relative to its default size. + * @type {Number} + */ + this.scale = 1; + + /** + * Gets or sets the rotation angle of the indicator, in degrees. + * @type {Number} + */ + this.rotation = 0; + + /** + * Gets or sets the visibility of the selection indicator. + * @type {Boolean} + */ + this.showSelection = false; + + knockout.track(this, ['position', '_screenPositionX', '_screenPositionY', 'scale', 'rotation', 'showSelection']); + + /** + * Gets the visibility of the position indicator. This can be false even if an + * object is selected, when the selected object has no position. + * @type {Boolean} + */ + this.isVisible = undefined; + knockout.defineProperty(this, 'isVisible', { + get : function() { + return this.showSelection && defined(this.position); + } + }); + + knockout.defineProperty(this, '_transform', { + get : function() { + return 'rotate(' + (this.rotation) + ') scale(' + (this.scale) + ')'; + } + }); + }; + + /** + * Updates the view of the selection indicator to match the position and content properties of the view model. + * This function should be called as part of the render loop. + * @memberof SelectionIndicatorViewModel + */ + SelectionIndicatorViewModel.prototype.update = function() { + if (this.showSelection && defined(this.position)) { + var screenPosition = this._computeScreenSpacePosition(this.position, screenSpacePos); + var container = this._container; + var containerWidth = container.parentNode.clientWidth; + var containerHeight = container.parentNode.clientHeight; + var indicatorSize = this._selectionIndicatorElement.clientWidth; + var halfSize = indicatorSize * 0.5; + + screenPosition.x = Math.min(Math.max(screenPosition.x, 0), containerWidth) - halfSize; + screenPosition.y = Math.min(Math.max(screenPosition.y, 0), containerHeight) - halfSize; + + this._screenPositionX = Math.floor(screenPosition.x + 0.25) + 'px'; + this._screenPositionY = Math.floor(screenPosition.y + 0.25) + 'px'; + } + }; + + /** + * Animate the indicator to draw attention to the selection. + * @memberof SelectionIndicatorViewModel + */ + SelectionIndicatorViewModel.prototype.animateAppear = function() { + var viewModel = this; + this._animationCollection.add({ + startValue : { + scale : 2 + }, + stopValue : { + scale: 1 + }, + duration : 800, + easingFunction : Tween.Easing.Exponential.Out, + onUpdate : function (value) { + viewModel.scale = value.scale; + } + }); + }; + + /** + * Animate the indicator to release the selection. + * @memberof SelectionIndicatorViewModel + */ + SelectionIndicatorViewModel.prototype.animateDepart = function() { + var viewModel = this; + this._animationCollection.add({ + startValue : { + scale : viewModel.scale + }, + stopValue : { + scale : 1.5 + }, + duration : 800, + easingFunction : Tween.Easing.Exponential.Out, + onUpdate : function (value) { + viewModel.scale = value.scale; + } + }); + }; + + defineProperties(SelectionIndicatorViewModel.prototype, { + /** + * Gets the HTML element containing the selection indicator. + * @memberof SelectionIndicatorViewModel.prototype + * + * @type {Element} + */ + container : { + get : function() { + return this._container; + } + }, + /** + * Gets the HTML element that holds the selection indicator. + * @memberof SelectionIndicatorViewModel.prototype + * + * @type {Element} + */ + selectionIndicatorElement : { + get : function() { + return this._selectionIndicatorElement; + } + }, + /** + * Gets the scene being used. + * @memberof SelectionIndicatorViewModel.prototype + * + * @type {Scene} + */ + scene : { + get : function() { + return this._scene; + } + }, + /** + * Gets or sets the function for converting the world position of the object to the screen space position. + * Expects the {Cartesian3} parameter for the position and the optional {Cartesian2} parameter for the result. + * Should return a {Cartesian2}. + * + * Defaults to SceneTransforms.wgs84ToWindowCoordinates + * + * @example + * selectionIndicatorViewModel.computeScreenSpacePosition = function(position, result) { + * return Cartesian2.clone(position, result); + * }; + * + * @memberof SelectionIndicatorViewModel.prototype + * + * @type {Function} + */ + computeScreenSpacePosition : { + get : function() { + return this._computeScreenSpacePosition; + }, + set : function(value) { + this._computeScreenSpacePosition = value; + } + } + }); + + return SelectionIndicatorViewModel; +}); diff --git a/Source/Widgets/Viewer/Viewer.js b/Source/Widgets/Viewer/Viewer.js index e11ad6eff758..2b9251e4b3e7 100644 --- a/Source/Widgets/Viewer/Viewer.js +++ b/Source/Widgets/Viewer/Viewer.js @@ -19,9 +19,11 @@ define([ '../FullscreenButton/FullscreenButton', '../Geocoder/Geocoder', '../getElement', - '../subscribeAndEvaluate', '../HomeButton/HomeButton', + '../InfoBox/InfoBox', '../SceneModePicker/SceneModePicker', + '../SelectionIndicator/SelectionIndicator', + '../subscribeAndEvaluate', '../Timeline/Timeline' ], function( defaultValue, @@ -43,9 +45,11 @@ define([ FullscreenButton, Geocoder, getElement, - subscribeAndEvaluate, HomeButton, + InfoBox, SceneModePicker, + SelectionIndicator, + subscribeAndEvaluate, Timeline) { "use strict"; @@ -100,7 +104,9 @@ define([ * @param {Boolean} [options.fullscreenButton=true] If set to false, the FullscreenButton widget will not be created. * @param {Boolean} [options.geocoder=true] If set to false, the Geocoder widget will not be created. * @param {Boolean} [options.homeButton=true] If set to false, the HomeButton widget will not be created. + * @param {Boolean} [options.infoBox=true] If set to false, the InfoBox widget will not be created. * @param {Boolean} [options.sceneModePicker=true] If set to false, the SceneModePicker widget will not be created. + * @param {Boolean} [options.selectionIndicator=true] If set to false, the SelectionIndicator widget will not be created. * @param {Boolean} [options.timeline=true] If set to false, the Timeline widget will not be created. * @param {ImageryProviderViewModel} [options.selectedImageryProviderViewModel] The view model for the current base imagery layer, if not supplied the first available base layer is used. This value is only valid if options.baseLayerPicker is set to true. * @param {Array} [options.imageryProviderViewModels=createDefaultBaseLayers()] The array of ImageryProviderViewModels to be selectable from the BaseLayerPicker. This value is only valid if options.baseLayerPicker is set to true. @@ -225,6 +231,25 @@ Either specify options.imageryProvider instead or set options.baseLayerPicker to dataSourceDisplay.update(clock.currentTime); }); + //Selection Indicator + var selectionIndicator; + if (!defined(options.selectionIndicator) || options.selectionIndicator !== false) { + var selectionIndicatorContainer = document.createElement('div'); + selectionIndicatorContainer.className = 'cesium-viewer-selectionIndicatorContainer'; + viewerContainer.appendChild(selectionIndicatorContainer); + selectionIndicator = new SelectionIndicator(selectionIndicatorContainer, cesiumWidget.scene); + } + + //Info Box + var infoBox; + if (!defined(options.infoBox) || options.infoBox !== false) { + var infoBoxContainer = document.createElement('div'); + infoBoxContainer.className = 'cesium-viewer-infoBoxContainer'; + viewerContainer.appendChild(infoBoxContainer); + infoBox = new InfoBox(infoBoxContainer); + } + + //Main Toolbar var toolbar = document.createElement('div'); toolbar.className = 'cesium-viewer-toolbar'; viewerContainer.appendChild(toolbar); @@ -354,6 +379,8 @@ Either specify options.imageryProvider instead or set options.baseLayerPicker to this._container = container; this._element = viewerContainer; this._cesiumWidget = cesiumWidget; + this._selectionIndicator = selectionIndicator; + this._infoBox = infoBox; this._dataSourceCollection = dataSourceCollection; this._dataSourceDisplay = dataSourceDisplay; this._clockViewModel = clockViewModel; @@ -400,6 +427,28 @@ Either specify options.imageryProvider instead or set options.baseLayerPicker to } }, + /** + * Gets the selection indicator. + * @memberof Viewer.prototype + * @type {SelectionIndicator} + */ + selectionIndicator : { + get : function() { + return this._selectionIndicator; + } + }, + + /** + * Gets the info box. + * @memberof Viewer.prototype + * @type {InfoBox} + */ + infoBox : { + get : function() { + return this._infoBox; + } + }, + /** * Gets the Geocoder. * @memberof Viewer.prototype @@ -655,10 +704,15 @@ Either specify options.imageryProvider instead or set options.baseLayerPicker to return; } + var panelMaxHeight = height - 125; + var baseLayerPickerDropDown = this._baseLayerPickerDropDown; if (defined(baseLayerPickerDropDown)) { - var baseLayerPickerMaxHeight = height - 125; - baseLayerPickerDropDown.style.maxHeight = baseLayerPickerMaxHeight + 'px'; + baseLayerPickerDropDown.style.maxHeight = panelMaxHeight + 'px'; + } + + if (defined(this._infoBox)) { + this._infoBox.viewModel.maxHeight = panelMaxHeight; } var timelineExists = defined(this._timeline); @@ -780,6 +834,16 @@ Either specify options.imageryProvider instead or set options.baseLayerPicker to this._fullscreenButton = this._fullscreenButton.destroy(); } + if (defined(this._infoBox)) { + this._element.removeChild(this._infoBox.container); + this._infoBox = this._infoBox.destroy(); + } + + if (defined(this._selectionIndicator)) { + this._element.removeChild(this._selectionIndicator.container); + this._selectionIndicator = this._selectionIndicator.destroy(); + } + this._clockViewModel = this._clockViewModel.destroy(); this._dataSourceDisplay = this._dataSourceDisplay.destroy(); this._cesiumWidget = this._cesiumWidget.destroy(); diff --git a/Source/Widgets/Viewer/viewerDynamicObjectMixin.js b/Source/Widgets/Viewer/viewerDynamicObjectMixin.js index 68fd468b010f..58cf05de3bba 100644 --- a/Source/Widgets/Viewer/viewerDynamicObjectMixin.js +++ b/Source/Widgets/Viewer/viewerDynamicObjectMixin.js @@ -1,24 +1,25 @@ /*global define*/ -define([ +define(['../../Core/BoundingSphere', + '../../Core/defaultValue', '../../Core/defined', '../../Core/DeveloperError', - '../../Core/defineProperties', - '../../Core/Event', '../../Core/EventHelper', '../../Core/ScreenSpaceEventType', '../../Core/wrapFunction', '../../Scene/SceneMode', + '../subscribeAndEvaluate', '../../DynamicScene/DynamicObjectView', '../../ThirdParty/knockout' ], function( + BoundingSphere, + defaultValue, defined, DeveloperError, - defineProperties, - Event, EventHelper, ScreenSpaceEventType, wrapFunction, SceneMode, + subscribeAndEvaluate, DynamicObjectView, knockout) { "use strict"; @@ -35,6 +36,7 @@ define([ * * @exception {DeveloperError} viewer is required. * @exception {DeveloperError} trackedObject is already defined by another mixin. + * @exception {DeveloperError} selectedObject is already defined by another mixin. * * @example * // Add support for working with DynamicObject instances to the Viewer. @@ -42,7 +44,9 @@ define([ * var viewer = new Cesium.Viewer('cesiumContainer'); * viewer.extend(Cesium.viewerDynamicObjectMixin); * viewer.trackedObject = dynamicObject; //Camera will now track dynamicObject + * viewer.selectedObject = object; //Selection will now appear over object */ + var viewerDynamicObjectMixin = function(viewer) { //>>includeStart('debug', pragmas.debug); if (!defined(viewer)) { @@ -51,83 +55,181 @@ define([ if (viewer.hasOwnProperty('trackedObject')) { throw new DeveloperError('trackedObject is already defined by another mixin.'); } + if (viewer.hasOwnProperty('selectedObject')) { + throw new DeveloperError('selectedObject is already defined by another mixin.'); + } //>>includeEnd('debug'); + var infoBox = viewer.infoBox; + var infoBoxViewModel = defined(infoBox) ? infoBox.viewModel : undefined; + + var selectionIndicator = viewer.selectionIndicator; + var selectionIndicatorViewModel = defined(selectionIndicator) ? selectionIndicator.viewModel : undefined; + + var enableInfoOrSelection = defined(infoBox) || defined(selectionIndicator); + var eventHelper = new EventHelper(); - var trackedObjectObservable = knockout.observable(); var dynamicObjectView; - //Subscribe to onTick so that we can update the view each update. - function updateView(clock) { + function trackSelectedObject() { + viewer.trackedObject = viewer.selectedObject; + } + + function clearTrackedObject() { + viewer.trackedObject = undefined; + } + + function clearSelectedObject() { + viewer.selectedObject = undefined; + } + + function clearObjects() { + viewer.trackedObject = undefined; + viewer.selectedObject = undefined; + } + + if (defined(infoBoxViewModel)) { + eventHelper.add(infoBoxViewModel.cameraClicked, trackSelectedObject); + eventHelper.add(infoBoxViewModel.closeClicked, clearSelectedObject); + } + + var scratchVertexPositions; + var scratchBoundingSphere; + + // Subscribe to onTick so that we can update the view each update. + function onTick(clock) { + var time = clock.currentTime; if (defined(dynamicObjectView)) { - dynamicObjectView.update(clock.currentTime); + dynamicObjectView.update(time); + } + + var selectedObject = viewer.selectedObject; + var showSelection = defined(selectedObject) && enableInfoOrSelection; + if (showSelection) { + var oldPosition = defined(selectionIndicatorViewModel) ? selectionIndicatorViewModel.position : undefined; + var position; + var enableCamera = false; + + if (selectedObject.isAvailable(time)) { + if (defined(selectedObject.position)) { + position = selectedObject.position.getValue(time, oldPosition); + enableCamera = defined(position) && (viewer.trackedObject !== viewer.selectedObject); + } else if (defined(selectedObject.vertexPositions)) { + scratchVertexPositions = selectedObject.vertexPositions.getValue(time, scratchVertexPositions); + scratchBoundingSphere = BoundingSphere.fromPoints(scratchVertexPositions, scratchBoundingSphere); + position = scratchBoundingSphere.center; + // Can't track scratch positions: "enableCamera" is false. + } + // else "position" is undefined and "enableCamera" is false. + } + // else "position" is undefined and "enableCamera" is false. + + if (defined(selectionIndicatorViewModel)) { + selectionIndicatorViewModel.position = position; + } + + if (defined(infoBoxViewModel)) { + infoBoxViewModel.enableCamera = enableCamera; + infoBoxViewModel.isCameraTracking = (viewer.trackedObject === viewer.selectedObject); + + if (defined(selectedObject.description)) { + infoBoxViewModel.descriptionRawHtml = defaultValue(selectedObject.description.getValue(time), ''); + } else { + infoBoxViewModel.descriptionRawHtml = ''; + } + } + } + + if (defined(selectionIndicatorViewModel)) { + selectionIndicatorViewModel.showSelection = showSelection; + selectionIndicatorViewModel.update(); + } + + if (defined(infoBoxViewModel)) { + infoBoxViewModel.showInfo = showSelection; } } - eventHelper.add(viewer.clock.onTick, updateView); + eventHelper.add(viewer.clock.onTick, onTick); function pickAndTrackObject(e) { - var p = viewer.scene.pick(e.position); - if (defined(p) && defined(p.primitive) && defined(p.primitive.dynamicObject) && defined(p.primitive.dynamicObject.position)) { - viewer.trackedObject = p.primitive.dynamicObject; + var picked = viewer.scene.pick(e.position); + if (defined(picked) && defined(picked.primitive) && defined(picked.primitive.dynamicObject)) { + viewer.trackedObject = picked.primitive.dynamicObject; } } - function clearTrackedObject() { - viewer.trackedObject = undefined; + function pickAndSelectObject(e) { + var picked = viewer.scene.pick(e.position); + if (defined(picked) && defined(picked.primitive) && defined(picked.primitive.dynamicObject)) { + viewer.selectedObject = picked.primitive.dynamicObject; + } else { + viewer.selectedObject = undefined; + } } - //Subscribe to the home button click if it exists, so that we can - //clear the trackedObject when it is clicked. + // Subscribe to the home button beforeExecute event if it exists, + // so that we can clear the trackedObject. if (defined(viewer.homeButton)) { eventHelper.add(viewer.homeButton.viewModel.command.beforeExecute, clearTrackedObject); } - //Subscribe to the geocoder search if it exists, so that we can - //clear the trackedObject when it is clicked. + // Subscribe to the geocoder search if it exists, so that we can + // clear the trackedObject when it is clicked. if (defined(viewer.geocoder)) { - eventHelper.add(viewer.geocoder.viewModel.search.beforeExecute, clearTrackedObject); + eventHelper.add(viewer.geocoder.viewModel.search.beforeExecute, clearObjects); } - //We need to subscribe to the data sources and collections so that we can clear the - //tracked object when it is removed from the scene. + // We need to subscribe to the data sources and collections so that we can clear the + // tracked object when it is removed from the scene. function onDynamicCollectionChanged(collection, added, removed) { var length = removed.length; for (var i = 0; i < length; i++) { var removedObject = removed[i]; if (viewer.trackedObject === removedObject) { viewer.homeButton.viewModel.command(); - break; + } + if (viewer.selectedObject === removedObject) { + viewer.selectedObject = undefined; } } } function dataSourceAdded(dataSourceCollection, dataSource) { - dataSource.getDynamicObjectCollection().collectionChanged.addEventListener(onDynamicCollectionChanged); + var dynamicObjectCollection = dataSource.getDynamicObjectCollection(); + dynamicObjectCollection.collectionChanged.addEventListener(onDynamicCollectionChanged); } function dataSourceRemoved(dataSourceCollection, dataSource) { - dataSource.getDynamicObjectCollection().collectionChanged.removeEventListener(onDynamicCollectionChanged); + var dynamicObjectCollection = dataSource.getDynamicObjectCollection(); + dynamicObjectCollection.collectionChanged.removeEventListener(onDynamicCollectionChanged); if (defined(viewer.trackedObject)) { - if (dataSource.getDynamicObjectCollection().getById(viewer.trackedObject.id) === viewer.trackedObject) { + if (dynamicObjectCollection.getById(viewer.trackedObject.id) === viewer.trackedObject) { viewer.homeButton.viewModel.command(); } } + + if (defined(viewer.selectedObject)) { + if (dynamicObjectCollection.getById(viewer.selectedObject.id) === viewer.selectedObject) { + viewer.selectedObject = undefined; + } + } } - //Subscribe to current data sources + // Subscribe to current data sources var dataSources = viewer.dataSources; var dataSourceLength = dataSources.getLength(); for (var i = 0; i < dataSourceLength; i++) { dataSourceAdded(dataSources, dataSources.get(i)); } - //Hook up events so that we can subscribe to future sources. + // Hook up events so that we can subscribe to future sources. eventHelper.add(viewer.dataSources.dataSourceAdded, dataSourceAdded); eventHelper.add(viewer.dataSources.dataSourceRemoved, dataSourceRemoved); - //Subscribe to left clicks and zoom to the picked object. - viewer.screenSpaceEventHandler.setInputAction(pickAndTrackObject, ScreenSpaceEventType.LEFT_CLICK); + // Subscribe to left clicks and zoom to the picked object. + viewer.screenSpaceEventHandler.setInputAction(pickAndSelectObject, ScreenSpaceEventType.LEFT_CLICK); + viewer.screenSpaceEventHandler.setInputAction(pickAndTrackObject, ScreenSpaceEventType.LEFT_DOUBLE_CLICK); /** * Gets or sets the DynamicObject instance currently being tracked by the camera. @@ -135,35 +237,61 @@ define([ * @type {DynamicObject} */ viewer.trackedObject = undefined; - knockout.defineProperty(viewer, 'trackedObject', { - get : function() { - return trackedObjectObservable(); - }, - set : function(value) { - var sceneMode = viewer.scene.getFrameState().mode; - - if (sceneMode === SceneMode.COLUMBUS_VIEW || sceneMode === SceneMode.SCENE2D) { - viewer.scene.getScreenSpaceCameraController().enableTranslate = !defined(value); - } - if (sceneMode === SceneMode.COLUMBUS_VIEW || sceneMode === SceneMode.SCENE3D) { - viewer.scene.getScreenSpaceCameraController().enableTilt = !defined(value); - } + /** + * Gets or sets the object instance for which to display a selection indicator. + * @memberof viewerDynamicObjectMixin.prototype + * @type {DynamicObject} + */ + viewer.selectedObject = undefined; + + knockout.track(viewer, ['trackedObject', 'selectedObject']); + + var trackedObjectSubscription = subscribeAndEvaluate(viewer, 'trackedObject', function(value) { + var scene = viewer.scene; + var sceneMode = scene.getFrameState().mode; + var isTracking = defined(value); + + if (sceneMode === SceneMode.COLUMBUS_VIEW || sceneMode === SceneMode.SCENE2D) { + scene.getScreenSpaceCameraController().enableTranslate = !isTracking; + } + + if (sceneMode === SceneMode.COLUMBUS_VIEW || sceneMode === SceneMode.SCENE3D) { + scene.getScreenSpaceCameraController().enableTilt = !isTracking; + } + + if (isTracking && defined(value.position)) { + dynamicObjectView = new DynamicObjectView(value, scene, viewer.centralBody.getEllipsoid()); + } else { + dynamicObjectView = undefined; + } + }); - if (trackedObjectObservable() !== value) { - dynamicObjectView = defined(value) ? new DynamicObjectView(value, viewer.scene, viewer.centralBody.getEllipsoid()) : undefined; - trackedObjectObservable(value); + var selectedObjectSubscription = subscribeAndEvaluate(viewer, 'selectedObject', function(value) { + if (defined(value)) { + if (defined(infoBoxViewModel)) { + infoBoxViewModel.titleText = defined(value.name) ? value.name : value.id; + } + if (defined(selectionIndicatorViewModel)) { + selectionIndicatorViewModel.animateAppear(); + } + } else { + // Leave the info text in place here, it is needed during the exit animation. + if (defined(selectionIndicatorViewModel)) { + selectionIndicatorViewModel.animateDepart(); } } }); - //Wrap destroy to clean up event subscriptions. + // Wrap destroy to clean up event subscriptions. viewer.destroy = wrapFunction(viewer, viewer.destroy, function() { eventHelper.removeAll(); - + trackedObjectSubscription.dispose(); + selectedObjectSubscription.dispose(); viewer.screenSpaceEventHandler.removeInputAction(ScreenSpaceEventType.LEFT_CLICK); + viewer.screenSpaceEventHandler.removeInputAction(ScreenSpaceEventType.LEFT_DOUBLE_CLICK); - //Unsubscribe from data sources + // Unsubscribe from data sources var dataSources = viewer.dataSources; var dataSourceLength = dataSources.getLength(); for (var i = 0; i < dataSourceLength; i++) { @@ -173,4 +301,4 @@ define([ }; return viewerDynamicObjectMixin; -}); \ No newline at end of file +}); diff --git a/Source/Widgets/widgets.css b/Source/Widgets/widgets.css index 73c29b53b5d7..6648fc9a55e9 100644 --- a/Source/Widgets/widgets.css +++ b/Source/Widgets/widgets.css @@ -5,6 +5,8 @@ @import url(./checkForChromeFrame.css); @import url(./FullscreenButton/FullscreenButton.css); @import url(./Geocoder/Geocoder.css); +@import url(./InfoBox/InfoBox.css); @import url(./SceneModePicker/SceneModePicker.css); +@import url(./SelectionIndicator/SelectionIndicator.css); @import url(./Timeline/Timeline.css); @import url(./Viewer/Viewer.css); diff --git a/Source/Workers/sanitizeHtml.js b/Source/Workers/sanitizeHtml.js new file mode 100644 index 000000000000..4b5661e3601a --- /dev/null +++ b/Source/Workers/sanitizeHtml.js @@ -0,0 +1,40 @@ +/*global define*/ +define([ + '../Core/defined', + '../Core/RuntimeError', + './createTaskProcessorWorker' + ], function( + defined, + RuntimeError, + createTaskProcessorWorker) { + "use strict"; + + var cajaScript = '//caja.appspot.com/html-css-sanitizer-minified.js'; + var html_sanitize; + + /** + * A worker that loads the Google Caja HTML & CSS sanitizer and sanitizes the + * provided HTML string. + * + * @exports sanitize + * + * @see TaskProcessor + * @see Web Workers + */ + var sanitizeHtml = function(html) { + if (!defined(html_sanitize)) { + /*global self,importScripts*/ + self.window = {}; + importScripts(cajaScript); // importScripts is synchronous + html_sanitize = window.html_sanitize; + + if (!defined(html_sanitize)) { + throw new RuntimeError('Unable to load Google Caja sanitizer script.'); + } + } + + return html_sanitize(html); + }; + + return createTaskProcessorWorker(sanitizeHtml); +}); diff --git a/Specs/Widgets/InfoBox/InfoBoxSpec.js b/Specs/Widgets/InfoBox/InfoBoxSpec.js new file mode 100644 index 000000000000..9ba465f23c34 --- /dev/null +++ b/Specs/Widgets/InfoBox/InfoBoxSpec.js @@ -0,0 +1,48 @@ +/*global defineSuite*/ +defineSuite([ + 'Widgets/InfoBox/InfoBox', + 'Core/Ellipsoid', + 'Scene/SceneTransitioner', + 'Specs/createScene', + 'Specs/destroyScene' + ], function( + InfoBox, + Ellipsoid, + SceneTransitioner, + createScene, + destroyScene) { + "use strict"; + /*global jasmine,describe,xdescribe,it,xit,expect,beforeEach,afterEach,beforeAll,afterAll,spyOn,runs,waits,waitsFor*/ + + it('constructor sets expected values', function() { + var testElement = document.createElement('span'); + var infoBox = new InfoBox(testElement); + expect(infoBox.container).toBe(testElement); + expect(infoBox.viewModel).toBeDefined(); + expect(infoBox.isDestroyed()).toEqual(false); + infoBox.destroy(); + expect(infoBox.isDestroyed()).toEqual(true); + }); + + it('constructor works with string id container', function() { + var testElement = document.createElement('span'); + testElement.id = 'testElement'; + document.body.appendChild(testElement); + var infoBox = new InfoBox('testElement'); + expect(infoBox.container).toBe(testElement); + document.body.removeChild(testElement); + infoBox.destroy(); + }); + + it('throws if container is undefined', function() { + expect(function() { + return new InfoBox(undefined); + }).toThrowDeveloperError(); + }); + + it('throws if container string is undefined', function() { + expect(function() { + return new InfoBox('testElement'); + }).toThrowDeveloperError(); + }); +}); \ No newline at end of file diff --git a/Specs/Widgets/InfoBox/InfoBoxViewModelSpec.js b/Specs/Widgets/InfoBox/InfoBoxViewModelSpec.js new file mode 100644 index 000000000000..7804b87ae8f8 --- /dev/null +++ b/Specs/Widgets/InfoBox/InfoBoxViewModelSpec.js @@ -0,0 +1,100 @@ +/*global defineSuite*/ +defineSuite([ + 'Widgets/InfoBox/InfoBoxViewModel', + 'Core/Ellipsoid', + 'Scene/SceneTransitioner', + 'Specs/createScene', + 'Specs/destroyScene' + ], function( + InfoBoxViewModel, + Ellipsoid, + SceneTransitioner, + createScene, + destroyScene) { + "use strict"; + /*global jasmine,describe,xdescribe,it,xit,expect,beforeEach,afterEach,beforeAll,afterAll,spyOn,runs,waits,waitsFor*/ + + it('constructor sets expected values', function() { + var viewModel = new InfoBoxViewModel(); + expect(viewModel.enableCamera).toBe(false); + expect(viewModel.isCameraTracking).toBe(false); + expect(viewModel.showInfo).toBe(false); + expect(viewModel.cameraClicked).toBeDefined(); + expect(viewModel.closeClicked).toBeDefined(); + expect(viewModel.descriptionRawHtml).toBe(''); + expect(viewModel.maxHeightOffset(0)).toBeDefined(); + expect(viewModel.sanitizer).toBeDefined(); + }); + + it('allows some HTML in description', function() { + var safeString = '
This is a test.
'; + var viewModel = new InfoBoxViewModel(); + viewModel.descriptionRawHtml = safeString; + waitsFor(function() { + return viewModel.descriptionSanitizedHtml !== ''; + }); + runs(function() { + expect(viewModel.descriptionSanitizedHtml).toBe(safeString); + }); + }); + + it('removes script tags from HTML description by default', function() { + var evilString = 'Testing. '; + var viewModel = new InfoBoxViewModel(); + viewModel.descriptionRawHtml = evilString; + waitsFor(function() { + return viewModel.descriptionSanitizedHtml !== ''; + }); + runs(function() { + expect(viewModel.descriptionSanitizedHtml).toContain('Testing.'); + expect(viewModel.descriptionSanitizedHtml).not.toContain('script'); + }); + }); + + it('indicates missing description', function() { + var viewModel = new InfoBoxViewModel(); + expect(viewModel._bodyless).toBe(true); + viewModel.descriptionRawHtml = 'Testing'; + waitsFor(function() { + return viewModel.descriptionSanitizedHtml !== ''; + }); + runs(function() { + expect(viewModel._bodyless).toBe(false); + }); + }); + + function customSanitizer(string) { + return string + ' (processed by customSanitizer)'; + } + + it('allows user-supplied HTML sanitization.', function() { + var testString = 'Testing hot-swap of custom sanitizer.'; + var viewModel = new InfoBoxViewModel(); + + viewModel.descriptionRawHtml = testString; + waitsFor(function() { + return viewModel.descriptionSanitizedHtml !== ''; + }); + runs(function() { + expect(viewModel.descriptionSanitizedHtml).toBe(testString); + + viewModel.sanitizer = customSanitizer; + expect(viewModel.descriptionSanitizedHtml).toContain(testString); + expect(viewModel.descriptionSanitizedHtml).toContain('processed by customSanitizer'); + testString = 'subsequent test, after the swap.'; + viewModel.descriptionRawHtml = testString; + expect(viewModel.descriptionSanitizedHtml).toContain(testString); + expect(viewModel.descriptionSanitizedHtml).toContain('processed by customSanitizer'); + }); + }); + + it('camera icon changes when tracking is not available, unless due to active tracking', function() { + var viewModel = new InfoBoxViewModel(); + viewModel.enableCamera = true; + var enabledCameraPath = viewModel.cameraIconPath; + viewModel.enableCamera = false; + expect(viewModel.cameraIconPath).not.toBe(enabledCameraPath); + viewModel.isCameraTracking = true; + expect(viewModel.cameraIconPath).toBe(enabledCameraPath); + }); +}); \ No newline at end of file diff --git a/Specs/Widgets/SelectionIndicator/SelectionIndicatorSpec.js b/Specs/Widgets/SelectionIndicator/SelectionIndicatorSpec.js new file mode 100644 index 000000000000..56eefe9ad9e1 --- /dev/null +++ b/Specs/Widgets/SelectionIndicator/SelectionIndicatorSpec.js @@ -0,0 +1,56 @@ +/*global defineSuite*/ +defineSuite([ + 'Widgets/SelectionIndicator/SelectionIndicator', + 'Core/Ellipsoid', + 'Scene/SceneTransitioner', + 'Specs/createScene', + 'Specs/destroyScene' + ], function( + SelectionIndicator, + Ellipsoid, + SceneTransitioner, + createScene, + destroyScene) { + "use strict"; + /*global jasmine,describe,xdescribe,it,xit,expect,beforeEach,afterEach,beforeAll,afterAll,spyOn,runs,waits,waitsFor*/ + + var scene; + beforeAll(function() { + scene = createScene(); + }); + + afterAll(function() { + destroyScene(scene); + }); + + it('constructor sets expected values', function() { + var selectionIndicator = new SelectionIndicator(document.body, scene); + expect(selectionIndicator.container).toBe(document.body); + expect(selectionIndicator.viewModel.scene).toBe(scene); + expect(selectionIndicator.isDestroyed()).toEqual(false); + selectionIndicator.destroy(); + expect(selectionIndicator.isDestroyed()).toEqual(true); + }); + + it('constructor works with string id container', function() { + var testElement = document.createElement('span'); + testElement.id = 'testElement'; + document.body.appendChild(testElement); + var selectionIndicator = new SelectionIndicator('testElement', scene); + expect(selectionIndicator.container).toBe(testElement); + document.body.removeChild(testElement); + selectionIndicator.destroy(); + }); + + it('throws if container is undefined', function() { + expect(function() { + return new SelectionIndicator(undefined, scene); + }).toThrowDeveloperError(); + }); + + it('throws if container string is undefined', function() { + expect(function() { + return new SelectionIndicator('testElement', scene); + }).toThrowDeveloperError(); + }); +}, 'WebGL'); \ No newline at end of file diff --git a/Specs/Widgets/SelectionIndicator/SelectionIndicatorViewModelSpec.js b/Specs/Widgets/SelectionIndicator/SelectionIndicatorViewModelSpec.js new file mode 100644 index 000000000000..a40d278af838 --- /dev/null +++ b/Specs/Widgets/SelectionIndicator/SelectionIndicatorViewModelSpec.js @@ -0,0 +1,98 @@ +/*global defineSuite*/ +defineSuite([ + 'Widgets/SelectionIndicator/SelectionIndicatorViewModel', + 'Core/Cartesian2', + 'Core/Cartesian3', + 'Core/Ellipsoid', + 'Scene/SceneTransitioner', + 'Specs/createScene', + 'Specs/destroyScene' + ], function( + SelectionIndicatorViewModel, + Cartesian2, + Cartesian3, + Ellipsoid, + SceneTransitioner, + createScene, + destroyScene) { + "use strict"; + /*global jasmine,describe,xdescribe,it,xit,expect,beforeEach,afterEach,beforeAll,afterAll,spyOn,runs,waits,waitsFor*/ + + var scene; + var selectionIndicatorElement = document.createElement('div'); + selectionIndicatorElement.style.width = '20px'; + selectionIndicatorElement.style.height = '20px'; + var container = document.createElement('div'); + container.appendChild(selectionIndicatorElement); + beforeAll(function() { + scene = createScene(); + }); + + afterAll(function() { + destroyScene(scene); + }); + + it('constructor sets expected values', function() { + var viewModel = new SelectionIndicatorViewModel(scene, selectionIndicatorElement, container); + expect(viewModel.scene).toBe(scene); + expect(viewModel.selectionIndicatorElement).toBe(selectionIndicatorElement); + expect(viewModel.container).toBe(container); + expect(viewModel.computeScreenSpacePosition).toBeDefined(); + }); + + it('throws if scene is undefined', function() { + expect(function() { + return new SelectionIndicatorViewModel(undefined); + }).toThrowDeveloperError(); + }); + + it('throws if selectionIndicatorElement is undefined', function() { + expect(function() { + return new SelectionIndicatorViewModel(scene); + }).toThrowDeveloperError(); + }); + + it('throws if container is undefined', function() { + expect(function() { + return new SelectionIndicatorViewModel(scene, selectionIndicatorElement); + }).toThrowDeveloperError(); + }); + + it('can animate selection element', function() { + var viewModel = new SelectionIndicatorViewModel(scene, selectionIndicatorElement, container); + expect(function() { + viewModel.animateAppear(); + }).not.toThrow(); + expect(function() { + viewModel.animateDepart(); + }).not.toThrow(); + }); + + it('can use custom screen space positions', function() { + document.body.appendChild(container); + var viewModel = new SelectionIndicatorViewModel(scene, selectionIndicatorElement, container); + viewModel.showSelection = true; + viewModel.position = new Cartesian3(1.0, 2.0, 3.0); + viewModel.computeScreenSpacePosition = function(position, result) { + return Cartesian2.clone(position, result); + }; + expect(function() { + viewModel.update(); + }).not.toThrow(); + expect(viewModel._screenPositionX).toBe('-9px'); // Negative half the test size, plus viewModel.position.x (1) + expect(viewModel._screenPositionY).toBe('-8px'); // Negative half the test size, plus viewModel.position.y (2) + + document.body.removeChild(container); + }); + + it('Hides the indicator when position is unknown', function() { + var viewModel = new SelectionIndicatorViewModel(scene, selectionIndicatorElement, container); + expect(viewModel.isVisible).toBe(false); + viewModel.showSelection = true; + expect(viewModel.isVisible).toBe(false); + viewModel.position = new Cartesian3(1.0, 2.0, 3.0); + expect(viewModel.isVisible).toBe(true); + viewModel.showSelection = false; + expect(viewModel.isVisible).toBe(false); + }); +}, 'WebGL'); \ No newline at end of file diff --git a/Specs/Widgets/Viewer/viewerDynamicObjectMixinSpec.js b/Specs/Widgets/Viewer/viewerDynamicObjectMixinSpec.js index 8212a14ec0b3..d049193c8269 100644 --- a/Specs/Widgets/Viewer/viewerDynamicObjectMixinSpec.js +++ b/Specs/Widgets/Viewer/viewerDynamicObjectMixinSpec.js @@ -2,17 +2,19 @@ defineSuite([ 'Widgets/Viewer/viewerDynamicObjectMixin', 'Core/Cartesian3', + 'DynamicScene/ConstantPositionProperty', + 'DynamicScene/ConstantProperty', 'DynamicScene/DynamicObject', 'Scene/CameraFlightPath', - 'DynamicScene/ConstantProperty', 'Widgets/Viewer/Viewer', 'Specs/MockDataSource' ], function( viewerDynamicObjectMixin, Cartesian3, + ConstantPositionProperty, + ConstantProperty, DynamicObject, CameraFlightPath, - ConstantProperty, Viewer, MockDataSource) { "use strict"; @@ -35,10 +37,11 @@ defineSuite([ document.body.removeChild(container); }); - it('adds trackedObject property', function() { + it('adds properties', function() { viewer = new Viewer(container); viewer.extend(viewerDynamicObjectMixin); expect(viewer.hasOwnProperty('trackedObject')).toEqual(true); + expect(viewer.hasOwnProperty('selectedObject')).toEqual(true); }); it('can get and set trackedObject', function() { @@ -55,6 +58,22 @@ defineSuite([ expect(viewer.trackedObject).toBeUndefined(); }); + it('can get and set selectedObject', function() { + var viewer = new Viewer(container); + viewer.extend(viewerDynamicObjectMixin); + + var dynamicObject = new DynamicObject(); + dynamicObject.position = new ConstantPositionProperty(new Cartesian3(123456, 123456, 123456)); + + viewer.selectedObject = dynamicObject; + expect(viewer.selectedObject).toBe(dynamicObject); + + viewer.selectedObject = undefined; + expect(viewer.selectedObject).toBeUndefined(); + + viewer.destroy(); + }); + it('home button resets tracked object', function() { viewer = new Viewer(container); viewer.extend(viewerDynamicObjectMixin); @@ -80,7 +99,7 @@ defineSuite([ }).toThrowDeveloperError(); }); - it('throws if dropTarget property already added by another mixin.', function() { + it('throws if trackedObject property already added by another mixin.', function() { viewer = new Viewer(container); viewer.trackedObject = true; expect(function() { @@ -88,6 +107,15 @@ defineSuite([ }).toThrowDeveloperError(); }); + it('throws if selectedObject property already added by another mixin.', function() { + var viewer = new Viewer(container); + viewer.selectedObject = true; + expect(function() { + viewer.extend(viewerDynamicObjectMixin); + }).toThrow(); + viewer.destroy(); + }); + it('returns to home when a tracked object is removed', function() { viewer = new Viewer(container); @@ -151,4 +179,4 @@ defineSuite([ expect(preMixinDataSource.dynamicObjectCollection.collectionChanged._listeners.length).not.toEqual(preMixinListenerCount); expect(postMixinDataSource.dynamicObjectCollection.collectionChanged._listeners.length).not.toEqual(postMixinListenerCount); }); -}, 'WebGL'); \ No newline at end of file +}, 'WebGL');