diff --git a/README.md b/README.md
index 697de497..acddc00d 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
PsychoJS is a JavaScript library that makes it possible to run neuroscience, psychology, and psychophysics experiments in a browser. It is the online counterpart of the [PsychoPy](http://www.psychopy.org/) Python library.
-You can create online experiments from the [PsychoPy Builder](http://www.psychopy.org/builder/builder.html), you can find and adapt existing experiments on [pavlovia.org](https://www.pavlovia.org), or create them from scratch: the PsychoJS API is available [here](https://psychopy.github.io/psychojs/).
+You can create online experiments from the [PsychoPy Builder](http://www.psychopy.org/builder/builder.html), you can find and adapt existing experiments on [pavlovia.org](https://www.pavlovia.org), or create them from scratch.
PsychoJS is an open-source project. You can contribute by submitting pull requests to the [PsychoJS GitHub repository](https://github.com/psychopy/psychojs), and discuss issues and current and future features on the [Online category of the PsychoPy Forum](https://discourse.psychopy.org/c/online).
@@ -55,6 +55,7 @@ Alain Pitiot - [@apitiot](https://github.com/apitiot)
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-):
- Alain Pitiot - [@apitiot](https://github.com/apitiot)
+- Nikita Agafonov - [@lightest](https://github.com/lightest)
- Sotiri Bakagiannis - [@thewhodidthis](https://github.com/thewhodidthis)
- Jonathan Peirce - [@peircej](https://github.com/peircej)
- Thomas Pronk - [@tpronk](https://github.com/tpronk)
diff --git a/docs/AudioClip.html b/docs/AudioClip.html
new file mode 100644
index 00000000..ab8a8972
--- /dev/null
+++ b/docs/AudioClip.html
@@ -0,0 +1,2132 @@
+
+
+
Utility class used by the experiment scripts to keep track of a clock and of the current status (whether or not we are currently checking the keyboard)
This map provides support for browsers that have not yet
+adopted the W3C KeyboardEvent.code standard for detecting key presses.
+It maps the deprecated KeyboardEvent.keycode values to the W3C UI event codes.
+
Unfortunately, it is not very fine-grained: for instance, there is no difference between Alt Left and Alt
+Right, or between Enter and Numpad Enter. Use at your own risk (or upgrade your browser...).
Note: The w3c key-event viewer can be used to see possible values for the items in the keyList given the user's keyboard and chosen layout. The "key" and "code" columns in the UI Events fields are the relevant values for the keyList argument.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
+
Properties
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
Attributes
+
+
+
+
Default
+
+
+
Description
+
+
+
+
+
+
+
+
+
keyList
+
+
+
+
+
+Array.<string>
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ null
+
+
+
+
+
keyList allows the user to specify a set of keys to check for. Only keypresses from this set of keys will be removed from the keyboard buffer. If no keyList is given, all keys will be checked and the key buffer will be cleared completely.
+
+
+
+
+
+
+
timeStamped
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
If true will return a list of tuples instead of a list of keynames. Each tuple has (keyname, time).
An ExperimentHandler keeps track of multiple loops and handlers. It is particularly useful
+for generating a single data file from an experiment with many different loops (e.g. interleaved
+staircases or loops within loops.
Multiple key/value pairs can be added to any given entry of the data file. There are
+considered part of the same entry until a call to nextEntry is made.
For an experiment running locally, the results are offered for immediate download.
+
For an experiment running on the server, the results are uploaded to the server.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
+
Properties
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
Attributes
+
+
+
+
Default
+
+
+
Description
+
+
+
+
+
+
+
+
+
attributes
+
+
+
+
+
+Array.<Object>
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
the attributes to be saved
+
+
+
+
+
+
+
sync
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
whether or not to communicate with the server in a synchronous manner
+
+
+
+
+
+
+
tag
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ ''
+
+
+
+
+
an optional tag to add to the filename to which the data is saved (for CSV and XLSX saving options)
+
+
+
+
+
+
+
clear
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ 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)
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.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
Attributes
+
+
+
+
Default
+
+
+
Description
+
+
+
+
+
+
+
+
+
withPixi
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
whether or not the PIXI representation must
+also be updated
+
+
+
+
+
+
+
withBoundingBox
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
whether or not to immediately estimate
+the bounding box
GUI manages the various pop-up dialog boxes that guide the participant, throughout the
+lifecycle of the experiment, e.g. at the start while the resources are downloading, or at the
+end when the data is uploading to the server
Create a dialog box that (a) enables the participant to set some
+experimental values (e.g. the session name), (b) shows progress of resource
+download, and (c) enables the participant to cancel the experiment.
+
Setting experiment values
+
DlgFromDict displays an input field for all values in the dictionary.
+It is possible to specify default values e.g.:
Create a dialog box with a progress bar, to inform the participant of
+the last stages of the experiment: upload of results, of log, and closing
+of session.
Get the list of keys pressed or pushed by the participant.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
+
Properties
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
Attributes
+
+
+
+
Default
+
+
+
Description
+
+
+
+
+
+
+
+
+
keyList
+
+
+
+
+
+Array.<string>
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ []
+
+
+
+
+
the list of keys to consider. If keyList is empty, we consider all keys.
+Note that we use pyglet keys here, to make the PsychoJs code more homogeneous with PsychoPy.
+
+
+
+
+
+
+
waitRelease
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+
whether or not to include those keys pressed but not released. If
+waitRelease = false, key presses without a corresponding key release will have an undefined duration.
+
+
+
+
+
+
+
clear
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
whether or not to keep in the buffer the key presses or pushes for a subsequent call to getKeys. If a keyList has been given and clear = true, we only remove from the buffer those keys in keyList
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
the list of keys that were pressed (keydown followed by keyup) or pushed
+(keydown with no subsequent keyup at the time getKeys is called).
MonotonicClock offers a convenient way to keep track of time during experiments. An experiment can have as many independent clocks as needed, e.g. one to time responses, another one to keep track of stimuli, etc.
Get the status of each button (pressed or released) and, optionally, the time elapsed between the last call to clickReset and the pressing or releasing of the buttons.
+
Note: clickReset is typically called at stimulus onset. When the participant presses a button, the time elapsed since the clickReset is stored internally and can be accessed any time afterwards with getPressed.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
Attributes
+
+
+
+
Default
+
+
+
Description
+
+
+
+
+
+
+
+
+
getTime
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
whether or not to also return timestamps
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
either an array of size 3 with the status (1 for pressed, 0 for released) of each mouse button [left, center, right], or a tuple with that array and another array of size 3 with the timestamps.
Determine whether the mouse has moved beyond a certain distance.
+
distance
+
+
mouseMoved() or mouseMoved(undefined, false): determine whether the mouse has moved at all since the last
+call to getPos
+
mouseMoved(distance: number, false): determine whether the mouse has travelled further than distance, in terms of line of sight
+
mouseMoved(distance: [number,number], false): determine whether the mouse has travelled horizontally or vertically further then the given horizontal and vertical distances
+
+
reset
+
+
mouseMoved(distance, true): reset the mouse move clock, return false
+
mouseMoved(distance, 'here'): return false
+
mouseMoved(distance, [x: number, y: number]: artifically set the previous mouse position to the given coordinates and determine whether the mouse moved further than the given distance
A Trial Handler that implements the Quest algorithm for quick measurement of
+ psychophysical thresholds. QuestHandler relies on the jsQuest library, a port of Prof Dennis Pelli's QUEST algorithm by Daiichiro Kuroki.
A scheduler helps run the main loop by managing scheduled functions,
+called tasks, after each frame is displayed.
+
+Tasks are either another Scheduler, or a
+JavaScript functions returning one of the following codes:
+
+
Scheduler.Event.NEXT: Move onto the next task *without* rendering the scene first.
+
Scheduler.Event.FLIP_REPEAT: Render the scene and repeat the task.
+
Scheduler.Event.FLIP_NEXT: Render the scene and move onto the next task.
+
Scheduler.Event.QUIT: Quit the scheduler.
+
+
+
It is possible to create sub-schedulers, e.g. to handle loops.
+Sub-schedulers are added to a parent scheduler as a normal
+task would be by calling scheduler.add(subScheduler).
This manager handles all communications between the experiment running in the participant's browser and the pavlovia.org server, in an asynchronous manner.
+
It is responsible for reading the configuration file of an experiment, for opening and closing a session, for listing and downloading resources, and for uploading results, logs, and audio recordings.
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).
Get the status of a single resource or the reduced status of an array of resources.
+
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
+
For example, given three resources:
+
+
if at least one of the resource status is ERROR, the reduced status is ERROR
+
if at least one of the resource status is DOWNLOADING, the reduced status is DOWNLOADING
+
if the status of all three resources is REGISTERED, the reduced status is REGISTERED
+
if the status of all three resources is DOWNLOADED, the reduced status is DOWNLOADED
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
names
+
+
+
+
+
+string
+|
+
+Array.<string>
+
+
+
+
+
+
+
+
+
+
names of the resources whose statuses are requested
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
if at least one of the names is not that of a previously
+registered resource
+
+
+
+
+
+
+ Type
+
+
+
+Object.<string, *>
+
+
+
+
+
+
+
+
+
+
+
+
+
Returns:
+
+
+
+
status of the resource if there is only one, or reduced status otherwise
Prepare resources for the experiment: register them with the server manager and possibly
+start downloading them right away.
+
+
For an experiment running locally: the root directory for the specified resources is that of index.html
+ unless they are prepended with a protocol, such as http:// or https://.
+
For an experiment running on the server: if no resources are specified, all files in the resources directory
+ of the experiment are downloaded, otherwise we only download the specified resources. All resources are assumed
+ local to index.html unless they are prepended with a protocol.
+
If resources is null, then we do not download any resources
Shelf handles persistent key/value pairs, or records, which are stored in the shelf collection on the
+server, and can be accessed and manipulated in a concurrent fashion.
Add a delta to the value of a record of type INTEGER associated with the given key.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
+
Properties
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
key
+
+
+
+
+
+Array.<string>
+
+
+
+
+
+
+
+
+
+
key as an array of key components
+
+
+
+
+
+
+
delta
+
+
+
+
+
+number
+
+
+
+
+
+
+
+
+
+
the delta, positive or negative, to add to the value
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
exception if delta is not an integer, or if there is no record with the given
+key, or if there is a record but it is locked or it is not of type INTEGER
Get the value of a given field in the dictionary record associated with the given key.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
+
Properties
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
key
+
+
+
+
+
+Array.<string>
+
+
+
+
+
+
+
+
+
+
key as an array of key components
+
+
+
+
+
+
+
fieldName
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
the name of the field
+
+
+
+
+
+
+
defaultValue
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+
+
the default value returned if no record with the given key exists on
+the shelf, or if is a record of type DICTIONARY with the given key but it has no such field
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
exception if there is no record with the given key,
+or if there is a record but it is locked or it is not of type DICTIONARY
Set the value of a record of type BOOLEAN associated with the given key.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
+
Properties
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
key
+
+
+
+
+
+Array.<string>
+
+
+
+
+
+
+
+
+
+
key as an array of key components
+
+
+
+
+
+
+
value
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+
+
the new value
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
exception if value is not a boolean, or if there is no record with the given
+key, or if there is a record but it is locked or it is not of type BOOLEAN
Set the value of a record of type DICTIONARY associated with the given key.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
+
Properties
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
key
+
+
+
+
+
+Array.<string>
+
+
+
+
+
+
+
+
+
+
key as an array of key components
+
+
+
+
+
+
+
value
+
+
+
+
+
+Object.<string, *>
+
+
+
+
+
+
+
+
+
+
the new value
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
exception if value is not an object, or or if there is no record
+with the given key, or if there is a record but it is locked or it is not of type DICTIONARY
Set the value of a record of type INTEGER associated with the given key.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
+
Properties
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
key
+
+
+
+
+
+Array.<string>
+
+
+
+
+
+
+
+
+
+
key as an array of key components
+
+
+
+
+
+
+
value
+
+
+
+
+
+number
+
+
+
+
+
+
+
+
+
+
the new value
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Throws:
+
+
+
+
+
+
+
exception if value is not an integer, or or if there is no record
+with the given key, or if there is a record but it is locked or it is not of type INTEGER
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.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
Attributes
+
+
+
+
Default
+
+
+
Description
+
+
+
+
+
+
+
+
+
withPixi
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
whether or not the PIXI representation must
+also be updated
+
+
+
+
+
+
+
withBoundingBox
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
whether or not to immediately estimate
+the bounding box
Get the list of transcripts still in the buffer, i.e. those that have not been
+previously cleared by calls to getTranscripts with clear = true.
+
+
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
options
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
+
Properties
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
Attributes
+
+
+
+
Default
+
+
+
Description
+
+
+
+
+
+
+
+
+
transcriptList
+
+
+
+
+
+Array.<string>
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ []
+
+
+
+
+
the list of transcripts texts to consider. If transcriptList is empty, we consider all transcripts.
+
+
+
+
+
+
+
clear
+
+
+
+
+
+boolean
+
+
+
+
+
+
+
+
+ <optional>
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
whether or not to keep in the buffer the transcripts for a subsequent call to getTranscripts. If a keyList has been given and clear = true, we only remove from the buffer those keys in keyList
* Manager handling the keyboard and mouse/touch events.
*
* @author Alain Pitiot
- * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @version 2022.2.3
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
-import {MonotonicClock, Clock} from '../util/Clock';
-import {PsychoJS} from './PsychoJS';
-
+import { Clock, MonotonicClock } from "../util/Clock.js";
+import { PsychoJS } from "./PsychoJS.js";
/**
- * @class
* <p>This manager handles all participant interactions with the experiment, i.e. keyboard, mouse and touch events.</p>
- *
- * @name module:core.EventManager
- * @class
- * @param {Object} options
- * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
*/
export class EventManager
{
-
+ /**
+ * @memberof module:core
+ * @param {Object} psychoJS
+ * @param {module:core.PsychoJS} psychoJS - the PsychoJS instance
+ */
constructor(psychoJS)
{
this._psychoJS = psychoJS;
@@ -75,31 +96,27 @@
Source: core/EventManager.js
pressed: [0, 0, 0],
clocks: [new Clock(), new Clock(), new Clock()],
// time elapsed from last reset of the button.Clocks:
- times: [0.0, 0.0, 0.0]
+ times: [0.0, 0.0, 0.0],
},
// clock reset when mouse is moved:
- moveClock: new Clock()
+ moveClock: new Clock(),
};
}
-
/**
* Get the list of keys pressed by the participant.
*
* <p>Note: The w3c [key-event viewer]{@link https://w3c.github.io/uievents/tools/key-event-viewer.html} can be used to see possible values for the items in the keyList given the user's keyboard and chosen layout. The "key" and "code" columns in the UI Events fields are the relevant values for the keyList argument.</p>
*
- * @name module:core.EventManager#getKeys
- * @function
- * @public
* @param {Object} options
* @param {string[]} [options.keyList= null] - keyList allows the user to specify a set of keys to check for. Only keypresses from this set of keys will be removed from the keyboard buffer. If no keyList is given, all keys will be checked and the key buffer will be cleared completely.
* @param {boolean} [options.timeStamped= false] - If true will return a list of tuples instead of a list of keynames. Each tuple has (keyname, time).
* @return {string[]} the list of keys that were pressed.
*/
getKeys({
- keyList = null,
- timeStamped = false
- } = {})
+ keyList = null,
+ timeStamped = false,
+ } = {})
{
if (keyList != null)
{
@@ -151,7 +168,6 @@
Source: core/EventManager.js
return keys;
}
-
/**
* @typedef EventManager.ButtonInfo
* @property {Array.number} pressed - the status of each mouse button [left, center, right]: 1 for pressed, 0 for released
@@ -169,9 +185,6 @@
Source: core/EventManager.js
/**
* Get the mouse info.
*
- * @name module:core.EventManager#getMouseInfo
- * @function
- * @public
* @return {EventManager.MouseInfo} the mouse info.
*/
getMouseInfo()
@@ -179,14 +192,9 @@
Source: core/EventManager.js
return this._mouseInfo;
}
-
/**
* Clear all events from the event buffer.
*
- * @name module:core.EventManager#clearEvents
- * @function
- * @public
- *
* @todo handle the attribs argument
*/
clearEvents(attribs)
@@ -194,68 +202,44 @@
}
}
-
/**
* <p>This map provides support for browsers that have not yet
* adopted the W3C KeyboardEvent.code standard for detecting key presses.
@@ -469,9 +439,8 @@
Source: core/EventManager.js
* <p>Unfortunately, it is not very fine-grained: for instance, there is no difference between Alt Left and Alt
* Right, or between Enter and Numpad Enter. Use at your own risk (or upgrade your browser...).</p>
*
- * @name module:core.EventManager#_keycodeMap
* @readonly
- * @private
+ * @protected
* @type {Object.<number,String>}
*/
EventManager._keycodeMap = {
@@ -550,17 +519,15 @@
Source: core/EventManager.js
39: "ArrowRight",
40: "ArrowDown",
27: "Escape",
- 32: "Space"
+ 32: "Space",
};
-
/**
* This map associates pyglet key names to the corresponding W3C KeyboardEvent codes values.
* <p>More information can be found [here]{@link https://www.w3.org/TR/uievents-code}</p>
*
- * @name module:core.EventManager#_pygletMap
* @readonly
- * @private
+ * @protected
* @type {Object.<String,String>}
*/
EventManager._pygletMap = {
@@ -653,26 +620,21 @@
Source: core/EventManager.js
"num_multiply": "NumpadMultiply",
"num_divide": "NumpadDivide",
"num_equal": "NumpadEqual",
- "num_numlock": "NumpadNumlock"
+ "num_numlock": "NumpadNumlock",
};
-
/**
* <p>This map associates W3C KeyboardEvent.codes to the corresponding pyglet key names.
*
- * @name module:core.EventManager#_reversePygletMap
* @readonly
- * @private
+ * @protected
* @type {Object.<String,String>}
*/
EventManager._reversePygletMap = {};
-
/**
* Utility class used by the experiment scripts to keep track of a clock and of the current status (whether or not we are currently checking the keyboard)
*
- * @name module:core.BuilderKeyResponse
- * @class
* @param {Object} options
* @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
*/
@@ -684,8 +646,8 @@
Source: core/EventManager.js
this.status = PsychoJS.Status.NOT_STARTED;
this.keys = []; // the key(s) pressed
- this.corr = 0; // was the resp correct this trial? (0=no, 1=yes)
- this.rt = []; // response time(s)
+ this.corr = 0; // was the resp correct this trial? (0=no, 1=yes)
+ this.rt = []; // response time(s)
this.clock = new Clock(); // we'll use this to measure the rt
}
}
@@ -696,19 +658,23 @@
*
* @author Alain Pitiot
* @author Sijia Zhao - fine-grained resource loading
- * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @version 2021.2.3
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
-import * as Tone from 'tone';
-import {PsychoJS} from './PsychoJS';
-import {ServerManager} from './ServerManager';
-import {Scheduler} from '../util/Scheduler';
-import {Clock} from '../util/Clock';
-import {ExperimentHandler} from '../data/ExperimentHandler';
-import * as util from '../util/Util';
-
+import * as Tone from "tone";
+import { ExperimentHandler } from "../data/ExperimentHandler.js";
+import { Clock } from "../util/Clock.js";
+import { Scheduler } from "../util/Scheduler.js";
+import * as util from "../util/Util.js";
+import { PsychoJS } from "./PsychoJS.js";
+import { ServerManager } from "./ServerManager.js";
+import A11yDialog from "a11y-dialog";
/**
- * @class
- * Graphic User Interface
- *
- * @name module:core.GUI
- * @class
- * @param {module:core.PsychoJS} psychoJS the PsychoJS instance
+ * <p>GUI manages the various pop-up dialog boxes that guide the participant, throughout the
+ * lifecycle of the experiment, e.g. at the start while the resources are downloading, or at the
+ * end when the data is uploading to the server</p>
*/
export class GUI
{
+ /**
+ * Default settings for GUI.
+ *
+ * @type {Object}
+ */
+ static DEFAULT_SETTINGS = {
+ DlgFromDict: {
+ // The dialog box shows an OK button. The button becomes enable when all registered resources
+ // have been downloaded. Participants must click on the OK button to move on with the experiment.
+ requireParticipantClick: true
+ }
+ };
get dialogComponent()
{
return this._dialogComponent;
}
+ /**
+ * @memberof module:core
+ * @param {module:core.PsychoJS} psychoJS - the PsychoJS instance
+ */
constructor(psychoJS)
{
this._psychoJS = psychoJS;
@@ -70,11 +107,8 @@
Source: core/GUI.js
{
this._onResourceEvents(signal);
});
-
- this._dialogScalingFactor = 0;
}
-
/**
* <p>Create a dialog box that (a) enables the participant to set some
* experimental values (e.g. the session name), (b) shows progress of resource
@@ -89,38 +123,39 @@
Source: core/GUI.js
* <p>If the participant cancels (by pressing Cancel or by closing the dialog box), then
* the dictionary remains unchanged.</p>
*
- * @name module:core.GUI#DlgFromDict
- * @function
- * @public
* @param {Object} options
* @param {String} [options.logoUrl] - Url of the experiment logo
* @param {String} [options.text] - information text
* @param {Object} options.dictionary - associative array of values for the participant to set
* @param {String} options.title - name of the project
+ * @param {boolean} [options.requireParticipantClick=true] - whether the participant must click on the OK
+ * button, when it becomes enabled, to move on with the experiment
*/
DlgFromDict({
- logoUrl,
- text,
- dictionary,
- title
- })
+ logoUrl,
+ text,
+ dictionary,
+ title,
+ requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick
+ })
{
// get info from URL:
const infoFromUrl = util.getUrlParameters();
- this._progressMsg = ' ';
this._progressBarMax = 0;
this._allResourcesDownloaded = false;
this._requiredKeys = [];
this._setRequiredKeys = new Map();
+ this._progressMessage = " ";
+ this._requireParticipantClick = requireParticipantClick;
+ this._dictionary = dictionary;
-
- // prepare PsychoJS component:
+ // prepare a PsychoJS component:
this._dialogComponent = {};
this._dialogComponent.status = PsychoJS.Status.NOT_STARTED;
const dialogClock = new Clock();
- const self = this;
+ const self = this;
return () =>
{
const t = dialogClock.getTime();
@@ -132,219 +167,141 @@
Source: core/GUI.js
// if the experiment is licensed, and running on the license rather than on credit,
// we use the license logo:
- if (self._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER &&
- typeof self._psychoJS.config.experiment.license !== 'undefined' &&
- self._psychoJS.config.experiment.runMode === 'LICENSE' &&
- typeof self._psychoJS.config.experiment.license.institutionLogo !== 'undefined')
+ if (self._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER
+ && typeof self._psychoJS.config.experiment.license !== "undefined"
+ && self._psychoJS.config.experiment.runMode === "LICENSE"
+ && typeof self._psychoJS.config.experiment.license.institutionLogo !== "undefined")
{
logoUrl = self._psychoJS.config.experiment.license.institutionLogo;
}
- // prepare jquery UI dialog box:
- let htmlCode =
- '<div id="expDialog" title="' + title + '">';
+ // prepare the markup for the a11y-dialog:
+ let markup = "<div class='dialog-container' id='experiment-dialog' aria-hidden='true' role='alertdialog'>";
+ markup += "<div class='dialog-overlay'></div>";
+ // markup += "<div class='dialog-overlay' data-a11y-dialog-hide></div>";
+ markup += "<div class='dialog-content'>";
- // uncomment for older version of the library:
- // htmlCode += '<p style="font-size: 0.8em; padding: 0.5em; margin-bottom: 0.5em; color: #FFAA00; border: 1px solid #FFAA00;">⚠ This experiment uses a deprecated version of the PsychoJS library. Consider updating to a newer version (e.g. by updating PsychoPy and re-exporting the experiment).</p>'+
+ // alert title and close button:
+ markup += `<div id='experiment-dialog-title' class='dialog-title'><p>${title}</p><button id='dialogClose' class='dialog-close' data-a11y-dialog-hide aria-label='Cancel Experiment'>×</button></div>`;
- // logo:
- if (typeof logoUrl === 'string')
+ // logo, if need be:
+ if (typeof logoUrl === "string")
{
- htmlCode += '<img id="dialog-logo" class="logo" alt="logo" src="' + logoUrl + '">';
+ markup += '<img id="dialog-logo" class="logo" alt="logo" src="' + logoUrl + '">';
}
- // information text:
- if (typeof text === 'string' && text.length > 0)
+ // add a combobox or text areas for each entry in the dictionary:
+ Object.keys(dictionary).forEach((key, keyIdx) =>
{
- htmlCode += '<p>' + text + '</p>';
- }
+ const value = dictionary[key];
+ const keyId = "form-input-" + keyIdx;
+ // only create an input if the key is not in the URL:
+ let inUrl = false;
+ const cleanedDictKey = key.trim().toLowerCase();
+ infoFromUrl.forEach((urlValue, urlKey) =>
+ {
+ const cleanedUrlKey = urlKey.trim().toLowerCase();
+ if (cleanedUrlKey === cleanedDictKey)
+ {
+ inUrl = true;
+ // break;
+ }
+ });
- // add a combobox or text areas for each entry in the dictionary:
-
- // These may include Symbols as opposed to when using a for...in loop,
- // but only strings are allowed in PsychoPy
- Object.keys(dictionary).forEach((key, keyIdx) =>
+ if (!inUrl)
{
- const value = dictionary[key];
- const keyId = 'form-input-' + keyIdx;
+ markup += `<label for='${keyId}'> ${key} </label>`;
- // only create an input if the key is not in the URL:
- let inUrl = false;
- const cleanedDictKey = key.trim().toLowerCase();
- infoFromUrl.forEach((urlValue, urlKey) =>
- {
- const cleanedUrlKey = urlKey.trim().toLowerCase();
- if (cleanedUrlKey === cleanedDictKey)
- {
- inUrl = true;
- // break;
- }
- });
-
- if (!inUrl)
+ // if the field is required:
+ if (key.slice(-1) === "*")
{
- htmlCode += '<label for="' + keyId + '">' + key + '</label>';
+ self._requiredKeys.push(keyId);
+ }
- // if the field is required:
- if (key.slice(-1) === '*')
+ // if value is an array, we create a select drop-down menu:
+ if (Array.isArray(value))
+ {
+ markup += `<select name='${key}' id='${keyId}' class='text'>`;
+
+ // if the field is required, we add an empty option and select it:
+ if (key.slice(-1) === "*")
{
- self._requiredKeys.push(keyId);
+ markup += "<option disabled selected>...</option>";
}
- // if value is an array, we create a select drop-down menu:
- if (Array.isArray(value))
+ for (const option of value)
{
- htmlCode += '<select name="' + key + '" id="' + keyId + '" class="text ui-widget-content' +
- ' ui-corner-all">';
-
- // if the field is required, we add an empty option and select it:
- if (key.slice(-1) === '*')
- {
- htmlCode += '<option disabled selected>...</option>';
- }
-
- for (const option of value)
- {
- htmlCode += '<option>' + option + '</option>';
- }
-
- htmlCode += '</select>';
- jQuery('#' + keyId).selectmenu({classes: {}});
+ markup += `<option> ${option} </option>`;
}
+ markup += "</select>";
+ }
// otherwise we use a single string input:
- else /*if (typeof value === 'string')*/
- {
- htmlCode += '<input type="text" name="' + key + '" id="' + keyId;
- htmlCode += '" value="' + value + '" class="text ui-widget-content ui-corner-all">';
- }
+ //if (typeof value === 'string')
+ else
+ {
+ markup += `<input type='text' name='${key}' id='${keyId}' value='${value}' class='text'>`;
}
}
- );
-
- 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>';
- htmlCode += '<div id="progressbar"></div></div>';
-
+ });
- // replace root by the html code:
- const dialogElement = document.getElementById('root');
- dialogElement.innerHTML = htmlCode;
+ if (self._requiredKeys.length > 0)
+ {
+ markup += "<p class='validateTips'>Fields marked with an asterisk (*) are required.</p>";
+ }
+ // progress bar:
+ markup += `<hr><div id='progressMsg' class='progress-msg'>${self._progressMessage}</div>`;
+ markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>";
- // when the logo is loaded, we call _onDialogOpen again to reset the dimensions and position of
- // the dialog box:
- if (typeof logoUrl === 'string')
+ // buttons:
+ markup += "<hr>";
+ markup += "<button id='dialogCancel' class='dialog-button' aria-label='Cancel Experiment'>Cancel</button>";
+ if (self._requireParticipantClick)
{
- jQuery("#dialog-logo").on('load', () =>
- {
- self._onDialogOpen('#expDialog')();
- });
+ markup += "<button id='dialogOK' class='dialog-button disabled' aria-label='Start Experiment'>Ok</button>";
}
+ markup += "</div></div>";
- // setup change event handlers for all required keys:
- this._requiredKeys.forEach((keyId) =>
- {
- const input = document.getElementById(keyId);
- if (input)
- {
- input.oninput = (event) => GUI._onKeyChange(self, event);
- }
- }
- );
+ // replace root by the markup code:
+ const dialogElement = document.getElementById("root");
+ dialogElement.innerHTML = markup;
// init and open the dialog box:
- self._dialogComponent.button = 'Cancel';
- self._estimateDialogScalingFactor();
- const dialogSize = self._getDialogSize();
- jQuery("#expDialog").dialog({
- width: dialogSize[0],
- maxHeight: dialogSize[1],
-
- autoOpen: true,
- modal: true,
- closeOnEscape: false,
- resizable: false,
- draggable: false,
-
- buttons: [
- {
- id: "buttonCancel",
- text: "Cancel",
- click: function ()
- {
- self._dialogComponent.button = 'Cancel';
- jQuery("#expDialog").dialog('close');
- }
- },
- {
- id: "buttonOk",
- text: "Ok",
- click: function ()
- {
+ const dialogDiv = document.getElementById("experiment-dialog");
+ self._dialog = new A11yDialog(dialogDiv);
+ self._dialog.show();
+
+ // button callbacks:
+ self._dialogComponent.button = "Cancel";
+ self._cancelButton = document.getElementById("dialogCancel");
+ self._cancelButton.onclick = self._onCancelExperiment.bind(self);
+ if (self._requireParticipantClick)
+ {
+ self._okButton = document.getElementById("dialogOK");
+ self._okButton.onclick = self._onStartExperiment.bind(self);
+ }
+ self._closeButton = document.getElementById("dialogClose");
+ self._closeButton.onclick = self._onCancelExperiment.bind(self);
- // update dictionary:
- Object.keys(dictionary).forEach((key, keyIdx) =>
- {
- const input = document.getElementById('form-input-' + keyIdx);
- if (input)
- {
- dictionary[key] = input.value;
- }
- }
- );
-
-
- self._dialogComponent.button = 'OK';
- jQuery("#expDialog").dialog('close');
-
- // Tackle browser demands on having user action initiate audio context
- Tone.start();
-
- // switch to full screen if requested:
- self._psychoJS.window.adjustScreenSize();
-
- // Clear events (and keypresses) accumulated during the dialog
- self._psychoJS.eventManager.clearEvents();
- }
- }
- ],
+ // update the OK button status:
+ self._updateDialog();
- // open the dialog in the middle of the screen:
- open: self._onDialogOpen('#expDialog'),
+ self._progressMsg = document.getElementById("progressMsg");
+ self._progressBar = document.getElementById("progressBar");
+ self._updateProgressBar();
- // close is called by both buttons and when the user clicks on the cross:
- close: function ()
+ // setup change event handlers for all required keys:
+ this._requiredKeys.forEach((keyId) =>
+ {
+ const input = document.getElementById(keyId);
+ if (input)
{
- //jQuery.unblockUI();
- jQuery(this).dialog('destroy').remove();
- self._dialogComponent.status = PsychoJS.Status.FINISHED;
+ input.oninput = (event) => GUI._onKeyChange(self, event);
}
-
- })
- // change colour of title bar
- .prev(".ui-dialog-titlebar").css("background", "green");
-
-
- // update the OK button status:
- self._updateOkButtonStatus();
-
-
- // when the browser window is resize, we redimension and reposition the dialog:
- self._dialogResize('#expDialog');
-
-
- // block UI until user has pressed dialog button:
- // note: block UI does not allow for text to be entered in the dialog form boxes, alas!
- //jQuery.blockUI({ message: "", baseZ: 1});
-
- // show dialog box:
- jQuery("#progressbar").progressbar({value: self._progressBarCurrentValue});
- jQuery("#progressbar").progressbar("option", "max", self._progressBarMax);
+ });
}
if (self._dialogComponent.status === PsychoJS.Status.FINISHED)
@@ -358,7 +315,6 @@
*
* <p>This function can be used to display both warning and error messages.</p>
*
- * @name module:core.GUI#dialog
- * @function
- * @public
* @param {Object} options
* @param {string} options.message - the message to be displayed
* @param {Object.<string, *>} options.error - an exception
@@ -378,55 +331,45 @@
Source: core/GUI.js
* @param {GUI.onOK} [options.onOK] - function called when the participant presses the OK button
*/
dialog({
- message,
- warning,
- error,
- showOK = true,
- onOK
- } = {})
+ message,
+ warning,
+ error,
+ showOK = true,
+ onOK
+ } = {})
{
-
// 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;
+ // prepare the markup for the a11y-dialog:
+ let markup = "<div class='dialog-container' id='experiment-dialog' aria-hidden='true' role='alertdialog'>";
+ markup += "<div class='dialog-overlay'></div>";
+ markup += "<div class='dialog-content'>";
// we are displaying an error:
- if (typeof error !== 'undefined')
+ if (typeof error !== "undefined")
{
this._psychoJS.logger.fatal(util.toString(error));
// deal with null error:
if (!error)
{
- error = 'Unspecified JavaScript error';
+ error = "Unspecified JavaScript error";
}
- let errorCode = null;
-
// go through the error stack and look for errorCode if there is one:
- let stackCode = '<ul>';
+ let errorCode = null;
+ let stackCode = "<ul>";
while (true)
{
-
- if (typeof error === 'object' && 'errorCode' in error)
+ if (typeof error === "object" && "errorCode" in error)
{
errorCode = error.errorCode;
}
- if (typeof error === 'object' && 'context' in error)
+ if (typeof error === "object" && "context" in error)
{
- stackCode += '<li>' + error.context + '</li>';
+ stackCode += "<li>" + error.context + "</li>";
error = error.error;
}
else
@@ -437,209 +380,247 @@
Source: core/GUI.js
error = error.substring(1, 1000);
}
- stackCode += '<li><b>' + error + '</b></li>';
+ stackCode += "<li><b>" + error + "</b></li>";
break;
}
}
- stackCode += '</ul>';
+ stackCode += "</ul>";
// if we found an errorCode, we replace the stack-based message by a more user-friendly one:
if (errorCode)
{
const error = this._userFriendlyError(errorCode);
- htmlCode = error.htmlCode;
- titleColour = error.titleColour;
+ markup += `<div id='experiment-dialog-title' class='dialog-title ${error.class}'><p>${error.title}</p></div>`;
+ markup += `<p>${error.text}</p>`;
}
else
{
- htmlCode = '<div id="msgDialog" title="Error">';
- htmlCode += '<p class="validateTips">Unfortunately we encountered the following error:</p>';
- htmlCode += stackCode;
- htmlCode += '<p>Try to run the experiment again. If the error persists, contact the experiment designer.</p>';
- htmlCode += '</div>';
-
- titleColour = 'red';
+ markup += `<div id='experiment-dialog-title' class='dialog-title dialog-error'><p>Error</p></div>`;
+ markup += `<p>Unfortunately we encountered the following error:</p>`;
+ markup += stackCode;
+ markup += "<p>Try to run the experiment again. If the error persists, contact the experiment designer.</p>";
}
}
- // we are displaying a message:
- else if (typeof message !== 'undefined')
+ // we are displaying a warning:
+ else if (typeof warning !== "undefined")
{
- htmlCode = '<div id="msgDialog" title="Message">' +
- '<p class="validateTips">' + message + '</p>' +
- '</div>';
- titleColour = 'green';
+ markup += `<div id='experiment-dialog-title' class='dialog-title dialog-warning'><p>Warning</p></div>`;
+ markup += `<p>${warning}</p>`;
}
- // we are displaying a warning:
- else if (typeof warning !== 'undefined')
+ // we are displaying a message:
+ else if (typeof message !== "undefined")
{
- htmlCode = '<div id="msgDialog" title="Warning">' +
- '<p class="validateTips">' + warning + '</p>' +
- '</div>';
- titleColour = 'orange';
+ markup += `<div id='experiment-dialog-title' class='dialog-title'><p>Message</p></div>`;
+ markup += `<p>${message}</p>`;
}
+ if (showOK)
+ {
+ markup += "<hr><button id='dialogOK' class='dialog-button' aria-label='Close dialog'>Ok</button>";
+ }
+ markup += "</div></div>";
- // replace root by the html code:
- const dialogElement = document.getElementById('root');
- dialogElement.innerHTML = htmlCode;
+ // replace root by the markup code:
+ const dialogElement = document.getElementById("root");
+ dialogElement.innerHTML = markup;
// init and open the dialog box:
- this._estimateDialogScalingFactor();
- const dialogSize = this._getDialogSize();
- const self = this;
- jQuery("#msgDialog").dialog({
- dialogClass: 'no-close',
-
- width: dialogSize[0],
- maxHeight: dialogSize[1],
-
- autoOpen: true,
- modal: true,
- closeOnEscape: false,
- resizable: false,
- draggable: false,
-
- buttons: (!showOK) ? [] : [{
- id: "buttonOk",
- text: "Ok",
- click: function ()
- {
- jQuery(this).dialog("destroy").remove();
+ const dialogDiv = document.getElementById("experiment-dialog");
+ this._dialog = new A11yDialog(dialogDiv);
+ this._dialog.show();
- // execute callback function:
- if (typeof onOK !== 'undefined')
- {
- onOK();
- }
+ // button callbacks:
+ if (showOK)
+ {
+ this._okButton = document.getElementById("dialogOK");
+ this._okButton.onclick = () =>
+ {
+ this.closeDialog();
+
+ // execute callback function:
+ if (typeof onOK !== "undefined")
+ {
+ onOK();
}
- }],
+ };
+ }
+ }
+
+ /**
+ * <p>Create a dialog box with a progress bar, to inform the participant of
+ * the last stages of the experiment: upload of results, of log, and closing
+ * of session.</p>
+ *
+ * @param {Object} options
+ * @param {String} [options.text] - information text
+ */
+ finishDialog({ text = "", nbSteps = 0 })
+ {
+ this.closeDialog();
- // open the dialog in the middle of the screen:
- open: self._onDialogOpen('#msgDialog'),
+ // prepare the markup for the a11y-dialog:
+ let markup = "<div class='dialog-container' id='experiment-dialog' aria-hidden='true' role='alertdialog'>";
+ markup += "<div class='dialog-overlay'></div>";
+ markup += "<div class='dialog-content'>";
+ markup += `<div id='experiment-dialog-title' class='dialog-title dialog-warning'><p>Warning</p></div>`;
+ markup += `<p>${text}</p>`;
- })
- // change colour of title bar
- .prev(".ui-dialog-titlebar").css("background", titleColour);
+ // progress bar:
+ markup += `<hr><div id='progressMsg' class='progress-msg'> </div>`;
+ markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>";
+ markup += "</div></div>";
- // when the browser window is resize, we redimension and reposition the dialog:
- self._dialogResize('#msgDialog');
+ // replace root by the markup code:
+ const dialogElement = document.getElementById("root");
+ dialogElement.innerHTML = markup;
+
+ // init and open the dialog box:
+ const dialogDiv = document.getElementById("experiment-dialog");
+ this._dialog = new A11yDialog(dialogDiv);
+ this._dialog.show();
+
+ this._progressMsg = document.getElementById("progressMsg");
+ this._progressBar = document.getElementById("progressBar");
+
+ this._progressMessage = " ";
+ this._progressBarCurrentValue = 0;
+ this._progressBarMax = nbSteps;
+ this._updateProgressBar();
}
+ finishDialogNextStep(text)
+ {
+ this._setProgressMessage(text);
+ ++ this._progressBarCurrentValue;
+ this._updateProgressBar();
+ }
/**
- * Callback triggered when the jQuery UI dialog box is open.
+ * Close the previously opened dialog box, if there is one.
+ */
+ closeDialog()
+ {
+ if (this._dialog)
+ {
+ this._dialog.hide();
+ }
+ }
+
+ /**
+ * Set the progress message.
*
- * @name module:core.GUI#_onDialogOpen
- * @function
- * @param {String} dialogId - the dialog ID
- * @returns {Function} function setting the dimension and position of the dialog box
- * @private
+ * @protected
+ * @param {string} message the message
*/
- _onDialogOpen(dialogId)
+ _setProgressMessage(message)
{
- const self = this;
+ this._progressMessage = message;
+ if (typeof this._progressMsg !== "undefined")
+ {
+ this._progressMsg.innerText = message;
+ }
+ }
- return () =>
+ /**
+ * Update the progress bar.
+ *
+ * @protected
+ */
+ _updateProgressBar()
+ {
+ if (typeof this._progressBar !== "undefined")
{
- const windowSize = [jQuery(window).width(), jQuery(window).height()];
-
- // note: jQuery(dialogId) is the dialog-content, jQuery(dialogId).parent() is the actual widget
- const parent = jQuery(dialogId).parent();
- parent.css({
- position: 'absolute',
- left: Math.max(0, (windowSize[0] - parent.outerWidth()) / 2.0),
- top: Math.max(0, (windowSize[1] - parent.outerHeight()) / 2.0)
- });
-
- // record width and height difference between dialog content and dialog:
- self._contentDelta = [
- parent.css('width').slice(0, -2) - jQuery(dialogId).css('width').slice(0, -2),
- parent.css('height').slice(0, -2) - jQuery(dialogId).css('height').slice(0, -2)];
- };
+ this._progressBar.style.width = `${Math.round(this._progressBarCurrentValue * 100.0 / this._progressBarMax)}%`;
+ }
}
+ /**
+ * Callback triggered when the participant presses the Cancel button
+ *
+ * @protected
+ */
+ _onCancelExperiment()
+ {
+ this._dialogComponent.button = "Cancel";
+
+ this._dialog.hide();
+ this._dialog = null;
+ this._dialogComponent.status = PsychoJS.Status.FINISHED;
+ }
/**
- * Ensure that the browser window's resize events redimension and reposition the dialog UI.
+ * Callback triggered when the participant presses the OK button
*
- * @name module:core.GUI#_dialogResize
- * @function
- * @param {String} dialogId - the dialog ID
- * @private
+ * @protected
*/
- _dialogResize(dialogId)
+ _onStartExperiment()
{
- const self = this;
+ this._dialogComponent.button = "OK";
- jQuery(window).resize(function ()
+ // update the dictionary:
+ Object.keys(this._dictionary).forEach((key, keyIdx) =>
{
- const parent = jQuery(dialogId).parent();
- const windowSize = [jQuery(window).width(), jQuery(window).height()];
-
- // size (we need to redimension both the dialog and the dialog content):
- const dialogSize = self._getDialogSize();
- parent.css({
- width: dialogSize[0],
- maxHeight: dialogSize[1]
- });
-
- const isDifferent = self._estimateDialogScalingFactor();
- if (!isDifferent)
+ const input = document.getElementById("form-input-" + keyIdx);
+ if (input)
{
- jQuery(dialogId).css({
- width: dialogSize[0] - self._contentDelta[0],
- maxHeight: dialogSize[1] - self._contentDelta[1]
- });
+ this._dictionary[key] = input.value;
}
-
- // position:
- parent.css({
- position: 'absolute',
- left: Math.max(0, (windowSize[0] - parent.outerWidth()) / 2.0),
- top: Math.max(0, (windowSize[1] - parent.outerHeight()) / 2.0),
- });
});
- }
+ // Start Tone here, since a user action is required to initiate the audio context:
+ Tone.start();
+
+ // switch to full screen if requested:
+ this._psychoJS.window.adjustScreenSize();
+
+ // clear all events (and keypresses) accumulated until now:
+ this._psychoJS.eventManager.clearEvents();
+
+ this._dialog.hide();
+ this._dialog = null;
+ this._dialogComponent.status = PsychoJS.Status.FINISHED;
+ }
+
/**
- * Listener for resource event from the [Server Manager]{@link ServerManager}.
+ * Callback triggered upon a resource event from the [Server Manager]{@link module:core.ServerManager}.
*
- * @name module:core.GUI#_onResourceEvents
- * @function
- * @private
- * @param {Object.<string, string|Symbol>} signal the signal
+ * @protected
+ * @param {Object.<string, string|Symbol>} signal - the ServerManager's signal
*/
_onResourceEvents(signal)
{
- this._psychoJS.logger.debug('signal: ' + util.toString(signal));
+ this._psychoJS.logger.debug("signal: " + util.toString(signal));
// the download of the specified resources has started:
if (signal.message === ServerManager.Event.DOWNLOADING_RESOURCES)
{
// for each resource, we have a 'downloading resource' and a 'resource downloaded' message:
this._progressBarMax = signal.count * 2;
- jQuery("#progressbar").progressbar("option", "max", this._progressBarMax);
-
this._progressBarCurrentValue = 0;
+ this._updateProgressBar();
}
-
// all the resources have been downloaded: show the ok button
else if (signal.message === ServerManager.Event.DOWNLOAD_COMPLETED)
{
this._allResourcesDownloaded = true;
- jQuery("#progressMsg").text('all resources downloaded.');
- this._updateOkButtonStatus();
- }
+ this._progressBarMax = 100;
+ this._progressBarCurrentValue = 100;
+ this._updateProgressBar();
+ this._setProgressMessage("all resources downloaded.");
+ this._updateDialog();
+ }
// update progress bar:
- else if (signal.message === ServerManager.Event.DOWNLOADING_RESOURCE
- || signal.message === ServerManager.Event.RESOURCE_DOWNLOADED)
+ else if (
+ signal.message === ServerManager.Event.DOWNLOADING_RESOURCE
+ || signal.message === ServerManager.Event.RESOURCE_DOWNLOADED
+ )
{
- if (typeof this._progressBarCurrentValue === 'undefined')
+ if (typeof this._progressBarCurrentValue === "undefined")
{
this._progressBarCurrentValue = 0;
}
@@ -647,129 +628,82 @@
Source: core/GUI.js
if (signal.message === ServerManager.Event.RESOURCE_DOWNLOADED)
{
- jQuery("#progressMsg").text('downloaded ' + (this._progressBarCurrentValue / 2) + ' / ' + (this._progressBarMax / 2));
+ this._setProgressMessage(`downloaded ${this._progressBarCurrentValue / 2} / ${this._progressBarMax / 2}`);
}
else
{
- jQuery("#progressMsg").text('downloading ' + (this._progressBarCurrentValue / 2) + ' / ' + (this._progressBarMax / 2));
+ this._setProgressMessage(`downloading ${this._progressBarCurrentValue / 2} / ${this._progressBarMax / 2}`);
}
- // $("#progressMsg").text(signal.resource + ': downloaded.');
- jQuery("#progressbar").progressbar("option", "value", this._progressBarCurrentValue);
- }
- // unknown message: we just display it
- else
- {
- jQuery("#progressMsg").text(signal.message);
- }
- }
-
-
- /**
- * Update the status of the OK button.
- *
- * @name module:core.GUI#_updateOkButtonStatus
- * @param [changeFocus = false] - whether or not to change the focus to the OK button
- * @function
- * @private
- */
- _updateOkButtonStatus(changeFocus = true)
- {
- if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL || (this._allResourcesDownloaded && this._setRequiredKeys && this._setRequiredKeys.size >= this._requiredKeys.length))
- {
- if (changeFocus)
- {
- jQuery("#buttonOk").button("option", "disabled", false).focus();
- }
- else
- {
- jQuery("#buttonOk").button("option", "disabled", false);
- }
+ this._updateProgressBar();
}
+ // unknown message: we just display it
else
{
- jQuery("#buttonOk").button("option", "disabled", true);
+ this._progressMsg.innerHTML = signal.message;
}
-
- // strangely, changing the disabled option sometimes fails to update the ui,
- // so we need to hide it and show it again:
- jQuery("#buttonOk").hide(0, () =>
- {
- jQuery("#buttonOk").show();
- });
}
-
/**
- * Estimate the scaling factor for the dialog popup windows.
+ * Update the dialog box.
*
- * @name module:core.GUI#_estimateDialogScalingFactor
- * @function
- * @private
- * @returns {boolean} whether or not the scaling factor is different from the previously estimated one
+ * @protected
+ * @param [changeOKButtonFocus = false] - whether to change the focus to the OK button
*/
- _estimateDialogScalingFactor()
+ _updateDialog(changeOKButtonFocus = true)
{
- const windowSize = [jQuery(window).width(), jQuery(window).height()];
-
- // desktop:
- let dialogScalingFactor = 1.0;
+ const allRequirementsFulfilled = this._allResourcesDownloaded
+ && (this._setRequiredKeys && this._setRequiredKeys.size >= this._requiredKeys.length);
- // mobile or tablet:
- if (windowSize[0] < 1080)
+ // if the participant is required to click on the OK button:
+ if (this._requireParticipantClick)
{
- // landscape:
- if (windowSize[0] > windowSize[1])
+ if (typeof this._okButton !== "undefined")
{
- dialogScalingFactor = 1.5;
- }// portrait:
- else
- {
- dialogScalingFactor = 2.0;
+ // locally the OK button is always enabled, otherwise only if all requirements have been fulfilled:
+ if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL || allRequirementsFulfilled)
+ {
+ if (changeOKButtonFocus)
+ {
+ this._okButton.classList = ["dialog-button"];
+ this._okButton.focus();
+ }
+ else
+ {
+ this._okButton.classList = ["dialog-button"];
+ }
+ }
+ else
+ {
+ this._okButton.classList = ["dialog-button", "disabled"];
+ }
}
- }
-
- const isDifferent = (dialogScalingFactor !== this._dialogScalingFactor);
- this._dialogScalingFactor = dialogScalingFactor;
- return isDifferent;
- }
+ return;
+ }
- /**
- * Get the size of the dialog.
- *
- * @name module:core.GUI#_getDialogSize
- * @private
- * @returns {number[]} the size of the popup dialog window
- */
- _getDialogSize()
- {
- const windowSize = [jQuery(window).width(), jQuery(window).height()];
- this._estimateDialogScalingFactor();
-
- return [
- Math.min(GUI.dialogMaxSize[0], (windowSize[0] - GUI.dialogMargin[0]) / this._dialogScalingFactor),
- Math.min(GUI.dialogMaxSize[1], (windowSize[1] - GUI.dialogMargin[1]) / this._dialogScalingFactor)];
+ // if all requirements are fulfilled and the participant is not required to click on the OK button,
+ // then we close the dialog box and move on with the experiment:
+ if (allRequirementsFulfilled)
+ {
+ this._onStartExperiment();
+ }
}
-
/**
- * Listener for change event for required keys.
+ * Callback triggered upon change event (for required keys).
*
- * @name module:core.GUI#_onKeyChange
- * @function
- * @static
- * @private
+ * @protected
* @param {module:core.GUI} gui - this GUI
- * @param {Event} event - event
+ * @param {Event} event - the key's event
*/
static _onKeyChange(gui, event)
{
const element = event.target;
const value = element.value;
- if (typeof value !== 'undefined' && value.length > 0)
+ if (typeof value !== "undefined" && value.length > 0)
{
gui._setRequiredKeys.set(event.target, true);
}
@@ -778,16 +712,15 @@
Source: core/GUI.js
gui._setRequiredKeys.delete(event.target);
}
- gui._updateOkButtonStatus(false);
+ gui._updateDialog(false);
}
-
/**
- * Get a more user-friendly html message.
+ * Get the user-friendly html message associated to a pavlovia.or server error code.
*
+ * @protected
* @param {number} errorCode - the pavlovia.org server error code
- * @private
- * @return {{htmlCode: string, titleColour: string}} a user-friendly error message
+ * @return {{class: string, title: string, text: string}} a user-friendly error message
*/
_userFriendlyError(errorCode)
{
@@ -796,111 +729,101 @@
Source: core/GUI.js
// INTERNAL_ERROR
case 1:
return {
- htmlCode: '<div id="msgDialog" title="Error"><p>Oops we encountered an internal server error.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p></div>',
- titleColour: 'red'
+ class: "dialog-error",
+ title: "Error",
+ text: "<p>Oops we encountered an <strong>internal server error</strong>.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p>"
};
// MONGODB_ERROR
case 2:
return {
- htmlCode: '<div id="msgDialog" title="Error"><p>Oops we encountered a database error.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p></div>',
- titleColour: 'red'
+ class: "dialog-error",
+ title: "Error",
+ text: "<p>Oops we encountered a <strong>database error</strong>.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p>"
};
// STATUS_NONE
case 20:
return {
- htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> does not have any status and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the status must be changed to RUNNING for participants to be able to run it.</p></div>`,
- titleColour: 'orange'
+ class: "dialog-warning",
+ title: "Warning",
+ text: `<p><strong>${this._psychoJS.config.experiment.fullpath}</strong> does not have any status and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the status must be changed to RUNNING for participants to be able to run it.</p>`
};
// STATUS_INACTIVE
case 21:
return {
- htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is currently inactive and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the status must be changed to RUNNING for participants to be able to run it.</p></div>`,
- titleColour: 'orange'
+ class: "dialog-warning",
+ title: "Warning",
+ text: `<p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is currently inactive and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the status must be changed to RUNNING for participants to be able to run it.</p>`
};
// STATUS_DELETED
case 22:
return {
- htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> has been deleted and cannot be run.</p><p>If you are the experiment designer, either go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING, or generate a new experiment.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment has been deleted and cannot be run any longer.</p></div>`,
- titleColour: 'orange'
+ class: "dialog-warning",
+ title: "Warning",
+ text: `<p><strong>${this._psychoJS.config.experiment.fullpath}</strong> has been deleted and cannot be run.</p><p>If you are the experiment designer, either go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING, or generate a new experiment.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment has been deleted and cannot be run any longer.</p>`
};
// STATUS_ARCHIVED
case 23:
return {
- htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> has been archived and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment has been archived and cannot be run at the moment.</p></div>`,
- titleColour: 'orange'
+ class: "dialog-warning",
+ title: "Warning",
+ text: `<p><strong>${this._psychoJS.config.experiment.fullpath}</strong> has been archived and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment has been archived and cannot be run at the moment.</p>`
};
// PILOTING_NO_TOKEN
case 30:
return {
- htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is currently in PILOTING mode but the pilot token is missing from the URL.</p><p>If you are the experiment designer, you can pilot it by pressing the pilot button on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment status must be changed to RUNNING for participants to be able to run it.</p></div>`,
- titleColour: 'orange'
+ class: "dialog-warning",
+ title: "Warning",
+ text: `<p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is currently in PILOTING mode but the pilot token is missing from the URL.</p><p>If you are the experiment designer, you can pilot it by pressing the pilot button on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment status must be changed to RUNNING for participants to be able to run it.</p>`
};
// PILOTING_INVALID_TOKEN
case 31:
return {
- htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> cannot be run because the pilot token in the URL is invalid, possibly because it has expired.</p><p>If you are the experiment designer, you can generate a new token by pressing the pilot button on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment status must be changed to RUNNING for participants to be able to run it.</p></div>`,
- titleColour: 'orange'
+ class: "dialog-warning",
+ title: "Warning",
+ text: `<p><strong>${this._psychoJS.config.experiment.fullpath}</strong> cannot be run because the pilot token in the URL is invalid, possibly because it has expired.</p><p>If you are the experiment designer, you can generate a new token by pressing the pilot button on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment status must be changed to RUNNING for participants to be able to run it.</p>`
};
// LICENSE_EXPIRED
case 50:
return {
- htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is covered by a license that has expired. </p><p>If you are the experiment designer, you can either contact the license manager to inquire about the expiration, or you can run your experiments using credits. You will find all relevant details about the license on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>, where you will also be able to change its running mode to CREDIT.</p><p>Otherwise please contact the experiment designer to let him or her know that there is an issue with the experiment's license having expired.</p></div>`,
- titleColour: 'orange'
+ class: "dialog-warning",
+ title: "Warning",
+ text: `<p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is covered by a license that has expired. </p><p>If you are the experiment designer, you can either contact the license manager to inquire about the expiration, or you can run your experiments using credits. You will find all relevant details about the license on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>, where you will also be able to change its running mode to CREDIT.</p><p>Otherwise please contact the experiment designer to let him or her know that there is an issue with the experiment's license having expired.</p>`
};
// LICENSE_APPROVAL_NEEDED
case 51:
return {
- htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is covered by a license that requires one or more documents to be approved before the experiment can be run. </p><p>If you are the experiment designer, please contact the license manager and ask him or her which documents must be approved. You will find all relevant details about the license on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that there is an issue with the experiment's license requiring documents to be approved.</p></div>`,
- titleColour: 'orange'
+ class: "dialog-warning",
+ title: "Warning",
+ text: `<p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is covered by a license that requires one or more documents to be approved before the experiment can be run. </p><p>If you are the experiment designer, please contact the license manager and ask him or her which documents must be approved. You will find all relevant details about the license on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that there is an issue with the experiment's license requiring documents to be approved.</p>`
};
// CREDIT_NOT_ENOUGH
case 60:
return {
- htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> does not have any assigned credit left and cannot be run.</p><p>If you are the experiment designer, you can assign more credits to it on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment requires more assigned credits to run.</p></div>`,
- titleColour: 'orange'
+ class: "dialog-warning",
+ title: "Warning",
+ text: `<p><strong>${this._psychoJS.config.experiment.fullpath}</strong> does not have any assigned credit left and cannot be run.</p><p>If you are the experiment designer, you can assign more credits to it on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment requires more assigned credits to run.</p>`
};
default:
return {
- htmlCode: `<div id="msgDialog" title="Error"><p>Unfortunately we encountered an unspecified error (error code: ${errorCode}.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p></div>`,
- titleColour: 'red'
+ class: "dialog-error",
+ title: "Error",
+ text: `<p>Unfortunately we encountered an unspecified error (error code: ${errorCode}.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p>`
};
}
}
-
}
-
-/**
- * Maximal dimensions of the dialog window.
- *
- * @name module:core.GUI#dialogMaxSize
- * @enum {Symbol}
- * @readonly
- * @public
- */
-GUI.dialogMaxSize = [500, 600];
-
-
-/**
- * Dialog window margins.
- *
- * @name module:core.GUI#dialogMargin
- * @enum {Symbol}
- * @readonly
- * @public
- */
-GUI.dialogMargin = [50, 50];
@@ -908,19 +831,23 @@
* Manager handling the keyboard events.
*
* @author Alain Pitiot
- * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @version 2022.2.3
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
-import {Clock, MonotonicClock} from "../util/Clock";
-import {PsychObject} from "../util/PsychObject";
-import {PsychoJS} from "./PsychoJS";
-import {EventManager} from "./EventManager";
-
+import { Clock, MonotonicClock } from "../util/Clock.js";
+import { PsychObject } from "../util/PsychObject.js";
+import { EventManager } from "./EventManager.js";
+import { PsychoJS } from "./PsychoJS.js";
/**
- * @name module:core.KeyPress
- * @class
- *
- * @param {string} code - W3C Key Code
- * @param {number} tDown - time of key press (keydown event) relative to the global Monotonic Clock
- * @param {string | undefined} name - pyglet key name
+ * <pKeyPress holds information about a key that has been pressed, such as the duration of the press.</p>
*/
export class KeyPress
{
+ /**
+ * @memberof module:core
+ * @param {string} code - W3C Key Code
+ * @param {number} tDown - time of key press (keydown event) relative to the global Monotonic Clock
+ * @param {string | undefined} name - pyglet key name
+ */
constructor(code, tDown, name)
{
this.code = code;
this.tDown = tDown;
- this.name = (typeof name !== 'undefined') ? name : EventManager.w3c2pyglet(code);
+ this.name = (typeof name !== "undefined") ? name : EventManager.w3c2pyglet(code);
// duration of the keypress (time between keydown and keyup events) or undefined if there was no keyup
this.duration = undefined;
@@ -65,83 +89,68 @@
Source: core/Keyboard.js
}
}
-
/**
* <p>This manager handles all keyboard events. It is a substitute for the keyboard component of EventManager. </p>
*
- * @name module:core.Keyboard
- * @class
- * @param {Object} options
- * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
- * @param {number} [options.bufferSize= 10000] - the maximum size of the circular keyboard event buffer
- * @param {boolean} [options.waitForStart= false] - whether or not to wait for a call to module:core.Keyboard#start
- * before recording keyboard events
- * @param {Clock} [options.clock= undefined] - an optional clock
- * @param {boolean} [options.autoLog= false] - whether or not to log
+ * @extends PsychObject
*/
export class Keyboard extends PsychObject
{
-
+ /**
+ * @memberof module:core
+ * @param {Object} options
+ * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
+ * @param {number} [options.bufferSize= 10000] - the maximum size of the circular keyboard event buffer
+ * @param {boolean} [options.waitForStart= false] - whether or not to wait for a call to module:core.Keyboard#start
+ * before recording keyboard events
+ * @param {Clock} [options.clock= undefined] - an optional clock
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ */
constructor({
- psychoJS,
- bufferSize = 10000,
- waitForStart = false,
- clock,
- autoLog = false,
- } = {})
+ psychoJS,
+ bufferSize = 10000,
+ waitForStart = false,
+ clock,
+ autoLog = false,
+ } = {})
{
-
super(psychoJS);
- if (typeof clock === 'undefined')
+ if (typeof clock === "undefined")
{
clock = new Clock();
- } //this._psychoJS.monotonicClock;
+ } // this._psychoJS.monotonicClock;
- this._addAttribute('bufferSize', bufferSize);
- this._addAttribute('waitForStart', waitForStart);
- this._addAttribute('clock', clock);
- this._addAttribute('autoLog', autoLog);
+ this._addAttribute("bufferSize", bufferSize);
+ this._addAttribute("waitForStart", waitForStart);
+ this._addAttribute("clock", clock);
+ this._addAttribute("autoLog", autoLog);
// start recording key events if need be:
- this._addAttribute('status', (waitForStart) ? PsychoJS.Status.NOT_STARTED : PsychoJS.Status.STARTED);
+ this._addAttribute("status", (waitForStart) ? PsychoJS.Status.NOT_STARTED : PsychoJS.Status.STARTED);
// setup circular buffer:
this.clearEvents();
// add key listeners:
this._addKeyListeners();
-
}
-
/**
* Start recording keyboard events.
- *
- * @name module:core.Keyboard#start
- * @function
- * @public
- *
*/
start()
{
this._status = PsychoJS.Status.STARTED;
}
-
/**
* Stop recording keyboard events.
- *
- * @name module:core.Keyboard#stop
- * @function
- * @public
- *
*/
stop()
{
this._status = PsychoJS.Status.STOPPED;
}
-
/**
* @typedef Keyboard.KeyEvent
*
@@ -155,9 +164,6 @@
Source: core/Keyboard.js
* Get the list of those keyboard events still in the buffer, i.e. those that have not been
* previously cleared by calls to getKeys with clear = true.
*
- * @name module:core.Keyboard#getEvents
- * @function
- * @public
* @return {Keyboard.KeyEvent[]} the list of events still in the buffer
*/
getEvents()
@@ -167,7 +173,6 @@
Source: core/Keyboard.js
return [];
}
-
// iterate over the buffer, from start to end, and discard the null event:
let filteredEvents = [];
const bufferWrap = (this._bufferLength === this._bufferSize);
@@ -185,13 +190,9 @@
Source: core/Keyboard.js
return filteredEvents;
}
-
/**
* Get the list of keys pressed or pushed by the participant.
*
- * @name module:core.Keyboard#getKeys
- * @function
- * @public
* @param {Object} options
* @param {string[]} [options.keyList= []]] - the list of keys to consider. If keyList is empty, we consider all keys.
* Note that we use pyglet keys here, to make the PsychoJs code more homogeneous with PsychoPy.
@@ -202,12 +203,11 @@
Source: core/Keyboard.js
* (keydown with no subsequent keyup at the time getKeys is called).
*/
getKeys({
- keyList = [],
- waitRelease = true,
- clear = true
- } = {})
+ keyList = [],
+ waitRelease = true,
+ clear = true,
+ } = {})
{
-
// if nothing in the buffer, return immediately:
if (this._bufferLength === 0)
{
@@ -231,7 +231,7 @@
Source: core/Keyboard.js
{
// look for a corresponding, preceding keydown event:
const precedingKeydownIndex = keyEvent.keydownIndex;
- if (typeof precedingKeydownIndex !== 'undefined')
+ if (typeof precedingKeydownIndex !== "undefined")
{
const precedingKeydownEvent = this._circularBuffer[precedingKeydownIndex];
if (precedingKeydownEvent)
@@ -278,13 +278,10 @@
Source: core/Keyboard.js
{
this._circularBuffer[i] = null;
}
-
}
}
-
} while (i !== this._bufferIndex);
-
// if waitRelease = false, we iterate again over the map of unmatched keydown events:
if (!waitRelease)
{
@@ -331,23 +328,17 @@
Source: core/Keyboard.js
} while (i !== this._bufferIndex);*/
}
-
// if clear = true and the keyList is empty, we clear all the events:
if (clear && keyList.length === 0)
{
this.clearEvents();
}
-
return keyPresses;
}
-
/**
* Clear all events and resets the circular buffers.
- *
- * @name module:core.Keyboard#clearEvents
- * @function
*/
clearEvents()
{
@@ -361,13 +352,9 @@
Source: core/Keyboard.js
this._unmatchedKeydownMap = new Map();
}
-
/**
* Test whether a list of KeyPress's contains one with a particular name.
*
- * @name module:core.Keyboard#includes
- * @function
- * @static
* @param {module:core.KeyPress[]} keypressList - list of KeyPress's
* @param {string } keyName - pyglet key name, e.g. 'escape', 'left'
* @return {boolean} whether or not a KeyPress with the given pyglet key name is present in the list
@@ -380,26 +367,22 @@
Source: core/Keyboard.js
}
const value = keypressList.find((keypress) => keypress.name === keyName);
- return (typeof value !== 'undefined');
+ return (typeof value !== "undefined");
}
-
/**
* Add key listeners to the document.
*
- * @name module:core.Keyboard#_addKeyListeners
- * @function
- * @private
+ * @protected
*/
_addKeyListeners()
{
this._previousKeydownKey = undefined;
const self = this;
-
// add a keydown listener:
window.addEventListener("keydown", (event) =>
- // document.addEventListener("keydown", (event) =>
+ // document.addEventListener("keydown", (event) =>
{
// only consider non-repeat events, i.e. only the first keydown event associated with a participant
// holding a key down:
@@ -426,14 +409,13 @@
Source: core/Keyboard.js
let code = event.code;
// take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge):
- if (typeof code === 'undefined')
+ if (typeof code === "undefined")
{
code = EventManager.keycode2w3c(event.keyCode);
}
let pigletKey = EventManager.w3c2pyglet(code);
-
self._bufferIndex = (self._bufferIndex + 1) % self._bufferSize;
self._bufferLength = Math.min(self._bufferLength + 1, self._bufferSize);
self._circularBuffer[self._bufferIndex] = {
@@ -441,20 +423,19 @@
let code = event.code;
// take care of legacy Microsoft Edge:
- if (typeof code === 'undefined')
+ if (typeof code === "undefined")
{
code = EventManager.keycode2w3c(event.keyCode);
}
@@ -482,39 +463,35 @@
Source: core/Keyboard.js
key: event.key,
pigletKey,
status: Keyboard.KeyStatus.KEY_UP,
- timestamp
+ timestamp,
};
// get the corresponding keydown event
// note: if more keys are down than there are slots in the circular buffer, there might
// not be a corresponding keydown event
const correspondingKeydownIndex = self._unmatchedKeydownMap.get(event.code);
- if (typeof correspondingKeydownIndex !== 'undefined')
+ if (typeof correspondingKeydownIndex !== "undefined")
{
self._circularBuffer[self._bufferIndex].keydownIndex = correspondingKeydownIndex;
self._unmatchedKeydownMap.delete(event.code);
}
- self._psychoJS.logger.trace('keyup: ', event.key);
+ self._psychoJS.logger.trace("keyup: ", event.key);
event.stopPropagation();
});
-
}
}
-
/**
* Keyboard KeyStatus.
*
- * @name module:core.Keyboard#KeyStatus
* @enum {Symbol}
* @readonly
- * @public
*/
Keyboard.KeyStatus = {
- KEY_DOWN: Symbol.for('KEY_DOWN'),
- KEY_UP: Symbol.for('KEY_UP')
+ KEY_DOWN: Symbol.for("KEY_DOWN"),
+ KEY_UP: Symbol.for("KEY_UP"),
};
@@ -523,19 +500,23 @@
// throttling message index:
index: 0,
// whether or not the designer has already been warned:
- designerWasWarned: false
+ designerWasWarned: false,
};
}
-
-
/**
* Change the logging level.
*
- * @name module:core.Logger#setLevel
- * @public
* @param {module:core.Logger.ServerLevel} serverLevel - the new logging level
*/
setLevel(serverLevel)
@@ -112,13 +130,9 @@
Source: core/Logger.js
this._serverLevelValue = this._getValue(this._serverLevel);
}
-
-
/**
* Log a server message at the EXP level.
*
- * @name module:core.Logger#exp
- * @public
* @param {string} msg - the message to be logged.
* @param {number} [time] - the logging time
* @param {object} [obj] - the associated object (e.g. a Trial)
@@ -128,13 +142,9 @@
Source: core/Logger.js
this.log(msg, Logger.ServerLevel.EXP, time, obj);
}
-
-
/**
* Log a server message at the DATA level.
*
- * @name module:core.Logger#data
- * @public
* @param {string} msg - the message to be logged.
* @param {number} [time] - the logging time
* @param {object} [obj] - the associated object (e.g. a Trial)
@@ -144,13 +154,9 @@
Source: core/Logger.js
this.log(msg, Logger.ServerLevel.DATA, time, obj);
}
-
-
/**
* Log a server message.
*
- * @name module:core.Logger#log
- * @public
* @param {string} msg - the message to be logged.
* @param {module:core.Logger.ServerLevel} level - logging level
* @param {number} [time] - the logging time
@@ -165,7 +171,7 @@
Source: core/Logger.js
return;
}
- if (typeof time === 'undefined')
+ if (typeof time === "undefined")
{
time = MonotonicClock.getReferenceTime();
}
@@ -182,18 +188,14 @@
Source: core/Logger.js
msg,
level,
time,
- obj: util.toString(obj)
+ obj: util.toString(obj),
});
}
-
-
/**
* Check whether or not a log messages must be throttled.
*
- * @name module:core.Logger#_throttle
* @protected
- *
* @param {number} time - the time of the latest log message
* @return {boolean} whether or not to log the message
*/
@@ -209,24 +211,26 @@
Source: core/Logger.js
// warn the designer if we are not already throttling:
if (!this._throttling.isThrottling)
{
- const msg = `<p>[time= ${time.toFixed(3)}] More than ${this._throttling.threshold} messages were logged in the past ${this._throttling.window}s.</p>` +
- `<p>We are now throttling: only 1 in ${this._throttling.factor} messages will be logged.</p>` +
- `<p>You may want to change your experiment's logging level. Please see <a href="https://www.psychopy.org/api/logging.html">psychopy.org/api/logging.html</a> for details.</p>`;
+ const msg = `<p>[time= ${time.toFixed(3)}] More than ${this._throttling.threshold} messages were logged in the past ${this._throttling.window}s.</p>`
+ + `<p>We are now throttling: only 1 in ${this._throttling.factor} messages will be logged.</p>`
+ + `<p>You may want to change your experiment's logging level. Please see <a href="https://www.psychopy.org/api/logging.html">psychopy.org/api/logging.html</a> for details.</p>`;
// console warning:
this._psychoJS.logger.warn(msg);
// in PILOTING mode and locally, we also warn the experimenter with a dialog box,
// but only once:
- if (!this._throttling.designerWasWarned &&
- (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL ||
- this._psychoJS.config.experiment.status === 'PILOTING'))
+ if (
+ !this._throttling.designerWasWarned
+ && (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL
+ || this._psychoJS.config.experiment.status === "PILOTING")
+ )
{
this._throttling.designerWasWarned = true;
this._psychoJS.gui.dialog({
warning: msg,
- showOK: true
+ showOK: true,
});
}
@@ -235,7 +239,7 @@
return customLayout;
}
-
-
/**
* Get the integer value associated with a logging level.
*
- * @name module:core.Logger#_getValue
* @protected
* @param {module:core.Logger.ServerLevel} level - the logging level
* @return {number} - the value associated with the logging level, or 30 is the logging level is unknown.
@@ -421,49 +418,43 @@
Source: core/Logger.js
}
}
-
-
/**
* Server logging level.
*
- * @name module:core.Logger#ServerLevel
* @enum {Symbol}
* @readonly
- * @public
*
* @note These are similar to PsychoPy's logging levels, as defined in logging.py
*/
Logger.ServerLevel = {
- CRITICAL: Symbol.for('CRITICAL'),
- ERROR: Symbol.for('ERROR'),
- WARNING: Symbol.for('WARNING'),
- DATA: Symbol.for('DATA'),
- EXP: Symbol.for('EXP'),
- INFO: Symbol.for('INFO'),
- DEBUG: Symbol.for('DEBUG'),
- NOTSET: Symbol.for('NOTSET')
+ CRITICAL: Symbol.for("CRITICAL"),
+ ERROR: Symbol.for("ERROR"),
+ WARNING: Symbol.for("WARNING"),
+ DATA: Symbol.for("DATA"),
+ EXP: Symbol.for("EXP"),
+ INFO: Symbol.for("INFO"),
+ DEBUG: Symbol.for("DEBUG"),
+ NOTSET: Symbol.for("NOTSET"),
};
-
/**
* Server logging level values.
*
* <p>We use those values to determine whether a log is to be sent to the server or not.</p>
*
- * @name module:core.Logger#_ServerLevelValue
* @enum {number}
* @readonly
* @protected
*/
Logger._ServerLevelValue = {
- 'CRITICAL': 50,
- 'ERROR': 40,
- 'WARNING': 30,
- 'DATA': 25,
- 'EXP': 22,
- 'INFO': 20,
- 'DEBUG': 10,
- 'NOTSET': 0
+ "CRITICAL": 50,
+ "ERROR": 40,
+ "WARNING": 30,
+ "DATA": 25,
+ "EXP": 22,
+ "INFO": 20,
+ "DEBUG": 10,
+ "NOTSET": 0,
};
@@ -472,19 +463,23 @@
* Base class for all stimuli.
*
* @author Alain Pitiot
- * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @version 2022.2.0
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
-
-import {PsychObject} from '../util/PsychObject';
-import {PsychoJS} from './PsychoJS';
-import * as util from '../util/Util';
-
-
+import { PsychObject } from "../util/PsychObject.js";
+import * as util from "../util/Util.js";
+import { PsychoJS } from "./PsychoJS.js";
/**
* <p>MinimalStim is the base class for all stimuli.</p>
*
- * @name module:core.MinimalStim
- * @class
* @extends PsychObject
- * @param {Object} options
- * @param {String} options.name - the name used when logging messages from this stimulus
- * @param {module:core.Window} options.win - the associated Window
- * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip
- * @param {boolean} [options.autoLog= win.autoLog] - whether or not to log
*/
export class MinimalStim extends PsychObject
{
- constructor({name, win, autoDraw, autoLog} = {})
+ /**
+ * @memberof module:core
+ * @param {Object} options
+ * @param {String} options.name - the name used when logging messages from this stimulus
+ * @param {module:core.Window} options.win - the associated Window
+ * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip
+ * @param {boolean} [options.autoLog= win.autoLog] - whether to log
+ */
+ constructor({ name, win, autoDraw, autoLog } = {})
{
super(win._psychoJS, name);
@@ -64,46 +86,40 @@
Source: core/MinimalStim.js
this._pixi = undefined;
this._addAttribute(
- 'win',
+ "win",
win,
- undefined
+ undefined,
);
this._addAttribute(
- 'autoDraw',
+ "autoDraw",
autoDraw,
- false
+ false,
);
this._addAttribute(
- 'autoLog',
+ "autoLog",
autoLog,
- (typeof win !== 'undefined' && win !== null) ? win.autoLog : false
+ (typeof win !== "undefined" && win !== null) ? win.autoLog : false,
);
this._needUpdate = false;
this.status = PsychoJS.Status.NOT_STARTED;
}
-
-
/**
* Setter for the autoDraw attribute.
*
- * @name module:core.MinimalStim#setAutoDraw
- * @function
- * @public
* @param {boolean} autoDraw - the new value
- * @param {boolean} [log= false] - whether or not to log
+ * @param {boolean} [log= false] - whether to log
*/
setAutoDraw(autoDraw, log = false)
{
- this._setAttribute('autoDraw', autoDraw, log);
+ this._setAttribute("autoDraw", autoDraw, log);
// autoDraw = true: add the stimulus to the draw list if it's not there already
if (this._autoDraw)
{
this.draw();
}
-
// autoDraw = false: remove the stimulus from the draw list (and from the root container if it's already there)
else
{
@@ -111,14 +127,8 @@
Source: core/MinimalStim.js
}
}
-
-
/**
* Draw this stimulus on the next frame draw.
- *
- * @name module:core.MinimalStim#draw
- * @function
- * @public
*/
draw()
{
@@ -131,13 +141,13 @@
Source: core/MinimalStim.js
{
// update the stimulus if need be before we add its PIXI representation to the window container:
this._updateIfNeeded();
- if (typeof this._pixi === 'undefined')
+ if (typeof this._pixi === "undefined")
{
- this.psychoJS.logger.warn('the Pixi.js representation of this stimulus is undefined.');
+ this.psychoJS.logger.warn("the Pixi.js representation of this stimulus is undefined.");
}
else
{
- this.win._rootContainer.addChild(this._pixi);
+ this._win.addPixiObject(this._pixi);
this.win._drawList.push(this);
}
}
@@ -145,11 +155,11 @@
Source: core/MinimalStim.js
{
// the stimulus is already in the list, if it needs to be updated, we remove it
// from the window container, update it, then put it back:
- if (this._needUpdate && typeof this._pixi !== 'undefined')
+ if (this._needUpdate && typeof this._pixi !== "undefined")
{
- this.win._rootContainer.removeChild(this._pixi);
+ this._win.removePixiObject(this._pixi);
this._updateIfNeeded();
- this.win._rootContainer.addChild(this._pixi);
+ this._win.addPixiObject(this._pixi);
}
}
}
@@ -157,14 +167,8 @@
Source: core/MinimalStim.js
this.status = PsychoJS.Status.STARTED;
}
-
-
/**
* Hide this stimulus on the next frame draw.
- *
- * @name module:core.MinimalStim#hide
- * @function
- * @public
*/
hide()
{
@@ -176,77 +180,62 @@
Source: core/MinimalStim.js
this._win._drawList.splice(index, 1);
// if the stimulus has a pixi representation, remove it from the root container:
- if (typeof this._pixi !== 'undefined')
+ if (typeof this._pixi !== "undefined")
{
- this._win._rootContainer.removeChild(this._pixi);
+ this._win.removePixiObject(this._pixi);
}
}
this.status = PsychoJS.Status.STOPPED;
}
}
-
-
/**
* Determine whether an object is inside this stimulus.
*
- * @name module:core.MinimalStim#contains
- * @function
* @abstract
- * @public
* @param {Object} object - the object
* @param {String} units - the stimulus units
*/
contains(object, units)
{
throw {
- origin: 'MinimalStim.contains',
+ origin: "MinimalStim.contains",
context: `when determining whether stimulus: ${this._name} contains object: ${util.toString(object)}`,
- error: 'this method is abstract and should not be called.'
+ error: "this method is abstract and should not be called.",
};
}
-
-
/**
* Release the PIXI representation, if there is one.
*
- * @name module:core.MinimalStim#release
- * @function
- * @public
- *
- * @param {boolean} [log= false] - whether or not to log
+ * @param {boolean} [log= false] - whether to log
*/
release(log = false)
{
- this._setAttribute('autoDraw', false, log);
+ this._setAttribute("autoDraw", false, log);
this.status = PsychoJS.Status.STOPPED;
- if (typeof this._pixi !== 'undefined')
+ if (typeof this._pixi !== "undefined")
{
this._pixi.destroy(true);
this._pixi = undefined;
}
}
-
-
/**
* Update the stimulus, if necessary.
*
* Note: this is an abstract function, which should not be called.
*
- * @name module:core.MinimalStim#_updateIfNeeded
- * @function
* @abstract
- * @private
+ * @protected
*/
_updateIfNeeded()
{
throw {
- origin: 'MinimalStim._updateIfNeeded',
- context: 'when updating stimulus: ' + this._name,
- error: 'this method is abstract and should not be called.'
+ origin: "MinimalStim._updateIfNeeded",
+ context: "when updating stimulus: " + this._name,
+ error: "this method is abstract and should not be called.",
};
}
}
@@ -257,19 +246,23 @@
*
* @author Alain Pitiot
* @author Sotiri Bakagiannis - isPressedIn
- * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @version 2022.2.3
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
-import {PsychoJS} from './PsychoJS';
-import {PsychObject} from '../util/PsychObject';
-import * as util from '../util/Util';
-
+import { PsychObject } from "../util/PsychObject.js";
+import * as util from "../util/Util.js";
+import { PsychoJS } from "./PsychoJS.js";
/**
* <p>This manager handles the interactions between the experiment's stimuli and the mouse.</p>
* <p>Note: the unit of Mouse is that of its associated Window.</p>
*
- * @name module:core.Mouse
- * @class
- * @extends PsychObject
- * @param {Object} options
- * @param {String} options.name - the name used when logging messages from this stimulus
- * @param {Window} options.win - the associated Window
- * @param {boolean} [options.autoLog= true] - whether or not to log
- *
* @todo visible is not handled at the moment (mouse is always visible)
*/
export class Mouse extends PsychObject
{
-
+ /**
+ * @memberof module:core
+ * @param {Object} options
+ * @param {String} options.name - the name used when logging messages from this stimulus
+ * @param {Window} options.win - the associated Window
+ * @param {boolean} [options.autoLog= true] - whether or not to log
+ */
constructor({
- name,
- win,
- autoLog = true
- } = {})
+ name,
+ win,
+ autoLog = true,
+ } = {})
{
super(win._psychoJS, name);
@@ -73,21 +94,17 @@
Source: core/Mouse.js
const units = win.units;
const visible = 1;
- this._addAttribute('win', win);
- this._addAttribute('units', units);
- this._addAttribute('visible', visible);
- this._addAttribute('autoLog', autoLog);
+ this._addAttribute("win", win);
+ this._addAttribute("units", units);
+ this._addAttribute("visible", visible);
+ this._addAttribute("autoLog", autoLog);
this.status = PsychoJS.Status.NOT_STARTED;
}
-
/**
* Get the current position of the mouse in mouse/Window units.
*
- * @name module:core.Mouse#getPos
- * @function
- * @public
* @return {Array.number} the position of the mouse in mouse/Window units
*/
getPos()
@@ -101,24 +118,20 @@
Source: core/Mouse.js
pos_px[1] = this.win.size[1] / 2 - pos_px[1];
// convert to window units:
- this._lastPos = util.to_win(pos_px, 'pix', this._win);
+ this._lastPos = util.to_win(pos_px, "pix", this._win);
return this._lastPos;
}
-
/**
* Get the position of the mouse relative to that at the last call to getRel
* or getPos, in mouse/Window units.
*
- * @name module:core.Mouse#getRel
- * @function
- * @public
* @return {Array.number} the relation position of the mouse in mouse/Window units.
*/
getRel()
{
- if (typeof this._lastPos === 'undefined')
+ if (typeof this._lastPos === "undefined")
{
return this.getPos();
}
@@ -131,16 +144,12 @@
Source: core/Mouse.js
}
}
-
/**
* Get the travel of the mouse scroll wheel since the last call to getWheelRel.
*
* <p>Note: Even though this method returns a [x, y] array, for most wheels/systems y is the only
* value that varies.</p>
*
- * @name module:core.Mouse#getWheelRel
- * @function
- * @public
* @return {Array.number} the mouse scroll wheel travel
*/
getWheelRel()
@@ -149,21 +158,17 @@
Source: core/Mouse.js
const wheelRel_px = mouseInfo.wheelRel.slice();
// convert to window units:
- const wheelRel = util.to_win(wheelRel_px, 'pix', this._win);
+ const wheelRel = util.to_win(wheelRel_px, "pix", this._win);
mouseInfo.wheelRel = [0, 0];
return wheelRel;
}
-
/**
* Get the status of each button (pressed or released) and, optionally, the time elapsed between the last call to [clickReset]{@link module:core.Mouse#clickReset} and the pressing or releasing of the buttons.
*
* <p>Note: clickReset is typically called at stimulus onset. When the participant presses a button, the time elapsed since the clickReset is stored internally and can be accessed any time afterwards with getPressed.</p>
*
- * @name module:core.Mouse#getPressed
- * @function
- * @public
* @param {boolean} [getTime= false] whether or not to also return timestamps
* @return {Array.number | Array.<Array.number>} either an array of size 3 with the status (1 for pressed, 0 for released) of each mouse button [left, center, right], or a tuple with that array and another array of size 3 with the timestamps.
*/
@@ -181,13 +186,9 @@
Source: core/Mouse.js
}
}
-
/**
* Helper method for checking whether a stimulus has had any button presses within bounds.
*
- * @name module:core.Mouse#isPressedIn
- * @function
- * @public
* @param {object|module:visual.VisualStim} shape A type of visual stimulus or object having a `contains()` method.
* @param {object|number} [buttons] The target button index potentially tucked inside an object.
* @param {object} [options]
@@ -198,14 +199,14 @@
Source: core/Mouse.js
isPressedIn(...args)
{
// Look for options given in object literal form, cut out falsy inputs
- const [{ shape: shapeMaybe, buttons: buttonsMaybe } = {}] = args.filter(v => !!v);
+ const [{ shape: shapeMaybe, buttons: buttonsMaybe } = {}] = args.filter((v) => !!v);
// Helper to check if some object features a certain key
- const hasKey = key => object => !!(object && object[key]);
+ const hasKey = (key) => (object) => !!(object && object[key]);
// Shapes are expected to be instances of stimuli, or at
// the very least objects featuring a `contains()` method
- const isShape = hasKey('contains');
+ const isShape = hasKey("contains");
// Go through arguments array looking for a shape if options object offers none
const shapeFound = isShape(shapeMaybe) ? shapeMaybe : args.find(isShape);
@@ -215,23 +216,23 @@
Source: core/Mouse.js
// Buttons values may be extracted from an object
// featuring the `buttons` key, or found as integers
// in the arguments array
- const hasButtons = hasKey('buttons');
+ const hasButtons = hasKey("buttons");
const { isInteger } = Number;
// Prioritize buttons value given as part of an options object,
// then look for the first occurrence in the arguments array of either
// an integer or an extra object with a `buttons` key
- const buttonsFound = isInteger(buttonsMaybe) ? buttonsMaybe : args.find(o => hasButtons(o) || isInteger(o));
+ const buttonsFound = isInteger(buttonsMaybe) ? buttonsMaybe : args.find((o) => hasButtons(o) || isInteger(o));
// Worst case scenario `wanted` ends up being an empty object
const { buttons: wanted = buttonsFound || buttonsMaybe } = buttonsFound || {};
// Will throw if stimulus is falsy or non-object like
- if (typeof shape.contains === 'function')
+ if (typeof shape.contains === "function")
{
const mouseInfo = this.psychoJS.eventManager.getMouseInfo();
const { pressed } = mouseInfo.buttons;
// If no specific button wanted, any pressed will do
- const hasButtonPressed = isInteger(wanted) ? pressed[wanted] > 0 : pressed.some(v => v > 0);
+ const hasButtonPressed = isInteger(wanted) ? pressed[wanted] > 0 : pressed.some((v) => v > 0);
return hasButtonPressed && shape.contains(this);
}
@@ -239,7 +240,6 @@
Source: core/Mouse.js
return false;
}
-
/**
* Determine whether the mouse has moved beyond a certain distance.
*
@@ -258,9 +258,6 @@
Source: core/Mouse.js
* <li>mouseMoved(distance, [x: number, y: number]: artifically set the previous mouse position to the given coordinates and determine whether the mouse moved further than the given distance</li>
* </ul></p>
*
- * @name module:core.Mouse#mouseMoved
- * @function
- * @public
* @param {undefined|number|Array.number} [distance] - the distance to which the mouse movement is compared (see above for a full description)
* @param {boolean|String|Array.number} [reset= false] - see above for a full description
* @return {boolean} see above for a full description
@@ -268,24 +265,26 @@
* Main component of the PsychoJS library.
*
* @author Alain Pitiot
- * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @version 2022.2.3
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
-import log4javascript from 'log4javascript';
-import {Scheduler} from '../util/Scheduler';
-import {ServerManager} from './ServerManager';
-import {ExperimentHandler} from '../data/ExperimentHandler';
-import {EventManager} from './EventManager';
-import {Window} from './Window';
-import {GUI} from './GUI';
-import {MonotonicClock} from '../util/Clock';
-import {Logger} from './Logger';
-import * as util from '../util/Util';
-// import {Shelf} from "../data/Shelf";
-
+import log4javascript from "log4javascript";
+import { ExperimentHandler } from "../data/ExperimentHandler.js";
+import { MonotonicClock } from "../util/Clock.js";
+import { Scheduler } from "../util/Scheduler.js";
+import * as util from "../util/Util.js";
+import { EventManager } from "./EventManager.js";
+import { GUI } from "./GUI.js";
+import { Logger } from "./Logger.js";
+import { ServerManager } from "./ServerManager.js";
+import { Window } from "./Window.js";
+import {Shelf} from "../data/Shelf";
/**
- * <p>PsychoJS manages the lifecycle of an experiment. It initialises the PsychoJS library and its various components (e.g. the {@link ServerManager}, the {@link EventManager}), and is used by the experiment to schedule the various tasks.</p>
- *
- * @class
- * @param {Object} options
- * @param {boolean} [options.debug= true] whether or not to log debug information in the browser console
- * @param {boolean} [options.collectIP= false] whether or not to collect the IP information of the participant
+ * <p>PsychoJS initialises the library and its various components (e.g. the [ServerManager]{@link module:core.ServerManager}, the [EventManager]{@link module:core.EventManager}), and manages
+ * the lifecycle of an experiment.</p>
*/
export class PsychoJS
{
-
- /**
- * Properties
- */
get status()
{
return this._status;
@@ -139,22 +154,22 @@
Source: core/PsychoJS.js
return this._browser;
}
- // get shelf()
- // {
- // return this._shelf;
- // }
-
+ get shelf()
+ {
+ return this._shelf;
+ }
/**
- * @constructor
- * @public
+ * @param {Object} options
+ * @param {boolean} [options.debug= true] whether to log debug information in the browser console
+ * @param {boolean} [options.collectIP= false] whether to collect the IP information of the participant
*/
constructor({
- debug = true,
- collectIP = false,
- hosts = [],
- topLevelStatus = true
- } = {})
+ debug = true,
+ collectIP = false,
+ hosts = [],
+ topLevelStatus = true,
+ } = {})
{
// logging:
this._logger = new Logger(this, (debug) ? log4javascript.Level.DEBUG : log4javascript.Level.INFO);
@@ -162,7 +177,7 @@
// managers:
this._eventManager = new EventManager(this);
this._serverManager = new ServerManager({
- psychoJS: this
+ psychoJS: this,
});
- // to be loading `configURL` files in `_configure` calls from
- const hostsEvidently = new Set([...hosts, 'https://pavlovia.org/run/', 'https://run.pavlovia.org/']);
- this._hosts = Array.from(hostsEvidently);
+ // add the pavlovia server to the list of hosts:
+ const hostsWithPavlovia = new Set([...hosts, "https://pavlovia.org/run/", "https://run.pavlovia.org/"]);
+ this._hosts = Array.from(hostsWithPavlovia);
// GUI:
this._gui = new GUI(this);
@@ -189,8 +204,8 @@
*/
getEnvironment()
{
- if (typeof this._config === 'undefined')
+ if (typeof this._config === "undefined")
{
return undefined;
}
return this._config.environment;
}
-
/**
* Open a PsychoJS Window.
*
@@ -244,26 +258,25 @@
Source: core/PsychoJS.js
* @param {boolean} [options.waitBlanking] whether or not to wait for all rendering operations to be done
* before flipping
* @throws {Object.<string, *>} exception if a window has already been opened
- *
- * @public
*/
openWindow({
- name,
- fullscr,
- color,
- units,
- waitBlanking,
- autoLog
- } = {})
+ name,
+ fullscr,
+ color,
+ gamma,
+ units,
+ waitBlanking,
+ autoLog,
+ } = {})
{
- this.logger.info('[PsychoJS] Open Window.');
+ this.logger.info("[PsychoJS] Open Window.");
- if (typeof this._window !== 'undefined')
+ if (typeof this._window !== "undefined")
{
throw {
- origin: 'PsychoJS.openWindow',
- context: 'when opening a Window',
- error: 'A Window has already been opened.'
+ origin: "PsychoJS.openWindow",
+ context: "when opening a Window",
+ error: "A Window has already been opened.",
};
}
@@ -272,13 +285,13 @@
Source: core/PsychoJS.js
name,
fullscr,
color,
+ gamma,
units,
waitBlanking,
- autoLog
+ autoLog,
});
}
-
/**
* Set the completion and cancellation URL to which the participant will be redirect at the end of the experiment.
*
@@ -291,22 +304,19 @@
Source: core/PsychoJS.js
this._cancellationUrl = cancellationUrl;
}
-
/**
* Schedule a task.
*
- * @param task - the task to be scheduled
- * @param args - arguments for that task
- * @public
+ * @param {module:util.Scheduler~Task} task - the task to be scheduled
+ * @param {*} args - arguments for that task
*/
schedule(task, args)
{
- this.logger.debug('schedule task: ', task.toString().substring(0, 50), '...');
+ this.logger.debug("schedule task: ", task.toString().substring(0, 50), "...");
this._scheduler.add(task, args);
}
-
/**
* @callback PsychoJS.condition
* @return {boolean} true if the thenScheduler is to be run, false if the elseScheduler is to be run
@@ -315,18 +325,16 @@
Source: core/PsychoJS.js
* Schedule a series of task based on a condition.
*
* @param {PsychoJS.condition} condition
- * @param {Scheduler} thenScheduler scheduler to run if the condition is true
- * @param {Scheduler} elseScheduler scheduler to run if the condition is false
- * @public
+ * @param {Scheduler} thenScheduler - scheduler to run if the condition is true
+ * @param {Scheduler} elseScheduler - scheduler to run if the condition is false
*/
scheduleCondition(condition, thenScheduler, elseScheduler)
{
- this.logger.debug('schedule condition: ', condition.toString().substring(0, 50), '...');
+ this.logger.debug("schedule condition: ", condition.toString().substring(0, 50), "...");
this._scheduler.addConditional(condition, thenScheduler, elseScheduler);
}
-
/**
* Start the experiment.
*
@@ -346,14 +354,12 @@
Source: core/PsychoJS.js
* @param {string} [options.expName=UNKNOWN] - the name of the experiment
* @param {Object.<string, *>} [options.expInfo] - additional information about the experiment
* @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources
- * @async
- * @public
*/
- async start({configURL = 'config.json', expName = 'UNKNOWN', expInfo = {}, resources = []} = {})
+ async start({ configURL = "config.json", expName = "UNKNOWN", expInfo = {}, resources = [], dataFileName } = {})
{
this.logger.debug();
- const response = {origin: 'PsychoJS.start', context: 'when starting the experiment'};
+ const response = { origin: "PsychoJS.start", context: "when starting the experiment" };
try
{
@@ -368,24 +374,25 @@
event.preventDefault();
// Chrome requires returnValue to be set:
- event.returnValue = '';
+ event.returnValue = "";
};
- window.addEventListener('beforeunload', this.beforeunloadCallback);
-
+ window.addEventListener("beforeunload", this.beforeunloadCallback);
// when the user closes the tab or browser, we attempt to close the session,
// optionally save the results, and release the WebGL context
// note: we communicate with the server using the Beacon API
const self = this;
- window.addEventListener('unload', (event) =>
+ window.addEventListener("unload", (event) =>
{
- if (self._config.session.status === 'OPEN')
+ if (self._config.session.status === "OPEN")
{
// save the incomplete results if need be:
if (self._config.experiment.saveIncompleteResults)
{
- self._experiment.save({sync: true});
+ self._experiment.save({ sync: true });
}
// close the session:
self._serverManager.closeSession(false, true);
}
- if (typeof self._window !== 'undefined')
+ if (typeof self._window !== "undefined")
{
self._window.close();
}
});
-
}
-
// start the asynchronous download of resources:
- await this._serverManager.prepareResources(resources);
+ this._serverManager.prepareResources(resources);
// start the experiment:
- this.logger.info('[PsychoJS] Start Experiment.');
+ this.logger.info("[PsychoJS] Start Experiment.");
await this._scheduler.start();
}
catch (error)
{
// this._gui.dialog({ error: { ...response, error } });
- this._gui.dialog({error: Object.assign(response, {error})});
+ this._gui.dialog({ error: Object.assign(response, { error }) });
}
}
-
-
/**
* Block the experiment until the specified resources have been downloaded.
*
@@ -462,13 +464,12 @@
Source: core/PsychoJS.js
* local to index.html unless they are prepended with a protocol.</li>
*
* @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources
- * @public
*/
waitForResources(resources = [])
{
const response = {
- origin: 'PsychoJS.waitForResources',
- context: 'while waiting for resources to be downloaded'
+ origin: "PsychoJS.waitForResources",
+ context: "while waiting for resources to be downloaded",
};
try
@@ -478,36 +479,30 @@
Source: core/PsychoJS.js
catch (error)
{
// this._gui.dialog({ error: { ...response, error } });
- this._gui.dialog({error: Object.assign(response, {error})});
+ this._gui.dialog({ error: Object.assign(response, { error }) });
}
}
-
-
/**
- * Make the attributes of the given object those of PsychoJS and those of
- * the top level variable (e.g. window) as well.
+ * Make the attributes of the given object those of window, such that they become global.
*
- * @param {Object.<string, *>} obj the object whose attributes we will mirror
- * @public
+ * @param {Object.<string, *>} obj the object whose attributes are to become global
*/
importAttributes(obj)
{
- this.logger.debug('import attributes from: ', util.toString(obj));
+ this.logger.debug("import attributes from: ", util.toString(obj));
- if (typeof obj === 'undefined')
+ if (typeof obj === "undefined")
{
return;
}
for (const attribute in obj)
{
- // this[attribute] = obj[attribute];
window[attribute] = obj[attribute];
}
}
-
/**
* Close everything and exit nicely at the end of the experiment,
* potentially redirecting to one of the URLs previously specified by setRedirectUrls.
@@ -517,16 +512,15 @@
Source: core/PsychoJS.js
*
* @param {Object} options
* @param {string} [options.message] - optional message to be displayed in a dialog box before quitting
- * @param {boolean} [options.isCompleted = false] - whether or not the participant has completed the experiment
- * @async
- * @public
+ * @param {boolean} [options.isCompleted = false] - whether the participant has completed the experiment
*/
- async quit({message, isCompleted = false} = {})
+ async quit({ message, isCompleted = false } = {})
{
- this.logger.info('[PsychoJS] Quit.');
+ this.logger.info("[PsychoJS] Quit.");
this._experiment.experimentEnded = true;
this._status = PsychoJS.Status.FINISHED;
+ const isServerEnv = this.getEnvironment() === ExperimentHandler.Environment.SERVER;
try
{
@@ -534,34 +528,38 @@
Source: core/PsychoJS.js
this._scheduler.stop();
// remove the beforeunload listener:
- if (this.getEnvironment() === ExperimentHandler.Environment.SERVER)
+ if (isServerEnv)
{
- window.removeEventListener('beforeunload', this.beforeunloadCallback);
+ window.removeEventListener("beforeunload", this.beforeunloadCallback);
}
// save the results and the logs of the experiment:
- this.gui.dialog({
- warning: 'Closing the session. Please wait a few moments.',
- showOK: false
+ this.gui.finishDialog({
+ text: "Terminating the experiment. Please wait a few moments...",
+ nbSteps: 2 + ((isServerEnv) ? 1 : 0)
});
+
if (isCompleted || this._config.experiment.saveIncompleteResults)
{
- if (!this._serverMsg.has('__noOutput'))
+ if (!this._serverMsg.has("__noOutput"))
{
+ this.gui.finishDialogNextStep("saving results");
await this._experiment.save();
+ this.gui.finishDialogNextStep("saving logs");
await this._logger.flush();
}
}
// close the session:
- if (this.getEnvironment() === ExperimentHandler.Environment.SERVER)
+ if (isServerEnv)
{
+ this.gui.finishDialogNextStep("closing the session");
await this._serverManager.closeSession(isCompleted);
}
// thank participant for waiting and either quit or redirect:
- let text = 'Thank you for your patience.<br/><br/>';
- text += (typeof message !== 'undefined') ? message : 'Goodbye!';
+ let text = "Thank you for your patience.<br/><br/>";
+ text += (typeof message !== "undefined") ? message : "Goodbye!";
const self = this;
this._gui.dialog({
message: text,
@@ -580,30 +578,28 @@
Source: core/PsychoJS.js
this._window.closeFullScreen();
// redirect if redirection URLs have been provided:
- if (isCompleted && typeof self._completionUrl !== 'undefined')
+ if (isCompleted && typeof self._completionUrl !== "undefined")
{
window.location = self._completionUrl;
}
- else if (!isCompleted && typeof self._cancellationUrl !== 'undefined')
+ else if (!isCompleted && typeof self._cancellationUrl !== "undefined")
{
window.location = self._cancellationUrl;
}
- }
+ },
});
}
catch (error)
{
console.error(error);
- this._gui.dialog({error});
+ this._gui.dialog({ error });
}
}
-
/**
* Configure PsychoJS for the running experiment.
*
- * @async
* @protected
* @param {string} configURL - the URL of the configuration file
* @param {string} name - the name of the experiment
@@ -611,68 +607,67 @@
Source: core/PsychoJS.js
async _configure(configURL, name)
{
const response = {
- origin: 'PsychoJS.configure',
- context: 'when configuring PsychoJS for the experiment'
+ origin: "PsychoJS.configure",
+ context: "when configuring PsychoJS for the experiment",
};
try
{
this.status = PsychoJS.Status.CONFIGURING;
- // if the experiment is running from the pavlovia.org server, we read the configuration file:
+ // if the experiment is running from an approved hosts, e.e pavlovia.org,
+ // we read the configuration file:
const experimentUrl = window.location.href;
- // go through each url in allow list
const isHost = this._hosts.some(url => experimentUrl.indexOf(url) === 0);
if (isHost)
{
const serverResponse = await this._serverManager.getConfiguration(configURL);
this._config = serverResponse.config;
- // legacy experiments had a psychoJsManager block instead of a pavlovia block,
- // and the URL pointed to https://pavlovia.org/server
- if ('psychoJsManager' in this._config)
+ // update the configuration for legacy experiments, which had a psychoJsManager
+ // block instead of a pavlovia block, with URL pointing to https://pavlovia.org/server
+ if ("psychoJsManager" in this._config)
{
delete this._config.psychoJsManager;
this._config.pavlovia = {
- URL: 'https://pavlovia.org'
+ URL: "https://pavlovia.org",
};
}
// tests for the presence of essential blocks in the configuration:
- if (!('experiment' in this._config))
+ if (!("experiment" in this._config))
{
- throw 'missing experiment block in configuration';
+ throw "missing experiment block in configuration";
}
- if (!('name' in this._config.experiment))
+ if (!("name" in this._config.experiment))
{
- throw 'missing name in experiment block in configuration';
+ throw "missing name in experiment block in configuration";
}
- if (!('fullpath' in this._config.experiment))
+ if (!("fullpath" in this._config.experiment))
{
- throw 'missing fullpath in experiment block in configuration';
+ throw "missing fullpath in experiment block in configuration";
}
- if (!('pavlovia' in this._config))
+ if (!("pavlovia" in this._config))
{
- throw 'missing pavlovia block in configuration';
+ throw "missing pavlovia block in configuration";
}
- if (!('URL' in this._config.pavlovia))
+ if (!("URL" in this._config.pavlovia))
{
- throw 'missing URL in pavlovia block in configuration';
+ throw "missing URL in pavlovia block in configuration";
}
- if (!('gitlab' in this._config))
+ if (!("gitlab" in this._config))
{
- throw 'missing gitlab block in configuration';
+ throw "missing gitlab block in configuration";
}
- if (!('projectId' in this._config.gitlab))
+ if (!("projectId" in this._config.gitlab))
{
- throw 'missing projectId in gitlab block in configuration';
+ throw "missing projectId in gitlab block in configuration";
}
this._config.environment = ExperimentHandler.Environment.SERVER;
-
}
- else
// otherwise we create an ad-hoc configuration:
+ else
{
this._config = {
environment: ExperimentHandler.Environment.LOCAL,
@@ -680,8 +675,8 @@
* Manager responsible for the communication between the experiment running in the participant's browser and the pavlovia.org server.
*
* @author Alain Pitiot
- * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @version 2022.2.3
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
-import { Howl } from 'howler';
-import {PsychoJS} from './PsychoJS';
-import {PsychObject} from '../util/PsychObject';
-import * as util from '../util/Util';
-import {ExperimentHandler} from "../data/ExperimentHandler";
-import {MonotonicClock} from "../util/Clock";
-
+import { Howl } from "howler";
+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";
/**
* <p>This manager handles all communications between the experiment running in the participant's browser and the [pavlovia.org]{@link http://pavlovia.org} server, <em>in an asynchronous manner</em>.</p>
* <p>It is responsible for reading the configuration file of an experiment, for opening and closing a session, for listing and downloading resources, and for uploading results, logs, and audio recordings.</p>
*
- * @name module:core.ServerManager
- * @class
* @extends PsychObject
- * @param {Object} options
- * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
- * @param {boolean} [options.autoLog= false] - whether or not to log
*/
export class ServerManager extends PsychObject
{
@@ -60,17 +79,22 @@
Source: core/ServerManager.js
* Used to indicate to the ServerManager that all resources must be registered (and
* subsequently downloaded)
*
- * @type {symbol}
+ * @type {Symbol}
* @readonly
* @public
*/
- static ALL_RESOURCES = Symbol.for('ALL_RESOURCES');
-
+ static ALL_RESOURCES = Symbol.for("ALL_RESOURCES");
+ /**
+ * @memberof module:core
+ * @param {Object} options
+ * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ */
constructor({
- psychoJS,
- autoLog = false
- } = {})
+ psychoJS,
+ autoLog = false,
+ } = {})
{
super(psychoJS);
@@ -79,11 +103,13 @@
Source: core/ServerManager.js
// 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);
- }
+ this._addAttribute("autoLog", autoLog);
+ this._addAttribute("status", ServerManager.Status.READY);
+ }
/**
* @typedef ServerManager.GetConfigurationPromise
@@ -95,43 +121,53 @@
/**
* Open a session for this experiment on the remote PsychoJS manager.
*
- * @name module:core.ServerManager#openSession
- * @function
- * @public
* @returns {Promise<ServerManager.OpenSessionPromise>} the response
*/
openSession()
{
const response = {
- origin: 'ServerManager.openSession',
- context: 'when opening a session for experiment: ' + this._psychoJS.config.experiment.fullpath
+ origin: "ServerManager.openSession",
+ context: "when opening a session for experiment: " + this._psychoJS.config.experiment.fullpath,
};
-
- this._psychoJS.logger.debug('opening a session for experiment: ' + this._psychoJS.config.experiment.fullpath);
+ this._psychoJS.logger.debug("opening a session for experiment: " + this._psychoJS.config.experiment.fullpath);
this.setStatus(ServerManager.Status.BUSY);
- // prepare POST query:
+ // prepare a POST query:
let data = {};
- if (this._psychoJS._serverMsg.has('__pilotToken'))
+ if (this._psychoJS._serverMsg.has("__pilotToken"))
{
- data.pilotToken = this._psychoJS._serverMsg.get('__pilotToken');
+ data.pilotToken = this._psychoJS._serverMsg.get("__pilotToken");
}
- // query pavlovia server:
+ // query the server:
const self = this;
- return new Promise((resolve, reject) =>
+ return new Promise(async (resolve, reject) =>
{
- const url = this._psychoJS.config.pavlovia.URL + '/api/v2/experiments/' + encodeURIComponent(self._psychoJS.config.experiment.fullpath) + '/sessions';
- jQuery.post(url, data, null, 'json')
- .done((data, textStatus) =>
- {
- if (!('token' in data))
- {
- self.setStatus(ServerManager.Status.ERROR);
- reject(Object.assign(response, {error: 'unexpected answer from server: no token'}));
- // reject({...response, error: 'unexpected answer from server: no token'});
- }
- if (!('experiment' in data))
- {
- self.setStatus(ServerManager.Status.ERROR);
- // reject({...response, error: 'unexpected answer from server: no experiment'});
- reject(Object.assign(response, {error: 'unexpected answer from server: no experiment'}));
- }
+ try
+ {
+ const postResponse = await this._queryServerAPI(
+ "POST",
+ `experiments/${this._psychoJS.config.gitlab.projectId}/sessions`,
+ data,
+ "FORM"
+ );
- self._psychoJS.config.session = {
- token: data.token,
- status: 'OPEN'
- };
- self._psychoJS.config.experiment.status = data.experiment.status2;
- self._psychoJS.config.experiment.saveFormat = Symbol.for(data.experiment.saveFormat);
- self._psychoJS.config.experiment.saveIncompleteResults = data.experiment.saveIncompleteResults;
- self._psychoJS.config.experiment.license = data.experiment.license;
- self._psychoJS.config.experiment.runMode = data.experiment.runMode;
-
- // secret keys for various services, e.g. Google Speech API
- if ('keys' in data.experiment)
- {
- self._psychoJS.config.experiment.keys = data.experiment.keys;
- }
- else
- {
- self._psychoJS.config.experiment.keys = [];
- }
+ const openSessionResponse = await postResponse.json();
- self.setStatus(ServerManager.Status.READY);
- // resolve({ ...response, token: data.token, status: data.status });
- resolve(Object.assign(response, {token: data.token, status: data.status}));
- })
- .fail((jqXHR, textStatus, errorThrown) =>
+ if (postResponse.status !== 200)
+ {
+ throw ('error' in openSessionResponse) ? openSessionResponse.error : openSessionResponse;
+ }
+ if (!("token" in openSessionResponse))
{
self.setStatus(ServerManager.Status.ERROR);
+ throw "unexpected answer from the server: no token";
+ }
+ if (!("experiment" in openSessionResponse))
+ {
+ self.setStatus(ServerManager.Status.ERROR);
+ throw "unexpected answer from server: no experiment";
+ }
- const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown);
- console.error('error:', errorMsg);
+ self._psychoJS.config.session = {
+ token: openSessionResponse.token,
+ status: "OPEN",
+ };
+ const experiment = openSessionResponse.experiment;
+ self._psychoJS.config.experiment.status = experiment.status2;
+ self._psychoJS.config.experiment.saveFormat = Symbol.for(experiment.saveFormat);
+ self._psychoJS.config.experiment.saveIncompleteResults = experiment.saveIncompleteResults;
+ self._psychoJS.config.experiment.license = experiment.license;
+ self._psychoJS.config.experiment.runMode = experiment.runMode;
+
+ // secret keys for various services, e.g. Google Speech API
+ if ("keys" in experiment)
+ {
+ self._psychoJS.config.experiment.keys = experiment.keys;
+ }
+ else
+ {
+ self._psychoJS.config.experiment.keys = [];
+ }
- reject(Object.assign(response, {error: errorMsg}));
- });
+ self.setStatus(ServerManager.Status.READY);
+ resolve({...response, token: openSessionResponse.token, status: openSessionResponse.status });
+ }
+ catch (error)
+ {
+ console.error(error);
+ self.setStatus(ServerManager.Status.ERROR);
+ reject({...response, error});
+ }
});
}
-
/**
* @typedef ServerManager.CloseSessionPromise
* @property {string} origin the calling method
@@ -232,9 +269,6 @@
Source: core/ServerManager.js
/**
* Close the session for this experiment on the remote PsychoJS manager.
*
- * @name module:core.ServerManager#closeSession
- * @function
- * @public
* @param {boolean} [isCompleted= false] - whether or not the experiment was completed
* @param {boolean} [sync= false] - whether or not to communicate with the server in a synchronous manner
* @returns {Promise<ServerManager.CloseSessionPromise> | void} the response
@@ -242,80 +276,64 @@
Source: core/ServerManager.js
async closeSession(isCompleted = false, sync = false)
{
const response = {
- origin: 'ServerManager.closeSession',
- context: 'when closing the session for experiment: ' + this._psychoJS.config.experiment.fullpath
+ origin: "ServerManager.closeSession",
+ context: "when closing the session for experiment: " + this._psychoJS.config.experiment.fullpath,
};
-
- this._psychoJS.logger.debug('closing the session for experiment: ' + this._psychoJS.config.experiment.name);
+ this._psychoJS.logger.debug("closing the session for experiment: " + this._psychoJS.config.experiment.name);
this.setStatus(ServerManager.Status.BUSY);
- // 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;
-
- // synchronous query the pavlovia server:
+ // synchronously query the pavlovia server:
if (sync)
{
- /* This is now deprecated in most browsers.
- const request = new XMLHttpRequest();
- request.open("DELETE", url, false);
- 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
- await fetch(url, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json;charset=UTF-8' },
- body: JSON.stringify(data),
- // keepalive makes it possible for the request to outlive the page (e.g. when the participant closes the tab)
- keepalive: true
- });
- */
+ const url = this._psychoJS.config.pavlovia.URL
+ + "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId
+ + "/sessions/" + this._psychoJS.config.session.token + "/delete";
const formData = new FormData();
- formData.append('isCompleted', isCompleted);
- navigator.sendBeacon(url + '/delete', formData);
- this._psychoJS.config.session.status = 'CLOSED';
+ formData.append("isCompleted", isCompleted);
+
+ navigator.sendBeacon(url, formData);
+ this._psychoJS.config.session.status = "CLOSED";
}
// asynchronously query the pavlovia server:
else
{
const self = this;
- return new Promise((resolve, reject) =>
+ return new Promise(async (resolve, reject) =>
{
- jQuery.ajax({
- url,
- type: 'delete',
- data: {isCompleted},
- dataType: 'json'
- })
- .done((data, textStatus) =>
- {
- self.setStatus(ServerManager.Status.READY);
- self._psychoJS.config.session.status = 'CLOSED';
+ try
+ {
+ const deleteResponse = await this._queryServerAPI(
+ "DELETE",
+ `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}`,
+ { isCompleted },
+ "FORM"
+ );
- // resolve({ ...response, data });
- resolve(Object.assign(response, {data}));
- })
- .fail((jqXHR, textStatus, errorThrown) =>
- {
- self.setStatus(ServerManager.Status.ERROR);
+ const closeSessionResponse = await deleteResponse.json();
- const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown);
- console.error('error:', errorMsg);
+ if (deleteResponse.status !== 200)
+ {
+ throw ('error' in closeSessionResponse) ? closeSessionResponse.error : closeSessionResponse;
+ }
- reject(Object.assign(response, {error: errorMsg}));
- });
+ self.setStatus(ServerManager.Status.READY);
+ self._psychoJS.config.session.status = "CLOSED";
+ resolve({ ...response, ...closeSessionResponse });
+ }
+ catch (error)
+ {
+ console.error(error);
+ self.setStatus(ServerManager.Status.ERROR);
+ reject({...response, error});
+ }
});
}
}
-
/**
* Get the value of a resource.
*
- * @name module:core.ServerManager#getResource
- * @function
- * @public
* @param {string} name - name of the requested resource
* @param {boolean} [errorIfNotDownloaded = false] whether or not to throw an exception if the
* resource status is not DOWNLOADED
@@ -326,83 +344,114 @@
Source: core/ServerManager.js
getResource(name, errorIfNotDownloaded = false)
{
const response = {
- origin: 'ServerManager.getResource',
- context: 'when getting the value of resource: ' + name
+ origin: "ServerManager.getResource",
+ context: "when getting the value of resource: " + name,
};
const pathStatusData = this._resources.get(name);
- if (typeof pathStatusData === 'undefined')
+ if (typeof pathStatusData === "undefined")
{
// throw { ...response, error: 'unknown resource' };
- throw Object.assign(response, {error: 'unknown resource'});
+ throw Object.assign(response, { error: "unknown resource" });
}
if (errorIfNotDownloaded && pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
{
throw Object.assign(response, {
- error: name + ' is not available for use (yet), its current status is: ' +
- util.toString(pathStatusData.status)
+ error: name + " is not available for use (yet), its current status is: "
+ + util.toString(pathStatusData.status),
});
}
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.
*
- * @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
+ * <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>
+ *
+ * @param {string | string[]} names names of the resources whose statuses are requested
+ * @return {module: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
+ origin: "ServerManager.getResourceStatus",
+ 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);
- return pathStatusData.status;
- }
+ 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 reducedStatus;
+ }
/**
* Set the resource manager status.
- *
- * @name module:core.ServerManager#setStatus
- * @function
- * @public
*/
setStatus(status)
{
const response = {
- origin: 'ServerManager.setStatus',
- context: 'when changing the status of the server manager to: ' + util.toString(status)
+ origin: "ServerManager.setStatus",
+ context: "when changing the status of the server manager to: " + util.toString(status),
};
// check status:
- const statusKey = (typeof status === 'symbol') ? Symbol.keyFor(status) : null;
+ const statusKey = (typeof status === "symbol") ? Symbol.keyFor(status) : null;
if (!statusKey)
- // throw { ...response, error: 'status must be a symbol' };
- {
- throw Object.assign(response, {error: 'status must be a symbol'});
+ { // throw { ...response, error: 'status must be a symbol' };
+ throw Object.assign(response, { error: "status must be a symbol" });
}
if (!ServerManager.Status.hasOwnProperty(statusKey))
- // throw { ...response, error: 'unknown status' };
- {
- throw Object.assign(response, {error: 'unknown status'});
+ { // throw { ...response, error: 'unknown status' };
+ throw Object.assign(response, { error: "unknown status" });
}
this._status = status;
@@ -413,13 +462,9 @@
Source: core/ServerManager.js
return this._status;
}
-
/**
* Reset the resource manager status to ServerManager.Status.READY.
*
- * @name module:core.ServerManager#resetStatus
- * @function
- * @public
* @return {ServerManager.Status.READY} the new status
*/
resetStatus()
@@ -427,7 +472,6 @@
Source: core/ServerManager.js
return this.setStatus(ServerManager.Status.READY);
}
-
/**
* Prepare resources for the experiment: register them with the server manager and possibly
* start downloading them right away.
@@ -441,19 +485,16 @@
Source: core/ServerManager.js
* <li>If resources is null, then we do not download any resources</li>
* </ul>
*
- * @name module:core.ServerManager#prepareResources
- * @param {Array.<{name: string, path: string, download: boolean} | Symbol>} [resources=[]] - the list of resources
- * @function
- * @public
+ * @param {String | Array.<{name: string, path: string, download: boolean} | String | Symbol>} [resources=[]] - the list of resources or a single resource
*/
async prepareResources(resources = [])
{
const response = {
- origin: 'ServerManager.prepareResources',
- context: 'when preparing resources for experiment: ' + this._psychoJS.config.experiment.name
+ origin: "ServerManager.prepareResources",
+ context: "when preparing resources for experiment: " + this._psychoJS.config.experiment.name,
};
- this._psychoJS.logger.debug('preparing resources for experiment: ' + this._psychoJS.config.experiment.name);
+ this._psychoJS.logger.debug("preparing resources for experiment: " + this._psychoJS.config.experiment.name);
try
{
@@ -462,19 +503,24 @@
Source: core/ServerManager.js
// 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:
- const allResources = (resources.length === 1 && resources[0] === ServerManager.ALL_RESOURCES);
+ const allResources = (resources.length === 1 &&
+ resources[0] === ServerManager.ALL_RESOURCES);
// if the experiment is hosted on the pavlovia.org server and
// resources is [ServerManager.ALL_RESOURCES], then we register all the resources
// in the "resources" sub-directory
- if (this._psychoJS.config.environment === ExperimentHandler.Environment.SERVER
- && allResources)
+ if (this._psychoJS.config.environment === ExperimentHandler.Environment.SERVER &&
+ allResources)
{
// list the resources from the resources directory of the experiment on the server:
const serverResponse = await this._listResources();
@@ -485,53 +531,66 @@
Source: core/ServerManager.js
{
if (!this._resources.has(name))
{
- const path = serverResponse.resourceDirectory + '/' + name;
+ const path = serverResponse.resourceDirectory + "/" + name;
this._resources.set(name, {
status: ServerManager.ResourceStatus.REGISTERED,
path,
- data: undefined
+ data: undefined,
});
- this._psychoJS.logger.debug('registered resource:', name, path);
+ this._psychoJS.logger.debug(`registered resource: name= ${name}, path= ${path}`);
resourcesToDownload.add(name);
}
}
}
-
// if the experiment is hosted locally (localhost) or if specific resources were given
// then we register those specific resources, if they have not been registered already
else
{
// we cannot ask for all resources to be registered locally, since we cannot list
// them:
- if (this._psychoJS.config.environment === ExperimentHandler.Environment.LOCAL
- && allResources)
+ if (this._psychoJS.config.environment === ExperimentHandler.Environment.LOCAL &&
+ allResources)
{
throw "resources must be manually specified when the experiment is running locally: ALL_RESOURCES cannot be used";
}
- for (let {name, path, download} of resources)
+ // 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;
+ path = "https://pavlovia.org/api/v2/proxy/" + path;
}
this._resources.set(name, {
status: ServerManager.ResourceStatus.REGISTERED,
path,
- data: undefined
+ 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)
+ if (typeof download === "undefined" || download)
{
resourcesToDownload.add(name);
}
@@ -540,25 +599,45 @@
Source: core/ServerManager.js
}
}
- // download those registered resources for which download = true:
- /*await*/ this._downloadResources(resourcesToDownload);
+ // download those registered resources for which download = true
+ // note: we return a Promise that will be resolved when all the resources are downloaded
+ if (resourcesToDownload.size === 0)
+ {
+ this.emit(ServerManager.Event.RESOURCE, {
+ message: ServerManager.Event.DOWNLOAD_COMPLETED,
+ });
+
+ 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);
- throw Object.assign(response, {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
* @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources
- * @function
- * @public
*/
waitForResources(resources = [])
{
@@ -566,11 +645,11 @@
Source: core/ServerManager.js
this._waitForDownloadComponent = {
status: PsychoJS.Status.NOT_STARTED,
clock: new Clock(),
- resources: new Set()
+ resources: new Set(),
};
const self = this;
- return () =>
+ return async () =>
{
const t = self._waitForDownloadComponent.clock.getTime();
@@ -583,75 +662,77 @@
Source: core/ServerManager.js
// if resources is an empty array, we consider all registered resources:
if (resources.length === 0)
{
- for (const [name, {status, path, data}] of this._resources)
+ 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)
+ for (let { name, path } of resources)
{
// 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://devlovia.org/api/v2/proxy/' + path;
+ path = "https://devlovia.org/api/v2/proxy/" + path;
}
const pathStatusData = this._resources.get(name);
// the resource has not been registered yet:
- if (typeof pathStatusData === 'undefined')
+ if (typeof pathStatusData === "undefined")
{
self._resources.set(name, {
status: ServerManager.ResourceStatus.REGISTERED,
path,
- data: undefined
+ data: undefined,
});
self._waitForDownloadComponent.resources.add(name);
resourcesToDownload.add(name);
- self._psychoJS.logger.debug('registered resource:', name, path);
+ 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')
- {
+ { // else if (typeof pathStatusData.data === 'undefined')
self._waitForDownloadComponent.resources.add(name);
}
-
}
+ 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);
-
- // the resource has not been downloaded yet: loop this component
- if (typeof pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
- // if (typeof pathStatusData.data === 'undefined')
+ // check whether all resources have been downloaded:
+ for (const name of self._waitForDownloadComponent.resources)
{
- return Scheduler.Event.FLIP_REPEAT;
+ const pathStatusData = this._resources.get(name);
+
+ // 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
@@ -661,76 +742,69 @@
Source: core/ServerManager.js
/**
* Asynchronously upload experiment data to the pavlovia server.
*
- * @name module:core.ServerManager#uploadData
- * @function
- * @public
* @param {string} key - the data key (e.g. the name of .csv file)
* @param {string} value - the data value (e.g. a string containing the .csv header and records)
* @param {boolean} [sync= false] - whether or not to communicate with the server in a synchronous manner
- *
* @returns {Promise<ServerManager.UploadDataPromise>} the response
*/
uploadData(key, value, sync = false)
{
const response = {
- origin: 'ServerManager.uploadData',
- context: 'when uploading participant\'s results for experiment: ' + this._psychoJS.config.experiment.fullpath
+ origin: "ServerManager.uploadData",
+ context: "when uploading participant's results for experiment: " + this._psychoJS.config.experiment.fullpath,
};
+ this._psychoJS.logger.debug("uploading data for experiment: " + this._psychoJS.config.experiment.fullpath);
- this._psychoJS.logger.debug('uploading data for experiment: ' + this._psychoJS.config.experiment.fullpath);
this.setStatus(ServerManager.Status.BUSY);
- const url = this._psychoJS.config.pavlovia.URL +
- '/api/v2/experiments/' + encodeURIComponent(this._psychoJS.config.experiment.fullpath) +
- '/sessions/' + this._psychoJS.config.session.token +
- '/results';
+ const path = `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}/results`;
- // synchronous query the pavlovia server:
+ // synchronously query the pavlovia server:
if (sync)
{
const formData = new FormData();
- formData.append('key', key);
- formData.append('value', value);
- navigator.sendBeacon(url, formData);
+ formData.append("key", key);
+ formData.append("value", value);
+ navigator.sendBeacon(`${this._psychoJS.config.pavlovia.URL}/api/v2/${path}`, formData);
}
// asynchronously query the pavlovia server:
else
{
const self = this;
- return new Promise((resolve, reject) =>
+ return new Promise(async (resolve, reject) =>
{
- const data = {
- key,
- value
- };
+ try
+ {
+ const postResponse = await this._queryServerAPI(
+ "POST",
+ `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}/results`,
+ { key, value },
+ "FORM"
+ );
- jQuery.post(url, data, null, 'json')
- .done((serverData, textStatus) =>
- {
- self.setStatus(ServerManager.Status.READY);
- resolve(Object.assign(response, {serverData}));
- })
- .fail((jqXHR, textStatus, errorThrown) =>
- {
- self.setStatus(ServerManager.Status.ERROR);
+ const uploadDataResponse = await postResponse.json();
- const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown);
- console.error('error:', errorMsg);
+ if (postResponse.status !== 200)
+ {
+ throw ('error' in uploadDataResponse) ? uploadDataResponse.error : uploadDataResponse;
+ }
- reject(Object.assign(response, {error: errorMsg}));
- });
+ self.setStatus(ServerManager.Status.READY);
+ resolve({ ...response, ...uploadDataResponse });
+ }
+ catch (error)
+ {
+ console.error(error);
+ self.setStatus(ServerManager.Status.ERROR);
+ reject({...response, error});
+ }
});
}
}
-
-
/**
* Asynchronously upload experiment logs to the pavlovia server.
*
- * @name module:core.ServerManager#uploadLog
- * @function
- * @public
* @param {string} logs - the base64 encoded, compressed, formatted logs
* @param {boolean} [compressed=false] - whether or not the logs are compressed
* @returns {Promise<ServerManager.UploadDataPromise>} the response
@@ -738,19 +812,17 @@
* Window responsible for displaying the experiment stimuli
*
* @author Alain Pitiot
- * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @version 2022.2.3
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
-import * as PIXI from 'pixi.js-legacy';
-import {Color} from '../util/Color';
-import {PsychObject} from '../util/PsychObject';
-import {MonotonicClock} from '../util/Clock';
-import {Logger} from "./Logger";
+import * as PIXI from "pixi.js-legacy";
+import {AdjustmentFilter} from "@pixi/filter-adjustment";
+import { MonotonicClock } from "../util/Clock.js";
+import { Color } from "../util/Color.js";
+import { PsychObject } from "../util/PsychObject.js";
+import { Logger } from "./Logger.js";
/**
* <p>Window displays the various stimuli of the experiment.</p>
* <p>It sets up a [PIXI]{@link http://www.pixijs.com/} renderer, which we use to render the experiment stimuli.</p>
*
- * @name module:core.Window
- * @class
* @extends PsychObject
- * @param {Object} options
- * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
- * @param {string} [options.name] the name of the window
- * @param {boolean} [options.fullscr= false] whether or not to go fullscreen
- * @param {Color} [options.color= Color('black')] the background color of the window
- * @param {string} [options.units= 'pix'] the units of the window
- * @param {boolean} [options.waitBlanking= false] whether or not to wait for all rendering operations to be done
- * before flipping
- * @param {boolean} [options.autoLog= true] whether or not to log
*/
export class Window extends PsychObject
{
-
/**
* Getter for monitorFramePeriod.
*
@@ -73,31 +86,62 @@
Source: core/Window.js
return 1.0 / this.getActualFrameRate();
}
+ /**
+ * @memberof module:core
+ * @param {Object} options
+ * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
+ * @param {string} [options.name] the name of the window
+ * @param {boolean} [options.fullscr= false] whether or not to go fullscreen
+ * @param {Color} [options.color= Color('black')] the background color of the window
+ * @param {number} [options.gamma= 1] sets the divisor for gamma correction. In other words gamma correction is calculated as pow(rgb, 1/gamma)
+ * @param {number} [options.contrast= 1] sets the contrast value
+ * @param {string} [options.units= 'pix'] the units of the window
+ * @param {boolean} [options.waitBlanking= false] whether or not to wait for all rendering operations to be done
+ * before flipping
+ * @param {boolean} [options.autoLog= true] whether or not to log
+ */
constructor({
- psychoJS,
- name,
- fullscr = false,
- color = new Color('black'),
- units = 'pix',
- waitBlanking = false,
- autoLog = true
- } = {})
+ psychoJS,
+ name,
+ fullscr = false,
+ color = new Color("black"),
+ gamma = 1,
+ contrast = 1,
+ units = "pix",
+ waitBlanking = false,
+ autoLog = true,
+ } = {})
{
super(psychoJS, name);
// messages to be logged at the next "flip":
this._msgToBeLogged = [];
+ // storing AdjustmentFilter instance to access later;
+ this._adjustmentFilter = new AdjustmentFilter({
+ gamma,
+ contrast
+ });
+
// list of all elements, in the order they are currently drawn:
this._drawList = [];
- this._addAttribute('fullscr', fullscr);
- this._addAttribute('color', color);
- this._addAttribute('units', units);
- this._addAttribute('waitBlanking', waitBlanking);
- this._addAttribute('autoLog', autoLog);
- this._addAttribute('size', []);
-
+ this._addAttribute("fullscr", fullscr);
+ this._addAttribute("color", color, new Color("black"), () => {
+ if (this._backgroundSprite) {
+ this._backgroundSprite.tint = this._color.int;
+ }
+ });
+ this._addAttribute("gamma", gamma, 1, () => {
+ this._adjustmentFilter.gamma = this._gamma;
+ });
+ this._addAttribute("contrast", contrast, 1, () => {
+ this._adjustmentFilter.contrast = this._contrast;
+ });
+ this._addAttribute("units", units);
+ this._addAttribute("waitBlanking", waitBlanking);
+ this._addAttribute("autoLog", autoLog);
+ this._addAttribute("size", []);
// setup PIXI:
this._setupPixi();
@@ -106,15 +150,14 @@
Source: core/Window.js
this._flipCallbacks = [];
-
// fullscreen listener:
this._windowAlreadyInFullScreen = false;
const self = this;
- document.addEventListener('fullscreenchange', (event) =>
+ document.addEventListener("fullscreenchange", (event) =>
{
self._windowAlreadyInFullScreen = !!document.fullscreenElement;
- console.log('windowAlreadyInFullScreen:', self._windowAlreadyInFullScreen);
+ console.log("windowAlreadyInFullScreen:", self._windowAlreadyInFullScreen);
// the Window and all of the stimuli need to be updated:
self._needUpdate = true;
@@ -124,22 +167,16 @@
Source: core/Window.js
}
});
-
if (this._autoLog)
{
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
}
}
-
/**
* Close the window.
*
* <p> Note: this actually only removes the canvas used to render the experiment stimuli.</p>
- *
- * @name module:core.Window#close
- * @function
- * @public
*/
close()
{
@@ -148,33 +185,31 @@
Source: core/Window.js
return;
}
+ this._rootContainer.destroy();
+
if (document.body.contains(this._renderer.view))
{
document.body.removeChild(this._renderer.view);
}
// destroy the renderer and the WebGL context:
- if (typeof this._renderer.gl !== 'undefined')
+ if (typeof this._renderer.gl !== "undefined")
{
- const extension = this._renderer.gl.getExtension('WEBGL_lose_context');
+ const extension = this._renderer.gl.getExtension("WEBGL_lose_context");
extension.loseContext();
}
this._renderer.destroy();
- window.removeEventListener('resize', this._resizeCallback);
- window.removeEventListener('orientationchange', this._resizeCallback);
+ window.removeEventListener("resize", this._resizeCallback);
+ window.removeEventListener("orientationchange", this._resizeCallback);
this._renderer = null;
}
-
/**
* Estimate the frame rate.
*
- * @name module:core.Window#getActualFrameRate
- * @function
- * @public
* @return {number} rAF based delta time based approximation, 60.0 by default
*/
getActualFrameRate()
@@ -186,13 +221,8 @@
Source: core/Window.js
return fps;
}
-
/**
* Take the browser full screen if possible.
- *
- * @name module:core.Window#adjustScreenSize
- * @function
- * @public
*/
adjustScreenSize()
{
@@ -201,104 +231,92 @@
Source: core/Window.js
// test whether the window is already fullscreen.
// this._windowAlreadyInFullScreen = (!window.screenTop && !window.screenY);
- if (this.fullscr/* && !this._windowAlreadyInFullScreen*/)
+ if (this.fullscr /* && !this._windowAlreadyInFullScreen*/)
{
- this._psychoJS.logger.debug('Resizing Window: ', this._name, 'to full screen.');
+ this._psychoJS.logger.debug("Resizing Window: ", this._name, "to full screen.");
- if (typeof document.documentElement.requestFullscreen === 'function')
+ if (typeof document.documentElement.requestFullscreen === "function")
{
document.documentElement.requestFullscreen()
.catch(() =>
{
- this.psychoJS.logger.warn('Unable to go fullscreen.');
+ this.psychoJS.logger.warn("Unable to go fullscreen.");
});
}
- else if (typeof document.documentElement.mozRequestFullScreen === 'function')
+ else if (typeof document.documentElement.mozRequestFullScreen === "function")
{
document.documentElement.mozRequestFullScreen();
}
- else if (typeof document.documentElement.webkitRequestFullscreen === 'function')
+ else if (typeof document.documentElement.webkitRequestFullscreen === "function")
{
document.documentElement.webkitRequestFullscreen();
}
- else if (typeof document.documentElement.msRequestFullscreen === 'function')
+ else if (typeof document.documentElement.msRequestFullscreen === "function")
{
document.documentElement.msRequestFullscreen();
}
else
{
- this.psychoJS.logger.warn('Unable to go fullscreen.');
+ this.psychoJS.logger.warn("Unable to go fullscreen.");
}
}
-
}
-
/**
* Take the browser back from full screen if needed.
- *
- * @name module:core.Window#closeFullScreen
- * @function
- * @public
*/
closeFullScreen()
{
if (this.fullscr)
{
- this._psychoJS.logger.debug('Resizing Window: ', this._name, 'back from full screen.');
+ this._psychoJS.logger.debug("Resizing Window: ", this._name, "back from full screen.");
- if (typeof document.exitFullscreen === 'function')
+ if (typeof document.exitFullscreen === "function")
{
document.exitFullscreen()
.catch(() =>
{
- this.psychoJS.logger.warn('Unable to close fullscreen.');
+ this.psychoJS.logger.warn("Unable to close fullscreen.");
});
}
- else if (typeof document.mozCancelFullScreen === 'function')
+ else if (typeof document.mozCancelFullScreen === "function")
{
document.mozCancelFullScreen();
}
- else if (typeof document.webkitExitFullscreen === 'function')
+ else if (typeof document.webkitExitFullscreen === "function")
{
document.webkitExitFullscreen();
}
- else if (typeof document.msExitFullscreen === 'function')
+ else if (typeof document.msExitFullscreen === "function")
{
document.msExitFullscreen();
}
else
{
- this.psychoJS.logger.warn('Unable to close fullscreen.');
+ this.psychoJS.logger.warn("Unable to close fullscreen.");
}
}
-
}
-
/**
* Log a message.
*
* <p> Note: the message will be time-stamped at the next call to requestAnimationFrame.</p>
*
- * @name module:core.Window#logOnFlip
- * @function
- * @public
* @param {Object} options
* @param {String} options.msg the message to be logged
* @param {module:util.Logger.ServerLevel} [level = module:util.Logger.ServerLevel.EXP] the log level
* @param {Object} [obj] the object associated with the message
*/
logOnFlip({
- msg,
- level = Logger.ServerLevel.EXP,
- obj
- } = {})
+ msg,
+ level = Logger.ServerLevel.EXP,
+ obj,
+ } = {})
{
- this._msgToBeLogged.push({msg, level, obj});
+ this._msgToBeLogged.push({ msg, level, obj });
}
-
/**
* Callback function for callOnFlip.
*
@@ -311,24 +329,32 @@
Source: core/Window.js
*
* <p>This is typically used to reset a timer or clock.</p>
*
- * @name module:core.Window#callOnFlip
- * @function
- * @public
* @param {module:core.Window~OnFlipCallback} flipCallback - callback function.
* @param {...*} flipCallbackArgs - arguments for the callback function.
*/
callOnFlip(flipCallback, ...flipCallbackArgs)
{
- this._flipCallbacks.push({function: flipCallback, arguments: flipCallbackArgs});
+ this._flipCallbacks.push({ function: flipCallback, arguments: flipCallbackArgs });
}
+ /**
+ * Add PIXI.DisplayObject to the container displayed on the scene (window)
+ */
+ addPixiObject(pixiObject)
+ {
+ this._stimsContainer.addChild(pixiObject);
+ }
+
+ /**
+ * Remove PIXI.DisplayObject from the container displayed on the scene (window)
+ */
+ removePixiObject(pixiObject)
+ {
+ this._stimsContainer.removeChild(pixiObject);
+ }
/**
* Render the stimuli onto the canvas.
- *
- * @name module:core.Window#render
- * @function
- * @public
*/
render()
{
@@ -337,13 +363,12 @@
Source: core/Window.js
return;
}
-
this._frameCount++;
// render the PIXI container:
this._renderer.render(this._rootContainer);
- if (typeof this._renderer.gl !== 'undefined')
+ if (typeof this._renderer.gl !== "undefined")
{
// this is to make sure that the GPU is done rendering, it may not be necessary
// [http://www.html5gamedevs.com/topic/27849-detect-when-view-has-been-rendered/]
@@ -359,7 +384,7 @@
Source: core/Window.js
// call the callOnFlip functions and remove them:
for (let callback of this._flipCallbacks)
{
- callback['function'](...callback['arguments']);
+ callback["function"](...callback["arguments"]);
}
this._flipCallbacks = [];
@@ -370,13 +395,10 @@
Source: core/Window.js
this._refresh();
}
-
/**
* Update this window, if need be.
*
- * @name module:core.Window#_updateIfNeeded
- * @function
- * @private
+ * @protected
*/
_updateIfNeeded()
{
@@ -385,6 +407,7 @@
Source: core/Window.js
if (this._renderer)
{
this._renderer.backgroundColor = this._color.int;
+ this._backgroundSprite.tint = this._color.int;
}
// we also change the background color of the body since
@@ -395,13 +418,10 @@
Source: core/Window.js
}
}
-
/**
* Recompute this window's draw list and _container children for the next animation frame.
*
- * @name module:core.Window#_refresh
- * @function
- * @private
+ * @protected
*/
_refresh()
{
@@ -411,22 +431,19 @@
Source: core/Window.js
// update it, then put it back
for (const stimulus of this._drawList)
{
- if (stimulus._needUpdate && typeof stimulus._pixi !== 'undefined')
+ if (stimulus._needUpdate && typeof stimulus._pixi !== "undefined")
{
- this._rootContainer.removeChild(stimulus._pixi);
+ this._stimsContainer.removeChild(stimulus._pixi);
stimulus._updateIfNeeded();
- this._rootContainer.addChild(stimulus._pixi);
+ this._stimsContainer.addChild(stimulus._pixi);
}
}
}
-
/**
* Force an update of all stimuli in this window's drawlist.
*
- * @name module:core.Window#_fullRefresh
- * @function
- * @private
+ * @protected
*/
_fullRefresh()
{
@@ -440,16 +457,13 @@
Source: core/Window.js
this._refresh();
}
-
/**
* Setup PIXI.
*
* <p>A new renderer is created and a container is added to it. The renderer's touch and mouse events
* are handled by the {@link EventManager}.</p>
*
- * @name module:core.Window#_setupPixi
- * @function
- * @private
+ * @protected
*/
_setupPixi()
{
@@ -462,18 +476,42 @@
Source: core/Window.js
width: this._size[0],
height: this._size[1],
backgroundColor: this.color.int,
- resolution: window.devicePixelRatio
+ powerPreference: "high-performance",
+ resolution: window.devicePixelRatio,
});
- this._renderer.view.style.transform = 'translatez(0)';
- this._renderer.view.style.position = 'absolute';
+ this._renderer.view.style.transform = "translatez(0)";
+ this._renderer.view.style.position = "absolute";
document.body.appendChild(this._renderer.view);
// we also change the background color of the body since the dialog popup may be longer than the window's height:
document.body.style.backgroundColor = this._color.hex;
+ // filters in PIXI work in a slightly unexpected fashion:
+ // when setting this._rootContainer.filters, filtering itself
+ // ignores backgroundColor of this._renderer and in addition to that
+ // all child elements of this._rootContainer ignore backgroundColor when blending.
+ // To circumvent that creating a separate PIXI.Sprite that serves as background color.
+ // Then placing all Stims to a separate this._stimsContainer which hovers on top of
+ // background sprite so that if we need to move all stims at once, the background sprite
+ // won't get affected.
+ this._backgroundSprite = new PIXI.Sprite(PIXI.Texture.WHITE);
+ this._backgroundSprite.tint = this.color.int;
+ this._backgroundSprite.width = this._size[0];
+ this._backgroundSprite.height = this._size[1];
+ this._backgroundSprite.anchor.set(.5);
+ this._stimsContainer = new PIXI.Container();
+ this._stimsContainer.sortableChildren = true;
+
// create a top-level PIXI container:
this._rootContainer = new PIXI.Container();
+ this._rootContainer.addChild(this._backgroundSprite, this._stimsContainer);
+
+ // sorts children according to their zIndex value. Higher zIndex means it will be moved towards the end of the array,
+ // and thus rendered on top of previous one.
+ this._rootContainer.sortableChildren = true;
+
this._rootContainer.interactive = true;
+ this._rootContainer.filters = [this._adjustmentFilter];
// set the initial size of the PIXI renderer and the position of the root container:
Window._resizePixiRenderer(this);
@@ -485,36 +523,35 @@
Source: core/Window.js
this._resizeCallback = (e) =>
{
Window._resizePixiRenderer(this, e);
+ this._backgroundSprite.width = this._size[0];
+ this._backgroundSprite.height = this._size[1];
this._fullRefresh();
};
- window.addEventListener('resize', this._resizeCallback);
- window.addEventListener('orientationchange', this._resizeCallback);
+ window.addEventListener("resize", this._resizeCallback);
+ window.addEventListener("orientationchange", this._resizeCallback);
}
-
/**
* Adjust the size of the renderer and the position of the root container
* in response to a change in the browser's size.
*
- * @name module:core.Window#_resizePixiRenderer
- * @function
- * @private
+ * @protected
* @param {module:core.Window} pjsWindow - the PsychoJS Window
* @param event
*/
static _resizePixiRenderer(pjsWindow, event)
{
- pjsWindow._psychoJS.logger.debug('resizing Window: ', pjsWindow._name, 'event:', JSON.stringify(event));
+ pjsWindow._psychoJS.logger.debug("resizing Window: ", pjsWindow._name, "event:", JSON.stringify(event));
// update the size of the PsychoJS Window:
pjsWindow._size[0] = window.innerWidth;
pjsWindow._size[1] = window.innerHeight;
// update the PIXI renderer:
- pjsWindow._renderer.view.style.width = pjsWindow._size[0] + 'px';
- pjsWindow._renderer.view.style.height = pjsWindow._size[1] + 'px';
- pjsWindow._renderer.view.style.left = '0px';
- pjsWindow._renderer.view.style.top = '0px';
+ pjsWindow._renderer.view.style.width = pjsWindow._size[0] + "px";
+ pjsWindow._renderer.view.style.height = pjsWindow._size[1] + "px";
+ pjsWindow._renderer.view.style.left = "0px";
+ pjsWindow._renderer.view.style.top = "0px";
pjsWindow._renderer.resize(pjsWindow._size[0], pjsWindow._size[1]);
// setup the container such that (0,0) is at the centre of the window
@@ -524,13 +561,10 @@
this._experimentEnded = false;
}
-
/**
* Whether or not the current entry (i.e. trial data) is empty.
* <p>Note: this is mostly useful at the end of an experiment, in order to ensure that the last entry is saved.</p>
*
- * @name module:data.ExperimentHandler#isEntryEmpty
- * @function
- * @public
* @returns {boolean} whether or not the current entry is empty
+ * @todo This really should be renamed: IsCurrentEntryNotEmpty
*/
isEntryEmpty()
{
return (Object.keys(this._currentTrialData).length > 0);
}
-
/**
* Add a loop.
*
* <p> The loop might be a {@link TrialHandler}, for instance.</p>
* <p> Data from this loop will be included in the resulting data files.</p>
*
- * @name module:data.ExperimentHandler#addLoop
- * @function
- * @public
* @param {Object} loop - the loop, e.g. an instance of TrialHandler or StairHandler
*/
addLoop(loop)
@@ -153,13 +179,9 @@
Source: data/ExperimentHandler.js
loop.experimentHandler = this;
}
-
/**
* Remove the given loop from the list of unfinished loops, e.g. when it has completed.
*
- * @name module:data.ExperimentHandler#removeLoop
- * @function
- * @public
* @param {Object} loop - the loop, e.g. an instance of TrialHandler or StairHandler
*/
removeLoop(loop)
@@ -171,16 +193,12 @@
Source: data/ExperimentHandler.js
}
}
-
/**
* Add the key/value pair.
*
* <p> Multiple key/value pairs can be added to any given entry of the data file. There are
* considered part of the same entry until a call to {@link nextEntry} is made. </p>
*
- * @name module:data.ExperimentHandler#addData
- * @function
- * @public
* @param {Object} key - the key
* @param {Object} value - the value
*/
@@ -200,19 +218,15 @@
Source: data/ExperimentHandler.js
this._currentTrialData[key] = value;
}
-
/**
* Inform this ExperimentHandler that the current trial has ended. Further calls to {@link addData}
* will be associated with the next trial.
*
- * @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)
{
- if (typeof snapshots !== 'undefined')
+ if (typeof snapshots !== "undefined")
{
// turn single snapshot into a one-element array:
if (!Array.isArray(snapshots))
@@ -231,7 +245,6 @@
Source: data/ExperimentHandler.js
}
}
}
-
}
// this is to support legacy generated JavaScript code and does not properly handle
// loops within loops:
@@ -264,7 +277,6 @@
Source: data/ExperimentHandler.js
this._currentTrialData = {};
}
-
/**
* Save the results of the experiment.
*
@@ -274,21 +286,22 @@
Source: data/ExperimentHandler.js
* </ul>
* <p>
*
- * @name module:data.ExperimentHandler#save
- * @function
- * @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
- } = {})
+ attributes = [],
+ sync = false,
+ tag = "",
+ clear = false
+ } = {})
{
- this._psychoJS.logger.info('[PsychoJS] Save experiment results.');
+ this._psychoJS.logger.info("[PsychoJS] Save experiment results.");
- // (*) get attributes:
+ // get attributes:
if (attributes.length === 0)
{
attributes = this._trialsKeys.slice();
@@ -314,101 +327,106 @@
Source: data/ExperimentHandler.js
}
}
+ 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 = [];
+ }
- // (*) 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;
-
-
- // (*) 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);
+ 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';
- if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER &&
- this._psychoJS.config.experiment.status === 'RUNNING' &&
- !this._psychoJS._serverMsg.has('__pilotToken'))
+ const filenameWithoutPath = this._dataFileName.split(/[\\/]/).pop();
+ const key = `${filenameWithoutPath}${tag}.csv`;
+ if (
+ this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER
+ && this._psychoJS.config.experiment.status === "RUNNING"
+ && !this._psychoJS._serverMsg.has("__pilotToken")
+ )
{
return /*await*/ this._psychoJS.serverManager.uploadData(key, csv, sync);
}
else
{
- util.offerDataForDownload(key, csv, 'text/csv');
+ 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);
}
// upload data to the pavlovia server or offer them for download:
- if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER &&
- this._psychoJS.config.experiment.status === 'RUNNING' &&
- !this._psychoJS._serverMsg.has('__pilotToken'))
+ if (
+ this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER
+ && this._psychoJS.config.experiment.status === "RUNNING"
+ && !this._psychoJS._serverMsg.has("__pilotToken")
+ )
{
- const key = 'results'; // name of the mongoDB collection
+ const key = "results"; // name of the mongoDB collection
return /*await*/ this._psychoJS.serverManager.uploadData(key, JSON.stringify(documents), sync);
}
else
{
- util.offerDataForDownload('results.json', JSON.stringify(documents), 'application/json');
+ util.offerDataForDownload("results.json", JSON.stringify(documents), "application/json");
}
-
}
}
-
/**
* Get the attribute names and values for the current trial of a given loop.
* <p> Only info relating to the trial execution are returned.</p>
*
- * @name module:data.ExperimentHandler#_getLoopAttributes
- * @function
- * @static
* @protected
* @param {Object} loop - the loop
*/
static _getLoopAttributes(loop)
{
// standard trial attributes:
- const properties = ['thisRepN', 'thisTrialN', 'thisN', 'thisIndex', 'stepSizeCurrent', 'ran', 'order'];
+ const properties = ["thisRepN", "thisTrialN", "thisN", "thisIndex", "stepSizeCurrent", "ran", "order"];
let attributes = {};
const loopName = loop.name;
for (const loopProperty in loop)
{
if (properties.includes(loopProperty))
{
- const key = (loopProperty === 'stepSizeCurrent') ? loopName + '.stepSize' : loopName + '.' + loopProperty;
+ const key = (loopProperty === "stepSizeCurrent") ? loopName + ".stepSize" : loopName + "." + loopProperty;
attributes[key] = loop[loopProperty];
}
}
// specific trial attributes:
- if (typeof loop.getCurrentTrial === 'function')
+ if (typeof loop.getCurrentTrial === "function")
{
const currentTrial = loop.getCurrentTrial();
for (const trialProperty in currentTrial)
@@ -432,7 +450,7 @@
/**
+ * Shelf handles persistent key/value pairs, or records, which are stored in the shelf collection on the
+ * server, and can be accessed and manipulated in a concurrent fashion.
+ *
+ * @author Alain Pitiot
+ * @version 2021.2.3
+ * @copyright (c) 2022 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @license Distributed under the terms of the MIT License
+ */
+
+import {PsychObject} from "../util/PsychObject.js";
+import {PsychoJS} from "../core/PsychoJS.js";
+import {ExperimentHandler} from "./ExperimentHandler";
+import {Scheduler} from "../util/Scheduler.js";
+
+
+/**
+ * <p>Shelf handles persistent key/value pairs, or records, which are stored in the shelf collection on the
+ * server, and can be accessed and manipulated in a concurrent fashion.</p>
+ *
+ * @extends PsychObject
+ */
+export class Shelf extends PsychObject
+{
+ /**
+ * Maximum number of components in a key
+ * @type {number}
+ * @note this value should mirror that on the server, i.e. the server also checks that the key is valid
+ */
+ static #MAX_KEY_LENGTH = 10;
+
+ /**
+ * @memberOf module:data
+ * @param {Object} options
+ * @param {module:core.PsychoJS} options.psychoJS the PsychoJS instance
+ * @param {boolean} [options.autoLog= false] whether to log
+ */
+ constructor({psychoJS, autoLog = false } = {})
+ {
+ super(psychoJS);
+
+ this._addAttribute('autoLog', autoLog);
+ this._addAttribute('status', Shelf.Status.READY);
+
+ // minimum period of time, in ms, before two calls to Shelf methods, i.e. throttling:
+ this._throttlingPeriod_ms = 500.0;
+
+ // timestamp of the last actual call to a Shelf method:
+ this._lastCallTimestamp = 0.0;
+ // timestamp of the last scheduled call to a Shelf method:
+ this._lastScheduledCallTimestamp = 0.0;
+ }
+
+ /**
+ * Get the value of a record of type BOOLEAN associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {boolean} options.defaultValue the default value returned if no record with the given key exists
+ * on the shelf
+ * @return {Promise<boolean>} the value associated with the key
+ * @throws {Object.<string, *>} exception if there is a record associated with the given key
+ * but it is not of type BOOLEAN
+ */
+ getBooleanValue({key, defaultValue} = {})
+ {
+ return this._getValue(key, Shelf.Type.BOOLEAN, {defaultValue});
+ }
+
+ /**
+ * Set the value of a record of type BOOLEAN associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {boolean} options.value the new value
+ * @return {Promise<boolean>} the new value
+ * @throws {Object.<string, *>} exception if value is not a boolean, or if there is no record with the given
+ * key, or if there is a record but it is locked or it is not of type BOOLEAN
+ */
+ setBooleanValue({key, value} = {})
+ {
+ // check the value:
+ if (typeof value !== "boolean")
+ {
+ throw {
+ origin: "Shelf.setIntegerValue",
+ context: `when setting the value of the BOOLEAN record associated with the key: ${JSON.stringify(key)}`,
+ error: "the value should be a boolean"
+ };
+ }
+
+ // update the value:
+ const update = {
+ action: "SET",
+ value
+ };
+ return this._updateValue(key, Shelf.Type.BOOLEAN, update);
+ }
+
+ /**
+ * Flip the value of a record of type BOOLEAN associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @return {Promise<boolean>} the new, flipped, value
+ * @throws {Object.<string, *>} exception if there is no record with the given key, or
+ * if there is a record but it is not of type BOOLEAN
+ */
+ flipBooleanValue({key} = {})
+ {
+ // update the value:
+ const update = {
+ action: "FLIP"
+ };
+ return this._updateValue(key, Shelf.Type.BOOLEAN, update);
+ }
+
+ /**
+ * Get the value of a record of type INTEGER associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {number} options.defaultValue the default value returned if no record with the given key
+ * exists on the shelf
+ * @return {Promise<number>} the value associated with the key
+ * @throws {Object.<string, *>} exception if there is no record with the given key,
+ * or if there is a record but it is locked or it is not of type BOOLEAN
+ */
+ getIntegerValue({key, defaultValue} = {})
+ {
+ return this._getValue(key, Shelf.Type.INTEGER, {defaultValue});
+ }
+
+ /**
+ * Set the value of a record of type INTEGER associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {number} options.value the new value
+ * @return {Promise<number>} the new value
+ * @throws {Object.<string, *>} exception if value is not an integer, or or if there is no record
+ * with the given key, or if there is a record but it is locked or it is not of type INTEGER
+ */
+ setIntegerValue({key, value} = {})
+ {
+ // check the value:
+ if (!Number.isInteger(value))
+ {
+ throw {
+ origin: "Shelf.setIntegerValue",
+ context: `when setting the value of the INTEGER record associated with the key: ${JSON.stringify(key)}`,
+ error: "the value should be an integer"
+ };
+ }
+
+ // update the value:
+ const update = {
+ action: "SET",
+ value
+ };
+ return this._updateValue(key, Shelf.Type.INTEGER, update);
+ }
+
+ /**
+ * Add a delta to the value of a record of type INTEGER associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {number} options.delta the delta, positive or negative, to add to the value
+ * @return {Promise<number>} the new value
+ * @throws {Object.<string, *>} exception if delta is not an integer, or if there is no record with the given
+ * key, or if there is a record but it is locked or it is not of type INTEGER
+ */
+ addIntegerValue({key, delta} = {})
+ {
+ // check the delta:
+ if (!Number.isInteger(delta))
+ {
+ throw {
+ origin: "Shelf.setIntegerValue",
+ context: `when adding a value to the value of the INTEGER record associated with the key: ${JSON.stringify(key)}`,
+ error: "the value should be an integer"
+ };
+ }
+
+ // update the value:
+ const update = {
+ action: "ADD",
+ delta
+ };
+ return this._updateValue(key, Shelf.Type.INTEGER, update);
+ }
+
+ /**
+ * Get the value of a record of type TEXT associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {string} options.defaultValue the default value returned if no record with the given key exists on
+ * the shelf
+ * @return {Promise<string>} the value associated with the key
+ * @throws {Object.<string, *>} exception if there is a record associated with the given key but it is
+ * not of type TEXT
+ */
+ getTextValue({key, defaultValue} = {})
+ {
+ return this._getValue(key, Shelf.Type.TEXT, {defaultValue});
+ }
+
+ /**
+ * Set the value of a record of type TEXT associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {string} options.value the new value
+ * @return {Promise<string>} the new value
+ * @throws {Object.<string, *>} exception if value is not a string, or if there is a record associated
+ * with the given key but it is not of type TEXT
+ */
+ setTextValue({key, value} = {})
+ {
+ // check the value:
+ if (typeof value !== "string")
+ {
+ throw {
+ origin: "Shelf.setTextValue",
+ context: `when setting the value of the TEXT record associated with the key: ${JSON.stringify(key)}`,
+ error: "the value should be a string"
+ };
+ }
+
+ // update the value:
+ const update = {
+ action: "SET",
+ value
+ };
+ return this._updateValue(key, Shelf.Type.TEXT, update);
+ }
+
+ /**
+ * Get the value of a record of type LIST associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {Array.<*>} options.defaultValue the default value returned if no record with the given key exists on
+ * the shelf
+ * @return {Promise<Array.<*>>} the value associated with the key
+ * @throws {Object.<string, *>} exception if there is no record with the given key, or if there is a record
+ * but it is locked or it is not of type LIST
+ */
+ getListValue({key, defaultValue} = {})
+ {
+ return this._getValue(key, Shelf.Type.LIST, {defaultValue});
+ }
+
+ /**
+ * Set the value of a record of type LIST associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {Array.<*>} options.value the new value
+ * @return {Promise<Array.<*>>} the new value
+ * @throws {Object.<string, *>} exception if value is not an array or if there is no record with the given key,
+ * or if there is a record but it is locked or it is not of type LIST
+ */
+ setListValue({key, value} = {})
+ {
+ // check the value:
+ if (!Array.isArray(value))
+ {
+ throw {
+ origin: "Shelf.setListValue",
+ context: `when setting the value of the LIST record associated with the key: ${JSON.stringify(key)}`,
+ error: "the value should be an array"
+ };
+ }
+
+ // update the value:
+ const update = {
+ action: "SET",
+ value
+ };
+ return this._updateValue(key, Shelf.Type.LIST, update);
+ }
+
+ /**
+ * Append an element, or a list of elements, to the value of a record of type LIST associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {*} options.elements the element or list of elements to be appended
+ * @return {Promise<Array.<*>>} the new value
+ * @throws {Object.<string, *>} exception if there is no record with the given key, or if there is a record
+ * but it is locked or it is not of type LIST
+ */
+ appendListValue({key, elements} = {})
+ {
+ // update the value:
+ const update = {
+ action: "APPEND",
+ elements
+ };
+ return this._updateValue(key, Shelf.Type.LIST, update);
+ }
+
+ /**
+ * Pop an element, at the given index, from the value of a record of type LIST associated
+ * with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {number} [options.index = -1] the index of the element to be popped
+ * @return {Promise<*>} the popped element
+ * @throws {Object.<string, *>} exception if there is no record with the given key, or if there is a record
+ * but it is locked or it is not of type LIST
+ */
+ popListValue({key, index = -1} = {})
+ {
+ // update the value:
+ const update = {
+ action: "POP",
+ index
+ };
+ return this._updateValue(key, Shelf.Type.LIST, update);
+ }
+
+ /**
+ * Empty the value of a record of type LIST associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @return {Promise<Array.<*>>} the new, empty value, i.e. []
+ * @throws {Object.<string, *>} exception if there is no record with the given key, or if there is a record
+ * but it is locked or it is not of type LIST
+ */
+ clearListValue({key} = {})
+ {
+ // update the value:
+ const update = {
+ action: "CLEAR"
+ };
+ return this._updateValue(key, Shelf.Type.LIST, update);
+ }
+
+ /**
+ * Shuffle the elements of the value of a record of type LIST associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @return {Promise<Array.<*>>} the new, shuffled value
+ * @throws {Object.<string, *>} exception if there is no record with the given key, or if there is a record
+ * but it is locked or it is not of type LIST
+ */
+ shuffleListValue({key} = {})
+ {
+ // update the value:
+ const update = {
+ action: "SHUFFLE"
+ };
+ return this._updateValue(key, Shelf.Type.LIST, update);
+ }
+
+
+ /**
+ * Get the names of the fields in the dictionary record associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @return {Promise<string[]>} the list of field names
+ * @throws {Object.<string, *>} exception if there is no record with the given key, or if there is a record
+ * but it is locked or it is not of type DICTIONARY
+ */
+ async getDictionaryFieldNames({key} = {})
+ {
+ return this._getValue(key, Shelf.Type.DICTIONARY, {fieldNames: true});
+ }
+
+ /**
+ * Get the value of a given field in the dictionary record associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {string} options.fieldName the name of the field
+ * @param {boolean} options.defaultValue the default value returned if no record with the given key exists on
+ * the shelf, or if is a record of type DICTIONARY with the given key but it has no such field
+ * @return {Promise<*>} the value of that field
+ * @throws {Object.<string, *>} exception if there is no record with the given key,
+ * or if there is a record but it is locked or it is not of type DICTIONARY
+ */
+ async getDictionaryFieldValue({key, fieldName, defaultValue} = {})
+ {
+ return this._getValue(key, Shelf.Type.DICTIONARY, {fieldName, defaultValue});
+ }
+
+ /**
+ * Set a field in the dictionary record associated to the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {string} options.fieldName the name of the field
+ * @param {*} options.fieldValue the value of the field
+ * @return {Promise<Object.<string, *>>} the updated dictionary
+ * @throws {Object.<string, *>} exception if there is no record with the given key,
+ * or if there is a record but it is locked or it is not of type DICTIONARY
+ */
+ async setDictionaryFieldValue({key, fieldName, fieldValue} = {})
+ {
+ // update the value:
+ const update = {
+ action: "FIELD_SET",
+ fieldName,
+ fieldValue
+ };
+ return this._updateValue(key, Shelf.Type.DICTIONARY, update);
+ }
+
+ /**
+ * Get the value of a record of type DICTIONARY associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {Object.<string, *>} options.defaultValue the default value returned if no record with the given key
+ * exists on the shelf
+ * @return {Promise<Object.<string, *>>} the value associated with the key
+ * @throws {Object.<string, *>} exception if there is no record with the given key,
+ * or if there is a record but it is locked or it is not of type DICTIONARY
+ */
+ getDictionaryValue({key, defaultValue} = {})
+ {
+ return this._getValue(key, Shelf.Type.DICTIONARY, {defaultValue});
+ }
+
+ /**
+ * Set the value of a record of type DICTIONARY associated with the given key.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {Object.<string, *>} options.value the new value
+ * @return {Promise<Object.<string, *>>} the new value
+ * @throws {Object.<string, *>} exception if value is not an object, or or if there is no record
+ * with the given key, or if there is a record but it is locked or it is not of type DICTIONARY
+ */
+ setDictionaryValue({key, value} = {})
+ {
+ // check the value:
+ if (typeof value !== "object")
+ {
+ throw {
+ origin: "Shelf.setDictionaryValue",
+ context: `when setting the value of the DICTIONARY record associated with the key: ${JSON.stringify(key)}`,
+ error: "the value should be an object"
+ };
+ }
+
+ // update the value:
+ const update = {
+ action: "SET",
+ value
+ };
+ return this._updateValue(key, Shelf.Type.DICTIONARY, update);
+ }
+
+ /**
+ * Schedulable component that will block the experiment until the counter associated with the given key
+ * has been incremented by the given amount.
+ *
+ * @param key
+ * @param increment
+ * @param callback
+ * @returns {function(): module:util.Scheduler.Event|Symbol|*} a component that can be scheduled
+ *
+ * @example
+ * const flowScheduler = new Scheduler(psychoJS);
+ * var experimentCounter = '<>';
+ * flowScheduler.add(psychoJS.shelf.incrementComponent(['counter'], 1, (value) => experimentCounter = value));
+ */
+ incrementComponent(key = [], increment = 1, callback)
+ {
+ const response = {
+ origin: 'Shelf.incrementComponent',
+ context: 'when making a component to increment a shelf counter'
+ };
+
+ try
+ {
+ // TODO replace this._incrementComponent by a component with a unique name
+ let incrementComponent = {};
+ incrementComponent.status = PsychoJS.Status.NOT_STARTED;
+ return () =>
+ {
+ if (incrementComponent.status === PsychoJS.Status.NOT_STARTED)
+ {
+ incrementComponent.status = PsychoJS.Status.STARTED;
+ this.increment(key, increment)
+ .then( (newValue) =>
+ {
+ callback(newValue);
+ incrementComponent.status = PsychoJS.Status.FINISHED;
+ });
+ }
+
+ return (incrementComponent.status === PsychoJS.Status.FINISHED) ?
+ Scheduler.Event.NEXT :
+ Scheduler.Event.FLIP_REPEAT;
+ };
+ }
+ catch (error)
+ {
+ this._status = Shelf.Status.ERROR;
+ throw {...response, error};
+ }
+ }
+
+ /**
+ * Get the name of a group, using a counterbalanced design.
+ *
+ * @param {Object} options
+ * @param {string[]} options.key key as an array of key components
+ * @param {string[]} options.groups the names of the groups
+ * @param {number[]} options.groupSizes the size of the groups
+ * @return {Promise<{string, boolean}>} an object with the name of the selected group and whether all groups
+ * have been depleted
+ */
+ async counterBalanceSelect({key, groups, groupSizes} = {})
+ {
+ const response = {
+ origin: 'Shelf.counterBalanceSelect',
+ context: `when getting the name of a group, using a counterbalanced design, with key: ${JSON.stringify(key)}`
+ };
+
+ try
+ {
+ await this._checkAvailability("counterBalanceSelect");
+ this._checkKey(key);
+
+ // prepare the request:
+ const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counterbalance`;
+ const data = {
+ key,
+ groups,
+ groupSizes
+ };
+
+ // query the server:
+ const putResponse = await fetch(url, {
+ method: 'PUT',
+ mode: 'cors',
+ cache: 'no-cache',
+ credentials: 'same-origin',
+ redirect: 'follow',
+ referrerPolicy: 'no-referrer',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(data)
+ });
+
+ // convert the response to json:
+ const document = await putResponse.json();
+
+ if (putResponse.status !== 200)
+ {
+ throw ('error' in document) ? document.error : document;
+ }
+
+ // return the updated value:
+ this._status = Shelf.Status.READY;
+ return {
+ group: document.group,
+ finished: document.finished
+ };
+ }
+ catch (error)
+ {
+ this._status = Shelf.Status.ERROR;
+ throw {...response, error};
+ }
+ }
+
+
+ /**
+ * Update the value associated with the given key.
+ *
+ * <p>This is a generic method, typically called from the Shelf helper methods, e.g. setBinaryValue.</p>
+ *
+ * @param {string[]} key key as an array of key components
+ * @param {Shelf.Type} type the type of the record associated with the given key
+ * @param {*} update the desired update
+ * @return {Promise<any>} the updated value
+ * @throws {Object.<string, *>} exception if there is no record associated with the given key or if there is one
+ * but it is not of the given type
+ */
+ async _updateValue(key, type, update)
+ {
+ const response = {
+ origin: 'Shelf._updateValue',
+ context: `when updating the value of the ${Symbol.keyFor(type)} record associated with key: ${JSON.stringify(key)}`
+ };
+
+ try
+ {
+ await this._checkAvailability("_updateValue");
+ this._checkKey(key);
+
+ // prepare the request:
+ const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/value`;
+ const data = {
+ key,
+ type: Symbol.keyFor(type),
+ update
+ };
+
+ // query the server:
+ const postResponse = await fetch(url, {
+ method: 'POST',
+ mode: 'cors',
+ cache: 'no-cache',
+ credentials: 'same-origin',
+ redirect: 'follow',
+ referrerPolicy: 'no-referrer',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(data)
+ });
+
+ // convert the response to json:
+ const document = await postResponse.json();
+
+ if (postResponse.status !== 200)
+ {
+ throw ('error' in document) ? document.error : document;
+ }
+
+ // return the updated value:
+ this._status = Shelf.Status.READY;
+ return document.value;
+ }
+ catch (error)
+ {
+ this._status = Shelf.Status.ERROR;
+ throw {...response, error};
+ }
+ }
+
+ /**
+ * Get the value associated with the given key.
+ *
+ * <p>This is a generic method, typically called from the Shelf helper methods, e.g. getBinaryValue.</p>
+ *
+ * @param {string[]} key key as an array of key components
+ * @param {Shelf.Type} type the type of the record associated with the given key
+ * @param {Object} [options] the options, e.g. the default value returned if no record with the
+ * given key exists on the shelf
+ * @return {Promise<any>} the value
+ * @throws {Object.<string, *>} exception if there is a record associated with the given key but it is not of
+ * the given type
+ */
+ async _getValue(key, type, options)
+ {
+ const response = {
+ origin: 'Shelf._getValue',
+ context: `when getting the value of the ${Symbol.keyFor(type)} record associated with key: ${JSON.stringify(key)}`
+ };
+
+ try
+ {
+ await this._checkAvailability("_getValue");
+ this._checkKey(key);
+
+ // prepare the request:
+ const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/value`;
+ const data = {
+ key,
+ type: Symbol.keyFor(type)
+ };
+
+ if (typeof options !== 'undefined')
+ {
+ for (const attribute in options)
+ {
+ if (typeof options[attribute] !== "undefined")
+ {
+ data[attribute] = options[attribute];
+ }
+ }
+ }
+
+ // query the server:
+ const putResponse = await fetch(url, {
+ method: 'PUT',
+ mode: 'cors',
+ cache: 'no-cache',
+ credentials: 'same-origin',
+ redirect: 'follow',
+ referrerPolicy: 'no-referrer',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(data)
+ });
+
+ const document = await putResponse.json();
+
+ if (putResponse.status !== 200)
+ {
+ throw ('error' in document) ? document.error : document;
+ }
+
+ // return the value:
+ this._status = Shelf.Status.READY;
+ return document.value;
+ }
+ catch (error)
+ {
+ this._status = Shelf.Status.ERROR;
+ throw {...response, error};
+ }
+ }
+
+ /**
+ * Check whether it is possible to run a given shelf command.
+ *
+ * <p>Since all Shelf methods call _checkAvailability, we also use it as a means to throttle those calls.</p>
+ *
+ * @param {string} [methodName=""] - name of the method requiring a check
+ * @throws {Object.<string, *>} exception if it is not possible to run the given shelf command
+ */
+ _checkAvailability(methodName = "")
+ {
+ // Shelf requires access to the server, where the key/value pairs are stored:
+ if (this._psychoJS.config.environment !== ExperimentHandler.Environment.SERVER)
+ {
+ throw {
+ origin: 'Shelf._checkAvailability',
+ context: 'when checking whether Shelf is available',
+ error: 'the experiment has to be run on the server: shelf commands are not available locally'
+ };
+ }
+
+ // throttle calls to Shelf methods:
+ const self = this;
+ return new Promise((resolve, reject) =>
+ {
+ const now = performance.now();
+
+ // if the last scheduled call already occurred, schedule this one as soon as possible,
+ // taking into account the throttling period:
+ let timeoutDuration;
+ if (now > self._lastScheduledCallTimestamp)
+ {
+ timeoutDuration = Math.max(0.0, self._throttlingPeriod_ms - (now - self._lastCallTimestamp));
+ self._lastScheduledCallTimestamp = now + timeoutDuration;
+ }
+ // otherwise, schedule it after the next call:
+ else
+ {
+ self._lastScheduledCallTimestamp += self._throttlingPeriod_ms;
+ timeoutDuration = self._lastScheduledCallTimestamp;
+ }
+
+ setTimeout(
+ () => {
+ self._lastCallTimestamp = performance.now();
+ self._status = Shelf.Status.BUSY;
+ resolve();
+ },
+ timeoutDuration
+ );
+ });
+ }
+
+ /**
+ * Check the validity of the key.
+ *
+ * @param {object} key key whose validity is to be checked
+ * @throws {Object.<string, *>} exception if the key is invalid
+ */
+ _checkKey(key)
+ {
+ // the key must be a non empty array:
+ if (!Array.isArray(key) || key.length === 0)
+ {
+ throw 'the key must be a non empty array';
+ }
+
+ if (key.length > Shelf.#MAX_KEY_LENGTH)
+ {
+ throw 'the key consists of too many components';
+ }
+
+ // the only @<component> in the key should be @designer and @experiment
+ // TODO
+ }
+}
+
+/**
+ * Shelf status
+ *
+ * @enum {Symbol}
+ * @readonly
+ */
+Shelf.Status = {
+ /**
+ * The shelf is ready.
+ */
+ READY: Symbol.for('READY'),
+
+ /**
+ * The shelf is busy, e.g. storing or retrieving values.
+ */
+ BUSY: Symbol.for('BUSY'),
+
+ /**
+ * The shelf has encountered an error.
+ */
+ ERROR: Symbol.for('ERROR')
+};
+
+/**
+ * Shelf record types.
+ *
+ * @enum {Symbol}
+ * @readonly
+ */
+Shelf.Type = {
+ INTEGER: Symbol.for('INTEGER'),
+ TEXT: Symbol.for('TEXT'),
+ DICTIONARY: Symbol.for('DICTIONARY'),
+ BOOLEAN: Symbol.for('BOOLEAN'),
+ LIST: Symbol.for('LIST')
+};
+
*
* @author Alain Pitiot
* @author Hiroyuki Sogo & Sotiri Bakagiannis - better support for BOM and accented characters
- * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @version 2022.2.3
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
-
-import seedrandom from 'seedrandom';
-import * as XLSX from 'xlsx';
-import {PsychObject} from '../util/PsychObject';
-import * as util from '../util/Util';
+import seedrandom from "seedrandom";
+import * as XLSX from "xlsx";
+import { PsychObject } from "../util/PsychObject.js";
+import * as util from "../util/Util.js";
/**
* <p>A Trial Handler handles the importing and sequencing of conditions.</p>
*
- * @class
* @extends PsychObject
- * @param {Object} 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
- * @param {module:data.TrialHandler.Method} options.method - the trial method
- * @param {Object} options.extraInfo - additional information to be stored alongside the trial data, e.g. session ID, participant ID, etc.
- * @param {number} options.seed - seed for the random number generator
- * @param {boolean} [options.autoLog= false] - whether or not to log
*/
export class TrialHandler extends PsychObject
{
-
/**
* Getter for experimentHandler.
- *
- * @name module:core.Window#experimentHandler
- * @function
- * @public
*/
get experimentHandler()
{
@@ -74,45 +83,46 @@
Source: data/TrialHandler.js
/**
* Setter for experimentHandler.
- *
- * @name module:core.Window#experimentHandler
- * @function
- * @public
*/
set experimentHandler(exp)
{
this._experimentHandler = exp;
}
-
/**
- * @constructor
- * @public
+ * @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
+ * @param {module:data.TrialHandler.Method} options.method - the trial method
+ * @param {Object} options.extraInfo - additional information to be stored alongside the trial data, e.g. session ID, participant ID, etc.
+ * @param {number} options.seed - seed for the random number generator
+ * @param {boolean} [options.autoLog= false] - whether or not to log
*
* @todo extraInfo is not taken into account, we use the expInfo of the ExperimentHandler instead
*/
constructor({
- psychoJS,
- trialList = [undefined],
- nReps,
- method = TrialHandler.Method.RANDOM,
- extraInfo = [],
- seed,
- name,
- autoLog = true
- } = {})
+ psychoJS,
+ trialList = [undefined],
+ nReps,
+ method = TrialHandler.Method.RANDOM,
+ extraInfo = [],
+ seed,
+ name,
+ autoLog = true,
+ } = {})
{
super(psychoJS);
- this._addAttribute('trialList', trialList);
- this._addAttribute('nReps', nReps);
- this._addAttribute('method', method);
- this._addAttribute('extraInfo', extraInfo);
- this._addAttribute('name', name);
- this._addAttribute('autoLog', autoLog);
- this._addAttribute('seed', seed);
- this._prepareTrialList(trialList);
-
+ this._addAttribute("trialList", trialList);
+ this._addAttribute("nReps", nReps);
+ this._addAttribute("method", method);
+ this._addAttribute("extraInfo", extraInfo);
+ this._addAttribute("name", name);
+ this._addAttribute("autoLog", autoLog);
+ this._addAttribute("seed", seed);
+ this._prepareTrialList();
+
// number of stimuli
this.nStim = this.trialList.length;
@@ -140,7 +150,6 @@
Source: data/TrialHandler.js
// array of current snapshots:
this._snapshots = [];
-
// setup the trial sequence:
this._prepareSequence();
@@ -149,18 +158,17 @@
Source: data/TrialHandler.js
this._finished = false;
}
-
/**
* Helps go through each trial in the sequence one by one, mirrors PsychoPy.
*/
- next() {
+ next()
+ {
const trialIterator = this[Symbol.iterator]();
const { value } = trialIterator.next();
return value;
}
-
/**
* Iterator over the trial sequence.
*
@@ -196,7 +204,7 @@
*
* <p>This is typically used in the LoopBegin function, in order to capture the current state of a TrialHandler</p>
*
- * @public
* @return {Snapshot} - a snapshot of the current internal state.
*/
getSnapshot()
@@ -281,12 +286,12 @@
{
this._psychoJS.logger.warn(`attempt to replace the value of protected TrialHandler variable: ${attribute}`);
}
- snapshot.trialAttributes = trialAttributes;
}
+ snapshot.trialAttributes = trialAttributes;
// add the snapshot to the list:
this._snapshots.push(snapshot);
@@ -308,7 +313,6 @@
Source: data/TrialHandler.js
return snapshot;
}
-
/**
* Setter for the seed attribute.
*
@@ -317,9 +321,9 @@
}
}
-
/**
- * Set the internal state of this trial handler from the given snapshot.
+ * Set the internal state of the snapshot's trial handler from the snapshot.
*
- * @public
- * @static
- * @param {Snapshot} snapshot - the snapshot from which to update the current internal state.
+ * @param {Snapshot} snapshot - the snapshot from which to update the current internal state of the
+ * snapshot's trial handler
*/
static fromSnapshot(snapshot)
{
// if snapshot is undefined, do nothing:
- if (typeof snapshot === 'undefined')
+ if (typeof snapshot === "undefined")
{
return;
}
@@ -354,17 +356,25 @@
Source: data/TrialHandler.js
snapshot.handler.thisIndex = snapshot.thisIndex;
snapshot.handler.ran = snapshot.ran;
snapshot.handler._finished = snapshot._finished;
-
snapshot.handler.thisTrial = snapshot.handler.getCurrentTrial();
- // add to the trial handler the snapshot's trial attributes:
+ // add the snapshot's trial attributes to a global variable, whose name is derived from
+ // that of the handler: loops -> thisLoop (note the dropped s):
+ let name = snapshot.name;
+ if (name[name.length - 1] === "s")
+ {
+ name = name.substr(0, name.length - 1);
+ }
+ name = `this${name[0].toUpperCase()}${name.substr(1)}`;
+
+ const value = {};
for (const attribute of snapshot.trialAttributes)
{
- snapshot.handler[attribute] = snapshot[attribute];
+ value[attribute] = snapshot[attribute];
}
+ window[name] = value;
}
-
/**
* Getter for the finished attribute.
*
@@ -375,7 +385,6 @@
Source: data/TrialHandler.js
return this._finished;
}
-
/**
* Setter for the finished attribute.
*
@@ -384,18 +393,16 @@
Source: data/TrialHandler.js
set finished(isFinished)
{
this._finished = isFinished;
-
- this._snapshots.forEach( snapshot =>
+
+ this._snapshots.forEach((snapshot) =>
{
snapshot.finished = isFinished;
});
}
-
/**
* Get the trial index.
*
- * @public
* @return {number} the current trial index
*/
getTrialIndex()
@@ -403,7 +410,6 @@
Source: data/TrialHandler.js
return this.thisIndex;
}
-
/**
* Set the trial index.
*
@@ -414,14 +420,12 @@
Source: data/TrialHandler.js
this.thisIndex = index;
}
-
/**
* Get the attributes of the trials.
*
* <p>Note: we assume that all trials in the trialList share the same attributes
* and consequently consider only the attributes of the first trial.</p>
*
- * @public
* @return {Array.string} the attributes
*/
getAttributes()
@@ -440,11 +444,9 @@
Source: data/TrialHandler.js
return Object.keys(this.trialList[0]);
}
-
/**
* Get the current trial.
*
- * @public
* @return {Object} the current trial
*/
getCurrentTrial()
@@ -452,7 +454,6 @@
Source: data/TrialHandler.js
return this.trialList[this.thisIndex];
}
-
/**
* Get the nth trial.
*
@@ -469,11 +470,9 @@
Source: data/TrialHandler.js
return this.trialList[index];
}
-
/**
* Get the nth future or past trial, without advancing through the trial list.
*
- * @public
* @param {number} [n = 1] - increment
* @return {Object|undefined} the future trial (if n is positive) or past trial (if n is negative)
* or undefined if attempting to go beyond the last trial.
@@ -488,12 +487,10 @@
Source: data/TrialHandler.js
return this.trialList[this.thisIndex + n];
}
-
/**
* Get the nth previous trial.
* <p> Note: this is useful for comparisons in n-back tasks.</p>
*
- * @public
* @param {number} [n = -1] - increment
* @return {Object|undefined} the past trial or undefined if attempting to go prior to the first trial.
*/
@@ -502,11 +499,9 @@
Source: data/TrialHandler.js
return getFutureTrial(-abs(n));
}
-
/**
* Add a key/value pair to data about the current trial held by the experiment handler
*
- * @public
* @param {Object} key - the key
* @param {Object} value - the value
*/
@@ -518,7 +513,6 @@
Source: data/TrialHandler.js
}
}
-
/**
* Import a list of conditions from a .xls, .xlsx, .odp, or .csv resource.
*
@@ -546,8 +540,6 @@
Source: data/TrialHandler.js
* '5:'
* '-5:-2, 9, 11:5:22'
*
- * @public
- * @static
* @param {module:core.ServerManager} serverManager - the server manager
* @param {String} resourceName - the name of the resource containing the list of conditions, which must have been registered with the server manager.
* @param {Object} [selection = null] - the selection
@@ -558,8 +550,8 @@
// which is then read in as a string
const decodedResourceMaybe = new Uint8Array(resourceValue);
// Could be set to 'buffer' for ASCII .csv
- const type = resourceExtension === 'csv' ? 'string' : 'array';
- const decodedResource = type === 'string' ? (new TextDecoder()).decode(decodedResourceMaybe) : decodedResourceMaybe;
+ const type = resourceExtension === "csv" ? "string" : "array";
+ const decodedResource = type === "string" ? (new TextDecoder()).decode(decodedResourceMaybe) : decodedResourceMaybe;
const workbook = XLSX.read(decodedResource, { type });
// we consider only the first worksheet:
if (workbook.SheetNames.length === 0)
{
- throw 'workbook should contain at least one worksheet';
+ throw "workbook should contain at least one worksheet";
}
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
// worksheet to array of arrays (the first array contains the fields):
- const sheet = XLSX.utils.sheet_to_json(worksheet, {header: 1, blankrows: false});
+ const sheet = XLSX.utils.sheet_to_json(worksheet, { header: 1, blankrows: false });
const fields = sheet.shift();
// (*) select conditions:
@@ -589,9 +581,9 @@
Source: data/TrialHandler.js
// (*) 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)
@@ -614,7 +606,7 @@
Source: data/TrialHandler.js
value = arrayMaybe;
}
- if (typeof value === 'string')
+ if (typeof value === "string")
{
const numberMaybe = Number.parseFloat(value);
@@ -626,7 +618,7 @@
Source: data/TrialHandler.js
else
{
// Parse doubly escaped line feeds
- value = value.replace(/(\n)/g, '\n');
+ value = value.replace(/(\n)/g, "\n");
}
}
@@ -637,74 +629,68 @@
Source: data/TrialHandler.js
return trialList;
}
-
else
{
- throw 'extension: ' + resourceExtension + ' currently not supported.';
+ throw "extension: " + resourceExtension + " currently not supported.";
}
}
catch (error)
{
throw {
- origin: 'TrialHandler.importConditions',
+ origin: "TrialHandler.importConditions",
context: `when importing condition: ${resourceName}`,
- error
+ error,
};
}
}
-
/**
* Prepare the trial list.
*
* @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',
- context: 'when preparing the trial list'
+ origin: "TrialHandler._prepareTrialList",
+ context: "when preparing the trial list",
};
// 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)}`
});
}
}
-
- /*
+ /**
* Prepare the sequence of trials.
*
* <p>The returned sequence is a matrix (an array of arrays) of trial indices
* with nStim columns and nReps rows. Note that this is the transpose of the
* matrix return by PsychoPY.
- *
+ *
* Example: with 3 trial and 5 repetitions, we get:
* - sequential:
* [[0 1 2]
@@ -723,25 +709,24 @@
Source: data/TrialHandler.js
* </p>
*
* @protected
- */
+ **/
_prepareSequence()
{
const response = {
- origin: 'TrialHandler._prepareSequence',
- context: 'when preparing a sequence of trials'
+ origin: "TrialHandler._prepareSequence",
+ 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; }, [] );
+ // 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)
@@ -749,11 +734,10 @@
Source: data/TrialHandler.js
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);
@@ -771,42 +755,39 @@
Source: data/TrialHandler.js
}
else
{
- throw Object.assign(response, {error: 'unknown method'});
+ throw Object.assign(response, { error: "unknown method" });
}
return this._trialSequence;
}
-
}
-
/**
* TrialHandler method
*
* @enum {Symbol}
* @readonly
- * @public
*/
TrialHandler.Method = {
/**
* Conditions are presented in the order they are given.
*/
- SEQUENTIAL: Symbol.for('SEQUENTIAL'),
+ SEQUENTIAL: Symbol.for("SEQUENTIAL"),
/**
* Conditions are shuffled within each repeat.
*/
- RANDOM: Symbol.for('RANDOM'),
+ RANDOM: Symbol.for("RANDOM"),
/**
* Conditions are fully randomised across all repeats.
*/
- FULL_RANDOM: Symbol.for('FULL_RANDOM'),
+ FULL_RANDOM: Symbol.for("FULL_RANDOM"),
/**
* Same as above, but named to reflect PsychoPy boileplate.
*/
- FULLRANDOM: Symbol.for('FULL_RANDOM')
+ FULLRANDOM: Symbol.for("FULL_RANDOM"),
};
@@ -815,19 +796,23 @@
Source: data/TrialHandler.js
+
+
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/docs/fonts/Montserrat/Montserrat-Bold.eot b/docs/fonts/Montserrat/Montserrat-Bold.eot
new file mode 100644
index 00000000..f2970bbd
Binary files /dev/null and b/docs/fonts/Montserrat/Montserrat-Bold.eot differ
diff --git a/docs/fonts/Montserrat/Montserrat-Bold.ttf b/docs/fonts/Montserrat/Montserrat-Bold.ttf
new file mode 100644
index 00000000..3bfd79b6
Binary files /dev/null and b/docs/fonts/Montserrat/Montserrat-Bold.ttf differ
diff --git a/docs/fonts/Montserrat/Montserrat-Bold.woff b/docs/fonts/Montserrat/Montserrat-Bold.woff
new file mode 100644
index 00000000..92607654
Binary files /dev/null and b/docs/fonts/Montserrat/Montserrat-Bold.woff differ
diff --git a/docs/fonts/Montserrat/Montserrat-Bold.woff2 b/docs/fonts/Montserrat/Montserrat-Bold.woff2
new file mode 100644
index 00000000..d9940cd1
Binary files /dev/null and b/docs/fonts/Montserrat/Montserrat-Bold.woff2 differ
diff --git a/docs/fonts/Montserrat/Montserrat-Regular.eot b/docs/fonts/Montserrat/Montserrat-Regular.eot
new file mode 100644
index 00000000..735d12b5
Binary files /dev/null and b/docs/fonts/Montserrat/Montserrat-Regular.eot differ
diff --git a/docs/fonts/Montserrat/Montserrat-Regular.ttf b/docs/fonts/Montserrat/Montserrat-Regular.ttf
new file mode 100644
index 00000000..5da852a3
Binary files /dev/null and b/docs/fonts/Montserrat/Montserrat-Regular.ttf differ
diff --git a/docs/fonts/Montserrat/Montserrat-Regular.woff b/docs/fonts/Montserrat/Montserrat-Regular.woff
new file mode 100644
index 00000000..bf918327
Binary files /dev/null and b/docs/fonts/Montserrat/Montserrat-Regular.woff differ
diff --git a/docs/fonts/Montserrat/Montserrat-Regular.woff2 b/docs/fonts/Montserrat/Montserrat-Regular.woff2
new file mode 100644
index 00000000..72d13c60
Binary files /dev/null and b/docs/fonts/Montserrat/Montserrat-Regular.woff2 differ
diff --git a/docs/fonts/OpenSans-Bold-webfont.eot b/docs/fonts/OpenSans-Bold-webfont.eot
deleted file mode 100644
index 5d20d916..00000000
Binary files a/docs/fonts/OpenSans-Bold-webfont.eot and /dev/null differ
diff --git a/docs/fonts/OpenSans-Bold-webfont.svg b/docs/fonts/OpenSans-Bold-webfont.svg
deleted file mode 100644
index 3ed7be4b..00000000
--- a/docs/fonts/OpenSans-Bold-webfont.svg
+++ /dev/null
@@ -1,1830 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/docs/fonts/OpenSans-Bold-webfont.woff b/docs/fonts/OpenSans-Bold-webfont.woff
deleted file mode 100644
index 1205787b..00000000
Binary files a/docs/fonts/OpenSans-Bold-webfont.woff and /dev/null differ
diff --git a/docs/fonts/OpenSans-BoldItalic-webfont.eot b/docs/fonts/OpenSans-BoldItalic-webfont.eot
deleted file mode 100644
index 1f639a15..00000000
Binary files a/docs/fonts/OpenSans-BoldItalic-webfont.eot and /dev/null differ
diff --git a/docs/fonts/OpenSans-BoldItalic-webfont.svg b/docs/fonts/OpenSans-BoldItalic-webfont.svg
deleted file mode 100644
index 6a2607b9..00000000
--- a/docs/fonts/OpenSans-BoldItalic-webfont.svg
+++ /dev/null
@@ -1,1830 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/docs/fonts/OpenSans-BoldItalic-webfont.woff b/docs/fonts/OpenSans-BoldItalic-webfont.woff
deleted file mode 100644
index ed760c06..00000000
Binary files a/docs/fonts/OpenSans-BoldItalic-webfont.woff and /dev/null differ
diff --git a/docs/fonts/OpenSans-Italic-webfont.eot b/docs/fonts/OpenSans-Italic-webfont.eot
deleted file mode 100644
index 0c8a0ae0..00000000
Binary files a/docs/fonts/OpenSans-Italic-webfont.eot and /dev/null differ
diff --git a/docs/fonts/OpenSans-Italic-webfont.svg b/docs/fonts/OpenSans-Italic-webfont.svg
deleted file mode 100644
index e1075dcc..00000000
--- a/docs/fonts/OpenSans-Italic-webfont.svg
+++ /dev/null
@@ -1,1830 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/docs/fonts/OpenSans-Italic-webfont.woff b/docs/fonts/OpenSans-Italic-webfont.woff
deleted file mode 100644
index ff652e64..00000000
Binary files a/docs/fonts/OpenSans-Italic-webfont.woff and /dev/null differ
diff --git a/docs/fonts/OpenSans-Light-webfont.eot b/docs/fonts/OpenSans-Light-webfont.eot
deleted file mode 100644
index 14868406..00000000
Binary files a/docs/fonts/OpenSans-Light-webfont.eot and /dev/null differ
diff --git a/docs/fonts/OpenSans-Light-webfont.svg b/docs/fonts/OpenSans-Light-webfont.svg
deleted file mode 100644
index 11a472ca..00000000
--- a/docs/fonts/OpenSans-Light-webfont.svg
+++ /dev/null
@@ -1,1831 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/docs/fonts/OpenSans-Light-webfont.woff b/docs/fonts/OpenSans-Light-webfont.woff
deleted file mode 100644
index e7860748..00000000
Binary files a/docs/fonts/OpenSans-Light-webfont.woff and /dev/null differ
diff --git a/docs/fonts/OpenSans-LightItalic-webfont.eot b/docs/fonts/OpenSans-LightItalic-webfont.eot
deleted file mode 100644
index 8f445929..00000000
Binary files a/docs/fonts/OpenSans-LightItalic-webfont.eot and /dev/null differ
diff --git a/docs/fonts/OpenSans-LightItalic-webfont.svg b/docs/fonts/OpenSans-LightItalic-webfont.svg
deleted file mode 100644
index 431d7e35..00000000
--- a/docs/fonts/OpenSans-LightItalic-webfont.svg
+++ /dev/null
@@ -1,1835 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/docs/fonts/OpenSans-LightItalic-webfont.woff b/docs/fonts/OpenSans-LightItalic-webfont.woff
deleted file mode 100644
index 43e8b9e6..00000000
Binary files a/docs/fonts/OpenSans-LightItalic-webfont.woff and /dev/null differ
diff --git a/docs/fonts/OpenSans-Regular-webfont.eot b/docs/fonts/OpenSans-Regular-webfont.eot
deleted file mode 100644
index 6bbc3cf5..00000000
Binary files a/docs/fonts/OpenSans-Regular-webfont.eot and /dev/null differ
diff --git a/docs/fonts/OpenSans-Regular-webfont.svg b/docs/fonts/OpenSans-Regular-webfont.svg
deleted file mode 100644
index 25a39523..00000000
--- a/docs/fonts/OpenSans-Regular-webfont.svg
+++ /dev/null
@@ -1,1831 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/docs/fonts/OpenSans-Regular-webfont.woff b/docs/fonts/OpenSans-Regular-webfont.woff
deleted file mode 100644
index e231183d..00000000
Binary files a/docs/fonts/OpenSans-Regular-webfont.woff and /dev/null differ
diff --git a/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot b/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot
new file mode 100644
index 00000000..0f24510b
Binary files /dev/null and b/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot differ
diff --git a/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg b/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg
new file mode 100644
index 00000000..5384f985
--- /dev/null
+++ b/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg
@@ -0,0 +1,978 @@
+
+
+
\ No newline at end of file
diff --git a/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf b/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf
new file mode 100644
index 00000000..e6c158c2
Binary files /dev/null and b/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf differ
diff --git a/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff b/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff
new file mode 100644
index 00000000..d0a1c292
Binary files /dev/null and b/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff differ
diff --git a/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 b/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2
new file mode 100644
index 00000000..d2869749
Binary files /dev/null and b/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 differ
diff --git a/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot b/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot
new file mode 100644
index 00000000..b4204488
Binary files /dev/null and b/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot differ
diff --git a/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg b/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg
new file mode 100644
index 00000000..dee0949f
--- /dev/null
+++ b/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg
@@ -0,0 +1,1049 @@
+
+
+
\ No newline at end of file
diff --git a/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf b/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf
new file mode 100644
index 00000000..4d56c337
Binary files /dev/null and b/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf differ
diff --git a/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff b/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff
new file mode 100644
index 00000000..4681019d
Binary files /dev/null and b/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff differ
diff --git a/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 b/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2
new file mode 100644
index 00000000..8ddcae37
Binary files /dev/null and b/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 differ
diff --git a/docs/hardware_Camera.js.html b/docs/hardware_Camera.js.html
new file mode 100644
index 00000000..cd01b60e
--- /dev/null
+++ b/docs/hardware_Camera.js.html
@@ -0,0 +1,724 @@
+
+
+
+
+
+ hardware/Camera.js - PsychoJS API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
hardware/Camera.js
+
+
+
+
+
+
+
+
+
+
/** **/
+/**
+ * Manager handling the recording of video signal.
+ *
+ * @author Alain Pitiot
+ * @version 2022.2.0
+ * @copyright (c) 2022 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @license Distributed under the terms of the MIT License
+ */
+
+import {Clock} from "../util/Clock.js";
+import {PsychObject} from "../util/PsychObject.js";
+import {PsychoJS} from "../core/PsychoJS.js";
+import * as util from "../util/Util.js";
+import {ExperimentHandler} from "../data/ExperimentHandler.js";
+// import {VideoClip} from "./VideoClip";
+
+
+/**
+ * <p>This manager handles the recording of video signal.</p>
+ *
+ * @name module:hardware.Camera
+ * @class
+ * @param {Object} options
+ * @param {module:core.Window} options.win - the associated Window
+ * @param {string} [options.format='video/webm;codecs=vp9'] the video format
+ * @param {Clock} [options.clock= undefined] - an optional clock
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ *
+ * @todo add video constraints as parameter
+ */
+export class Camera extends PsychObject
+{
+ constructor({win, name, format, clock, autoLog} = {})
+ {
+ super(win._psychoJS);
+
+ this._addAttribute("win", win, undefined);
+ this._addAttribute("name", name, "camera");
+ this._addAttribute("format", format, "video/webm;codecs=vp9", this._onChange);
+ this._addAttribute("clock", clock, new Clock());
+ this._addAttribute("autoLog", autoLog, false);
+ this._addAttribute("status", PsychoJS.Status.NOT_STARTED);
+
+ this._stream = null;
+ this._recorder = null;
+
+ if (this._autoLog)
+ {
+ this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
+ }
+ }
+
+ /**
+ * Prompt the user for permission to use the camera on their device.
+ *
+ * @name module:hardware.Camera#authorize
+ * @function
+ * @public
+ * @param {boolean} [showDialog=false] - whether to open a dialog box to inform the
+ * participant to wait for the camera to be initialised
+ * @param {string} [dialogMsg] - the dialog message
+ * @returns {boolean} whether or not the camera is ready to record
+ */
+ async authorize(showDialog = false, dialogMsg = undefined)
+ {
+ const response = {
+ origin: "Camera.authorize",
+ context: "when authorizing access to the device's camera"
+ };
+
+ // open pop-up dialog, if required:
+ if (showDialog)
+ {
+ dialogMsg ??= "Please wait a few moments while the camera initialises. You may need to grant permission to your browser to use the camera.";
+ this.psychoJS.gui.dialog({
+ warning: dialogMsg,
+ showOK: false,
+ });
+ }
+
+ try
+ {
+ // prompt for permission and get a MediaStream:
+ // TODO use size constraints [https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia]
+ this._stream = await navigator.mediaDevices.getUserMedia({
+ video: true
+ });
+ }
+ catch (error)
+ {
+ // close the dialog, if need be:
+ if (showDialog)
+ {
+ this.psychoJS.gui.closeDialog();
+ }
+
+ this._status = PsychoJS.Status.ERROR;
+ throw {...response, error};
+ }
+
+ // close the dialog, if need be:
+ if (showDialog)
+ {
+ this.psychoJS.gui.closeDialog();
+ }
+ }
+
+ /**
+ * Query whether the camera is ready to record.
+ *
+ * @name module:hardware.Camera#isReady
+ * @function
+ * @public
+ * @returns {boolean} true if the camera is ready to record, false otherwise
+ */
+ get isReady()
+ {
+ return (this._recorder !== null);
+ }
+
+ /**
+ * Get the underlying video stream.
+ *
+ * @name module:hardware.Camera#getStream
+ * @function
+ * @public
+ * @returns {MediaStream} the video stream
+ */
+ getStream()
+ {
+ return this._stream;
+ }
+
+ /**
+ * Get a video element pointing to the Camera stream.
+ *
+ * @name module:hardware.Camera#getVideo
+ * @function
+ * @public
+ * @returns {HTMLVideoElement} a video element
+ */
+ getVideo()
+ {
+ // note: we need to return a new video each time, since the camera feed can be used by
+ // several stimuli and one of them might pause the feed
+
+ // create a video with the appropriate size:
+ const video = document.createElement("video");
+ this._videos.push(video);
+
+ video.width = this._streamSettings.width;
+ video.height = this._streamSettings.height;
+ video.autoplay = true;
+
+ // prevent clicking:
+ video.onclick = (mouseEvent) =>
+ {
+ mouseEvent.preventDefault();
+ return false;
+ };
+
+ // use the camera stream as source for the video:
+ video.srcObject = this._stream;
+
+ return video;
+ }
+
+ /**
+ * Open the video stream.
+ *
+ * @name module:hardware.Camera#open
+ * @function
+ * @public
+ */
+ open()
+ {
+ if (this._stream === null)
+ {
+ throw {
+ origin: "Camera.open",
+ context: "when opening the camera's video stream",
+ error: "access to the camera has not been authorized, or no camera could be found"
+ };
+ }
+
+ // prepare the recording:
+ this._prepareRecording();
+ }
+
+ /**
+ * Submit a request to start the recording.
+ *
+ * @name module:hardware.Camera#record
+ * @function
+ * @public
+ * @return {Promise} promise fulfilled when the recording actually starts
+ */
+ record()
+ {
+ // if the camera is currently paused, a call to start resumes it
+ // with a new recording:
+ if (this._status === PsychoJS.Status.PAUSED)
+ {
+ return this.resume({clear: true});
+ }
+
+ if (this._status !== PsychoJS.Status.STARTED)
+ {
+ this._psychoJS.logger.debug("request to start video recording");
+
+ try
+ {
+ if (!this._recorder)
+ {
+ throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record video";
+ }
+
+ this._recorder.start();
+
+ // return a promise, which will be satisfied when the recording actually starts, which
+ // is also when the reset of the clock and the change of status takes place
+ const self = this;
+ return new Promise((resolve, reject) =>
+ {
+ self._startCallback = resolve;
+ self._errorCallback = reject;
+ });
+ }
+ catch (error)
+ {
+ this._psychoJS.logger.error("unable to start the video recording: " + JSON.stringify(error));
+ this._status = PsychoJS.Status.ERROR;
+
+ throw {
+ origin: "Camera.record",
+ context: "when starting the video recording for camera: " + this._name,
+ error
+ };
+ }
+
+ }
+
+ }
+
+ /**
+ * Submit a request to stop the recording.
+ *
+ * @name module:hardware.Camera#stop
+ * @function
+ * @public
+ * @param {Object} options
+ * @return {Promise} promise fulfilled when the recording actually stopped, and the recorded
+ * data was made available
+ */
+ stop()
+ {
+ if (this._status === PsychoJS.Status.STARTED || this._status === PsychoJS.Status.PAUSED)
+ {
+ this._psychoJS.logger.debug("request to stop video recording");
+
+ // stop the videos:
+ for (const video of this._videos)
+ {
+ video.pause();
+ }
+
+ // note: calling the MediaRecorder.stop will first raise a dataavailable event, and then a stop event
+ // ref: https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/stop
+ this._recorder.stop();
+
+ // return a promise, which will be satisfied when the recording actually stops and the data
+ // has been made available:
+ const self = this;
+ return new Promise((resolve, reject) =>
+ {
+ self._stopCallback = resolve;
+ self._errorCallback = reject;
+ });
+ }
+ }
+
+ /**
+ * Submit a request to pause the recording.
+ *
+ * @name module:hardware.Camera#pause
+ * @function
+ * @public
+ * @return {Promise} promise fulfilled when the recording actually paused
+ */
+ pause()
+ {
+ if (this._status === PsychoJS.Status.STARTED)
+ {
+ this._psychoJS.logger.debug("request to pause video recording");
+
+ try
+ {
+ if (!this._recorder)
+ {
+ throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record video";
+ }
+
+ // note: calling the pause method of the MediaRecorder raises a pause event
+ this._recorder.pause();
+
+ // return a promise, which will be satisfied when the recording actually pauses:
+ const self = this;
+ return new Promise((resolve, reject) =>
+ {
+ self._pauseCallback = resolve;
+ self._errorCallback = reject;
+ });
+ }
+ catch (error)
+ {
+ self._psychoJS.logger.error("unable to pause the video recording: " + JSON.stringify(error));
+ this._status = PsychoJS.Status.ERROR;
+
+ throw {
+ origin: "Camera.pause",
+ context: "when pausing the video recording for camera: " + this._name,
+ error
+ };
+ }
+
+ }
+ }
+
+ /**
+ * Submit a request to resume the recording.
+ *
+ * <p>resume has no effect if the recording was not previously paused.</p>
+ *
+ * @name module:hardware.Camera#resume
+ * @function
+ * @param {Object} options
+ * @param {boolean} [options.clear= false] whether or not to empty the video buffer before
+ * resuming the recording
+ * @return {Promise} promise fulfilled when the recording actually resumed
+ */
+ resume({clear = false } = {})
+ {
+ if (this._status === PsychoJS.Status.PAUSED)
+ {
+ this._psychoJS.logger.debug("request to resume video recording");
+
+ try
+ {
+ if (!this._recorder)
+ {
+ throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record video";
+ }
+
+ // empty the audio buffer is needed:
+ if (clear)
+ {
+ this._audioBuffer = [];
+ this._videoBuffer.length = 0;
+ }
+
+ this._recorder.resume();
+
+ // return a promise, which will be satisfied when the recording actually resumes:
+ const self = this;
+ return new Promise((resolve, reject) =>
+ {
+ self._resumeCallback = resolve;
+ self._errorCallback = reject;
+ });
+ }
+ catch (error)
+ {
+ self._psychoJS.logger.error("unable to resume the video recording: " + JSON.stringify(error));
+ this._status = PsychoJS.Status.ERROR;
+
+ throw {
+ origin: "Camera.resume",
+ context: "when resuming the video recording for camera: " + this._name,
+ error
+ };
+ }
+
+ }
+ }
+
+ /**
+ * Submit a request to flush the recording.
+ *
+ * @name module:hardware.Camera#flush
+ * @function
+ * @public
+ * @return {Promise} promise fulfilled when the data has actually been made available
+ */
+ flush()
+ {
+ if (this._status === PsychoJS.Status.STARTED || this._status === PsychoJS.Status.PAUSED)
+ {
+ this._psychoJS.logger.debug("request to flush video recording");
+
+ // note: calling the requestData method of the MediaRecorder will raise a
+ // dataavailable event
+ // ref: https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/requestData
+ this._recorder.requestData();
+
+ // return a promise, which will be satisfied when the data has been made available:
+ const self = this;
+ return new Promise((resolve, reject) =>
+ {
+ self._dataAvailableCallback = resolve;
+ self._errorCallback = reject;
+ });
+ }
+ }
+
+ /**
+ * Get the current video recording as a VideoClip in the given format.
+ *
+ * @name module:hardware.Camera#getRecording
+ * @function
+ * @public
+ * @param {string} tag an optional tag for the video clip
+ * @param {boolean} [flush=false] whether or not to first flush the recording
+ */
+ async getRecording({tag, flush = false} = {})
+ {
+ // default tag: the name of this Microphone object
+ if (typeof tag === "undefined")
+ {
+ tag = this._name;
+ }
+
+ // TODO
+ }
+
+ /**
+ * Upload the video recording to the pavlovia server.
+ *
+ * @name module:hardware.Camera#_upload
+ * @function
+ * @protected
+ * @param {string} tag an optional tag for the video file
+ * @param {boolean} [waitForCompletion= false] whether to wait for completion
+ * before returning
+ * @param {boolean} [showDialog=false] - whether to open a dialog box to inform the participant to wait for the data to be uploaded to the server
+ * @param {string} [dialogMsg=""] - default message informing the participant to wait for the data to be uploaded to the server
+ */
+ save({tag, waitForCompletion = false, showDialog = false, dialogMsg = ""} = {})
+ {
+ this._psychoJS.logger.info("[PsychoJS] Save video recording.");
+
+ // default tag: the name of this Camera object
+ if (typeof tag === "undefined")
+ {
+ tag = this._name;
+ }
+
+ // add a format-dependent video extension to the tag:
+ tag += util.extensionFromMimeType(this._format);
+
+ // if the video recording cannot be uploaded, e.g. the experiment is running locally, or
+ // if it is piloting mode, then we offer the video recording as a file for download:
+ if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER ||
+ this._psychoJS.config.experiment.status !== "RUNNING" ||
+ this._psychoJS._serverMsg.has("__pilotToken"))
+ {
+ const videoBlob = new Blob(this._videoBuffer);
+
+ const anchor = document.createElement("a");
+ anchor.href = window.URL.createObjectURL(videoBlob);
+ anchor.download = tag;
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+
+ return;
+ }
+
+ // upload the blob:
+ const videoBlob = new Blob(this._videoBuffer);
+ return this._psychoJS.serverManager.uploadAudioVideo({
+ mediaBlob: videoBlob,
+ tag,
+ waitForCompletion,
+ showDialog,
+ dialogMsg});
+ }
+
+ /**
+ * Close the camera stream.
+ *
+ * @name module:hardware.Camera#close
+ * @function
+ * @public
+ * @returns {Promise<void>} promise fulfilled when the stream has stopped and is now closed
+ */
+ async close()
+ {
+ await this.stop();
+
+ this._videos = [];
+ this._stream = null;
+ this._recorder = null;
+ }
+
+ /**
+ * Callback for changes to the recording settings.
+ *
+ * <p>Changes to the settings require the recording to stop and be re-started.</p>
+ *
+ * @name module:hardware.Camera#_onChange
+ * @function
+ * @protected
+ */
+ _onChange()
+ {
+ if (this._status === PsychoJS.Status.STARTED)
+ {
+ this.stop();
+ }
+
+ this._prepareRecording();
+
+ this.start();
+ }
+
+ /**
+ * Prepare the recording.
+ *
+ * @name module:hardware.Camera#_prepareRecording
+ * @function
+ * @protected
+ */
+ _prepareRecording()
+ {
+ // empty the video buffer:
+ this._videoBuffer = [];
+ this._recorder = null;
+ this._videos = [];
+
+ // check the actual width and height:
+ this._streamSettings = this._stream.getVideoTracks()[0].getSettings();
+ this._psychoJS.logger.debug(`camera stream settings: ${JSON.stringify(this._streamSettings)}`);
+
+ // check that the specified format is supported, use default if it is not:
+ let options;
+ if (typeof this._format === "string" && MediaRecorder.isTypeSupported(this._format))
+ {
+ options = { type: this._format };
+ }
+ else
+ {
+ this._psychoJS.logger.warn(`The specified video format, ${this._format}, is not supported by this browser, using the default format instead`);
+ }
+
+ // create a video recorder:
+ this._recorder = new MediaRecorder(this._stream, options);
+
+ // setup the callbacks:
+ const self = this;
+
+ // called upon Camera.start(), at which point the audio data starts being gathered
+ // into a blob:
+ this._recorder.onstart = () =>
+ {
+ self._videoBuffer = [];
+ self._videoBuffer.length = 0;
+ self._clock.reset();
+ self._status = PsychoJS.Status.STARTED;
+ self._psychoJS.logger.debug("video recording started");
+
+ // resolve the Camera.start promise:
+ if (self._startCallback)
+ {
+ self._startCallback(self._psychoJS.monotonicClock.getTime());
+ }
+ };
+
+ // called upon Camera.pause():
+ this._recorder.onpause = () =>
+ {
+ self._status = PsychoJS.Status.PAUSED;
+ self._psychoJS.logger.debug("video recording paused");
+
+ // resolve the Camera.pause promise:
+ if (self._pauseCallback)
+ {
+ self._pauseCallback(self._psychoJS.monotonicClock.getTime());
+ }
+ };
+
+ // called upon Camera.resume():
+ this._recorder.onresume = () =>
+ {
+ self._status = PsychoJS.Status.STARTED;
+ self._psychoJS.logger.debug("video recording resumed");
+
+ // resolve the Camera.resume promise:
+ if (self._resumeCallback)
+ {
+ self._resumeCallback(self._psychoJS.monotonicClock.getTime());
+ }
+ };
+
+ // called when video data is available, typically upon Camera.stop() or Camera.flush():
+ this._recorder.ondataavailable = (event) =>
+ {
+ const data = event.data;
+
+ // add data to the buffer:
+ self._videoBuffer.push(data);
+ self._psychoJS.logger.debug("video data added to the buffer");
+
+ // resolve the data available promise, if needed:
+ if (self._dataAvailableCallback)
+ {
+ self._dataAvailableCallback(self._psychoJS.monotonicClock.getTime());
+ }
+ };
+
+ // called upon Camera.stop(), after data has been made available:
+ this._recorder.onstop = () =>
+ {
+ self._psychoJS.logger.debug("video recording stopped");
+ self._status = PsychoJS.Status.STOPPED;
+
+ // resolve the Camera.stop promise:
+ if (self._stopCallback)
+ {
+ self._stopCallback(self._psychoJS.monotonicClock.getTime());
+ }
+ };
+
+ // called upon recording errors:
+ this._recorder.onerror = (event) =>
+ {
+ // TODO
+ self._psychoJS.logger.error("video recording error: " + JSON.stringify(event));
+ self._status = PsychoJS.Status.ERROR;
+ };
+ }
+
+}
+
+
+
PsychoJS is a JavaScript library that makes it possible to run neuroscience, psychology, and psychophysics experiments in a browser. It is the online counterpart of the PsychoPy Python library.
-
You can create online experiments from the PsychoPy Builder, you can find and adapt existing experiments on pavlovia.org, or create them from scratch: the PsychoJS API is available here.
+
You can create online experiments from the PsychoPy Builder, you can find and adapt existing experiments on pavlovia.org, or create them from scratch.
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.
+
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.
The idea behind PsychoJS is to make PsychoPy experiments available online, from a web page, so participants can run them on any device equipped with a web browser such as desktops, laptops, or tablets. In some circumstance, they can even use their phone!
Getting Started
Running PsychoPy experiments online requires the generation of an index.html file and of a javascript file that contains the code describing the experiment. Those files need to be hosted on a web server to which participants will point their browser in order to run the experiment. The server will also need to host the PsychoJS library.
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.
-
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 to present stimuli and collect responses. PixiJs is a multi-platform, accelerated, 2-D 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.
+
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 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 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
A convenient way to make experiment available to participants is to host them on pavlovia.org, an open-science server. PsychoPy Builder offers the possibility of uploading the experiment directly to pavlovia.org.
Which PsychoPy Components are supported by PsychoJS?