panorama/Panorama.js

import { Infospot } from '../infospot/Infospot';
import { DataImage } from '../DataImage';
import * as THREE from 'three';
import TWEEN from '@tweenjs/tween.js';
import { EquirectShader } from '../shaders/EquirectShader';
import { EVENTS } from '../Constants';

/**
 * @classdesc Base Panorama
 * @constructor
 */
function Panorama () {

    this.edgeLength = 10000;

    THREE.Mesh.call( this, this.createGeometry( this.edgeLength ), this.createMaterial() );

    this.type = 'panorama';

    this.ImageQualityLow = 1;
    this.ImageQualityFair = 2;
    this.ImageQualityMedium = 3;
    this.ImageQualityHigh = 4;
    this.ImageQualitySuperHigh = 5;

    this.animationDuration = 1000;
    this.defaultInfospotSize = 350;
    this.container = undefined;
    this.loaded = false;
    this.linkedSpots = [];
    this.isInfospotVisible = false;
	
    this.linkingImageURL = undefined;
    this.linkingImageScale = undefined;

    this.renderOrder = -1;
    this.visible = false;
    this.active = false;

    this.infospotAnimation = new TWEEN.Tween( this ).to( {}, this.animationDuration / 2 );

    this.addEventListener( EVENTS.CONTAINER, this.setContainer.bind( this ) );
    this.addEventListener( 'click', this.onClick.bind( this ) );

    this.setupTransitions();

}

