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

Merge pull request #490 from apitiot/2022.2.0

2022.2.0
This commit is contained in:
Alain Pitiot 2022-04-12 09:21:55 +02:00 committed by GitHub
commit df3f509402
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1508 additions and 395 deletions

View File

@ -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,

View File

@ -12,7 +12,7 @@ PsychoJS is an open-source project. You can contribute by submitting pull reques
## Motivation
Many studies in behavioural sciences (e.g., psychology, neuroscience, linguistics or mental health) use computers to present stimuli and record responses in a precise manner. These studies are still typically conducted on small numbers of people in laboratory environments equipped with dedicated hardware.
Many studies in behavioural sciences (e.g. psychology, neuroscience, linguistics or mental health) use computers to present stimuli and record responses in a precise manner. These studies are still typically conducted on small numbers of people in laboratory environments equipped with dedicated hardware.
With high-speed broadband, improved web technologies and smart devices everywhere, studies can now go online without sacrificing too much temporal precision. This is a "game changer". Data can be collected on larger, more varied, international populations. We can study people in environments they do not find intimidating. Experiments can be run multiple times per day, without data collection becoming impractical.
@ -27,11 +27,10 @@ Running PsychoPy experiments online requires the generation of an index.html fil
The recommended approach to creating experiments is to use [PsychoPy Builder](http://www.psychopy.org/builder/builder.html) to generate the javascript and html files. Many of the existing Builder experiments should "just work", subject to the [Components being compatible between PsychoPy and PsychoJS](https://www.psychopy.org/online/status.html).
### JavaScript Code
We built the PsychoJS library to make the JavaScript experiment files look and behave in very much the same way as to the Builder-generated Python files. PsychoJS offers classes such as `Window` and `ImageStim`, with very similar attributes to their Python equivalents. Experiment designers familiar with the PsychoPy library should feel at home with PsychoJS, and can expect the same level of control they have with PsychoPy, from the structure of the trials/loops all the way down to frame-by-frame updates.
We built the PsychoJS library to make the JavaScript experiment files look and behave in very much the same way as the Builder-generated Python files. PsychoJS offers classes such as `Window` and `ImageStim`, with very similar attributes to their Python equivalents. Experiment designers familiar with the PsychoPy library should feel at home with PsychoJS, and can expect the same level of control they have with PsychoPy, from the structure of the trials/loops all the way down to frame-by-frame updates.
There are however notable differences between the PsychoJS and PsychoPy libraries, most of which have to do with the way a web browser interprets and runs JavaScript, deals with resources (such as images, sound or videos), or render stimuli. To manage those web-specific aspect, PsychoJS introduces the concept of Scheduler. As the name indicate, Scheduler's offer a way to organise various PsychoJS along a timeline, such as downloading resources, running a loop, checking for keyboard input, saving experiment results, etc. As an illustration, a Flow in PsychoPy can be conceptualised as a Schedule, with various tasks on it. Some of those tasks, such as trial loops, can also schedule further events (i.e. the individual trials to be run).
Under the hood PsychoJS relies on [PixiJS](http://www.pixijs.com) to present stimuli and collect responses. PixiJS is a multi-platform, accelerated, 2D renderer, that runs in most modern browsers. It uses WebGL wherever possible and silently falls back to HTML5 canvas where not. WebGL directly addresses the graphic card, thereby considerably improving the rendering performance.
There are however notable differences between the PsychoJS and PsychoPy libraries, most of which having to do with the way a web browser interprets and runs JavaScript, deals with resources (such as images, sound or videos), or render stimuli. To manage those web-specific aspect, PsychoJS introduces the concept of Scheduler. As the name indicate, Scheduler's offer a way to organise various tasks along a timeline, such as downloading resources, running a loop, checking for keyboard input, saving experiment results, etc. As an illustration, a Flow in PsychoPy can be conceptualised as a Schedule, with various tasks on it. Some of those tasks, such as trial loops, can also schedule further events (i.e. the individual trials to be run).
taskshe hood PsychoJS relies on [PixiJS](http://www.pixijs.com) to present stimuli and collect responses. PixiJS is a high performance, multi-platform 2D renderer, that runs in most modern browsers. It uses WebGL wherever possible and silently falls back to HTML5 canvas where not. WebGL directly addresses the graphic card, thereby considerably improving the rendering performance.
### Hosting Experiments
@ -39,11 +38,11 @@ A convenient way to make experiment available to participants is to host them on
## Which PsychoPy Components are supported by PsychoJS?
The list of PsychoPy Builder Components supported by PsychoJS see the [PsychoPy/JS online status page](https://www.psychopy.org/online/status.html)
For the list of PsychoPy Builder Components supported by PsychoJS see this [PsychoPy/JS online status page](https://www.psychopy.org/online/status.html).
## API
There is full documentation of the [PsychoJS API](https://psychopy.github.io/psychojs/).
The documentation of the PsychoJS API is available [here](https://psychopy.github.io/psychojs/).
## Maintainers
@ -54,7 +53,7 @@ Alain Pitiot - [@apitiot](https://github.com/apitiot)
## Contributors
The PsychoJS library was initially written by [Ilixa](http://www.ilixa.com) with support from the [Wellcome Trust](https://wellcome.ac.uk).
It is now a collaborative effort, supported by the [Chan Zuckerberg Initiative](https://chanzuckerberg.com/) (2020-2021) and [Open Science Tools](https://opensciencetools.org/) (2020-):
It is now a collaborative effort, supported by the [Chan Zuckerberg Initiative](https://chanzuckerberg.com/) (2020-2021) and [Open Science Tools](https://opensciencetools.org/) (2020-):
- Alain Pitiot - [@apitiot](https://github.com/apitiot)
- Sotiri Bakagiannis - [@thewhodidthis](https://github.com/thewhodidthis)
- Nikita Agafonov - [@lightest](https://github.com/lightest)

42
package-lock.json generated
View File

@ -1,14 +1,16 @@
{
"name": "psychojs",
"version": "2021.2.x",
"version": "2022.1.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "psychojs",
"version": "2021.2.x",
"version": "2022.1.1",
"license": "MIT",
"dependencies": {
"@pixi/filter-adjustment": "^4.1.3",
"esbuild-plugin-glsl": "^1.0.5",
"howler": "^2.2.1",
"log4javascript": "github:Ritzlgrmft/log4javascript",
"pako": "^1.0.10",
@ -337,6 +339,15 @@
"@pixi/utils": "6.0.4"
}
},
"node_modules/@pixi/filter-adjustment": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-4.1.3.tgz",
"integrity": "sha512-W+NhPiZRYKoRToa5+tkU95eOw8gnS5dfIp3ZP+pLv2mdER9RI+4xHxp1uLHMqUYZViTaMdZIIoVOuCgHFPYCbQ==",
"peerDependencies": {
"@pixi/constants": "^6.0.0",
"@pixi/core": "^6.0.0"
}
},
"node_modules/@pixi/filter-alpha": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-6.0.4.tgz",
@ -957,12 +968,22 @@
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.5.tgz",
"integrity": "sha512-vcuP53pA5XiwUU4FnlXM+2PnVjTfHGthM7uP1gtp+9yfheGvFFbq/KyuESThmtoHPUrfZH5JpxGVJIFDVD1Egw==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
}
},
"node_modules/esbuild-plugin-glsl": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/esbuild-plugin-glsl/-/esbuild-plugin-glsl-1.1.0.tgz",
"integrity": "sha512-OBzCa/nRy/Vbm62DBzBnV25p1BfTpvFf2SP2Vv9Ls38sdEEuHzhYT5xTOh3Ghu+77VI4iZsOam19cmjwq5RcJQ==",
"engines": {
"node": ">= 0.10.18"
},
"peerDependencies": {
"esbuild": "0.x.x"
}
},
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@ -2690,6 +2711,12 @@
"@pixi/utils": "6.0.4"
}
},
"@pixi/filter-adjustment": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-4.1.3.tgz",
"integrity": "sha512-W+NhPiZRYKoRToa5+tkU95eOw8gnS5dfIp3ZP+pLv2mdER9RI+4xHxp1uLHMqUYZViTaMdZIIoVOuCgHFPYCbQ==",
"requires": {}
},
"@pixi/filter-alpha": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-6.0.4.tgz",
@ -3230,8 +3257,13 @@
"esbuild": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.5.tgz",
"integrity": "sha512-vcuP53pA5XiwUU4FnlXM+2PnVjTfHGthM7uP1gtp+9yfheGvFFbq/KyuESThmtoHPUrfZH5JpxGVJIFDVD1Egw==",
"dev": true
"integrity": "sha512-vcuP53pA5XiwUU4FnlXM+2PnVjTfHGthM7uP1gtp+9yfheGvFFbq/KyuESThmtoHPUrfZH5JpxGVJIFDVD1Egw=="
},
"esbuild-plugin-glsl": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/esbuild-plugin-glsl/-/esbuild-plugin-glsl-1.1.0.tgz",
"integrity": "sha512-OBzCa/nRy/Vbm62DBzBnV25p1BfTpvFf2SP2Vv9Ls38sdEEuHzhYT5xTOh3Ghu+77VI4iZsOam19cmjwq5RcJQ==",
"requires": {}
},
"escape-string-regexp": {
"version": "1.0.5",

View File

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

View File

@ -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) =>

View File

@ -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}.
*
@ -517,7 +532,7 @@ export class GUI
_updateOkButtonStatus(changeFocus = true)
{
if (
this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL
(this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL)
|| (this._allResourcesDownloaded && this._setRequiredKeys && this._setRequiredKeys.size >= this._requiredKeys.length)
)
{

View File

@ -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 2022.1.2");
// hide the initialisation message:
jQuery("#root").addClass("is-ready");
@ -314,7 +314,7 @@ export class PsychoJS
* @async
* @public
*/
async start({ configURL = "config.json", expName = "UNKNOWN", expInfo = {}, resources = [] } = {})
async start({ configURL = "config.json", expName = "UNKNOWN", expInfo = {}, resources = [], dataFileName } = {})
{
this.logger.debug();
@ -346,6 +346,7 @@ export class PsychoJS
this._experiment = new ExperimentHandler({
psychoJS: this,
extraInfo: expInfo,
dataFileName
});
// setup the logger:
@ -395,7 +396,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.");

View File

@ -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)
*
@ -49,19 +50,22 @@ export class ServerManager extends PsychObject
// resources is a map of <name: string, { path: string, status: ResourceStatus, data: any }>
this._resources = new Map();
this._nbLoadedResources = 0;
this._setupPreloadQueue();
this._addAttribute("autoLog", autoLog);
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 +104,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
@ -137,7 +141,9 @@ export class ServerManager extends PsychObject
const self = this;
return new Promise((resolve, reject) =>
{
const url = this._psychoJS.config.pavlovia.URL + "/api/v2/experiments/" + encodeURIComponent(self._psychoJS.config.experiment.fullpath) + "/sessions";
const url = this._psychoJS.config.pavlovia.URL
+ "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId
+ "/sessions";
jQuery.post(url, data, null, "json")
.done((data, textStatus) =>
{
@ -190,13 +196,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
@ -218,8 +224,9 @@ export class ServerManager extends PsychObject
this.setStatus(ServerManager.Status.BUSY);
// prepare DELETE query:
const url = this._psychoJS.config.pavlovia.URL + "/api/v2/experiments/" + encodeURIComponent(this._psychoJS.config.experiment.fullpath) + "/sessions/"
+ this._psychoJS.config.session.token;
const url = this._psychoJS.config.pavlovia.URL
+ "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId
+ "/sessions/" + this._psychoJS.config.session.token;
// synchronous query the pavlovia server:
if (sync)
@ -230,7 +237,7 @@ export class ServerManager extends PsychObject
request.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
request.send(JSON.stringify(data));
*/
/* This does not work in Chrome before of a CORS bug
/* This does not work in Chrome because of a CORS bug
await fetch(url, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
@ -277,7 +284,7 @@ export class ServerManager extends PsychObject
}
}
/**
/****************************************************************************
* Get the value of a resource.
*
* @name module:core.ServerManager#getResource
@ -316,34 +323,76 @@ export class ServerManager extends PsychObject
return pathStatusData.data;
}
/**
* Get the status of a resource.
/****************************************************************************
* Get the status of a single resource or the reduced status of an array of resources.
*
* <p>If an array of resources is given, getResourceStatus returns a single, reduced status
* that is the status furthest away from DOWNLOADED, with the status ordered as follow:
* ERROR (furthest from DOWNLOADED), REGISTERED, DOWNLOADING, and DOWNLOADED</p>
* <p>For example, given three resources:
* <ul>
* <li>if at least one of the resource status is ERROR, the reduced status is ERROR</li>
* <li>if at least one of the resource status is DOWNLOADING, the reduced status is DOWNLOADING</li>
* <li>if the status of all three resources is REGISTERED, the reduced status is REGISTERED</li>
* <li>if the status of all three resources is DOWNLOADED, the reduced status is DOWNLOADED</li>
* </ul>
* </p>
*
* @name module:core.ServerManager#getResourceStatus
* @function
* @public
* @param {string} name of the requested resource
* @return {core.ServerManager.ResourceStatus} status of the resource
* @throws {Object.<string, *>} exception if no resource with that name has previously been registered
* @param {string | string[]} names names of the resources whose statuses are requested
* @return {core.ServerManager.ResourceStatus} status of the resource if there is only one, or reduced status otherwise
* @throws {Object.<string, *>} if at least one of the names is not that of a previously
* registered resource
*/
getResourceStatus(name)
getResourceStatus(names)
{
const response = {
origin: "ServerManager.getResourceStatus",
context: "when getting the status of resource: " + name,
context: `when getting the status of resource(s): ${JSON.stringify(names)}`,
};
const pathStatusData = this._resources.get(name);
if (typeof pathStatusData === "undefined")
// sanity checks:
if (typeof names === 'string')
{
// throw { ...response, error: 'unknown resource' };
throw Object.assign(response, { error: "unknown resource" });
names = [names];
}
if (!Array.isArray(names))
{
throw Object.assign(response, { error: "names should be either a string or an array of strings" });
}
const statusOrder = new Map([
[Symbol.keyFor(ServerManager.ResourceStatus.ERROR), 0],
[Symbol.keyFor(ServerManager.ResourceStatus.REGISTERED), 1],
[Symbol.keyFor(ServerManager.ResourceStatus.DOWNLOADING), 2],
[Symbol.keyFor(ServerManager.ResourceStatus.DOWNLOADED), 3]
]);
let reducedStatus = ServerManager.ResourceStatus.DOWNLOADED;
for (const name of names)
{
const pathStatusData = this._resources.get(name);
if (typeof pathStatusData === "undefined")
{
// throw { ...response, error: 'unknown resource' };
throw Object.assign(response, {
error: `unable to find a previously registered resource with name: ${name}`
});
}
// update the reduced status according to the order given by statusOrder:
if (statusOrder.get(Symbol.keyFor(pathStatusData.status)) <
statusOrder.get(Symbol.keyFor(reducedStatus)))
{
reducedStatus = pathStatusData.status;
}
}
return pathStatusData.status;
return reducedStatus;
}
/**
/****************************************************************************
* Set the resource manager status.
*
* @name module:core.ServerManager#setStatus
@ -376,7 +425,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 +438,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.
*
@ -403,7 +452,7 @@ export class ServerManager extends PsychObject
* </ul>
*
* @name module:core.ServerManager#prepareResources
* @param {Array.<{name: string, path: string, download: boolean} | Symbol>} [resources=[]] - the list of resources
* @param {String | Array.<{name: string, path: string, download: boolean} | String | Symbol>} [resources=[]] - the list of resources or a single resource
* @function
* @public
*/
@ -423,9 +472,13 @@ export class ServerManager extends PsychObject
// register the resources:
if (resources !== null)
{
if (typeof resources === "string")
{
resources = [resources];
}
if (!Array.isArray(resources))
{
throw "resources should be an array of objects";
throw "resources should be either (a) a string or (b) an array of string or objects";
}
// whether all resources have been requested:
@ -453,7 +506,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);
}
}
@ -470,18 +523,30 @@ export class ServerManager extends PsychObject
throw "resources must be manually specified when the experiment is running locally: ALL_RESOURCES cannot be used";
}
// convert those resources that are only a string to an object with name and path:
for (let r = 0; r < resources.length; ++r)
{
const resource = resources[r];
if (typeof resource === "string")
{
resources[r] = {
name: resource,
path: resource,
download: true
}
}
}
for (let { name, path, download } of resources)
{
if (!this._resources.has(name))
{
// 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 +556,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)
@ -505,29 +570,40 @@ export class ServerManager extends PsychObject
// download those registered resources for which download = true
// note: we return a Promise that will be resolved when all the resources are downloaded
return new Promise((resolve, reject) =>
if (resourcesToDownload.size === 0)
{
const uuid = this.on(ServerManager.Event.RESOURCE, (signal) =>
{
if (signal.message === ServerManager.Event.DOWNLOAD_COMPLETED)
{
this.off(ServerManager.Event.RESOURCE, uuid);
resolve();
}
this.emit(ServerManager.Event.RESOURCE, {
message: ServerManager.Event.DOWNLOAD_COMPLETED,
});
this._downloadResources(resourcesToDownload);
});
return Promise.resolve();
}
else
{
return new Promise((resolve, reject) =>
{
const uuid = this.on(ServerManager.Event.RESOURCE, (signal) =>
{
if (signal.message === ServerManager.Event.DOWNLOAD_COMPLETED)
{
this.off(ServerManager.Event.RESOURCE, uuid);
resolve();
}
});
this._downloadResources(resourcesToDownload);
});
}
}
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 +621,7 @@ export class ServerManager extends PsychObject
};
const self = this;
return () =>
return async () =>
{
const t = self._waitForDownloadComponent.clock.getTime();
@ -560,11 +636,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 +670,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 +678,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 +776,7 @@ export class ServerManager extends PsychObject
}
}
/**
/****************************************************************************
* Asynchronously upload experiment logs to the pavlovia server.
*
* @name module:core.ServerManager#uploadLog
@ -753,37 +835,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 +886,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 +903,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 +970,9 @@ export class ServerManager extends PsychObject
}
}
/**
/****************************************************************************
* List the resources available to the experiment.
*
* @name module:core.ServerManager#_listResources
* @function
* @private
@ -898,7 +1039,7 @@ export class ServerManager extends PsychObject
});
}
/**
/****************************************************************************
* Download the specified resources.
*
* <p>Note: we use the [preloadjs library]{@link https://www.createjs.com/preloadjs}.</p>
@ -923,89 +1064,11 @@ export class ServerManager extends PsychObject
count: resources.size,
});
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 +1090,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 +1120,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 +1138,10 @@ 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._preloadQueue.loadManifest(preloadManifest);
}
else
{
@ -1080,7 +1154,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 +1242,103 @@ export class ServerManager extends PsychObject
howl.load();
}
}
/****************************************************************************
* Setup the preload.js queue, and the associated callbacks.
*
* @name module:core.ServerManager#_setupPreloadQueue
* @function
* @protected
*/
_setupPreloadQueue()
{
const response = {
origin: "ServerManager._setupPreloadQueue",
context: "when setting up a preload queue"
};
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 === self._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 +1380,7 @@ ServerManager.Event = {
STATUS: Symbol.for("STATUS"),
};
/**
/****************************************************************************
* Server status
*
* @name module:core.ServerManager#Status
@ -1193,7 +1405,7 @@ ServerManager.Status = {
ERROR: Symbol.for("ERROR"),
};
/**
/****************************************************************************
* Resource status
*
* @name module:core.ServerManager#ResourceStatus
@ -1202,6 +1414,11 @@ ServerManager.Status = {
* @public
*/
ServerManager.ResourceStatus = {
/**
* There was an error during downloading, or the resource is in an unknown state.
*/
ERROR: Symbol.for("ERROR"),
/**
* The resource has been registered.
*/
@ -1216,9 +1433,4 @@ ServerManager.ResourceStatus = {
* The resource has been downloaded.
*/
DOWNLOADED: Symbol.for("DOWNLOADED"),
/**
* There was an error during downloading, or the resource is in an unknown state.
*/
ERROR: Symbol.for("ERROR"),
};

View File

@ -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 = {

View File

@ -68,12 +68,33 @@ export class ExperimentHandler extends PsychObject
psychoJS,
name,
extraInfo,
dataFileName
} = {})
{
super(psychoJS, name);
this._addAttribute("extraInfo", extraInfo);
// process the extra info:
this._experimentName = (typeof extraInfo.expName === "string" && extraInfo.expName.length > 0)
? extraInfo.expName
: this.psychoJS.config.experiment.name;
this._participant = (typeof extraInfo.participant === "string" && extraInfo.participant.length > 0)
? extraInfo.participant
: "PARTICIPANT";
this._session = (typeof extraInfo.session === "string" && extraInfo.session.length > 0)
? extraInfo.session
: "SESSION";
this._datetime = (typeof extraInfo.date !== "undefined")
? extraInfo.date
: MonotonicClock.getDateStr();
this._addAttribute(
"dataFileName",
dataFileName,
`${this._participant}_${this._experimentName}_${this._datetime}`
);
// loop handlers:
this._loops = [];
this._unfinishedLoops = [];
@ -94,6 +115,7 @@ export class ExperimentHandler extends PsychObject
* @function
* @public
* @returns {boolean} whether or not the current entry is empty
* @todo This really should be renamed: IsCurrentEntryNotEmpty
*/
isEntryEmpty()
{
@ -170,7 +192,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 +261,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,26 +300,27 @@ export class ExperimentHandler extends PsychObject
}
}
// (*) 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");
const __session = ((typeof info.session === "string" && info.session.length > 0) ? info.session : "SESSION");
const __datetime = ((typeof info.date !== "undefined") ? info.date : MonotonicClock.getDateStr());
const gitlabConfig = this._psychoJS.config.gitlab;
const __projectId = (typeof gitlabConfig !== "undefined" && typeof gitlabConfig.projectId !== "undefined") ? gitlabConfig.projectId : undefined;
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:
// 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 filenameWithoutPath = this._dataFileName.split(/[\\/]/).pop();
const key = `${filenameWithoutPath}${tag}.csv`;
if (
this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER
&& this._psychoJS.config.experiment.status === "RUNNING"
@ -307,17 +334,26 @@ 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)
{
const gitlabConfig = this._psychoJS.config.gitlab;
const __projectId = (typeof gitlabConfig !== "undefined" && typeof gitlabConfig.projectId !== "undefined") ? gitlabConfig.projectId : undefined;
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 };
let doc = {
__projectId,
__experimentName: this._experimentName,
__participant: this._participant,
__session: this._session,
__datetime: this._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);

View File

@ -0,0 +1,422 @@
/** @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)}`
};
}
this._psychoJS.experiment.addData(this._name+'.response', response);
if (!this._finished)
{
// update the current staircase, but do not add the response again:
this._currentStaircase.addResponse(response, value, false);
// 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;
// label becomes name:
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._name+"."+this._varName]: value,
[this._name+".intensity"]: value
};
for (const attribute of this._currentStaircase._userAttributes)
{
// "name" becomes "label" again:
if (attribute === "name")
{
this._trialList[t][this._name+".label"] = this._currentStaircase["_name"];
}
else if (attribute !== "trialList" && attribute !== "extraInfo")
{
this._trialList[t][this._name+"."+attribute] = this._currentStaircase["_" + attribute];
}
}
if (typeof this._snapshots[t] !== "undefined")
{
let fieldName = this._name + "." + this._varName;
this._snapshots[t][fieldName] = value;
this._snapshots[t].trialAttributes.push(fieldName);
fieldName = this._name + ".intensity";
this._snapshots[t][fieldName] = value;
this._snapshots[t].trialAttributes.push(fieldName);
for (const attribute of this._currentStaircase._userAttributes)
{
// "name" becomes "label" again:
if (attribute === 'name')
{
fieldName = this._name + ".label";
this._snapshots[t][fieldName] = this._currentStaircase["_name"];
this._snapshots[t].trialAttributes.push(fieldName);
}
else if (attribute !== 'trialList' && attribute !== 'extraInfo')
{
fieldName = this._name+"."+attribute;
this._snapshots[t][fieldName] = this._currentStaircase["_" + attribute];
this._snapshots[t].trialAttributes.push(fieldName);
}
}
}
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")
};

View File

@ -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,31 +117,49 @@ 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
* @param{boolean} [doAddData = true] - whether or not to add the response as data to the
* experiment
* @returns {void}
*/
addResponse(response)
addResponse(response, value, doAddData = true)
{
// 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)}`
};
}
if (doAddData)
{
this._psychoJS.experiment.addData(this._name + '.response', 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)
{
// estimate the next value of the QUEST variable (and update the trial list and snapshots):
this.next();
// estimate the next value of the QUEST variable
// (and update the trial list and snapshots):
this._estimateQuestValue();
}
}
/**
* Simulate a response.
*
@ -128,6 +167,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 +181,6 @@ export class QuestHandler extends TrialHandler
return response;
}
/**
* Get the mean of the Quest posterior PDF.
*
@ -155,7 +194,6 @@ export class QuestHandler extends TrialHandler
return jsQUEST.QuestMean(this._jsQuest);
}
/**
* Get the standard deviation of the Quest posterior PDF.
*
@ -169,7 +207,6 @@ export class QuestHandler extends TrialHandler
return jsQUEST.QuestSd(this._jsQuest);
}
/**
* Get the mode of the Quest posterior PDF.
*
@ -184,7 +221,6 @@ export class QuestHandler extends TrialHandler
return mode;
}
/**
* Get the standard deviation of the Quest posterior PDF.
*
@ -199,6 +235,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 +254,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 +274,13 @@ export class QuestHandler extends TrialHandler
}
}
/**
* Setup the JS Quest object.
*
* @name module:data.QuestHandler#_setupJsQuest
* @function
* @protected
* @returns {void}
*/
_setupJsQuest()
{
@ -243,11 +292,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 +301,7 @@ export class QuestHandler extends TrialHandler
* @name module:data.QuestHandler#_estimateQuestValue
* @function
* @protected
* @returns {void}
*/
_estimateQuestValue()
{
@ -275,8 +322,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 +331,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 +352,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 +365,8 @@ export class QuestHandler extends TrialHandler
}
}
}
}
/**
* QuestHandler method
*
@ -334,15 +378,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")
};

View File

@ -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)}`
});
}
}
@ -687,16 +688,16 @@ export class TrialHandler extends PsychObject
context: "when preparing a sequence of trials",
};
// get an array of the indices of the elements of trialList :
// 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);

View File

@ -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";

View File

@ -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
});
}
/**

View File

@ -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
});
}
/**

View File

@ -813,27 +813,29 @@ export function addInfoFromUrl(info)
*/
export function selectFromArray(array, selection)
{
// if selection is an integer, or a string representing an integer, we treat it as an index in the array
// and return that entry:
// if selection is an integer, or a string representing an integer, we treat it
// as an index in the array and return that entry:
if (isInt(selection))
{
return [array[parseInt(selection)]];
}
// if selection is an array, we treat it as a list of indices
// and return an array with the entries corresponding to those indices:
else if (Array.isArray(selection))
{
// Pick out `array` items matching indices contained in `selection` in order
return selection.map((i) => array[i]);
return selection.map( (i) => array[i] );
}
// if selection is a string, we decode it:
// if selection is a string:
else if (typeof selection === "string")
{
if (selection.indexOf(",") > -1)
{
return selection.split(",").map((a) => selectFromArray(array, a));
const selectionAsArray = selection.split(",").map( (i) => parseInt(i) );
return selectFromArray(array, selectionAsArray);
}
// return flattenArray( selection.split(',').map(a => selectFromArray(array, a)) );
else if (selection.indexOf(":") > -1)
{
let sliceParams = selection.split(":").map((a) => parseInt(a));
@ -1433,3 +1435,58 @@ export function extensionFromMimeType(mimeType)
return '.dat';
}
/**
* Get an estimate of the download speed, by repeatedly downloading an image file from a distant
* server.
*
* @name module:util.getDownloadSpeed
* @function
* @public
* @param {PsychoJS} psychoJS the instance of PsychoJS
* @param {number} [nbDownloads = 1] the number of image downloads over which to average
* the download speed
* @return {number} the download speed, in megabits per second
*/
export async function getDownloadSpeed(psychoJS, nbDownloads = 1)
{
// url of the image to download and size of the image in bits:
// TODO use a variety of files, with different sizes
const imageUrl = "https://upload.wikimedia.org/wikipedia/commons/a/a6/Brandenburger_Tor_abends.jpg";
const imageSize_b = 2707459 * 8;
return new Promise( (resolve, reject) =>
{
let downloadTimeAccumulator = 0;
let downloadCounter = 0;
const download = new Image();
download.onload = () =>
{
const toc = performance.now();
downloadTimeAccumulator += (toc-tic);
++ downloadCounter;
if (downloadCounter === nbDownloads)
{
const speed_bps = (imageSize_b * nbDownloads) / (downloadTimeAccumulator / 1000);
resolve(speed_bps / 1024 / 1024);
}
else
{
tic = performance.now();
download.src = `${imageUrl}?salt=${tic}`;
}
}
download.onerror = (event) =>
{
const errorMsg = `unable to estimate the download speed: ${JSON.stringify(event)}`;
psychoJS.logger.error(errorMsg);
reject(errorMsg);
}
let tic = performance.now();
download.src = `${imageUrl}?salt=${tic}`;
});
}

View File

@ -104,6 +104,11 @@ export class ButtonStim extends TextBox
[],
);
this._addAttribute(
"numClicks",
0,
);
if (this._autoLog)
{
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);

View File

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

View File

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

View File

@ -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
{

View File

@ -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],
);

View File

@ -145,7 +145,10 @@ export class VisualStim extends util.mix(MinimalStim).with(WindowMixin)
if (hasChanged)
{
let radians = -ori * 0.017453292519943295;
this._rotationMatrix = [[Math.cos(radians), -Math.sin(radians)], [Math.sin(radians), Math.cos(radians)]];
this._rotationMatrix = [
[Math.cos(radians), -Math.sin(radians)],
[Math.sin(radians), Math.cos(radians)]
];
this._onChange(true, true)();
}
@ -258,14 +261,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)
{