src/provider/ExhibitionProvider.js

goog.module('gep.provider.ExhibitionProvider');

const Completer = goog.require('clulib.async.Completer');
const {MediaProvider} = goog.require('nbsrc.provider.MediaProvider');
const {request} = goog.require('nbsrc.net.HttpRequest');

const EventTarget = goog.require('goog.events.EventTarget');
const {Rect} = goog.require('goog.math');
const Range = goog.require('goog.math.Range');

/**
 * This provider (singleton) handles the communication with the backend.
 * It loads all relevant data and prepares the data in various model objects and
 * provides them to the application for building the 3D scene and the content layers.
 * @extends {EventTarget}
 */
class ExhibitionProvider extends EventTarget
{
    constructor()
    {
        super();

        /**
         * @type {THREE.GLTFLoader}
         * @private
         */
        this.gltfLoader_ = new THREE.GLTFLoader();

        /**
         * @type {THREE.DRACOLoader}
         * @private
         */
        this.dracoLoader_ = new THREE.DRACOLoader();
        this.dracoLoader_.setDecoderPath('javascript/node_modules/three/examples/js/libs/draco/');
        this.gltfLoader_.setDRACOLoader(this.dracoLoader_);

        /**
         * @type {THREE.TextureLoader}
         * @private
         */
        this.textureLoader_ = new THREE.TextureLoader();
        this.textureLoader_.crossOrigin = "";

        /**
         * List of info objects for generating the artwork info layer. Will be filled with backend data.
         * @type {Array<{type:string,text:string}>}
         * @private
         */
        this.info_ = [];

        /**
         * List of all exhibit model items
         * @type {Array<ExhibitionItemModel>}
         * @private
         */
        this.items_ = [];

        /**
         * Represents a model with the preferences of the visitor generated from the tags of the viewed artworks.
         * @type {ExhibitionVisitorModel}
         * @private
         */
        this.visitor_ = null;

        /**
         * Completer is a helper which returns a promise. Its solved if the general exhibition data is loaded.
         * @type {Completer}
         * @private
         */
        this.dataCompleter_ = null;

        /**
         * Completer is a helper which returns a promise. Its solved if the visitor info data is loaded.
         * @type {Completer}
         * @private
         */
        this.infoCompleter_ = null;

        /**
         * Saves the already loaded Vimeo video data in a list
         * @type {Map<string,Object>}
         * @private
         */
        this.videoData_ = new Map();

        /**
         * List to assign Cannon body ids (number) to THREE objects names (string)
         * @type {Map<number,string>}
         */
        this.physicBodyReferences = new Map();

        /**
         * Loading progress of the exhibition. Is used for the main loader at the beginning.
         * @type {number}
         * @private
         */
        this.loadProgress = 0;

        /**
         * Save the reflection texture used for all 3D objects.
         * @type {THREE.Texture}
         * @private
         */
        this.reflectionTexture_ = null;

        /**
         * List of defined exhibit ids that should not be displayed in the mobile view due to their size or structure to improve mobile performance.
         * @type {Array<number>}
         * @private
         */
        this.excludedItemIds_ = [];
    }

    /**
     * Load the general structure of the exhibition from the backend via the POST service `/get-exhibition-data`.
     * For local development, the URL parameter `backend=0` can be used to replace the request by loading the `public/json/exhibition.json`.
     * @return {Promise}
     */
    load()
    {
        if(!this.dataCompleter_)
        {
            this.dataCompleter_ = new Completer();

            if(window['DEBUG_MODE'] === true && window['BACKEND_MODE'] === 'off')
            {
                MediaProvider.getInstance().mediaAssetPath = '';
                MediaProvider.getInstance().load('json/exhibition.json').then((result) => {
                    const data = result['data'];
                    this.generateItems_(/** @type {Array<Object>} */ (data));
                    this.dataCompleter_.resolve();
                });
            }
            else
            {
                let data = new FormData();
                data.append('action', 'get-exhibition-data');
                if(window['PREVIEW_MODE'] === 'on')
                    data.append('preview', 'yes');
                request('/get-exhibition-data', 'POST', '', data).then((response) => {
                    const data = JSON.parse(response)['data'];
                    if(window['STATS_MODE'] === 'on' || window['PREVIEW_MODE'] === 'on')
                        console.log("DATA", data);
                    this.generateItems_(/** @type {Array<Object>} */ (data));
                    this.dataCompleter_.resolve();
                });
            }
        }

        return this.dataCompleter_.getPromise();
    }

