mirror of
https://github.com/psychopy/psychojs.git
synced 2025-05-10 10:40:54 +00:00
commit
988d1c69e6
@ -17,7 +17,7 @@ module.exports = {
|
||||
"block-spacing": 2,
|
||||
"brace-style": [2, "allman", { allowSingleLine: true }],
|
||||
"camelcase": 1,
|
||||
"capitalized-comments": [1, "always", { ignoreConsecutiveComments: true }],
|
||||
"capitalized-comments": 0,
|
||||
"comma-spacing": 2,
|
||||
"comma-style": 2,
|
||||
"consistent-return": 1,
|
||||
@ -47,7 +47,7 @@ module.exports = {
|
||||
"no-console": 1,
|
||||
"no-div-regex": 2,
|
||||
"no-duplicate-imports": 2,
|
||||
"no-else-return": 2,
|
||||
"no-else-return": 1,
|
||||
"no-eval": 2,
|
||||
"no-extend-native": 2,
|
||||
"no-extra-bind": 2,
|
||||
@ -65,7 +65,7 @@ module.exports = {
|
||||
"no-mixed-requires": 2,
|
||||
"no-multi-spaces": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-multiple-empty-lines": [2, { max: 1, maxEOF: 0 }],
|
||||
"no-multiple-empty-lines": [1, { max: 2, maxEOF: 0 }],
|
||||
"no-new": 2,
|
||||
"no-new-func": 2,
|
||||
"no-new-object": 2,
|
||||
@ -74,7 +74,7 @@ module.exports = {
|
||||
"no-octal-escape": 2,
|
||||
"no-param-reassign": 1,
|
||||
"no-path-concat": 2,
|
||||
"no-plusplus": 2,
|
||||
"no-plusplus": 0,
|
||||
"no-proto": 2,
|
||||
"no-restricted-properties": 2,
|
||||
"no-return-assign": [2, "except-parens"],
|
||||
@ -85,14 +85,15 @@ module.exports = {
|
||||
"no-shadow-restricted-names": 2,
|
||||
"no-tabs": [1, { allowIndentationTabs: true }],
|
||||
"no-template-curly-in-string": 2,
|
||||
"no-throw-literal": 2,
|
||||
"no-throw-literal": 0,
|
||||
"no-trailing-spaces": 2,
|
||||
"no-undef-init": 2,
|
||||
// https://eslint.org/docs/rules/no-underscore-dangle#disallow-dangling-underscores-in-identifiers-no-underscore-dangle
|
||||
"no-underscore-dangle": 1,
|
||||
"no-underscore-dangle": 0,
|
||||
"no-unmodified-loop-condition": 2,
|
||||
"no-unneeded-ternary": 2,
|
||||
"no-unused-expressions": 2,
|
||||
"no-unused-expressions": 1,
|
||||
"no-unused-vars": 1,
|
||||
"no-use-before-define": [2, { functions: false }],
|
||||
"no-useless-call": 2,
|
||||
"no-useless-computed-key": 2,
|
||||
@ -106,7 +107,7 @@ module.exports = {
|
||||
"object-property-newline": [2, { allowMultiplePropertiesPerLine: true }],
|
||||
"one-var": [2, "never"],
|
||||
"one-var-declaration-per-line": 2,
|
||||
"operator-linebreak": [2, "before"],
|
||||
"operator-linebreak": [1, "before"],
|
||||
"padded-blocks": [2, "never"],
|
||||
"padding-line-between-statements": 2,
|
||||
"prefer-const": 2,
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psychojs",
|
||||
"version": "2021.2.1",
|
||||
"version": "2022.1.0",
|
||||
"private": true,
|
||||
"description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments",
|
||||
"license": "MIT",
|
||||
|
@ -258,7 +258,19 @@ export class EventManager
|
||||
self._mouseInfo.buttons.times[event.button] = self._psychoJS._monotonicClock.getTime() - self._mouseInfo.buttons.clocks[event.button].getLastResetTime();
|
||||
self._mouseInfo.pos = [event.offsetX, event.offsetY];
|
||||
|
||||
this._psychoJS.experimentLogger.data("Mouse: " + event.button + " button down, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")");
|
||||
this._psychoJS.experimentLogger.data("Mouse: " + event.button + " button up, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")");
|
||||
}, false);
|
||||
|
||||
renderer.view.addEventListener("pointerout", (event) =>
|
||||
{
|
||||
event.preventDefault();
|
||||
|
||||
// if the pointer leaves the canvas: cancel all buttons
|
||||
self._mouseInfo.buttons.pressed = [0, 0, 0];
|
||||
self._mouseInfo.buttons.times = [0.0, 0.0, 0.0];
|
||||
self._mouseInfo.pos = [event.offsetX, event.offsetY];
|
||||
|
||||
this._psychoJS.experimentLogger.data("Mouse: out, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")");
|
||||
}, false);
|
||||
|
||||
renderer.view.addEventListener("touchend", (event) =>
|
||||
@ -272,7 +284,7 @@ export class EventManager
|
||||
const touches = event.changedTouches;
|
||||
self._mouseInfo.pos = [touches[0].pageX, touches[0].pageY];
|
||||
|
||||
this._psychoJS.experimentLogger.data("Mouse: " + event.button + " button down, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")");
|
||||
this._psychoJS.experimentLogger.data("Mouse: " + event.button + " button up, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")");
|
||||
}, false);
|
||||
|
||||
renderer.view.addEventListener("pointermove", (event) =>
|
||||
|
@ -190,7 +190,10 @@ export class GUI
|
||||
}
|
||||
});
|
||||
|
||||
htmlCode += '<p class="validateTips">Fields marked with an asterisk (*) are required.</p>';
|
||||
if (this._requiredKeys.length > 0)
|
||||
{
|
||||
htmlCode += '<p class="validateTips">Fields marked with an asterisk (*) are required.</p>';
|
||||
}
|
||||
|
||||
// add a progress bar:
|
||||
htmlCode += '<hr><div id="progressMsg" class="progress">' + self._progressMsg + "</div>";
|
||||
@ -322,16 +325,7 @@ export class GUI
|
||||
} = {})
|
||||
{
|
||||
// close the previously opened dialog box, if there is one:
|
||||
const expDialog = jQuery("#expDialog");
|
||||
if (expDialog.length)
|
||||
{
|
||||
expDialog.dialog("destroy").remove();
|
||||
}
|
||||
const msgDialog = jQuery("#msgDialog");
|
||||
if (msgDialog.length)
|
||||
{
|
||||
msgDialog.dialog("destroy").remove();
|
||||
}
|
||||
this.closeDialog();
|
||||
|
||||
let htmlCode;
|
||||
let titleColour;
|
||||
@ -448,6 +442,27 @@ export class GUI
|
||||
.prev(".ui-dialog-titlebar").css("background", titleColour);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the previously opened dialog box, if there is one.
|
||||
*
|
||||
* @name module:core.GUI#closeDialog
|
||||
* @function
|
||||
* @public
|
||||
*/
|
||||
closeDialog()
|
||||
{
|
||||
const expDialog = jQuery("#expDialog");
|
||||
if (expDialog.length)
|
||||
{
|
||||
expDialog.dialog("destroy").remove();
|
||||
}
|
||||
const msgDialog = jQuery("#msgDialog");
|
||||
if (msgDialog.length)
|
||||
{
|
||||
msgDialog.dialog("destroy").remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for resource event from the [Server Manager]{@link ServerManager}.
|
||||
*
|
||||
|
@ -176,7 +176,7 @@ export class PsychoJS
|
||||
}
|
||||
|
||||
this.logger.info("[PsychoJS] Initialised.");
|
||||
this.logger.info("[PsychoJS] @version 2021.2.0");
|
||||
this.logger.info("[PsychoJS] @version 2021.3.0");
|
||||
|
||||
// hide the initialisation message:
|
||||
jQuery("#root").addClass("is-ready");
|
||||
@ -393,7 +393,7 @@ export class PsychoJS
|
||||
}
|
||||
|
||||
// start the asynchronous download of resources:
|
||||
await this._serverManager.prepareResources(resources);
|
||||
this._serverManager.prepareResources(resources);
|
||||
|
||||
// start the experiment:
|
||||
this.logger.info("[PsychoJS] Start Experiment.");
|
||||
|
@ -12,6 +12,7 @@ import { ExperimentHandler } from "../data/ExperimentHandler.js";
|
||||
import { Clock, MonotonicClock } from "../util/Clock.js";
|
||||
import { PsychObject } from "../util/PsychObject.js";
|
||||
import * as util from "../util/Util.js";
|
||||
import { Scheduler } from "../util/Scheduler.js";
|
||||
import { PsychoJS } from "./PsychoJS.js";
|
||||
|
||||
/**
|
||||
@ -27,7 +28,7 @@ import { PsychoJS } from "./PsychoJS.js";
|
||||
*/
|
||||
export class ServerManager extends PsychObject
|
||||
{
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Used to indicate to the ServerManager that all resources must be registered (and
|
||||
* subsequently downloaded)
|
||||
*
|
||||
@ -54,14 +55,14 @@ export class ServerManager extends PsychObject
|
||||
this._addAttribute("status", ServerManager.Status.READY);
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* @typedef ServerManager.GetConfigurationPromise
|
||||
* @property {string} origin the calling method
|
||||
* @property {string} context the context
|
||||
* @property {Object.<string, *>} [config] the configuration
|
||||
* @property {Object.<string, *>} [error] an error message if we could not read the configuration file
|
||||
*/
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Read the configuration file for the experiment.
|
||||
*
|
||||
* @name module:core.ServerManager#getConfiguration
|
||||
@ -100,14 +101,14 @@ export class ServerManager extends PsychObject
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* @typedef ServerManager.OpenSessionPromise
|
||||
* @property {string} origin the calling method
|
||||
* @property {string} context the context
|
||||
* @property {string} [token] the session token
|
||||
* @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.
|
||||
*
|
||||
* @name module:core.ServerManager#openSession
|
||||
@ -190,13 +191,13 @@ export class ServerManager extends PsychObject
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* @typedef ServerManager.CloseSessionPromise
|
||||
* @property {string} origin the calling method
|
||||
* @property {string} context the context
|
||||
* @property {Object.<string, *>} [error] an error message if we could not close the session (e.g. if it has not previously been opened)
|
||||
*/
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Close the session for this experiment on the remote PsychoJS manager.
|
||||
*
|
||||
* @name module:core.ServerManager#closeSession
|
||||
@ -277,7 +278,7 @@ export class ServerManager extends PsychObject
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Get the value of a resource.
|
||||
*
|
||||
* @name module:core.ServerManager#getResource
|
||||
@ -316,7 +317,7 @@ export class ServerManager extends PsychObject
|
||||
return pathStatusData.data;
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Get the status of a resource.
|
||||
*
|
||||
* @name module:core.ServerManager#getResourceStatus
|
||||
@ -343,7 +344,7 @@ export class ServerManager extends PsychObject
|
||||
return pathStatusData.status;
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Set the resource manager status.
|
||||
*
|
||||
* @name module:core.ServerManager#setStatus
|
||||
@ -376,7 +377,7 @@ export class ServerManager extends PsychObject
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Reset the resource manager status to ServerManager.Status.READY.
|
||||
*
|
||||
* @name module:core.ServerManager#resetStatus
|
||||
@ -389,7 +390,7 @@ export class ServerManager extends PsychObject
|
||||
return this.setStatus(ServerManager.Status.READY);
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Prepare resources for the experiment: register them with the server manager and possibly
|
||||
* start downloading them right away.
|
||||
*
|
||||
@ -453,7 +454,7 @@ export class ServerManager extends PsychObject
|
||||
path,
|
||||
data: undefined,
|
||||
});
|
||||
this._psychoJS.logger.debug("registered resource:", name, path);
|
||||
this._psychoJS.logger.debug(`registered resource: name= ${name}, path= ${path}`);
|
||||
resourcesToDownload.add(name);
|
||||
}
|
||||
}
|
||||
@ -476,12 +477,10 @@ export class ServerManager extends PsychObject
|
||||
{
|
||||
// to deal with potential CORS issues, we use the pavlovia.org proxy for resources
|
||||
// not hosted on pavlovia.org:
|
||||
if (
|
||||
(path.toLowerCase().indexOf("www.") === 0
|
||||
|| path.toLowerCase().indexOf("http:") === 0
|
||||
|| path.toLowerCase().indexOf("https:") === 0)
|
||||
&& (path.indexOf("pavlovia.org") === -1)
|
||||
)
|
||||
if ( (path.toLowerCase().indexOf("www.") === 0 ||
|
||||
path.toLowerCase().indexOf("http:") === 0 ||
|
||||
path.toLowerCase().indexOf("https:") === 0) &&
|
||||
(path.indexOf("pavlovia.org") === -1) )
|
||||
{
|
||||
path = "https://pavlovia.org/api/v2/proxy/" + path;
|
||||
}
|
||||
@ -491,7 +490,7 @@ export class ServerManager extends PsychObject
|
||||
path,
|
||||
data: undefined,
|
||||
});
|
||||
this._psychoJS.logger.debug("registered resource:", name, path);
|
||||
this._psychoJS.logger.debug(`registered resource: name= ${name}, path= ${path}`);
|
||||
|
||||
// download resources by default:
|
||||
if (typeof download === "undefined" || download)
|
||||
@ -521,13 +520,13 @@ export class ServerManager extends PsychObject
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.log("error", error);
|
||||
console.error("error", error);
|
||||
throw Object.assign(response, { error });
|
||||
// throw { ...response, error: error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Block the experiment until the specified resources have been downloaded.
|
||||
*
|
||||
* @name module:core.ServerManager#waitForResources
|
||||
@ -545,7 +544,7 @@ export class ServerManager extends PsychObject
|
||||
};
|
||||
|
||||
const self = this;
|
||||
return () =>
|
||||
return async () =>
|
||||
{
|
||||
const t = self._waitForDownloadComponent.clock.getTime();
|
||||
|
||||
@ -560,11 +559,11 @@ export class ServerManager extends PsychObject
|
||||
{
|
||||
for (const [name, { status, path, data }] of this._resources)
|
||||
{
|
||||
resources.append({ name, path });
|
||||
resources.push({ name, path });
|
||||
}
|
||||
}
|
||||
|
||||
// only download those resources not already downloaded or downloading:
|
||||
// only download those resources not already downloaded and not downloading:
|
||||
const resourcesToDownload = new Set();
|
||||
for (let { name, path } of resources)
|
||||
{
|
||||
@ -594,6 +593,7 @@ export class ServerManager extends PsychObject
|
||||
resourcesToDownload.add(name);
|
||||
self._psychoJS.logger.debug("registered resource:", name, path);
|
||||
}
|
||||
|
||||
// the resource has been registered but is not downloaded yet:
|
||||
else if (typeof pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
|
||||
{ // else if (typeof pathStatusData.data === 'undefined')
|
||||
@ -601,35 +601,40 @@ export class ServerManager extends PsychObject
|
||||
}
|
||||
}
|
||||
|
||||
self._waitForDownloadComponent.status = PsychoJS.Status.STARTED;
|
||||
|
||||
// start the download:
|
||||
self._downloadResources(resourcesToDownload);
|
||||
}
|
||||
|
||||
// check whether all resources have been downloaded:
|
||||
for (const name of self._waitForDownloadComponent.resources)
|
||||
if (self._waitForDownloadComponent.status === PsychoJS.Status.STARTED)
|
||||
{
|
||||
const pathStatusData = this._resources.get(name);
|
||||
// check whether all resources have been downloaded:
|
||||
for (const name of self._waitForDownloadComponent.resources)
|
||||
{
|
||||
const pathStatusData = this._resources.get(name);
|
||||
|
||||
// the resource has not been downloaded yet: loop this component
|
||||
if (typeof pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
|
||||
{ // if (typeof pathStatusData.data === 'undefined')
|
||||
return Scheduler.Event.FLIP_REPEAT;
|
||||
// the resource has not been downloaded yet: loop this component
|
||||
if (pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
|
||||
{ // if (typeof pathStatusData.data === 'undefined')
|
||||
return Scheduler.Event.FLIP_REPEAT;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// all resources have been downloaded: move to the next component:
|
||||
self._waitForDownloadComponent.status = PsychoJS.Status.FINISHED;
|
||||
return Scheduler.Event.NEXT;
|
||||
// all resources have been downloaded: move to the next component:
|
||||
self._waitForDownloadComponent.status = PsychoJS.Status.FINISHED;
|
||||
return Scheduler.Event.NEXT;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* @typedef ServerManager.UploadDataPromise
|
||||
* @property {string} origin the calling method
|
||||
* @property {string} context the context
|
||||
* @property {Object.<string, *>} [error] an error message if we could not upload the data
|
||||
*/
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Asynchronously upload experiment data to the pavlovia server.
|
||||
*
|
||||
* @name module:core.ServerManager#uploadData
|
||||
@ -694,7 +699,7 @@ export class ServerManager extends PsychObject
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Asynchronously upload experiment logs to the pavlovia server.
|
||||
*
|
||||
* @name module:core.ServerManager#uploadLog
|
||||
@ -753,37 +758,49 @@ export class ServerManager extends PsychObject
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously upload audio data to the pavlovia server.
|
||||
/****************************************************************************
|
||||
* Synchronously or asynchronously upload audio data to the pavlovia server.
|
||||
*
|
||||
* @name module:core.ServerManager#uploadAudioVideo
|
||||
* @function
|
||||
* @public
|
||||
* @param {Blob} audioBlob - the audio blob to be uploaded
|
||||
* @param {string} tag - additional tag
|
||||
* @param @param {Object} options
|
||||
* @param {Blob} options.mediaBlob - the audio or video blob to be uploaded
|
||||
* @param {string} options.tag - additional tag
|
||||
* @param {boolean} [options.waitForCompletion=false] - whether or not to wait for completion
|
||||
* before returning
|
||||
* @param {boolean} [options.showDialog=false] - whether or not to open a dialog box to inform the participant to wait for the data to be uploaded to the server
|
||||
* @param {string} [options.dialogMsg="Please wait a few moments while the data is uploading to the server"] - default message informing the participant to wait for the data to be uploaded to the server
|
||||
* @returns {Promise<ServerManager.UploadDataPromise>} the response
|
||||
*/
|
||||
async uploadAudioVideo(audioBlob, tag)
|
||||
async uploadAudioVideo({mediaBlob, tag, waitForCompletion = false, showDialog = false, dialogMsg = "Please wait a few moments while the data is uploading to the server"})
|
||||
{
|
||||
const response = {
|
||||
origin: "ServerManager.uploadAudio",
|
||||
context: "when uploading audio data for experiment: " + this._psychoJS.config.experiment.fullpath,
|
||||
context: "when uploading media data for experiment: " + this._psychoJS.config.experiment.fullpath,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (
|
||||
this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER
|
||||
if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER
|
||||
|| this._psychoJS.config.experiment.status !== "RUNNING"
|
||||
|| this._psychoJS._serverMsg.has("__pilotToken")
|
||||
)
|
||||
|| this._psychoJS._serverMsg.has("__pilotToken"))
|
||||
{
|
||||
throw "audio recordings can only be uploaded to the server for experiments running on the server";
|
||||
throw "media recordings can only be uploaded to the server for experiments running on the server";
|
||||
}
|
||||
|
||||
this._psychoJS.logger.debug("uploading audio data for experiment: " + this._psychoJS.config.experiment.fullpath);
|
||||
this._psychoJS.logger.debug(`uploading media data for experiment: ${this._psychoJS.config.experiment.fullpath}`);
|
||||
this.setStatus(ServerManager.Status.BUSY);
|
||||
|
||||
// open pop-up dialog:
|
||||
if (showDialog)
|
||||
{
|
||||
this.psychoJS.gui.dialog({
|
||||
warning: dialogMsg,
|
||||
showOK: false,
|
||||
});
|
||||
}
|
||||
|
||||
// prepare the request:
|
||||
const info = this.psychoJS.experiment.extraInfo;
|
||||
const participant = ((typeof info.participant === "string" && info.participant.length > 0) ? info.participant : "PARTICIPANT");
|
||||
@ -792,15 +809,15 @@ export class ServerManager extends PsychObject
|
||||
const filename = participant + "_" + experimentName + "_" + datetime + "_" + tag;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioBlob, filename);
|
||||
formData.append("media", mediaBlob, filename);
|
||||
|
||||
const url = this._psychoJS.config.pavlovia.URL
|
||||
let url = this._psychoJS.config.pavlovia.URL
|
||||
+ "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId
|
||||
+ "/sessions/" + this._psychoJS.config.session.token
|
||||
+ "/audio";
|
||||
+ "/media";
|
||||
|
||||
// query the pavlovia server:
|
||||
const response = await fetch(url, {
|
||||
// query the server:
|
||||
let response = await fetch(url, {
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
cache: "no-cache",
|
||||
@ -809,16 +826,63 @@ export class ServerManager extends PsychObject
|
||||
referrerPolicy: "no-referrer",
|
||||
body: formData,
|
||||
});
|
||||
const jsonResponse = await response.json();
|
||||
const postMediaResponse = await response.json();
|
||||
this._psychoJS.logger.debug(`post media response: ${JSON.stringify(postMediaResponse)}`);
|
||||
|
||||
// deal with server errors:
|
||||
if (!response.ok)
|
||||
{
|
||||
throw jsonResponse;
|
||||
throw postMediaResponse;
|
||||
}
|
||||
|
||||
// wait until the upload has completed:
|
||||
if (waitForCompletion)
|
||||
{
|
||||
if (!("uploadToken" in postMediaResponse))
|
||||
{
|
||||
throw "incorrect server response: missing uploadToken";
|
||||
}
|
||||
const uploadToken = postMediaResponse['uploadToken'];
|
||||
|
||||
while (true)
|
||||
{
|
||||
// wait a bit:
|
||||
await new Promise(r =>
|
||||
{
|
||||
setTimeout(r, 1000);
|
||||
});
|
||||
|
||||
// check the status of the upload:
|
||||
url = this._psychoJS.config.pavlovia.URL
|
||||
+ "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId
|
||||
+ "/sessions/" + this._psychoJS.config.session.token
|
||||
+ "/media/" + uploadToken + "/status";
|
||||
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
mode: "cors",
|
||||
cache: "no-cache",
|
||||
credentials: "same-origin",
|
||||
redirect: "follow",
|
||||
referrerPolicy: "no-referrer"
|
||||
});
|
||||
const checkStatusResponse = await response.json();
|
||||
this._psychoJS.logger.debug(`check upload status response: ${JSON.stringify(checkStatusResponse)}`);
|
||||
|
||||
if (("status" in checkStatusResponse) && checkStatusResponse["status"] === "COMPLETED")
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog)
|
||||
{
|
||||
this.psychoJS.gui.closeDialog();
|
||||
}
|
||||
|
||||
this.setStatus(ServerManager.Status.READY);
|
||||
return jsonResponse;
|
||||
return postMediaResponse;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
@ -829,9 +893,9 @@ export class ServerManager extends PsychObject
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* List the resources available to the experiment.
|
||||
|
||||
*
|
||||
* @name module:core.ServerManager#_listResources
|
||||
* @function
|
||||
* @private
|
||||
@ -898,7 +962,7 @@ export class ServerManager extends PsychObject
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Download the specified resources.
|
||||
*
|
||||
* <p>Note: we use the [preloadjs library]{@link https://www.createjs.com/preloadjs}.</p>
|
||||
@ -925,87 +989,11 @@ export class ServerManager extends PsychObject
|
||||
|
||||
this._nbLoadedResources = 0;
|
||||
|
||||
// (*) set-up preload.js:
|
||||
this._resourceQueue = new createjs.LoadQueue(true, "", true);
|
||||
|
||||
const self = this;
|
||||
|
||||
// the loading of a specific resource has started:
|
||||
this._resourceQueue.addEventListener("filestart", (event) =>
|
||||
{
|
||||
const pathStatusData = self._resources.get(event.item.id);
|
||||
pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING;
|
||||
|
||||
self.emit(ServerManager.Event.RESOURCE, {
|
||||
message: ServerManager.Event.DOWNLOADING_RESOURCE,
|
||||
resource: event.item.id,
|
||||
});
|
||||
});
|
||||
|
||||
// the loading of a specific resource has completed:
|
||||
this._resourceQueue.addEventListener("fileload", (event) =>
|
||||
{
|
||||
const pathStatusData = self._resources.get(event.item.id);
|
||||
pathStatusData.data = event.result;
|
||||
pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED;
|
||||
|
||||
++self._nbLoadedResources;
|
||||
self.emit(ServerManager.Event.RESOURCE, {
|
||||
message: ServerManager.Event.RESOURCE_DOWNLOADED,
|
||||
resource: event.item.id,
|
||||
});
|
||||
});
|
||||
|
||||
// the loading of all given resources completed:
|
||||
this._resourceQueue.addEventListener("complete", (event) =>
|
||||
{
|
||||
self._resourceQueue.close();
|
||||
if (self._nbLoadedResources === resources.size)
|
||||
{
|
||||
self.setStatus(ServerManager.Status.READY);
|
||||
self.emit(ServerManager.Event.RESOURCE, {
|
||||
message: ServerManager.Event.DOWNLOAD_COMPLETED,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// error: we throw an exception
|
||||
this._resourceQueue.addEventListener("error", (event) =>
|
||||
{
|
||||
self.setStatus(ServerManager.Status.ERROR);
|
||||
if (typeof event.item !== "undefined")
|
||||
{
|
||||
const pathStatusData = self._resources.get(event.item.id);
|
||||
pathStatusData.status = ServerManager.ResourceStatus.ERROR;
|
||||
throw Object.assign(response, {
|
||||
error: "unable to download resource: " + event.item.id + " (" + event.title + ")",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
console.error(event);
|
||||
|
||||
if (event.title === "FILE_LOAD_ERROR" && typeof event.data !== "undefined")
|
||||
{
|
||||
const id = event.data.id;
|
||||
const title = event.data.src;
|
||||
|
||||
throw Object.assign(response, {
|
||||
error: "unable to download resource: " + id + " (" + title + ")",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
throw Object.assign(response, {
|
||||
error: "unspecified download error",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// (*) dispatch resources to preload.js or howler.js based on extension:
|
||||
let manifest = [];
|
||||
// based on the resource extension either (a) add it to the preload manifest, (b) mark it for
|
||||
// download by howler, or (c) add it to the document fonts
|
||||
const preloadManifest = [];
|
||||
const soundResources = new Set();
|
||||
const fontResources = [];
|
||||
for (const name of resources)
|
||||
{
|
||||
const nameParts = name.toLowerCase().split(".");
|
||||
@ -1027,23 +1015,27 @@ export class ServerManager extends PsychObject
|
||||
throw Object.assign(response, { error: name + " is already downloaded or is currently already downloading" });
|
||||
}
|
||||
|
||||
// preload.js with forced binary for xls and xlsx:
|
||||
const pathParts = pathStatusData.path.toLowerCase().split(".");
|
||||
const pathExtension = (pathParts.length > 1) ? pathParts.pop() : undefined;
|
||||
|
||||
// preload.js with forced binary:
|
||||
if (["csv", "odp", "xls", "xlsx", "json"].indexOf(extension) > -1)
|
||||
{
|
||||
manifest.push(/*new createjs.LoadItem().set(*/ {
|
||||
preloadManifest.push(/*new createjs.LoadItem().set(*/ {
|
||||
id: name,
|
||||
src: pathStatusData.path,
|
||||
type: createjs.Types.BINARY,
|
||||
crossOrigin: "Anonymous",
|
||||
} /*)*/);
|
||||
}
|
||||
/* ascii .csv are adequately handled in binary format
|
||||
|
||||
/* note: ascii .csv are adequately handled in binary format, no need to treat them separately
|
||||
// forced text for .csv:
|
||||
else if (['csv'].indexOf(resourceExtension) > -1)
|
||||
manifest.push({ id: resourceName, src: resourceName, type: createjs.Types.TEXT });
|
||||
*/
|
||||
|
||||
// sound files are loaded through howler.js:
|
||||
// sound files:
|
||||
else if (["mp3", "mpeg", "opus", "ogg", "oga", "wav", "aac", "caf", "m4a", "weba", "dolby", "flac"].indexOf(extension) > -1)
|
||||
{
|
||||
soundResources.add(name);
|
||||
@ -1053,10 +1045,17 @@ export class ServerManager extends PsychObject
|
||||
this.psychoJS.logger.warn(`wav files are not supported by all browsers. We recommend you convert "${name}" to another format, e.g. mp3`);
|
||||
}
|
||||
}
|
||||
// preload.js for the other extensions (download type decided by preload.js):
|
||||
|
||||
// font files
|
||||
else if (["ttf", "otf", "woff", "woff2"].indexOf(pathExtension) > -1)
|
||||
{
|
||||
fontResources.push(name);
|
||||
}
|
||||
|
||||
// all other extensions handled by preload.js (download type decided by preload.js):
|
||||
else
|
||||
{
|
||||
manifest.push(/*new createjs.LoadItem().set(*/ {
|
||||
preloadManifest.push(/*new createjs.LoadItem().set(*/ {
|
||||
id: name,
|
||||
src: pathStatusData.path,
|
||||
crossOrigin: "Anonymous",
|
||||
@ -1064,10 +1063,11 @@ export class ServerManager extends PsychObject
|
||||
}
|
||||
}
|
||||
|
||||
// (*) start loading non-sound resources:
|
||||
if (manifest.length > 0)
|
||||
// start loading resources marked for preload.js:
|
||||
if (preloadManifest.length > 0)
|
||||
{
|
||||
this._resourceQueue.loadManifest(manifest);
|
||||
this._setupPreloadQueue(resources);
|
||||
this._preloadQueue.loadManifest(preloadManifest);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -1080,7 +1080,51 @@ export class ServerManager extends PsychObject
|
||||
}
|
||||
}
|
||||
|
||||
// (*) prepare and start loading sound resources:
|
||||
// start loading fonts:
|
||||
for (const name of fontResources)
|
||||
{
|
||||
const pathStatusData = this._resources.get(name);
|
||||
pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING;
|
||||
this.emit(ServerManager.Event.RESOURCE, {
|
||||
message: ServerManager.Event.DOWNLOADING_RESOURCE,
|
||||
resource: name,
|
||||
});
|
||||
|
||||
const pathExtension = pathStatusData.path.toLowerCase().split(".").pop();
|
||||
try
|
||||
{
|
||||
const newFont = await new FontFace(name, `url('${pathStatusData.path}') format('${pathExtension}')`).load();
|
||||
document.fonts.add(newFont);
|
||||
|
||||
++this._nbLoadedResources;
|
||||
|
||||
pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED;
|
||||
this.emit(ServerManager.Event.RESOURCE, {
|
||||
message: ServerManager.Event.RESOURCE_DOWNLOADED,
|
||||
resource: name,
|
||||
});
|
||||
|
||||
if (this._nbLoadedResources === resources.size)
|
||||
{
|
||||
this.setStatus(ServerManager.Status.READY);
|
||||
this.emit(ServerManager.Event.RESOURCE, {
|
||||
message: ServerManager.Event.DOWNLOAD_COMPLETED,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
this.setStatus(ServerManager.Status.ERROR);
|
||||
pathStatusData.status = ServerManager.ResourceStatus.ERROR;
|
||||
throw Object.assign(response, {
|
||||
error: `unable to download resource: ${name}: ${error}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// start loading resources marked for howler.js:
|
||||
const self = this;
|
||||
for (const name of soundResources)
|
||||
{
|
||||
const pathStatusData = this._resources.get(name);
|
||||
@ -1124,9 +1168,99 @@ export class ServerManager extends PsychObject
|
||||
howl.load();
|
||||
}
|
||||
}
|
||||
|
||||
/****************************************************************************
|
||||
* Setup the preload.js queue, and the associated callbacks.
|
||||
*
|
||||
* @name module:core.ServerManager#_setupPreloadQueue
|
||||
* @function
|
||||
* @protected
|
||||
* @param {Set} resources - a set of names of previously registered resources
|
||||
*/
|
||||
_setupPreloadQueue(resources)
|
||||
{
|
||||
this._preloadQueue = new createjs.LoadQueue(true, "", true);
|
||||
|
||||
const self = this;
|
||||
|
||||
// the loading of a specific resource has started:
|
||||
this._preloadQueue.addEventListener("filestart", (event) =>
|
||||
{
|
||||
const pathStatusData = self._resources.get(event.item.id);
|
||||
pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING;
|
||||
|
||||
self.emit(ServerManager.Event.RESOURCE, {
|
||||
message: ServerManager.Event.DOWNLOADING_RESOURCE,
|
||||
resource: event.item.id,
|
||||
});
|
||||
});
|
||||
|
||||
// the loading of a specific resource has completed:
|
||||
this._preloadQueue.addEventListener("fileload", (event) =>
|
||||
{
|
||||
const pathStatusData = self._resources.get(event.item.id);
|
||||
pathStatusData.data = event.result;
|
||||
pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED;
|
||||
|
||||
++self._nbLoadedResources;
|
||||
self.emit(ServerManager.Event.RESOURCE, {
|
||||
message: ServerManager.Event.RESOURCE_DOWNLOADED,
|
||||
resource: event.item.id,
|
||||
});
|
||||
});
|
||||
|
||||
// the loading of all given resources completed:
|
||||
this._preloadQueue.addEventListener("complete", (event) =>
|
||||
{
|
||||
self._preloadQueue.close();
|
||||
if (self._nbLoadedResources === resources.size)
|
||||
{
|
||||
self.setStatus(ServerManager.Status.READY);
|
||||
self.emit(ServerManager.Event.RESOURCE, {
|
||||
message: ServerManager.Event.DOWNLOAD_COMPLETED,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// error: we throw an exception
|
||||
this._preloadQueue.addEventListener("error", (event) =>
|
||||
{
|
||||
self.setStatus(ServerManager.Status.ERROR);
|
||||
if (typeof event.item !== "undefined")
|
||||
{
|
||||
const pathStatusData = self._resources.get(event.item.id);
|
||||
pathStatusData.status = ServerManager.ResourceStatus.ERROR;
|
||||
throw Object.assign(response, {
|
||||
error: "unable to download resource: " + event.item.id + " (" + event.title + ")",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
console.error(event);
|
||||
|
||||
if (event.title === "FILE_LOAD_ERROR" && typeof event.data !== "undefined")
|
||||
{
|
||||
const id = event.data.id;
|
||||
const title = event.data.src;
|
||||
|
||||
throw Object.assign(response, {
|
||||
error: "unable to download resource: " + id + " (" + title + ")",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
throw Object.assign(response, {
|
||||
error: "unspecified download error",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Server event
|
||||
*
|
||||
* <p>A server event is emitted by the manager to inform its listeners of either a change of status, or of a resource related event (e.g. download started, download is completed).</p>
|
||||
@ -1168,7 +1302,7 @@ ServerManager.Event = {
|
||||
STATUS: Symbol.for("STATUS"),
|
||||
};
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Server status
|
||||
*
|
||||
* @name module:core.ServerManager#Status
|
||||
@ -1193,7 +1327,7 @@ ServerManager.Status = {
|
||||
ERROR: Symbol.for("ERROR"),
|
||||
};
|
||||
|
||||
/**
|
||||
/****************************************************************************
|
||||
* Resource status
|
||||
*
|
||||
* @name module:core.ServerManager#ResourceStatus
|
||||
|
@ -76,14 +76,14 @@ export let WindowMixin = (superclass) =>
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given length from pixel units to the stimulus units
|
||||
*
|
||||
* @name module:core.WindowMixin#_getLengthUnits
|
||||
* @function
|
||||
* @protected
|
||||
* @param {number} length_px - the length in pixel units
|
||||
* @return {number} - the length in stimulus units
|
||||
*/
|
||||
* Convert the given length from pixel units to the stimulus units
|
||||
*
|
||||
* @name module:core.WindowMixin#_getLengthUnits
|
||||
* @function
|
||||
* @protected
|
||||
* @param {number} length_px - the length in pixel units
|
||||
* @return {number} - the length in stimulus units
|
||||
*/
|
||||
_getLengthUnits(length_px)
|
||||
{
|
||||
let response = {
|
||||
|
@ -170,7 +170,7 @@ export class ExperimentHandler extends PsychObject
|
||||
* @name module:data.ExperimentHandler#nextEntry
|
||||
* @function
|
||||
* @public
|
||||
* @param {Object[]} snapshots - array of loop snapshots
|
||||
* @param {Object | Object[] | undefined} snapshots - array of loop snapshots
|
||||
*/
|
||||
nextEntry(snapshots)
|
||||
{
|
||||
@ -239,16 +239,20 @@ export class ExperimentHandler extends PsychObject
|
||||
* @public
|
||||
* @param {Object} options
|
||||
* @param {Array.<Object>} [options.attributes] - the attributes to be saved
|
||||
* @param {Array.<Object>} [options.sync] - whether or not to communicate with the server in a synchronous manner
|
||||
* @param {boolean} [options.sync=false] - whether or not 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)
|
||||
*/
|
||||
async save({
|
||||
attributes = [],
|
||||
sync = false,
|
||||
tag = "",
|
||||
clear = false
|
||||
} = {})
|
||||
{
|
||||
this._psychoJS.logger.info("[PsychoJS] Save experiment results.");
|
||||
|
||||
// (*) get attributes:
|
||||
// get attributes:
|
||||
if (attributes.length === 0)
|
||||
{
|
||||
attributes = this._trialsKeys.slice();
|
||||
@ -274,7 +278,7 @@ export class ExperimentHandler extends PsychObject
|
||||
}
|
||||
}
|
||||
|
||||
// (*) get various experiment info:
|
||||
// get various experiment info:
|
||||
const info = this.extraInfo;
|
||||
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");
|
||||
@ -283,17 +287,26 @@ export class ExperimentHandler extends PsychObject
|
||||
const gitlabConfig = this._psychoJS.config.gitlab;
|
||||
const __projectId = (typeof gitlabConfig !== "undefined" && typeof gitlabConfig.projectId !== "undefined") ? gitlabConfig.projectId : undefined;
|
||||
|
||||
// (*) save to a .csv file:
|
||||
let data = this._trialsData;
|
||||
// if the experiment data have to be cleared, we first make a copy of them:
|
||||
if (clear)
|
||||
{
|
||||
data = this._trialsData.slice();
|
||||
this._trialsData = [];
|
||||
}
|
||||
|
||||
// save to a .csv file:
|
||||
if (this._psychoJS.config.experiment.saveFormat === ExperimentHandler.SaveFormat.CSV)
|
||||
{
|
||||
// note: we use the XLSX library as it automatically deals with header, takes care of quotes,
|
||||
// newlines, etc.
|
||||
const worksheet = XLSX.utils.json_to_sheet(this._trialsData);
|
||||
// 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);
|
||||
|
||||
// upload data to the pavlovia server or offer them for download:
|
||||
const key = __participant + "_" + __experimentName + "_" + __datetime + ".csv";
|
||||
const key = `${__participant}_${__experimentName}_${__datetime}${tag}.csv`;
|
||||
if (
|
||||
this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER
|
||||
&& this._psychoJS.config.experiment.status === "RUNNING"
|
||||
@ -307,17 +320,17 @@ export class ExperimentHandler extends PsychObject
|
||||
util.offerDataForDownload(key, csv, "text/csv");
|
||||
}
|
||||
}
|
||||
// (*) save in the database on the remote server:
|
||||
// save to the database on the pavlovia server:
|
||||
else if (this._psychoJS.config.experiment.saveFormat === ExperimentHandler.SaveFormat.DATABASE)
|
||||
{
|
||||
let documents = [];
|
||||
|
||||
for (let r = 0; r < this._trialsData.length; r++)
|
||||
for (let r = 0; r < data.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]];
|
||||
doc[attributes[h]] = data[r][attributes[h]];
|
||||
}
|
||||
|
||||
documents.push(doc);
|
||||
|
383
src/data/MultiStairHandler.js
Normal file
383
src/data/MultiStairHandler.js
Normal file
@ -0,0 +1,383 @@
|
||||
/** @module data */
|
||||
/**
|
||||
* Multiple Staircase Trial Handler
|
||||
*
|
||||
* @author Alain Pitiot
|
||||
* @version 2021.2.1
|
||||
* @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd.
|
||||
* (https://opensciencetools.org)
|
||||
* @license Distributed under the terms of the MIT License
|
||||
*/
|
||||
|
||||
|
||||
import {TrialHandler} from "./TrialHandler.js";
|
||||
import {QuestHandler} from "./QuestHandler.js";
|
||||
import * as util from "../util/Util.js";
|
||||
import seedrandom from "seedrandom";
|
||||
|
||||
|
||||
/**
|
||||
* <p>A handler dealing with multiple staircases, simultaneously.</p>
|
||||
*
|
||||
* <p>Note that, at the moment, using the MultiStairHandler requires the jsQuest.js
|
||||
* library to be loaded as a resource, at the start of the experiment.</p>
|
||||
*
|
||||
* @class module.data.MultiStairHandler
|
||||
* @extends TrialHandler
|
||||
* @param {Object} options - the handler options
|
||||
* @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
|
||||
* @param {string} options.varName - the name of the variable / intensity / contrast
|
||||
* / threshold manipulated by the staircases
|
||||
* @param {module:data.MultiStairHandler.StaircaseType} [options.stairType="simple"] - the
|
||||
* handler type
|
||||
* @param {Array.<Object> | String} [options.conditions= [undefined] ] - if it is a string,
|
||||
* we treat it as the name of a conditions resource
|
||||
* @param {module:data.TrialHandler.Method} options.method - the trial method
|
||||
* @param {number} [options.nTrials=50] - maximum number of trials
|
||||
* @param {number} options.randomSeed - seed for the random number generator
|
||||
* @param {string} options.name - name of the handler
|
||||
* @param {boolean} [options.autoLog= false] - whether or not to log
|
||||
*/
|
||||
export class MultiStairHandler extends TrialHandler
|
||||
{
|
||||
/**
|
||||
* @constructor
|
||||
* @public
|
||||
*/
|
||||
constructor({
|
||||
psychoJS,
|
||||
varName,
|
||||
stairType,
|
||||
conditions,
|
||||
method = TrialHandler.Method.RANDOM,
|
||||
nTrials = 50,
|
||||
randomSeed,
|
||||
name,
|
||||
autoLog
|
||||
} = {})
|
||||
{
|
||||
super({
|
||||
psychoJS,
|
||||
name,
|
||||
autoLog,
|
||||
seed: randomSeed,
|
||||
// note: multiStairHandler is a sequential TrialHandler, we deal with randomness
|
||||
// in _nextTrial
|
||||
method: TrialHandler.Method.SEQUENTIAL,
|
||||
trialList: Array(nTrials),
|
||||
nReps: 1
|
||||
});
|
||||
|
||||
// now that we have initialised a sequential TrialHandler, we update method:
|
||||
this._multiMethod = method;
|
||||
this._addAttribute("varName", varName);
|
||||
this._addAttribute("stairType", stairType, MultiStairHandler.StaircaseType.SIMPLE);
|
||||
this._addAttribute("conditions", conditions, [undefined]);
|
||||
this._addAttribute("nTrials", nTrials);
|
||||
|
||||
if (typeof randomSeed !== "undefined")
|
||||
{
|
||||
this._randomNumberGenerator = seedrandom(randomSeed);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._randomNumberGenerator = seedrandom();
|
||||
}
|
||||
|
||||
this._prepareStaircases();
|
||||
this._nextTrial();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a response to the current staircase.
|
||||
*
|
||||
* @name module:data.MultiStairHandler#addResponse
|
||||
* @function
|
||||
* @public
|
||||
* @param{number} response - the response to the trial, must be either 0 (incorrect or
|
||||
* non-detected) or 1 (correct or detected)
|
||||
* @param{number | undefined} [value] - optional intensity / contrast / threshold
|
||||
* @returns {void}
|
||||
*/
|
||||
addResponse(response, value)
|
||||
{
|
||||
// check that response is either 0 or 1:
|
||||
if (response !== 0 && response !== 1)
|
||||
{
|
||||
throw {
|
||||
origin: "MultiStairHandler.addResponse",
|
||||
context: "when adding a trial response",
|
||||
error: `the response must be either 0 or 1, got: ${JSON.stringify(response)}`
|
||||
};
|
||||
}
|
||||
|
||||
if (!this._finished)
|
||||
{
|
||||
// update the current staircase:
|
||||
this._currentStaircase.addResponse(response, value);
|
||||
|
||||
// move onto the next trial:
|
||||
this._nextTrial();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the conditions.
|
||||
*
|
||||
* @name module:data.MultiStairHandler#_validateConditions
|
||||
* @function
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_validateConditions()
|
||||
{
|
||||
try
|
||||
{
|
||||
// conditions must be a non empty array:
|
||||
if (!Array.isArray(this._conditions) || this._conditions.length === 0)
|
||||
{
|
||||
throw "conditions should be a non empty array of objects";
|
||||
}
|
||||
|
||||
// TODO this is temporary until we have implemented StairHandler:
|
||||
if (this._stairType === MultiStairHandler.StaircaseType.SIMPLE)
|
||||
{
|
||||
throw "'simple' staircases are currently not supported";
|
||||
}
|
||||
|
||||
for (const condition of this._conditions)
|
||||
{
|
||||
// each condition must be an object:
|
||||
if (typeof condition !== "object")
|
||||
{
|
||||
throw "one of the conditions is not an object";
|
||||
}
|
||||
|
||||
// each condition must include certain fields, such as startVal and label:
|
||||
if (!("startVal" in condition))
|
||||
{
|
||||
throw "each condition should include a startVal field";
|
||||
}
|
||||
if (!("label" in condition))
|
||||
{
|
||||
throw "each condition should include a label field";
|
||||
}
|
||||
|
||||
// for QUEST, we also need startValSd:
|
||||
if (this._stairType === MultiStairHandler.StaircaseType.QUEST && !("startValSd" in condition))
|
||||
{
|
||||
throw "QUEST conditions must include a startValSd field";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
throw {
|
||||
origin: "MultiStairHandler._validateConditions",
|
||||
context: "when validating the conditions",
|
||||
error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the staircases, according to the conditions.
|
||||
*
|
||||
* @name module:data.MultiStairHandler#_prepareStaircases
|
||||
* @function
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_prepareStaircases()
|
||||
{
|
||||
try
|
||||
{
|
||||
this._validateConditions();
|
||||
|
||||
this._staircases = [];
|
||||
|
||||
for (const condition of this._conditions)
|
||||
{
|
||||
let handler;
|
||||
|
||||
// QUEST handler:
|
||||
if (this._stairType === MultiStairHandler.StaircaseType.QUEST)
|
||||
{
|
||||
const args = Object.assign({}, condition);
|
||||
args.psychoJS = this._psychoJS;
|
||||
args.varName = this._varName;
|
||||
args.name = condition.label;
|
||||
args.autoLog = this._autoLog;
|
||||
if (typeof condition.nTrials === "undefined")
|
||||
{
|
||||
args.nTrials = this._nTrials;
|
||||
}
|
||||
|
||||
handler = new QuestHandler(args);
|
||||
}
|
||||
|
||||
// simple StairCase handler:
|
||||
if (this._stairType === MultiStairHandler.StaircaseType.SIMPLE)
|
||||
{
|
||||
// TODO not supported just yet, an exception is raised in _validateConditions
|
||||
continue;
|
||||
}
|
||||
|
||||
this._staircases.push(handler);
|
||||
}
|
||||
|
||||
this._currentPass = [];
|
||||
this._currentStaircase = null;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
throw {
|
||||
origin: "MultiStairHandler._prepareStaircases",
|
||||
context: "when preparing the staircases",
|
||||
error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move onto the next trial.
|
||||
*
|
||||
* @name module:data.MultiStairHandler#_nextTrial
|
||||
* @function
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_nextTrial()
|
||||
{
|
||||
try
|
||||
{
|
||||
// if the current pass is empty, get a new one:
|
||||
if (this._currentPass.length === 0)
|
||||
{
|
||||
this._currentPass = this._staircases.filter(handler => !handler.finished);
|
||||
|
||||
if (this._multiMethod === TrialHandler.Method.SEQUENTIAL)
|
||||
{
|
||||
// nothing to do
|
||||
}
|
||||
else if (this._multiMethod === TrialHandler.Method.RANDOM)
|
||||
{
|
||||
this._currentPass = util.shuffle(this._currentPass, this._randomNumberGenerator);
|
||||
}
|
||||
else if (this._multiMethod === TrialHandler.Method.FULL_RANDOM)
|
||||
{
|
||||
if (this._currentPass.length > 0)
|
||||
{
|
||||
// select a handler at random:
|
||||
const index = Math.floor(this._randomNumberGenerator() * this._currentPass.length);
|
||||
const handler = this._currentPass[index];
|
||||
this._currentPass = [handler];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// pick the next staircase in the pass:
|
||||
this._currentStaircase = this._currentPass.shift();
|
||||
|
||||
|
||||
// test for termination:
|
||||
if (typeof this._currentStaircase === "undefined")
|
||||
{
|
||||
this._finished = true;
|
||||
|
||||
// update the snapshots associated with the current trial in the trial list:
|
||||
for (let t = 0; t < this._snapshots.length - 1; ++t)
|
||||
{
|
||||
// the current trial is the last defined one:
|
||||
if (typeof this._trialList[t + 1] === "undefined")
|
||||
{
|
||||
this._snapshots[t].finished = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// get the value, based on the type of the trial handler:
|
||||
let value = Number.MIN_VALUE;
|
||||
if (this._currentStaircase instanceof QuestHandler)
|
||||
{
|
||||
value = this._currentStaircase.getQuestValue();
|
||||
}
|
||||
// TODO add a test for simple staircase:
|
||||
// if (this._currentStaircase instanceof StaircaseHandler)
|
||||
// {
|
||||
// value = this._currentStaircase.getStairValue();
|
||||
// }
|
||||
|
||||
|
||||
this._psychoJS.logger.debug(`selected staircase: ${this._currentStaircase.name}, estimated value for variable ${this._varName}: ${value}`);
|
||||
|
||||
|
||||
// update the next undefined trial in the trial list, and the associated snapshot:
|
||||
for (let t = 0; t < this._trialList.length; ++t)
|
||||
{
|
||||
if (typeof this._trialList[t] === "undefined")
|
||||
{
|
||||
this._trialList[t] = {[this._varName]: value};
|
||||
|
||||
if (typeof this._snapshots[t] !== "undefined")
|
||||
{
|
||||
this._snapshots[t][this._varName] = value;
|
||||
this._snapshots[t].trialAttributes.push(this._varName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
throw {
|
||||
origin: "MultiStairHandler._nextTrial",
|
||||
context: "when moving onto the next trial",
|
||||
error
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MultiStairHandler staircase type.
|
||||
*
|
||||
* @enum {Symbol}
|
||||
* @readonly
|
||||
* @public
|
||||
*/
|
||||
MultiStairHandler.StaircaseType = {
|
||||
/**
|
||||
* Simple staircase handler.
|
||||
*/
|
||||
SIMPLE: Symbol.for("SIMPLE"),
|
||||
|
||||
/**
|
||||
* QUEST handler.
|
||||
*/
|
||||
QUEST: Symbol.for("QUEST")
|
||||
};
|
||||
|
||||
/**
|
||||
* Staircase status.
|
||||
*
|
||||
* @enum {Symbol}
|
||||
* @readonly
|
||||
* @public
|
||||
*/
|
||||
MultiStairHandler.StaircaseStatus = {
|
||||
/**
|
||||
* The staircase is currently running.
|
||||
*/
|
||||
RUNNING: Symbol.for("RUNNING"),
|
||||
|
||||
/**
|
||||
* The staircase is now finished.
|
||||
*/
|
||||
FINISHED: Symbol.for("FINISHED")
|
||||
};
|
@ -9,7 +9,6 @@
|
||||
*/
|
||||
|
||||
|
||||
|
||||
import {TrialHandler} from "./TrialHandler.js";
|
||||
|
||||
/**
|
||||
@ -18,7 +17,7 @@ import {TrialHandler} from "./TrialHandler.js";
|
||||
*
|
||||
* @class module.data.QuestHandler
|
||||
* @extends TrialHandler
|
||||
* @param {Object} options
|
||||
* @param {Object} options - the handler options
|
||||
* @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
|
||||
* @param {string} options.varName - the name of the variable / intensity / contrast / threshold manipulated by QUEST
|
||||
* @param {number} options.startVal - initial guess for the threshold
|
||||
@ -43,23 +42,23 @@ export class QuestHandler extends TrialHandler
|
||||
* @public
|
||||
*/
|
||||
constructor({
|
||||
psychoJS,
|
||||
varName,
|
||||
startVal,
|
||||
startValSd,
|
||||
minVal,
|
||||
maxVal,
|
||||
pThreshold,
|
||||
nTrials,
|
||||
stopInterval,
|
||||
method,
|
||||
beta,
|
||||
delta,
|
||||
gamma,
|
||||
grain,
|
||||
name,
|
||||
autoLog
|
||||
} = {})
|
||||
psychoJS,
|
||||
varName,
|
||||
startVal,
|
||||
startValSd,
|
||||
minVal,
|
||||
maxVal,
|
||||
pThreshold,
|
||||
nTrials,
|
||||
stopInterval,
|
||||
method,
|
||||
beta,
|
||||
delta,
|
||||
gamma,
|
||||
grain,
|
||||
name,
|
||||
autoLog
|
||||
} = {})
|
||||
{
|
||||
super({
|
||||
psychoJS,
|
||||
@ -70,24 +69,46 @@ export class QuestHandler extends TrialHandler
|
||||
nReps: 1
|
||||
});
|
||||
|
||||
this._addAttribute('varName', varName);
|
||||
this._addAttribute('startVal', startVal);
|
||||
this._addAttribute('minVal', minVal, Number.MIN_VALUE);
|
||||
this._addAttribute('maxVal', maxVal, Number.MAX_VALUE);
|
||||
this._addAttribute('startValSd', startValSd);
|
||||
this._addAttribute('pThreshold', pThreshold, 0.82);
|
||||
this._addAttribute('nTrials', nTrials);
|
||||
this._addAttribute('stopInterval', stopInterval, Number.MIN_VALUE);
|
||||
this._addAttribute('beta', beta, 3.5);
|
||||
this._addAttribute('delta', delta, 0.01);
|
||||
this._addAttribute('gamma', gamma, 0.5);
|
||||
this._addAttribute('grain', grain, 0.01);
|
||||
this._addAttribute('method', method, QuestHandler.Method.QUANTILE);
|
||||
this._addAttribute("varName", varName);
|
||||
this._addAttribute("startVal", startVal);
|
||||
this._addAttribute("minVal", minVal, Number.MIN_VALUE);
|
||||
this._addAttribute("maxVal", maxVal, Number.MAX_VALUE);
|
||||
this._addAttribute("startValSd", startValSd);
|
||||
this._addAttribute("pThreshold", pThreshold, 0.82);
|
||||
this._addAttribute("nTrials", nTrials);
|
||||
this._addAttribute("stopInterval", stopInterval, Number.MIN_VALUE);
|
||||
this._addAttribute("beta", beta, 3.5);
|
||||
this._addAttribute("delta", delta, 0.01);
|
||||
this._addAttribute("gamma", gamma, 0.5);
|
||||
this._addAttribute("grain", grain, 0.01);
|
||||
this._addAttribute("method", method, QuestHandler.Method.QUANTILE);
|
||||
|
||||
// setup jsQuest:
|
||||
this._setupJsQuest();
|
||||
this._estimateQuestValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for the method attribute.
|
||||
*
|
||||
* @param {mixed} method - the method value, PsychoPy-style values ("mean", "median",
|
||||
* "quantile") are converted to their respective QuestHandler.Method values
|
||||
* @param {boolean} log - whether or not to log the change of seed
|
||||
*/
|
||||
setMethod(method, log)
|
||||
{
|
||||
let methodMapping = {
|
||||
"quantile": QuestHandler.Method.QUANTILE,
|
||||
"mean": QuestHandler.Method.MEAN,
|
||||
"mode": QuestHandler.Method.MODE
|
||||
};
|
||||
// If method is a key in methodMapping, convert method to corresponding value
|
||||
if (methodMapping.hasOwnProperty(method))
|
||||
{
|
||||
method = methodMapping[method];
|
||||
}
|
||||
this._setAttribute("method", method, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a response and update the PDF.
|
||||
@ -96,22 +117,31 @@ export class QuestHandler extends TrialHandler
|
||||
* @function
|
||||
* @public
|
||||
* @param{number} response - the response to the trial, must be either 0 (incorrect or
|
||||
* non-detected) or 1 (correct or detected).
|
||||
* non-detected) or 1 (correct or detected)
|
||||
* @param{number | undefined} [value] - optional intensity / contrast / threshold
|
||||
* @returns {void}
|
||||
*/
|
||||
addResponse(response)
|
||||
addResponse(response, value)
|
||||
{
|
||||
// check that response is either 0 or 1:
|
||||
if (response !== 0 && response !== 1)
|
||||
{
|
||||
throw {
|
||||
origin: 'QuestHandler.addResponse',
|
||||
context: 'when adding a trial response',
|
||||
origin: "QuestHandler.addResponse",
|
||||
context: "when adding a trial response",
|
||||
error: `the response must be either 0 or 1, got: ${JSON.stringify(response)}`
|
||||
};
|
||||
}
|
||||
|
||||
// update the QUEST pdf:
|
||||
this._jsQuest = jsQUEST.QuestUpdate(this._jsQuest, this._questValue, response);
|
||||
if (typeof value !== "undefined")
|
||||
{
|
||||
this._jsQuest = jsQUEST.QuestUpdate(this._jsQuest, value, response);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._jsQuest = jsQUEST.QuestUpdate(this._jsQuest, this._questValue, response);
|
||||
}
|
||||
|
||||
if (!this._finished)
|
||||
{
|
||||
@ -120,7 +150,6 @@ export class QuestHandler extends TrialHandler
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Simulate a response.
|
||||
*
|
||||
@ -128,6 +157,7 @@ export class QuestHandler extends TrialHandler
|
||||
* @function
|
||||
* @public
|
||||
* @param{number} trueValue - the true, known value of the threshold / contrast / intensity
|
||||
* @returns{number} the simulated response, 0 or 1
|
||||
*/
|
||||
simulate(trueValue)
|
||||
{
|
||||
@ -141,7 +171,6 @@ export class QuestHandler extends TrialHandler
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the mean of the Quest posterior PDF.
|
||||
*
|
||||
@ -155,7 +184,6 @@ export class QuestHandler extends TrialHandler
|
||||
return jsQUEST.QuestMean(this._jsQuest);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the standard deviation of the Quest posterior PDF.
|
||||
*
|
||||
@ -169,7 +197,6 @@ export class QuestHandler extends TrialHandler
|
||||
return jsQUEST.QuestSd(this._jsQuest);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the mode of the Quest posterior PDF.
|
||||
*
|
||||
@ -184,7 +211,6 @@ export class QuestHandler extends TrialHandler
|
||||
return mode;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the standard deviation of the Quest posterior PDF.
|
||||
*
|
||||
@ -199,6 +225,18 @@ export class QuestHandler extends TrialHandler
|
||||
return jsQUEST.QuestQuantile(this._jsQuest, quantileOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current value of the variable / contrast / threshold.
|
||||
*
|
||||
* @name module:data.QuestHandler#getQuestValue
|
||||
* @function
|
||||
* @public
|
||||
* @returns {number} the current QUEST value for the variable / contrast / threshold
|
||||
*/
|
||||
getQuestValue()
|
||||
{
|
||||
return this._questValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an estimate of the 5%-95% confidence interval (CI).
|
||||
@ -206,7 +244,8 @@ export class QuestHandler extends TrialHandler
|
||||
* @name module:data.QuestHandler#confInterval
|
||||
* @function
|
||||
* @public
|
||||
* @param{boolean} [getDifference=false] if true, return the width of the CI instead of the CI
|
||||
* @param{boolean} [getDifference=false] - if true, return the width of the CI instead of the CI
|
||||
* @returns{number[] | number} the 5%-95% CI or the width of the CI
|
||||
*/
|
||||
confInterval(getDifference = false)
|
||||
{
|
||||
@ -225,13 +264,13 @@ export class QuestHandler extends TrialHandler
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Setup the JS Quest object.
|
||||
*
|
||||
* @name module:data.QuestHandler#_setupJsQuest
|
||||
* @function
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_setupJsQuest()
|
||||
{
|
||||
@ -243,11 +282,8 @@ export class QuestHandler extends TrialHandler
|
||||
this._delta,
|
||||
this._gamma,
|
||||
this._grain);
|
||||
|
||||
this._estimateQuestValue();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Estimate the next value of the QUEST variable, based on the current value
|
||||
* and on the selected QUEST method.
|
||||
@ -255,6 +291,7 @@ export class QuestHandler extends TrialHandler
|
||||
* @name module:data.QuestHandler#_estimateQuestValue
|
||||
* @function
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_estimateQuestValue()
|
||||
{
|
||||
@ -275,8 +312,8 @@ export class QuestHandler extends TrialHandler
|
||||
else
|
||||
{
|
||||
throw {
|
||||
origin: 'QuestHandler._estimateQuestValue',
|
||||
context: 'when estimating the next value of the QUEST variable',
|
||||
origin: "QuestHandler._estimateQuestValue",
|
||||
context: "when estimating the next value of the QUEST variable",
|
||||
error: `unknown method: ${this._method}, please use: mean, mode, or quantile`
|
||||
};
|
||||
}
|
||||
@ -284,16 +321,15 @@ export class QuestHandler extends TrialHandler
|
||||
this._psychoJS.logger.debug(`estimated value for QUEST variable ${this._varName}: ${this._questValue}`);
|
||||
|
||||
// check whether we should finish the trial:
|
||||
if (this.thisN > 0 &&
|
||||
(this.nRemaining === 0 || this.confInterval(true) < this._stopInterval))
|
||||
if (this.thisN > 0 && (this.nRemaining === 0 || this.confInterval(true) < this._stopInterval))
|
||||
{
|
||||
this._finished = true;
|
||||
|
||||
// update the snapshots associated with the current trial in the trial list:
|
||||
for (let t = 0; t < this._trialList.length-1; ++t)
|
||||
for (let t = 0; t < this._snapshots.length - 1; ++t)
|
||||
{
|
||||
// the current trial is the last defined one:
|
||||
if (typeof this._trialList[t+1] === 'undefined')
|
||||
if (typeof this._trialList[t + 1] === "undefined")
|
||||
{
|
||||
this._snapshots[t].finished = true;
|
||||
break;
|
||||
@ -306,11 +342,11 @@ export class QuestHandler extends TrialHandler
|
||||
// update the next undefined trial in the trial list, and the associated snapshot:
|
||||
for (let t = 0; t < this._trialList.length; ++t)
|
||||
{
|
||||
if (typeof this._trialList[t] === 'undefined')
|
||||
if (typeof this._trialList[t] === "undefined")
|
||||
{
|
||||
this._trialList[t] = { [this._varName]: this._questValue };
|
||||
|
||||
if (typeof this._snapshots[t] !== 'undefined')
|
||||
if (typeof this._snapshots[t] !== "undefined")
|
||||
{
|
||||
this._snapshots[t][this._varName] = this._questValue;
|
||||
this._snapshots[t].trialAttributes.push(this._varName);
|
||||
@ -319,10 +355,8 @@ export class QuestHandler extends TrialHandler
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* QuestHandler method
|
||||
*
|
||||
@ -334,15 +368,15 @@ QuestHandler.Method = {
|
||||
/**
|
||||
* Quantile threshold estimate.
|
||||
*/
|
||||
QUANTILE: Symbol.for('QUANTILE'),
|
||||
QUANTILE: Symbol.for("QUANTILE"),
|
||||
|
||||
/**
|
||||
* Mean threshold estimate.
|
||||
*/
|
||||
MEAN: Symbol.for('MEAN'),
|
||||
MEAN: Symbol.for("MEAN"),
|
||||
|
||||
/**
|
||||
* Mode threshold estimate.
|
||||
*/
|
||||
MODE: Symbol.for('MODE')
|
||||
MODE: Symbol.for("MODE")
|
||||
};
|
||||
|
@ -19,7 +19,7 @@ import * as util from "../util/Util.js";
|
||||
*
|
||||
* @class
|
||||
* @extends PsychObject
|
||||
* @param {Object} options
|
||||
* @param {Object} options - the handler options
|
||||
* @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
|
||||
* @param {Array.<Object> | String} [options.trialList= [undefined] ] - if it is a string, we treat it as the name of a condition resource
|
||||
* @param {number} options.nReps - number of repetitions
|
||||
@ -80,7 +80,7 @@ export class TrialHandler extends PsychObject
|
||||
this._addAttribute("name", name);
|
||||
this._addAttribute("autoLog", autoLog);
|
||||
this._addAttribute("seed", seed);
|
||||
this._prepareTrialList(trialList);
|
||||
this._prepareTrialList();
|
||||
|
||||
// number of stimuli
|
||||
this.nStim = this.trialList.length;
|
||||
@ -520,7 +520,7 @@ export class TrialHandler extends PsychObject
|
||||
{
|
||||
try
|
||||
{
|
||||
let resourceExtension = resourceName.split(".").pop();
|
||||
const resourceExtension = resourceName.split(".").pop();
|
||||
if (["csv", "odp", "xls", "xlsx"].indexOf(resourceExtension) > -1)
|
||||
{
|
||||
// (*) read conditions from resource:
|
||||
@ -551,9 +551,9 @@ export class TrialHandler extends PsychObject
|
||||
|
||||
// (*) return the selected conditions as an array of 'object as map':
|
||||
// [
|
||||
// {field0: value0-0, field1: value0-1, ...}
|
||||
// {field0: value1-0, field1: value1-1, ...}
|
||||
// ...
|
||||
// {field0: value0-0, field1: value0-1, ...}
|
||||
// {field0: value1-0, field1: value1-1, ...}
|
||||
// ...
|
||||
// ]
|
||||
let trialList = new Array(selectedRows.length - 1);
|
||||
for (let r = 0; r < selectedRows.length; ++r)
|
||||
@ -617,10 +617,11 @@ export class TrialHandler extends PsychObject
|
||||
/**
|
||||
* Prepare the trial list.
|
||||
*
|
||||
* @function
|
||||
* @protected
|
||||
* @param {Array.<Object> | String} trialList - a list of trials, or the name of a condition resource
|
||||
* @returns {void}
|
||||
*/
|
||||
_prepareTrialList(trialList)
|
||||
_prepareTrialList()
|
||||
{
|
||||
const response = {
|
||||
origin: "TrialHandler._prepareTrialList",
|
||||
@ -628,28 +629,28 @@ export class TrialHandler extends PsychObject
|
||||
};
|
||||
|
||||
// we treat undefined trialList as a list with a single empty entry:
|
||||
if (typeof trialList === "undefined")
|
||||
if (typeof this._trialList === "undefined")
|
||||
{
|
||||
this.trialList = [undefined];
|
||||
}
|
||||
// if trialList is an array, we make sure it is not empty:
|
||||
else if (Array.isArray(trialList))
|
||||
else if (Array.isArray(this._trialList))
|
||||
{
|
||||
if (trialList.length === 0)
|
||||
if (this._trialList.length === 0)
|
||||
{
|
||||
this.trialList = [undefined];
|
||||
}
|
||||
}
|
||||
// if trialList is a string, we treat it as the name of the condition resource:
|
||||
else if (typeof trialList === "string")
|
||||
else if (typeof this._trialList === "string")
|
||||
{
|
||||
this.trialList = TrialHandler.importConditions(this.psychoJS.serverManager, trialList);
|
||||
this.trialList = TrialHandler.importConditions(this.psychoJS.serverManager, this._trialList);
|
||||
}
|
||||
// unknown type:
|
||||
else
|
||||
{
|
||||
throw Object.assign(response, {
|
||||
error: "unable to prepare trial list: unknown type: " + (typeof trialList),
|
||||
error: `unable to prepare trial list: unknown type: ${(typeof this._trialList)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -690,13 +691,13 @@ export class TrialHandler extends PsychObject
|
||||
// get an array of the indices of the elements of trialList :
|
||||
const indices = Array.from(this.trialList.keys());
|
||||
|
||||
if (this.method === TrialHandler.Method.SEQUENTIAL)
|
||||
if (this._method === TrialHandler.Method.SEQUENTIAL)
|
||||
{
|
||||
this._trialSequence = Array(this.nReps).fill(indices);
|
||||
// transposed version:
|
||||
// this._trialSequence = indices.reduce( (seq, e) => { seq.push( Array(this.nReps).fill(e) ); return seq; }, [] );
|
||||
}
|
||||
else if (this.method === TrialHandler.Method.RANDOM)
|
||||
else if (this._method === TrialHandler.Method.RANDOM)
|
||||
{
|
||||
this._trialSequence = [];
|
||||
for (let i = 0; i < this.nReps; ++i)
|
||||
@ -704,10 +705,10 @@ export class TrialHandler extends PsychObject
|
||||
this._trialSequence.push(util.shuffle(indices.slice(), this._randomNumberGenerator));
|
||||
}
|
||||
}
|
||||
else if (this.method === TrialHandler.Method.FULL_RANDOM)
|
||||
else if (this._method === TrialHandler.Method.FULL_RANDOM)
|
||||
{
|
||||
// create a flat sequence with nReps repeats of indices:
|
||||
let flatSequence = [];
|
||||
const flatSequence = [];
|
||||
for (let i = 0; i < this.nReps; ++i)
|
||||
{
|
||||
flatSequence.push.apply(flatSequence, indices);
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from './ExperimentHandler.js';
|
||||
export * from './TrialHandler.js';
|
||||
export * from './QuestHandler';
|
||||
//export * from './Shelf.js';
|
||||
export * from "./ExperimentHandler.js";
|
||||
export * from "./TrialHandler.js";
|
||||
export * from "./QuestHandler.js";
|
||||
export * from "./MultiStairHandler.js";
|
||||
//export * from "./Shelf.js";
|
||||
|
@ -157,7 +157,10 @@ export class AudioClip extends PsychObject
|
||||
}
|
||||
|
||||
// upload the data:
|
||||
return this._psychoJS.serverManager.uploadAudioVideo(this._data, filename);
|
||||
return this._psychoJS.serverManager.uploadAudioVideo({
|
||||
mediaBlob: this._data,
|
||||
tag: filename
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -322,7 +322,10 @@ export class Microphone extends PsychObject
|
||||
|
||||
// upload the blob:
|
||||
const audioBlob = new Blob(this._audioBuffer);
|
||||
return this._psychoJS.serverManager.uploadAudioVideo(audioBlob, tag);
|
||||
return this._psychoJS.serverManager.uploadAudioVideo({
|
||||
mediaBlob: audioBlob,
|
||||
tag
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -21,8 +21,12 @@ import {ExperimentHandler} from "../data/ExperimentHandler.js";
|
||||
* @name module:visual.Camera
|
||||
* @class
|
||||
* @param {Object} options
|
||||
* @param @param {module:core.Window} options.win - the associated Window
|
||||
* @param {module:core.Window} options.win - the associated Window
|
||||
* @param {string} [options.format='video/webm;codecs=vp9'] the video format
|
||||
* @param {boolean} [options.showDialog=false] - whether or not to open a dialog box to inform the
|
||||
* participant to wait for the camera to be initialised
|
||||
* @param {string} [options.dialogMsg="Please wait a few moments while the camera initialises"] -
|
||||
* default message informing the participant to wait for the camera to initialise
|
||||
* @param {Clock} [options.clock= undefined] - an optional clock
|
||||
* @param {boolean} [options.autoLog= false] - whether or not to log
|
||||
*
|
||||
@ -34,7 +38,7 @@ export class Camera extends PsychObject
|
||||
* @constructor
|
||||
* @public
|
||||
*/
|
||||
constructor({win, name, format, clock, autoLog} = {})
|
||||
constructor({win, name, format, showDialog, dialogMsg = "Please wait a few moments while the camera initialises", clock, autoLog} = {})
|
||||
{
|
||||
super(win._psychoJS);
|
||||
|
||||
@ -45,8 +49,23 @@ export class Camera extends PsychObject
|
||||
this._addAttribute("autoLog", autoLog, false);
|
||||
this._addAttribute("status", PsychoJS.Status.NOT_STARTED);
|
||||
|
||||
// open pop-up dialog:
|
||||
if (showDialog)
|
||||
{
|
||||
this.psychoJS.gui.dialog({
|
||||
warning: dialogMsg,
|
||||
showOK: false,
|
||||
});
|
||||
}
|
||||
|
||||
// prepare the recording:
|
||||
this._prepareRecording();
|
||||
this._prepareRecording().then( () =>
|
||||
{
|
||||
if (showDialog)
|
||||
{
|
||||
this.psychoJS.gui.closeDialog();
|
||||
}
|
||||
})
|
||||
|
||||
if (this._autoLog)
|
||||
{
|
||||
@ -54,6 +73,19 @@ export class Camera extends PsychObject
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query whether or not the camera is ready to record.
|
||||
*
|
||||
* @name module:visual.Camera#isReady
|
||||
* @function
|
||||
* @public
|
||||
* @returns {boolean} whether or not the camera is ready to record
|
||||
*/
|
||||
isReady()
|
||||
{
|
||||
return (this._recorder !== null);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the underlying video stream.
|
||||
@ -369,9 +401,14 @@ export class Camera extends PsychObject
|
||||
* @name module:visual.Camera#upload
|
||||
* @function
|
||||
* @public
|
||||
* @param {string} tag an optional tag for the audio file
|
||||
* @param @param {Object} options
|
||||
* @param {string} options.tag an optional tag for the video file
|
||||
* @param {boolean} [options.waitForCompletion= false] whether or not to wait for completion
|
||||
* before returning
|
||||
* @param {boolean} [options.showDialog=false] - whether or not to open a dialog box to inform the participant to wait for the data to be uploaded to the server
|
||||
* @param {string} [options.dialogMsg=""] - default message informing the participant to wait for the data to be uploaded to the server
|
||||
*/
|
||||
async upload({tag} = {})
|
||||
async upload({tag, waitForCompletion = false, showDialog = false, dialogMsg = ""} = {})
|
||||
{
|
||||
// default tag: the name of this Camera object
|
||||
if (typeof tag === "undefined")
|
||||
@ -394,7 +431,12 @@ export class Camera extends PsychObject
|
||||
|
||||
// upload the blob:
|
||||
const videoBlob = new Blob(this._videoBuffer);
|
||||
return this._psychoJS.serverManager.uploadAudioVideo(videoBlob, tag);
|
||||
return this._psychoJS.serverManager.uploadAudioVideo({
|
||||
mediaBlob: videoBlob,
|
||||
tag,
|
||||
waitForCompletion,
|
||||
showDialog,
|
||||
dialogMsg});
|
||||
}
|
||||
|
||||
|
||||
|
@ -65,6 +65,19 @@ export class FaceDetector extends VisualStim
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query whether or not the face detector is ready to detect.
|
||||
*
|
||||
* @name module:visual.FaceDetector#isReady
|
||||
* @function
|
||||
* @public
|
||||
* @returns {boolean} whether or not the face detector is ready to detect
|
||||
*/
|
||||
isReady()
|
||||
{
|
||||
return this._modelsLoaded;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Setter for the video attribute.
|
||||
@ -207,7 +220,8 @@ export class FaceDetector extends VisualStim
|
||||
* @protected
|
||||
*/
|
||||
async _initFaceApi()
|
||||
{/*
|
||||
{
|
||||
/*
|
||||
// load the library:
|
||||
await this._psychoJS.serverManager.prepareResources([
|
||||
{
|
||||
@ -215,13 +229,16 @@ export class FaceDetector extends VisualStim
|
||||
"path": this.faceApiUrl,
|
||||
"download": true
|
||||
}
|
||||
]);*/
|
||||
]);
|
||||
*/
|
||||
|
||||
// load the models:
|
||||
faceapi.nets.tinyFaceDetector.loadFromUri(this._modelDir);
|
||||
faceapi.nets.faceLandmark68Net.loadFromUri(this._modelDir);
|
||||
faceapi.nets.faceRecognitionNet.loadFromUri(this._modelDir);
|
||||
faceapi.nets.faceExpressionNet.loadFromUri(this._modelDir);
|
||||
this._modelsLoaded = false;
|
||||
await faceapi.nets.tinyFaceDetector.loadFromUri(this._modelDir);
|
||||
await faceapi.nets.faceLandmark68Net.loadFromUri(this._modelDir);
|
||||
await faceapi.nets.faceRecognitionNet.loadFromUri(this._modelDir);
|
||||
await faceapi.nets.faceExpressionNet.loadFromUri(this._modelDir);
|
||||
this._modelsLoaded = true;
|
||||
}
|
||||
|
||||
|
||||
|
@ -289,6 +289,19 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query whether or not the marker is currently being dragged.
|
||||
*
|
||||
* @name module:visual.Slider#isMarkerDragging
|
||||
* @function
|
||||
* @public
|
||||
* @returns {boolean} whether or not the marker is being dragged
|
||||
*/
|
||||
isMarkerDragging()
|
||||
{
|
||||
return this._markerDragging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current value of the rating.
|
||||
*
|
||||
@ -593,6 +606,9 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin)
|
||||
*/
|
||||
_sanitizeAttributes()
|
||||
{
|
||||
this._isSliderStyle = false;
|
||||
this._frozenMarker = false;
|
||||
|
||||
// convert potential string styles into Symbols:
|
||||
this._style.forEach((style, index) =>
|
||||
{
|
||||
@ -602,7 +618,51 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin)
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: only two ticks for SLIDER type, non-empty ticks, that RADIO is also categorical, etc.
|
||||
// TODO: non-empty ticks, RADIO is also categorical, etc.
|
||||
|
||||
// SLIDER style: two ticks, first one is zero, second one is > 1
|
||||
if (this._style.indexOf(Slider.Style.SLIDER) > -1)
|
||||
{
|
||||
this._isSliderStyle = true;
|
||||
|
||||
// more than 2 ticks: cut to two
|
||||
if (this._ticks.length > 2)
|
||||
{
|
||||
this.psychoJS.logger.warn(`Slider "${this._name}" has style: SLIDER and more than two ticks. We cut the ticks to 2.`);
|
||||
this._ticks = this._ticks.slice(0, 2);
|
||||
}
|
||||
|
||||
// less than 2 ticks: error
|
||||
if (this._ticks.length < 2)
|
||||
{
|
||||
throw {
|
||||
origin: "Slider._sanitizeAttributes",
|
||||
context: "when sanitizing the attributes of Slider: " + this._name,
|
||||
error: "less than 2 ticks were given for a slider of type: SLIDER"
|
||||
}
|
||||
}
|
||||
|
||||
// first tick different from zero: change it to zero
|
||||
if (this._ticks[0] !== 0)
|
||||
{
|
||||
this.psychoJS.logger.warn(`Slider "${this._name}" has style: SLIDER but the first tick is not 0. We changed it to 0.`);
|
||||
this._ticks[0] = 0;
|
||||
}
|
||||
|
||||
// second tick smaller than 1: change it to 1
|
||||
if (this._ticks[1] < 1)
|
||||
{
|
||||
this.psychoJS.logger.warn(`Slider "${this._name}" has style: SLIDER but the second tick is less than 1. We changed it to 1.`);
|
||||
this._ticks[1] = 1;
|
||||
}
|
||||
|
||||
// second tick is 1: the marker is frozen
|
||||
if (this._ticks[1] === 1)
|
||||
{
|
||||
this._frozenMarker = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// deal with categorical sliders:
|
||||
this._isCategorical = (this._ticks.length === 0);
|
||||
@ -911,7 +971,7 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin)
|
||||
}
|
||||
else if (this._markerType === Slider.Shape.BOX)
|
||||
{
|
||||
this._marker.lineStyle(1, this.getContrastedColor(this._markerColor, 0.5).int, 1, 0.5);
|
||||
this._marker.lineStyle(1, this.getContrastedColor(this._markerColor, 0.5).int, 1, 0);
|
||||
this._marker.beginFill(this._markerColor.int, 1);
|
||||
this._marker.drawRect(
|
||||
Math.round(-this._markerSize_px[0] / 2),
|
||||
@ -954,9 +1014,12 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin)
|
||||
{
|
||||
self._markerDragging = false;
|
||||
|
||||
const mouseLocalPos_px = event.data.getLocalPosition(self._pixi);
|
||||
const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]);
|
||||
self.recordRating(rating);
|
||||
if (!this._frozenMarker)
|
||||
{
|
||||
const mouseLocalPos_px = event.data.getLocalPosition(self._pixi);
|
||||
const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]);
|
||||
self.recordRating(rating);
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
@ -967,12 +1030,15 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin)
|
||||
{
|
||||
if (self._markerDragging)
|
||||
{
|
||||
const mouseLocalPos_px = event.data.getLocalPosition(self._pixi);
|
||||
const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]);
|
||||
self.recordRating(rating);
|
||||
|
||||
self._markerDragging = false;
|
||||
|
||||
if (!this._frozenMarker)
|
||||
{
|
||||
const mouseLocalPos_px = event.data.getLocalPosition(self._pixi);
|
||||
const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]);
|
||||
self.recordRating(rating);
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
@ -982,9 +1048,12 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin)
|
||||
{
|
||||
if (self._markerDragging)
|
||||
{
|
||||
const mouseLocalPos_px = event.data.getLocalPosition(self._pixi);
|
||||
const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]);
|
||||
self.setMarkerPos(rating);
|
||||
if (!this._frozenMarker)
|
||||
{
|
||||
const mouseLocalPos_px = event.data.getLocalPosition(self._pixi);
|
||||
const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]);
|
||||
self.setMarkerPos(rating);
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
@ -1015,12 +1084,37 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin)
|
||||
|
||||
this._pixi.pointerup = this._pixi.mouseup = this._pixi.touchend = (event) =>
|
||||
{
|
||||
const mouseLocalPos_px = event.data.getLocalPosition(self._body);
|
||||
const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]);
|
||||
self.recordRating(rating);
|
||||
if (!this._frozenMarker)
|
||||
{
|
||||
const mouseLocalPos_px = event.data.getLocalPosition(self._body);
|
||||
const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]);
|
||||
self.recordRating(rating);
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// mouse wheel over slider:
|
||||
if (this._isSliderStyle)
|
||||
{
|
||||
self._pointerIsOver = false;
|
||||
|
||||
this._pixi.pointerover = this._pixi.mouseover = (event) =>
|
||||
{
|
||||
self._pointerIsOver = true;
|
||||
event.stopPropagation();
|
||||
}
|
||||
this._pixi.pointerout = this._pixi.mouseout = (event) =>
|
||||
{
|
||||
self._pointerIsOver = false;
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
/*renderer.view.addEventListener("wheel", (event) =>
|
||||
{
|
||||
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1082,7 +1176,7 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin)
|
||||
/**
|
||||
* Setup the labels.
|
||||
*
|
||||
* @name module:visual.Slider#_setupTicks
|
||||
* @name module:visual.Slider#_setupLabels
|
||||
* @function
|
||||
* @private
|
||||
*/
|
||||
@ -1311,7 +1405,14 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin)
|
||||
{
|
||||
if (this._style.indexOf(Slider.Style.SLIDER) > -1)
|
||||
{
|
||||
return (pos_px[1] / (size_px[1] - markerSize_px[1]) + 0.5) * range + this._ticks[0];
|
||||
if (size_px[1] === markerSize_px[1])
|
||||
{
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
return (pos_px[1] / (size_px[1] - markerSize_px[1]) + 0.5) * range + this._ticks[0];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -151,19 +151,17 @@ export class TextStim extends util.mix(VisualStim).with(ColorMixin)
|
||||
false,
|
||||
onChange(true, true, true),
|
||||
);
|
||||
|
||||
// color:
|
||||
this._addAttribute(
|
||||
"color",
|
||||
color,
|
||||
"white",
|
||||
this._onChange(true, false),
|
||||
"white"
|
||||
// this._onChange(true, false)
|
||||
);
|
||||
this._addAttribute(
|
||||
"contrast",
|
||||
contrast,
|
||||
1.0,
|
||||
this._onChange(true, false),
|
||||
this._onChange(true, false)
|
||||
);
|
||||
|
||||
// estimate the bounding box (using TextMetrics):
|
||||
@ -178,8 +176,8 @@ export class TextStim extends util.mix(VisualStim).with(ColorMixin)
|
||||
/**
|
||||
* Get the metrics estimated for the text and style.
|
||||
*
|
||||
* Note: getTextMetrics does not require the PIXI representation of the stimulus to be instantiated,
|
||||
* unlike getSize().
|
||||
* Note: getTextMetrics does not require the PIXI representation of the stimulus
|
||||
* to be instantiated, unlike getSize().
|
||||
*
|
||||
* @name module:visual.TextStim#getTextMetrics
|
||||
* @public
|
||||
@ -189,6 +187,19 @@ export class TextStim extends util.mix(VisualStim).with(ColorMixin)
|
||||
if (typeof this._textMetrics === "undefined")
|
||||
{
|
||||
this._textMetrics = PIXI.TextMetrics.measureText(this._text, this._getTextStyle());
|
||||
|
||||
// since PIXI.TextMetrics does not give us the actual bounding box of the text
|
||||
// (e.g. the height is really just the ascent + descent of the font), we use measureText:
|
||||
const textMetricsCanvas = document.createElement('canvas');
|
||||
document.body.appendChild(textMetricsCanvas);
|
||||
|
||||
const ctx = textMetricsCanvas.getContext("2d");
|
||||
ctx.font = this._getTextStyle().toFontString();
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.textAlign = "left";
|
||||
this._textMetrics.boundingBox = ctx.measureText(this._text);
|
||||
|
||||
document.body.removeChild(textMetricsCanvas);
|
||||
}
|
||||
|
||||
return this._textMetrics;
|
||||
@ -240,6 +251,72 @@ export class TextStim extends util.mix(VisualStim).with(ColorMixin)
|
||||
return wrapWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bounding gox.
|
||||
*
|
||||
* @name module:visual.TextStim#getBoundingBox
|
||||
* @function
|
||||
* @protected
|
||||
* @param {boolean} [tight= false] - whether or not to fit as closely as possible to the text
|
||||
* @return {number[]} - the bounding box, in the units of this TextStim
|
||||
*/
|
||||
getBoundingBox(tight = false)
|
||||
{
|
||||
if (tight)
|
||||
{
|
||||
const textMetrics_px = this.getTextMetrics();
|
||||
let left_px = this._pos[0] - textMetrics_px.boundingBox.actualBoundingBoxLeft;
|
||||
let top_px = this._pos[1] + textMetrics_px.fontProperties.descent - textMetrics_px.boundingBox.actualBoundingBoxDescent;
|
||||
const width_px = textMetrics_px.boundingBox.actualBoundingBoxRight + textMetrics_px.boundingBox.actualBoundingBoxLeft;
|
||||
const height_px = textMetrics_px.boundingBox.actualBoundingBoxAscent + textMetrics_px.boundingBox.actualBoundingBoxDescent;
|
||||
|
||||
// adjust the bounding box position by taking into account the anchoring of the text:
|
||||
const boundingBox_px = this._getBoundingBox_px();
|
||||
switch (this._alignHoriz)
|
||||
{
|
||||
case "left":
|
||||
// nothing to do
|
||||
break;
|
||||
case "right":
|
||||
// TODO
|
||||
break;
|
||||
default:
|
||||
case "center":
|
||||
left_px -= (boundingBox_px.width - width_px) / 2;
|
||||
}
|
||||
switch (this._alignVert)
|
||||
{
|
||||
case "top":
|
||||
// TODO
|
||||
break;
|
||||
case "bottom":
|
||||
// nothing to do
|
||||
break;
|
||||
default:
|
||||
case "center":
|
||||
top_px -= (boundingBox_px.height - height_px) / 2;
|
||||
}
|
||||
|
||||
// convert from pixel to this stimulus' units:
|
||||
const leftTop = util.to_unit(
|
||||
[left_px, top_px],
|
||||
"pix",
|
||||
this._win,
|
||||
this._units);
|
||||
const dimensions = util.to_unit(
|
||||
[width_px, height_px],
|
||||
"pix",
|
||||
this._win,
|
||||
this._units);
|
||||
|
||||
return new PIXI.Rectangle(leftTop[0], leftTop[1], dimensions[0], dimensions[1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
return this._boundingBox.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the bounding box.
|
||||
*
|
||||
@ -263,7 +340,7 @@ export class TextStim extends util.mix(VisualStim).with(ColorMixin)
|
||||
const anchor = this._getAnchor();
|
||||
this._boundingBox = new PIXI.Rectangle(
|
||||
this._pos[0] - anchor[0] * textSize[0],
|
||||
this._pos[1] - anchor[1] * textSize[1],
|
||||
this._pos[1] - textSize[1] - anchor[1] * textSize[1],
|
||||
textSize[0],
|
||||
textSize[1],
|
||||
);
|
||||
@ -291,6 +368,28 @@ export class TextStim extends util.mix(VisualStim).with(ColorMixin)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for the color attribute.
|
||||
*
|
||||
* @name module:visual.TextStim#setColor
|
||||
* @public
|
||||
* @param {undefined | null | number} color - the color
|
||||
* @param {boolean} [log= false] - whether of not to log
|
||||
*/
|
||||
setColor(color, log = false)
|
||||
{
|
||||
const hasChanged = this._setAttribute("color", color, log);
|
||||
|
||||
if (hasChanged)
|
||||
{
|
||||
if (typeof this._pixi !== "undefined")
|
||||
{
|
||||
this._pixi.style = this._getTextStyle();
|
||||
this._needUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stimulus, if necessary.
|
||||
*
|
||||
@ -316,6 +415,8 @@ export class TextStim extends util.mix(VisualStim).with(ColorMixin)
|
||||
this._pixi.destroy(true);
|
||||
}
|
||||
this._pixi = new PIXI.Text(this._text, this._getTextStyle());
|
||||
// TODO is updateText necessary?
|
||||
// this._pixi.updateText();
|
||||
}
|
||||
|
||||
const anchor = this._getAnchor();
|
||||
@ -333,16 +434,18 @@ export class TextStim extends util.mix(VisualStim).with(ColorMixin)
|
||||
// apply the clip mask:
|
||||
this._pixi.mask = this._clipMask;
|
||||
|
||||
// update the size attributes:
|
||||
this._size = [
|
||||
this._getLengthUnits(Math.abs(this._pixi.width)),
|
||||
this._getLengthUnits(Math.abs(this._pixi.height)),
|
||||
];
|
||||
// update the size attribute:
|
||||
this._size = util.to_unit(
|
||||
[Math.abs(this._pixi.width), Math.abs(this._pixi.height)],
|
||||
"pix",
|
||||
this._win,
|
||||
this._units
|
||||
);
|
||||
|
||||
// refine the estimate of the bounding box:
|
||||
this._boundingBox = new PIXI.Rectangle(
|
||||
this._pos[0] - anchor[0] * this._size[0],
|
||||
this._pos[1] - anchor[1] * this._size[1],
|
||||
this._pos[1] - this._size[1] - anchor[1] * this._size[1],
|
||||
this._size[0],
|
||||
this._size[1],
|
||||
);
|
||||
|
@ -258,14 +258,17 @@ export class VisualStim extends util.mix(MinimalStim).with(WindowMixin)
|
||||
|
||||
/**
|
||||
* Generate a callback that prepares updates to the stimulus.
|
||||
* This is typically called in the constructor of a stimulus, when attributes are added with _addAttribute.
|
||||
* This is typically called in the constructor of a stimulus, when attributes are added
|
||||
* with _addAttribute.
|
||||
*
|
||||
* @name module:visual.VisualStim#_onChange
|
||||
* @function
|
||||
* @param {boolean} [withPixi = false] - whether or not the PIXI representation must also be updated
|
||||
* @param {boolean} [withBoundingBox = false] - whether or not to immediately estimate the bounding box
|
||||
* @return {Function}
|
||||
* @protected
|
||||
* @param {boolean} [withPixi = false] - whether or not the PIXI representation must
|
||||
* also be updated
|
||||
* @param {boolean} [withBoundingBox = false] - whether or not to immediately estimate
|
||||
* the bounding box
|
||||
* @return {Function}
|
||||
*/
|
||||
_onChange(withPixi = false, withBoundingBox = false)
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user