Panorama.prototype = Object.assign( Object.create( THREE.Mesh.prototype ), {

    constructor: Panorama,

    /**
     * Create a skybox geometry
     * @memberOf Panorama
     * @instance
     */
    createGeometry: function ( edgeLength ) {

        return new THREE.BoxBufferGeometry( edgeLength, edgeLength, edgeLength );

    },

    /**
     * Create equirectangular shader material
     * @param {THREE.Vector2} [repeat=new THREE.Vector2( 1, 1 )] - Texture Repeat
     * @param {THREE.Vector2} [offset=new THREE.Vector2( 0, 0 )] - Texture Offset
     * @memberOf Panorama
     * @instance
     */
    createMaterial: function ( repeat = new THREE.Vector2( 1, 1 ), offset = new THREE.Vector2( 0, 0 ) ) {

        const { fragmentShader, vertexShader } = EquirectShader;
        const uniforms = THREE.UniformsUtils.clone( EquirectShader.uniforms );
        
        uniforms.repeat.value.copy( repeat );
        uniforms.offset.value.copy( offset );
        uniforms.opacity.value = 0.0;

        const material = new THREE.ShaderMaterial( {

            fragmentShader,
            vertexShader,
            uniforms,
            side: THREE.BackSide,
            transparent: true
    
        } );

        return material;

    },

    /**
     * Adding an object
     * @memberOf Panorama
     * @instance
     * @param {THREE.Object3D} object - The object to be added
     */
    add: function ( object ) {

        if ( arguments.length > 1 ) {

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

                this.add( arguments[ i ] );

            }

            return this;

        }

        // In case of infospots
        if ( object instanceof Infospot ) {

            const { container } = this;

            if ( container ) { 
                
                object.dispatchEvent( { type: EVENTS.CONTAINER, container } ); 
            
            }
            
            object.dispatchEvent( { type: 'panolens-infospot-focus', method: function ( vector, duration, easing ) {

                /**
                 * Infospot focus handler event
                 * @type {object}
                 * @event Panorama#panolens-viewer-handler
                 * @property {string} method - Viewer function name
                 * @property {*} data - The argument to be passed into the method
                 */
                this.dispatchEvent( { type: EVENTS.VIEWER_HANDLER, method: 'tweenControlCenter', data: [ vector, duration, easing ] } );


            }.bind( this ) } );

        }

        THREE.Object3D.prototype.add.call( this, object );

    },

    getTexture: function(){

        return this.material.uniforms.texture.value;

    },

    /**
     * Load Panorama
     * @param {boolean} immediate load immediately
     */
    load: function ( immediate = true ) {

        /**
         * Start loading panorama event
         * @type {object}
         * @event Panorama#load-start
         */
        this.dispatchEvent( { type: EVENTS.LOAD_START } );

        if (immediate) this.onLoad();
		
    },

    /**
     * Click event handler
     * @param  {object} event - Click event
     * @memberOf Panorama
     * @instance
     * @fires Infospot#dismiss
     */
    onClick: function ( event ) {

        if ( event.intersects && event.intersects.length === 0 ) {

            this.traverse( function ( object ) {

                /**
                 * Dimiss event
                 * @type {object}
                 * @event Infospot#dismiss
                 */
                object.dispatchEvent( { type: 'dismiss' } );

            } );

        }

    },

    /**
     * Set container of this panorama 
     * @param {HTMLElement|object} data - Data with container information
     * @memberOf Panorama
     * @instance
     * @fires Infospot#panolens-container
     */
    setContainer: function ( data ) {

        let container;

        if ( data instanceof HTMLElement ) {

            container = data;

        } else if ( data && data.container ) {

            container = data.container;

        }

        if ( container ) {

            this.children.forEach( function ( child ) {

                if ( child instanceof Infospot && child.dispatchEvent ) {

                    /**
                     * Set container event
                     * @type {object}
                     * @event Infospot#panolens-container
                     * @property {HTMLElement} container - The container of this panorama
                     */
                    child.dispatchEvent( { type: EVENTS.CONTAINER, container: container } );

                }

            } );

            this.container = container;

        }

    },

    /**
     * This will be called when panorama is loaded
     * @memberOf Panorama
     * @instance
     * @fires Panorama#loaded
     */
    onLoad: function () {

        this.loaded = true;

        /**
         * Loaded panorama event
         * @type {object}
         * @event Panorama#loaded
         */
        this.dispatchEvent( { type: EVENTS.LOADED } );

        /**
         * Alias of loaded event
         * @type {object}
         * @event Panorama#load
         */
        this.dispatchEvent( { type: EVENTS.LOAD } );

        /**
         * Panorama is ready to be animated
         * @event Panorama#ready
         * @type {object} 
         */
        this.dispatchEvent( { type: EVENTS.READY } );

    },

    /**
     * This will be called when panorama is in progress
     * @memberOf Panorama
     * @instance
     * @fires Panorama#progress
     */
    onProgress: function ( progress ) {

        /**
         * Loading panorama progress event
         * @type {object}
         * @event Panorama#progress
         * @property {object} progress - The progress object containing loaded and total amount
         */
        this.dispatchEvent( { type: EVENTS.PROGRESS, progress: progress } );

    },

    /**
     * This will be called when panorama loading has error
     * @memberOf Panorama
     * @instance
     * @fires Panorama#error
     */
    onError: function () {

        /**
         * Loading panorama error event
         * @type {object}
         * @event Panorama#error
         */
        this.dispatchEvent( { type: EVENTS.READY } );

    },

    /**
     * Get zoom level based on window width
     * @memberOf Panorama
     * @instance
     * @return {number} zoom level indicating image quality
     */
    getZoomLevel: function () {

        let zoomLevel;

        if ( window.innerWidth <= 800 ) {

            zoomLevel = this.ImageQualityFair;

        } else if ( window.innerWidth > 800 &&  window.innerWidth <= 1280 ) {

            zoomLevel = this.ImageQualityMedium;

        } else if ( window.innerWidth > 1280 && window.innerWidth <= 1920 ) {

            zoomLevel = this.ImageQualityHigh;

        } else if ( window.innerWidth > 1920 ) {

            zoomLevel = this.ImageQualitySuperHigh;

        } else {

            zoomLevel = this.ImageQualityLow;

        }

        return zoomLevel;

    },

    /**
     * Update texture of a panorama
     * @memberOf Panorama
     * @instance
     * @param {THREE.Texture} texture - Texture to be updated
     */
    updateTexture: function ( texture ) {

        this.material.uniforms.texture.value = texture;

    },

    /**
     * Toggle visibility of infospots in this panorama
     * @param  {boolean} isVisible - Visibility of infospots
     * @param  {number} delay - Delay in milliseconds to change visibility
     * @memberOf Panorama
     * @instance
     * @fires Panorama#infospot-animation-complete
     */
    toggleInfospotVisibility: function ( isVisible, delay ) {

        delay = ( delay !== undefined ) ? delay : 0;

        const visible = ( isVisible !== undefined ) ? isVisible : ( this.isInfospotVisible ? false : true );

        this.traverse( function ( object ) {

            if ( object instanceof Infospot ) {

                if ( visible ) {

                    object.show( delay );

                } else {

                    object.hide( delay );

                }

            }

        } );

        this.isInfospotVisible = visible;

        // Animation complete event
        this.infospotAnimation.onComplete( function () {

            /**
             * Complete toggling infospot visibility
             * @event Panorama#infospot-animation-complete
             * @type {object} 
             */
            this.dispatchEvent( { type: EVENTS.INFOSPOT_ANIMATION_COMPLETE, visible: visible } );

        }.bind( this ) ).delay( delay ).start();

    },

    /**
     * Set image of this panorama's linking infospot
     * @memberOf Panorama
     * @instance
     * @param {string} url   - Url to the image asset
     * @param {number} scale - Scale factor of the infospot
     */
    setLinkingImage: function ( url, scale ) {

        this.linkingImageURL = url;
        this.linkingImageScale = scale;

    },

    /**
     * Link one-way panorama
     * @param  {Panorama} pano  - The panorama to be linked to
     * @param  {THREE.Vector3} position - The position of infospot which navigates to the pano
     * @param  {number} [imageScale=300] - Image scale of linked infospot
     * @param  {string} [imageSrc=DataImage.Arrow] - The image source of linked infospot
     * @memberOf Panorama
     * @instance
     */
    link: function ( pano, position, imageScale, imageSrc ) {

        let scale, img;

        this.visible = true;

        if ( !position ) {

            console.warn( 'Please specify infospot position for linking' );

            return;

        }

        // Infospot scale
        if ( imageScale !== undefined ) {

            scale = imageScale;

        } else if ( pano.linkingImageScale !== undefined ) {

            scale = pano.linkingImageScale;

        } else {

            scale = 300;

        }


        // Infospot image
        if ( imageSrc ) {

            img = imageSrc;

        } else if ( pano.linkingImageURL ) {

            img = pano.linkingImageURL;

        } else {

            img = DataImage.Arrow;

        }

        // Creates a new infospot
        const spot = new Infospot( scale, img );
        spot.position.copy( position );
        spot.toPanorama = pano;
        spot.addEventListener( 'click', function () {

            /**
             * Viewer handler event
             * @type {object}
             * @event Panorama#panolens-viewer-handler
             * @property {string} method - Viewer function name
             * @property {*} data - The argument to be passed into the method
             */
            this.dispatchEvent( { type: EVENTS.VIEWER_HANDLER, method: 'setPanorama', data: pano } );

        }.bind( this ) );

        this.linkedSpots.push( spot );

        this.add( spot );

        this.visible = false;

    },

    reset: function () {

        this.children.length = 0;	

    },

    setupTransitions: function () {

        this.fadeInAnimation = new TWEEN.Tween();

        this.fadeOutAnimation = new TWEEN.Tween();

        this.enterTransition = new TWEEN.Tween( this )
            .easing( TWEEN.Easing.Quartic.Out )
            .onComplete( function () {

                /**
                 * Enter panorama and animation complete event
                 * @event Panorama#enter-complete
                 * @type {object} 
                 */
                this.dispatchEvent( { type: EVENTS.ENTER_COMPLETE } );

            }.bind ( this ) )
            .start();

        this.leaveTransition = new TWEEN.Tween( this )
            .easing( TWEEN.Easing.Quartic.Out );

    },

    /**
     * Start fading in animation
     * @memberOf Panorama
     * @instance
     * @fires Panorama#enter-fade-complete
     */
    fadeIn: function ( duration = this.animationDuration ) {

        /**
         * Fade in event
         * @event Panorama#fade-in
         * @type {object} 
         */
        this.dispatchEvent( { type: EVENTS.FADE_IN } );

        const { opacity } = this.material.uniforms ? this.material.uniforms : { opacity: this.material.opacity };
        const onStart = function() {

            this.visible = true;

            /**
             * Enter panorama fade in start event
             * @event Panorama#enter-fade-start
             * @type {object} 
             */
            this.dispatchEvent( { type: EVENTS.ENTER_FADE_START } );

        }.bind( this );
        const onComplete = function() {

            this.toggleInfospotVisibility( true, duration / 2 );

            /**
             * Enter panorama fade complete event
             * @event Panorama#enter-fade-complete
             * @type {object} 
             */
            this.dispatchEvent( { type: EVENTS.ENTER_FADE_COMPLETE } );

        }.bind( this );

        this.fadeOutAnimation.stop();
        this.fadeInAnimation = new TWEEN.Tween( opacity )
            .to( { value: 1 }, duration )
            .easing( TWEEN.Easing.Quartic.Out )
            .onStart( onStart )
            .onComplete( onComplete )
            .start();
        
    },

    /**
     * Start fading out animation
     * @memberOf Panorama
     * @instance
     */
    fadeOut: function ( duration = this.animationDuration ) {

        /**
         * Fade out event
         * @event Panorama#fade-out
         * @type {object} 
         */
        this.dispatchEvent( { type: EVENTS.FADE_OUT } );

        const { opacity } = this.material.uniforms ? this.material.uniforms : { opacity: this.material.opacity };
        const onComplete = function() {

            this.visible = false;

            /**
             * Leave panorama complete event
             * @event Panorama#leave-complete
             * @type {object} 
             */
            this.dispatchEvent( { type: EVENTS.LEAVE_COMPLETE } );

        }.bind( this );

        this.fadeInAnimation.stop();
        this.fadeOutAnimation = new TWEEN.Tween( opacity )
            .to( { value: 0 }, duration )
            .easing( TWEEN.Easing.Quartic.Out )
            .onComplete( onComplete )
            .start();

    },

    /**
     * This will be called when entering a panorama 
     * @memberOf Panorama
     * @instance
     * @fires Panorama#enter
     * @fires Panorama#enter-start
     */
    onEnter: function () {

        const duration = this.animationDuration;

        /**
         * Enter panorama event
         * @event Panorama#enter
         * @type {object} 
         */
        this.dispatchEvent( { type: EVENTS.ENTER } );

        this.leaveTransition.stop();
        this.enterTransition
            .to( {}, duration )
            .onStart( function () {

                /**
                 * Enter panorama and animation starting event
                 * @event Panorama#enter-start
                 * @type {object} 
                 */
                this.dispatchEvent( { type: EVENTS.ENTER_START } );
				
                if ( this.loaded ) {

                    /**
                     * Panorama is ready to go
                     * @event Panorama#ready
                     * @type {object} 
                     */
                    this.dispatchEvent( { type: EVENTS.READY } );

                } else {

                    this.load();

                }
				
            }.bind( this ) )
            .start();

        this.children.forEach( child => {

            child.dispatchEvent( { type: 'panorama-enter' } );

        } );

        this.active = true;

    },

    /**
     * This will be called when leaving a panorama
     * @memberOf Panorama
     * @instance
     * @fires Panorama#leave
     */
    onLeave: function () {

        const duration = this.animationDuration;

        this.enterTransition.stop();
        this.leaveTransition
            .to( {}, duration )
            .onStart( function () {

                /**
                 * Leave panorama and animation starting event
                 * @event Panorama#leave-start
                 * @type {object} 
                 */
                this.dispatchEvent( { type: EVENTS.LEAVE_START } );

                this.fadeOut( duration );
                this.toggleInfospotVisibility( false );

            }.bind( this ) )
            .start();

        /**
         * Leave panorama event
         * @event Panorama#leave
         * @type {object} 
         */
        this.dispatchEvent( { type: EVENTS.LEAVE } );

        // dispatch panorama-leave to descendents
        this.traverse( child => child.dispatchEvent( { type: 'panorama-leave' } ));

        // mark active
        this.active = false;

    },

    /**
     * Dispose panorama
     * @memberOf Panorama
     * @instance
     */
    dispose: function () {

        const { material } = this;

        if ( material && material.uniforms && material.uniforms.texture ) material.uniforms.texture.value.dispose();

        this.infospotAnimation.stop();
        this.fadeInAnimation.stop();
        this.fadeOutAnimation.stop();
        this.enterTransition.stop();
        this.leaveTransition.stop();

        /**
         * On panorama dispose handler
         * @type {object}
         * @event Panorama#panolens-viewer-handler
         * @property {string} method - Viewer function name
         * @property {*} data - The argument to be passed into the method
         */
        this.dispatchEvent( { type: EVENTS.VIEWER_HANDLER, method: 'onPanoramaDispose', data: this } );

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

            const { geometry, material } = object;

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

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

            }

            if ( object instanceof Infospot ) {

                object.dispose();

            }
			
            if ( geometry ) { geometry.dispose(); object.geometry = null; }
            if ( material ) { material.dispose(); object.material = null; }

        }

        recursiveDispose( this );

        if ( this.parent ) {

            this.parent.remove( this );

        }

    }

} );

export { Panorama };