    /**
     * Load the visitor info from the backend via the POST service `/get-info-data`.
     * For local development, the URL parameter `backend=0` can be used to replace the request by loading the `public/json/visitor-info.json`.
     * @return {Promise}
     */
    loadInfo()
    {
        if(!this.infoCompleter_)
        {
            this.infoCompleter_ = new Completer();
            if(window['DEBUG_MODE'] === true && window['BACKEND_MODE'] === 'off')
            {
                MediaProvider.getInstance().mediaAssetPath = '';
                MediaProvider.getInstance().load('json/visitor-info.json').then((result) => {
                    this.info_ = /** @type {Array<{type:string,text:string}>} */ (result['data']);
                    this.infoCompleter_.resolve();
                });
            }
            else
            {
                let data = new FormData();
                data.append('action', 'get-info-data');
                request('/get-info-data', 'POST', '', data).then((response) => {
                    this.info_ = /** @type {Array<{type:string,text:string}>} */ (JSON.parse(response));
                    this.infoCompleter_.resolve();
                });
            }
        }

        return this.infoCompleter_.getPromise();
    }

    /**
     * Creates the {@Link ExhibitionItemModel} from the loaded data and creates a new {@Link ExhibitionVisitorModel}.
     * @param {Array<Object>} result
     * @private
     */
    generateItems_(result)
    {
        let profile = new Map();
        result.forEach((data) => {
            let item = new ExhibitionItemModel(data);
            let tagNames = item.tags.keys();
            for(const tagName of tagNames)
            {
                if(!profile.has(tagName))
                    profile.set(tagName, 0);
            }
            if(this.excludedItemIds_.indexOf(item.id) == -1)
                this.items_.push(item);
        });
        let sortedProfile = new Map([...profile].sort());
        this.visitor_ = new ExhibitionVisitorModel(sortedProfile);
    }

    /**
     * Loads Vimeo video info to a video id via the POST service `/get-video-data`.
     * @param {string} mediaId
     * @return {Promise}
     */
    loadVideoData(mediaId)
    {
        if(this.videoData_.has(mediaId))
            return Promise.resolve(this.videoData_.get(mediaId));

        let data = new FormData();
        data.append('vimeoId', mediaId);
        return request('/get-video-data', 'POST', '', data).then((response) => {
            let result = JSON.parse(response);
            this.videoData_.set(mediaId, result.data);
            return result.data;
        });
    }

    /**
     * Can be called to receive a Promise that is triggered when the main exhibition data loading process is completed.
     * @return {Promise}
     */
    ready()
    {
        return this.load();
    }

    /**
     * Loads a glb file and returns a loading Promise
     * @param {string} filePath
     * @param {Function=} progressCallback
     * @return {Promise}
     */
    loadSceneObject(filePath, progressCallback = null)
    {
        let completer = new Completer();
        this.gltfLoader_.load(filePath, (gltf) => {
            completer.resolve(gltf);
        }, (progress) => {if(progressCallback) progressCallback(progress);}, (error) => {completer.reject('Failed to load object:"'+filePath+'"')});
        return completer.getPromise();
    }

    /**
     * Loads a texture file and returns a loading Promise
     * @param {string} filePath
     * @param {Function=} progressCallback
     * @return {Promise}
     */
    loadTextureFile(filePath, progressCallback = null)
    {
        let completer = new Completer();
        this.textureLoader_.load(filePath, (texture) => {
            completer.resolve(texture);
        }, (progress) => {if(progressCallback) progressCallback(progress);}, (error) => {completer.reject('Failed to load texture:"'+filePath+'"')});
        return completer.getPromise();
    }

    /**
     * Loads all texture files for a THREE cube texture (used as a reflection) and returns a loading Promise
     * @param {Function=} progressCallback
     * @return {Promise}
     */
    loadReflectionTexture(progressCallback = null)
    {
        let completer = new Completer();
        new THREE.CubeTextureLoader()
            .setPath( './images/textures/' )
            .load(['px.png', 'nx.png', 'py.png', 'ny.png', 'pz.png', 'nz.png'], (texture) => {
                this.reflectionTexture_ = texture;
                completer.resolve(this.reflectionTexture_);
            }, (progress) => {if(progressCallback) progressCallback(progress);}, (error) => {completer.reject('Failed to load cube texture')});
        return completer.getPromise();
    }

