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};