# viewer/Viewer.js

    import { MODES, CONTROLS } from '../Constants';
    import { OrbitControls } from '../lib/controls/OrbitControls';
    import { DeviceOrientationControls } from '../lib/controls/DeviceOrientationControls';
    import { CardboardEffect } from '../lib/effects/CardboardEffect';
    import { StereoEffect } from '../lib/effects/StereoEffect';
    import { Widget } from '../widget/Widget';
    import { Reticle } from '../interface/Reticle';
    import { Infospot } from '../infospot/Infospot';
    import { DataImage } from '../DataImage';
    import { Panorama } from '../panorama/Panorama';
    import { VideoPanorama } from '../panorama/VideoPanorama';
    import { CameraPanorama } from '../panorama/CameraPanorama';
    import * as THREE from 'three';
    import TWEEN from '@tweenjs/tween.js';

    /**
     * @classdesc Viewer contains pre-defined scene, camera and renderer
     * @constructor
     * @param {object} [options] - Use custom or default config options
     * @param {HTMLElement} [options.container] - A HTMLElement to host the canvas
     * @param {THREE.Scene} [options.scene=THREE.Scene] - A THREE.Scene which contains panorama and 3D objects
     * @param {THREE.Camera} [options.camera=THREE.PerspectiveCamera] - A THREE.Camera to view the scene
     * @param {THREE.WebGLRenderer} [options.renderer=THREE.WebGLRenderer] - A THREE.WebGLRenderer to render canvas
     * @param {boolean} [options.controlBar=true] - Show/hide control bar on the bottom of the container
     * @param {array}   [options.controlButtons=[]] - Button names to mount on controlBar if controlBar exists, Defaults to ['fullscreen', 'setting', 'video']
     * @param {boolean} [options.autoHideControlBar=false] - Auto hide control bar when click on non-active area
     * @param {boolean} [options.autoHideInfospot=true] - Auto hide infospots when click on non-active area
     * @param {boolean} [options.horizontalView=false] - Allow only horizontal camera control
     * @param {number}  [options.clickTolerance=10] - Distance tolerance to tigger click / tap event
     * @param {number}  [options.cameraFov=60] - Camera field of view value
     * @param {boolean} [options.reverseDragging=false] - Reverse dragging direction
     * @param {boolean} [options.enableReticle=false] - Enable reticle for mouseless interaction other than VR mode
     * @param {number}  [options.dwellTime=1500] - Dwell time for reticle selection in ms
     * @param {boolean} [options.autoReticleSelect=true] - Auto select a clickable target after dwellTime
     * @param {boolean} [options.viewIndicator=false] - Adds an angle view indicator in upper left corner
     * @param {number}  [options.indicatorSize=30] - Size of View Indicator
     * @param {string}  [options.output='none'] - Whether and where to output raycast position. Could be 'console' or 'overlay'
     * @param {boolean} [options.autoRotate=false] - Auto rotate
     * @param {number}  [options.autoRotateSpeed=2.0] - Auto rotate speed as in degree per second. Positive is counter-clockwise and negative is clockwise.
     * @param {number}  [options.autoRotateActivationDuration=5000] - Duration before auto rotatation when no user interactivity in ms
     */
    function Viewer ( options ) {

        let container;

        options = options || {};
        options.controlBar = options.controlBar !== undefined ? options.controlBar : true;
        options.controlButtons = options.controlButtons || [ 'fullscreen', 'setting', 'video' ];
        options.autoHideControlBar = options.autoHideControlBar !== undefined ? options.autoHideControlBar : false;
        options.autoHideInfospot = options.autoHideInfospot !== undefined ? options.autoHideInfospot : true;
        options.horizontalView = options.horizontalView !== undefined ? options.horizontalView : false;
        options.clickTolerance = options.clickTolerance || 10;
        options.cameraFov = options.cameraFov || 60;
        options.reverseDragging = options.reverseDragging || false;
        options.enableReticle = options.enableReticle || false;
        options.dwellTime = options.dwellTime || 1500;
        options.autoReticleSelect = options.autoReticleSelect !== undefined ? options.autoReticleSelect : true;
        options.viewIndicator = options.viewIndicator !== undefined ? options.viewIndicator : false;
        options.indicatorSize = options.indicatorSize || 30;
        options.output = options.output ? options.output : 'none';
        options.autoRotate = options.autoRotate || false;
        options.autoRotateSpeed = options.autoRotateSpeed || 2.0;
        options.autoRotateActivationDuration = options.autoRotateActivationDuration || 5000;

        this.options = options;

        /*
         * CSS Icon
         * const styleLoader = new StyleLoader();
         * styleLoader.inject( 'icono' );
         */

        // Container
        if ( options.container ) {

            container = options.container;
            container._width = container.clientWidth;
            container._height = container.clientHeight;

        } else {

            container = document.createElement( 'div' );
            container.classList.add( 'panolens-container' );
            container.style.width = '100%';
            container.style.height = '100%';
            container._width = window.innerWidth;
            container._height = window.innerHeight;
            document.body.appendChild( container );

        }

        this.container = container;

        this.camera = options.camera || new THREE.PerspectiveCamera( this.options.cameraFov, this.container.clientWidth / this.container.clientHeight, 1, 10000 );
        this.scene = options.scene || new THREE.Scene();
        this.renderer = options.renderer || new THREE.WebGLRenderer( { alpha: true, antialias: false } );
        this.sceneReticle = new THREE.Scene();

        this.viewIndicatorSize = this.options.indicatorSize;

        this.reticle = {};
        this.tempEnableReticle = this.options.enableReticle;

        this.mode = MODES.NORMAL;

        this.panorama = null;
        this.widget = null;

        this.hoverObject = null;
        this.infospot = null;
        this.pressEntityObject = null;
        this.pressObject = null;

        this.raycaster = new THREE.Raycaster();
        this.raycasterPoint = new THREE.Vector2();
        this.userMouse = new THREE.Vector2();
        this.updateCallbacks = [];
        this.requestAnimationId = null;

        this.cameraFrustum = new THREE.Frustum();
        this.cameraViewProjectionMatrix = new THREE.Matrix4();

        this.autoRotateRequestId = null;

        this.outputDivElement = null;

        this.touchSupported = 'ontouchstart' in window || window.DocumentTouch && document instanceof DocumentTouch;

        // Handler references
        this.HANDLER_MOUSE_DOWN = this.onMouseDown.bind( this );
        this.HANDLER_MOUSE_UP = this.onMouseUp.bind( this );
        this.HANDLER_MOUSE_MOVE = this.onMouseMove.bind( this );
        this.HANDLER_WINDOW_RESIZE = this.onWindowResize.bind( this );
        this.HANDLER_KEY_DOWN = this.onKeyDown.bind( this );
        this.HANDLER_KEY_UP = this.onKeyUp.bind( this );
        this.HANDLER_TAP = this.onTap.bind( this, {
            clientX: this.container.clientWidth / 2,
            clientY: this.container.clientHeight / 2
        } );

        // Flag for infospot output
        this.OUTPUT_INFOSPOT = false;

        // Animations
        this.tweenLeftAnimation = new TWEEN.Tween();
        this.tweenUpAnimation = new TWEEN.Tween();

        // Renderer
        this.renderer.setPixelRatio( window.devicePixelRatio );
        this.renderer.setSize( this.container.clientWidth, this.container.clientHeight );
        this.renderer.setClearColor( 0x000000, 0 );
        this.renderer.autoClear = false;

        // Append Renderer Element to container
        this.renderer.domElement.classList.add( 'panolens-canvas' );
        this.renderer.domElement.style.display = 'block';
        this.container.style.backgroundColor = '#000';
        this.container.appendChild( this.renderer.domElement );

        // Camera Controls
        this.OrbitControls = new OrbitControls( this.camera, this.container );
        this.OrbitControls.id = 'orbit';
        this.OrbitControls.minDistance = 1;
        this.OrbitControls.noPan = true;
        this.OrbitControls.autoRotate = this.options.autoRotate;
        this.OrbitControls.autoRotateSpeed = this.options.autoRotateSpeed;

        this.DeviceOrientationControls = new DeviceOrientationControls( this.camera, this.container );
        this.DeviceOrientationControls.id = 'device-orientation';
        this.DeviceOrientationControls.enabled = false;
        this.camera.position.z = 1;

        // Register change event if passiveRenering
        if ( this.options.passiveRendering ) {

            console.warn( 'passiveRendering is now deprecated' );

        }

        // Controls
        this.controls = [ this.OrbitControls, this.DeviceOrientationControls ];
        this.control = this.OrbitControls;

        // Cardboard effect
        this.CardboardEffect = new CardboardEffect( this.renderer );
        this.CardboardEffect.setSize( this.container.clientWidth, this.container.clientHeight );

        // Stereo effect
        this.StereoEffect = new StereoEffect( this.renderer );
        this.StereoEffect.setSize( this.container.clientWidth, this.container.clientHeight );

        this.effect = this.CardboardEffect;

        // Add default hidden reticle
        this.addReticle();

        // Lock horizontal view
        if ( this.options.horizontalView ) {
            this.OrbitControls.minPolarAngle = Math.PI / 2;
            this.OrbitControls.maxPolarAngle = Math.PI / 2;
        }

        // Add Control UI
        if ( this.options.controlBar !== false ) {
            this.addDefaultControlBar( this.options.controlButtons );
        }

        // Add View Indicator
        if ( this.options.viewIndicator ) {
            this.addViewIndicator();
        }

        // Reverse dragging direction
        if ( this.options.reverseDragging ) {
            this.reverseDraggingDirection();
        }

        // Register event if reticle is enabled, otherwise defaults to mouse
        if ( this.options.enableReticle ) {
            this.enableReticleControl();
        } else {
            this.registerMouseAndTouchEvents();
        }

        // Output infospot position to an overlay container if specified
        if ( this.options.output === 'overlay' ) {
            this.addOutputElement();
        }

        // Register dom event listeners
        this.registerEventListeners();

        // Animate
        this.animate.call( this );

    };

    Viewer.prototype = Object.assign( Object.create( THREE.EventDispatcher.prototype ), {

        constructor: Viewer,

        /**
         * Add an object to the scene
         * Automatically hookup with panolens-viewer-handler listener
         * to communicate with viewer method
         * @param {THREE.Object3D} object - The object to be added
         * @memberOf Viewer
         * @instance
         */
        add: function ( object ) {

            if ( arguments.length > 1 ) {

                for ( let i = 0; i < arguments.length; i ++ ) {

                    this.add( arguments[ i ] );

                }

                return this;

            }

            this.scene.add( object );

            // All object added to scene has 'panolens-viewer-handler' event to handle viewer communication
            if ( object.addEventListener ) {

                object.addEventListener( 'panolens-viewer-handler', this.eventHandler.bind( this ) );

            }

            // All object added to scene being passed with container
            if ( object instanceof Panorama && object.dispatchEvent ) {

                object.dispatchEvent( { type: 'panolens-container', container: this.container } );

            }

            if ( object instanceof CameraPanorama ) {

                object.dispatchEvent( { type: 'panolens-scene', scene: this.scene } );

            }

            // Hookup default panorama event listeners
            if ( object.type === 'panorama' ) {

                this.addPanoramaEventListener( object );

                if ( !this.panorama ) {

                    this.setPanorama( object );

                }

            }

        },

        /**
         * Remove an object from the scene
         * @param  {THREE.Object3D} object - Object to be removed
         * @memberOf Viewer
         * @instance
         */
        remove: function ( object ) {

            if ( object.removeEventListener ) {

                object.removeEventListener( 'panolens-viewer-handler', this.eventHandler.bind( this ) );

            }

            this.scene.remove( object );

        },

        /**
         * Add default control bar
         * @param {array} array - The control buttons array
         * @memberOf Viewer
         * @instance
         */
        addDefaultControlBar: function ( array ) {

            if ( this.widget ) {

                console.warn( 'Default control bar exists' );
                return;

            }

            const widget = new Widget( this.container );
            widget.addEventListener( 'panolens-viewer-handler', this.eventHandler.bind( this ) );
            widget.addControlBar();
            array.forEach( buttonName => {

                widget.addControlButton( buttonName );

            } );

            this.widget = widget;

        },

        /**
         * Set a panorama to be the current one
         * @param {Panorama} pano - Panorama to be set
         * @memberOf Viewer
         * @instance
         */
        setPanorama: function ( pano ) {

            const leavingPanorama = this.panorama;

            if ( pano.type === 'panorama' && leavingPanorama !== pano ) {

                // Clear exisiting infospot
                this.hideInfospot();

                const afterEnterComplete = function () {

                    if ( leavingPanorama ) { leavingPanorama.onLeave(); }
                    pano.removeEventListener( 'enter-fade-start', afterEnterComplete );

                };

                pano.addEventListener( 'enter-fade-start', afterEnterComplete );

                // Assign and enter panorama
                (this.panorama = pano).onEnter();

            }

        },

        /**
         * Event handler to execute commands from child objects
         * @param {object} event - The dispatched event with method as function name and data as an argument
         * @memberOf Viewer
         * @instance
         */
        eventHandler: function ( event ) {

            if ( event.method && this[ event.method ] ) {

                this[ event.method ]( event.data );

            }

        },

        /**
         * Dispatch event to all descendants
         * @param  {object} event - Event to be passed along
         * @memberOf Viewer
         * @instance
         */
        dispatchEventToChildren: function ( event ) {

            this.scene.traverse( function ( object ) {

                if ( object.dispatchEvent ) {

                    object.dispatchEvent( event );

                }

            });

        },

        /**
         * Set widget content
         * @method activateWidgetItem
         * @param  {integer} controlIndex - Control index
         * @param  {integer} mode - Modes for effects
         * @memberOf Viewer
         * @instance
         */
        activateWidgetItem: function ( controlIndex, mode ) {

            const mainMenu = this.widget.mainMenu;
            const ControlMenuItem = mainMenu.children[ 0 ];
            const ModeMenuItem = mainMenu.children[ 1 ];

            let item;

            if ( controlIndex !== undefined ) {

                switch ( controlIndex ) {

                case 0:

                    item = ControlMenuItem.subMenu.children[ 1 ];

                    break;

                case 1:

                    item = ControlMenuItem.subMenu.children[ 2 ];

                    break;

                default:

                    item = ControlMenuItem.subMenu.children[ 1 ];

                    break;

                }

                ControlMenuItem.subMenu.setActiveItem( item );
                ControlMenuItem.setSelectionTitle( item.textContent );

            }

            if ( mode !== undefined ) {

                switch( mode ) {

                case MODES.CARDBOARD:

                    item = ModeMenuItem.subMenu.children[ 2 ];

                    break;

                case MODES.STEREO:

                    item = ModeMenuItem.subMenu.children[ 3 ];

                    break;

                default:

                    item = ModeMenuItem.subMenu.children[ 1 ];

                    break;
                }

                ModeMenuItem.subMenu.setActiveItem( item );
                ModeMenuItem.setSelectionTitle( item.textContent );

            }

        },

        /**
         * Enable rendering effect
         * @param  {MODES} mode - Modes for effects
         * @memberOf Viewer
         * @instance
         */
        enableEffect: function ( mode ) {

            if ( this.mode === mode ) { return; }
            if ( mode === MODES.NORMAL ) { this.disableEffect(); return; }
            else { this.mode = mode; }

            const fov = this.camera.fov;

            switch( mode ) {

            case MODES.CARDBOARD:

                this.effect = this.CardboardEffect;
                this.enableReticleControl();

                break;

            case MODES.STEREO:

                this.effect = this.StereoEffect;
                this.enableReticleControl();

                break;

            default:

                this.effect = null;
                this.disableReticleControl();

                break;

            }

            this.activateWidgetItem( undefined, this.mode );

            /**
             * Dual eye effect event
             * @type {object}
             * @event Infospot#panolens-dual-eye-effect
             * @property {MODES} mode - Current display mode
             */
            this.dispatchEventToChildren( { type: 'panolens-dual-eye-effect', mode: this.mode } );

            // Force effect stereo camera to update by refreshing fov
            this.camera.fov = fov + 10e-3;
            this.effect.setSize( this.container.clientWidth, this.container.clientHeight );
            this.render();
            this.camera.fov = fov;

            /**
             * Dispatch mode change event
             * @type {object}
             * @event Viewer#mode-change
             * @property {MODES} mode - Current display mode
             */
            this.dispatchEvent( { type: 'mode-change', mode: this.mode } );

        },

        /**
         * Disable additional rendering effect
         * @memberOf Viewer
         * @instance
         */
        disableEffect: function () {

            if ( this.mode === MODES.NORMAL ) { return; }

            this.mode = MODES.NORMAL;
            this.disableReticleControl();

            this.activateWidgetItem( undefined, this.mode );

            /**
             * Dual eye effect event
             * @type {object}
             * @event Infospot#panolens-dual-eye-effect
             * @property {MODES} mode - Current display mode
             */
            this.dispatchEventToChildren( { type: 'panolens-dual-eye-effect', mode: this.mode } );

            this.renderer.setSize( this.container.clientWidth, this.container.clientHeight );
            this.render();

            /**
             * Dispatch mode change event
             * @type {object}
             * @event Viewer#mode-change
             * @property {MODES} mode - Current display mode
             */
            this.dispatchEvent( { type: 'mode-change', mode: this.mode } );
        },

        /**
         * Enable reticle control
         * @memberOf Viewer
         * @instance
         */
        enableReticleControl: function () {

            if ( this.reticle.visible ) { return; }

            this.tempEnableReticle = true;

            // Register reticle event and unregister mouse event
            this.unregisterMouseAndTouchEvents();
            this.reticle.show();
            this.registerReticleEvent();
            this.updateReticleEvent();

        },

        /**
         * Disable reticle control
         * @memberOf Viewer
         * @instance
         */
        disableReticleControl: function () {

            this.tempEnableReticle = false;

            // Register mouse event and unregister reticle event
            if ( !this.options.enableReticle ) {

                this.reticle.hide();
                this.unregisterReticleEvent();
                this.registerMouseAndTouchEvents();

            } else {

                this.updateReticleEvent();

            }

        },

        /**
         * Enable auto rotation
         * @memberOf Viewer
         * @instance
         */
        enableAutoRate: function () {

            this.options.autoRotate = true;
            this.OrbitControls.autoRotate = true;

        },

        /**
         * Disable auto rotation
         * @memberOf Viewer
         * @instance
         */
        disableAutoRate: function () {

            clearTimeout( this.autoRotateRequestId );
            this.options.autoRotate = false;
            this.OrbitControls.autoRotate = false;

        },

        /**
         * Toggle video play or stop
         * @param {boolean} pause
         * @memberOf Viewer
         * @instance
         * @fires Viewer#video-toggle
         */
        toggleVideoPlay: function ( pause ) {

            if ( this.panorama instanceof VideoPanorama ) {

                /**
                 * Toggle video event
                 * @type {object}
                 * @event Viewer#video-toggle
                 */
                this.panorama.dispatchEvent( { type: 'video-toggle', pause: pause } );

            }

        },

        /**
         * Set currentTime in a video
         * @param {number} percentage - Percentage of a video. Range from 0.0 to 1.0
         * @memberOf Viewer
         * @instance
         * @fires Viewer#video-time
         */
        setVideoCurrentTime: function ( percentage ) {

            if ( this.panorama instanceof VideoPanorama ) {

                /**
                 * Setting video time event
                 * @type {object}
                 * @event Viewer#video-time
                 * @property {number} percentage - Percentage of a video. Range from 0.0 to 1.0
                 */
                this.panorama.dispatchEvent( { type: 'video-time', percentage: percentage } );

            }

        },

        /**
         * This will be called when video updates if an widget is present
         * @param {number} percentage - Percentage of a video. Range from 0.0 to 1.0
         * @memberOf Viewer
         * @instance
         * @fires Viewer#video-update
         */
        onVideoUpdate: function ( percentage ) {

            const { widget } = this;

            /**
             * Video update event
             * @type {object}
             * @event Viewer#video-update
             * @property {number} percentage - Percentage of a video. Range from 0.0 to 1.0
             */
            if( widget ) { widget.dispatchEvent( { type: 'video-update', percentage: percentage } ); }

        },

        /**
         * Add update callback to be called every animation frame
         * @param {function} callback
         * @memberOf Viewer
         * @instance
         */
        addUpdateCallback: function ( fn ) {

            if ( fn ) {

                this.updateCallbacks.push( fn );

            }

        },

        /**
         * Remove update callback
         * @param  {function} fn - The function to be removed
         * @memberOf Viewer
         * @instance
         */
        removeUpdateCallback: function ( fn ) {

            const index = this.updateCallbacks.indexOf( fn );

            if ( fn && index >= 0 ) {

                this.updateCallbacks.splice( index, 1 );

            }

        },

        /**
         * Show video widget
         * @memberOf Viewer
         * @instance
         */
        showVideoWidget: function () {

            const { widget } = this;

            /**
             * Show video widget event
             * @type {object}
             * @event Viewer#video-control-show
             */
            if( widget ) { widget.dispatchEvent( { type: 'video-control-show' } ); }

        },

        /**
         * Hide video widget
         * @memberOf Viewer
         * @instance
         */
        hideVideoWidget: function () {

            const { widget } = this;

            /**
             * Hide video widget
             * @type {object}
             * @event Viewer#video-control-hide
             */
            if( widget ) { widget.dispatchEvent( { type: 'video-control-hide' } ); }

        },

        /**
         * Update video play button
         * @param {boolean} paused
         * @memberOf Viewer
         * @instance
         */
        updateVideoPlayButton: function ( paused ) {

            const { widget } = this;

            if ( widget && widget.videoElement && widget.videoElement.controlButton ) {

                widget.videoElement.controlButton.update( paused );

            }

        },

        /**
         * Add default panorama event listeners
         * @param {Panorama} pano - The panorama to be added with event listener
         * @memberOf Viewer
         * @instance
         */
        addPanoramaEventListener: function ( pano ) {

            // Set camera control on every panorama
            pano.addEventListener( 'enter-fade-start', this.setCameraControl.bind( this ) );

            // Show and hide widget event only when it's VideoPanorama
            if ( pano instanceof VideoPanorama ) {

                pano.addEventListener( 'enter-fade-start', this.showVideoWidget.bind( this ) );
                pano.addEventListener( 'leave', function () {

                    if ( !(this.panorama instanceof VideoPanorama) ) {

                        this.hideVideoWidget.call( this );

                    }

                }.bind( this ) );

            }

        },

        /**
         * Set camera control
         * @memberOf Viewer
         * @instance
         */
        setCameraControl: function () {

            this.OrbitControls.target.copy( this.panorama.position );

        },

        /**
         * Get current camera control
         * @return {object} - Current navigation control
         * @memberOf Viewer
         * @instance
         * @returns {THREE.OrbitControls|THREE.DeviceOrientationControls}
         */
        getControl: function () {

            return this.control;

        },

        /**
         * Get scene
         * @memberOf Viewer
         * @instance
         * @return {THREE.Scene} - Current scene which the viewer is built on
         */
        getScene: function () {

            return this.scene;

        },

        /**
         * Get camera
         * @memberOf Viewer
         * @instance
         * @return {THREE.Camera} - The scene camera
         */
        getCamera: function () {

            return this.camera;

        },

        /**
         * Get renderer
         * @memberOf Viewer
         * @instance
         * @return {THREE.WebGLRenderer} - The renderer using webgl
         */
        getRenderer: function () {

            return this.renderer;

        },

        /**
         * Get container
         * @memberOf Viewer
         * @instance
         * @return {HTMLElement} - The container holds rendererd canvas
         */
        getContainer: function () {

            return this.container;

        },

        /**
         * Get control id
         * @memberOf Viewer
         * @instance
         * @return {string} - Control id. 'orbit' or 'device-orientation'
         */
        getControlId: function () {

            return this.control.id;

        },

        /**
         * Get next navigation control id
         * @memberOf Viewer
         * @instance
         * @return {string} - Next control id
         */
        getNextControlId: function () {

            return this.controls[ this.getNextControlIndex() ].id;

        },

        /**
         * Get next navigation control index
         * @memberOf Viewer
         * @instance
         * @return {number} - Next control index
         */
        getNextControlIndex: function () {

            const controls = this.controls;
            const control = this.control;
            const nextIndex = controls.indexOf( control ) + 1;

            return ( nextIndex >= controls.length ) ? 0 : nextIndex;

        },

        /**
         * Set field of view of camera
         * @param {number} fov
         * @memberOf Viewer
         * @instance
         */
        setCameraFov: function ( fov ) {

            this.camera.fov = fov;
            this.camera.updateProjectionMatrix();

        },

        /**
         * Enable control by index
         * @param  {CONTROLS} index - Index of camera control
         * @memberOf Viewer
         * @instance
         */
        enableControl: function ( index ) {

            index = ( index >= 0 && index < this.controls.length ) ? index : 0;

            this.control.enabled = false;

            this.control = this.controls[ index ];

            this.control.enabled = true;

            switch ( index ) {

            case CONTROLS.ORBIT:

                this.camera.position.copy( this.panorama.position );
                this.camera.position.z += 1;

                break;

            case CONTROLS.DEVICEORIENTATION:

                this.camera.position.copy( this.panorama.position );

                break;

            default:

                break;
            }

            this.control.update();

            this.activateWidgetItem( index, undefined );

        },

        /**
         * Disable current control
         * @memberOf Viewer
         * @instance
         */
        disableControl: function () {

            this.control.enabled = false;

        },

        /**
         * Toggle next control
         * @memberOf Viewer
         * @instance
         */
        toggleNextControl: function () {

            this.enableControl( this.getNextControlIndex() );

        },

        /**
         * Screen Space Projection
         * @memberOf Viewer
         * @instance
         */
        getScreenVector: function ( worldVector ) {

            const vector = worldVector.clone();
            const widthHalf = ( this.container.clientWidth ) / 2;
            const heightHalf = this.container.clientHeight / 2;

            vector.project( this.camera );

            vector.x = ( vector.x * widthHalf ) + widthHalf;
            vector.y = - ( vector.y * heightHalf ) + heightHalf;
            vector.z = 0;

            return vector;

        },

        /**
         * Check Sprite in Viewport
         * @memberOf Viewer
         * @instance
         */
        checkSpriteInViewport: function ( sprite ) {

            this.camera.matrixWorldInverse.getInverse( this.camera.matrixWorld );
            this.cameraViewProjectionMatrix.multiplyMatrices( this.camera.projectionMatrix, this.camera.matrixWorldInverse );
            this.cameraFrustum.setFromMatrix( this.cameraViewProjectionMatrix );

            return sprite.visible && this.cameraFrustum.intersectsSprite( sprite );

        },

        /**
         * Reverse dragging direction
         * @memberOf Viewer
         * @instance
         */
        reverseDraggingDirection: function () {

            this.OrbitControls.rotateSpeed *= -1;
            this.OrbitControls.momentumScalingFactor *= -1;

        },

        /**
         * Add reticle
         * @memberOf Viewer
         * @instance
         */
        addReticle: function () {

            this.reticle = new Reticle( 0xffffff, true, this.options.dwellTime );
            this.reticle.hide();
            this.camera.add( this.reticle );
            this.sceneReticle.add( this.camera );

        },

        /**
         * Tween control looking center
         * @param {THREE.Vector3} vector - Vector to be looked at the center
         * @param {number} [duration=1000] - Duration to tween
         * @param {function} [easing=TWEEN.Easing.Exponential.Out] - Easing function
         * @memberOf Viewer
         * @instance
         */
        tweenControlCenter: function ( vector, duration, easing ) {

            if ( this.control !== this.OrbitControls ) {

                return;

            }

            // Pass in arguments as array
            if ( vector instanceof Array ) {

                duration = vector[ 1 ];
                easing = vector[ 2 ];
                vector = vector[ 0 ];

            }

            duration = duration !== undefined ? duration : 1000;
            easing = easing || TWEEN.Easing.Exponential.Out;

            let scope, ha, va, chv, cvv, hv, vv, vptc, ov, nv;

            scope = this;

            chv = this.camera.getWorldDirection( new THREE.Vector3() );
            cvv = chv.clone();

            vptc = this.panorama.getWorldPosition( new THREE.Vector3() ).sub( this.camera.getWorldPosition( new THREE.Vector3() ) );

            hv = vector.clone();
            // Scale effect
            hv.x *= -1;
            hv.add( vptc ).normalize();
            vv = hv.clone();

            chv.y = 0;
            hv.y = 0;

            ha = Math.atan2( hv.z, hv.x ) - Math.atan2( chv.z, chv.x );
            ha = ha > Math.PI ? ha - 2 * Math.PI : ha;
            ha = ha < -Math.PI ? ha + 2 * Math.PI : ha;
            va = Math.abs( cvv.angleTo( chv ) + ( cvv.y * vv.y <= 0 ? vv.angleTo( hv ) : -vv.angleTo( hv ) ) );
            va *= vv.y < cvv.y ? 1 : -1;

            ov = { left: 0, up: 0 };
            nv = { left: 0, up: 0 };

            this.tweenLeftAnimation.stop();
            this.tweenUpAnimation.stop();

            this.tweenLeftAnimation = new TWEEN.Tween( ov )
                .to( { left: ha }, duration )
                .easing( easing )
                .onUpdate(function(ov){
                    scope.control.rotateLeft( ov.left - nv.left );
                    nv.left = ov.left;
                })
                .start();

            this.tweenUpAnimation = new TWEEN.Tween( ov )
                .to( { up: va }, duration )
                .easing( easing )
                .onUpdate(function(ov){
                    scope.control.rotateUp( ov.up - nv.up );
                    nv.up = ov.up;
                })
                .start();

        },

        /**
         * Tween control looking center by object
         * @param {THREE.Object3D} object - Object to be looked at the center
         * @param {number} [duration=1000] - Duration to tween
         * @param {function} [easing=TWEEN.Easing.Exponential.Out] - Easing function
         * @memberOf Viewer
         * @instance
         */
        tweenControlCenterByObject: function ( object, duration, easing ) {

            let isUnderScalePlaceHolder = false;

            object.traverseAncestors( function ( ancestor ) {

                if ( ancestor.scalePlaceHolder ) {

                    isUnderScalePlaceHolder = true;

                }
            } );

            if ( isUnderScalePlaceHolder ) {

                const invertXVector = new THREE.Vector3( -1, 1, 1 );

                this.tweenControlCenter( object.getWorldPosition( new THREE.Vector3() ).multiply( invertXVector ), duration, easing );

            } else {

                this.tweenControlCenter( object.getWorldPosition( new THREE.Vector3() ), duration, easing );

            }

        },

        /**
         * This is called when window size is changed
         * @fires Viewer#window-resize
         * @param {number} [windowWidth] - Specify if custom element has changed width
         * @param {number} [windowHeight] - Specify if custom element has changed height
         * @memberOf Viewer
         * @instance
         */
        onWindowResize: function ( windowWidth, windowHeight ) {

            let width, height;

            const expand = this.container.classList.contains( 'panolens-container' ) || this.container.isFullscreen;

            if ( windowWidth !== undefined && windowHeight !== undefined ) {

                width = windowWidth;
                height = windowHeight;
                this.container._width = windowWidth;
                this.container._height = windowHeight;

            } else {

                const isAndroid = /(android)/i.test(window.navigator.userAgent);

                const adjustWidth = isAndroid
                    ? Math.min(document.documentElement.clientWidth, window.innerWidth || 0)
                    : Math.max(document.documentElement.clientWidth, window.innerWidth || 0);

                const adjustHeight = isAndroid
                    ? Math.min(document.documentElement.clientHeight, window.innerHeight || 0)
                    : Math.max(document.documentElement.clientHeight, window.innerHeight || 0);

                width = expand ? adjustWidth : this.container.clientWidth;
                height = expand ? adjustHeight : this.container.clientHeight;

                this.container._width = width;
                this.container._height = height;

            }

            this.camera.aspect = width / height;
            this.camera.updateProjectionMatrix();

            this.renderer.setSize( width, height );

            // Update reticle
            if ( this.options.enableReticle || this.tempEnableReticle ) {

                this.updateReticleEvent();

            }

            /**
             * Window resizing event
             * @type {object}
             * @event Viewer#window-resize
             * @property {number} width  - Width of the window
             * @property {number} height - Height of the window
             */
            this.dispatchEvent( { type: 'window-resize', width: width, height: height });
            this.scene.traverse( function ( object ) {

                if ( object.dispatchEvent ) {

                    object.dispatchEvent( { type: 'window-resize', width: width, height: height });

                }

            } );

        },

        /**
         * Add output element
         * @memberOf Viewer
         * @instance
         */
        addOutputElement: function () {

            const element = document.createElement( 'div' );
            element.style.position = 'absolute';
            element.style.right = '10px';
            element.style.top = '10px';
            element.style.color = '#fff';
            this.container.appendChild( element );
            this.outputDivElement = element;

        },

        /**
         * Output position in developer console by holding down Ctrl button
         * @memberOf Viewer
         * @instance
         */
        outputPosition: function () {

            const intersects = this.raycaster.intersectObject( this.panorama, true );

            if ( intersects.length > 0 ) {

                const point = intersects[ 0 ].point.clone();
                const converter = new THREE.Vector3( -1, 1, 1 );
                const world = this.panorama.getWorldPosition( new THREE.Vector3() );
                point.sub( world ).multiply( converter );

                const message = `${point.x.toFixed(2)}, ${point.y.toFixed(2)}, ${point.z.toFixed(2)}`;

                if ( point.length() === 0 ) { return; }

                switch ( this.options.output ) {

                case 'console':
                    console.info( message );
                    break;

                case 'overlay':
                    this.outputDivElement.textContent = message;
                    break;

                default:
                    break;

                }

            }

        },

        /**
         * On mouse down
         * @param {MouseEvent} event
         * @memberOf Viewer
         * @instance
         */
        onMouseDown: function ( event ) {

            event.preventDefault();

            this.userMouse.x = ( event.clientX >= 0 ) ? event.clientX : event.touches[0].clientX;
            this.userMouse.y = ( event.clientY >= 0 ) ? event.clientY : event.touches[0].clientY;
            this.userMouse.type = 'mousedown';
            this.onTap( event );

        },

        /**
         * On mouse move
         * @param {MouseEvent} event
         * @memberOf Viewer
         * @instance
         */
        onMouseMove: function ( event ) {

            event.preventDefault();
            this.userMouse.type = 'mousemove';
            this.onTap( event );

        },

        /**
         * On mouse up
         * @param {MouseEvent} event
         * @memberOf Viewer
         * @instance
         */
        onMouseUp: function ( event ) {

            let onTarget = false;

            this.userMouse.type = 'mouseup';

            const type = ( this.userMouse.x >= event.clientX - this.options.clickTolerance
    				&& this.userMouse.x <= event.clientX + this.options.clickTolerance
    				&& this.userMouse.y >= event.clientY - this.options.clickTolerance
    				&& this.userMouse.y <= event.clientY + this.options.clickTolerance )
    				||  ( event.changedTouches
    				&& this.userMouse.x >= event.changedTouches[0].clientX - this.options.clickTolerance
    				&& this.userMouse.x <= event.changedTouches[0].clientX + this.options.clickTolerance
    				&& this.userMouse.y >= event.changedTouches[0].clientY - this.options.clickTolerance
    				&& this.userMouse.y <= event.changedTouches[0].clientY + this.options.clickTolerance )
                ? 'click' : undefined;

            // Event should happen on canvas
            if ( event && event.target && !event.target.classList.contains( 'panolens-canvas' ) ) { return; }

            event.preventDefault();

            if ( event.changedTouches && event.changedTouches.length === 1 ) {

                onTarget = this.onTap( { clientX: event.changedTouches[0].clientX, clientY: event.changedTouches[0].clientY }, type );

            } else {

                onTarget = this.onTap( event, type );

            }

            this.userMouse.type = 'none';

            if ( onTarget ) {

                return;

            }

            if ( type === 'click' ) {

                const { options: { autoHideInfospot, autoHideControlBar }, panorama, toggleControlBar } = this;

                if ( autoHideInfospot && panorama ) {

                    panorama.toggleInfospotVisibility();

                }

                if ( autoHideControlBar ) {

                    toggleControlBar();

                }

            }

        },

        /**
         * On tap eveny frame
         * @param {MouseEvent} event
         * @param {string} type
         * @memberOf Viewer
         * @instance
         */
        onTap: function ( event, type ) {

            const { left, top } = this.container.getBoundingClientRect();
            const { clientWidth, clientHeight } = this.container;

            this.raycasterPoint.x = ( ( event.clientX - left ) / clientWidth ) * 2 - 1;
            this.raycasterPoint.y = - ( ( event.clientY - top ) / clientHeight ) * 2 + 1;

            this.raycaster.setFromCamera( this.raycasterPoint, this.camera );

            // Return if no panorama
            if ( !this.panorama ) {

                return;

            }

            // output infospot information
            if ( event.type !== 'mousedown' && this.touchSupported || this.OUTPUT_INFOSPOT ) {

                this.outputPosition();

            }

            const intersects = this.raycaster.intersectObjects( this.panorama.children, true );
            const intersect_entity = this.getConvertedIntersect( intersects );
            const intersect = ( intersects.length > 0 ) ? intersects[0].object : undefined;

            if ( this.userMouse.type === 'mouseup'  ) {

                if ( intersect_entity && this.pressEntityObject === intersect_entity && this.pressEntityObject.dispatchEvent ) {

                    this.pressEntityObject.dispatchEvent( { type: 'pressstop-entity', mouseEvent: event } );

                }

                this.pressEntityObject = undefined;

            }

            if ( this.userMouse.type === 'mouseup'  ) {

                if ( intersect && this.pressObject === intersect && this.pressObject.dispatchEvent ) {

                    this.pressObject.dispatchEvent( { type: 'pressstop', mouseEvent: event } );

                }

                this.pressObject = undefined;

            }

            if ( type === 'click' ) {

                this.panorama.dispatchEvent( { type: 'click', intersects: intersects, mouseEvent: event } );

                if ( intersect_entity && intersect_entity.dispatchEvent ) {

                    intersect_entity.dispatchEvent( { type: 'click-entity', mouseEvent: event } );

                }

                if ( intersect && intersect.dispatchEvent ) {

                    intersect.dispatchEvent( { type: 'click', mouseEvent: event } );

                }

            } else {

                this.panorama.dispatchEvent( { type: 'hover', intersects: intersects, mouseEvent: event } );

                if ( ( this.hoverObject && intersects.length > 0 && this.hoverObject !== intersect_entity )
    				|| ( this.hoverObject && intersects.length === 0 ) ){

                    if ( this.hoverObject.dispatchEvent ) {

                        this.hoverObject.dispatchEvent( { type: 'hoverleave', mouseEvent: event } );

                        this.reticle.end();

                    }

                    this.hoverObject = undefined;

                }

                if ( intersect_entity && intersects.length > 0 ) {

                    if ( this.hoverObject !== intersect_entity ) {

                        this.hoverObject = intersect_entity;

                        if ( this.hoverObject.dispatchEvent ) {

                            this.hoverObject.dispatchEvent( { type: 'hoverenter', mouseEvent: event } );

                            // Start reticle timer
                            if ( this.options.autoReticleSelect && this.options.enableReticle || this.tempEnableReticle ) {
                                this.reticle.start( this.onTap.bind( this, event, 'click' ) );
                            }

                        }

                    }

                    if ( this.userMouse.type === 'mousedown' && this.pressEntityObject != intersect_entity ) {

                        this.pressEntityObject = intersect_entity;

                        if ( this.pressEntityObject.dispatchEvent ) {

                            this.pressEntityObject.dispatchEvent( { type: 'pressstart-entity', mouseEvent: event } );

                        }

                    }

                    if ( this.userMouse.type === 'mousedown' && this.pressObject != intersect ) {

                        this.pressObject = intersect;

                        if ( this.pressObject.dispatchEvent ) {

                            this.pressObject.dispatchEvent( { type: 'pressstart', mouseEvent: event } );

                        }

                    }

                    if ( this.userMouse.type === 'mousemove' || this.options.enableReticle ) {

                        if ( intersect && intersect.dispatchEvent ) {

                            intersect.dispatchEvent( { type: 'hover', mouseEvent: event } );

                        }

                        if ( this.pressEntityObject && this.pressEntityObject.dispatchEvent ) {

                            this.pressEntityObject.dispatchEvent( { type: 'pressmove-entity', mouseEvent: event } );

                        }

                        if ( this.pressObject && this.pressObject.dispatchEvent ) {

                            this.pressObject.dispatchEvent( { type: 'pressmove', mouseEvent: event } );

                        }

                    }

                }

                if ( !intersect_entity && this.pressEntityObject && this.pressEntityObject.dispatchEvent ) {

                    this.pressEntityObject.dispatchEvent( { type: 'pressstop-entity', mouseEvent: event } );

                    this.pressEntityObject = undefined;

                }

                if ( !intersect && this.pressObject && this.pressObject.dispatchEvent ) {

                    this.pressObject.dispatchEvent( { type: 'pressstop', mouseEvent: event } );

                    this.pressObject = undefined;

                }

            }

            // Infospot handler
            if ( intersect && intersect instanceof Infospot ) {

                this.infospot = intersect;

                if ( type === 'click' ) {

                    return true;

                }

            } else if ( this.infospot ) {

                this.hideInfospot();

            }

            // Auto rotate
            if ( this.options.autoRotate && this.userMouse.type !== 'mousemove' ) {

                // Auto-rotate idle timer
                clearTimeout( this.autoRotateRequestId );

                if ( this.control === this.OrbitControls ) {

                    this.OrbitControls.autoRotate = false;
                    this.autoRotateRequestId = window.setTimeout( this.enableAutoRate.bind( this ), this.options.autoRotateActivationDuration );

                }

            }

        },

        /**
         * Get converted intersect
         * @param {array} intersects
         * @memberOf Viewer
         * @instance
         */
        getConvertedIntersect: function ( intersects ) {

            let intersect;

            for ( let i = 0; i < intersects.length; i++ ) {

                if ( intersects[i].distance >= 0 && intersects[i].object && !intersects[i].object.passThrough ) {

                    if ( intersects[i].object.entity && intersects[i].object.entity.passThrough ) {
                        continue;
                    } else if ( intersects[i].object.entity && !intersects[i].object.entity.passThrough ) {
                        intersect = intersects[i].object.entity;
                        break;
                    } else {
                        intersect = intersects[i].object;
                        break;
                    }

                }

            }

            return intersect;

        },

        /**
         * Hide infospot
         * @memberOf Viewer
         * @instance
         */
        hideInfospot: function () {

            if ( this.infospot ) {

                this.infospot.onHoverEnd();

                this.infospot = undefined;

            }

        },

        /**
         * Toggle control bar
         * @memberOf Viewer
         * @instance
         * @fires Viewer#control-bar-toggle
         */
        toggleControlBar: function () {

            const { widget } = this;

            /**
             * Toggle control bar event
             * @type {object}
             * @event Viewer#control-bar-toggle
             */
            if ( widget ) {

                widget.dispatchEvent( { type: 'control-bar-toggle' } );

            }

        },

        /**
         * On key down
         * @param {KeyboardEvent} event
         * @memberOf Viewer
         * @instance
         */
        onKeyDown: function ( event ) {

            if ( this.options.output && this.options.output !== 'none' && event.key === 'Control' ) {

                this.OUTPUT_INFOSPOT = true;

            }

        },

        /**
         * On key up
         * @param {KeyboardEvent} event
         * @memberOf Viewer
         * @instance
         */
        onKeyUp: function () {

            this.OUTPUT_INFOSPOT = false;

        },

        /**
         * Update control and callbacks
         * @memberOf Viewer
         * @instance
         */
        update: function () {

            TWEEN.update();

            this.updateCallbacks.forEach( function( callback ){ callback(); } );

            this.control.update();

            this.scene.traverse( function( child ){
                if ( child instanceof Infospot
    				&& child.element
    				&& ( this.hoverObject === child
    					|| child.element.style.display !== 'none'
    					|| (child.element.left && child.element.left.style.display !== 'none')
    					|| (child.element.right && child.element.right.style.display !== 'none') ) ) {
                    if ( this.checkSpriteInViewport( child ) ) {
                        const { x, y } = this.getScreenVector( child.getWorldPosition( new THREE.Vector3() ) );
                        child.translateElement( x, y );
                    } else {
                        child.onDismiss();
                    }

                }
            }.bind( this ) );

        },

        /**
         * Rendering function to be called on every animation frame
         * Render reticle last
         * @memberOf Viewer
         * @instance
         */
        render: function () {

            if ( this.mode === MODES.CARDBOARD || this.mode === MODES.STEREO ) {

                this.renderer.clear();
                this.effect.render( this.scene, this.camera );
                this.effect.render( this.sceneReticle, this.camera );

            } else {

                this.renderer.clear();
                this.renderer.render( this.scene, this.camera );
                this.renderer.clearDepth();
                this.renderer.render( this.sceneReticle, this.camera );

            }

        },

        /**
         * Animate
         * @memberOf Viewer
         * @instance
         */
        animate: function () {

            this.requestAnimationId = window.requestAnimationFrame( this.animate.bind( this ) );

            this.onChange();

        },

        /**
         * On change
         * @memberOf Viewer
         * @instance
         */
        onChange: function () {

            this.update();
            this.render();

        },

        /**
         * Register mouse and touch event on container
         * @memberOf Viewer
         * @instance
         */
        registerMouseAndTouchEvents: function () {

            const options = { passive: false };

            this.container.addEventListener( 'mousedown' , 	this.HANDLER_MOUSE_DOWN, options );
            this.container.addEventListener( 'mousemove' , 	this.HANDLER_MOUSE_MOVE, options );
            this.container.addEventListener( 'mouseup'	 , 	this.HANDLER_MOUSE_UP  , options );
            this.container.addEventListener( 'touchstart', 	this.HANDLER_MOUSE_DOWN, options );
            this.container.addEventListener( 'touchend'  , 	this.HANDLER_MOUSE_UP  , options );

        },

        /**
         * Unregister mouse and touch event on container
         * @memberOf Viewer
         * @instance
         */
        unregisterMouseAndTouchEvents: function () {

            this.container.removeEventListener( 'mousedown' ,  this.HANDLER_MOUSE_DOWN, false );
            this.container.removeEventListener( 'mousemove' ,  this.HANDLER_MOUSE_MOVE, false );
            this.container.removeEventListener( 'mouseup'	,  this.HANDLER_MOUSE_UP  , false );
            this.container.removeEventListener( 'touchstart',  this.HANDLER_MOUSE_DOWN, false );
            this.container.removeEventListener( 'touchend'  ,  this.HANDLER_MOUSE_UP  , false );

        },

        /**
         * Register reticle event
         * @memberOf Viewer
         * @instance
         */
        registerReticleEvent: function () {

            this.addUpdateCallback( this.HANDLER_TAP );

        },

        /**
         * Unregister reticle event
         * @memberOf Viewer
         * @instance
         */
        unregisterReticleEvent: function () {

            this.removeUpdateCallback( this.HANDLER_TAP );

        },

        /**
         * Update reticle event
         * @memberOf Viewer
         * @instance
         */
        updateReticleEvent: function () {

            const clientX = this.container.clientWidth / 2 + this.container.offsetLeft;
            const clientY = this.container.clientHeight / 2;

            this.removeUpdateCallback( this.HANDLER_TAP );
            this.HANDLER_TAP = this.onTap.bind( this, { clientX, clientY } );
            this.addUpdateCallback( this.HANDLER_TAP );

        },

        /**
         * Register container and window listeners
         * @memberOf Viewer
         * @instance
         */
        registerEventListeners: function () {

            // Resize Event
            window.addEventListener( 'resize' , this.HANDLER_WINDOW_RESIZE, true );

            // Keyboard Event
            window.addEventListener( 'keydown', this.HANDLER_KEY_DOWN, true );
            window.addEventListener( 'keyup'  , this.HANDLER_KEY_UP	 , true );

        },

        /**
         * Unregister container and window listeners
         * @memberOf Viewer
         * @instance
         */
        unregisterEventListeners: function () {

            // Resize Event
            window.removeEventListener( 'resize' , this.HANDLER_WINDOW_RESIZE, true );

            // Keyboard Event
            window.removeEventListener( 'keydown', this.HANDLER_KEY_DOWN, true );
            window.removeEventListener( 'keyup'  , this.HANDLER_KEY_UP  , true );

        },

        /**
         * Dispose all scene objects and clear cache
         * @memberOf Viewer
         * @instance
         */
        dispose: function () {

            this.tweenLeftAnimation.stop();
            this.tweenUpAnimation.stop();

            // Unregister dom event listeners
            this.unregisterEventListeners();

            // recursive disposal on 3d objects
            function recursiveDispose ( object ) {

                for ( let i = object.children.length - 1; i >= 0; i-- ) {

                    recursiveDispose( object.children[i] );
                    object.remove( object.children[i] );

                }

                if ( object instanceof Panorama || object instanceof Infospot ) {

                    object.dispose();
                    object = null;

                } else if ( object.dispatchEvent ){

                    object.dispatchEvent( 'dispose' );

                }

            }

            recursiveDispose( this.scene );

            // dispose widget
            if ( this.widget ) {

                this.widget.dispose();
                this.widget = null;

            }

            // clear cache
            if ( THREE.Cache && THREE.Cache.enabled ) {

                THREE.Cache.clear();

            }

        },

        /**
         * Destroy viewer by disposing and stopping requestAnimationFrame
         * @memberOf Viewer
         * @instance
         */
        destroy: function () {

            this.dispose();
            this.render();
            window.cancelAnimationFrame( this.requestAnimationId );

        },

        /**
         * On panorama dispose
         * @memberOf Viewer
         * @instance
         */
        onPanoramaDispose: function ( panorama ) {

            if ( panorama instanceof VideoPanorama ) {

                this.hideVideoWidget();

            }

            if ( panorama === this.panorama ) {

                this.panorama = null;

            }

        },

        /**
         * Load ajax call
         * @param {string} url - URL to be requested
         * @param {function} [callback] - Callback after request completes
         * @memberOf Viewer
         * @instance
         */
        loadAsyncRequest: function ( url, callback = () => {} ) {

            const request = new window.XMLHttpRequest();
            request.onloadend = function ( event ) {
                callback( event );
            };
            request.open( 'GET', url, true );
            request.send( null );

        },

        /**
         * View indicator in upper left
         * @memberOf Viewer
         * @instance
         */
        addViewIndicator: function () {

            const scope = this;

            function loadViewIndicator ( asyncEvent ) {

                if ( asyncEvent.loaded === 0 ) return;

                const viewIndicatorDiv = asyncEvent.target.responseXML.documentElement;
                viewIndicatorDiv.style.width = scope.viewIndicatorSize + 'px';
                viewIndicatorDiv.style.height = scope.viewIndicatorSize + 'px';
                viewIndicatorDiv.style.position = 'absolute';
                viewIndicatorDiv.style.top = '10px';
                viewIndicatorDiv.style.left = '10px';
                viewIndicatorDiv.style.opacity = '0.5';
                viewIndicatorDiv.style.cursor = 'pointer';
                viewIndicatorDiv.id = 'panolens-view-indicator-container';

                scope.container.appendChild( viewIndicatorDiv );

                const indicator = viewIndicatorDiv.querySelector( '#indicator' );
                const setIndicatorD = function () {

                    scope.radius = scope.viewIndicatorSize * 0.225;
                    scope.currentPanoAngle = scope.camera.rotation.y - THREE.Math.degToRad( 90 );
                    scope.fovAngle = THREE.Math.degToRad( scope.camera.fov ) ;
                    scope.leftAngle = -scope.currentPanoAngle - scope.fovAngle / 2;
                    scope.rightAngle = -scope.currentPanoAngle + scope.fovAngle / 2;
                    scope.leftX = scope.radius * Math.cos( scope.leftAngle );
                    scope.leftY = scope.radius * Math.sin( scope.leftAngle );
                    scope.rightX = scope.radius * Math.cos( scope.rightAngle );
                    scope.rightY = scope.radius * Math.sin( scope.rightAngle );
                    scope.indicatorD = 'M ' + scope.leftX + ' ' + scope.leftY + ' A ' + scope.radius + ' ' + scope.radius + ' 0 0 1 ' + scope.rightX + ' ' + scope.rightY;

                    if ( scope.leftX && scope.leftY && scope.rightX && scope.rightY && scope.radius ) {

                        indicator.setAttribute( 'd', scope.indicatorD );

                    }

                };

                scope.addUpdateCallback( setIndicatorD );

                const indicatorOnMouseEnter = function () {

                    this.style.opacity = '1';

                };

                const indicatorOnMouseLeave = function () {

                    this.style.opacity = '0.5';

                };

                viewIndicatorDiv.addEventListener( 'mouseenter', indicatorOnMouseEnter );
                viewIndicatorDiv.addEventListener( 'mouseleave', indicatorOnMouseLeave );
            }

            this.loadAsyncRequest( DataImage.ViewIndicator, loadViewIndicator );

        },

        /**
         * Append custom control item to existing control bar
         * @param {object} [option={}] - Style object to overwirte default element style. It takes 'style', 'onTap' and 'group' properties.
         * @memberOf Viewer
         * @instance
         */
        appendControlItem: function ( option ) {

            const item = this.widget.createCustomItem( option );

            if ( option.group === 'video' ) {

                this.widget.videoElement.appendChild( item );

            } else {

                this.widget.barElement.appendChild( item );

            }

            return item;

        },

        /**
         * Clear all cached files
         * @memberOf Viewer
         * @instance
         */
        clearAllCache: function () {

            THREE.Cache.clear();

        }

    } );

    export { Viewer };