    /**
     * Adds a new visitor preference
     * @param {number} itemId Id of the exhibit
     * @param {number} elementId Id of the artwork in the exhibit
     */
    addPreference(itemId, elementId)
    {
        let tags = this.items_[itemId].tags;
        this.visitor_.addPreference(itemId, elementId, tags);
    }

    /**
     * Getter
     * @return {Array<ExhibitionItemModel>}
     */
    get items()
    {
        return this.items_;
    }

    /**
     * Getter
     * @return {Array<{type: string, text: string}>}
     */
    get info()
    {
        return this.info_;
    }

    /**
     * Getter
     * @return {ExhibitionVisitorModel}
     */
    get visitor()
    {
        return this.visitor_;
    }

    /**
     * Getter
     * @return {THREE.Texture}
     */
    get reflectionTexture()
    {
        return this.reflectionTexture_;
    }

    /**
     * Setter
     * @param {Array<number>} excludedItemIds
     */
    set excludedItemIds(excludedItemIds)
    {
        this.excludedItemIds_ = excludedItemIds;
    }

    /**
     * Getter
     * @return {Array<number>}
     */
    get excludedItemIds()
    {
        return this.excludedItemIds_;
    }
}

goog.addSingletonGetter(ExhibitionProvider);

/**
 * Model contains the preferences of the visitor generated from the tags of the viewed artworks.
 * @extends {EventTarget}
 */
class ExhibitionVisitorModel extends EventTarget
{
    /**
     * @param {Map<string,number>} profile
     */
    constructor(profile)
    {
        super();

        /**
         * Presents a list of all known tags and their value/importance to the user based preferences.
         * @type {Map<string,number>}
         */
        this.profile = profile;

        /**
         * List of all preference models that have influenced the visitor in the past
         * @type {Array<ExhibitionVisitorPreverenceModel>}
         */
        this.history = [];

        /**
         * Last added preference model
         * @type {ExhibitionVisitorPreverenceModel}
         * @private
         */
        this.currentPreference_ = null;
    }

    /**
     * Adds a new Preference
     * @param {number} itemId Id of the exhibit
     * @param {number} elementId Id of the artwork inside the exhibit
     * @param {Map<string,number>} tags List of belonging tags of the artwork
     * @return {ExhibitionVisitorPreverenceModel}
     */
    addPreference(itemId, elementId, tags)
    {
        this.currentPreference_ = new ExhibitionVisitorPreverenceModel(itemId+'::'+elementId, tags);
        this.history.push(this.currentPreference_);
        this.dispatchEvent(new Event(ExhibitionEventType.UPDATE_VISITOR_PREFERENCE));

        return this.currentPreference_;
    }

    /**
     * This method is called after leaving an activation area of a exhibit to stop the visit time. The visit time influences the calculation of the value/importance of the different tags.
     */
    completeCurrentPreference()
    {
        if(this.currentPreference_)
            this.currentPreference_.complete();
        this.currentPreference_ = null;

        this.dispatchEvent(new Event(ExhibitionEventType.UPDATE_VISITOR_PREFERENCE));
    }

    /**
     * This method is called in the main render process of the WebGLRenderer to recalculate the values of the visitor profile.
     * @param {number} delta
     * @param {number} changeAmount
     * @return {Map<string,number>}
     */
    updateProfile(delta, changeAmount)
    {
        if(this.currentPreference_)
        {
            this.profile.forEach((userValue, tag) => {
                let artworkValue = this.currentPreference_.tags.has(tag) ? this.currentPreference_.tags.get(tag) : 0;
                this.profile.set(tag, userValue + (artworkValue - userValue) / 100 * changeAmount * delta);
            });
            this.dispatchEvent(new Event(ExhibitionEventType.UPDATE_VISITOR_PREFERENCE));
        }
        return this.profile;
    }

    /**
     * Calculates a value between 0 and 1 for the display of the importance of each tag depending on the total number of all known tags.
     * @param {Map<string,number>} tags
     */
    caluculateEqualityFactor(tags)
    {
        let compareProduct = 0;
        let userNormSum = 0;
        let compareNormSum = 0;
        this.profile.forEach((userValue, tag) => {
            let compareValue = tags.has(tag) ? tags.get(tag) : 0;
            compareProduct += userValue * compareValue;
            userNormSum += Math.pow(userValue,2);
            compareNormSum += Math.pow(compareValue,2);
        });
        return compareProduct / (Math.sqrt(userNormSum) * Math.sqrt(compareNormSum));
    }

