1
0
mirror of https://github.com/psychopy/psychojs.git synced 2025-05-10 10:40:54 +00:00

Merge pull request #599 from apitiot/2024.1.0

2024.1.0
This commit is contained in:
Alain Pitiot 2024-07-29 14:16:02 +02:00 committed by GitHub
commit 2ccc301f4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 412 additions and 153 deletions

View File

@ -1,6 +1,6 @@
{
"name": "psychojs",
"version": "2023.2.3",
"version": "2024.1.0",
"private": true,
"description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments",
"license": "MIT",

View File

@ -80,14 +80,17 @@ export class GUI
* @param {Object} options.dictionary - associative array of values for the participant to set
* @param {String} options.title - name of the project
* @param {boolean} [options.requireParticipantClick=true] - whether the participant must click on the OK
* button, when it becomes enabled, to move on with the experiment
* button, when it becomes enabled, to move on with the experiment
* @param {boolean} [options.OKAlwaysEnabledForLocal=false] - whether the OK button is always enabled
* when the experiment runs locally
*/
DlgFromDict({
logoUrl,
text,
dictionary,
title,
requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick
requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick,
OKAlwaysEnabledForLocal = true
})
{
this._progressBarMax = 0;
@ -96,6 +99,7 @@ export class GUI
this._setRequiredKeys = new Map();
this._progressMessage = " ";
this._requireParticipantClick = requireParticipantClick;
this._OKAlwaysEnabledForLocal = OKAlwaysEnabledForLocal;
this._dictionary = dictionary;
// prepare a PsychoJS component:
@ -183,13 +187,26 @@ export class GUI
{
atLeastOneIncludedKey = true;
markup += `<label for='${keyId}'> ${key} </label>`;
// if the field is required:
// deal with field options:
// - if the field is required:
if (key.slice(-4) === "|req")
{
key = `${key.slice(0, -4)}*`;
}
if (key.slice(-1) === "*")
{
self._requiredKeys.push(keyId);
}
// - all other new options are currently discarded
// TODO
// remove the new option extensions:
if (key.slice(-4) === "|req" || key.slice(-4) === "|cfg" || key.slice(-4) === "|fix" || key.slice(-4) === "|opt")
{
key = key.slice(0, -4);
}
markup += `<label for='${keyId}'> ${key} </label>`;
// if value is an array, we create a select drop-down menu:
if (Array.isArray(value))
@ -236,7 +253,6 @@ export class GUI
markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>";
// buttons:
markup += "<hr>";
markup += "<div class='dialog-button-group'>";
markup += "<button id='dialogCancel' class='dialog-button' aria-label='Cancel Experiment'>Cancel</button>";
if (self._requireParticipantClick)
@ -276,7 +292,7 @@ export class GUI
self._updateProgressBar();
// setup change event handlers for all required keys:
this._requiredKeys.forEach((keyId) =>
self._requiredKeys.forEach((keyId) =>
{
const input = document.getElementById(keyId);
if (input)
@ -413,13 +429,13 @@ export class GUI
markup += "</div>";
}
if (showOK || showCancel)
{
markup += "<hr>";
}
// if (showOK || showCancel)
// {
// markup += "<hr>";
// }
if (showCancel || showOK)
{
markup += "<div class='button-group'>";
markup += "<div class='dialog-button-group'>";
if (showCancel)
{
markup += "<button id='dialogCancel' class='dialog-button' aria-label='Close dialog'>Cancel</button>";
@ -489,11 +505,15 @@ export class GUI
markup += "<div class='dialog-overlay'></div>";
markup += "<div class='dialog-content'>";
markup += `<div id='experiment-dialog-title' class='dialog-title dialog-warning'><p>Warning</p></div>`;
markup += "<div class='scrollable-container'>";
markup += `<p>${text}</p>`;
markup += "</div>";
// progress bar:
markup += `<hr><div id='progressMsg' class='progress-msg'>&nbsp;</div>`;
markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>";
markup += "<div class='dialog-button-group'></div>";
markup += "</div></div>";
@ -590,6 +610,12 @@ export class GUI
const input = document.getElementById("form-input-" + keyIdx);
if (input)
{
// deal with field options:
if (key.slice(-4) === "|req" || key.slice(-4) === "|cfg" || key.slice(-4) === "|fix" || key.slice(-4) === "|opt")
{
delete this._dictionary[key];
key = key.slice(0, -4);
}
this._dictionary[key] = input.value;
}
});
@ -604,8 +630,11 @@ export class GUI
// clear all events (and keypresses) accumulated until now:
this._psychoJS.eventManager.clearEvents();
this._dialog.hide();
this._dialog = null;
if (this._dialog)
{
this._dialog.hide();
this._dialog = null;
}
this._dialogComponent.status = PsychoJS.Status.FINISHED;
}
@ -685,7 +714,10 @@ export class GUI
if (typeof this._okButton !== "undefined")
{
// locally the OK button is always enabled, otherwise only if all requirements have been fulfilled:
if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL || allRequirementsFulfilled)
if (
(this._OKAlwaysEnabledForLocal && this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL)
|| allRequirementsFulfilled
)
{
this._okButton.classList.add("dialog-button");
this._okButton.classList.remove("disabled");
@ -703,7 +735,6 @@ export class GUI
return;
}
// if all requirements are fulfilled and the participant is not required to click on the OK button,
// then we close the dialog box and move on with the experiment:
if (allRequirementsFulfilled)

View File

@ -108,9 +108,9 @@ export class PsychoJS
}
/**
* @param {Object} options
* @param {boolean} [options.debug= true] whether to log debug information in the browser console
* @param {boolean} [options.collectIP= false] whether to collect the IP information of the participant
* @param {Object} options - options
* @param {boolean} [options.debug= true] - whether to log debug information in the browser console
* @param {boolean} [options.collectIP= false] - whether to collect the IP information of the participant
*/
constructor({
debug = true,
@ -144,8 +144,8 @@ export class PsychoJS
});
// add the pavlovia server to the list of hosts:
const hostsWithPavlovia = new Set([...hosts, "https://pavlovia.org/run/", "https://run.pavlovia.org/"]);
this._hosts = Array.from(hostsWithPavlovia);
const pavloviaHosts = new Set([...hosts, "https://pavlovia.org/run/", "https://run.pavlovia.org/", "https://devlovia.org/run/", "https://run.devlovia.org/"]);
this._hosts = Array.from(pavloviaHosts);
// GUI:
this._gui = new GUI(this);
@ -166,6 +166,9 @@ export class PsychoJS
this._cancellationUrl = undefined;
this._completionUrl = undefined;
// survey id, if applicable:
this._surveyId = undefined;
// status:
this.status = PsychoJS.Status.NOT_CONFIGURED;
@ -186,7 +189,7 @@ export class PsychoJS
this._saveResults = saveResults;
this.logger.info("[PsychoJS] Initialised.");
this.logger.info("[PsychoJS] @version 2022.3.0");
this.logger.info("[PsychoJS] @version 2024.1.0");
// hide the initialisation message:
const root = document.getElementById("root");
@ -377,6 +380,7 @@ export class PsychoJS
if (typeof surveyId !== "undefined")
{
params.surveyId = surveyId;
this._surveyId = surveyId;
}
await this._serverManager.openSession(params);
@ -399,10 +403,21 @@ export class PsychoJS
{
if (self._config.session.status === "OPEN")
{
// stop the regular uploading of results, if need be:
if (self._config.experiment.resultsUpload.intervalId > 0)
{
clearInterval(self._config.experiment.resultsUpload.intervalId);
self._config.experiment.resultsUpload.intervalId = -1;
}
// save the incomplete results if need be:
if (self._config.experiment.saveIncompleteResults && self._saveResults)
{
self._experiment.save({ sync: true });
// note: we set lastUploadTimestamp to undefined to prevent uploadData from throttling this call
delete self._config.experiment.resultsUpload.lastUploadTimestamp;
self._experiment.save({
sync: true
});
}
// close the session:
@ -414,6 +429,20 @@ export class PsychoJS
self._window.close();
}
});
// upload the data at regular interval, if need be:
if (self._saveResults && self._config.experiment.resultsUpload.period > 0)
{
self._config.experiment.resultsUpload.intervalId = setInterval(() =>
{
self._experiment.save({
tag: "",
clear: false
});
},
self._config.experiment.resultsUpload.period * 60 * 1000
);
}
}
// start the asynchronous download of resources:
@ -423,7 +452,7 @@ export class PsychoJS
if (this._checkWebGLSupport && !Window.checkWebGLSupport())
{
// add an entry to experiment results to warn the designer about a potential WebGL issue:
this._experiment.addData('hardware_acceleration', 'NOT SUPPORTED');
this._experiment.addData("hardware_acceleration", "NOT SUPPORTED");
this._experiment.nextEntry();
this._gui.dialog({
@ -519,9 +548,10 @@ export class PsychoJS
* <p>Note: if the resource manager is busy, we inform the participant
* that he or she needs to wait for a bit.</p>
*
* @param {Object} options
* @param {Object} options - options
* @param {string} [options.message] - optional message to be displayed in a dialog box before quitting
* @param {boolean} [options.isCompleted = false] - whether the participant has completed the experiment
* @return {void}
*/
async quit({ message, isCompleted = false, closeWindow = true, showOK = true } = {})
{
@ -545,6 +575,14 @@ export class PsychoJS
window.removeEventListener("beforeunload", this.beforeunloadCallback);
}
// stop the regular uploading of results, if need be:
if (this._config.experiment.resultsUpload.intervalId > 0)
{
clearInterval(this._config.experiment.resultsUpload.intervalId);
this._config.experiment.resultsUpload.intervalId = -1;
}
delete this._config.experiment.resultsUpload.lastUploadTimestamp;
// save the results and the logs of the experiment:
this.gui.finishDialog({
text: "Terminating the experiment. Please wait a few moments...",
@ -602,7 +640,7 @@ export class PsychoJS
if (showOK)
{
let text = "Thank you for your patience.";
let text = "Thank you for your patience. ";
text += (typeof message !== "undefined") ? message : "Goodbye!";
this._gui.dialog({
message: text,
@ -629,6 +667,7 @@ export class PsychoJS
* @protected
* @param {string} configURL - the URL of the configuration file
* @param {string} name - the name of the experiment
* @return {void}
*/
async _configure(configURL, name)
{
@ -701,10 +740,15 @@ export class PsychoJS
name,
saveFormat: ExperimentHandler.SaveFormat.CSV,
saveIncompleteResults: true,
keys: [],
keys: []
},
};
}
// init the partial results upload options
this._config.experiment.resultsUpload = {
period: -1,
intervalId: -1
};
// get the server parameters (those starting with a double underscore):
this._serverMsg = new Map();
@ -737,6 +781,7 @@ export class PsychoJS
*
* <p>Note: we use [http://www.geoplugin.net/json.gp]{@link http://www.geoplugin.net/json.gp}.</p>
* @protected
* @return {void}
*/
async _getParticipantIPInfo()
{

View File

@ -58,6 +58,13 @@ export class ServerManager extends PsychObject
this._nbLoadedResources = 0;
this._setupPreloadQueue();
// throttling period for calls to uploadData and uploadLog (in mn):
// note: (a) the period is potentially updated when a session is opened to reflect that associated with
// the experiment on the back-end database
// (b) throttling is also enforced on the back-end: artificially altering the period
// on the participant's browser will result in server errors
this._uploadThrottlePeriod = 5;
this._addAttribute("autoLog", autoLog);
this._addAttribute("status", ServerManager.Status.READY);
}
@ -127,7 +134,7 @@ export class ServerManager extends PsychObject
* @property {Object.<string, *>} [error] an error message if we could not open the session
*/
/**
* Open a session for this experiment on the remote PsychoJS manager.
* Open a session for this experiment on the pavlovia server.
*
* @param {Object} params - the open session parameters
*
@ -194,6 +201,21 @@ export class ServerManager extends PsychObject
self._psychoJS.config.experiment.keys = [];
}
// partial results upload options:
if ("partialResultsUploadPeriod" in experiment)
{
// note: resultsUpload is initialised in PsychoJS._configure but we reinitialise it here
// all the same (belt and braces approach)
self._psychoJS.config.experiment.resultsUpload = {
period: experiment.partialResultsUploadPeriod,
intervalId: -1
};
}
if ("uploadThrottlePeriod" in experiment)
{
this._uploadThrottlePeriod = experiment.uploadThrottlePeriod;
}
self.setStatus(ServerManager.Status.READY);
resolve({...response, token: openSessionResponse.token, status: openSessionResponse.status });
}
@ -214,10 +236,10 @@ export class ServerManager extends PsychObject
* previously been opened)
*/
/**
* Close the session for this experiment on the remote PsychoJS manager.
* Close the session for this experiment on the pavlovia server.
*
* @param {boolean} [isCompleted= false] - whether or not the experiment was completed
* @param {boolean} [sync= false] - whether or not to communicate with the server in a synchronous manner
* @param {boolean} [isCompleted= false] - whether the experiment was completed
* @param {boolean} [sync= false] - whether to communicate with the server in a synchronous manner
* @returns {Promise<ServerManager.CloseSessionPromise> | void} the response
*/
async closeSession(isCompleted = false, sync = false)
@ -238,6 +260,10 @@ export class ServerManager extends PsychObject
+ "/sessions/" + this._psychoJS.config.session.token + "/delete";
const formData = new FormData();
formData.append("isCompleted", isCompleted);
if (typeof this._psychoJS._surveyId !== "undefined")
{
formData.append("surveyId", this._psychoJS._surveyId);
}
navigator.sendBeacon(url, formData);
this._psychoJS.config.session.status = "CLOSED";
@ -250,10 +276,18 @@ export class ServerManager extends PsychObject
{
try
{
const data = {
isCompleted
};
if (typeof this._psychoJS._surveyId !== "undefined")
{
data["surveyId"] = this._psychoJS._surveyId;
}
const deleteResponse = await this._queryServerAPI(
"DELETE",
`experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}`,
{ isCompleted },
data,
"FORM"
);
@ -569,18 +603,24 @@ export class ServerManager extends PsychObject
{
// add the SurveyJS and PsychoJS Survey .js and .css resources:
resources[r] = {
name: "jquery-3.6.0.min.js",
path: "./lib/vendors/jquery-3.6.0.min.js",
name: "jquery-3.5.1.min.js",
path: "./lib/vendors/jquery-3.5.1.min.js",
// name: "jquery-3.6.0.min.js",
// path: "./lib/vendors/jquery-3.6.0.min.js",
download: true
};
resources.push({
name: "survey.jquery-1.9.50.min.js",
path: "./lib/vendors/survey.jquery-1.9.50.min.js",
name: "surveyjs.jquery-1.9.126.min.js",
path: "./lib/vendors/surveyjs.jquery-1.9.126.min.js",
// name: "survey.jquery-1.9.50.min.js",
// path: "./lib/vendors/survey.jquery-1.9.50.min.js",
download: true
});
resources.push({
name: "survey.defaultV2-1.9.50.min.css",
path: "./lib/vendors/survey.defaultV2-1.9.50.min.css",
name: "surveyjs.defaultV2-1.9.126-OST.min.css",
path: "./lib/vendors/surveyjs.defaultV2-1.9.126-OST.min.css",
// name: "survey.defaultV2-1.9.50.min.css",
// path: "./lib/vendors/survey.defaultV2-1.9.50.min.css",
download: true
});
resources.push({
@ -783,6 +823,15 @@ export class ServerManager extends PsychObject
};
this._psychoJS.logger.debug("uploading data for experiment: " + this._psychoJS.config.experiment.fullpath);
// data upload throttling:
const now = MonotonicClock.getReferenceTime();
const checkThrottling = (typeof this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp !== "undefined");
if (checkThrottling && (now - this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp < this._uploadThrottlePeriod * 60))
{
return Promise.reject({ ...response, error: "upload canceled by throttling"});
}
this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp = now;
this.setStatus(ServerManager.Status.BUSY);
const path = `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}/results`;

View File

@ -97,6 +97,9 @@ export class ExperimentHandler extends PsychObject
this._trialsData = [];
this._currentTrialData = {};
// whether a header for the .csv result file is necessary:
this._isCsvHeaderNeeded = true;
this._experimentEnded = false;
}
@ -236,9 +239,9 @@ export class ExperimentHandler extends PsychObject
*
* @param {Object} options
* @param {Array.<Object>} [options.attributes] - the attributes to be saved
* @param {boolean} [options.sync=false] - whether or not to communicate with the server in a synchronous manner
* @param {boolean} [options.sync=false] - whether to communicate with the server in a synchronous manner
* @param {string} [options.tag=''] - an optional tag to add to the filename to which the data is saved (for CSV and XLSX saving options)
* @param {boolean} [options.clear=false] - whether or not to clear all experiment results immediately after they are saved (this is useful when saving data in separate chunks, throughout an experiment)
* @param {boolean} [options.clear=false] - whether to clear all experiment results immediately after they are saved (this is useful when saving data in separate chunks, throughout an experiment)
*/
async save({
attributes = [],
@ -266,7 +269,7 @@ export class ExperimentHandler extends PsychObject
}
}
}
for (let a in this.extraInfo)
for (const a in this.extraInfo)
{
if (this.extraInfo.hasOwnProperty(a))
{
@ -289,10 +292,28 @@ export class ExperimentHandler extends PsychObject
{
// note: we use the XLSX library as it automatically deals with header, takes care of quotes,
// newlines, etc.
// we need a header if it is asked for and there is actual data to save:
const withHeader = this._isCsvHeaderNeeded && (data.length > 0);
/* INCORRECT: since new attributes can be added throughout the participant session, we need, currently,
to upload the whole result data, on each call to save.
// if we are outputting a header on this occasion, we won't need one thereafter:
if (this._isCsvHeaderNeeded)
{
this._isCsvHeaderNeeded = !withHeader;
}
*/
// TODO only save the given attributes
const worksheet = XLSX.utils.json_to_sheet(data);
// prepend BOM
const csv = "\ufeff" + XLSX.utils.sheet_to_csv(worksheet);
const worksheet = XLSX.utils.json_to_sheet(data, {skipHeader: !withHeader});
// note: start with a BOM if necessary
let csv = ( (withHeader) ? "\ufeff" : "" ) + XLSX.utils.sheet_to_csv(worksheet);
if (data.length > 0)
{
csv += "\n";
}
// upload data to the pavlovia server or offer them for download:
const filenameWithoutPath = this._dataFileName.split(/[\\/]/).pop();
@ -303,7 +324,7 @@ export class ExperimentHandler extends PsychObject
&& !this._psychoJS._serverMsg.has("__pilotToken")
)
{
return /*await*/ this._psychoJS.serverManager.uploadData(key, csv, sync);
return this._psychoJS.serverManager.uploadData(key, csv, sync);
}
else
{
@ -320,7 +341,7 @@ export class ExperimentHandler extends PsychObject
for (let r = 0; r < data.length; r++)
{
let doc = {
const doc = {
__projectId,
__experimentName: this._experimentName,
__participant: this._participant,

View File

@ -514,31 +514,105 @@ export class Shelf extends PsychObject
/**
* Get the name of a group, using a counterbalanced design.
*
* @note the participant token returned by this call is useful when confirming or cancelling that participant's
* participation with counterBalanceConfirm/Cancel
*
* @param {Object} options
* @param {string[]} options.key key as an array of key components
* @param {string[]} options.groups the names of the groups
* @param {number[]} options.groupSizes the size of the groups
* @return {Promise<{string, boolean}>} an object with the name of the selected group and whether all groups
* have been depleted
* @param {string[]} options.key key as an array of key components
* @return {Promise<{string, boolean, string}>} an object with the name of the selected group,
* whether all groups have been depleted, and a participant token
*/
async counterBalanceSelect({key, groups, groupSizes} = {})
async counterbalanceSelect({
key,
reserveTimeout
} = {})
{
const response = {
origin: 'Shelf.counterBalanceSelect',
context: `when getting the name of a group, using a counterbalanced design, with key: ${JSON.stringify(key)}`
origin: 'Shelf.counterbalanceSelect',
context: `when getting the name of a group, using a counterbalanced design with key: ${JSON.stringify(key)}`
};
try
{
await this._checkAvailability("counterBalanceSelect");
await this._checkAvailability("counterbalanceSelect");
this._checkKey(key);
// prepare the request:
const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counterbalance`;
const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counterbalance/select`;
const data = {
key
};
if (typeof reserveTimeout !== "undefined")
{
data.reserveTimeout = reserveTimeout;
}
// query the server:
const putResponse = await fetch(url, {
method: 'PUT',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
redirect: 'follow',
referrerPolicy: 'no-referrer',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
// convert the response to json:
const document = await putResponse.json();
if (putResponse.status !== 200)
{
throw ('error' in document) ? document.error : document;
}
// return the result:
this._status = Shelf.Status.READY;
return {
group: document.group,
finished: document.finished,
participantToken: document.participantToken
};
}
catch (error)
{
this._status = Shelf.Status.ERROR;
throw {...response, error};
}
}
/**
* Confirm or cancel a participant's participation to a counterbalanced design.
*
* @note the required participant token is the one returned by a call to counterBalanceSelect
*
* @param {string[]} key - key as an array of key components
* @param {string} participantToken - the participant token
* @param {boolean} confirmed - when the participant's participation is confirmed or cancelled
* @return {Promise<{string, boolean, string}>} an object with the name of the participant group, and
* whether all groups have been depleted
*/
async counterbalanceConfirm(key, participantToken, confirmed)
{
const response = {
origin: 'Shelf.counterBalanceConfirm',
context: `when confirming or cancelling a participant's participation to the counterbalanced design with key: ${JSON.stringify(key)}`
};
try
{
await this._checkAvailability("counterbalanceConfirm");
this._checkKey(key);
// prepare the request:
const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counterbalance/confirm`;
const data = {
key,
groups,
groupSizes
participantToken,
confirmed
};
// query the server:
@ -563,7 +637,7 @@ export class Shelf extends PsychObject
throw ('error' in document) ? document.error : document;
}
// return the updated value:
// return the result:
this._status = Shelf.Status.READY;
return {
group: document.group,
@ -577,7 +651,6 @@ export class Shelf extends PsychObject
}
}
/**
* Update the value associated with the given key.
*

View File

@ -36,16 +36,16 @@ body {
.dialog-container input.text,
.dialog-container select.text {
margin-bottom: 1em;
padding: 0.5em;
width: 100%;
margin-bottom: 1em;
padding: 0.5em;
width: 100%;
height: 34px;
border: 1px solid #767676;
border-radius: 2px;
background: #ffffff;
color: #333;
font-size: 14px;
height: 34px;
border: 1px solid #767676;
border-radius: 2px;
background: #ffffff;
color: #333;
font-size: 14px;
}
.dialog-container fieldset {
@ -90,7 +90,7 @@ body {
/*max-height: 90vh;*/
max-height: 93%;
padding: 0.5em;
padding: 2px; /*0.5em;*/
border-radius: 2px;
font-family: 'Open Sans', sans-serif;
@ -103,22 +103,29 @@ body {
.dialog-content .scrollable-container {
height: 100%;
padding: 0 0.5em;
padding: 0.5em; /*0 0.5em;*/
box-shadow: inset rgba(0, 0, 0, 0.2) 1px 1px 2px, inset rgba(255, 255, 255, 1) -1px -1px 2px;
overflow-x: hidden;
overflow-y: auto;
}
.dialog-content .scrollable-container p {
margin: 0;
padding: 0;
}
.dialog-content hr {
width: 100%;
margin: 0.5em 2px 2px 2px;
width: calc(100% - 4px);
}
.dialog-title {
padding: 0.5em;
margin-bottom: 1em;
margin-bottom: 2px; /*0.5em;*/
background-color: #00dd00;
/*background-color: #009900;*/
background-color: #008500;
border-radius: 2px;
}
@ -133,6 +140,8 @@ body {
.dialog-title p {
margin: 0;
padding: 0;
color: #FFFFFF;
font-weight: bold;
}
@ -140,17 +149,17 @@ body {
display: flex;
justify-content: center;
align-items: center;
line-height: 1.1em;
line-height: 1em;
position: absolute;
top: 0.7em;
right: 0.7em;
top: 5px;
right: 5px;
border: 0;
padding: 0;
border-radius: 2px;
width: 1.1em;
height: 1.1em;
width: calc(2em - 12px);
height: calc(2em - 12px);
color: #333333;
background-color: #FFFFFF;
@ -163,12 +172,14 @@ body {
}
.progress-msg {
margin: 0 0.2em;
box-sizing: border-box;
padding: 0.5em 0;
}
.progress-container {
padding: 0.2em;
margin: 0 0.2em;
padding: 2px;
border: 1px solid #555555;
border-radius: 2px;
@ -208,11 +219,16 @@ body {
}
.dialog-button-group {
margin: 0.5em 0 0 0;
padding: 0.5em 1em calc(0.5em - 2px) 0.2em;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
column-gap: 0.5em;
border-top: 1px solid rgba(0, 0, 0, 0.2);
}
.disabled {
@ -227,8 +243,16 @@ body {
.logo {
display: flex;
flex: 0 1 auto;
height: 100%;
width: auto;
width: calc(100% - 1em);
height: 100%;
max-height: 25vh;
object-fit: contain;
/*width: auto;*/
/*margin: 0 auto;*/
padding: 0.5em 0.5em 1em 0.5em;
margin-bottom: 0.5em;
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
/*display: block;
margin: 0 auto 1em;

View File

@ -21,7 +21,7 @@ export class PsychObject extends EventEmitter
{
/**
* @param {module:core.PsychoJS} psychoJS - the PsychoJS instance
* @param {string} name - the name of the object (mostly useful for debugging)
* @param {string} [name] - the name of the object (mostly useful for debugging)
*/
constructor(psychoJS, name)
{

View File

@ -71,6 +71,7 @@ export class ButtonStim extends TextBox
win,
name,
text,
placeholder: text,
font,
pos,
size,

View File

@ -198,8 +198,8 @@ export class Survey extends VisualStim
model = {
surveys: [model],
embeddedData: [],
surveysMap: {},
questionMapsBySurvey: {},
// surveysMap: {},
// questionMapsBySurvey: {},
surveyFlow: {
name: "root",
type: "SEQUENTIAL_GROUP",
@ -211,7 +211,7 @@ export class Survey extends VisualStim
surveySettings: { showPrevButton: false },
surveyRunLogic: {},
// surveyRunLogic: {},
inQuestionRandomization: {},
questionsOrderRandomization: [],
questionSkipLogic: {},
@ -224,6 +224,9 @@ export class Survey extends VisualStim
this.psychoJS.logger.debug(`converted the legacy model to the new super-flow model: ${JSON.stringify(model)}`);
}
// mark the root (top-most) node:
model.surveyFlow.isRootNode = true;
this._surveyData = model;
this._setAttribute("model", model, log);
this._onChange(true, true)();
@ -478,15 +481,12 @@ export class Survey extends VisualStim
// if a survey div does not exist, create it:
if (document.getElementById(this._surveyDivId) === null)
{
document.body.insertAdjacentHTML("beforeend", `<div id=${this._surveyDivId} class='survey'></div>`)
document.body.insertAdjacentHTML("beforeend", `<div id=${this._surveyDivId} class='survey'></div>`);
}
// start the survey flow:
if (typeof this._surveyData !== "undefined")
{
// this._startSurvey(surveyId, this._surveyModel);
// jQuery(`#${surveyId}`).Survey({model: this._surveyModel});
this._runSurveyFlow(this._surveyData.surveyFlow, this._surveyData);
}
}
@ -701,7 +701,6 @@ export class Survey extends VisualStim
_applyInQuestionRandomization (questionData, inQuestionRandomizationSettings, surveyData)
{
let t = performance.now();
let choicesFieldName;
let valueFieldName;
if (questionData.rows !== undefined)
@ -721,7 +720,7 @@ export class Survey extends VisualStim
}
else
{
console.log("[Survey runner]: Uknown choicesFieldName for", questionData);
console.log("[Survey runner]: Unknown choicesFieldName for", questionData);
}
if (inQuestionRandomizationSettings.randomizeAll)
@ -745,8 +744,7 @@ export class Survey extends VisualStim
let choicesMap = {};
// TODO: generalize further i.e. figure out how to calculate the length of array based on availability of sets.
const setIndices = [0, 0, 0];
let i;
for (i = 0; i < questionData[choicesFieldName].length; i++)
for (let i = 0; i < questionData[choicesFieldName].length; i++)
{
choicesMap[questionData[choicesFieldName][i][valueFieldName]] = questionData[choicesFieldName][i];
}
@ -758,7 +756,7 @@ export class Survey extends VisualStim
// const shuffledSet0 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set0);
// const shuffledSet1 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set1);
const reversedSet = Math.round(Math.random()) === 1 ? inQuestionRandomizationSettings.reverseOrder.reverse() : inQuestionRandomizationSettings.reverseOrder;
for (i = 0; i < inQuestionRandomizationSettings.layout.length; i++)
for (let i = 0; i < inQuestionRandomizationSettings.layout.length; i++)
{
if (inQuestionRandomizationSettings.layout[i] === "set0")
{
@ -797,53 +795,58 @@ export class Survey extends VisualStim
}
}
console.log("applying question randomization took", performance.now() - t);
// console.log(questionData);
}
/**
* @desc: Go over required surveyModelData and apply randomization settings.
* Go over required surveyModelData and apply randomization settings.
* @protected
*/
_processSurveyData (surveyData, surveyIdx)
_processSurveyData(surveyData, surveyIdx)
{
let t = performance.now();
let i, j;
let newSurveyModel = undefined;
if (surveyData.questionsOrderRandomization[surveyIdx] !== undefined)
// Qualtrics's in-block randomization ignores the presence of page breaks within the block.
// Hence creating a fresh survey data object with shuffled question order.
if (typeof surveyData.questionsOrderRandomization[surveyIdx] !== "undefined")
{
// Qualtrics's in-block randomization ignores presense of page breaks within the block.
// Hence creating a fresh survey data object with shuffled question order.
newSurveyModel = this._composeModelWithRandomizedQuestions(surveyData.surveys[surveyIdx], surveyData.questionsOrderRandomization[surveyIdx]);
newSurveyModel = this._composeModelWithRandomizedQuestions(
surveyData.surveys[surveyIdx],
surveyData.questionsOrderRandomization[surveyIdx]
);
}
// Checking if there's in-question randomization that needs to be applied.
for (i = 0; i < surveyData.surveys[surveyIdx].pages.length; i++)
// note: we need to check whether the survey model has a "pages" field since empty surveys do not:
if ("pages" in surveyData.surveys[surveyIdx])
{
for (j = 0; j < surveyData.surveys[surveyIdx].pages[i].elements.length; j++)
// checking whether in-question randomization needs to be applied:
for (let i = 0; i < surveyData.surveys[surveyIdx].pages.length; ++i)
{
if (surveyData.inQuestionRandomization[surveyData.surveys[surveyIdx].pages[i].elements[j].name] !== undefined)
for (let j = 0; j < surveyData.surveys[surveyIdx].pages[i].elements.length; ++j)
{
if (newSurveyModel === undefined)
if (typeof surveyData.inQuestionRandomization[surveyData.surveys[surveyIdx].pages[i].elements[j].name] !== "undefined")
{
// Marking a deep copy of survey model input data, to avoid data loss if randomization returns a subset of choices.
// TODO: think of somehting more optimal.
newSurveyModel = JSON.parse(JSON.stringify(surveyData.surveys[surveyIdx]));
if (typeof newSurveyModel === "undefined")
{
// Marking a deep copy of survey model input data, to avoid data loss if randomization returns a subset of choices.
// TODO: think of something more optimal.
newSurveyModel = JSON.parse(JSON.stringify(surveyData.surveys[surveyIdx]));
}
this._applyInQuestionRandomization(
newSurveyModel.pages[i].elements[j],
surveyData.inQuestionRandomization[newSurveyModel.pages[i].elements[j].name],
surveyData
);
}
this._applyInQuestionRandomization(
newSurveyModel.pages[i].elements[j],
surveyData.inQuestionRandomization[newSurveyModel.pages[i].elements[j].name],
surveyData
);
}
}
}
if (newSurveyModel === undefined)
if (typeof newSurveyModel === "undefined")
{
// No changes were made, just return original data.
newSurveyModel = surveyData.surveys[surveyIdx];
}
console.log("survey model preprocessing took", performance.now() - t);
return newSurveyModel;
}
@ -975,7 +978,12 @@ export class Survey extends VisualStim
this._surveyRunningPromiseResolve(completionCode);
}
_onFlowComplete ()
/**
* Callback triggered when we have run through the whole flow.
*
* @protected
*/
_onFlowComplete()
{
this.isFinished = true;
this._onFinishedCallback();
@ -1042,32 +1050,41 @@ export class Survey extends VisualStim
return this._surveyRunningPromise;
}
async _runSurveyFlow(surveyBlock, surveyData, prevBlockResults = {})
/**
*
* @param node
* @param surveyData
* @param prevBlockResults
* @return {Promise<number>}
* @private
*/
async _runSurveyFlow(node, surveyData, prevBlockResults = {})
{
let nodeExitCode = Survey.NODE_EXIT_CODES.NORMAL;
if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL)
if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL)
{
const dataset = Object.assign({}, this._overallSurveyResults, this._variables);
this._expressionsRunner.expressionExecutor.setExpression(surveyBlock.condition);
if (this._expressionsRunner.run(dataset) && surveyBlock.nodes[0] !== undefined)
this._expressionsRunner.expressionExecutor.setExpression(node.condition);
if (this._expressionsRunner.run(dataset) && node.nodes[0] !== undefined)
{
nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[0], surveyData, prevBlockResults);
nodeExitCode = await this._runSurveyFlow(node.nodes[0], surveyData, prevBlockResults);
}
else if (surveyBlock.nodes[1] !== undefined)
else if (node.nodes[1] !== undefined)
{
nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[1], surveyData, prevBlockResults);
nodeExitCode = await this._runSurveyFlow(node.nodes[1], surveyData, prevBlockResults);
}
}
else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.RANDOMIZER)
else if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.RANDOMIZER)
{
util.shuffle(surveyBlock.nodes, Math.random, 0, surveyBlock.nodes.length - 1);
// this._InPlaceFisherYatesShuffle(surveyBlock.nodes, 0, surveyBlock.nodes.length - 1);
util.shuffle(node.nodes, Math.random, 0, node.nodes.length - 1);
}
else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.EMBEDDED_DATA)
else if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.EMBEDDED_DATA)
{
let t = performance.now();
const surveyBlockData = surveyData.embeddedData[surveyBlock.dataIdx];
const surveyBlockData = surveyData.embeddedData[node.dataIdx];
for (let j = 0; j < surveyBlockData.length; j++)
{
// TODO: handle the rest data types.
@ -1090,7 +1107,8 @@ export class Survey extends VisualStim
}
console.log("embedded data variables accumulation took", performance.now() - t);
}
else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.ENDSURVEY)
else if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.ENDSURVEY)
{
if (this._surveyModel)
{
@ -1099,9 +1117,10 @@ export class Survey extends VisualStim
console.log("EndSurvey block encountered, exiting.");
nodeExitCode = Survey.NODE_EXIT_CODES.BREAK_FLOW;
}
else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.DIRECT)
else if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.DIRECT)
{
const surveyCompletionCode = await this._beginSurvey(surveyData, surveyBlock);
const surveyCompletionCode = await this._beginSurvey(surveyData, node);
Object.assign({}, prevBlockResults, this._surveyModel.data);
// SkipLogic had destination set to ENDOFSURVEY.
@ -1111,13 +1130,14 @@ export class Survey extends VisualStim
}
}
// run through the children nodes of this node:
if (nodeExitCode === Survey.NODE_EXIT_CODES.NORMAL &&
surveyBlock.type !== Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL &&
surveyBlock.nodes instanceof Array)
node.type !== Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL &&
node.nodes instanceof Array)
{
for (let i = 0; i < surveyBlock.nodes.length; i++)
for (const childNode of node.nodes)
{
nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[i], surveyData, prevBlockResults);
nodeExitCode = await this._runSurveyFlow(childNode, surveyData, prevBlockResults);
if (nodeExitCode === Survey.NODE_EXIT_CODES.BREAK_FLOW)
{
break;
@ -1125,9 +1145,9 @@ export class Survey extends VisualStim
}
}
if (surveyBlock.name === "root")
// if we have just run through the top node, mark the whole flow as completed:
if (node.isRootNode)
{
// At this point we went through the entire survey flow tree.
this._onFlowComplete();
}

View File

@ -573,21 +573,16 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
{
this._needPixiUpdate = false;
let enteredText = "";
// at this point this._pixi might exist but is removed from the scene, in such cases this._pixi.text
// does not retain the information about new lines etc. so we go with a local copy of entered text
if (this._pixi !== undefined && this._pixi.parent !== null) {
enteredText = this._pixi.text;
} else {
enteredText = this._text;
}
// note: destroying _pixi will get rid of _pixi.text, which will, in turn, remove information about
// new lines etc., so we get a copy here, which we will restore on the new _pixi
const prevText = (this._pixi !== undefined && this._pixi.parent !== null) ? this._pixi.text : this._text;
if (typeof this._pixi !== "undefined")
{
this._pixi.destroy(true);
}
// Create new TextInput
// create a new TextInput
this._pixi = new TextInput(this._getTextInputOptions());
// listeners required for regular textboxes, but may cause problems with button stimuli
@ -610,7 +605,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
}
if (this._editable)
{
this.text = enteredText;
this.text = prevText;
this._pixi.placeholder = this._placeholder;
}
else