diff --git a/js/core/EventManager.js b/js/core/EventManager.js index 4bba799..8ebdda6 100644 --- a/js/core/EventManager.js +++ b/js/core/EventManager.js @@ -1,5 +1,5 @@ /** - * @file Manager handling the keyboard and mouse/touch events. + * Manager handling the keyboard and mouse/touch events. * * @author Alain Pitiot * @version 3.0.0b11 @@ -207,6 +207,14 @@ export class EventManager { const view = renderer.view; + /* + // TEMPORARY DEBUG FOR IPAD/IPHONE: + for (let eventName of ['click', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'mouseupoutside', 'pointercancel', 'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointertap', 'pointerup', 'pointerupoutside', 'rightclick', 'rightdown', 'rightup', ' rightupoutside', 'tap', 'touchcancel', 'touchend', 'touchendoutside', 'touchmove', 'touchstart']) + view.addEventListener(eventName, event => { + console.log('event: ' + eventName + ' -> ', event); + }); + */ + view.addEventListener("pointerdown", event => { self._mouseInfo.buttons.pressed[event.button] = 1; self._mouseInfo.buttons.times[event.button] = self._psychoJS._monotonicClock.getTime() - self._mouseInfo.buttons.clocks[event.button].getLastResetTime(); @@ -232,12 +240,13 @@ export class EventManager { view.addEventListener("wheel", event => { self._mouseInfo.wheelRel[0] += event.deltaX; self._mouseInfo.wheelRel[1] += event.deltaY; - /* - var x = ev.offsetX; - var y = ev.offsetY; - var msg = "Mouse: wheel shift=(" + ev.deltaX + "," + ev.deltaY + "), pos=(" + x + "," + y + ")"; - psychoJS.logging.data(msg);*/ + + //var x = ev.offsetX; + //var y = ev.offsetY; + //var msg = "Mouse: wheel shift=(" + ev.deltaX + "," + ev.deltaY + "), pos=(" + x + "," + y + ")"; + //psychoJS.logging.data(msg); }, false); + } @@ -508,4 +517,4 @@ export class BuilderKeyResponse { this.rt = []; // response time(s) this.clock = new Clock(); // we'll use this to measure the rt } -} \ No newline at end of file +} diff --git a/js/core/GUI.js b/js/core/GUI.js index 785087a..14f27a8 100644 --- a/js/core/GUI.js +++ b/js/core/GUI.js @@ -1,5 +1,5 @@ /** - * @file Graphic User Interface + * Graphic User Interface * * @author Alain Pitiot * @version 3.0.0b11 @@ -340,4 +340,4 @@ export class GUI } } -} \ No newline at end of file +} diff --git a/js/core/MinimalStim.js b/js/core/MinimalStim.js index 72366c8..f1a6207 100644 --- a/js/core/MinimalStim.js +++ b/js/core/MinimalStim.js @@ -1,5 +1,5 @@ /** - * @file Base class for all stimuli. + * Base class for all stimuli. * * @author Alain Pitiot * @version 3.0.0b11 diff --git a/js/core/Mouse.js b/js/core/Mouse.js index 8da4bd9..cc10e95 100644 --- a/js/core/Mouse.js +++ b/js/core/Mouse.js @@ -1,5 +1,5 @@ /** - * @file Manager responsible for the interactions between the experiment's stimuli and the mouse. + * Manager responsible for the interactions between the experiment's stimuli and the mouse. * * @author Alain Pitiot * @version 3.0.0b11 diff --git a/js/core/PsychoJS.js b/js/core/PsychoJS.js index 4ba067b..ff35c61 100644 --- a/js/core/PsychoJS.js +++ b/js/core/PsychoJS.js @@ -1,6 +1,6 @@ /** @module core */ /** - * @file Main component of the PsychoJS library. + * Main component of the PsychoJS library. * * @author Alain Pitiot * @version 3.0.0b11 @@ -217,7 +217,7 @@ export class PsychoJS { // setup the logger: //my.logger.console.setLevel(psychoJS.logging.WARNING); - //my.logger.server.set({'level':psychoJS.logging.WARNING, 'saveTo':'EXPERIMENT_SERVER', 'experimentInfo': my.expInfo}); + //my.logger.server.set({'level':psychoJS.logging.WARNING, 'experimentInfo': my.expInfo}); // open a new session: await this._serverManager.openSession(); @@ -277,7 +277,7 @@ export class PsychoJS { await this._experiment.save(); // close the session: - await this._serverManager.closeSession(); + await this._serverManager.closeSession(isCompleted); // stop the main scheduler: this._scheduler.stop(); @@ -344,6 +344,12 @@ export class PsychoJS { if (!('URL' in this._config.psychoJsManager)) throw 'missing URL in psychoJsManager block in configuration'; + // 'CSV' is the default format for the experiment results: + if ('saveFormat' in this._config.experiment) + this._config.experiment.saveFormat = Symbol.for(this._config.experiment.saveFormat); + else + this._config.experiment.saveFormat = ExperimentHandler.SaveFormat.CSV; + return response; } catch (error) { diff --git a/js/core/ServerManager.js b/js/core/ServerManager.js index 2d7855f..4d2c0e8 100644 --- a/js/core/ServerManager.js +++ b/js/core/ServerManager.js @@ -1,5 +1,5 @@ /** - * @file Manager responsible for the communication between the experiment running in the participant's browser and the remote PsychoJS manager running on the remote https://pavlovia.org server. + * Manager responsible for the communication between the experiment running in the participant's browser and the remote PsychoJS manager running on the remote https://pavlovia.org server. * * @author Alain Pitiot * @version 3.0.0b11 @@ -106,11 +106,16 @@ export class ServerManager extends PsychObject { this.setStatus(ServerManager.Status.BUSY); + let data = { + experimentFullPath: this._psychoJS.config.experiment.fullpath + }; + const gitlabConfig = this._psychoJS.config.gitlab; + if (typeof gitlabConfig !== 'undefined' && typeof gitlabConfig.projectId !== 'undefined') + data.projectId = gitlabConfig.projectId; + let self = this; return new Promise((resolve, reject) => { - const data = { - experimentFullPath: self._psychoJS.config.experiment.fullpath - }; + $.post(this._psychoJS.config.psychoJsManager.URL + '?command=open_session', data, null, 'json') .done((data, textStatus) => { // check for error: @@ -150,21 +155,28 @@ export class ServerManager extends PsychObject { * @name module:core.ServerManager#closeSession * @function * @public + * @param {boolean} [isCompleted= false] - whether or not the experiment was completed * @returns {Promise} the response */ - closeSession() { + closeSession(isCompleted = false) { let response = { origin: 'ServerManager.closeSession', context: 'when closing the session for experiment: ' + this._psychoJS.config.experiment.name }; this._psychoJS.logger.debug('closing the session for experiment: ' + this._psychoJS.config.experiment.name); this.setStatus(ServerManager.Status.BUSY); + let data = { + experimentFullPath: this._psychoJS.config.experiment.fullpath, + 'token': this._psychoJS.config.experiment.token, + 'isCompleted': isCompleted + }; + const gitlabConfig = this._psychoJS.config.gitlab; + if (typeof gitlabConfig !== 'undefined' && typeof gitlabConfig.projectId !== 'undefined') + data.projectId = gitlabConfig.projectId; + + let self = this; - return new Promise((resolve, reject) => { - const data = { - experimentFullPath: self._psychoJS.config.experiment.fullpath, - 'token': self._psychoJS.config.experiment.token - }; + return new Promise((resolve, reject) => { $.post(this._psychoJS.config.psychoJsManager.URL + '?command=close_session', data, null, 'json') .done((data, textStatus) => { // check for error: @@ -282,9 +294,7 @@ export class ServerManager extends PsychObject { download(); } - - - + /** * @typedef ServerManager.UploadDataPromise * @property {string} origin the calling method @@ -297,8 +307,8 @@ export class ServerManager extends PsychObject { * @name module:core.ServerManager#uploadData * @function * @public - * @param {string} key the data key - * @param {*} value the data value + * @param {string} key - the data key (e.g. the name of .csv file) + * @param {string} value - the data value (e.g. a string containing the .csv header and records) * * @returns {Promise} the response */ @@ -312,14 +322,14 @@ export class ServerManager extends PsychObject { experimentFullPath: this._psychoJS.config.experiment.fullpath, token: this._psychoJS.config.experiment.token, key, - value + value, + saveFormat: Symbol.keyFor(this._psychoJS.config.experiment.saveFormat) }; // add gitlab ID of experiment if there is one: const gitlabConfig = this._psychoJS.config.gitlab; if (typeof gitlabConfig !== 'undefined' && typeof gitlabConfig.projectId !== 'undefined') data.projectId = gitlabConfig.projectId; - // (*) upload data: const self = this; return new Promise((resolve, reject) => { @@ -564,4 +574,4 @@ ServerManager.Status = { * The manager has encountered an error, e.g. it was unable to download a resource. */ ERROR: Symbol.for('ERROR') -}; \ No newline at end of file +}; diff --git a/js/core/Window.js b/js/core/Window.js index b1c314b..9398fb8 100644 --- a/js/core/Window.js +++ b/js/core/Window.js @@ -1,5 +1,5 @@ /** - * @file Window responsible for displaying the experiment stimuli + * Window responsible for displaying the experiment stimuli * * @author Alain Pitiot * @version 3.0.0b11 @@ -18,6 +18,7 @@ import * as util from '../util/Util'; * * @name module:core.Window * @class + * @extends PsychObject * @param {Object} options * @param {PsychoJS} options.psychoJS - the PsychoJS instance * @param {string} [options.name] the name of the window @@ -25,8 +26,6 @@ import * as util from '../util/Util'; * @param {Color} [options.color= Color('black')] the background color of the window * @param {string} [options.units= 'pix'] the units of the window * @param {boolean} [options.autoLog= true] whether or not to log - * - * @extends PsychObject */ export class Window extends PsychObject { @@ -262,6 +261,7 @@ export class Window extends PsychObject { // top-level container: this._rootContainer = new PIXI.Container(); + this._rootContainer.interactive = true; // set size of renderer and position of root container: this._onResize(this); diff --git a/js/core/WindowMixin.js b/js/core/WindowMixin.js index ca445d3..5b5bedb 100644 --- a/js/core/WindowMixin.js +++ b/js/core/WindowMixin.js @@ -1,5 +1,5 @@ /** - * @file Mixin implementing various unit-handling measurement methods. + * Mixin implementing various unit-handling measurement methods. * * @author Alain Pitiot * @version 3.0.0b11 @@ -19,10 +19,8 @@ * @mixin * */ -export let WindowMixin = (superclass) => class extends superclass -{ - constructor(args) - { +export let WindowMixin = (superclass) => class extends superclass { + constructor(args) { super(args); } @@ -36,8 +34,7 @@ export let WindowMixin = (superclass) => class extends superclass * @param {String} [units= this.win.units] - the units * @param {boolean} [log= false] - whether or not to log */ - setUnits(units = this.win.units, log = false) - { + setUnits(units = this.win.units, log = false) { this._setAttribute('units', units, log); } @@ -51,8 +48,7 @@ export let WindowMixin = (superclass) => class extends superclass * @param {number} length - the length in stimulus units * @return {number} - the length in pixel units */ - _getLengthPix(length) - { + _getLengthPix(length) { let errorPrefix = { origin: 'WindowMixin._getLengthPix', context: 'when converting a length from stimulus unit to pixel units' }; if (this._units === 'pix') { @@ -60,14 +56,14 @@ export let WindowMixin = (superclass) => class extends superclass } else if (typeof this._units === 'undefined' || this._units === 'norm') { var winSize = this.win.size; - return length * winSize[1]/2; // TODO: how do we handle norm when width != height? + return length * winSize[1] / 2; // TODO: how do we handle norm when width != height? } else if (this._units === 'height') { const minSize = Math.min(this.win.size[0], this.win.size[1]); return length * minSize; } else { - throw {...errorPrefix, error: 'unable to deal with unit: ' + this._units}; + throw { ...errorPrefix, error: 'unable to deal with unit: ' + this._units }; } } @@ -81,8 +77,7 @@ export let WindowMixin = (superclass) => class extends superclass * @param {number} length_px - the length in pixel units * @return {number} - the length in stimulus units */ - _getLengthUnits(length_px) - { + _getLengthUnits(length_px) { let errorPrefix = { origin: 'WindowMixin._getLengthUnits', context: 'when converting a length from pixel unit to stimulus units' }; if (this._units === 'pix') { @@ -90,14 +85,14 @@ export let WindowMixin = (superclass) => class extends superclass } else if (typeof this._units === 'undefined' || this._units === 'norm') { const winSize = this.win.size; - return length_px / (winSize[1]/2); // TODO: how do we handle norm when width != height? + return length_px / (winSize[1] / 2); // TODO: how do we handle norm when width != height? } else if (this._units === 'height') { const minSize = Math.min(this.win.size[0], this.win.size[1]); return length_px / minSize; } else { - throw {...errorPrefix, error: 'unable to deal with unit: ' + this._units}; + throw { ...errorPrefix, error: 'unable to deal with unit: ' + this._units }; } } @@ -111,8 +106,7 @@ export let WindowMixin = (superclass) => class extends superclass * @param {number} length_px - the length in pixel units * @return {number} - the length in stimulus units */ - _getHorLengthPix(length) - { + _getHorLengthPix(length) { let errorPrefix = { origin: 'WindowMixin._getHorLengthPix', context: 'when converting a length from pixel unit to stimulus units' }; if (this._units === 'pix') { @@ -120,14 +114,14 @@ export let WindowMixin = (superclass) => class extends superclass } else if (typeof this._units === 'undefined' || this._units === 'norm') { var winSize = this.win.size; - return length * winSize[0]/2; + return length * winSize[0] / 2; } else if (this._units === 'height') { const minSize = Math.min(this.win.size[0], this.win.size[1]); return length * minSize; } else { - throw {...errorPrefix, error: 'unable to deal with unit: ' + this._units}; + throw { ...errorPrefix, error: 'unable to deal with unit: ' + this._units }; } } @@ -140,8 +134,7 @@ export let WindowMixin = (superclass) => class extends superclass * @param {number} length_px - the length in pixel units * @return {number} - the length in stimulus units */ - _getVerLengthPix(length) - { + _getVerLengthPix(length) { let errorPrefix = { origin: 'WindowMixin._getVerLengthPix', context: 'when converting a length from pixel unit to stimulus units' }; if (this._units === 'pix') { @@ -149,16 +142,15 @@ export let WindowMixin = (superclass) => class extends superclass } else if (typeof this._units === 'undefined' || this._units === 'norm') { var winSize = this.win.size; - return length * winSize[1]/2; + return length * winSize[1] / 2; } else if (this._units === 'height') { const minSize = Math.min(this.win.size[0], this.win.size[1]); return length * minSize; } else { - throw {...errorPrefix, error: 'unable to deal with unit: ' + this._units}; + throw { ...errorPrefix, error: 'unable to deal with unit: ' + this._units }; } } - } diff --git a/js/data/ExperimentHandler.js b/js/data/ExperimentHandler.js index e6ce44e..8ebc5f9 100644 --- a/js/data/ExperimentHandler.js +++ b/js/data/ExperimentHandler.js @@ -1,5 +1,5 @@ /** - * @file Experiment Handler + * Experiment Handler * * @author Alain Pitiot * @version 3.0.0b11 @@ -159,57 +159,84 @@ export class ExperimentHandler extends PsychObject { * @public * @param {Object} options * @param {PsychoJS} options.attributes - the attributes to be saved - * - * @todo deal with attributes */ async save({ attributes = [] } = {}) { this._psychoJS.logger.info('[PsychoJS] Save experiment results.'); - // key is based on extraInfo: + // (*) get attributes: + if (attributes.length == 0) { + attributes = this._trialsKeys.slice(); + for (let l = 0; l < this._loops.length; l++) { + const loop = this._loops[l]; + + const loopAttributes = this.getLoopAttributes(loop); + for (let a in loopAttributes) + if (loopAttributes.hasOwnProperty(a)) + attributes.push(a); + } + for (let a in this.extraInfo) { + if (this.extraInfo.hasOwnProperty(a)) + attributes.push(a); + } + } + + + // (*) get various experiment info: const info = this.extraInfo; - let key = (typeof info.expName !== 'undefined') ? info.expName : this.psychoJS.config.experiment.name; - key += "_" + ((typeof info.participant === 'string' && info.participant.length > 0) ? info.participant : 'PARTICIPANT'); - key += "_" + ((typeof info.session === 'string' && info.session.length > 0) ? info.session : 'SESSION'); - key += "_" + ((typeof info.date !== 'undefined') ? info.date : MonotonicClock.getDateStr()); + const __experimentName = (typeof info.expName !== 'undefined') ? info.expName : this.psychoJS.config.experiment.name; + const __participant = ((typeof info.participant === 'string' && info.participant.length > 0) ? info.participant : 'PARTICIPANT'); + const __session = ((typeof info.session === 'string' && info.session.length > 0) ? info.session : 'SESSION'); + const __datetime = ((typeof info.date !== 'undefined') ? info.date : MonotonicClock.getDateStr()); + const gitlabConfig = this._psychoJS.config.gitlab; + const __projectId = (typeof gitlabConfig !== 'undefined' && typeof gitlabConfig.projectId !== 'undefined')?gitlabConfig.projectId:undefined; - // data is in the csv format: - // build the csv header: - let csv = ""; - let header = this._trialsKeys.slice(); - for (let l = 0; l < this._loops.length; l++) { - const loop = this._loops[l]; - const loopAttributes = this.getLoopAttributes(loop); - for (let a in loopAttributes) - if (loopAttributes.hasOwnProperty(a)) - header.push(a); - } - for (let a in this.extraInfo) { - if (this.extraInfo.hasOwnProperty(a)) - header.push(a); - } + // (*) save to a .csv file on the remote server: + if (this._psychoJS.config.experiment.saveFormat == ExperimentHandler.SaveFormat.CSV) { + let csv = ""; - for (let h = 0; h < header.length; h++) { - if (h > 0) - csv = csv + ', '; - csv = csv + header[h]; - } - csv = csv + '\n'; - - // build the records: - for (let r = 0; r < this._trialsData.length; r++) { - for (let h = 0; h < header.length; h++) { + // build the csv header: + for (let h = 0; h < attributes.length; h++) { if (h > 0) csv = csv + ', '; - csv = csv + this._trialsData[r][header[h]]; + csv = csv + attributes[h]; } csv = csv + '\n'; + + // build the records: + for (let r = 0; r < this._trialsData.length; r++) { + for (let h = 0; h < attributes.length; h++) { + if (h > 0) + csv = csv + ', '; + csv = csv + this._trialsData[r][attributes[h]]; + } + csv = csv + '\n'; + } + + // upload data to the remote PsychoJS manager: + const key = __participant + '_' + __experimentName + '_' + __datetime + '.csv'; + return await this._psychoJS.serverManager.uploadData(key, csv); } - // upload data to the remote PsychoJS manager: - return await this._psychoJS.serverManager.uploadData(key + '.csv', csv); + + // (*) save in the database on the remote server: + else if (this._psychoJS.config.experiment.saveFormat == ExperimentHandler.SaveFormat.DATABASE) { + let documents = []; + + for (let r = 0; r < this._trialsData.length; r++) { + let doc = { __projectId, __experimentName, __participant, __session, __datetime }; + for (let h = 0; h < attributes.length; h++) + doc[attributes[h]] = this._trialsData[r][attributes[h]]; + + documents.push(doc); + } + + // upload data to the remote PsychoJS manager: + const key = 'results'; // name of the mongoDB collection + return await this._psychoJS.serverManager.uploadData(key, JSON.stringify(documents)); + } } @@ -273,4 +300,25 @@ export class ExperimentHandler extends PsychObject { return attributes; } -} +}; + + +/** + * Experiment result format + * + * @name module:core.ServerManager#SaveFormat + * @enum {Symbol} + * @readonly + * @public + */ +ExperimentHandler.SaveFormat = { + /** + * Results are saved to a .csv file + */ + CSV: Symbol.for('CSV'), + + /** + * Results are saved to a database + */ + DATABASE: Symbol.for('DATABASE') +}; diff --git a/js/data/TrialHandler.js b/js/data/TrialHandler.js index 0d6d594..8cdd36f 100644 --- a/js/data/TrialHandler.js +++ b/js/data/TrialHandler.js @@ -1,6 +1,6 @@ /** @module data */ /** - * @file Trial Handler + * Trial Handler * * @author Alain Pitiot * @version 3.0.0b11 diff --git a/js/sound/Sound.js b/js/sound/Sound.js index 61ee7a4..38d2348 100644 --- a/js/sound/Sound.js +++ b/js/sound/Sound.js @@ -1,6 +1,6 @@ /** @module sound */ /** - * @file Sound stimulus. + * Sound stimulus. * * @author Alain Pitiot * @version 3.0.0b11 @@ -171,4 +171,4 @@ export class Sound extends PsychObject { } -} \ No newline at end of file +} diff --git a/js/sound/SoundPlayer.js b/js/sound/SoundPlayer.js index 47dfc37..b5f700e 100644 --- a/js/sound/SoundPlayer.js +++ b/js/sound/SoundPlayer.js @@ -1,5 +1,5 @@ /** - * @file Sound player interface + * Sound player interface * * @author Alain Pitiot * @version 3.0.0b11 diff --git a/js/sound/TonePlayer.js b/js/sound/TonePlayer.js index c33408b..62d6347 100644 --- a/js/sound/TonePlayer.js +++ b/js/sound/TonePlayer.js @@ -1,5 +1,5 @@ /** - * @file Tone Player. + * Tone Player. * * @author Alain Pitiot * @version 3.0.0b11 diff --git a/js/sound/TrackPlayer.js b/js/sound/TrackPlayer.js index c58df7a..88dd2f6 100644 --- a/js/sound/TrackPlayer.js +++ b/js/sound/TrackPlayer.js @@ -1,5 +1,5 @@ /** - * @file Track Player. + * Track Player. * * @author Alain Pitiot * @version 3.0.0b11 @@ -136,7 +136,7 @@ export class TrackPlayer extends SoundPlayer { * @name module:sound.TrackPlayer#play * @function * @public - * @param {boolean} [loops] how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped. + * @param {number} loops - how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped. */ play(loops) { if (typeof loops !== 'undefined') @@ -173,4 +173,4 @@ export class TrackPlayer extends SoundPlayer { this._howl.off('end'); } -} \ No newline at end of file +} diff --git a/js/util/Clock.js b/js/util/Clock.js new file mode 100644 index 0000000..23449ad --- /dev/null +++ b/js/util/Clock.js @@ -0,0 +1,202 @@ +/** + * Clock component. + * + * @author Alain Pitiot + * @version 3.0.0b11 + * @copyright (c) 2018 Ilixa Ltd. ({@link http://ilixa.com}) + * @license Distributed under the terms of the MIT License + */ + + +/** + *

MonotonicClock offers a convenient way to keep track of time during experiments. An experiment can have as many independent clocks as needed, e.g. one to time responses, another one to keep track of stimuli, etc.

+ * + * @name module:util.MonotonicClock + * @class + * @param {number} [startTime=