    /**
     * Checks if the current preference contains a specific tag.
     * @param {string} tag
     * @return {boolean}
     */
    currentPreferenceHasTag(tag)
    {
        return this.currentPreference_ != null && this.currentPreference_.tags.has(tag) && this.currentPreference_.tags.get(tag) != 0;
    }
}

/**
 * This model represents exactly one visitor preference.
 * It contains a name consisting of the exhibit id and the artwork id
 * so that an assignment is possible at any time, the corresponding tags,
 * the activation time and the time when the user has left the activation zone again.
 */
class ExhibitionVisitorPreverenceModel
{
    /**
     * @param {string} name Is created by exhibit id plus artwork id: `itemId::elementName`
     * @param {Map<string,number>} tags
     */
    constructor(name, tags)
    {
        /**
         * Is created by exhibit id plus artwork id: `itemId::elementName`
         * @type {string}
         */
        this.name = name;

        /**
         * List of tags and their value/importance to the user based preferences.
         * @type {Map<string, number>}
         */
        this.tags = tags;

        /**
         * With creation of the model, when the user enters an activity zone, the start time is also set.
         * @type {number}
         * @private
         */
        this.startTime_ = Date.now();

        /**
         * End time will be set when the user has left the activation zone again.
         * @type {number|undefined}
         * @private
         */
        this.completeTime_ = undefined;
    }

    /**
     * Method is called to set the end/complete time
     */
    complete()
    {
        this.completeTime_ = Date.now();
    }

    /**
     * Getter
     * @return {number}
     */
    get time()
    {
        return (this.completeTime_ ? this.completeTime_ : Date.now()) - this.startTime_;
    }
}

/**
 * This model contains all data of an exhibits and is filled by passing a data object from the loading process of {@Link ExhibitionProvider#load}.
 */
class ExhibitionItemModel
{
    /**
     * @param {Object} data Filled with data loaded in {@Link ExhibitionProvider#load}
     */
    constructor(data)
    {
        /**
         * Unique identifier from the backend
         * @type {number}
         */
        this.id = parseInt(data['id'], 10) || -1;

        /**
         * Tags are used to give each exhibit a profile that influences the visitor's profile through their viewing/activation.
         * A tag consists of a unique key e.g. Politic and a value/importance between 0-10.
         * @type {Map<string,number>}
         */
        this.tags = new Map();
        if(data['tags'])
        {
            for(let key in data['tags'])
            {
                this.tags.set(key, parseFloat(data['tags'][key]));
            }
        }

        /**
         * 3D position of the whole exhibit (world space)
         * @type {THREE.Vector3}
         */
        this.position = new THREE.Vector3(0,0,0);
        if(data['position'])
        {
            this.position.x = parseFloat(data['position'][0]);
            this.position.z = parseFloat(data['position'][1]);
        }

        /**
         * 3D rotation of the whole exhibit (world space)
         * @type {number}
         */
        this.rotation = data['rotation'] ? parseFloat(data['rotation']) * Math.PI / 180 : 0;

        /**
         * Defines the collision box that is also used to create the physical mass object.
         * If no height is specified, a sphere is created instead of a box.
         * The width in this case is the radius.
         * @type {Rect}
         */
        this.collisionArea = new Rect(0,0,0,0);
        if(data['collisionArea'])
        {
            this.collisionArea.left = parseFloat(data['collisionArea'][0]);
            this.collisionArea.top = parseFloat(data['collisionArea'][1]);
            this.collisionArea.width = parseFloat(data['collisionArea'][2]);
            this.collisionArea.height = data['collisionArea'][3] ? parseFloat(data['collisionArea'][3]) : 0;
        }

        /**
         * The position, rotation and collision area information can also be uploaded via a separate glb file in the backend.
         * It contains only one 3D object. Its position and rotation are then determined by the values in the model.
         * The collision area is created from the calculated bounding box of the object.
         * By default, this is a sphere if the object name is `_square`, a box is used.
         * @type {string}
         */
        this.transformFile = data['transformFile'] || '';
        if(this.transformFile.length > 1 && this.transformFile.substr(this.transformFile.length-1, 1) == '/')
            this.transformFile = '';

        /**
         * List of all artwork models
         * @type {Array<ExhibitionElementModel>}
         */
        this.elements = [];

        /**
         * List of model ids of all artworks in this exhibit
         * @type {Array<number>}
         */
        this.artworks = [];

        if(data['elements'])
        {
            data['elements'].forEach((elementData) => {
                let model = new ExhibitionElementModel(elementData);
                this.elements.push(model);
                if(model.id)
                    this.artworks.push(model.id);
            });
        }
    }

    /**
     * Searches the ExhibitionElementModels (artworks) for id and returns the model if it matches.
     * @param {number} id
     * @return {ExhibitionElementModel}
     */
    getElement(id)
    {
        for(let i=0; i<this.elements.length; i++)
        {
            if(this.elements[i].id == id)
                return this.elements[i];
        }
        return null;
    }
}

/**
 * This model represents an artwork within an exhibit
 */
class ExhibitionElementModel
{
    /**
     * @param {Object} data Filled with data loaded in {@Link ExhibitionProvider#load}
     */
    constructor(data)
    {
        /**
         * Unique identifier from the backend
         * @type {?number}
         */
        this.id = data['id'] ? parseInt(data['id'], 10) : null;

        /**
         * Path of the uploaded glb file
         * @type {string}
         */
        this.file3D = data['file3D'] || '';
        if(this.file3D.length > 1 && this.file3D.substr(this.file3D.length-1, 1) == '/')
            this.file3D = '';

        /**
         * Optional path of the high resolution glb file uploaded
         * @type {string}
         */
        this.fileHighres3D = data['fileHighres3D'] || '';
        if(this.fileHighres3D.length > 1 && this.fileHighres3D.substr(this.fileHighres3D.length-1, 1) == '/')
            this.fileHighres3D = '';

        /**
         * Position within the exhibits
         * @type {THREE.Vector3}
         */
        this.position = new THREE.Vector3(0,0,0);
        if(data['position'])
        {
            this.position.x = parseFloat(data['position'][0]);
            this.position.z = parseFloat(data['position'][1]);
        }

        /**
         * Rotation within the exhibits
         * @type {number}
         */
        this.rotation = data['rotation'] ? parseFloat(data['rotation']) * Math.PI / 180 : 0;

        /**
         * Describes the area of the activation zone for a work of art.
         * If height is set, a rectangle is used otherwise a circle (width corresponds to the radius).
         * @type {Rect}
         */
        this.interactionArea = data['interactionArea'] ? new Rect(0,0,0,0) : null;
        if(this.interactionArea)
        {
            this.interactionArea.left = parseFloat(data['interactionArea'][0]);
            this.interactionArea.top = parseFloat(data['interactionArea'][1]);
            this.interactionArea.width = parseFloat(data['interactionArea'][2]);
            this.interactionArea.height = data['interactionArea'][3] ? parseFloat(data['interactionArea'][3]) : 0;
        }

        /**
         * Defines if the possibility of an orbit view should be offered for the artwork.
         * A corresponding button is then displayed in the frontend when entering the activation zone of the artwork.
         * On click the control is changed to an orbit control around the artwork.
         * @type {ExhibitionElementOrbitViewModel}
         */
        this.orbitView = data['detailView'] && data['detailView']['type'] == 'orbit' ? new ExhibitionElementOrbitViewModel(data['detailView']['orbit']) : null;

        /**
         * Defines whether an external application exists for the artwork. A corresponding button
         * is then displayed in the frontend when the activation zone of the artwork is entered.
         * On click a layer opens in which the external application is loaded in an iFrame.
         * @type {string}
         */
        this.externUrl = data['detailView'] && data['detailView']['type'] == 'extern' ? data['detailView']['url'] : '';

        /**
         * List from information models that belong to this artwork. When entering the artwork, a corresponding info button is displayed.
         * If more than one info model is included, a selection menu is created in the info layer before the content is displayed.
         * @type {Array<ExhibitionElementInfoModel>}
         */
        this.infos = null;

        /**
         * Completer is a helper which returns a promise. Its solved if all artwork info data is loaded.
         * @type {Completer}
         * @private
         */
        this.dataCompleter_ = null;
    }

    /**
     * Load the artwork info from the backend via the POST service `/get-exhibit-data` sending the unique `exhibit-id` (corresponds to the model id).
     * For local development, the URL parameter `backend=0` can be used to replace the request by loading every time the same test json file `public/json/exhibit-info.json`.
     * @return {Promise}
     */
    loadInfos()
    {
        if(!this.dataCompleter_)
        {
            this.dataCompleter_ = new Completer();
            if(window['DEBUG_MODE'] === true && window['BACKEND_MODE'] === 'off')
            {
                MediaProvider.getInstance().mediaAssetPath = '';
                MediaProvider.getInstance().load('json/exhibit-info.json').then((result) => {
                    const data = result['data'];
                    this.generateInfos_(/** @type {Array<Object>} */ (data));
                    this.dataCompleter_.resolve();
                });
            }
            else
            {
                let data = new FormData();
                data.append('action', 'get-exhibit-data');
                data.append('exhibit-id', this.id.toString());
                request('/get-exhibit-data', 'POST', '', data).then((response) => {
                    const data = JSON.parse(response)['data'];
                    this.generateInfos_(/** @type {Array<Object>} */ (data));
                    this.dataCompleter_.resolve();
                });
            }
        }

        return this.dataCompleter_.getPromise();
    }

    /**
     * Creates all {@Link ExhibitionElementInfoModel} from the loaded data and save them in the model.
     * @param {Array<Object>} result
     * @private
     */
    generateInfos_(result)
    {
        this.infos = [];
        result.forEach((data) => {
            let info = new ExhibitionElementInfoModel(data);
            this.infos.push(info);
        });
    }
}

/**
 * This model represents an artwork info
 */
class ExhibitionElementInfoModel
{
    /**
     * @param {Object} data Filled with data loaded in {@Link ExhibitionElementModel#loadInfos}
     */
    constructor(data)
    {
        /**
         * Display name of the info/artwork
         * @type {string}
         */
        this.name = data['name'] || '';

        /**
         * Details is a list of text
         * @type {Map<string,string>}
         */
        this.details = new Map();
        if(data['details'])
        {
            for (const property in data['details']) {
                this.details.set(property, data['details'][property]);
            }
        }

        /**
         * List of media (image or video) elements
         * Each media object consists of
         * type: `image` or `video`,
         * src: the path to the image or a Vimeo id and
         * description: which is displayed as a caption.
         * @type {Array<{type:string,src:string,description:string}>}
         */
        this.media = [];
        if(data['media'])
        {
            data['media'].forEach((media) => {
                let type = media['type'] || '';
                let src = media['src'] || '';
                let description = media['description'] || '';
                this.media.push({'type': type, 'src': src, 'description': description});
            });
        }
    }
}

/**
 * This model defines the orbit view around an artwork
 */
class ExhibitionElementOrbitViewModel
{
    /**
     * @param {Object} data Filled with data loaded in {@Link ExhibitionProvider#load}
     */
    constructor(data)
    {
        /**
         * Center of the orbit view
         * @type {THREE.Vector3}
         */
        this.center = new THREE.Vector3(0,1,0);
        if(data['center'])
        {
            this.center.x = parseFloat(data['center'][0]);
            this.center.y = Math.max(1, parseFloat(data['center'][1]));
            this.center.z = parseFloat(data['center'][2]);
        }

        /**
         * Default distance by entering the orbit view
         * @type {number}
         */
        this.distance = data['distance'] ? parseFloat(data['distance']) : 4;

        /**
         * Range in which the azimuth angle can be changed in the orbit view
         * @type {Range}
         */
        this.limitAzimuth = data['limitAzimuth'] ? new Range(parseFloat(data['limitAzimuth'][0]), parseFloat(data['limitAzimuth'][1])) : null;

        /**
         * Range in which the polar angle can be changed in the orbit view
         * type {Range}
         */
        this.limitPolar = data['limitPolar'] ? new Range(parseFloat(data['limitPolar'][0]), parseFloat(data['limitPolar'][1])) : null;

        /**
         * Range in which the distance/zoom can be changed in the orbit view
         * @type {Range}
         */
        this.limitDistance = data['limitDistance'] ? new Range(parseFloat(data['limitDistance'][0]), parseFloat(data['limitDistance'][1])) : null;
    }
}

/**
 * @enum {string}
 */
const ExhibitionEventType = {
    /** update-visitor-preference */ UPDATE_VISITOR_PREFERENCE: 'update-visitor-preference'
};

exports = {ExhibitionProvider,ExhibitionItemModel,ExhibitionElementModel,ExhibitionElementInfoModel,ExhibitionElementOrbitViewModel,ExhibitionEventType,ExhibitionVisitorModel,ExhibitionVisitorPreverenceModel};