diff --git a/docs/AudioClip.html b/docs/AudioClip.html index 0ed740c..ab8a897 100644 --- a/docs/AudioClip.html +++ b/docs/AudioClip.html @@ -2117,7 +2117,7 @@ transcription confidence


diff --git a/docs/AudioClipPlayer.html b/docs/AudioClipPlayer.html index 6c8d83c..efe2159 100644 --- a/docs/AudioClipPlayer.html +++ b/docs/AudioClipPlayer.html @@ -1657,7 +1657,7 @@
diff --git a/docs/BuilderKeyResponse.html b/docs/BuilderKeyResponse.html index 84b980d..e6f633a 100644 --- a/docs/BuilderKeyResponse.html +++ b/docs/BuilderKeyResponse.html @@ -291,7 +291,7 @@
diff --git a/docs/ButtonStim.html b/docs/ButtonStim.html index c803113..8f5b513 100644 --- a/docs/ButtonStim.html +++ b/docs/ButtonStim.html @@ -1062,7 +1062,7 @@
Source:
@@ -1154,7 +1154,7 @@
Source:
@@ -1246,7 +1246,7 @@
Source:
@@ -1362,7 +1362,7 @@
Source:
@@ -1478,7 +1478,7 @@
Source:
@@ -1570,7 +1570,7 @@
Source:
@@ -1761,7 +1761,7 @@
Source:
@@ -2081,7 +2081,7 @@ - left + center @@ -2167,7 +2167,7 @@
Source:
@@ -2365,7 +2365,7 @@
Source:
@@ -2561,7 +2561,7 @@
Source:
@@ -2757,7 +2757,7 @@
Source:
@@ -2953,7 +2953,7 @@
Source:
@@ -3149,7 +3149,7 @@
Source:
@@ -3347,7 +3347,7 @@
Source:
@@ -3545,7 +3545,7 @@
Source:
@@ -3745,7 +3745,7 @@
Source:
@@ -3941,7 +3941,7 @@
Source:
@@ -4085,7 +4085,7 @@
diff --git a/docs/Clock.html b/docs/Clock.html index 2685592..fd73b74 100644 --- a/docs/Clock.html +++ b/docs/Clock.html @@ -737,7 +737,7 @@ smaller). As a consequence, getTime() may return a negative number.


diff --git a/docs/Color.html b/docs/Color.html index d111e6e..59c3370 100644 --- a/docs/Color.html +++ b/docs/Color.html @@ -6491,7 +6491,7 @@ static methods for converting colors from one space to another.


diff --git a/docs/CountdownTimer.html b/docs/CountdownTimer.html index 596e88c..c9ec6ef 100644 --- a/docs/CountdownTimer.html +++ b/docs/CountdownTimer.html @@ -809,7 +809,7 @@ to newTime


diff --git a/docs/EventEmitter.html b/docs/EventEmitter.html index c138eec..c7cca15 100644 --- a/docs/EventEmitter.html +++ b/docs/EventEmitter.html @@ -880,7 +880,7 @@ observable.emit("change", { a: 1 });
diff --git a/docs/EventManager.html b/docs/EventManager.html index d9ec311..216a0e1 100644 --- a/docs/EventManager.html +++ b/docs/EventManager.html @@ -2371,7 +2371,7 @@ Right, or between Enter and Numpad Enter. Use at your own risk (or upgrade your
diff --git a/docs/ExperimentHandler.html b/docs/ExperimentHandler.html index 798c243..0e77cd7 100644 --- a/docs/ExperimentHandler.html +++ b/docs/ExperimentHandler.html @@ -1995,7 +1995,7 @@ will be associated with the next trial.


diff --git a/docs/FaceDetector.html b/docs/FaceDetector.html index 19a3d73..415f795 100644 --- a/docs/FaceDetector.html +++ b/docs/FaceDetector.html @@ -1729,7 +1729,7 @@ movie resource or a HTMLVideoElement or a Camera component


diff --git a/docs/Form.html b/docs/Form.html index 8a19b90..08963fa 100644 --- a/docs/Form.html +++ b/docs/Form.html @@ -4654,7 +4654,7 @@ the bounding box


diff --git a/docs/GUI.html b/docs/GUI.html index aebf661..c1386d9 100644 --- a/docs/GUI.html +++ b/docs/GUI.html @@ -2402,7 +2402,7 @@ of session.


diff --git a/docs/GratingStim.html b/docs/GratingStim.html index 602e4e2..032ef7b 100644 --- a/docs/GratingStim.html +++ b/docs/GratingStim.html @@ -85,7 +85,7 @@
Source:
@@ -975,7 +975,7 @@
Source:
@@ -1062,7 +1062,7 @@
Source:
@@ -1172,7 +1172,7 @@ it contains.

Source:
@@ -1353,7 +1353,7 @@ it contains.

Source:
@@ -1440,7 +1440,7 @@ it contains.

Source:
@@ -1633,7 +1633,7 @@ it contains.

Source:
@@ -1826,7 +1826,7 @@ it contains.

Source:
@@ -2019,7 +2019,7 @@ it contains.

Source:
@@ -2212,7 +2212,7 @@ it contains.

Source:
@@ -2406,7 +2406,7 @@ it contains.

Source:
@@ -2601,7 +2601,7 @@ it contains.

Source:
@@ -2792,7 +2792,7 @@ it contains.

Source:
@@ -2983,7 +2983,7 @@ it contains.

Source:
@@ -3180,7 +3180,7 @@ it contains.


diff --git a/docs/ImageStim.html b/docs/ImageStim.html index e992fd4..43fe403 100644 --- a/docs/ImageStim.html +++ b/docs/ImageStim.html @@ -1884,7 +1884,7 @@ it contains.


diff --git a/docs/KeyPress.html b/docs/KeyPress.html index d6ee0b2..6f2ad69 100644 --- a/docs/KeyPress.html +++ b/docs/KeyPress.html @@ -286,7 +286,7 @@
diff --git a/docs/Keyboard.html b/docs/Keyboard.html index 3ea6f5d..37c873d 100644 --- a/docs/Keyboard.html +++ b/docs/Keyboard.html @@ -1778,7 +1778,7 @@ waitRelease = false, key presses without a corresponding key release will have a
diff --git a/docs/Logger.html b/docs/Logger.html index cd40f2f..3c1126c 100644 --- a/docs/Logger.html +++ b/docs/Logger.html @@ -2162,7 +2162,7 @@ See https://github.com/nodeca/pako for details.


diff --git a/docs/Microphone.html b/docs/Microphone.html index 6a807aa..9fa12a3 100644 --- a/docs/Microphone.html +++ b/docs/Microphone.html @@ -1931,7 +1931,7 @@ data was made available


diff --git a/docs/MinimalStim.html b/docs/MinimalStim.html index f4dd821..be8dc29 100644 --- a/docs/MinimalStim.html +++ b/docs/MinimalStim.html @@ -1198,7 +1198,7 @@
diff --git a/docs/MonotonicClock.html b/docs/MonotonicClock.html index 0ebd80e..51ae4ed 100644 --- a/docs/MonotonicClock.html +++ b/docs/MonotonicClock.html @@ -972,7 +972,7 @@
diff --git a/docs/Mouse.html b/docs/Mouse.html index 7c2923a..dcba2bc 100644 --- a/docs/Mouse.html +++ b/docs/Mouse.html @@ -1742,7 +1742,7 @@ call to getPos
diff --git a/docs/MovieStim.html b/docs/MovieStim.html index 3046a12..7fa0abc 100644 --- a/docs/MovieStim.html +++ b/docs/MovieStim.html @@ -2348,7 +2348,7 @@ movie resource or of a HTMLVideoElement or of a Camera component


diff --git a/docs/MultiStairHandler.html b/docs/MultiStairHandler.html index 137f7ad..7e3b581 100644 --- a/docs/MultiStairHandler.html +++ b/docs/MultiStairHandler.html @@ -1395,7 +1395,7 @@ we treat it as the name of a conditions resource


diff --git a/docs/Polygon.html b/docs/Polygon.html index fe2dfb5..8f6b4e4 100644 --- a/docs/Polygon.html +++ b/docs/Polygon.html @@ -2110,7 +2110,7 @@
diff --git a/docs/QuestHandler.html b/docs/QuestHandler.html index 453461e..5d843f7 100644 --- a/docs/QuestHandler.html +++ b/docs/QuestHandler.html @@ -2247,7 +2247,7 @@ and on the selected QUEST method.


diff --git a/docs/Rect.html b/docs/Rect.html index d803f78..b71fe10 100644 --- a/docs/Rect.html +++ b/docs/Rect.html @@ -2199,7 +2199,7 @@
diff --git a/docs/Scheduler.html b/docs/Scheduler.html index c7a90d7..6efc906 100644 --- a/docs/Scheduler.html +++ b/docs/Scheduler.html @@ -1463,7 +1463,7 @@ task would be by calling scheduler.add(subSch
diff --git a/docs/ServerManager.html b/docs/ServerManager.html index 7f35f0d..68ff35b 100644 --- a/docs/ServerManager.html +++ b/docs/ServerManager.html @@ -4656,7 +4656,7 @@ before returning


diff --git a/docs/ShapeStim.html b/docs/ShapeStim.html index c85946e..be13260 100644 --- a/docs/ShapeStim.html +++ b/docs/ShapeStim.html @@ -1765,7 +1765,7 @@
diff --git a/docs/Shelf.html b/docs/Shelf.html index 88566a8..43857c5 100644 --- a/docs/Shelf.html +++ b/docs/Shelf.html @@ -6999,7 +6999,7 @@ but it is locked or it is not of type LIST


diff --git a/docs/Slider.html b/docs/Slider.html index 781b55f..1cbf4cd 100644 --- a/docs/Slider.html +++ b/docs/Slider.html @@ -1217,7 +1217,7 @@ which must be updated when this Slider is updated, e.g. a Form.

Source:
@@ -1411,7 +1411,7 @@ which must be updated when this Slider is updated, e.g. a Form.

Source:
@@ -1582,7 +1582,7 @@ which must be updated when this Slider is updated, e.g. a Form.

Source:
@@ -1958,7 +1958,7 @@ which must be updated when this Slider is updated, e.g. a Form.

Source:
@@ -2362,7 +2362,7 @@ which must be updated when this Slider is updated, e.g. a Form.

Source:
@@ -2449,7 +2449,7 @@ which must be updated when this Slider is updated, e.g. a Form.

Source:
@@ -2694,7 +2694,7 @@ which must be updated when this Slider is updated, e.g. a Form.

Source:
@@ -2781,7 +2781,7 @@ which must be updated when this Slider is updated, e.g. a Form.

Source:
@@ -2868,7 +2868,7 @@ which must be updated when this Slider is updated, e.g. a Form.

Source:
@@ -3200,7 +3200,7 @@ the bounding box

Source:
@@ -3358,7 +3358,7 @@ the bounding box

Source:
@@ -3517,7 +3517,7 @@ with 0 at the center of the Slider)

Source:
@@ -3691,7 +3691,7 @@ with 0 at the center of the Slider)

Source:
@@ -3778,7 +3778,7 @@ with 0 at the center of the Slider)

Source:
@@ -3865,7 +3865,7 @@ with 0 at the center of the Slider)

Source:
@@ -3952,7 +3952,7 @@ with 0 at the center of the Slider)

Source:
@@ -4039,7 +4039,7 @@ with 0 at the center of the Slider)

Source:
@@ -4126,7 +4126,7 @@ with 0 at the center of the Slider)

Source:
@@ -7154,7 +7154,7 @@ but does not change the actual rating returned by the slider.


diff --git a/docs/SoundPlayer.html b/docs/SoundPlayer.html index 716df83..d39de67 100644 --- a/docs/SoundPlayer.html +++ b/docs/SoundPlayer.html @@ -1064,7 +1064,7 @@
diff --git a/docs/SpeechRecognition.html b/docs/SpeechRecognition.html index 8bd9a3a..41c3baf 100644 --- a/docs/SpeechRecognition.html +++ b/docs/SpeechRecognition.html @@ -1421,7 +1421,7 @@ previously cleared by calls to getTranscripts with clear = true.


diff --git a/docs/TextBox.html b/docs/TextBox.html index c3a61a6..f52597d 100644 --- a/docs/TextBox.html +++ b/docs/TextBox.html @@ -1279,7 +1279,7 @@
Source:
@@ -1342,7 +1342,7 @@
Source:
@@ -1415,7 +1415,7 @@
Source:
@@ -1502,7 +1502,7 @@
Source:
@@ -1589,7 +1589,7 @@
Source:
@@ -1700,7 +1700,7 @@
Source:
@@ -1811,7 +1811,7 @@
Source:
@@ -1898,7 +1898,7 @@
Source:
@@ -2079,7 +2079,7 @@
Source:
@@ -2384,7 +2384,7 @@ - left + center @@ -2470,7 +2470,7 @@
Source:
@@ -2663,7 +2663,7 @@
Source:
@@ -2854,7 +2854,7 @@
Source:
@@ -3045,7 +3045,7 @@
Source:
@@ -3236,7 +3236,7 @@
Source:
@@ -3427,7 +3427,7 @@
Source:
@@ -3620,7 +3620,7 @@
Source:
@@ -3813,7 +3813,7 @@
Source:
@@ -4008,7 +4008,7 @@
Source:
@@ -4199,7 +4199,7 @@
Source:
@@ -4338,7 +4338,7 @@
diff --git a/docs/TextStim.html b/docs/TextStim.html index b77c618..215fc66 100644 --- a/docs/TextStim.html +++ b/docs/TextStim.html @@ -2281,7 +2281,7 @@ to be instantiated, unlike getSize().


diff --git a/docs/TonePlayer.html b/docs/TonePlayer.html index 94dc9ce..6a14583 100644 --- a/docs/TonePlayer.html +++ b/docs/TonePlayer.html @@ -1642,7 +1642,7 @@ we throw an exception.


diff --git a/docs/TrackPlayer.html b/docs/TrackPlayer.html index 952f3d0..3088bf4 100644 --- a/docs/TrackPlayer.html +++ b/docs/TrackPlayer.html @@ -1667,7 +1667,7 @@ file


diff --git a/docs/Transcript.html b/docs/Transcript.html index b196e51..c97a1b6 100644 --- a/docs/Transcript.html +++ b/docs/Transcript.html @@ -308,7 +308,7 @@
diff --git a/docs/Window.html b/docs/Window.html index abbc7eb..3cedd7b 100644 --- a/docs/Window.html +++ b/docs/Window.html @@ -2312,7 +2312,7 @@ Window.


diff --git a/docs/core_EventManager.js.html b/docs/core_EventManager.js.html index 124bf9d..a176dfc 100644 --- a/docs/core_EventManager.js.html +++ b/docs/core_EventManager.js.html @@ -665,7 +665,7 @@ export class BuilderKeyResponse
diff --git a/docs/core_GUI.js.html b/docs/core_GUI.js.html index 6d863b1..98e5325 100644 --- a/docs/core_GUI.js.html +++ b/docs/core_GUI.js.html @@ -838,7 +838,7 @@ export class GUI
diff --git a/docs/core_Keyboard.js.html b/docs/core_Keyboard.js.html index 0b59d26..96d7d46 100644 --- a/docs/core_Keyboard.js.html +++ b/docs/core_Keyboard.js.html @@ -507,7 +507,7 @@ Keyboard.KeyStatus = {
diff --git a/docs/core_Logger.js.html b/docs/core_Logger.js.html index e4989fd..9256721 100644 --- a/docs/core_Logger.js.html +++ b/docs/core_Logger.js.html @@ -470,7 +470,7 @@ Logger._ServerLevelValue = {
diff --git a/docs/core_MinimalStim.js.html b/docs/core_MinimalStim.js.html index cd401da..6544b11 100644 --- a/docs/core_MinimalStim.js.html +++ b/docs/core_MinimalStim.js.html @@ -253,7 +253,7 @@ export class MinimalStim extends PsychObject
diff --git a/docs/core_Mouse.js.html b/docs/core_Mouse.js.html index b5a8362..3b9cabd 100644 --- a/docs/core_Mouse.js.html +++ b/docs/core_Mouse.js.html @@ -388,7 +388,7 @@ export class Mouse extends PsychObject
diff --git a/docs/core_PsychoJS.js.html b/docs/core_PsychoJS.js.html index de12cdf..413033c 100644 --- a/docs/core_PsychoJS.js.html +++ b/docs/core_PsychoJS.js.html @@ -842,7 +842,7 @@ PsychoJS.Status = {
diff --git a/docs/core_ServerManager.js.html b/docs/core_ServerManager.js.html index 916654e..6681b71 100644 --- a/docs/core_ServerManager.js.html +++ b/docs/core_ServerManager.js.html @@ -1521,7 +1521,7 @@ ServerManager.ResourceStatus = {
diff --git a/docs/core_Window.js.html b/docs/core_Window.js.html index 267b4a4..b3c6b26 100644 --- a/docs/core_Window.js.html +++ b/docs/core_Window.js.html @@ -591,7 +591,7 @@ export class Window extends PsychObject
diff --git a/docs/core_WindowMixin.js.html b/docs/core_WindowMixin.js.html index 047687f..5ac14b9 100644 --- a/docs/core_WindowMixin.js.html +++ b/docs/core_WindowMixin.js.html @@ -252,7 +252,7 @@ export let WindowMixin = (superclass) =>
diff --git a/docs/data_ExperimentHandler.js.html b/docs/data_ExperimentHandler.js.html index 6a59258..0ed0b69 100644 --- a/docs/data_ExperimentHandler.js.html +++ b/docs/data_ExperimentHandler.js.html @@ -505,7 +505,7 @@ ExperimentHandler.Environment = {
diff --git a/docs/data_MultiStairHandler.js.html b/docs/data_MultiStairHandler.js.html index f77f3b8..9fbad0e 100644 --- a/docs/data_MultiStairHandler.js.html +++ b/docs/data_MultiStairHandler.js.html @@ -499,7 +499,7 @@ MultiStairHandler.StaircaseStatus = {
diff --git a/docs/data_QuestHandler.js.html b/docs/data_QuestHandler.js.html index 56a736f..16e58a3 100644 --- a/docs/data_QuestHandler.js.html +++ b/docs/data_QuestHandler.js.html @@ -433,7 +433,7 @@ QuestHandler.Method = {
diff --git a/docs/data_Shelf.js.html b/docs/data_Shelf.js.html index 0d58bac..b032519 100644 --- a/docs/data_Shelf.js.html +++ b/docs/data_Shelf.js.html @@ -896,7 +896,7 @@ Shelf.Type = {
diff --git a/docs/data_TrialHandler.js.html b/docs/data_TrialHandler.js.html index df179b4..3695887 100644 --- a/docs/data_TrialHandler.js.html +++ b/docs/data_TrialHandler.js.html @@ -803,7 +803,7 @@ TrialHandler.Method = {
diff --git a/docs/hardware_Camera.js.html b/docs/hardware_Camera.js.html index 917b8f8..cd01b60 100644 --- a/docs/hardware_Camera.js.html +++ b/docs/hardware_Camera.js.html @@ -709,7 +709,7 @@ export class Camera extends PsychObject
diff --git a/docs/index.html b/docs/index.html index 601a1ba..0ffe5df 100644 --- a/docs/index.html +++ b/docs/index.html @@ -126,7 +126,7 @@ It is now a collaborative effort, supported by the diff --git a/docs/module-core.PsychoJS.html b/docs/module-core.PsychoJS.html index 23613cc..db16989 100644 --- a/docs/module-core.PsychoJS.html +++ b/docs/module-core.PsychoJS.html @@ -2981,7 +2981,7 @@ considered.


diff --git a/docs/module-core.WindowMixin.html b/docs/module-core.WindowMixin.html index 66f3471..208483e 100644 --- a/docs/module-core.WindowMixin.html +++ b/docs/module-core.WindowMixin.html @@ -854,7 +854,7 @@
diff --git a/docs/module-core.html b/docs/module-core.html index 8c5e019..7101249 100644 --- a/docs/module-core.html +++ b/docs/module-core.html @@ -141,7 +141,7 @@
diff --git a/docs/module-data.TrialHandler.html b/docs/module-data.TrialHandler.html index 873316f..eca4204 100644 --- a/docs/module-data.TrialHandler.html +++ b/docs/module-data.TrialHandler.html @@ -3383,7 +3383,7 @@ for (const thisTrial of handler) { console.log(thisTrial); }

diff --git a/docs/module-data.html b/docs/module-data.html index fe7dae1..f1515a5 100644 --- a/docs/module-data.html +++ b/docs/module-data.html @@ -497,7 +497,7 @@
diff --git a/docs/module-hardware.Camera.html b/docs/module-hardware.Camera.html index df6db87..ad8d4c8 100644 --- a/docs/module-hardware.Camera.html +++ b/docs/module-hardware.Camera.html @@ -2516,7 +2516,7 @@ data was made available


diff --git a/docs/module-sound.Sound.html b/docs/module-sound.Sound.html index 7d14cba..336658c 100644 --- a/docs/module-sound.Sound.html +++ b/docs/module-sound.Sound.html @@ -2180,7 +2180,7 @@ Repeat calls to play may results in the sounds being played on top of each other
diff --git a/docs/module-sound.html b/docs/module-sound.html index 458c490..449daf5 100644 --- a/docs/module-sound.html +++ b/docs/module-sound.html @@ -132,7 +132,7 @@
diff --git a/docs/module-util.ColorMixin.html b/docs/module-util.ColorMixin.html index 0c9fd81..bc34d76 100644 --- a/docs/module-util.ColorMixin.html +++ b/docs/module-util.ColorMixin.html @@ -703,7 +703,7 @@
diff --git a/docs/module-util.PsychObject.html b/docs/module-util.PsychObject.html index 7254e49..adbe04d 100644 --- a/docs/module-util.PsychObject.html +++ b/docs/module-util.PsychObject.html @@ -1782,7 +1782,7 @@ was not previously set)


diff --git a/docs/module-util.html b/docs/module-util.html index fa15676..9274f04 100644 --- a/docs/module-util.html +++ b/docs/module-util.html @@ -14778,7 +14778,7 @@ missing the naught prefix, and is able to process several arrays, e.g. "[1,
diff --git a/docs/module-visual.VisualStim.html b/docs/module-visual.VisualStim.html index 0aeed49..11a4137 100644 --- a/docs/module-visual.VisualStim.html +++ b/docs/module-visual.VisualStim.html @@ -2807,7 +2807,7 @@ the bounding box


diff --git a/docs/module-visual.html b/docs/module-visual.html index 7d5ff05..d5c24eb 100644 --- a/docs/module-visual.html +++ b/docs/module-visual.html @@ -143,7 +143,7 @@
diff --git a/docs/sound_AudioClip.js.html b/docs/sound_AudioClip.js.html index 3e33b85..22eb543 100644 --- a/docs/sound_AudioClip.js.html +++ b/docs/sound_AudioClip.js.html @@ -530,7 +530,7 @@ AudioClip.Status = {
diff --git a/docs/sound_AudioClipPlayer.js.html b/docs/sound_AudioClipPlayer.js.html index 3d6ea7b..d60ed02 100644 --- a/docs/sound_AudioClipPlayer.js.html +++ b/docs/sound_AudioClipPlayer.js.html @@ -227,7 +227,7 @@ export class AudioClipPlayer extends SoundPlayer
diff --git a/docs/sound_Microphone.js.html b/docs/sound_Microphone.js.html index 3e148a8..74ef762 100644 --- a/docs/sound_Microphone.js.html +++ b/docs/sound_Microphone.js.html @@ -553,7 +553,7 @@ export class Microphone extends PsychObject
diff --git a/docs/sound_Sound.js.html b/docs/sound_Sound.js.html index 6d9e89a..a50d0ae 100644 --- a/docs/sound_Sound.js.html +++ b/docs/sound_Sound.js.html @@ -303,7 +303,7 @@ export class Sound extends PsychObject
diff --git a/docs/sound_SoundPlayer.js.html b/docs/sound_SoundPlayer.js.html index f8754f5..c7137e0 100644 --- a/docs/sound_SoundPlayer.js.html +++ b/docs/sound_SoundPlayer.js.html @@ -196,7 +196,7 @@ export class SoundPlayer extends PsychObject
diff --git a/docs/sound_SpeechRecognition.js.html b/docs/sound_SpeechRecognition.js.html index 1e1d99c..a5f4d74 100644 --- a/docs/sound_SpeechRecognition.js.html +++ b/docs/sound_SpeechRecognition.js.html @@ -445,7 +445,7 @@ export class SpeechRecognition extends PsychObject
diff --git a/docs/sound_TonePlayer.js.html b/docs/sound_TonePlayer.js.html index 7b6b738..6efc911 100644 --- a/docs/sound_TonePlayer.js.html +++ b/docs/sound_TonePlayer.js.html @@ -418,7 +418,7 @@ TonePlayer.SoundLibrary = {
diff --git a/docs/sound_TrackPlayer.js.html b/docs/sound_TrackPlayer.js.html index 078fe3a..ccf9deb 100644 --- a/docs/sound_TrackPlayer.js.html +++ b/docs/sound_TrackPlayer.js.html @@ -261,7 +261,7 @@ export class TrackPlayer extends SoundPlayer
diff --git a/docs/styles/jsdoc.css b/docs/styles/jsdoc.css index ff0d5e5..eb45265 100644 --- a/docs/styles/jsdoc.css +++ b/docs/styles/jsdoc.css @@ -64,24 +64,24 @@ h1 { } h1.page-title { - font-size: 30px; + font-size: 48px; margin: 1em 30px; line-height: 100%; word-wrap: break-word; } h2 { - font-size: 20px; + font-size: 24px; margin: 1.5em 0 .3em; } h3 { - font-size: 20px; + font-size: 24px; margin: 1.2em 0 .3em; } h4 { - font-size: 16px; + font-size: 18px; margin: 1em 0 .2em; color: #4d4e53; } @@ -169,8 +169,8 @@ tt, code, kbd, samp { } .class-description { - /*font-size: 130%; - line-height: 140%;*/ + font-size: 130%; + line-height: 140%; margin-bottom: 1em; margin-top: 1em; } @@ -257,7 +257,7 @@ nav ul a:active { line-height: 18px; padding: 0; display: block; - font-size: 14px; + font-size: 12px; } nav a:hover, @@ -762,4 +762,4 @@ html[data-search-mode] .level-hide { font-weight: 300; font-style: normal; -} +} \ No newline at end of file diff --git a/docs/util_Clock.js.html b/docs/util_Clock.js.html index fb9aac7..32b518c 100644 --- a/docs/util_Clock.js.html +++ b/docs/util_Clock.js.html @@ -280,7 +280,7 @@ export class CountdownTimer extends Clock
diff --git a/docs/util_Color.js.html b/docs/util_Color.js.html index 4f34f32..cd2e50c 100644 --- a/docs/util_Color.js.html +++ b/docs/util_Color.js.html @@ -706,7 +706,7 @@ Color.NAMED_COLORS = {
diff --git a/docs/util_ColorMixin.js.html b/docs/util_ColorMixin.js.html index 1258bb5..c2dbac3 100644 --- a/docs/util_ColorMixin.js.html +++ b/docs/util_ColorMixin.js.html @@ -135,7 +135,7 @@ export let ColorMixin = (superclass) =>
diff --git a/docs/util_EventEmitter.js.html b/docs/util_EventEmitter.js.html index 861298c..cb64774 100644 --- a/docs/util_EventEmitter.js.html +++ b/docs/util_EventEmitter.js.html @@ -200,7 +200,7 @@ export class EventEmitter
diff --git a/docs/util_Pixi.js.html b/docs/util_Pixi.js.html index f990815..89af443 100644 --- a/docs/util_Pixi.js.html +++ b/docs/util_Pixi.js.html @@ -99,7 +99,7 @@ export function to_pixiPoint(pos, posUnit, win, integerCoordinates = false)
diff --git a/docs/util_PsychObject.js.html b/docs/util_PsychObject.js.html index 8ada66f..9820216 100644 --- a/docs/util_PsychObject.js.html +++ b/docs/util_PsychObject.js.html @@ -462,7 +462,7 @@ export class PsychObject extends EventEmitter
diff --git a/docs/util_Scheduler.js.html b/docs/util_Scheduler.js.html index fad2435..d90dd1b 100644 --- a/docs/util_Scheduler.js.html +++ b/docs/util_Scheduler.js.html @@ -354,7 +354,7 @@ Scheduler.Status = {
diff --git a/docs/util_Util.js.html b/docs/util_Util.js.html index 0accf08..8331df3 100644 --- a/docs/util_Util.js.html +++ b/docs/util_Util.js.html @@ -1478,7 +1478,7 @@ export const TEXT_DIRECTION = {
diff --git a/docs/visual_ButtonStim.js.html b/docs/visual_ButtonStim.js.html index 73e7e7f..4ed56fd 100644 --- a/docs/visual_ButtonStim.js.html +++ b/docs/visual_ButtonStim.js.html @@ -202,7 +202,7 @@ export class ButtonStim extends TextBox
diff --git a/docs/visual_FaceDetector.js.html b/docs/visual_FaceDetector.js.html index 36612ac..e0815c9 100644 --- a/docs/visual_FaceDetector.js.html +++ b/docs/visual_FaceDetector.js.html @@ -363,7 +363,7 @@ export class FaceDetector extends VisualStim
diff --git a/docs/visual_Form.js.html b/docs/visual_Form.js.html index 7372f63..e72e28c 100644 --- a/docs/visual_Form.js.html +++ b/docs/visual_Form.js.html @@ -1223,7 +1223,7 @@ Form._defaultItems = {
diff --git a/docs/visual_GratingStim.js.html b/docs/visual_GratingStim.js.html index b8ae42c..f0d6c7f 100644 --- a/docs/visual_GratingStim.js.html +++ b/docs/visual_GratingStim.js.html @@ -78,6 +78,7 @@ import gaussShader from "./shaders/gaussShader.frag"; import crossShader from "./shaders/crossShader.frag"; import radRampShader from "./shaders/radRampShader.frag"; import raisedCosShader from "./shaders/raisedCosShader.frag"; +import radialStim from "./shaders/radialShader.frag"; /** * Grating Stimulus. @@ -292,6 +293,15 @@ export class GratingStim extends VisualStim uColor: [1., 1., 1.], uAlpha: 1.0 } + }, + radialStim: { + shader: radialStim, + uniforms: { + uFreq: 20.0, + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } } }; @@ -769,7 +779,8 @@ export class GratingStim extends VisualStim const maskMesh = this._getPixiMeshFromPredefinedShaders(this._mask); const rt = PIXI.RenderTexture.create({ width: this._size_px[0], - height: this._size_px[1] + height: this._size_px[1], + scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST }); this.win._renderer.render(maskMesh, { renderTexture: rt @@ -824,7 +835,7 @@ export class GratingStim extends VisualStim
diff --git a/docs/visual_ImageStim.js.html b/docs/visual_ImageStim.js.html index 7d1f306..1359b6b 100644 --- a/docs/visual_ImageStim.js.html +++ b/docs/visual_ImageStim.js.html @@ -453,7 +453,7 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
diff --git a/docs/visual_MovieStim.js.html b/docs/visual_MovieStim.js.html index c2399eb..18cd734 100644 --- a/docs/visual_MovieStim.js.html +++ b/docs/visual_MovieStim.js.html @@ -500,7 +500,7 @@ export class MovieStim extends VisualStim
diff --git a/docs/visual_Polygon.js.html b/docs/visual_Polygon.js.html index 1c2b350..68c8872 100644 --- a/docs/visual_Polygon.js.html +++ b/docs/visual_Polygon.js.html @@ -199,7 +199,7 @@ export class Polygon extends ShapeStim
diff --git a/docs/visual_Rect.js.html b/docs/visual_Rect.js.html index 98e5070..b682c7f 100644 --- a/docs/visual_Rect.js.html +++ b/docs/visual_Rect.js.html @@ -201,7 +201,7 @@ export class Rect extends ShapeStim
diff --git a/docs/visual_ShapeStim.js.html b/docs/visual_ShapeStim.js.html index ddab829..1071ab4 100644 --- a/docs/visual_ShapeStim.js.html +++ b/docs/visual_ShapeStim.js.html @@ -431,7 +431,7 @@ ShapeStim.KnownShapes = {
diff --git a/docs/visual_Slider.js.html b/docs/visual_Slider.js.html index 203bae6..a739124 100644 --- a/docs/visual_Slider.js.html +++ b/docs/visual_Slider.js.html @@ -870,15 +870,17 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin) * @protected */ _handlePointerDown (e) { - if (e.data.button === 0) + if (e.data.pointerType === "mouse" && e.data.button !== 0) { - this._markerDragging = true; - if (!this._frozenMarker) - { - const mouseLocalPos_px = e.data.getLocalPosition(this._pixi); - const rating = this._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]); - this.setMarkerPos(rating); - } + return; + } + + this._markerDragging = true; + if (!this._frozenMarker) + { + const mouseLocalPos_px = e.data.getLocalPosition(this._pixi); + const rating = this._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]); + this.setMarkerPos(rating); } e.stopPropagation(); @@ -1366,6 +1368,7 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin) { this._barLineWidth_px = 0; this._tickType = Slider.Shape.DISC; + this.granularity = 1.0; if (!this._skin.MARKER_SIZE) { @@ -1579,7 +1582,7 @@ Slider.Skin = {
diff --git a/docs/visual_TextBox.js.html b/docs/visual_TextBox.js.html index db2abfa..904f279 100644 --- a/docs/visual_TextBox.js.html +++ b/docs/visual_TextBox.js.html @@ -198,7 +198,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this._addAttribute( "alignment", alignment, - "left" + "center" ); this._addAttribute( "languageStyle", @@ -289,11 +289,16 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) * @param {boolean} alignment - alignment of the text * @param {boolean} [log= false] - whether or not to log */ - setAlignment(alignment = "left", log = false) + setAlignment(alignment = "center", log = false) { this._setAttribute("alignment", alignment, log); if (this._pixi !== undefined) { - this._pixi.setInputStyle("textAlign", alignment); + let alignmentStyles = TextBox._alignmentToFlexboxMap.get(alignment); + if (!alignmentStyles) { + alignmentStyles = ["center", "center"]; + } + this._pixi.setInputStyle("justifyContent", alignmentStyles[0]); + this._pixi.setInputStyle("textAlign", alignmentStyles[1]); } } @@ -543,18 +548,24 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) const borderWidth_px = Math.round(this._getLengthPix(this._borderWidth)); const width_px = Math.abs(Math.round(this._getLengthPix(this._size[0]))); const height_px = Math.abs(Math.round(this._getLengthPix(this._size[1]))); + let alignmentStyles = TextBox._alignmentToFlexboxMap.get(this._alignment); + if (!alignmentStyles) { + alignmentStyles = ["center", "center"]; + } return { // input style properties eventually become CSS, so same syntax applies input: { - display: "inline-block", + display: "flex", + flexDirection: "column", fontFamily: this._font, fontSize: `${letterHeight_px}px`, color: this._color === undefined || this._color === null ? 'transparent' : new Color(this._color).hex, fontWeight: (this._bold) ? "bold" : "normal", fontStyle: (this._italic) ? "italic" : "normal", direction: util.TEXT_DIRECTION[this._languageStyle], - textAlign: this._alignment, + justifyContent: alignmentStyles[0], + textAlign: alignmentStyles[1], padding: `${padding_px}px`, multiline: this._multiline, text: this._text, @@ -738,6 +749,18 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) } } +TextBox._alignmentToFlexboxMap = new Map([ + ["center", ["center", "center"]], + ["top-center", ["flex-start", "center"]], + ["bottom-center", ["flex-end", "center"]], + ["center-left", ["center", "left"]], + ["center-right", ["center", "right"]], + ["top-left", ["flex-start", "left"]], + ["top-right", ["flex-start", "right"]], + ["bottom-left", ["flex-end", "left"]], + ["bottom-right", ["flex-end", "right"]] +]); + /** * <p>This map associates units to default letter height.</p> * @@ -787,7 +810,7 @@ TextBox._defaultSizeMap = new Map([
diff --git a/docs/visual_TextStim.js.html b/docs/visual_TextStim.js.html index 0d38a83..4007ef4 100644 --- a/docs/visual_TextStim.js.html +++ b/docs/visual_TextStim.js.html @@ -577,7 +577,7 @@ TextStim._defaultWrapWidthMap = new Map([
diff --git a/docs/visual_VisualStim.js.html b/docs/visual_VisualStim.js.html index fc46e05..e8be617 100644 --- a/docs/visual_VisualStim.js.html +++ b/docs/visual_VisualStim.js.html @@ -356,7 +356,7 @@ export class VisualStim extends util.mix(MinimalStim).with(WindowMixin)
diff --git a/package-lock.json b/package-lock.json index cb6071b..76977f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psychojs", - "version": "2022.2.0", + "version": "2023.2.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "psychojs", - "version": "2022.2.0", + "version": "2023.2.1", "license": "MIT", "dependencies": { "@pixi/filter-adjustment": "^4.1.3", @@ -16,10 +16,11 @@ "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", + "pixi-filters": "^5.0.0", "pixi.js-legacy": "^6.0.4", "seedrandom": "^3.0.5", "tone": "^14.7.77", - "xlsx": "^0.17.0" + "xlsx": "^0.18.5" }, "devDependencies": { "csslint": "^1.0.5", @@ -288,6 +289,15 @@ "@pixi/text": "6.0.4" } }, + "node_modules/@pixi/color": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.2.4.tgz", + "integrity": "sha512-B/+9JRcXe2uE8wQfsueFRPZVayF2VEMRB7XGeRAsWCryOX19nmWhv0Nt3nOU2rvzI0niz9XgugJXsB6vVmDFSg==", + "peer": true, + "dependencies": { + "colord": "^2.9.3" + } + }, "node_modules/@pixi/compressed-textures": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/compressed-textures/-/compressed-textures-6.0.4.tgz", @@ -331,6 +341,12 @@ "@pixi/utils": "6.0.4" } }, + "node_modules/@pixi/extensions": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.2.4.tgz", + "integrity": "sha512-Mnqv9scbL1ARD3QFKfOWs2aSVJJfP1dL8g5UiqGImYO3rZbz/9QCzXOeMVIZ5n3iaRyKMNhFFr84/zUja2H7Dw==", + "peer": true + }, "node_modules/@pixi/extract": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/extract/-/extract-6.0.4.tgz", @@ -639,11 +655,23 @@ "url": "^0.11.0" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "peer": true + }, "node_modules/@types/earcut": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.1.tgz", "integrity": "sha512-w8oigUCDjElRHRRrMvn/spybSMyX8MTkKA5Dv+tS1IE/TgmNZPqUYtvYBXGY8cieSE66gm+szeK+bnbxC2xHTQ==" }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.0.tgz", + "integrity": "sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg==", + "peer": true + }, "node_modules/a11y-dialog": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/a11y-dialog/-/a11y-dialog-7.5.0.tgz", @@ -674,16 +702,9 @@ } }, "node_modules/adler-32": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz", - "integrity": "sha1-aj5r8KY5ALoVZSgIyxXGgT0aXyU=", - "dependencies": { - "exit-on-epipe": "~1.0.1", - "printj": "~1.1.0" - }, - "bin": { - "adler32": "bin/adler32.njs" - }, + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", "engines": { "node": ">=0.8" } @@ -799,13 +820,12 @@ } }, "node_modules/cfb": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.0.tgz", - "integrity": "sha512-sXMvHsKCICVR3Naq+J556K+ExBo9n50iKl6LGarlnvuA2035uMlGA/qVrc0wQtow5P1vJEw9UyrKLCbtIKz+TQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", "dependencies": { - "adler-32": "~1.2.0", - "crc-32": "~1.2.0", - "printj": "~1.1.2" + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" }, "engines": { "node": ">=0.8" @@ -835,25 +855,13 @@ } }, "node_modules/codepage": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz", - "integrity": "sha1-jL4lSBMjVZ19MHVxsP/5HnodL5k=", - "dependencies": { - "commander": "~2.14.1", - "exit-on-epipe": "~1.0.1" - }, - "bin": { - "codepage": "bin/codepage.njs" - }, + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", "engines": { "node": ">=0.8" } }, - "node_modules/codepage/node_modules/commander": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz", - "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==" - }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -869,6 +877,12 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "peer": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -876,13 +890,9 @@ "dev": true }, "node_modules/crc-32": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz", - "integrity": "sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA==", - "dependencies": { - "exit-on-epipe": "~1.0.1", - "printj": "~1.1.0" - }, + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "bin": { "crc32": "bin/crc32.njs" }, @@ -957,9 +967,9 @@ } }, "node_modules/earcut": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.2.tgz", - "integrity": "sha512-eZoZPPJcUHnfRZ0PjLvx2qBordSiO8ofC3vt+qACLM95u+4DovnbYNpQtJh0DNsWj8RnxrQytD4WA8gj5cRIaQ==" + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -1419,14 +1429,6 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" }, - "node_modules/exit-on-epipe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", - "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==", - "engines": { - "node": ">=0.8" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1445,11 +1447,6 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, - "node_modules/fflate": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", - "integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==" - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -1954,6 +1951,409 @@ "node": ">=8" } }, + "node_modules/pixi-filters": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-5.0.0.tgz", + "integrity": "sha512-j90nvbiRpDozxalSUaQ2kTIyFNGAUKxJ2qhPs4ThmVLiR9lam5x+GpP+c1Yx5N+qc+u0tH5G3VRY1usB69atrw==", + "dependencies": { + "@pixi/filter-adjustment": "5.0.0", + "@pixi/filter-advanced-bloom": "5.0.0", + "@pixi/filter-ascii": "5.0.0", + "@pixi/filter-bevel": "5.0.0", + "@pixi/filter-bloom": "5.0.0", + "@pixi/filter-bulge-pinch": "5.0.0", + "@pixi/filter-color-map": "5.0.0", + "@pixi/filter-color-overlay": "5.0.0", + "@pixi/filter-color-replace": "5.0.0", + "@pixi/filter-convolution": "5.0.0", + "@pixi/filter-cross-hatch": "5.0.0", + "@pixi/filter-crt": "5.0.0", + "@pixi/filter-dot": "5.0.0", + "@pixi/filter-drop-shadow": "5.0.0", + "@pixi/filter-emboss": "5.0.0", + "@pixi/filter-glitch": "5.0.0", + "@pixi/filter-glow": "5.0.0", + "@pixi/filter-godray": "5.0.0", + "@pixi/filter-kawase-blur": "5.0.0", + "@pixi/filter-motion-blur": "5.0.0", + "@pixi/filter-multi-color-replace": "5.0.0", + "@pixi/filter-old-film": "5.0.0", + "@pixi/filter-outline": "5.0.0", + "@pixi/filter-pixelate": "5.0.0", + "@pixi/filter-radial-blur": "5.0.0", + "@pixi/filter-reflection": "5.0.0", + "@pixi/filter-rgb-split": "5.0.0", + "@pixi/filter-shockwave": "5.0.0", + "@pixi/filter-simple-lightmap": "5.0.0", + "@pixi/filter-tilt-shift": "5.0.0", + "@pixi/filter-twist": "5.0.0", + "@pixi/filter-zoom-blur": "5.0.0" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/constants": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.2.4.tgz", + "integrity": "sha512-hKuHBWR6N4Q0Sf5MGF3/9l+POg/G5rqhueHfzofiuelnKg7aBs3BVjjZ+6hZbd6M++vOUmxYelEX/NEFBxrheA==", + "peer": true + }, + "node_modules/pixi-filters/node_modules/@pixi/core": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/core/-/core-7.2.4.tgz", + "integrity": "sha512-0XtvrfxHlS2T+beBBSpo7GI8+QLyyTqMVQpNmPqB4woYxzrOEJ9JaUFBaBfCvycLeUkfVih1u6HAbtF+2d1EjQ==", + "peer": true, + "dependencies": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/extensions": "7.2.4", + "@pixi/math": "7.2.4", + "@pixi/runner": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/ticker": "7.2.4", + "@pixi/utils": "7.2.4", + "@types/offscreencanvas": "^2019.6.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-adjustment": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-5.0.0.tgz", + "integrity": "sha512-Epci8zSWCNWhFtnarvQqOcnmOqLfhXIJ7NNENEi2E1rom1Ar13RLM76CBGBbuDRK7flweqcWmZb0QZLxqwxTDg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-advanced-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-advanced-bloom/-/filter-advanced-bloom-5.0.0.tgz", + "integrity": "sha512-P5Xt65GLBEqjZVUkLe4ZZk4D1/j9UEXYnYFG3JrLPYkdcniwD4Y+NIyNCJ+eP91ivgoCmK/+SyBRv0P0AEQkTw==", + "dependencies": { + "@pixi/filter-kawase-blur": "5.0.0" + }, + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-alpha": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-7.2.4.tgz", + "integrity": "sha512-UTUMSGyktUr+I9vmigqJo9iUhb0nwGyqTTME2xBWZvVGCnl5z+/wHxvIBBCe5pNZ66IM15pGXQ4cDcfqCuP2kA==", + "peer": true, + "peerDependencies": { + "@pixi/core": "7.2.4" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-ascii": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-ascii/-/filter-ascii-5.0.0.tgz", + "integrity": "sha512-A49yNhiye/aFDOnI11zwEm/td2xho0td/Cvzvru8FUgi1MzJvZE03W/JoLl04ToZczw143wFPxutl6V/Ohw5bQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-bevel": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bevel/-/filter-bevel-5.0.0.tgz", + "integrity": "sha512-0Odat0tW/uoS/uyp0rigm07Q3YPgwKLTgkZZZSzIUVsPnwcJjiocSzWel73JkiY3m2ZjTrj+JZjkyGjkYH+2gQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bloom/-/filter-bloom-5.0.0.tgz", + "integrity": "sha512-vOSNJNV5y+ifwQWfzEmml3owcgoJAQIQtMR17SELBUwfYP60qxy5bNWBdYBlipSJVwX2AuGi8Xk5Ia9dijcqZQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X", + "@pixi/filter-alpha": "^7.0.0-X", + "@pixi/filter-blur": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-blur": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-blur/-/filter-blur-7.2.4.tgz", + "integrity": "sha512-aLyXIoxy14bTansCPtbY8x7Sdn2OrrqkF/pcKiRXHJGGhi7wPacvB/NcmYJdnI/n2ExQ6V5Njuj/nfrsejVwcA==", + "peer": true, + "peerDependencies": { + "@pixi/core": "7.2.4" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-bulge-pinch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bulge-pinch/-/filter-bulge-pinch-5.0.0.tgz", + "integrity": "sha512-j1feWsCpyTZk4aHbYNjax52lt0OtyYDbHvYaePYzGO/SBb1t/spDnHQEkAP7R3bZ7Ud/GI4RgefAFnvsYeSetQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-color-map": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-map/-/filter-color-map-5.0.0.tgz", + "integrity": "sha512-w77mRi89sLUMwjhl7qL/q1YrhEKyOk2MJZQdKBksvGEV/Mf5mV2h3+EOC62wB18Q4iUVQy1MS4sANyVaCctu2w==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-color-overlay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-overlay/-/filter-color-overlay-5.0.0.tgz", + "integrity": "sha512-AjxVN6gnZ+xCryQUmI+TVy3yVF+CcLgDPv+nSVPDlQowuqYhZjD6qSzgRCl3Kezdi3AxrL1vi1fnBudEnzdDJg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-replace/-/filter-color-replace-5.0.0.tgz", + "integrity": "sha512-u4VOtKbY6SSr2P9v5AL8/2MVsUcAH9z92c1eaqeE3PXCPNyCgZKuNHWl8+FjBIDl/1UMQVhXH2zNrC3Vuqo3JA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-convolution": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-convolution/-/filter-convolution-5.0.0.tgz", + "integrity": "sha512-SYjyKXODdHbjzBP9c5QGMOfowNwkNFi7zW1XzGwEadmv6mLHNanO3nm0PtRu/3B9B6AW1fvOaUecYmhjAZfQjg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-cross-hatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-cross-hatch/-/filter-cross-hatch-5.0.0.tgz", + "integrity": "sha512-J4bcI3MUc/Ol3nQIsXZldYEtiLAl3ktU28zlidwffkANyl/XjP76bLEgFBoc4RE2iP/FQ+9ZeEqpsN8DIg6vVg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-crt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-crt/-/filter-crt-5.0.0.tgz", + "integrity": "sha512-/kgjNW+BCCVtUa0s8Usk3WyxgBX8kelAiqkyVnM1g8xM19Dh2689gK2wjx0ibS0p74EHs42QpkJj7jTL+1MS7A==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-dot": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-dot/-/filter-dot-5.0.0.tgz", + "integrity": "sha512-kytardK58Ifl5D8Ss3kkfI29FMzV3+npJYr5GAKnA80R7XGOPOMoxrknhou8y+Dw9LUcOv8y643wryvL43P2vw==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-drop-shadow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-drop-shadow/-/filter-drop-shadow-5.0.0.tgz", + "integrity": "sha512-kz2eL+ikCLL7/2RICyIkw3pZXkyMY0Ji6skhnPj7JaZSjH4V+7TiKqYXp532gTbwSRj/mzLCvFfOL3WwTDgZ1w==", + "dependencies": { + "@pixi/filter-kawase-blur": "5.0.0" + }, + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-emboss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-emboss/-/filter-emboss-5.0.0.tgz", + "integrity": "sha512-wvrk9zB62lGaPcCWbTwoaO48FrLIE4+hi02BVS+exx5RvIniNUJD/ledGxdmUjcHX/2mDIIs7PH0kAs1L/ziZw==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-glitch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glitch/-/filter-glitch-5.0.0.tgz", + "integrity": "sha512-yK3plqExyQp9eo3dwV03dnSHpQgh0xeD112ieAsqefrAOLc5AXSfTelPvEQaZ07ZkcxSDE5eqKcRvcIVi2IgLQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-glow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glow/-/filter-glow-5.0.0.tgz", + "integrity": "sha512-D+YE9DGSJXtmZa6aoWJfuNu+6MnSw90GP7oRRzr7S1/4moeFZ7EWbvQehl9Y9j98idHG87Cvuh6mmsRqpgS6ow==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-godray": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-godray/-/filter-godray-5.0.0.tgz", + "integrity": "sha512-L4PD3cysUMjTSDYk5q5xUtal9q6kfH8NVIdNT3aTDJpR0VW4b/ClanmOTFpJVzN6Ld/JlJbdg8ogUpXBe1gVuw==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-kawase-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-kawase-blur/-/filter-kawase-blur-5.0.0.tgz", + "integrity": "sha512-dKSTaPUOvdVkfx9x+kp0TzYjGAl8CLxIRGz6Wh43NKx96nVqd/lWqvlda+zloHVgZyQoJNHZZ4Spjcw2mYoaWg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-motion-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-motion-blur/-/filter-motion-blur-5.0.0.tgz", + "integrity": "sha512-2av4dnVL1uyyCKF8RlZaMfeO8YnQwA893j24S15ubWHZaz4WlWH3lFIYmCMqlEqHPlFDBER4vLxpR1WjsUsX/Q==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-multi-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-multi-color-replace/-/filter-multi-color-replace-5.0.0.tgz", + "integrity": "sha512-hcmCKFFQ1baGDrZc/blK9zWpe3f02rqWGsPx5VRRgc1sk44UYXHCKZnDjF80/g0ls8U4Lj+/5Xb7HOQq2LyyDg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-old-film": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-old-film/-/filter-old-film-5.0.0.tgz", + "integrity": "sha512-XSHBz4JDbvYtUrf/NP5eKCw/wvaKTAKXQENDxk480tKYtDuteSCMg87ZjLrPlyKtGySW8KTmdzl58bZjSYpiyA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-outline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-outline/-/filter-outline-5.0.0.tgz", + "integrity": "sha512-efS3Or7VQFXo2ZyPFR2M/JlZrcLAxeVbOTPYvgKe574yUghSQbQ/pyqDWE16tRB/W7+osMrTV0+C4/N/9wIxhQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-pixelate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-pixelate/-/filter-pixelate-5.0.0.tgz", + "integrity": "sha512-3g1ajOLsYy+x0FCC67WhDcjixrcBlhK3Zo+JP9zlHSxh0W4yNzfhsw9EsIb9XP4WnMtMAUMg5T0MLTnjbsrK4g==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-radial-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-radial-blur/-/filter-radial-blur-5.0.0.tgz", + "integrity": "sha512-zafBJCAiqRtsTNGKiQ8iMt00KbG20qtBi71h286wWbr0na37iXsRcg4EN76eyNbpfAOX+1ylBgIuSd9hLyQBFA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-reflection": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-reflection/-/filter-reflection-5.0.0.tgz", + "integrity": "sha512-PuZe19XUq0gTdmAStu3hcyGKkNlKGrpblN4s6vJmV+vAKVcFv2OpfjtuGUXcP/oi2LmLakC/vKfEx4bDgZzz+w==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-rgb-split": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-rgb-split/-/filter-rgb-split-5.0.0.tgz", + "integrity": "sha512-zsWBrDkj9EdjJRPjGCt/0O2Vx/8Gt+8VTmjRA0ONoegcMD9slJdJMgL9EbH/1y5WHgmzGbgZIPvWULIqepVxBQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-shockwave": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-shockwave/-/filter-shockwave-5.0.0.tgz", + "integrity": "sha512-aL0ExAkJGcUo463Ktq4HXjZGlJDpoYcyZhwd87maJrFsBjQZl2gopse6bEsy7IJxbAKzlpUKFmAP9rxwZWqMVQ==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-simple-lightmap": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-simple-lightmap/-/filter-simple-lightmap-5.0.0.tgz", + "integrity": "sha512-0WIKQIGZ3aNafe2VZIbGQJWxSlBMbmjM9J+Tswjaeg8Z1dz6Qux5lYIC16wZOaIqVlWL5GTpfn8HU0BHCOvESA==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-tilt-shift": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-tilt-shift/-/filter-tilt-shift-5.0.0.tgz", + "integrity": "sha512-nIxYoTU9kFDx3EE1fyoIEOfAia9Tvoj+sakTKCJZUvTk+5tjpZdAm+Ump42cnb6UxTR8AMTQiwH54C7I0pbA4Q==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-twist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-twist/-/filter-twist-5.0.0.tgz", + "integrity": "sha512-YVtz3ZPfvaz22gZRZo+cOC0/L6SgSZmr/HEa6Ir+BRNVqLff6CpPx6YBVJqPREh+HFZjDomSP0kf5JasQYhzSg==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/filter-zoom-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-zoom-blur/-/filter-zoom-blur-5.0.0.tgz", + "integrity": "sha512-Q1ftuY/KPgbVtJHCvl0p4hrwVWRMWZ/yX1YRjdLGSyOwMEN8u16MEEXFQUtixEHY7+MBRBWaPOaXBaQrd+Xq7A==", + "peerDependencies": { + "@pixi/core": "^7.0.0-X" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/math": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.2.4.tgz", + "integrity": "sha512-LJB+mozyEPllxa0EssFZrKNfVwysfaBun4b2dJKQQInp0DafgbA0j7A+WVg0oe51KhFULTJMpDqbLn/ITFc41A==", + "peer": true + }, + "node_modules/pixi-filters/node_modules/@pixi/runner": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.2.4.tgz", + "integrity": "sha512-YtyqPk1LA+0guEFKSFx6t/YSvbEQwajFwi4Ft8iDhioa6VK2MmTir1GjWwy7JQYLcDmYSAcQjnmFtVTZohyYSw==", + "peer": true + }, + "node_modules/pixi-filters/node_modules/@pixi/settings": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.2.4.tgz", + "integrity": "sha512-ZPKRar9EwibijGmH8EViu4Greq1I/O7V/xQx2rNqN23XA7g09Qo6yfaeQpufu5xl8+/lZrjuHtQSnuY7OgG1CA==", + "peer": true, + "dependencies": { + "@pixi/constants": "7.2.4", + "@types/css-font-loading-module": "^0.0.7", + "ismobilejs": "^1.1.0" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/ticker": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.2.4.tgz", + "integrity": "sha512-hQQHIHvGeFsP4GNezZqjzuhUgNQEVgCH9+qU05UX1Mc5UHC9l6OJnY4VTVhhcHxZjA6RnyaY+1zBxCnoXuazpg==", + "peer": true, + "dependencies": { + "@pixi/extensions": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/utils": "7.2.4" + } + }, + "node_modules/pixi-filters/node_modules/@pixi/utils": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.2.4.tgz", + "integrity": "sha512-VUGQHBOINIS4ePzoqafwxaGPVRTa3oM/mEutIIHbNGI3b+QvSO+1Dnk40M0zcH6Bo+MxQZbOZK5X/wO9oU5+LQ==", + "peer": true, + "dependencies": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/settings": "7.2.4", + "@types/earcut": "^2.1.0", + "earcut": "^2.2.4", + "eventemitter3": "^4.0.0", + "url": "^0.11.0" + } + }, + "node_modules/pixi-filters/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "peer": true + }, "node_modules/pixi.js": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-6.0.4.tgz", @@ -2031,17 +2431,6 @@ "node": ">= 0.8.0" } }, - "node_modules/printj": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz", - "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==", - "bin": { - "printj": "bin/printj.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -2414,17 +2803,14 @@ "dev": true }, "node_modules/xlsx": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.17.0.tgz", - "integrity": "sha512-bZ36FSACiAyjoldey1+7it50PMlDp1pcAJrZKcVZHzKd8BC/z6TQ/QAN8onuqcepifqSznR6uKnjPhaGt6ig9A==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", "dependencies": { - "adler-32": "~1.2.0", - "cfb": "^1.1.4", - "codepage": "~1.14.0", - "commander": "~2.17.1", - "crc-32": "~1.2.0", - "exit-on-epipe": "~1.0.1", - "fflate": "^0.3.8", + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" @@ -2436,11 +2822,6 @@ "node": ">=0.8" } }, - "node_modules/xlsx/node_modules/commander": { - "version": "2.17.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", - "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==" - }, "node_modules/xmlcreate": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.3.tgz", @@ -2682,6 +3063,15 @@ "@pixi/text": "6.0.4" } }, + "@pixi/color": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.2.4.tgz", + "integrity": "sha512-B/+9JRcXe2uE8wQfsueFRPZVayF2VEMRB7XGeRAsWCryOX19nmWhv0Nt3nOU2rvzI0niz9XgugJXsB6vVmDFSg==", + "peer": true, + "requires": { + "colord": "^2.9.3" + } + }, "@pixi/compressed-textures": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/compressed-textures/-/compressed-textures-6.0.4.tgz", @@ -2721,6 +3111,12 @@ "@pixi/utils": "6.0.4" } }, + "@pixi/extensions": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.2.4.tgz", + "integrity": "sha512-Mnqv9scbL1ARD3QFKfOWs2aSVJJfP1dL8g5UiqGImYO3rZbz/9QCzXOeMVIZ5n3iaRyKMNhFFr84/zUja2H7Dw==", + "peer": true + }, "@pixi/extract": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/extract/-/extract-6.0.4.tgz", @@ -3026,11 +3422,23 @@ "url": "^0.11.0" } }, + "@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "peer": true + }, "@types/earcut": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.1.tgz", "integrity": "sha512-w8oigUCDjElRHRRrMvn/spybSMyX8MTkKA5Dv+tS1IE/TgmNZPqUYtvYBXGY8cieSE66gm+szeK+bnbxC2xHTQ==" }, + "@types/offscreencanvas": { + "version": "2019.7.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.0.tgz", + "integrity": "sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg==", + "peer": true + }, "a11y-dialog": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/a11y-dialog/-/a11y-dialog-7.5.0.tgz", @@ -3053,13 +3461,9 @@ "requires": {} }, "adler-32": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz", - "integrity": "sha1-aj5r8KY5ALoVZSgIyxXGgT0aXyU=", - "requires": { - "exit-on-epipe": "~1.0.1", - "printj": "~1.1.0" - } + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==" }, "ajv": { "version": "6.12.6", @@ -3150,13 +3554,12 @@ } }, "cfb": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.0.tgz", - "integrity": "sha512-sXMvHsKCICVR3Naq+J556K+ExBo9n50iKl6LGarlnvuA2035uMlGA/qVrc0wQtow5P1vJEw9UyrKLCbtIKz+TQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", "requires": { - "adler-32": "~1.2.0", - "crc-32": "~1.2.0", - "printj": "~1.1.2" + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" } }, "chalk": { @@ -3177,20 +3580,9 @@ "dev": true }, "codepage": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz", - "integrity": "sha1-jL4lSBMjVZ19MHVxsP/5HnodL5k=", - "requires": { - "commander": "~2.14.1", - "exit-on-epipe": "~1.0.1" - }, - "dependencies": { - "commander": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz", - "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==" - } - } + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==" }, "color-convert": { "version": "1.9.3", @@ -3207,6 +3599,12 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "peer": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3214,13 +3612,9 @@ "dev": true }, "crc-32": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz", - "integrity": "sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA==", - "requires": { - "exit-on-epipe": "~1.0.1", - "printj": "~1.1.0" - } + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==" }, "csslint": { "version": "1.0.5", @@ -3268,9 +3662,9 @@ "dev": true }, "earcut": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.2.tgz", - "integrity": "sha512-eZoZPPJcUHnfRZ0PjLvx2qBordSiO8ofC3vt+qACLM95u+4DovnbYNpQtJh0DNsWj8RnxrQytD4WA8gj5cRIaQ==" + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" }, "emoji-regex": { "version": "8.0.0", @@ -3598,11 +3992,6 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" }, - "exit-on-epipe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", - "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==" - }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3621,11 +4010,6 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, - "fflate": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", - "integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==" - }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4044,6 +4428,335 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, + "pixi-filters": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-5.0.0.tgz", + "integrity": "sha512-j90nvbiRpDozxalSUaQ2kTIyFNGAUKxJ2qhPs4ThmVLiR9lam5x+GpP+c1Yx5N+qc+u0tH5G3VRY1usB69atrw==", + "requires": { + "@pixi/filter-adjustment": "5.0.0", + "@pixi/filter-advanced-bloom": "5.0.0", + "@pixi/filter-ascii": "5.0.0", + "@pixi/filter-bevel": "5.0.0", + "@pixi/filter-bloom": "5.0.0", + "@pixi/filter-bulge-pinch": "5.0.0", + "@pixi/filter-color-map": "5.0.0", + "@pixi/filter-color-overlay": "5.0.0", + "@pixi/filter-color-replace": "5.0.0", + "@pixi/filter-convolution": "5.0.0", + "@pixi/filter-cross-hatch": "5.0.0", + "@pixi/filter-crt": "5.0.0", + "@pixi/filter-dot": "5.0.0", + "@pixi/filter-drop-shadow": "5.0.0", + "@pixi/filter-emboss": "5.0.0", + "@pixi/filter-glitch": "5.0.0", + "@pixi/filter-glow": "5.0.0", + "@pixi/filter-godray": "5.0.0", + "@pixi/filter-kawase-blur": "5.0.0", + "@pixi/filter-motion-blur": "5.0.0", + "@pixi/filter-multi-color-replace": "5.0.0", + "@pixi/filter-old-film": "5.0.0", + "@pixi/filter-outline": "5.0.0", + "@pixi/filter-pixelate": "5.0.0", + "@pixi/filter-radial-blur": "5.0.0", + "@pixi/filter-reflection": "5.0.0", + "@pixi/filter-rgb-split": "5.0.0", + "@pixi/filter-shockwave": "5.0.0", + "@pixi/filter-simple-lightmap": "5.0.0", + "@pixi/filter-tilt-shift": "5.0.0", + "@pixi/filter-twist": "5.0.0", + "@pixi/filter-zoom-blur": "5.0.0" + }, + "dependencies": { + "@pixi/constants": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.2.4.tgz", + "integrity": "sha512-hKuHBWR6N4Q0Sf5MGF3/9l+POg/G5rqhueHfzofiuelnKg7aBs3BVjjZ+6hZbd6M++vOUmxYelEX/NEFBxrheA==", + "peer": true + }, + "@pixi/core": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/core/-/core-7.2.4.tgz", + "integrity": "sha512-0XtvrfxHlS2T+beBBSpo7GI8+QLyyTqMVQpNmPqB4woYxzrOEJ9JaUFBaBfCvycLeUkfVih1u6HAbtF+2d1EjQ==", + "peer": true, + "requires": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/extensions": "7.2.4", + "@pixi/math": "7.2.4", + "@pixi/runner": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/ticker": "7.2.4", + "@pixi/utils": "7.2.4", + "@types/offscreencanvas": "^2019.6.4" + } + }, + "@pixi/filter-adjustment": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-5.0.0.tgz", + "integrity": "sha512-Epci8zSWCNWhFtnarvQqOcnmOqLfhXIJ7NNENEi2E1rom1Ar13RLM76CBGBbuDRK7flweqcWmZb0QZLxqwxTDg==", + "requires": {} + }, + "@pixi/filter-advanced-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-advanced-bloom/-/filter-advanced-bloom-5.0.0.tgz", + "integrity": "sha512-P5Xt65GLBEqjZVUkLe4ZZk4D1/j9UEXYnYFG3JrLPYkdcniwD4Y+NIyNCJ+eP91ivgoCmK/+SyBRv0P0AEQkTw==", + "requires": { + "@pixi/filter-kawase-blur": "5.0.0" + } + }, + "@pixi/filter-alpha": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-7.2.4.tgz", + "integrity": "sha512-UTUMSGyktUr+I9vmigqJo9iUhb0nwGyqTTME2xBWZvVGCnl5z+/wHxvIBBCe5pNZ66IM15pGXQ4cDcfqCuP2kA==", + "peer": true, + "requires": {} + }, + "@pixi/filter-ascii": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-ascii/-/filter-ascii-5.0.0.tgz", + "integrity": "sha512-A49yNhiye/aFDOnI11zwEm/td2xho0td/Cvzvru8FUgi1MzJvZE03W/JoLl04ToZczw143wFPxutl6V/Ohw5bQ==", + "requires": {} + }, + "@pixi/filter-bevel": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bevel/-/filter-bevel-5.0.0.tgz", + "integrity": "sha512-0Odat0tW/uoS/uyp0rigm07Q3YPgwKLTgkZZZSzIUVsPnwcJjiocSzWel73JkiY3m2ZjTrj+JZjkyGjkYH+2gQ==", + "requires": {} + }, + "@pixi/filter-bloom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bloom/-/filter-bloom-5.0.0.tgz", + "integrity": "sha512-vOSNJNV5y+ifwQWfzEmml3owcgoJAQIQtMR17SELBUwfYP60qxy5bNWBdYBlipSJVwX2AuGi8Xk5Ia9dijcqZQ==", + "requires": {} + }, + "@pixi/filter-blur": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/filter-blur/-/filter-blur-7.2.4.tgz", + "integrity": "sha512-aLyXIoxy14bTansCPtbY8x7Sdn2OrrqkF/pcKiRXHJGGhi7wPacvB/NcmYJdnI/n2ExQ6V5Njuj/nfrsejVwcA==", + "peer": true, + "requires": {} + }, + "@pixi/filter-bulge-pinch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-bulge-pinch/-/filter-bulge-pinch-5.0.0.tgz", + "integrity": "sha512-j1feWsCpyTZk4aHbYNjax52lt0OtyYDbHvYaePYzGO/SBb1t/spDnHQEkAP7R3bZ7Ud/GI4RgefAFnvsYeSetQ==", + "requires": {} + }, + "@pixi/filter-color-map": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-map/-/filter-color-map-5.0.0.tgz", + "integrity": "sha512-w77mRi89sLUMwjhl7qL/q1YrhEKyOk2MJZQdKBksvGEV/Mf5mV2h3+EOC62wB18Q4iUVQy1MS4sANyVaCctu2w==", + "requires": {} + }, + "@pixi/filter-color-overlay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-overlay/-/filter-color-overlay-5.0.0.tgz", + "integrity": "sha512-AjxVN6gnZ+xCryQUmI+TVy3yVF+CcLgDPv+nSVPDlQowuqYhZjD6qSzgRCl3Kezdi3AxrL1vi1fnBudEnzdDJg==", + "requires": {} + }, + "@pixi/filter-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-color-replace/-/filter-color-replace-5.0.0.tgz", + "integrity": "sha512-u4VOtKbY6SSr2P9v5AL8/2MVsUcAH9z92c1eaqeE3PXCPNyCgZKuNHWl8+FjBIDl/1UMQVhXH2zNrC3Vuqo3JA==", + "requires": {} + }, + "@pixi/filter-convolution": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-convolution/-/filter-convolution-5.0.0.tgz", + "integrity": "sha512-SYjyKXODdHbjzBP9c5QGMOfowNwkNFi7zW1XzGwEadmv6mLHNanO3nm0PtRu/3B9B6AW1fvOaUecYmhjAZfQjg==", + "requires": {} + }, + "@pixi/filter-cross-hatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-cross-hatch/-/filter-cross-hatch-5.0.0.tgz", + "integrity": "sha512-J4bcI3MUc/Ol3nQIsXZldYEtiLAl3ktU28zlidwffkANyl/XjP76bLEgFBoc4RE2iP/FQ+9ZeEqpsN8DIg6vVg==", + "requires": {} + }, + "@pixi/filter-crt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-crt/-/filter-crt-5.0.0.tgz", + "integrity": "sha512-/kgjNW+BCCVtUa0s8Usk3WyxgBX8kelAiqkyVnM1g8xM19Dh2689gK2wjx0ibS0p74EHs42QpkJj7jTL+1MS7A==", + "requires": {} + }, + "@pixi/filter-dot": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-dot/-/filter-dot-5.0.0.tgz", + "integrity": "sha512-kytardK58Ifl5D8Ss3kkfI29FMzV3+npJYr5GAKnA80R7XGOPOMoxrknhou8y+Dw9LUcOv8y643wryvL43P2vw==", + "requires": {} + }, + "@pixi/filter-drop-shadow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-drop-shadow/-/filter-drop-shadow-5.0.0.tgz", + "integrity": "sha512-kz2eL+ikCLL7/2RICyIkw3pZXkyMY0Ji6skhnPj7JaZSjH4V+7TiKqYXp532gTbwSRj/mzLCvFfOL3WwTDgZ1w==", + "requires": { + "@pixi/filter-kawase-blur": "5.0.0" + } + }, + "@pixi/filter-emboss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-emboss/-/filter-emboss-5.0.0.tgz", + "integrity": "sha512-wvrk9zB62lGaPcCWbTwoaO48FrLIE4+hi02BVS+exx5RvIniNUJD/ledGxdmUjcHX/2mDIIs7PH0kAs1L/ziZw==", + "requires": {} + }, + "@pixi/filter-glitch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glitch/-/filter-glitch-5.0.0.tgz", + "integrity": "sha512-yK3plqExyQp9eo3dwV03dnSHpQgh0xeD112ieAsqefrAOLc5AXSfTelPvEQaZ07ZkcxSDE5eqKcRvcIVi2IgLQ==", + "requires": {} + }, + "@pixi/filter-glow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-glow/-/filter-glow-5.0.0.tgz", + "integrity": "sha512-D+YE9DGSJXtmZa6aoWJfuNu+6MnSw90GP7oRRzr7S1/4moeFZ7EWbvQehl9Y9j98idHG87Cvuh6mmsRqpgS6ow==", + "requires": {} + }, + "@pixi/filter-godray": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-godray/-/filter-godray-5.0.0.tgz", + "integrity": "sha512-L4PD3cysUMjTSDYk5q5xUtal9q6kfH8NVIdNT3aTDJpR0VW4b/ClanmOTFpJVzN6Ld/JlJbdg8ogUpXBe1gVuw==", + "requires": {} + }, + "@pixi/filter-kawase-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-kawase-blur/-/filter-kawase-blur-5.0.0.tgz", + "integrity": "sha512-dKSTaPUOvdVkfx9x+kp0TzYjGAl8CLxIRGz6Wh43NKx96nVqd/lWqvlda+zloHVgZyQoJNHZZ4Spjcw2mYoaWg==", + "requires": {} + }, + "@pixi/filter-motion-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-motion-blur/-/filter-motion-blur-5.0.0.tgz", + "integrity": "sha512-2av4dnVL1uyyCKF8RlZaMfeO8YnQwA893j24S15ubWHZaz4WlWH3lFIYmCMqlEqHPlFDBER4vLxpR1WjsUsX/Q==", + "requires": {} + }, + "@pixi/filter-multi-color-replace": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-multi-color-replace/-/filter-multi-color-replace-5.0.0.tgz", + "integrity": "sha512-hcmCKFFQ1baGDrZc/blK9zWpe3f02rqWGsPx5VRRgc1sk44UYXHCKZnDjF80/g0ls8U4Lj+/5Xb7HOQq2LyyDg==", + "requires": {} + }, + "@pixi/filter-old-film": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-old-film/-/filter-old-film-5.0.0.tgz", + "integrity": "sha512-XSHBz4JDbvYtUrf/NP5eKCw/wvaKTAKXQENDxk480tKYtDuteSCMg87ZjLrPlyKtGySW8KTmdzl58bZjSYpiyA==", + "requires": {} + }, + "@pixi/filter-outline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-outline/-/filter-outline-5.0.0.tgz", + "integrity": "sha512-efS3Or7VQFXo2ZyPFR2M/JlZrcLAxeVbOTPYvgKe574yUghSQbQ/pyqDWE16tRB/W7+osMrTV0+C4/N/9wIxhQ==", + "requires": {} + }, + "@pixi/filter-pixelate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-pixelate/-/filter-pixelate-5.0.0.tgz", + "integrity": "sha512-3g1ajOLsYy+x0FCC67WhDcjixrcBlhK3Zo+JP9zlHSxh0W4yNzfhsw9EsIb9XP4WnMtMAUMg5T0MLTnjbsrK4g==", + "requires": {} + }, + "@pixi/filter-radial-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-radial-blur/-/filter-radial-blur-5.0.0.tgz", + "integrity": "sha512-zafBJCAiqRtsTNGKiQ8iMt00KbG20qtBi71h286wWbr0na37iXsRcg4EN76eyNbpfAOX+1ylBgIuSd9hLyQBFA==", + "requires": {} + }, + "@pixi/filter-reflection": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-reflection/-/filter-reflection-5.0.0.tgz", + "integrity": "sha512-PuZe19XUq0gTdmAStu3hcyGKkNlKGrpblN4s6vJmV+vAKVcFv2OpfjtuGUXcP/oi2LmLakC/vKfEx4bDgZzz+w==", + "requires": {} + }, + "@pixi/filter-rgb-split": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-rgb-split/-/filter-rgb-split-5.0.0.tgz", + "integrity": "sha512-zsWBrDkj9EdjJRPjGCt/0O2Vx/8Gt+8VTmjRA0ONoegcMD9slJdJMgL9EbH/1y5WHgmzGbgZIPvWULIqepVxBQ==", + "requires": {} + }, + "@pixi/filter-shockwave": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-shockwave/-/filter-shockwave-5.0.0.tgz", + "integrity": "sha512-aL0ExAkJGcUo463Ktq4HXjZGlJDpoYcyZhwd87maJrFsBjQZl2gopse6bEsy7IJxbAKzlpUKFmAP9rxwZWqMVQ==", + "requires": {} + }, + "@pixi/filter-simple-lightmap": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-simple-lightmap/-/filter-simple-lightmap-5.0.0.tgz", + "integrity": "sha512-0WIKQIGZ3aNafe2VZIbGQJWxSlBMbmjM9J+Tswjaeg8Z1dz6Qux5lYIC16wZOaIqVlWL5GTpfn8HU0BHCOvESA==", + "requires": {} + }, + "@pixi/filter-tilt-shift": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-tilt-shift/-/filter-tilt-shift-5.0.0.tgz", + "integrity": "sha512-nIxYoTU9kFDx3EE1fyoIEOfAia9Tvoj+sakTKCJZUvTk+5tjpZdAm+Ump42cnb6UxTR8AMTQiwH54C7I0pbA4Q==", + "requires": {} + }, + "@pixi/filter-twist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-twist/-/filter-twist-5.0.0.tgz", + "integrity": "sha512-YVtz3ZPfvaz22gZRZo+cOC0/L6SgSZmr/HEa6Ir+BRNVqLff6CpPx6YBVJqPREh+HFZjDomSP0kf5JasQYhzSg==", + "requires": {} + }, + "@pixi/filter-zoom-blur": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@pixi/filter-zoom-blur/-/filter-zoom-blur-5.0.0.tgz", + "integrity": "sha512-Q1ftuY/KPgbVtJHCvl0p4hrwVWRMWZ/yX1YRjdLGSyOwMEN8u16MEEXFQUtixEHY7+MBRBWaPOaXBaQrd+Xq7A==", + "requires": {} + }, + "@pixi/math": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.2.4.tgz", + "integrity": "sha512-LJB+mozyEPllxa0EssFZrKNfVwysfaBun4b2dJKQQInp0DafgbA0j7A+WVg0oe51KhFULTJMpDqbLn/ITFc41A==", + "peer": true + }, + "@pixi/runner": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.2.4.tgz", + "integrity": "sha512-YtyqPk1LA+0guEFKSFx6t/YSvbEQwajFwi4Ft8iDhioa6VK2MmTir1GjWwy7JQYLcDmYSAcQjnmFtVTZohyYSw==", + "peer": true + }, + "@pixi/settings": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.2.4.tgz", + "integrity": "sha512-ZPKRar9EwibijGmH8EViu4Greq1I/O7V/xQx2rNqN23XA7g09Qo6yfaeQpufu5xl8+/lZrjuHtQSnuY7OgG1CA==", + "peer": true, + "requires": { + "@pixi/constants": "7.2.4", + "@types/css-font-loading-module": "^0.0.7", + "ismobilejs": "^1.1.0" + } + }, + "@pixi/ticker": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.2.4.tgz", + "integrity": "sha512-hQQHIHvGeFsP4GNezZqjzuhUgNQEVgCH9+qU05UX1Mc5UHC9l6OJnY4VTVhhcHxZjA6RnyaY+1zBxCnoXuazpg==", + "peer": true, + "requires": { + "@pixi/extensions": "7.2.4", + "@pixi/settings": "7.2.4", + "@pixi/utils": "7.2.4" + } + }, + "@pixi/utils": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.2.4.tgz", + "integrity": "sha512-VUGQHBOINIS4ePzoqafwxaGPVRTa3oM/mEutIIHbNGI3b+QvSO+1Dnk40M0zcH6Bo+MxQZbOZK5X/wO9oU5+LQ==", + "peer": true, + "requires": { + "@pixi/color": "7.2.4", + "@pixi/constants": "7.2.4", + "@pixi/settings": "7.2.4", + "@types/earcut": "^2.1.0", + "earcut": "^2.2.4", + "eventemitter3": "^4.0.0", + "url": "^0.11.0" + } + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "peer": true + } + } + }, "pixi.js": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-6.0.4.tgz", @@ -4110,11 +4823,6 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, - "printj": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz", - "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==" - }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -4417,27 +5125,17 @@ "dev": true }, "xlsx": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.17.0.tgz", - "integrity": "sha512-bZ36FSACiAyjoldey1+7it50PMlDp1pcAJrZKcVZHzKd8BC/z6TQ/QAN8onuqcepifqSznR6uKnjPhaGt6ig9A==", + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", "requires": { - "adler-32": "~1.2.0", - "cfb": "^1.1.4", - "codepage": "~1.14.0", - "commander": "~2.17.1", - "crc-32": "~1.2.0", - "exit-on-epipe": "~1.0.1", - "fflate": "^0.3.8", + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" - }, - "dependencies": { - "commander": { - "version": "2.17.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", - "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==" - } } }, "xmlcreate": { diff --git a/package.json b/package.json index 3797ef4..22526c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psychojs", - "version": "2022.2.3", + "version": "2023.2.3", "private": true, "description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments", "license": "MIT", @@ -34,10 +34,11 @@ "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", + "pixi-filters": "^5.0.0", "pixi.js-legacy": "^6.0.4", "seedrandom": "^3.0.5", "tone": "^14.7.77", - "xlsx": "^0.17.0" + "xlsx": "^0.18.5" }, "devDependencies": { "csslint": "^1.0.5", diff --git a/src/core/EventManager.js b/src/core/EventManager.js index c9f8255..245b3a7 100644 --- a/src/core/EventManager.js +++ b/src/core/EventManager.js @@ -302,7 +302,13 @@ export class EventManager { const timestamp = MonotonicClock.getReferenceTime(); - let code = event.code; + // Note: we are using event.key since we are interested in the input character rather than + // the physical key position on the keyboard, i.e. we need to take into account the keyboard + // layout + // See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code for a comment regarding + // event.code's lack of suitability + let code = EventManager._pygletMap[event.key]; + // let code = event.code; // take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge): if (typeof code === "undefined") diff --git a/src/core/GUI.js b/src/core/GUI.js index 168ee94..e3cc571 100644 --- a/src/core/GUI.js +++ b/src/core/GUI.js @@ -50,6 +50,9 @@ export class GUI { this._psychoJS = psychoJS; + // info fields excluded from the GUI: + this._excludedInfo = {}; + // gui listens to RESOURCE events from the server manager: psychoJS.serverManager.on(ServerManager.Event.RESOURCE, (signal) => { @@ -87,9 +90,6 @@ export class GUI requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick }) { - // get info from URL: - const infoFromUrl = util.getUrlParameters(); - this._progressBarMax = 0; this._allResourcesDownloaded = false; this._requiredKeys = []; @@ -113,6 +113,19 @@ export class GUI self._dialogComponent.tStart = t; self._dialogComponent.status = PsychoJS.Status.STARTED; + // prepare the info fields excluded from the GUI, including those from the URL: + const excludedInfo = {}; + for (let key in self._excludedInfo) + { + excludedInfo[key.trim().toLowerCase()] = self._excludedInfo[key]; + } + const infoFromUrl = util.getUrlParameters(); + infoFromUrl.forEach((value, key) => + { + excludedInfo[key.trim().toLowerCase()] = value; + }); + + // 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 @@ -130,7 +143,13 @@ export class GUI markup += "
"; // alert title and close button: - markup += `

${title}

`; + markup += "
"; + markup += `

${title}

`; + markup += ""; + markup += "
"; + + // everything above the buttons is in a scrollable container: + markup += "
"; // logo, if need be: if (typeof logoUrl === "string") @@ -139,14 +158,16 @@ export class GUI } // add a combobox or text areas for each entry in the dictionary: + let atLeastOneIncludedKey = false; Object.keys(dictionary).forEach((key, keyIdx) => { 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(); + const isIncluded = !(cleanedDictKey in excludedInfo); + /*let inUrl = false; infoFromUrl.forEach((urlValue, urlKey) => { const cleanedUrlKey = urlKey.trim().toLowerCase(); @@ -155,10 +176,13 @@ export class GUI inUrl = true; // break; } - }); + });*/ - if (!inUrl) + if (isIncluded) + // if (!inUrl) { + atLeastOneIncludedKey = true; + markup += ``; // if the field is required: @@ -185,7 +209,7 @@ export class GUI markup += ""; } - // otherwise we use a single string input: + // otherwise we use a single string input: //if (typeof value === 'string') else { @@ -199,17 +223,27 @@ export class GUI markup += "

Fields marked with an asterisk (*) are required.

"; } + markup += "
"; // scrollable-container + + // separator, if need be: + if (atLeastOneIncludedKey) + { + markup += "
"; + } + // progress bar: - markup += `
${self._progressMessage}
`; + markup += `
${self._progressMessage}
`; markup += "
"; // buttons: markup += "
"; + markup += "
"; markup += ""; if (self._requireParticipantClick) { markup += ""; } + markup += "
"; // button-group markup += "
"; @@ -266,24 +300,31 @@ export class GUI /** * @callback GUI.onOK */ + /** + * @callback GUI.onCancel + */ /** * Show a message to the participant in a dialog box. * - *

This function can be used to display both warning and error messages.

+ *

This function can be used to display ordinary, warning, and error messages.

* * @param {Object} options * @param {string} options.message - the message to be displayed * @param {Object.} options.error - an exception * @param {string} options.warning - a warning message - * @param {boolean} [options.showOK=true] - specifies whether to show the OK button + * @param {boolean} [options.showOK=true] - whether to show the OK button * @param {GUI.onOK} [options.onOK] - function called when the participant presses the OK button + * @param {boolean} [options.showCancel=false] - whether to show the Cancel button + * @param {GUI.onCancel} [options.onCancel] - function called when the participant presses the Cancel button */ dialog({ message, warning, error, showOK = true, - onOK + onOK, + showCancel = false, + onCancel } = {}) { // close the previously opened dialog box, if there is one: @@ -339,14 +380,18 @@ export class GUI { const error = this._userFriendlyError(errorCode); markup += `

${error.title}

`; + markup += "
"; markup += `

${error.text}

`; + markup += "
"; } else { markup += `

Error

`; + markup += "
"; markup += `

Unfortunately we encountered the following error:

`; markup += stackCode; markup += "

Try to run the experiment again. If the error persists, contact the experiment designer.

"; + markup += "
"; } } @@ -354,19 +399,36 @@ export class GUI else if (typeof warning !== "undefined") { markup += `

Warning

`; + markup += "
"; markup += `

${warning}

`; + markup += "
"; } // we are displaying a message: else if (typeof message !== "undefined") { - markup += `

Message

`; + markup += "

Message

"; + markup += "
"; markup += `

${message}

`; + markup += "
"; } - if (showOK) + if (showOK || showCancel) { - markup += "
"; + markup += "
"; + } + if (showCancel || showOK) + { + markup += "
"; + if (showCancel) + { + markup += ""; + } + if (showOK) + { + markup += ""; + } + markup += "
"; // button-group } markup += ""; @@ -394,6 +456,20 @@ export class GUI } }; } + if (showCancel) + { + this._cancelButton = document.getElementById("dialogCancel"); + this._cancelButton.onclick = () => + { + this.closeDialog(); + + // execute callback function: + if (typeof onCancel !== "undefined") + { + onCancel(); + } + }; + } } /** @@ -611,19 +687,16 @@ export class GUI // locally the OK button is always enabled, otherwise only if all requirements have been fulfilled: if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL || allRequirementsFulfilled) { + this._okButton.classList.add("dialog-button"); + this._okButton.classList.remove("disabled"); if (changeOKButtonFocus) { - this._okButton.classList = ["dialog-button"]; this._okButton.focus(); } - else - { - this._okButton.classList = ["dialog-button"]; - } } else { - this._okButton.classList = ["dialog-button", "disabled"]; + this._okButton.classList.add("dialog-button", "disabled"); } } diff --git a/src/core/Keyboard.js b/src/core/Keyboard.js index dd2427d..56df760 100644 --- a/src/core/Keyboard.js +++ b/src/core/Keyboard.js @@ -354,7 +354,13 @@ export class Keyboard extends PsychObject */ self._previousKeydownKey = event.key; - let code = event.code; + // Note: we are using event.key since we are interested in the input character rather than + // the physical key position on the keyboard, i.e. we need to take into account the keyboard + // layout + // See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code for a comment regarding + // event.code's lack of suitability + let code = EventManager._pygletMap[event.key]; + // let code = event.code; // take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge): if (typeof code === "undefined") @@ -394,7 +400,9 @@ export class Keyboard extends PsychObject self._previousKeydownKey = undefined; - let code = event.code; + // Note: see above for explanation regarding the use of event.key in lieu of event.code + let code = EventManager._pygletMap[event.key]; + // let code = event.code; // take care of legacy Microsoft Edge: if (typeof code === "undefined") diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index aabd33c..c8ca9d1 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -117,11 +117,18 @@ export class PsychoJS collectIP = false, hosts = [], topLevelStatus = true, + autoStartScheduler = true, + saveResults = true, + captureErrors = true, + checkWebGLSupport = false } = {}) { // logging: this._logger = new Logger(this, (debug) ? log4javascript.Level.DEBUG : log4javascript.Level.INFO); - this._captureErrors(); + if (captureErrors) + { + this._captureErrors(); + } // detect the browser: this._browser = util.detectBrowser(); @@ -160,7 +167,7 @@ export class PsychoJS this._completionUrl = undefined; // status: - this._status = PsychoJS.Status.NOT_CONFIGURED; + this.status = PsychoJS.Status.NOT_CONFIGURED; // make the PsychoJS.Status accessible from the top level of the generated experiment script // in order to accommodate PsychoPy's Code Components @@ -169,8 +176,17 @@ export class PsychoJS this._makeStatusTopLevel(); } + // whether to start the scheduler when the experiment starts: + this._autoStartScheduler = autoStartScheduler; + + // whether to check for actual hardware accelerated WebGL support: + this._checkWebGLSupport = checkWebGLSupport; + + // whether to save results at the end of the experiment: + this._saveResults = saveResults; + this.logger.info("[PsychoJS] Initialised."); - this.logger.info("[PsychoJS] @version 2022.2.1"); + this.logger.info("[PsychoJS] @version 2022.3.0"); // hide the initialisation message: const root = document.getElementById("root"); @@ -256,7 +272,7 @@ export class PsychoJS * Schedule a task. * * @param {module:util.Scheduler~Task} task - the task to be scheduled - * @param {*} args - arguments for that task + * @param {*} [args] - arguments for that task */ schedule(task, args) { @@ -303,7 +319,14 @@ export class PsychoJS * @param {Object.} [options.expInfo] - additional information about the experiment * @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources */ - async start({ configURL = "config.json", expName = "UNKNOWN", expInfo = {}, resources = [], dataFileName } = {}) + async start({ + configURL = "config.json", + expName = "UNKNOWN", + expInfo = {}, + resources = [], + dataFileName, + surveyId} = {} + ) { this.logger.debug(); @@ -346,7 +369,16 @@ export class PsychoJS if (this.getEnvironment() === ExperimentHandler.Environment.SERVER) { // open a session: - await this._serverManager.openSession(); + const params = {}; + if (this._serverMsg.has("__pilotToken")) + { + params.pilotToken = this._serverMsg.get("__pilotToken"); + } + if (typeof surveyId !== "undefined") + { + params.surveyId = surveyId; + } + await this._serverManager.openSession(params); // warn the user when they attempt to close the tab or browser: this.beforeunloadCallback = (event) => @@ -368,7 +400,7 @@ export class PsychoJS if (self._config.session.status === "OPEN") { // save the incomplete results if need be: - if (self._config.experiment.saveIncompleteResults) + if (self._config.experiment.saveIncompleteResults && self._saveResults) { self._experiment.save({ sync: true }); } @@ -387,14 +419,43 @@ export class PsychoJS // start the asynchronous download of resources: this._serverManager.prepareResources(resources); - // start the experiment: - this.logger.info("[PsychoJS] Start Experiment."); - await this._scheduler.start(); + // if WebGL is not actually available, warn the participant and ask them whether they want to go ahead + if (this._checkWebGLSupport && !Window.checkWebGLSupport()) + { + // add an entry to experiment results to warn the designer about a potential WebGL issue: + this._experiment.addData('hardware_acceleration', 'NOT SUPPORTED'); + this._experiment.nextEntry(); + + this._gui.dialog({ + warning: "It appears that hardware acceleration is either not supported by your browser or currently switched off.
As a consequence, this experiment will be rendered using software emulation and advanced features, such as gratings and gamma correction, will not be available.

You may want to press Cancel, change your browser settings, and reload the experiment. Otherwise press OK to proceed as is.", + showCancel: true, + onCancel: () => + { + this.quit(); + }, + onOK: () => + { + this.status = PsychoJS.Status.STARTED; + this.logger.info("[PsychoJS] Start Experiment (software emulation mode)."); + this._scheduler.start(); + } + }); + } + else + { + if (this._autoStartScheduler) + { + this.status = PsychoJS.Status.STARTED; + this.logger.info("[PsychoJS] Start Experiment."); + this._scheduler.start(); + } + } + } catch (error) { - // this._gui.dialog({ error: { ...response, error } }); - this._gui.dialog({ error: Object.assign(response, { error }) }); + this.status = PsychoJS.Status.ERROR; + throw { ...response, error }; } } @@ -462,13 +523,16 @@ export class PsychoJS * @param {string} [options.message] - optional message to be displayed in a dialog box before quitting * @param {boolean} [options.isCompleted = false] - whether the participant has completed the experiment */ - async quit({ message, isCompleted = false } = {}) + async quit({ message, isCompleted = false, closeWindow = true, showOK = true } = {}) { this.logger.info("[PsychoJS] Quit."); + const response = { origin: "PsychoJS.quit", context: "when terminating the experiment" }; + this._experiment.experimentEnded = true; - this._status = PsychoJS.Status.FINISHED; - const isServerEnv = this.getEnvironment() === ExperimentHandler.Environment.SERVER; + this._experiment.isCompleted = isCompleted; + this.status = PsychoJS.Status.STOPPED; + const isServerEnv = (this.getEnvironment() === ExperimentHandler.Environment.SERVER); try { @@ -484,12 +548,12 @@ export class PsychoJS // save the results and the logs of the experiment: this.gui.finishDialog({ text: "Terminating the experiment. Please wait a few moments...", - nbSteps: 2 + ((isServerEnv) ? 1 : 0) + nbSteps: ((this._saveResults) ? 2 : 0) + ((isServerEnv) ? 1 : 0) }); if (isCompleted || this._config.experiment.saveIncompleteResults) { - if (!this._serverMsg.has("__noOutput")) + if (this._saveResults) { this.gui.finishDialogNextStep("saving results"); await this._experiment.save(); @@ -505,43 +569,57 @@ export class PsychoJS await this._serverManager.closeSession(isCompleted); } - // thank participant for waiting and either quit or redirect: - let text = "Thank you for your patience.

"; - text += (typeof message !== "undefined") ? message : "Goodbye!"; - const self = this; - this._gui.dialog({ - message: text, - onOK: () => + // thank participant for waiting, and either quit or redirect: + const onTerminate = () => + { + if (closeWindow) { // close the window: - self._window.close(); + this._window.close(); // remove everything from the browser window: while (document.body.hasChildNodes()) { document.body.removeChild(document.body.lastChild); } + } - // return from fullscreen if we were there: - this._window.closeFullScreen(); + // return from fullscreen if we were there: + this._window.closeFullScreen(); - // redirect if redirection URLs have been provided: - if (isCompleted && typeof self._completionUrl !== "undefined") - { - window.location = self._completionUrl; - } - else if (!isCompleted && typeof self._cancellationUrl !== "undefined") - { - window.location = self._cancellationUrl; - } - }, - }); + this.status = PsychoJS.Status.FINISHED; + // redirect if redirection URLs have been provided: + if (isCompleted && typeof this._completionUrl !== "undefined") + { + window.location = this._completionUrl; + } + else if (!isCompleted && typeof this._cancellationUrl !== "undefined") + { + window.location = this._cancellationUrl; + } + }; + + if (showOK) + { + let text = "Thank you for your patience."; + text += (typeof message !== "undefined") ? message : "Goodbye!"; + this._gui.dialog({ + message: text, + onOK: onTerminate + }); + } + else + { + this._gui.closeDialog(); + onTerminate(); + } } catch (error) { - console.error(error); - this._gui.dialog({ error }); + this.status = PsychoJS.Status.ERROR; + throw { ...response, error }; + // this._gui.dialog({ error: { ...response, error } }); } } @@ -563,7 +641,7 @@ export class PsychoJS { this.status = PsychoJS.Status.CONFIGURING; - // if the experiment is running from an approved hosts, e.e pavlovia.org, + // if the experiment is running from an approved host, e.g pavlovia.org, // we read the configuration file: const experimentUrl = window.location.href; const isHost = this._hosts.some(url => experimentUrl.indexOf(url) === 0); @@ -614,7 +692,7 @@ export class PsychoJS this._config.environment = ExperimentHandler.Environment.SERVER; } - // otherwise we create an ad-hoc configuration: + // otherwise, we create an ad-hoc configuration: else { this._config = { @@ -638,6 +716,12 @@ export class PsychoJS } }); + // note: __noOutput is typically used for automated testing + if (this._serverMsg.has("__noOutput")) + { + this._saveResults = false; + } + this.status = PsychoJS.Status.CONFIGURED; this.logger.debug("configuration:", util.toString(this._config)); } @@ -667,7 +751,7 @@ export class PsychoJS try { const url = "http://www.geoplugin.net/json.gp"; - const response = await fetch(url, { + const getResponse = await fetch(url, { method: "GET", mode: "cors", cache: "no-cache", @@ -675,11 +759,11 @@ export class PsychoJS redirect: "follow", referrerPolicy: "no-referrer" }); - if (response.status !== 200) + if (getResponse.status !== 200) { throw `unable to obtain the IP of the participant: ${response.statusText}`; } - const geoData = await response.json(); + const geoData = await getResponse.json(); this._IP = { IP: geoData.geoplugin_request, @@ -687,7 +771,7 @@ export class PsychoJS latitude: geoData.geoplugin_latitude, longitude: geoData.geoplugin_longitude, }; - this.logger.debug("IP information of the participant: " + util.toString(this._IP)); + this.logger.debug("IP information of the participant:", util.toString(this._IP)); } catch (error) { @@ -707,6 +791,15 @@ export class PsychoJS const self = this; window.onerror = function(message, source, lineno, colno, error) { + // check for ResizeObserver loop limit exceeded error: + // ref: https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded + if (message === "ResizeObserver loop limit exceeded" || + message === "ResizeObserver loop completed with undelivered notifications.") + { + console.warn(message); + return true; + } + console.error(error); document.body.setAttribute( @@ -720,7 +813,14 @@ export class PsychoJS }), ); - self._gui.dialog({ "error": error }); + if (error !== null) + { + self._gui.dialog({"error": error}); + } + else + { + self._gui.dialog({"error": message}); + } return true; }; @@ -729,12 +829,12 @@ export class PsychoJS console.error(error?.reason); if (error?.reason?.stack === undefined) { - // No stack? Error thrown by PsychoJS; stringify whole error + // No stack? Error thrown by PsychoJS: stringify whole error document.body.setAttribute("data-error", JSON.stringify(error?.reason)); } else { - // Yes stack? Error thrown by JS; stringify stack + // Yes stack? Error thrown by JS: stringify stack document.body.setAttribute("data-error", JSON.stringify(error?.reason?.stack)); } self._gui.dialog({ error: error?.reason }); diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index 5fd8654..d522f6f 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -1,9 +1,11 @@ /** - * Manager responsible for the communication between the experiment running in the participant's browser and the pavlovia.org server. + * Manager responsible for the communication between the experiment running in the participant's browser and the + * pavlovia.org server. * * @author Alain Pitiot * @version 2022.2.3 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @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 */ @@ -16,8 +18,10 @@ import { Scheduler } from "../util/Scheduler.js"; import { PsychoJS } from "./PsychoJS.js"; /** - *

This manager handles all communications between the experiment running in the participant's browser and the [pavlovia.org]{@link http://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.

+ *

This manager handles all communications between the experiment running in the participant's browser and the + * [pavlovia.org]{@link http://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.

* * @extends PsychObject */ @@ -54,7 +58,6 @@ export class ServerManager extends PsychObject this._nbLoadedResources = 0; this._setupPreloadQueue(); - this._addAttribute("autoLog", autoLog); this._addAttribute("status", ServerManager.Status.READY); } @@ -126,9 +129,11 @@ export class ServerManager extends PsychObject /** * Open a session for this experiment on the remote PsychoJS manager. * + * @param {Object} params - the open session parameters + * * @returns {Promise} the response */ - openSession() + openSession(params = {}) { const response = { origin: "ServerManager.openSession", @@ -138,13 +143,6 @@ export class ServerManager extends PsychObject this.setStatus(ServerManager.Status.BUSY); - // prepare a POST query: - let data = {}; - if (this._psychoJS._serverMsg.has("__pilotToken")) - { - data.pilotToken = this._psychoJS._serverMsg.get("__pilotToken"); - } - // query the server: const self = this; return new Promise(async (resolve, reject) => @@ -154,7 +152,7 @@ export class ServerManager extends PsychObject const postResponse = await this._queryServerAPI( "POST", `experiments/${this._psychoJS.config.gitlab.projectId}/sessions`, - data, + params, "FORM" ); @@ -212,7 +210,8 @@ export class ServerManager extends PsychObject * @typedef ServerManager.CloseSessionPromise * @property {string} origin the calling method * @property {string} context the context - * @property {Object.} [error] an error message if we could not close the session (e.g. if it has not previously been opened) + * @property {Object.} [error] an error message if we could not close the session (e.g. if it has not + * previously been opened) */ /** * Close the session for this experiment on the remote PsychoJS manager. @@ -315,6 +314,34 @@ export class ServerManager extends PsychObject return pathStatusData.data; } + /** + * Release a resource. + * + * @param {string} name - the name of the resource to release + * @return {boolean} true if a resource with the given name was previously registered with the manager, + * false otherwise. + */ + releaseResource(name) + { + const response = { + origin: "ServerManager.releaseResource", + context: "when releasing resource: " + name, + }; + + const pathStatusData = this._resources.get(name); + + if (typeof pathStatusData === "undefined") + { + return false; + } + + // TODO check the current status: prevent the release of a resources currently downloading + + this._psychoJS.logger.debug(`releasing resource: ${name}`); + this._resources.delete(name); + return true; + } + /** * Get the status of a single resource or the reduced status of an array of resources. * @@ -331,7 +358,8 @@ export class ServerManager extends PsychObject *

* * @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 + * @return {module:core.ServerManager.ResourceStatus} status of the resource if there is only one, or reduced status + * otherwise * @throws {Object.} if at least one of the names is not that of a previously * registered resource */ @@ -433,7 +461,8 @@ export class ServerManager extends PsychObject *
  • If resources is null, then we do not download any resources
  • * * - * @param {String | Array.<{name: string, path: string, download: boolean} | String | Symbol>} [resources=[]] - the list of resources or a single resource + * @param {String | Array.<{name: string, path: string, download: boolean} | String | Symbol>} [resources=[]] - the + * list of resources or a single resource */ async prepareResources(resources = []) { @@ -502,17 +531,68 @@ export class ServerManager extends PsychObject throw "resources must be manually specified when the experiment is running locally: ALL_RESOURCES cannot be used"; } - // convert those resources that are only a string to an object with name and path: + // pre-process the resources: for (let r = 0; r < resources.length; ++r) { - const resource = resources[r]; - if (typeof resource === "string") + // convert those resources that are only a string to an object with name and path: + if (typeof resources[r] === "string") { resources[r] = { - name: resource, - path: resource, + name: resources[r], + path: resources[r], download: true + }; + } + + const resource = resources[r]; + + // deal with survey models: + if ("surveyId" in resource) + { + // survey models can only be downloaded if the experiment is hosted on the pavlovia.org server: + if (this._psychoJS.config.environment !== ExperimentHandler.Environment.SERVER) + { + throw "survey models cannot be downloaded when the experiment is running locally"; } + + // we add a .sid extension so _downloadResources knows what to download the associated + // survey model from the server + resources[r] = { + name: `${resource["surveyId"]}.sid`, + path: resource["surveyId"], + download: true + }; + } + + // deal with survey libraries: + if ("surveyLibrary" in resource) + { + // add the SurveyJS and PsychoJS Survey .js and .css resources: + resources[r] = { + name: "jquery-3.6.0.min.js", + path: "./lib/vendors/jquery-3.6.0.min.js", + download: true + }; + resources.push({ + name: "survey.jquery-1.9.50.min.js", + path: "./lib/vendors/survey.jquery-1.9.50.min.js", + download: true + }); + resources.push({ + name: "survey.defaultV2-1.9.50.min.css", + path: "./lib/vendors/survey.defaultV2-1.9.50.min.css", + download: true + }); + resources.push({ + name: "survey.widgets.css", + path: "./lib/vendors/survey.widgets.css", + download: true + }); + resources.push({ + name: "survey.grey_style.css", + path: "./lib/vendors/survey.grey_style.css", + download: true + }); } } @@ -629,7 +709,7 @@ export class ServerManager extends PsychObject && (path.indexOf("pavlovia.org") === -1) ) { - path = "https://devlovia.org/api/v2/proxy/" + path; + path = "https://pavlovia.org/api/v2/proxy/" + path; } const pathStatusData = this._resources.get(name); @@ -729,7 +809,6 @@ export class ServerManager extends PsychObject { key, value }, "FORM" ); - const uploadDataResponse = await postResponse.json(); if (postResponse.status !== 200) @@ -818,8 +897,10 @@ export class ServerManager extends PsychObject * @param {string} options.tag - additional tag * @param {boolean} [options.waitForCompletion=false] - whether or not to wait for completion * before returning - * @param {boolean} [options.showDialog=false] - whether or not to open a dialog box to inform the participant to wait for the data to be uploaded to the server - * @param {string} [options.dialogMsg="Please wait a few moments while the data is uploading to the server"] - default message informing the participant to wait for the data to be uploaded to the server + * @param {boolean} [options.showDialog=false] - whether or not to open a dialog box to inform the participant to + * wait for the data to be uploaded to the server + * @param {string} [options.dialogMsg="Please wait a few moments while the data is uploading to the server"] - + * default message informing the participant to wait for the data to be uploaded to the server * @returns {Promise} the response */ async uploadAudioVideo({mediaBlob, tag, waitForCompletion = false, showDialog = false, dialogMsg = "Please wait a few moments while the data is uploading to the server"}) @@ -942,6 +1023,136 @@ export class ServerManager extends PsychObject } } + /** + * Asynchronously upload a survey response to the pavlovia server. + * + * @returns {Promise} a promise resolved when the survey response has been uploaded + */ + async uploadSurveyResponse(surveyId, surveyResponse, isComplete) + { + const response = { + origin: "ServerManager.uploadSurveyResponse", + context: `when uploading the survey response for experiment: ${this._psychoJS.config.experiment.fullpath} and survey: ${surveyId}` + }; + + if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER || + this._psychoJS.config.experiment.status !== "RUNNING" || + this._psychoJS._serverMsg.has("__pilotToken")) + { + throw "survey responses can only be uploaded to the server for experiments running on the server"; + } + + this._psychoJS.logger.debug(`uploading a survey response for experiment: ${this._psychoJS.config.experiment.fullpath} and survey: ${surveyId}`); + this.setStatus(ServerManager.Status.BUSY); + + const self = this; + return new Promise(async (resolve, reject) => + { + try + { + const info = this._psychoJS.experiment.extraInfo; + const participant = (typeof info.participant === "string" && info.participant.length > 0) ? + info.participant : + "PARTICIPANT"; + + const postResponse = await this._queryServerAPI( + "POST", + `surveys/${surveyId}`, + { + experimentId: this._psychoJS.config.gitlab.projectId, + sessionToken: this._psychoJS.config.session.token, + participant: participant, + surveyResponse, + isComplete + }, + "JSON" + ); + const uploadDataResponse = await postResponse.json(); + + if (postResponse.status !== 200) + { + throw ('error' in uploadDataResponse) ? uploadDataResponse.error : uploadDataResponse; + } + + self.setStatus(ServerManager.Status.READY); + resolve({ ...response, ...uploadDataResponse }); + } + catch (error) + { + console.error(error); + self.setStatus(ServerManager.Status.ERROR); + reject({...response, error}); + } + }); + } + + /** + * Asynchronously get a survey's experiment parameters from the pavlovia server, and update experimentInfo + * + * @note only those fields not previously defined in experimentInfo are updated + * + * @param surveyId + * @param experimentInfo + * @returns {Promise} a promise resolved when the survey experiment parameters have been downloaded + */ + async getSurveyExperimentParameters(surveyId, experimentInfo) + { + const response = { + origin: "ServerManager.getSurveyExperimentParameters", + context: `when downloading the experiment parameters for survey: ${surveyId}` + }; + + if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER) + { + throw "survey experiment parameters cannot be downloaded when the experiment is running locally"; + } + + this._psychoJS.logger.debug(`downloading the experiment parameters of survey: ${surveyId}`); + this.setStatus(ServerManager.Status.BUSY); + + const self = this; + return new Promise(async (resolve, reject) => + { + try + { + const getResponse = await this._queryServerAPI( + "GET", + `surveys/${surveyId}/experiment` + ); + const getExperimentParametersResponse = await getResponse.json(); + + if (getResponse.status !== 200) + { + throw ('error' in getExperimentParametersResponse) ? getExperimentParametersResponse.error : getExperimentParametersResponse; + } + + if (getExperimentParametersResponse["experimentParameters"] === null) + { + throw "either there is no survey with the given id, or it is not currently active"; + } + + // update the info with the survey experiment parameters: + const experimentParameters = getExperimentParametersResponse['experimentParameters']; + for (const parameter in experimentParameters) + { + if (typeof experimentInfo[parameter] === "undefined") + { + experimentInfo[parameter] = experimentParameters[parameter]; + } + } + + self.setStatus(ServerManager.Status.READY); + resolve({ ...response, ...getExperimentParametersResponse }); + } + catch (error) + { + console.error(error); + self.setStatus(ServerManager.Status.ERROR); + reject({...response, error}); + } + }); + } + /** * List the resources available to the experiment. * @@ -1023,10 +1234,12 @@ export class ServerManager extends PsychObject }); // based on the resource extension either (a) add it to the preload manifest, (b) mark it for - // download by howler, or (c) add it to the document fonts + // download by howler, (c) add it to the document fonts, or (d) download the associated survey model + // from the server const preloadManifest = []; const soundResources = new Set(); const fontResources = []; + const surveyModelResources = []; for (const name of resources) { const nameParts = name.toLowerCase().split("."); @@ -1079,12 +1292,18 @@ export class ServerManager extends PsychObject } } - // font files - else if (["ttf", "otf", "woff", "woff2"].indexOf(pathExtension) > -1) + // font files: + else if (["ttf", "otf", "woff", "woff2","eot"].indexOf(pathExtension) > -1) { fontResources.push(name); } + // survey models: + else if (["sid"].indexOf(extension) > -1) + { + surveyModelResources.push(name); + } + // all other extensions handled by preload.js (download type decided by preload.js): else { @@ -1155,8 +1374,64 @@ export class ServerManager extends PsychObject } } - // start loading resources marked for howler.js: + // start loading the survey models: const self = this; + for (const name of surveyModelResources) + { + const pathStatusData = this._resources.get(name); + pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING; + this.emit(ServerManager.Event.RESOURCE, { + message: ServerManager.Event.DOWNLOADING_RESOURCE, + resource: name, + }); + + try + { + const getResponse = await this._queryServerAPI("GET", `surveys/${pathStatusData.path}/model`); + + const getModelResponse = await getResponse.json(); + + if (getResponse.status !== 200) + { + const error = ("error" in getModelResponse) ? getModelResponse.error : getModelResponse; + throw util.toString(error); + } + + if (getModelResponse["model"] === null) + { + throw "either there is no survey with the given id, or it is not currently active"; + } + + ++self._nbLoadedResources; + + // note: we encode the json model as a string since it will be decoded in Survey.setModel, + // just like the model loaded directly from a resource by preloadJS + pathStatusData.data = new TextEncoder().encode(JSON.stringify(getModelResponse['model'])); + + pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED; + self.emit(ServerManager.Event.RESOURCE, { + message: ServerManager.Event.RESOURCE_DOWNLOADED, + resource: name, + }); + + if (self._nbLoadedResources === resources.size) + { + self.setStatus(ServerManager.Status.READY); + self.emit(ServerManager.Event.RESOURCE, { + message: ServerManager.Event.DOWNLOAD_COMPLETED, + }); + } + } + catch(error) + { + console.error(error); + self.setStatus(ServerManager.Status.ERROR); + throw { ...response, error: `unable to download resource: ${name}: ${util.toString(error)}` }; + } + } + + // start loading resources marked for howler.js: + // TODO load them sequentially, not all at once! for (const name of soundResources) { const pathStatusData = this._resources.get(name); @@ -1368,7 +1643,8 @@ export class ServerManager extends PsychObject /** * Server event * - *

    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).

    + *

    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).

    * * @enum {Symbol} * @readonly diff --git a/src/core/Window.js b/src/core/Window.js index c666e8b..16761a0 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -1,7 +1,7 @@ /** * Window responsible for displaying the experiment stimuli * - * @author Alain Pitiot + * @author Alain Pitiot & Nikita Agafonov * @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 @@ -13,6 +13,7 @@ import { MonotonicClock } from "../util/Clock.js"; import { Color } from "../util/Color.js"; import { PsychObject } from "../util/PsychObject.js"; import { Logger } from "./Logger.js"; +import { hasTouchScreen } from "../util/Util.js"; /** *

    Window displays the various stimuli of the experiment.

    @@ -22,12 +23,29 @@ import { Logger } from "./Logger.js"; */ export class Window extends PsychObject { + /** + * Check whether PsychoJS/Pixi.js is actually using WebGL in the participant's browser, i.e. + * hardware acceleration, rather than software emulation or Pixi.js' canvas fallback. + * + * @return true if WebGL is supported and false if it is not or if it is supported + * only through software emulation + */ + static checkWebGLSupport() + { + // Note: in order to detect whether the participant's browser has hardware acceleration turned off + // we set FAIL_IF_MAJOR_PERFORMANCE_CAVEAT to true. This ensures that the WebGL context creation that + // takes place in PIXI.utils.isWebGLSupported fails if the performance is low, which is typically the case + // with software emulation. + // See details here: https://registry.khronos.org/webgl/specs/latest/1.0/#5.2 + PIXI.settings.FAIL_IF_MAJOR_PERFORMANCE_CAVEAT = true; + return PIXI.utils.isWebGLSupported(); + } + /** * Getter for monitorFramePeriod. * * @name module:core.Window#monitorFramePeriod - * @function - * @public + * @return the estimated monitor frame period */ get monitorFramePeriod() { @@ -164,7 +182,7 @@ export class Window extends PsychObject { // gets updated frame by frame const lastDelta = this.psychoJS.scheduler._lastDelta; - const fps = lastDelta === 0 ? 60.0 : 1000 / lastDelta; + const fps = (lastDelta === 0) ? 60.0 : (1000.0 / lastDelta); return fps; } @@ -419,6 +437,12 @@ export class Window extends PsychObject this._size[0] = window.innerWidth; this._size[1] = window.innerHeight; + if (this._psychoJS._checkWebGLSupport) + { + // see checkWebGLSupport() method for details. + PIXI.settings.FAIL_IF_MAJOR_PERFORMANCE_CAVEAT = true; + } + // create a PIXI renderer and add it to the document: this._renderer = PIXI.autoDetectRenderer({ width: this._size[0], @@ -470,6 +494,17 @@ export class Window extends PsychObject // update the renderer size and the Window's stimuli whenever the browser's size or orientation change: this._resizeCallback = (e) => { + // if the user device is a mobile phone or tablet (we use the presence of a touch screen as a + // proxy), we need to detect whether the change in size is due to the appearance of a virtual keyboard + // in which case we do not want to resize the canvas. This is rather tricky and so we resort to + // the below trick. It would be better to use the VirtualKeyboard API, but it is not widely + // available just yet, as of 2023-06. + const keyboardHeight = 300; + if (hasTouchScreen() && (window.screen.height - window.visualViewport.height) > keyboardHeight) + { + return; + } + Window._resizePixiRenderer(this, e); this._backgroundSprite.width = this._size[0]; this._backgroundSprite.height = this._size[1]; diff --git a/src/data/ExperimentHandler.js b/src/data/ExperimentHandler.js index 2c2bebc..97692b5 100644 --- a/src/data/ExperimentHandler.js +++ b/src/data/ExperimentHandler.js @@ -258,7 +258,7 @@ export class ExperimentHandler extends PsychObject const loop = this._loops[l]; const loopAttributes = ExperimentHandler._getLoopAttributes(loop); - for (let a in loopAttributes) + for (const a in loopAttributes) { if (loopAttributes.hasOwnProperty(a)) { @@ -276,6 +276,7 @@ export class ExperimentHandler extends PsychObject } let data = this._trialsData; + // if the experiment data have to be cleared, we first make a copy of them: if (clear) { @@ -351,6 +352,19 @@ export class ExperimentHandler extends PsychObject } } + /** + * Get the results of the experiment as a .csv string, ready to be uploaded or stored. + * + * @return {string} a .csv representation of the experiment results. + */ + getResultAsCsv() + { + // 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); + return "\ufeff" + XLSX.utils.sheet_to_csv(worksheet); + } + /** * Get the attribute names and values for the current trial of a given loop. *

    Only info relating to the trial execution are returned.

    diff --git a/src/data/Shelf.js b/src/data/Shelf.js index a92507c..4635a8d 100644 --- a/src/data/Shelf.js +++ b/src/data/Shelf.js @@ -648,8 +648,8 @@ export class Shelf extends PsychObject * *

    This is a generic method, typically called from the Shelf helper methods, e.g. getBinaryValue.

    * - * @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 {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} the value diff --git a/src/index.css b/src/index.css index 00f232b..8194d84 100644 --- a/src/index.css +++ b/src/index.css @@ -10,97 +10,50 @@ body { margin: 0; } -/* Project and resource dialogs */ -label, -input, -select { - box-sizing: border-box; - display: block; - padding-bottom: 0.5em; -} - -input.text, -select.text { - margin-bottom: 1em; - padding: 0.5em; - width: 100%; -} - -fieldset { - border: 0; - margin-top: 1em; - padding: 0; -} - -a, -a:active, -a:focus, -a:visited { - color: #007eb7; - outline: 0; -} - -a:hover { - color: #000; -} - -.progress { - box-sizing: border-box; - padding: 0.5em 0; -} - -.logo { - display: block; - margin: 0 auto 1em; - max-height: 20vh; - max-width: 100%; -} - -.ui-dialog { - left: auto !important; - margin: auto; - max-width: 88vw; - position: relative; - top: auto !important; -} - -/* Don't display close button in the top right corner of the box */ -.ui-dialog.no-close .ui-dialog-titlebar-close { - display: none; -} - -.ui-dialog .ui-dialog-content { - margin-top: 1em; - max-height: calc(100vh - 12em) !important; - overflow-y: auto; -} - -.ui-dialog .ui-dialog-buttonpane { - /* Avoid padding related overflow */ - box-sizing: border-box; -} - -@media only screen and (max-width: 1080px) { - .ui-dialog .ui-dialog-buttonpane { - padding-top: 1em; - } -} /* Initialisation message (which will disappear behind the canvas) */ #root::after { - content: "initialising the experiment..."; + content: "initialising..."; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); } -#root.is-ready::after -{ +#root.is-ready::after { content: "" } +/* Project and resource dialogs */ +.dialog-container label, +.dialog-container input, +.dialog-container select { + box-sizing: border-box; + display: block; + padding-bottom: 0.5em; +} + +.dialog-container input.text, +.dialog-container select.text { + margin-bottom: 1em; + padding: 0.5em; + width: 100%; + + height: 34px; + border: 1px solid #767676; + border-radius: 2px; + background: #ffffff; + color: #333; + font-size: 14px; +} + +.dialog-container fieldset { + border: 0; + margin-top: 1em; + padding: 0; +} + .dialog-container, .dialog-overlay { position: fixed; @@ -124,12 +77,19 @@ a:hover { } .dialog-content { + display: flex; + flex-direction: column; + row-gap: 0; + margin: auto; z-index: 2; position: relative; width: 500px; max-width: 88vw; + /*max-height: 90vh;*/ + max-height: 93%; + padding: 0.5em; border-radius: 2px; @@ -141,11 +101,24 @@ a:hover { box-shadow: 1px 1px 3px #555555; } +.dialog-content .scrollable-container { + height: 100%; + padding: 0 0.5em; + + overflow-x: hidden; + overflow-y: auto; +} + +.dialog-content hr { + width: 100%; +} + .dialog-title { padding: 0.5em; margin-bottom: 1em; - background-color: #009900; + background-color: #00dd00; + /*background-color: #009900;*/ border-radius: 2px; } @@ -164,6 +137,11 @@ a:hover { } .dialog-close { + display: flex; + justify-content: center; + align-items: center; + line-height: 1.1em; + position: absolute; top: 0.7em; right: 0.7em; @@ -206,7 +184,7 @@ a:hover { .dialog-button { padding: 0.5em 1em 0.5em 1em; - margin: 0.5em 0.5em 0.5em 0; + /*margin: 0.5em 0.5em 0.5em 0;*/ border: 1px solid #555555; border-radius: 2px; @@ -229,6 +207,13 @@ a:hover { border: 1px solid #000000; } +.dialog-button-group { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + column-gap: 0.5em; +} .disabled { border: 1px solid #AAAAAA; @@ -238,3 +223,28 @@ a:hover { cursor: not-allowed; pointer-events: none; } + +.logo { + display: flex; + flex: 0 1 auto; + height: 100%; + width: auto; + + /*display: block; + margin: 0 auto 1em; + max-height: 20vh; + max-width: 100%;*/ +} + +a, +a:active, +a:focus, +a:visited { + color: #007eb7; + outline: 0; +} + +a:hover { + color: #000; +} + diff --git a/src/sound/AudioClipPlayer.js b/src/sound/AudioClipPlayer.js index 10dd99b..81792f6 100644 --- a/src/sound/AudioClipPlayer.js +++ b/src/sound/AudioClipPlayer.js @@ -53,28 +53,21 @@ export class AudioClipPlayer extends SoundPlayer /** * Determine whether this player can play the given sound. * - * @param {module:sound.Sound} sound - the sound object, which should be an AudioClip - * @return {Object|undefined} an instance of AudioClipPlayer if sound is an AudioClip or undefined otherwise + * @param {module:core.PsychoJS} psychoJS - the PsychoJS instance + * @param {string} value - the sound value, which should be the name of an audio resource + * file + * @return {Object|boolean} argument needed to instantiate a AudioClipPlayer that can play the given sound + * or false otherwise */ - static accept(sound) + static accept(psychoJS, value) { - if (sound.value instanceof AudioClip) + if (value instanceof AudioClip) { - // build the player: - const player = new AudioClipPlayer({ - psychoJS: sound.psychoJS, - audioClip: sound.value, - startTime: sound.startTime, - stopTime: sound.stopTime, - stereo: sound.stereo, - loops: sound.loops, - volume: sound.volume, - }); - return player; + return { audioClip: value }; } // AudioClipPlayer is not an appropriate player for the given sound: - return undefined; + return false; } /** @@ -129,6 +122,23 @@ export class AudioClipPlayer extends SoundPlayer // TODO } + /** + * Set the audio clip. + * + * @param {Object} options.audioClip - the module:sound.AudioClip. + */ + setAudioClip(audioClip) + { + if (audioClip instanceof AudioClip) + { + if (this._audioClip !== undefined) + { + this.stop(); + } + this._audioClip = audioClip; + } + } + /** * Start playing the sound. * diff --git a/src/sound/Sound.js b/src/sound/Sound.js index cf4ef45..145eb78 100644 --- a/src/sound/Sound.js +++ b/src/sound/Sound.js @@ -2,7 +2,7 @@ /** * Sound stimulus. * - * @author Alain Pitiot + * @author Alain Pitiot, Nikita Agafonov * @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 @@ -74,7 +74,6 @@ export class Sound extends PsychObject this._player = undefined; this._addAttribute("win", win); - this._addAttribute("value", value); this._addAttribute("octave", octave); this._addAttribute("secs", secs); this._addAttribute("startTime", startTime); @@ -84,8 +83,9 @@ export class Sound extends PsychObject this._addAttribute("loops", loops); this._addAttribute("autoLog", autoLog); - // identify an appropriate player: - this._getPlayer(); + // note: setValue will identify the appropriate SoundPlayer and possibly instantiate it + // consequently _addAtribute("value") needs to be the last one so the other attributes are already set + this._addAttribute("value", value); this.status = PsychoJS.Status.NOT_STARTED; } @@ -97,7 +97,7 @@ export class Sound extends PsychObject * Repeat calls to play may results in the sounds being played on top of each other.

    * * @param {number} loops how many times to repeat the sound after it plays once. If loops == -1, the sound will repeat indefinitely until stopped. - * @param {boolean} [log= true] whether or not to log + * @param {boolean} [log= true] whether to log */ play(loops, log = true) { @@ -109,7 +109,7 @@ export class Sound extends PsychObject * Stop playing the sound immediately. * * @param {Object} options - * @param {boolean} [options.log= true] - whether or not to log + * @param {boolean} [options.log= true] - whether to log */ stop({ log = true, @@ -134,7 +134,7 @@ export class Sound extends PsychObject * * @param {number} volume - the volume (values should be between 0 and 1) * @param {boolean} [mute= false] - whether or not to mute the sound - * @param {boolean} [log= true] - whether of not to log + * @param {boolean} [log= true] - whether to log */ setVolume(volume, mute = false, log = true) { @@ -147,38 +147,108 @@ export class Sound extends PsychObject } /** - * Set the sound value on demand past initialisation. + * Set the sound value. * * @param {object} sound - a sound instance to replace the current one - * @param {boolean} [log= true] - whether or not to log + * @param {boolean} [log= true] - whether to log */ setSound(sound, log = true) { - if (sound instanceof Sound) + if (!(sound instanceof Sound)) { - this._setAttribute("value", sound.value, log); + throw { + origin: "Sound.setSound", + context: "when setting the sound", + error: "the argument should be an instance of the Sound class.", + }; + } - if (typeof this._player !== "undefined") + this._setAttribute("value", sound.value, log); + + if (typeof this._player !== "undefined") + { + this._player = this._player.constructor.accept(this); + } + + return this; + } + + /** + * Set the sound value. + * + * @param {number|string} [value = "C"] - the sound value + * @param {number} [octave = 4] - the octave corresponding to the tone (if applicable) + * @param {boolean} [log=true] - whether to log + */ + setValue(value = "C", octave = 4, log = true) + { + this._setAttribute("value", value, log); + + const args = { + psychoJS: this._psychoJS, + stereo: this._stereo, + volume: this._volume, + loops: this._loops, + startTime: this._startTime, + stopTime: this._stopTime, + secs: this._secs + } + + let playerArgs = TonePlayer.accept(value, octave); + if (playerArgs) + { + if (this._player instanceof TonePlayer) { - this._player = this._player.constructor.accept(this); + this._player.setTone(value, octave); } + else + { + this._player = new TonePlayer(Object.assign(args, playerArgs)); + } + return; + } - // Be fluent? - return this; + playerArgs = TrackPlayer.accept(this._psychoJS, value); + if (playerArgs) + { + if (this._player instanceof TrackPlayer) + { + this._player.setTrack(value); + } + else + { + this._player = new TrackPlayer(Object.assign(args, playerArgs)); + } + return; + } + + playerArgs = AudioClipPlayer.accept(this._psychoJS, value); + if (playerArgs) + { + if (this._player instanceof AudioClipPlayer) + { + this._player.setAudioClip(value); + } + else + { + this._player = new AudioClipPlayer(Object.assign(args, playerArgs)); + } + return; } throw { - origin: "Sound.setSound", - context: "when replacing the current sound", - error: "invalid input, need an instance of the Sound class.", + origin: "Sound.setValue", + context: "when setting the sound value", + error: "could not find an appropriate player.", }; + } /** * Set the number of loops. * * @param {number} [loops=0] - how many times to repeat the sound after it has played once. If loops == -1, the sound will repeat indefinitely until stopped. - * @param {boolean} [log=true] - whether of not to log + * @param {boolean} [log=true] - whether to log */ setLoops(loops = 0, log = true) { @@ -194,7 +264,7 @@ export class Sound extends PsychObject * Set the duration (in seconds) * * @param {number} [secs=0.5] - duration of the tone (in seconds) If secs == -1, the sound will play indefinitely. - * @param {boolean} [log=true] - whether or not to log + * @param {boolean} [log=true] - whether to log */ setSecs(secs = 0.5, log = true) { diff --git a/src/sound/SoundPlayer.js b/src/sound/SoundPlayer.js index 512c365..77d610c 100644 --- a/src/sound/SoundPlayer.js +++ b/src/sound/SoundPlayer.js @@ -26,22 +26,6 @@ export class SoundPlayer extends PsychObject super(psychoJS); } - /** - * Determine whether this player can play the given sound. - * - * @abstract - * @param {module:sound.Sound} - the sound - * @return {Object|undefined} an instance of the SoundPlayer that can play the sound, or undefined if none could be found - */ - static accept(sound) - { - throw { - origin: "SoundPlayer.accept", - context: "when evaluating whether this player can play a given sound", - error: "this method is abstract and should not be called.", - }; - } - /** * Start playing the sound. * diff --git a/src/sound/TonePlayer.js b/src/sound/TonePlayer.js index 488016a..75c7d08 100644 --- a/src/sound/TonePlayer.js +++ b/src/sound/TonePlayer.js @@ -24,7 +24,7 @@ export class TonePlayer extends SoundPlayer * @memberOf module:sound * @param {Object} options * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {number} [options.duration_s= 0.5] - duration of the tone (in seconds). If duration_s == -1, the sound will play indefinitely. + * @param {number} [options.secs= 0.5] - duration of the tone (in seconds). If secs == -1, the sound will play indefinitely. * @param {string|number} [options.note= 'C4'] - note (if string) or frequency (if number) * @param {number} [options.volume= 1.0] - volume of the tone (must be between 0 and 1.0) * @param {number} [options.loops= 0] - how many times to repeat the tone after it has played once. If loops == -1, the tone will repeat indefinitely until stopped. @@ -32,7 +32,7 @@ export class TonePlayer extends SoundPlayer constructor({ psychoJS, note = "C4", - duration_s = 0.5, + secs = 0.5, volume = 1.0, loops = 0, soundLibrary = TonePlayer.SoundLibrary.TONE_JS, @@ -42,7 +42,7 @@ export class TonePlayer extends SoundPlayer super(psychoJS); this._addAttribute("note", note); - this._addAttribute("duration_s", duration_s); + this._addAttribute("duration_s", secs); this._addAttribute("volume", volume); this._addAttribute("loops", loops); this._addAttribute("soundLibrary", soundLibrary); @@ -66,25 +66,21 @@ export class TonePlayer extends SoundPlayer *

    Note: if TonePlayer accepts the sound but Tone.js is not available, e.g. if the browser is IE11, * we throw an exception.

    * - * @param {module:sound.Sound} sound - the sound - * @return {Object|undefined} an instance of TonePlayer that can play the given sound or undefined otherwise + * @param {string|number} value - potential frequency or note + * @param {number} octave - the octave corresponding to the tone + * @return {Object|boolean} argument needed to instantiate a TonePlayer that can play the given sound + * or false otherwise */ - static accept(sound) + static accept(value, octave) { // if the sound's value is an integer, we interpret it as a frequency: - if (isNumeric(sound.value)) + if (isNumeric(value)) { - return new TonePlayer({ - psychoJS: sound.psychoJS, - note: sound.value, - duration_s: sound.secs, - volume: sound.volume, - loops: sound.loops, - }); + return { note: value } } // if the sound's value is a string, we check whether it is a note: - if (typeof sound.value === "string") + if (typeof value === "string") { // mapping between the PsychoPY notes and the standard ones: let psychopyToToneMap = new Map(); @@ -96,21 +92,15 @@ export class TonePlayer extends SoundPlayer } // check whether the sound's value is a recognised note: - const note = psychopyToToneMap.get(sound.value); + const note = psychopyToToneMap.get(value); if (typeof note !== "undefined") { - return new TonePlayer({ - psychoJS: sound.psychoJS, - note: note + sound.octave, - duration_s: sound.secs, - volume: sound.volume, - loops: sound.loops, - }); + return { note: note + octave }; } } - // TonePlayer is not an appropriate player for the given sound: - return undefined; + // the value does not seem to correspond to a tone we can play: + return false; } /** @@ -126,11 +116,11 @@ export class TonePlayer extends SoundPlayer /** * Set the duration of the tone. * - * @param {number} duration_s - the duration of the tone (in seconds) If duration_s == -1, the sound will play indefinitely. + * @param {number} secs - the duration of the tone (in seconds) If secs == -1, the sound will play indefinitely. */ - setDuration(duration_s) + setDuration(secs) { - this.duration_s = duration_s; + this.duration_s = secs; } /** @@ -172,6 +162,23 @@ export class TonePlayer extends SoundPlayer } } + /** + * Set the note for tone. + * + * @param {string|number} value - potential frequency or note + * @param {number} octave - the octave corresponding to the tone + */ + setTone(value = "C", octave = 4) + { + const args = TonePlayer.accept(value, octave); + this._note = args.note; + + if (typeof this._synth !== "undefined") + { + this._synth.setNote(this._note); + } + } + /** * Start playing the sound. * diff --git a/src/sound/TrackPlayer.js b/src/sound/TrackPlayer.js index 7451ae6..dd9774c 100644 --- a/src/sound/TrackPlayer.js +++ b/src/sound/TrackPlayer.js @@ -8,6 +8,7 @@ */ import { SoundPlayer } from "./SoundPlayer.js"; +import { Howl } from "howler"; /** *

    This class handles the playback of sound tracks.

    @@ -54,34 +55,43 @@ export class TrackPlayer extends SoundPlayer /** * Determine whether this player can play the given sound. * - * @param {module:sound.Sound} sound - the sound, which should be the name of an audio resource - * file - * @return {Object|undefined} an instance of TrackPlayer that can play the given track or undefined otherwise + * @param {string} value - the sound, which should be the name of an audio resource file + * @return {boolean} whether or not value is supported */ - static accept(sound) + static checkValueSupport (value) { - // if the sound's value is a string, we check whether it is the name of a resource: - if (typeof sound.value === "string") + if (typeof value === "string") { - const howl = sound.psychoJS.serverManager.getResource(sound.value); + return true; + } + + return false; + } + + /** + * Determine whether this player can play the given sound. + * + * @param {module:core.PsychoJS} psychoJS - the PsychoJS instance + * @param {string} value - the sound value, which should be the name of an audio resource + * file + * @return {Object|boolean} argument needed to instantiate a TrackPlayer that can play the given sound + * or false otherwise + */ + static accept(psychoJS, value) + { + // value should be a string: + if (typeof value === "string") + { + // check whether the value is the name of a resource: + const howl = psychoJS.serverManager.getResource(value); if (typeof howl !== "undefined") { - // build the player: - const player = new TrackPlayer({ - psychoJS: sound.psychoJS, - howl: howl, - startTime: sound.startTime, - stopTime: sound.stopTime, - stereo: sound.stereo, - loops: sound.loops, - volume: sound.volume, - }); - return player; + return { howl }; } } // TonePlayer is not an appropriate player for the given sound: - return undefined; + return false; } /** @@ -142,6 +152,36 @@ export class TrackPlayer extends SoundPlayer } } + /** + * Set new track to play. + * + * @param {Object|string} track - a track resource name or Howl object (see {@link https://howlerjs.com/}) + */ + setTrack(track) + { + let newHowl = undefined; + + if (typeof track === "string") + { + newHowl = this.psychoJS.serverManager.getResource(track); + } + else if (track instanceof Howl) + { + newHowl = track; + } + + if (newHowl !== undefined) + { + this._howl.once("fade", (id) => + { + this._howl.stop(id); + this._howl.off("end"); + this._howl = newHowl; + }); + this._howl.fade(this._howl.volume(), 0, 17, this._id); + } + } + /** * Start playing the sound. * diff --git a/src/util/Clock.js b/src/util/Clock.js index cd89800..f0e7874 100644 --- a/src/util/Clock.js +++ b/src/util/Clock.js @@ -58,13 +58,12 @@ export class MonotonicClock *

    Note: This is just a convenience wrapper around `Intl.DateTimeFormat()`.

    * * @param {string|array.string} locales - A string with a BCP 47 language tag, or an array of such strings. - * @param {object} options - An object with detailed date and time styling information. + * @param {object} [options] - An object with detailed date and time styling information. * @return {string} The current timestamp in the chosen format. */ - static getDate(locales = "en-CA", optionsMaybe) + static getDate(locales = "en-CA", options) { - const date = new Date(); - const options = Object.assign({ + const dataTimeOptions = Object.assign({ hour12: false, year: "numeric", month: "2-digit", @@ -73,10 +72,10 @@ export class MonotonicClock minute: "numeric", second: "numeric", fractionalSecondDigits: 3, - }, optionsMaybe); - - const dateTimeFormat = new Intl.DateTimeFormat(locales, options); + }, options); + const dateTimeFormat = new Intl.DateTimeFormat(locales, dataTimeOptions); + const date = new Date(); return dateTimeFormat.format(date); } @@ -91,6 +90,7 @@ export class MonotonicClock { // yyyy-mm-dd, hh:mm:ss.sss return MonotonicClock.getDate() + .replaceAll("/","-") // yyyy-mm-dd_hh:mm:ss.sss .replace(", ", "_") // yyyy-mm-dd_hh[h]mm:ss.sss diff --git a/src/util/Scheduler.js b/src/util/Scheduler.js index c7002ec..4bbaf6e 100644 --- a/src/util/Scheduler.js +++ b/src/util/Scheduler.js @@ -117,9 +117,12 @@ export class Scheduler * Start this scheduler. * *

    Note: tasks are run after each animation frame.

    + * + * @return {Promise} a promise resolved when the scheduler stops, e.g. when the experiments finishes */ - async start() + start() { + let shedulerResolve; const self = this; const update = async (timestamp) => { @@ -127,6 +130,7 @@ export class Scheduler if (self._stopAtNextUpdate) { self._status = Scheduler.Status.STOPPED; + shedulerResolve(); return; } @@ -137,6 +141,7 @@ export class Scheduler if (state === Scheduler.Event.QUIT) { self._status = Scheduler.Status.STOPPED; + shedulerResolve(); return; } @@ -155,6 +160,12 @@ export class Scheduler // start the animation: requestAnimationFrame(update); + + // return a promise resolved when the scheduler is stopped: + return new Promise((resolve, _) => + { + shedulerResolve = resolve; + }); } /** diff --git a/src/util/Util.js b/src/util/Util.js index 2b01f96..1e4d2a5 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -8,6 +8,8 @@ * @license Distributed under the terms of the MIT License */ +import seedrandom from "seedrandom"; + /** * Syntactic sugar for Mixins * @@ -55,18 +57,30 @@ export function promiseToTupple(promise) } /** - * Get a Universally Unique Identifier (RFC4122 version 4) + * Get a Universally Unique Identifier (RFC4122 version 4) or a pseudo-uuid based on a root *

    See details here: https://www.ietf.org/rfc/rfc4122.txt

    * + * @param {string} [root] - the root, for string dependent pseudo uuid's * @return {string} the uuid */ -export function makeUuid() +export function makeUuid(root) { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) + // bonafide uuid v4 generator: + if (typeof root === "undefined") { - const r = Math.random() * 16 | 0, v = (c === "x") ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0, v = (c === "x") ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + else + { + // our in-house pseudo uuid generator: + const generator = seedrandom(root); + let digits = generator().toString().substring(2); + digits += generator().toString().substring(2); + return `${digits.substring(0, 8)}-${digits.substring(8, 12)}-4${digits.substring(12, 15)}-8${digits.substring(15, 18)}-${digits.substring(18, 30)}`; + } } /** @@ -308,27 +322,64 @@ export function IsPointInsidePolygon(point, vertices) } /** - * Shuffle an array in place using the Fisher-Yastes's modern algorithm + * Shuffle an array, or a portion of that array, in place using the Fisher-Yastes's modern algorithm *

    See details here: https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm

    * * @param {Object[]} array - the input 1-D array - * @param {Function} [randomNumberGenerator = undefined] - A function used to generated random numbers in the interal [0, 1). Defaults to Math.random + * @param {Function} [randomNumberGenerator= undefined] - A function used to generated random numbers in the interval [0, 1). Defaults to Math.random + * @param [startIndex= undefined] - start index in the array + * @param [endIndex= undefined] - end index in the array * @return {Object[]} the shuffled array */ -export function shuffle(array, randomNumberGenerator = undefined) +export function shuffle(array, randomNumberGenerator = undefined, startIndex = undefined, endIndex = undefined) { - if (randomNumberGenerator === undefined) + // if array is not an array, we return it untouched rather than throwing an exception: + if (!array || !Array.isArray(array)) + { + return array; + } + + if (typeof startIndex === "undefined") + { + startIndex = 0; + } + if (typeof endIndex === "undefined") + { + endIndex = array.length - 1; + } + + if (typeof randomNumberGenerator === "undefined") { randomNumberGenerator = Math.random; } - for (let i = array.length - 1; i > 0; i--) + + for (let i = endIndex; i > startIndex; i--) { const j = Math.floor(randomNumberGenerator() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } + return array; } +/** + * linspace + * + * @name module:util.linspace + * @function + * @public + * @param {Object[]} startValue, stopValue, cardinality + * @return {Object[]} an array from startValue to stopValue with cardinality steps + */ +export function linspace(startValue, stopValue, cardinality) { + var arr = []; + var step = (stopValue - startValue) / (cardinality - 1); + for (var i = 0; i < cardinality; i++) { + arr.push(startValue + (step * i)); + } + return arr; +} + /** * Pick a random value from an array, uses `util.shuffle` to shuffle the array and returns the last value. * @@ -596,6 +647,11 @@ export function toString(object) return object.toString(); } + if (typeof object === "function") + { + return ``; + } + try { const symbolReplacer = (key, value) => @@ -1357,7 +1413,7 @@ export function extensionFromMimeType(mimeType) * the download speed * @return {number} the download speed, in megabits per second */ -export async function getDownloadSpeed(psychoJS, nbDownloads = 1) +export function getDownloadSpeed(psychoJS, nbDownloads = 1) { // url of the image to download and size of the image in bits: // TODO use a variety of files, with different sizes @@ -1374,11 +1430,11 @@ export async function getDownloadSpeed(psychoJS, nbDownloads = 1) { const toc = performance.now(); downloadTimeAccumulator += (toc-tic); - ++ downloadCounter; + ++downloadCounter; if (downloadCounter === nbDownloads) { - const speed_bps = (imageSize_b * nbDownloads) / (downloadTimeAccumulator / 1000); + const speed_bps = (imageSize_b * nbDownloads) / (downloadTimeAccumulator / 1000); resolve(speed_bps / 1024 / 1024); } else @@ -1386,20 +1442,83 @@ export async function getDownloadSpeed(psychoJS, nbDownloads = 1) tic = performance.now(); download.src = `${imageUrl}?salt=${tic}`; } - } + }; download.onerror = (event) => { const errorMsg = `unable to estimate the download speed: ${JSON.stringify(event)}`; psychoJS.logger.error(errorMsg); reject(errorMsg); - } + }; let tic = performance.now(); download.src = `${imageUrl}?salt=${tic}`; }); } +/** + * Dynamically load a css stylesheet. + * + * @param {string} cssId - the unique id + * @param {string} cssPath - the path to the stylesheet + * @return {void} + */ +export function loadCss(cssId, cssPath) +{ + if (!document.getElementById(cssId)) + { + const head = document.getElementsByTagName("head")[0]; + const link = document.createElement("link"); + link.id = cssId; + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = cssPath; + link.media = "all"; + head.appendChild(link); + } +} + +/** + * Whether the user device has a touchscreen, e.g. it is a mobile phone or tablet. + * + * @return {boolean} true if the user device has a touchscreen. + * @note the code below is directly adapted from MDN + */ +export function hasTouchScreen() +{ + let hasTouchScreen = false; + + if ("maxTouchPoints" in navigator) + { + hasTouchScreen = navigator.maxTouchPoints > 0; + } + else if ("msMaxTouchPoints" in navigator) + { + hasTouchScreen = navigator.msMaxTouchPoints > 0; + } + else + { + const mQ = matchMedia?.("(pointer:coarse)"); + if (mQ?.media === "(pointer:coarse)") + { + hasTouchScreen = !!mQ.matches; + } + else if ("orientation" in window) + { + hasTouchScreen = true; + } + else + { + const UA = navigator.userAgent; + hasTouchScreen = + /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) || + /\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA); + } + } + + return hasTouchScreen; +} + /** * Enum that stores possible text directions. * Note that Arabic is the same as RTL but added here to support PsychoPy's diff --git a/src/visual/ButtonStim.js b/src/visual/ButtonStim.js index c007b51..5b4d34f 100644 --- a/src/visual/ButtonStim.js +++ b/src/visual/ButtonStim.js @@ -9,6 +9,7 @@ import { Mouse } from "../core/Mouse.js"; import { TextBox } from "./TextBox.js"; +import * as util from "../util/Util"; /** *

    ButtonStim visual stimulus.

    @@ -32,6 +33,7 @@ export class ButtonStim extends TextBox * @param {Color} [options.borderColor= Color("white")] the border color * @param {Color} [options.borderWidth= 0] the border width * @param {number} [options.opacity= 1.0] - the opacity + * @param {number} [options.depth= 0] - the depth (i.e. the z order) * @param {number} [options.letterHeight= undefined] - the height of the text * @param {boolean} [options.bold= true] - whether or not the text is bold * @param {boolean} [options.italic= false] - whether or not the text is italic @@ -54,11 +56,14 @@ export class ButtonStim extends TextBox borderColor, borderWidth = 0, opacity, + depth, letterHeight, bold = true, italic, autoDraw, autoLog, + boxFn, + multiline } = {}, ) { @@ -77,12 +82,15 @@ export class ButtonStim extends TextBox borderColor, borderWidth, opacity, + depth, letterHeight, + multiline, bold, italic, alignment: "center", autoDraw, autoLog, + boxFn }); this.psychoJS.logger.debug("create a new Button with name: ", name); @@ -112,7 +120,7 @@ export class ButtonStim extends TextBox if (this._autoLog) { - this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${util.toString(this)}`); } } diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index 140a78c..ae040c1 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -3,7 +3,7 @@ * * @author Nikita Agafonov * @version 2021.2.3 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -28,6 +28,21 @@ import radRampShader from "./shaders/radRampShader.frag"; import raisedCosShader from "./shaders/raisedCosShader.frag"; import radialStim from "./shaders/radialShader.frag"; +import defaultQuadVertWGL1 from "./shaders/wgl1/defaultQuad.vert"; +import imageShaderWGL1 from "./shaders/wgl1/imageShader.frag"; +import sinShaderWGL1 from "./shaders/wgl1/sinShader.frag"; +import sqrShaderWGL1 from "./shaders/wgl1/sqrShader.frag"; +import sawShaderWGL1 from "./shaders/wgl1/sawShader.frag"; +import triShaderWGL1 from "./shaders/wgl1/triShader.frag"; +import sinXsinShaderWGL1 from "./shaders/wgl1/sinXsinShader.frag"; +import sqrXsqrShaderWGL1 from "./shaders/wgl1/sqrXsqrShader.frag"; +import circleShaderWGL1 from "./shaders/wgl1/circleShader.frag"; +import gaussShaderWGL1 from "./shaders/wgl1/gaussShader.frag"; +import crossShaderWGL1 from "./shaders/wgl1/crossShader.frag"; +import radRampShaderWGL1 from "./shaders/wgl1/radRampShader.frag"; +import raisedCosShaderWGL1 from "./shaders/wgl1/raisedCosShader.frag"; +import radialStimWGL1 from "./shaders/wgl1/radialShader.frag"; + /** * Grating Stimulus. * @@ -253,6 +268,127 @@ export class GratingStim extends VisualStim } }; + static #SHADERSWGL1 = { + imageShader: { + shader: imageShaderWGL1, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + sin: { + shader: sinShaderWGL1, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + sqr: { + shader: sqrShaderWGL1, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + saw: { + shader: sawShaderWGL1, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + tri: { + shader: triShaderWGL1, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uPeriod: 1.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + sinXsin: { + shader: sinXsinShaderWGL1, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + sqrXsqr: { + shader: sqrXsqrShaderWGL1, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + circle: { + shader: circleShaderWGL1, + uniforms: { + uRadius: 1.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + gauss: { + shader: gaussShaderWGL1, + uniforms: { + uA: 1.0, + uB: 0.0, + uC: 0.16, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + cross: { + shader: crossShaderWGL1, + uniforms: { + uThickness: 0.2, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + radRamp: { + shader: radRampShaderWGL1, + uniforms: { + uSqueeze: 1.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + raisedCos: { + shader: raisedCosShaderWGL1, + uniforms: { + uBeta: 0.25, + uPeriod: 0.625, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, + radialStim: { + shader: radialStimWGL1, + uniforms: { + uFreq: 20.0, + uStep: .0017, + uDX: 1., + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + } + }; + /** * Default size of the Grating Stimuli in pixels. * @@ -279,6 +415,7 @@ export class GratingStim extends VisualStim * @param {number} [options.sf=1.0] - spatial frequency of the function used in grating stimulus * @param {number} [options.phase=0.0] - phase of the function used in grating stimulus, multiples of period of that function * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus + * @param {string} [options.anchor = "center"] - sets the origin point of the stim * @param {number} [options.ori= 0.0] - the orientation (in degrees) * @param {number} [options.size] - the size of the rendered image (DEFAULT_STIM_SIZE_PX will be used if size is not specified) * @param {Color} [options.color= "white"] - Foreground color of the stimulus. Can be String like "red" or "#ff0000" or Number like 0xff0000. @@ -296,6 +433,7 @@ export class GratingStim extends VisualStim win, mask, pos, + anchor, units, sf = 1.0, ori, @@ -313,7 +451,7 @@ export class GratingStim extends VisualStim maskParams } = {}) { - super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog }); + super({ name, win, units, ori, opacity, depth, pos, anchor, size, autoDraw, autoLog }); this._adjustmentFilter = new AdjustmentFilter({ contrast @@ -531,9 +669,21 @@ export class GratingStim extends VisualStim 2 ); geometry.addIndex([0, 1, 2, 0, 2, 3]); - const vertexSrc = defaultQuadVert; - const fragmentSrc = GratingStim.#SHADERS[shaderName].shader; - const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[shaderName].uniforms, uniforms); + let vertexSrc; + let fragmentSrc; + let uniformsFinal; + if (this._win._renderer.context.webGLVersion >= 2) + { + vertexSrc = defaultQuadVert; + fragmentSrc = GratingStim.#SHADERS[shaderName].shader; + uniformsFinal = Object.assign({}, GratingStim.#SHADERS[shaderName].uniforms, uniforms); + } + else + { + vertexSrc = defaultQuadVertWGL1; + fragmentSrc = GratingStim.#SHADERSWGL1[shaderName].shader; + uniformsFinal = Object.assign({}, GratingStim.#SHADERSWGL1[shaderName].uniforms, uniforms); + } const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } @@ -741,9 +891,15 @@ export class GratingStim extends VisualStim } else { - // for some reason setting PIXI.Mesh as .mask doesn't do anything, - // rendering mask to texture for further use. const maskMesh = this._getPixiMeshFromPredefinedShaders(this._mask); + + // Since maskMesh is centered around (0, 0) (has vertices going around it), + // offsetting maskMesh position to properly cover render target texture, + // which created with top-left corner at (0, 0). + maskMesh.position.set(this._size_px[0] * 0.5, this._size_px[1] * 0.5); + + // For some reason setting PIXI.Mesh as .mask doesn't do anything, + // rendering mask to texture for further use. const rt = PIXI.RenderTexture.create({ width: this._size_px[0], height: this._size_px[1], @@ -756,6 +912,8 @@ export class GratingStim extends VisualStim this._pixi.mask = maskSprite; this._pixi.addChild(maskSprite); } + // Since grating mesh is centered around (0, 0), setting mask's anchor to center to properly cover target image. + this._pixi.mask.anchor.set(0.5); } // since _pixi.width may not be immediately available but the rest of the code needs its value diff --git a/src/visual/ImageStim.js b/src/visual/ImageStim.js index f043579..397aa44 100644 --- a/src/visual/ImageStim.js +++ b/src/visual/ImageStim.js @@ -46,11 +46,38 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) * @param {boolean} [options.flipVert= false] - whether or not to flip vertically * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log + * @param {ImageStim.AspectRatioStrategy} [options.aspectRatio= ImageStim.AspectRatioStrategy.VARIABLE] - the aspect ratio handling strategy + * @param {number} [options.blurVal= 0] - the blur value. Goes 0 to as hish as you like. 0 is no blur. */ - constructor({ name, win, image, mask, pos, anchor, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, autoDraw, autoLog } = {}) + constructor({ + name, + win, + image, + mask, + pos, + anchor, + units, + ori, + size, + color, + opacity, + contrast, + texRes, + depth, + interpolate, + flipHoriz, + flipVert, + autoDraw, + autoLog, + aspectRatio, + blurVal + } = {}) { super({ name, win, units, ori, opacity, depth, pos, anchor, size, autoDraw, autoLog }); + // Holds an instance of PIXI blur filter. Used if blur value is passed. + this._blurFilter = undefined; + this._addAttribute( "image", image, @@ -94,6 +121,17 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) false, this._onChange(false, false), ); + this._addAttribute( + "aspectRatio", + aspectRatio, + ImageStim.AspectRatioStrategy.VARIABLE, + this._onChange(true, true), + ); + this._addAttribute( + "blurVal", + blurVal, + 0 + ); // estimate the bounding box: this._estimateBoundingBox(); @@ -234,6 +272,33 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) } } + setBlurVal (blurVal = 0, log = false) + { + this._setAttribute("blurVal", blurVal, log); + if (this._pixi instanceof PIXI.Sprite) + { + if (this._blurFilter === undefined) + { + this._blurFilter = new PIXI.filters.BlurFilter(); + this._blurFilter.blur = blurVal; + } + else + { + this._blurFilter.blur = blurVal; + } + + // this._pixi might get destroyed and recreated again with no filters. + if (this._pixi.filters instanceof Array && this._pixi.filters.indexOf(this._blurFilter) === -1) + { + this._pixi.filters.push(this._blurFilter); + } + else + { + this._pixi.filters = [this._blurFilter]; + } + } + } + /** * Estimate the bounding box. * @@ -276,6 +341,7 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) if (typeof this._pixi !== "undefined") { + this._pixi.filters = null; this._pixi.destroy(true); } this._pixi = undefined; @@ -309,7 +375,18 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) this._texture = new PIXI.Texture(new PIXI.BaseTexture(this._image, texOpts)); } - this._pixi = PIXI.Sprite.from(this._texture); + if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING) + { + const [width_px, _] = util.to_px([this.size[0], 0], this.units, this.win); + this._pixi = PIXI.TilingSprite.from(this._texture, 1, 1); + this._pixi.width = width_px; + this._pixi.height = this._texture.height; + } + else + { + this._pixi = PIXI.Sprite.from(this._texture); + } + // add a mask if need be: if (typeof this._mask !== "undefined") @@ -349,8 +426,24 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) // set the scale: const displaySize = this._getDisplaySize(); const size_px = util.to_px(displaySize, this.units, this.win); - const scaleX = size_px[0] / this._texture.width; - const scaleY = size_px[1] / this._texture.height; + let scaleX = size_px[0] / this._texture.width; + let scaleY = size_px[1] / this._texture.height; + if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_WIDTH) + { + scaleY = scaleX; + } + else if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_HEIGHT) + { + scaleX = scaleY; + } + else if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING) + { + scaleX = 1.0; + scaleY = 1.0; + } + + // note: this calls VisualStim.setAnchor, which properly sets the PixiJS anchor + // from the PsychoPy text format this.anchor = this._anchor; this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; @@ -359,6 +452,11 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); this._pixi.rotation = -this.ori * Math.PI / 180; + if (this._blurVal > 0) + { + this.setBlurVal(this._blurVal); + } + // re-estimate the bounding box, as the texture's width may now be available: this._estimateBoundingBox(); } @@ -383,7 +481,47 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) displaySize = util.to_unit(textureSize, "pix", this.win, this.units); } } + else + { + if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_WIDTH) + { + // use the size of the texture, if we have access to it: + if (typeof this._texture !== "undefined" && this._texture.width > 0) + { + displaySize = [displaySize[0], displaySize[0] * this._texture.height / this._texture.width]; + } + } + else if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_HEIGHT) + { + // use the size of the texture, if we have access to it: + if (typeof this._texture !== "undefined" && this._texture.width > 0) + { + displaySize = [displaySize[1] * this._texture.width / this._texture.height, displaySize[1]]; + } + } + else if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING) + { + // use the size of the texture, if we have access to it: + if (typeof this._texture !== "undefined" && this._texture.width > 0) + { + displaySize = [displaySize[0], this._texture.height]; + } + } + } return displaySize; } } + +/** + * ImageStim Aspect Ratio Strategy. + * + * @enum {Symbol} + * @readonly + */ +ImageStim.AspectRatioStrategy = { + FIT_TO_WIDTH: Symbol.for("FIT_TO_WIDTH"), + HORIZONTAL_TILING: Symbol.for("HORIZONTAL_TILING"), + FIT_TO_HEIGHT: Symbol.for("FIT_TO_HEIGHT"), + VARIABLE: Symbol.for("VARIABLE"), +}; diff --git a/src/visual/ParticleEmitter.js b/src/visual/ParticleEmitter.js new file mode 100644 index 0000000..b0f1ed0 --- /dev/null +++ b/src/visual/ParticleEmitter.js @@ -0,0 +1,331 @@ +/** + * Particle Emitter. + * + * @author Nikita Agafonov + * @version 2023.2.0 + * @copyright (c) 2020-2023 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + */ + +import * as PIXI from "pixi.js-legacy"; + +const DEFAULT_POOL_SIZE = 1024; +const DEFAULT_PARTICLE_WIDTH = 10; +const DEFAULT_PARTICLE_HEIGHT = 10; +const DEFAULT_PARTICLE_LIFETIME = 3; // Seconds. +const DEFAULT_PARTICLE_COLOR = 0xffffff; +const DEFAULT_PARTICLES_PER_SEC = 60; +const DEFAULT_PARTICLE_V = 100; + +class Particle +{ + constructor (cfg) + { + this.x = 0; + this.y = 0; + this.ax = 0; + this.ay = 0; + this.vx = 0; + this.vy = 0; + this.lifeTime = 0; + this.widthChange = 0; + this.heightChange = 0; + this.sprite = undefined; + this.inUse = false; + + if (cfg.particleImage !== undefined) + { + this.sprite = PIXI.Sprite.from(PIXI.Texture.from(cfg.particleImage)); + } + else + { + this.sprite = new PIXI.Sprite(PIXI.Texture.WHITE); + this.sprite.tint = cfg.particleColor || DEFAULT_PARTICLE_COLOR; + } + + // TODO: Should we instead incorporate that in position calculation? + // Consider: accurate spawn position of the particle confined by spawnArea. + this.sprite.anchor.set(0.5); + + this.width = cfg.particleWidth || DEFAULT_PARTICLE_WIDTH; + this.height = cfg.particleHeight || DEFAULT_PARTICLE_HEIGHT; + } + + set width (w) + { + this._width = w; + this.sprite.width = w; + } + + get width () + { + return this._width; + } + + set height (h) + { + this._height = h; + this.sprite.height = h; + } + + get height () + { + return this._height; + } + + update (dt) + { + const dt2 = dt * dt; + + // Update velocity with current acceleration. + this.vx += this.ax * dt; + this.vy += this.ay * dt; + + // Update position with current velocity and acceleration. + this.x = this.x + this.vx * dt + this.ax * dt2 * .5; + this.y = this.y + this.vy * dt + this.ay * dt2 * .5; + + this.sprite.rotation = Math.atan2(this.vy, this.vx); + + this.sprite.x = this.x; + this.sprite.y = this.y; + + if (this.width > 0) + { + this.width = Math.max(0, this.width + this.widthChange); + } + + if (this.height > 0) + { + this.height = Math.max(0, this.height + this.heightChange); + } + this.lifeTime -= dt; + + if (this.width <= 0 && this.height <= 0) + { + this.lifeTime = 0; + } + + if (this.lifeTime <= 0) + { + this.inUse = false; + } + } +} + +export class ParticleEmitter +{ + constructor (cfg = {}) + { + this.x = 0; + this.y = 0; + this._cfg = cfg; + this._particlesPerSec = cfg.particlesPerSec || DEFAULT_PARTICLES_PER_SEC; + this._spawnCoolDown = 0; + this._parentObj = undefined; + this._particlePool = new Array(DEFAULT_POOL_SIZE); + this.setParentObject(cfg.parentObject); + this._fillParticlePool(cfg); + } + + _fillParticlePool (cfg) + { + let i; + for (i = 0; i < this._particlePool.length; i++) + { + this._particlePool[i] = new Particle(cfg); + } + } + + _setupParticle (p) + { + let spawnAreaWidth = this._cfg.spawnAreaWidth || 0; + let spawnAreaHeight = this._cfg.spawnAreaHeight || 0; + + if (this._parentObj !== undefined && this._cfg.useParentSizeAsSpawnArea) + { + spawnAreaWidth = this._parentObj.width; + spawnAreaHeight = this._parentObj.height; + } + + const spawnOffsetX = Math.random() * spawnAreaWidth - spawnAreaWidth * .5; + const spawnOffsetY = Math.random() * spawnAreaHeight - spawnAreaHeight * .5; + const x = this.x + spawnOffsetX; + const y = this.y + spawnOffsetY; + + p.x = x; + p.y = y; + + p.ax = 0; + p.ay = 0; + + if (Number.isFinite(this._cfg.initialVx)) + { + p.vx = this._cfg.initialVx; + } + else if (this._cfg.initialVx instanceof Array && this._cfg.initialVx.length >= 2) + { + p.vx = Math.random() * (this._cfg.initialVx[1] - this._cfg.initialVx[0]) + this._cfg.initialVx[0]; + } + else + { + p.vx = Math.random() * DEFAULT_PARTICLE_V - DEFAULT_PARTICLE_V * .5; + } + + if (Number.isFinite(this._cfg.initialVy)) + { + p.vy = this._cfg.initialVy; + } + else if (this._cfg.initialVy instanceof Array && this._cfg.initialVy.length >= 2) + { + p.vy = Math.random() * (this._cfg.initialVy[1] - this._cfg.initialVy[0]) + this._cfg.initialVy[0]; + } + else + { + p.vy = Math.random() * DEFAULT_PARTICLE_V - DEFAULT_PARTICLE_V * .5; + } + + p.lifeTime = this._cfg.lifeTime || DEFAULT_PARTICLE_LIFETIME; + p.width = this._cfg.particleWidth || DEFAULT_PARTICLE_WIDTH; + p.height = this._cfg.particleHeight || DEFAULT_PARTICLE_HEIGHT; + p.widthChange = this._cfg.particleWidthChange || 0; + p.heightChange = this._cfg.particleHeightChange || 0; + + // TODO: run proper checks here. + if (this._cfg.particleImage) + { + p.sprite.texture = PIXI.Texture.from(this._cfg.particleImage); + } + else + { + p.sprite.texture = PIXI.Texture.WHITE; + } + + if (this._cfg.particleColor !== undefined) + { + p.sprite.tint = this._cfg.particleColor; + } + else + { + p.sprite.tint = 0xffffff; + } + } + + _spawnParticles (n = 0) + { + let i; + for (i = 0; i < this._particlePool.length && n > 0; i++) + { + if (this._particlePool[i].inUse === false) + { + this._particlePool[i].inUse = true; + n--; + + this._setupParticle(this._particlePool[i]); + this._cfg.container.addChild(this._particlePool[i].sprite); + } + } + } + + _getResultingExternalForce () + { + let externalForce = [0, 0]; + if (this._cfg.externalForces instanceof Array) + { + let i; + for (i = 0; i < this._cfg.externalForces.length; i++) + { + externalForce[0] += this._cfg.externalForces[i][0]; + externalForce[1] += this._cfg.externalForces[i][1]; + } + } + + return externalForce; + } + + setParentObject (po) + { + this._parentObj = po; + } + + /** + * @desc: Adds external force which acts on a particle + * @param: f - Array with two elements, first is x component, second is y component. + * It's a vector of length L which sets the direction and the margnitude of the force. + * */ + addExternalForce (f) + { + this._cfg.externalForces.push(f); + } + + removeExternalForce (f) + { + const i = this._cfg.externalForces.indexOf(f); + if (i !== -1) + { + this._cfg.externalForces.splice(i, 1); + } + } + + removeExternalForceByIdx (idx) + { + if (this._cfg.externalForces[idx] !== undefined) + { + this._cfg.externalForces.splice(idx, 1); + } + } + + update (dt) + { + let externalForce; + + // Sync with parent object if it exists. + if (this._parentObj !== undefined) + { + this.x = this._parentObj.x; + this.y = this._parentObj.y; + } + + if (Number.isFinite(this._cfg.positionOffsetX)) + { + this.x += this._cfg.positionOffsetX; + } + + if (Number.isFinite(this._cfg.positionOffsetY)) + { + this.y += this._cfg.positionOffsetY; + } + + if (this._spawnCoolDown <= 0) + { + this._spawnCoolDown = 1 / this._particlesPerSec; + + // Assuming that we have at least 60FPS. + const frameTime = Math.min(dt, 1 / 60); + const particlesPerFrame = Math.ceil(frameTime / this._spawnCoolDown); + this._spawnParticles(particlesPerFrame); + } + else + { + this._spawnCoolDown -= dt; + } + + let i; + for (i = 0; i < this._particlePool.length; i++) + { + if (this._particlePool[i].inUse) + { + externalForce = this._getResultingExternalForce(); + this._particlePool[i].ax = externalForce[0]; + this._particlePool[i].ay = externalForce[1]; + this._particlePool[i].update(dt); + } + + // Check if particle should be removed. + if (this._particlePool[i].lifeTime <= 0 && this._particlePool[i].sprite.parent) + { + this._cfg.container.removeChild(this._particlePool[i].sprite); + } + } + } +} diff --git a/src/visual/Progress.js b/src/visual/Progress.js new file mode 100644 index 0000000..0d27771 --- /dev/null +++ b/src/visual/Progress.js @@ -0,0 +1,162 @@ +import * as PIXI from "pixi.js-legacy"; +import * as util from "../util/Util.js"; +import { Color } from "../util/Color.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import { VisualStim } from "./VisualStim.js"; + +export class Progress extends VisualStim +{ + constructor ( + { + name, + win, + units = "pix", + ori, + opacity, + depth, + pos, + anchor = "left", + size = [300, 30], + clipMask, + autoDraw, + autoLog, + progress = 1, + type, + fillColor, + fillTexture + }) + { + super({ + name, + win, + units, + ori, + opacity, + depth, + pos, + anchor, + size, + clipMask, + autoDraw, + autoLog + }); + + this._addAttribute("progress", progress, 0); + this._addAttribute("type", type, PROGRESS_TYPES.BAR); + this._addAttribute("fillColor", fillColor, "lightgreen"); + this._addAttribute("fillTexture", fillTexture, PIXI.Texture.WHITE); + + if (this._autoLog) + { + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + } + } + + /** + * Setter for the progress attribute. + */ + setProgress (progress = 0, log = false) + { + this._setAttribute("progress", Math.min(1.0, Math.max(0.0, progress)), log); + if (this._pixi !== undefined) + { + this._pixi.clear(); + const size_px = util.to_px(this._size, this._units, this._win); + const progressWidth = size_px[0] * this._progress; + if (this._fillTexture) + { + let t = PIXI.Texture.WHITE; + if (typeof this._fillTexture === "string") + { + t = PIXI.Texture.from(this._fillTexture); + t.baseTexture.scaleMode = PIXI.SCALE_MODES.NEAREST; + } + this._pixi.beginTextureFill({ + texture: t + }); + } + else + { + this._pixi.beginFill(new Color(this._fillColor).int, this._opacity); + } + + if (this._type === PROGRESS_TYPES.BAR) + { + this._pixi.drawRect(0, 0, progressWidth, size_px[1]); + } + + this._pixi.endFill(); + + // TODO: is there a better way to ensure anchor works? + this.anchor = this._anchor; + } + } + + /** + * Estimate the bounding box. + * + * @override + * @protected + */ + _estimateBoundingBox() + { + let boundingBox = new PIXI.Rectangle(0, 0, 0, 0); + const anchorNum = this._anchorTextToNum(this._anchor); + const pos_px = util.to_px(this._pos, this._units, this._win); + const size_px = util.to_px(this._size, this._units, this._win); + boundingBox.x = pos_px[ 0 ] - anchorNum[ 0 ] * size_px[ 0 ]; + boundingBox.y = pos_px[ 1 ] - anchorNum[ 1 ] * size_px[ 1 ]; + boundingBox.width = size_px[ 0 ]; + boundingBox.height = size_px[ 1 ]; + + this._boundingBox = boundingBox; + } + + /** + * Update the stimulus, if necessary. + * + * @protected + */ + _updateIfNeeded() + { + // TODO: figure out what is the error with estimateBoundBox on resize? + if (!this._needUpdate) + { + return; + } + this._needUpdate = false; + + // update the PIXI representation, if need be: + if (this._needPixiUpdate) + { + this._needPixiUpdate = false; + + if (typeof this._pixi !== "undefined") + { + this._pixi.destroy(true); + } + this._pixi = new PIXI.Graphics(); + // TODO: Should we do this? + // this._pixi.lineStyle(this._lineWidth, this._lineColor.int, this._opacity, 0.5); + + // TODO: Should just .setProgress() be called? + this.setProgress(this._progress); + + this._pixi.scale.y = -1; + this._pixi.zIndex = -this._depth; + this.anchor = this._anchor; + } + + // set polygon position and rotation: + this._pixi.position = to_pixiPoint(this._pos, this._units, this._win); + this._pixi.rotation = -this.ori * Math.PI / 180.0; + + this._estimateBoundingBox(); + } +} + +export const PROGRESS_TYPES = +{ + BAR: 0, + CIRCLE: 1 +} diff --git a/src/visual/ShapeStim.js b/src/visual/ShapeStim.js index 49b1049..307ff8e 100644 --- a/src/visual/ShapeStim.js +++ b/src/visual/ShapeStim.js @@ -385,4 +385,29 @@ ShapeStim.KnownShapes = { [-0.39, 0.31], [-0.09, 0.18], ], + + triangle: [ + [+0.0, 0.5], // Point + [-0.5, -0.5], // Bottom left + [+0.5, -0.5], // Bottom right + ], + + rectangle: [ + [-.5, .5], // Top left + [ .5, .5], // Top right + [ .5, -.5], // Bottom left + [-.5, -.5], // Bottom right + ], + + arrow: [ + [0.0, 0.5], + [-0.5, 0.0], + [-1/6, 0.0], + [-1/6, -0.5], + [1/6, -0.5], + [1/6, 0.0], + [0.5, 0.0], + ], }; +// Alias some names for convenience +ShapeStim.KnownShapes['star'] = ShapeStim.KnownShapes['star7'] diff --git a/src/visual/Survey.js b/src/visual/Survey.js new file mode 100644 index 0000000..57bf41f --- /dev/null +++ b/src/visual/Survey.js @@ -0,0 +1,1210 @@ +/** + * Survey Stimulus. + * + * @author Alain Pitiot and Nikita Agafonov + * @version 2022.3 + * @copyright (c) 2023 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + */ + +import * as PIXI from "pixi.js-legacy"; +import { VisualStim } from "./VisualStim.js"; +import * as util from "../util/Util.js"; +import {Clock} from "../util/Clock.js"; +import {ExperimentHandler} from "../data/ExperimentHandler.js"; + +// PsychoJS SurveyJS extensions: +import registerSelectBoxWidget from "./survey/widgets/SelectBox.js"; +import registerSliderWidget from "./survey/widgets/SliderWidget.js"; +import registerSideBySideMatrix from "./survey/widgets/SideBySideMatrix.js"; +import registerMaxDiffMatrix from "./survey/widgets/MaxDiffMatrix.js"; +import registerSliderStar from "./survey/widgets/SliderStar.js"; +import MatrixBipolar from "./survey/components/MatrixBipolar.js"; +import DropdownExtensions from "./survey/components/DropdownExtensions.js"; +import customExpressionFunctionsArray from "./survey/extensions/customExpressionFunctions.js"; + + + +/** + * Survey Stimulus. + * + * @extends VisualStim + */ +export class Survey extends VisualStim +{ + static SURVEY_EXPERIMENT_PARAMETERS = ["surveyId", "showStartDialog", "showEndDialog", "completionUrl", "cancellationUrl", "quitOnEsc"]; + + static SURVEY_FLOW_PLAYBACK_TYPES = + { + DIRECT: "QUESTION_BLOCK", + CONDITIONAL: "IF_THEN_ELSE_GROUP", + EMBEDDED_DATA: "VARIABLES", + RANDOMIZER: "RANDOM_GROUP", + SEQUENTIAL: "SEQUENTIAL_GROUP", + ENDSURVEY: "END" + }; + + static CAPTIONS = + { + NEXT: "Next" + }; + + static SURVEY_COMPLETION_CODES = + { + NORMAL: 0, + SKIP_TO_END_OF_BLOCK: 1, + SKIP_TO_END_OF_SURVEY: 2 + }; + + static NODE_EXIT_CODES = + { + NORMAL: 0, + BREAK_FLOW: 1 + }; + + /** + * @memberOf module:visual + * @param {Object} options + * @param {String} options.name - the name used when logging messages from this stimulus + * @param {Window} options.win - the associated Window + * @param {string} [options.surveyId] - the survey id + * @param {Object | string} [options.model] - the survey model + * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus + * @param {number} [options.ori= 0.0] - the orientation (in degrees) + * @param {number} [options.size] - the size of the rendered survey + * @param {number} [options.depth= 0] - the depth (i.e. the z order) + * @param {boolean} [options.autoDraw= false] - whether the stimulus should be automatically drawn + * on every frame flip + * @param {boolean} [options.autoLog= false] - whether to log + */ + constructor({ name, win, model, surveyId, pos, units, ori, size, depth, autoDraw, autoLog } = {}) + { + super({ name, win, units, ori, depth, pos, size, autoDraw, autoLog }); + + // Storing all existing signaturePad questions to properly handle their resize. + // Unfortunately signaturepad question type can't handle resizing properly by itself. + this._signaturePads = []; + + // whether the user is done with the survey, independently of whether the survey is completed: + this.isFinished = false; + + // accumulated completion flag updated after each survey node is completed + // note: this make it possible to track completion as we move through the survey flow. + // _isCompletedAll will be flipped to false whenever a survey node is not completed + this._isCompletedAll = true; + + // timestamps associated to each question: + this._questionAnswerTimestamps = {}; + // timestamps clock: + this._questionAnswerTimestampClock = new Clock(); + + this._overallSurveyResults = {}; + this._surveyData = undefined; + this._surveyModel = undefined; + this._expressionsRunner = undefined; + this._lastPageSwitchHandledIdx = -1; + this._variables = {}; + + this._surveyRunningPromise = undefined; + this._surveyRunningPromiseResolve = undefined; + this._surveyRunningPromiseReject = undefined; + // callback triggered when the user is done with the survey: nothing to do by default + this._onFinishedCallback = () => {}; + + // init SurveyJS: + this._initSurveyJS(); + + // default size: + if (typeof size === "undefined") + { + this.size = (this.unit === "norm") ? [2.0, 2.0] : [1.0, 1.0]; + } + + this._addAttribute( + "model", + model + ); + + // the default surveyId is an uuid based on the experiment id (or name) and the survey name: + // this way, it is always the same within a given experiment + this._hasSelfGeneratedSurveyId = (typeof surveyId === "undefined"); + const defaultSurveyId = (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER) ? + util.makeUuid(`${name}@${this._psychoJS.config.gitlab.projectId}`) : + util.makeUuid(`${name}@${this._psychoJS.config.experiment.name}`); + this._addAttribute( + "surveyId", + surveyId, + defaultSurveyId + ); + + + // estimate the bounding box: + this._estimateBoundingBox(); + + if (this._autoLog) + { + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + } + } + + get isCompleted () + { + return this.isFinished && this._isCompletedAll; + } + + /** + * Setter for the model attribute. + * + * @param {Object | string} model - the survey model + * @param {boolean} [log= false] - whether to log + * @return {void} + */ + setModel(model, log = false) + { + const response = { + origin: "Survey.setModel", + context: `when setting the model of Survey: ${this._name}`, + }; + + try + { + // model is undefined: that's fine, but we raise a warning in case this is a symptom of an actual problem + if (typeof model === "undefined") + { + this.psychoJS.logger.warn(`setting the model of Survey: ${this._name} with argument: undefined.`); + this.psychoJS.logger.debug(`set the model of Survey: ${this._name} as: undefined`); + } + else + { + // model is a string: it should be the name of a resource, which we load + if (typeof model === "string") + { + const encodedModel = this.psychoJS.serverManager.getResource(model); + const decodedModel = new TextDecoder("utf-8").decode(encodedModel); + model = JSON.parse(decodedModel); + } + + // model should now be an object: + if (typeof model !== "object") + { + throw "model is neither the name of a resource nor an object"; + } + + // if model is a straight-forward SurveyJS model, instead of a Pavlovia Survey super-flow model, + // convert it: + if (!('surveyFlow' in model)) + { + model = { + surveys: [model], + embeddedData: [], + surveysMap: {}, + questionMapsBySurvey: {}, + surveyFlow: { + name: "root", + type: "SEQUENTIAL_GROUP", + nodes: [{ + type: "QUESTION_BLOCK", + surveyIdx: 0 + }] + }, + + surveySettings: { showPrevButton: false }, + + surveyRunLogic: {}, + inQuestionRandomization: {}, + questionsOrderRandomization: [], + questionSkipLogic: {}, + + questionsConverted: -1, + questionsTotal: -1, + logs: [] + }; + + this.psychoJS.logger.debug(`converted the legacy model to the new super-flow model: ${JSON.stringify(model)}`); + } + + this._surveyData = model; + this._setAttribute("model", model, log); + this._onChange(true, true)(); + } + } + catch (error) + { + throw { ...response, error }; + } + } + + /** + * Setter for the surveyId attribute. + * + * @param {string} surveyId - the survey Id + * @param {boolean} [log= false] - whether to log + * @return {void} + */ + setSurveyId(surveyId, log = false) + { + this._setAttribute("surveyId", surveyId, log); + + // only update the model if a genuine surveyId was given as parameter to the Survey: + if (!this._hasSelfGeneratedSurveyId) + { + this.setModel(`${surveyId}.sid`, log); + } + } + + /** + * Set survey variables. + * + * @param {Object} variables - an object with a number of variable name/variable value pairs + * @param {string[]} [excludedNames={}] - excluded variable names + * @return {void} + */ + setVariables(variables, excludedNames) + { + // filter the variables and set them: + // const filteredVariables = {}; + // for (const name in variables) + // { + // if (excludedNames.indexOf(name) === -1) + // { + // filteredVariables[name] = variables[name]; + // this._surveyModel.setVariable(name, variables[name]); + // } + // } + + // // set the values: + // this._surveyModel.mergeData(filteredVariables); + + for (const name in variables) + { + if (excludedNames.indexOf(name) === -1) + { + this._variables[name] = variables[name]; + // this._surveyData.variables[name] = variables[name]; + } + } + } + + /** + * Evaluate an expression, taking into account the survey responses. + * + * @param {string} expression - the expression to evaluate + * @returns {any} the evaluated expression + */ + evaluateExpression(expression) + { + if (typeof expression === "undefined" || typeof this._surveyModel === "undefined") + { + return undefined; + } + + // modify the expression when it is a simple URL, without variables + // i.e. when there is no quote and no brackets + if (expression.indexOf("'") === -1 && expression.indexOf("{") === -1) + { + expression = `'${expression}'`; + } + + return this._surveyModel.runExpression(expression); + } + + /** + * Add a callback that will be triggered when the participant finishes the survey. + * + * @param callback - callback triggered when the participant finishes the survey + * @return {void} + */ + onFinished(callback) + { + if (typeof this._surveyData === "undefined") + { + throw { + origin: "Survey.onFinished", + context: "when setting a callback triggered when the participant finishes the survey", + error: "the survey does not have a model" + }; + } + + // note: we cannot simply add the callback to surveyModel.onComplete since we first need + // to run _onSurveyComplete in order to collect data, estimate whether the survey is complete, etc. + if (typeof callback === "function") + { + this._onFinishedCallback = callback; + } + // this._surveyModel.onComplete.add(callback); + } + + /** + * Get the survey response. + */ + getResponse() + { + // if (typeof this._surveyModel === "undefined") + // { + // return {}; + // } + + // return this._surveyModel.data; + + return this._overallSurveyResults; + } + + /** + * Upload the survey response to the pavlovia.org server. + * + * @returns {Promise} a promise resolved when the survey response + * has been saved + */ + save() + { + this._psychoJS.logger.info("[PsychoJS] Save survey response."); + + // get the survey response and complement it with experimentInfo fields: + const response = this.getResponse(); + for (const field in this.psychoJS.experiment.extraInfo) + { + if (Survey.SURVEY_EXPERIMENT_PARAMETERS.indexOf(field) === -1) + { + response[field] = this.psychoJS.experiment.extraInfo[field]; + } + } + + // add timing information: + for (const question in this._questionAnswerTimestamps) + { + response[`${question}_rt`] = this._questionAnswerTimestamps[question].timestamp; + } + + // sort the questions and question response times alphabetically: + const sortedResponses = Object.keys(response).sort().reduce( (sorted, key) => + { + sorted[key] = response[key]; + return sorted; + }, + {} + ); + + // if the response cannot be uploaded, e.g. the experiment is running locally, or + // if it is piloting mode, then we offer the response as a file for download: + if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER || + this._psychoJS.config.experiment.status !== "RUNNING" || + this._psychoJS._serverMsg.has("__pilotToken")) + { + const filename = `survey_${this._surveyId}.json`; + const blob = new Blob([JSON.stringify(sortedResponses)], { type: "application/json" }); + + const anchor = document.createElement("a"); + anchor.href = window.URL.createObjectURL(blob); + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + + return Promise.resolve({}); + } + + // otherwise, we do upload the survey response + // note: if the surveyId was self-generated instead of being a parameter of the constructor, + // we need to also upload the survey model, as a new survey might need to be created on the fly + // by the server for this experiment. + if (!this._hasSelfGeneratedSurveyId) + { + return this._psychoJS.serverManager.uploadSurveyResponse( + this._surveyId, sortedResponses, this.isCompleted + ); + } + else + { + return this._psychoJS.serverManager.uploadSurveyResponse( + this._surveyId, sortedResponses, this.isCompleted, this._surveyData + ); + } + } + + /** + * Hide this stimulus on the next frame draw. + * + * @override + * @note We over-ride MinimalStim.hide such that we can remove the survey DOM element + */ + hide() + { + const surveyDiv = document.getElementById(this._surveyDivId); + if (surveyDiv !== null) + { + document.body.removeChild(surveyDiv); + } + + super.hide(); + } + + /** + * Estimate the bounding box. + * + * @override + * @protected + */ + _estimateBoundingBox() + { + this._boundingBox = new PIXI.Rectangle( + this._pos[0] - this._size[0] / 2, + this._pos[1] - this._size[1] / 2, + this._size[0], + this._size[1], + ); + + // TODO take the orientation into account + } + + /** + * Update the stimulus, if necessary. + * + * @protected + */ + _updateIfNeeded() + { + if (!this._needUpdate) + { + return; + } + this._needUpdate = false; + + // update the PIXI representation, if need be: + if (this._needPixiUpdate) + { + this._needPixiUpdate = false; + + // if a survey div does not exist, create it: + if (document.getElementById(this._surveyDivId) === null) + { + document.body.insertAdjacentHTML("beforeend", `
    `) + } + + // start the survey flow: + if (typeof this._surveyData !== "undefined") + { + // this._startSurvey(surveyId, this._surveyModel); + // jQuery(`#${surveyId}`).Survey({model: this._surveyModel}); + + this._runSurveyFlow(this._surveyData.surveyFlow, this._surveyData); + } + } + + // TODO change the position, scale, anchor, z-index, etc. + // TODO update the size, taking into account the actual size of the survey + /* + this._pixi.zIndex = -this._depth; + this._pixi.alpha = this.opacity; + + // set the scale: + const displaySize = this._getDisplaySize(); + const size_px = util.to_px(displaySize, this.units, this.win); + const scaleX = size_px[0] / this._texture.width; + const scaleY = size_px[1] / this._texture.height; + this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; + this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; + + // set the position, rotation, and anchor (image centered on pos): + this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); + this._pixi.rotation = -this.ori * Math.PI / 180; + this._pixi.anchor.x = 0.5; + this._pixi.anchor.y = 0.5; +*/ + } + + /** + * Register custom SurveyJS expression functions. + * + * @protected + * @return {void} + */ + _registerCustomExpressionFunctions (Survey, customFuncs = []) + { + for (let i = 0; i < customFuncs.length; i++) + { + Survey.FunctionFactory.Instance.register(customFuncs[i].func.name, customFuncs[i].func, customFuncs[i].isAsync); + } + } + + /** + * Register SurveyJS widgets. + * + * @protected + * @return {void} + */ + _registerWidgets(Survey) + { + registerSelectBoxWidget(Survey); + registerSliderWidget(Survey); + registerSideBySideMatrix(Survey); + registerMaxDiffMatrix(Survey); + registerSliderStar(Survey); + + // load the widget style: + // TODO + // util.loadCss("./survey/css/widgets.css"); + } + + /** + * Register custom Survey properties. Usially these are relevant for different question types. + * + * @protected + * @return {void} + */ + _registerCustomSurveyProperties(Survey) + { + MatrixBipolar.registerSurveyProperties(Survey); + Survey.Serializer.addProperty("signaturepad", { + name: "maxSignatureWidth", + type: "number", + default: 500 + }); + } + + _registerCustomComponentCallbacks(surveyModel) + { + MatrixBipolar.registerModelCallbacks(surveyModel); + DropdownExtensions.registerModelCallbacks(surveyModel); + } + + /** + * Callback triggered whenever the participant answer a question. + * + * @param survey + * @param questionData + * @protected + */ + _onQuestionValueChanged(survey, questionData) + { + if (typeof this._questionAnswerTimestamps[questionData.name] === "undefined") + { + this._questionAnswerTimestamps[questionData.name] = { + timestamp: 0 + }; + } + this._questionAnswerTimestamps[questionData.name].timestamp = this._questionAnswerTimestampClock.getTime(); + } + +/* + // This probably needs to be moved to some kind of utils.js. + // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle + _FisherYatesShuffle (targetArray = []) + { + // Copying array to preserve initial data. + const out = Array.from(targetArray); + const len = targetArray.length; + let i, j, k; + for (i = len - 1; i >= 1; i--) + { + j = Math.floor(Math.random() * (i + 1)); + k = out[j]; + out[j] = out[i]; + out[i] = k; + } + + return out; + } + + // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle + _InPlaceFisherYatesShuffle (inOutArray = [], startIdx, endIdx) + { + // Shuffling right in the input array. + let i, j, k; + for (i = endIdx; i >= startIdx; i--) + { + j = Math.floor(Math.random() * (i + 1)); + k = inOutArray[j]; + inOutArray[j] = inOutArray[i]; + inOutArray[i] = k; + } + + return inOutArray; + } +*/ + + _composeModelWithRandomizedQuestions (surveyModel, inBlockRandomizationSettings) + { + let t = performance.now(); + // Qualtrics's in-block randomization ignores presense of page breaks within the block. + // Hence creating a fresh survey data object with shuffled question order. + let questions = []; + let questionsMap = {}; + let newSurveyModel = + { + pages:[{ elements: new Array(inBlockRandomizationSettings.questionsPerPage) }] + }; + for (let i = 0; i < surveyModel.pages.length; i++) + { + for (let j = 0; j < surveyModel.pages[i].elements.length; j++) + { + questions.push(surveyModel.pages[i].elements[j]); + const k = questions.length - 1; + questionsMap[questions[k].name] = questions[k]; + } + } + + if (inBlockRandomizationSettings.layout.length > 0) + { + let j = 0; + let k = 0; + let curPage = 0; + let curElement = 0; + + const shuffledSet0 = util.shuffle(Array.from(inBlockRandomizationSettings.set0)); + const shuffledSet1 = util.shuffle(Array.from(inBlockRandomizationSettings.set1)); + // const shuffledSet0 = this._FisherYatesShuffle(inBlockRandomizationSettings.set0); + // const shuffledSet1 = this._FisherYatesShuffle(inBlockRandomizationSettings.set1); + for (let i = 0; i < inBlockRandomizationSettings.layout.length; i++) + { + // Create new page if questionsPerPage reached. + if (curElement === inBlockRandomizationSettings.questionsPerPage) + { + newSurveyModel.pages.push({ elements: new Array(inBlockRandomizationSettings.questionsPerPage) }); + curPage++; + curElement = 0; + } + + if (inBlockRandomizationSettings.layout[i] === "set0") + { + newSurveyModel.pages[curPage].elements[curElement] = questionsMap[shuffledSet0[j]]; + j++; + } + else if (inBlockRandomizationSettings.layout[i] === "set1") + { + newSurveyModel.pages[curPage].elements[curElement] = questionsMap[shuffledSet1[k]]; + k++; + } + else + { + newSurveyModel.pages[curPage].elements[curElement] = questionsMap[inBlockRandomizationSettings.layout[i]]; + } + curElement++; + } + } + else if (inBlockRandomizationSettings.showOnly > 0) + { + // TODO: Check if there can be questionsPerPage applicable in this case. + const shuffledQuestions = util.shuffle(Array.from(questions)); + // shuffledQuestions = this._FisherYatesShuffle(questions); + newSurveyModel.pages[0].elements = shuffledQuestions.splice(0, inBlockRandomizationSettings.showOnly); + } + else { + // TODO: Check if there can be questionsPerPage applicable in this case. + newSurveyModel.pages[0].elements = util.shuffle(Array.from(questions)); + // newSurveyModel.pages[0].elements = this._FisherYatesShuffle(questions); + } + console.log("model recomposition took", performance.now() - t); + console.log("recomposed model:", newSurveyModel); + return newSurveyModel; + } + + _applyInQuestionRandomization (questionData, inQuestionRandomizationSettings, surveyData) + { + let t = performance.now(); + let choicesFieldName; + let valueFieldName; + if (questionData.rows !== undefined) + { + choicesFieldName = "rows"; + valueFieldName = "value"; + } + else if (questionData.choices !== undefined) + { + choicesFieldName = "choices"; + valueFieldName = "value"; + } + else if (questionData.items !== undefined) + { + choicesFieldName = "items"; + valueFieldName = "name"; + } + else + { + console.log("[Survey runner]: Uknown choicesFieldName for", questionData); + } + + if (inQuestionRandomizationSettings.randomizeAll) + { + questionData[choicesFieldName] = util.shuffle(Array.from(questionData[choicesFieldName])); + // questionData[choicesFieldName] = this._FisherYatesShuffle(questionData[choicesFieldName]); + // Handle dynamic choices. + } + else if (inQuestionRandomizationSettings.showOnly > 0) + { + questionData[choicesFieldName] = util.shuffle(Array.from(questionData[choicesFieldName]).splice(0, inQuestionRandomizationSettings.showOnly)); + // questionData[choicesFieldName] = this._FisherYatesShuffle(questionData[choicesFieldName]).splice(0, inQuestionRandomizationSettings.showOnly); + } + else if (inQuestionRandomizationSettings.reverse) + { + questionData[choicesFieldName] = Math.round(Math.random()) === 1 ? questionData[choicesFieldName].reverse() : questionData[choicesFieldName]; + } + else if (inQuestionRandomizationSettings.layout.length > 0) + { + const initialChoices = questionData[choicesFieldName]; + let choicesMap = {}; + // TODO: generalize further i.e. figure out how to calculate the length of array based on availability of sets. + const setIndices = [0, 0, 0]; + let i; + for (i = 0; i < questionData[choicesFieldName].length; i++) + { + choicesMap[questionData[choicesFieldName][i][valueFieldName]] = questionData[choicesFieldName][i]; + } + + // Creating new array of choices to which we're going to write from randomized/reversed sets. + questionData[choicesFieldName] = new Array(inQuestionRandomizationSettings.layout.length); + const shuffledSet0 = util.shuffle(Array.from(inQuestionRandomizationSettings.set0)); + const shuffledSet1 = util.shuffle(Array.from(inQuestionRandomizationSettings.set1)); + // const shuffledSet0 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set0); + // const shuffledSet1 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set1); + const reversedSet = Math.round(Math.random()) === 1 ? inQuestionRandomizationSettings.reverseOrder.reverse() : inQuestionRandomizationSettings.reverseOrder; + for (i = 0; i < inQuestionRandomizationSettings.layout.length; i++) + { + if (inQuestionRandomizationSettings.layout[i] === "set0") + { + questionData[choicesFieldName][i] = choicesMap[shuffledSet0[ setIndices[0] ]]; + setIndices[0]++; + } + else if (inQuestionRandomizationSettings.layout[i] === "set1") + { + questionData[choicesFieldName][i] = choicesMap[shuffledSet1[ setIndices[1] ]]; + setIndices[1]++; + } + else if (inQuestionRandomizationSettings.layout[i] === "reverseOrder") + { + questionData[choicesFieldName][i] = choicesMap[reversedSet[ setIndices[2] ]]; + setIndices[2]++; + } + else + { + questionData[choicesFieldName][i] = choicesMap[inQuestionRandomizationSettings.layout[i]]; + } + } + + if (inQuestionRandomizationSettings.layout.length < initialChoices.length) + { + // Compose unused choices set. + // TODO: This is potentially how data loss can be avoided and thus no need to deepcopy model. + if (surveyData.unusedChoices === undefined) + { + surveyData.unusedChoices = {}; + } + surveyData.unusedChoices[questionData.name] = { + // All other sets are always used entirely. + set1: shuffledSet1.splice(setIndices[1], shuffledSet1.length) + }; + console.log("unused choices", questionData.name, surveyData.unusedChoices[questionData.name]); + } + } + + console.log("applying question randomization took", performance.now() - t); + // console.log(questionData); + } + + /** + * @desc: Go over required surveyModelData and apply randomization settings. + */ + _processSurveyData (surveyData, surveyIdx) + { + let t = performance.now(); + let i, j; + let newSurveyModel = undefined; + if (surveyData.questionsOrderRandomization[surveyIdx] !== undefined) + { + // Qualtrics's in-block randomization ignores presense of page breaks within the block. + // Hence creating a fresh survey data object with shuffled question order. + newSurveyModel = this._composeModelWithRandomizedQuestions(surveyData.surveys[surveyIdx], surveyData.questionsOrderRandomization[surveyIdx]); + } + + // Checking if there's in-question randomization that needs to be applied. + for (i = 0; i < surveyData.surveys[surveyIdx].pages.length; i++) + { + for (j = 0; j < surveyData.surveys[surveyIdx].pages[i].elements.length; j++) + { + if (surveyData.inQuestionRandomization[surveyData.surveys[surveyIdx].pages[i].elements[j].name] !== undefined) + { + if (newSurveyModel === undefined) + { + // Marking a deep copy of survey model input data, to avoid data loss if randomization returns a subset of choices. + // TODO: think of somehting more optimal. + newSurveyModel = JSON.parse(JSON.stringify(surveyData.surveys[surveyIdx])); + } + this._applyInQuestionRandomization( + newSurveyModel.pages[i].elements[j], + surveyData.inQuestionRandomization[newSurveyModel.pages[i].elements[j].name], + surveyData + ); + } + } + } + + if (newSurveyModel === undefined) + { + // No changes were made, just return original data. + newSurveyModel = surveyData.surveys[surveyIdx]; + } + console.log("survey model preprocessing took", performance.now() - t); + return newSurveyModel; + } + + /** + * Callback triggered when the participant changed the page. + * + * @protected + */ + _onCurrentPageChanging (surveyModel, options) + { + if (this._lastPageSwitchHandledIdx === options.oldCurrentPage.visibleIndex) + { + // When surveyModel.currentPage is called from this handler, pagechange event gets triggered again. + // Hence returning if we already handled this pagechange to avoid max callstack exceeded errors. + return; + } + this._lastPageSwitchHandledIdx = options.oldCurrentPage.visibleIndex; + const questions = surveyModel.getCurrentPageQuestions(); + + // It is guaranteed that the question with skip logic is always last on the page. + const lastQuestion = questions[questions.length - 1]; + const skipLogic = this._surveyData.questionSkipLogic[lastQuestion.name]; + if (skipLogic !== undefined) + { + this._expressionsRunner.expressionExecutor.setExpression(skipLogic.expression); + const result = this._expressionsRunner.run(surveyModel.data); + if (result) + { + options.allowChanging = false; + + if (skipLogic.destination === "ENDOFSURVEY") + { + surveyModel.setCompleted(); + this._surveyRunningPromiseResolve(Survey.SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY); + } + else if (skipLogic.destination === "ENDOFBLOCK") + { + surveyModel.setCompleted(); + this._surveyRunningPromiseResolve(Survey.SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_BLOCK); + } + else + { + // skipLogic.destination is a question within the current survey (qualtrics block). + const targetQuestion = surveyModel.getQuestionByName(skipLogic.destination); + const page = surveyModel.getPageByQuestion(targetQuestion); + const pageQuestions = page.questions; + let i; + for (i = 0; i < pageQuestions.length; i++) + { + if (pageQuestions[i] === targetQuestion) + { + break; + } + pageQuestions[i].visible = false; + } + targetQuestion.focus(); + surveyModel.currentPage = page; + } + } + } + } + + /** + * Callback triggered when the participant is done with the survey, i.e. when the + * [Complete] button as been pressed. + * + * @param surveyModel + * @param options + * @protected + */ + _onSurveyComplete(surveyModel, options) + { + Object.assign(this._overallSurveyResults, surveyModel.data); + let completionCode = Survey.SURVEY_COMPLETION_CODES.NORMAL; + const questions = surveyModel.getAllQuestions(); + + // It is guaranteed that the question with skip logic is always last on the page. + const lastQuestion = questions[questions.length - 1]; + const skipLogic = this._surveyData.questionSkipLogic[lastQuestion.name]; + if (skipLogic !== undefined) + { + this._expressionsRunner.expressionExecutor.setExpression(skipLogic.expression); + const result = this._expressionsRunner.run(surveyModel.data); + if (result) + { + if (skipLogic.destination === "ENDOFSURVEY") + { + completionCode = Survey.SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY; + surveyModel.setCompleted(); + } + else if (skipLogic.destination === "ENDOFBLOCK") + { + completionCode = Survey.SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_BLOCK; + } + } + } + + surveyModel.stopTimer(); + + // check whether the survey was completed: + const surveyVisibleQuestions = this._surveyModel.getAllQuestions(true); + const nbAnsweredQuestions = surveyVisibleQuestions.reduce( + (count, question) => + { + // note: the response of a html, ranking, checkbox, or comment question is empty if the user + // did not interact with it + const type = question.getType(); + if (type === "html" || + type === "ranking" || + type === "checkbox" || + type === "comment" || + !question.isEmpty()) + { + return count + 1; + } + else + { + return count; + } + }, + 0 + ); + this._isCompletedAll = this._isCompletedAll && (nbAnsweredQuestions === surveyVisibleQuestions.length); + if (this._isCompletedAll === false) + { + this.psychoJS.logger.warn(`Flag _isCompletedAll is false!`); + } + + this._surveyRunningPromiseResolve(completionCode); + } + + _onFlowComplete () + { + this.isFinished = true; + this._onFinishedCallback(); + } + + _onTextMarkdown(survey, options) + { + // TODO add sanitization / checks if required. + options.html = options.text; + } + + /** + * Run the survey using flow data provided. This method runs recursively. + * + * @protected + * @param {Object} surveyData - surveyData / model. + * @param {Object} surveyFlowBlock - XXX + * @return {void} + */ + _beginSurvey(surveyData, surveyFlowBlock) + { + this._lastPageSwitchHandledIdx = -1; + const surveyIdx = surveyFlowBlock.surveyIdx; + let surveyModelInput = this._processSurveyData(surveyData, surveyIdx); + + this._surveyModel = new window.Survey.Model(surveyModelInput); + for (let j in this._variables) + { + // Adding variables directly to hash to get higher performance (this is instantaneous compared to .setVariable()). + // At this stage we don't care to trigger all the callbacks like .setVariable() does, since this is very beginning of survey presentation. + this._surveyModel.variablesHash[j] = this._variables[j]; + // this._surveyModel.setVariable(j, this._variables[j]); + } + + if (!this._surveyModel.isInitialized) + { + this._registerCustomComponentCallbacks(this._surveyModel); + this._surveyModel.onValueChanged.add(this._onQuestionValueChanged.bind(this)); + this._surveyModel.onCurrentPageChanging.add(this._onCurrentPageChanging.bind(this)); + this._surveyModel.onComplete.add(this._onSurveyComplete.bind(this)); + this._surveyModel.onTextMarkdown.add(this._onTextMarkdown.bind(this)); + this._surveyModel.isInitialized = true; + this._surveyModel.onAfterRenderQuestion.add(this._handleAfterQuestionRender.bind(this)); + } + + const completeText = surveyIdx < this._surveyData.surveys.length - 1 ? (this._surveyModel.pageNextText || Survey.CAPTIONS.NEXT) : undefined; + jQuery(".survey").Survey({ + model: this._surveyModel, + showItemsInOrder: "column", + completeText, + ...surveyData.surveySettings, + }); + + this._questionAnswerTimestampClock.reset(); + + // TODO: should this be conditional? + this._surveyModel.startTimer(); + + this._surveyRunningPromise = new Promise((res, rej) => { + this._surveyRunningPromiseResolve = res; + this._surveyRunningPromiseReject = rej; + }); + + return this._surveyRunningPromise; + } + + async _runSurveyFlow(surveyBlock, surveyData, prevBlockResults = {}) + { + let nodeExitCode = Survey.NODE_EXIT_CODES.NORMAL; + + if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL) + { + const dataset = Object.assign({}, this._overallSurveyResults, this._variables); + this._expressionsRunner.expressionExecutor.setExpression(surveyBlock.condition); + if (this._expressionsRunner.run(dataset) && surveyBlock.nodes[0] !== undefined) + { + nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[0], surveyData, prevBlockResults); + } + else if (surveyBlock.nodes[1] !== undefined) + { + nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[1], surveyData, prevBlockResults); + } + } + else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.RANDOMIZER) + { + util.shuffle(surveyBlock.nodes, Math.random, 0, surveyBlock.nodes.length - 1); + // this._InPlaceFisherYatesShuffle(surveyBlock.nodes, 0, surveyBlock.nodes.length - 1); + } + else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.EMBEDDED_DATA) + { + let t = performance.now(); + const surveyBlockData = surveyData.embeddedData[surveyBlock.dataIdx]; + for (let j = 0; j < surveyBlockData.length; j++) + { + // TODO: handle the rest data types. + if (surveyBlockData[j].type === "Custom") + { + // Variable value can be an expression. Check if so and if valid - run it. + // surveyBlockData is an array so all the variables in it are in order they were declared in Qualtrics. + // This means this._variables is saturated gradually with the data necessary to perform a computation. + // It's guaranteed to be there, unless there are declaration order mistakes. + this._expressionsRunner.expressionExecutor.setExpression(surveyBlockData[j].value); + if (this._expressionsRunner.expressionExecutor.canRun()) + { + this._variables[surveyBlockData[j].key] = this._expressionsRunner.run(this._variables); + } + else + { + this._variables[surveyBlockData[j].key] = surveyBlockData[j].value; + } + } + } + console.log("embedded data variables accumulation took", performance.now() - t); + } + else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.ENDSURVEY) + { + if (this._surveyModel) + { + this._surveyModel.setCompleted(); + } + console.log("EndSurvey block encountered, exiting."); + nodeExitCode = Survey.NODE_EXIT_CODES.BREAK_FLOW; + } + else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.DIRECT) + { + const surveyCompletionCode = await this._beginSurvey(surveyData, surveyBlock); + Object.assign({}, prevBlockResults, this._surveyModel.data); + + // SkipLogic had destination set to ENDOFSURVEY. + if (surveyCompletionCode === Survey.SURVEY_COMPLETION_CODES.SKIP_TO_END_OF_SURVEY) + { + nodeExitCode = Survey.NODE_EXIT_CODES.BREAK_FLOW; + } + } + + if (nodeExitCode === Survey.NODE_EXIT_CODES.NORMAL && + surveyBlock.type !== Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL && + surveyBlock.nodes instanceof Array) + { + for (let i = 0; i < surveyBlock.nodes.length; i++) + { + nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[i], surveyData, prevBlockResults); + if (nodeExitCode === Survey.NODE_EXIT_CODES.BREAK_FLOW) + { + break; + } + } + } + + if (surveyBlock.name === "root") + { + // At this point we went through the entire survey flow tree. + this._onFlowComplete(); + } + + return nodeExitCode; + } + + _resetState () + { + this._lastPageSwitchHandledIdx = -1; + } + + _handleWindowResize(e) + { + if (this._surveyModel) + { + for (let i = this._signaturePads.length - 1; i >= 0; i--) + { + // As of writing this (24.03.2023). SurveyJS doesn't have a proper event + // for question being removed from nested locations, such as dynamic panel. + // However, surveyJS will set .signaturePad property to null once the question is removed. + // Utilising this knowledge to sync our lists. + if (this._signaturePads[ i ].question.signaturePad) + { + this._signaturePads[ i ].question.signatureWidth = Math.min( + this._signaturePads[i].question.maxSignatureWidth, + this._signaturePads[ i ].htmlElement.getBoundingClientRect().width + ); + } + else + { + // Signature pad was removed. Syncing list. + this._signaturePads.splice(i, 1); + } + } + } + } + + _addEventListeners() + { + window.addEventListener("resize", (e) => this._handleWindowResize(e)); + } + + _handleAfterQuestionRender (sender, options) + { + if (options.question.getType() === "signaturepad") + { + this._signaturePads.push(options); + options.question.signatureWidth = Math.min(options.question.maxSignatureWidth, options.htmlElement.getBoundingClientRect().width); + } + } + + /** + * Init the SurveyJS.io library and various extensions, setup the theme. + * + * @protected + */ + _initSurveyJS() + { + // note: the Survey.js libraries must be added to the list of resources in PsychoJS.start: + // psychoJS.start({ resources: [ {'surveyLibrary': true}, ... ], ...}); + + // id of the SurveyJS html div: + this._surveyDivId = `survey-${this._name}`; + + this._registerCustomExpressionFunctions(window.Survey, customExpressionFunctionsArray); + this._registerWidgets(window.Survey); + this._registerCustomSurveyProperties(window.Survey); + this._addEventListeners(); + this._expressionsRunner = new window.Survey.ExpressionRunner(); + + // setup the survey theme: + window.Survey.Serializer.getProperty("expression", "minWidth").defaultValue = "100px"; + window.Survey.settings.minWidth = "100px"; + window.Survey.StylesManager.applyTheme("defaultV2"); + + // load the desired style: + // TODO + // util.loadCss("./survey/css/grey_style.css"); + } +} diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 3930cbf..ab06378 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -65,6 +65,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) opacity, depth, text, + placeholder, font, letterHeight, bold, @@ -85,7 +86,8 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) clipMask, autoDraw, autoLog, - fitToContent + fitToContent, + boxFn } = {}, ) { @@ -98,7 +100,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) ); this._addAttribute( "placeholder", - text, + placeholder, "", this._onChange(true, true), ); @@ -201,12 +203,14 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) // and setSize called from super class would not have a proper effect this.setSize(size); + this._addAttribute("boxFn", boxFn, null); + // estimate the bounding box: this._estimateBoundingBox(); if (this._autoLog) { - this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${util.toString(this)}`); } } @@ -480,6 +484,26 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) alignmentStyles = ["center", "center"]; } + let box; + if (this._boxFn !== null) + { + box = this._boxFn; + } + else + { + // note: box style properties eventually become PIXI.Graphics settings, so same syntax applies + box = { + fill: new Color(this._fillColor).int, + alpha: this._fillColor === undefined || this._fillColor === null ? 0 : 1, + rounded: 5, + stroke: { + color: new Color(this._borderColor).int, + width: borderWidth_px, + alpha: this._borderColor === undefined || this._borderColor === null ? 0 : 1 + } + }; + } + return { // input style properties eventually become CSS, so same syntax applies input: { @@ -503,41 +527,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) overflow: "hidden", pointerEvents: "none" }, - // box style properties eventually become PIXI.Graphics settings, so same syntax applies - box: { - fill: new Color(this._fillColor).int, - alpha: this._fillColor === undefined || this._fillColor === null ? 0 : 1, - rounded: 5, - stroke: { - color: new Color(this._borderColor).int, - width: borderWidth_px, - alpha: this._borderColor === undefined || this._borderColor === null ? 0 : 1 - }, - /*default: { - fill: new Color(this._fillColor).int, - rounded: 5, - stroke: { - color: new Color(this._borderColor).int, - width: borderWidth_px - } - }, - focused: { - fill: new Color(this._fillColor).int, - rounded: 5, - stroke: { - color: new Color(this._borderColor).int, - width: borderWidth_px - } - }, - disabled: { - fill: new Color(this._fillColor).int, - rounded: 5, - stroke: { - color: new Color(this._borderColor).int, - width: borderWidth_px - } - }*/ - }, + box }; } diff --git a/src/visual/index.js b/src/visual/index.js index fb96f41..9152e78 100644 --- a/src/visual/index.js +++ b/src/visual/index.js @@ -12,3 +12,6 @@ export * from "./TextInput.js"; export * from "./TextStim.js"; export * from "./VisualStim.js"; export * from "./FaceDetector.js"; +export * from "./Survey.js"; +export * from "./ParticleEmitter.js"; +export * from "./Progress.js"; \ No newline at end of file diff --git a/src/visual/shaders/wgl1/circleShader.frag b/src/visual/shaders/wgl1/circleShader.frag new file mode 100644 index 0000000..ca321e5 --- /dev/null +++ b/src/visual/shaders/wgl1/circleShader.frag @@ -0,0 +1,26 @@ +/** + * Circle Shape. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates a filled circle shape with sharp edges. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +#define M_PI 3.14159265358979 +uniform float uRadius; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vUvs; + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + float s = (1. - step(uRadius, length(uv * 2. - 1.))) * 2. - 1.; + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/crossShader.frag b/src/visual/shaders/wgl1/crossShader.frag new file mode 100644 index 0000000..1a00b78 --- /dev/null +++ b/src/visual/shaders/wgl1/crossShader.frag @@ -0,0 +1,28 @@ +/** + * Cross Shape. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates a filled cross shape with sharp edges. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +#define M_PI 3.14159265358979 +uniform float uThickness; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vUvs; + float sx = step(uThickness, length(uv.x * 2. - 1.)); + float sy = step(uThickness, length(uv.y * 2. - 1.)); + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + float s = (1. - sx * sy) * 2. - 1.; + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/defaultQuad.vert b/src/visual/shaders/wgl1/defaultQuad.vert new file mode 100644 index 0000000..b4dc9ae --- /dev/null +++ b/src/visual/shaders/wgl1/defaultQuad.vert @@ -0,0 +1,13 @@ +precision mediump float; + +attribute vec2 aVertexPosition; +attribute vec2 aUvs; +varying vec2 vUvs; + +uniform mat3 translationMatrix; +uniform mat3 projectionMatrix; + +void main() { + vUvs = aUvs; + gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0); +} diff --git a/src/visual/shaders/wgl1/gaussShader.frag b/src/visual/shaders/wgl1/gaussShader.frag new file mode 100644 index 0000000..27ca741 --- /dev/null +++ b/src/visual/shaders/wgl1/gaussShader.frag @@ -0,0 +1,32 @@ +/** + * Gaussian Function. + * https://en.wikipedia.org/wiki/Gaussian_function + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +uniform float uA; +uniform float uB; +uniform float uC; +uniform vec3 uColor; +uniform float uAlpha; + +#define M_PI 3.14159265358979 + +void main() { + vec2 uv = vUvs; + float c2 = uC * uC; + float x = length(uv - .5); + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + float g = uA * exp(-pow(x - uB, 2.) / c2 * .5) * 2. - 1.; + gl_FragColor = vec4(vec3(g) * uColor * .5 + .5, 1.) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/imageShader.frag b/src/visual/shaders/wgl1/imageShader.frag new file mode 100644 index 0000000..b2f876c --- /dev/null +++ b/src/visual/shaders/wgl1/imageShader.frag @@ -0,0 +1,29 @@ +/** + * Image shader. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Renders passed in image with applied effects. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +#define M_PI 3.14159265358979 +uniform sampler2D uTex; +uniform float uFreq; +uniform float uPhase; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vUvs; + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + vec4 s = texture2D(uTex, vec2(uv.x * uFreq + uPhase, uv.y)); + s.xyz = s.xyz * 2. - 1.; + gl_FragColor = vec4(s.xyz * uColor * .5 + .5, s.a) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/radRampShader.frag b/src/visual/shaders/wgl1/radRampShader.frag new file mode 100644 index 0000000..8849bc8 --- /dev/null +++ b/src/visual/shaders/wgl1/radRampShader.frag @@ -0,0 +1,27 @@ +/** + * Radial Ramp. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d radial ramp image. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +uniform float uSqueeze; +uniform vec3 uColor; +uniform float uAlpha; + +#define M_PI 3.14159265358979 + +void main() { + vec2 uv = vUvs; + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + float s = (1. - length(uv * 2. - 1.) * uSqueeze) * 2. - 1.; + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/radialShader.frag b/src/visual/shaders/wgl1/radialShader.frag new file mode 100644 index 0000000..ce38588 --- /dev/null +++ b/src/visual/shaders/wgl1/radialShader.frag @@ -0,0 +1,41 @@ +/** + * Radial grating. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d radial grating image. Based on https://www.shadertoy.com/view/wtjGzt + * @usedby GratingStim.js + */ +precision mediump float; + +varying vec2 vUvs; + +uniform float uFreq; +uniform float uPhase; +uniform vec3 uColor; +uniform float uAlpha; +uniform float uStep; +uniform float uDX; + +#define M_PI 3.14159265358979 +#define PI2 2.* M_PI + +float aastep(float x, float w) { // --- antialiased step(.5) + return smoothstep(.7,-.7,(abs(fract(x-.25)-.5)-.25)/w); // just use (offseted) smooth squares +} + +void main() { + vec2 uv = vUvs * 2. - 1.; + float v = uFreq * atan(uv.y, uv.x) / 6.28; + // WGL1 has dFdx, dFdy and fwidth() defined as part of OES_standard_derivatives extension. + // BUT using this extension fails due to how currently used version of PIXI goes about shader program compilation. + // Calculating derivatives manually instead. + float dF_dx = (uFreq * (atan(uv.y, uv.x + uStep) - atan(uv.y, uv.x - uStep)) / 6.28) / uDX; + float dF_dy = (uFreq * (atan(uv.y + uStep, uv.x) - atan(uv.y - uStep, uv.x)) / 6.28) / uDX; + float w = abs(dF_dx) + abs(dF_dy); + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + float s = aastep(v, w) * 2. - 1.; + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/raisedCosShader.frag b/src/visual/shaders/wgl1/raisedCosShader.frag new file mode 100644 index 0000000..58c7764 --- /dev/null +++ b/src/visual/shaders/wgl1/raisedCosShader.frag @@ -0,0 +1,38 @@ +/** + * Raised-cosine. + * https://en.wikipedia.org/wiki/Raised-cosine_filter + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +#define M_PI 3.14159265358979 +uniform float uBeta; +uniform float uPeriod; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vUvs; + float absX = length(uv * 2. - 1.); + float edgeArgument1 = (1. - uBeta) / (2. * uPeriod); + float edgeArgument2 = (1. + uBeta) / (2. * uPeriod); + float frequencyFactor = (M_PI * uPeriod) / uBeta; + float s = .5 * (1. + cos(frequencyFactor * (absX - edgeArgument1))); + if (absX <= edgeArgument1) { + s = 1.; + } else if (absX > edgeArgument2) { + s = 0.; + } + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + s = s * 2. - 1.; + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/sawShader.frag b/src/visual/shaders/wgl1/sawShader.frag new file mode 100644 index 0000000..239755d --- /dev/null +++ b/src/visual/shaders/wgl1/sawShader.frag @@ -0,0 +1,29 @@ +/** + * Sawtooth wave. + * https://en.wikipedia.org/wiki/Sawtooth_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d sawtooth wave image as if 1d sawtooth graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vUvs; + float s = uFreq * uv.x + uPhase; + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + s = mod(s, 1.) * 2. - 1.; + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/sinShader.frag b/src/visual/shaders/wgl1/sinShader.frag new file mode 100644 index 0000000..25f4594 --- /dev/null +++ b/src/visual/shaders/wgl1/sinShader.frag @@ -0,0 +1,27 @@ +/** + * Sine wave. + * https://en.wikipedia.org/wiki/Sine_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vUvs - .25; + float s = sin((uFreq * uv.x + uPhase) * 2. * M_PI); + // it's important to convert to [0, 1] while multiplying to uColor, not before, to preserve desired coloring functionality + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/sinXsinShader.frag b/src/visual/shaders/wgl1/sinXsinShader.frag new file mode 100644 index 0000000..74915b6 --- /dev/null +++ b/src/visual/shaders/wgl1/sinXsinShader.frag @@ -0,0 +1,30 @@ +/** + * Sine wave multiplied by another sine wave. + * https://en.wikipedia.org/wiki/Sine_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates an image of two 2d sine waves multiplied with each other. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +#define M_PI 3.14159265358979 +#define PI2 2.* M_PI +uniform float uFreq; +uniform float uPhase; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vec2(vUvs.x - .25, vUvs.y * -1. - .25); + float sx = sin((uFreq * uv.x + uPhase) * PI2); + float sy = sin((uFreq * uv.y + uPhase) * PI2); + float s = sx * sy; + // it's important to convert to [0, 1] while multiplying to uColor, not before, to preserve desired coloring functionality + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/sqrShader.frag b/src/visual/shaders/wgl1/sqrShader.frag new file mode 100644 index 0000000..3e169fc --- /dev/null +++ b/src/visual/shaders/wgl1/sqrShader.frag @@ -0,0 +1,27 @@ +/** + * Square wave. + * https://en.wikipedia.org/wiki/Square_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d square wave image as if 1d square graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vUvs - .25; + float s = sign(sin((uFreq * uv.x + uPhase) * 2. * M_PI)); + // it's important to convert to [0, 1] while multiplying to uColor, not before, to preserve desired coloring functionality + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/sqrXsqrShader.frag b/src/visual/shaders/wgl1/sqrXsqrShader.frag new file mode 100644 index 0000000..56f145c --- /dev/null +++ b/src/visual/shaders/wgl1/sqrXsqrShader.frag @@ -0,0 +1,30 @@ +/** + * Square wave multiplied by another square wave. + * https://en.wikipedia.org/wiki/Square_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates an image of two 2d square waves multiplied with each other. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +#define M_PI 3.14159265358979 +#define PI2 2.* M_PI +uniform float uFreq; +uniform float uPhase; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vec2(vUvs.x - .25, vUvs.y * -1. - .25); + float sx = sign(sin((uFreq * uv.x + uPhase) * PI2)); + float sy = sign(sin((uFreq * uv.y + uPhase) * PI2)); + float s = sx * sy; + // it's important to convert to [0, 1] while multiplying to uColor, not before, to preserve desired coloring functionality + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/shaders/wgl1/triShader.frag b/src/visual/shaders/wgl1/triShader.frag new file mode 100644 index 0000000..30469b5 --- /dev/null +++ b/src/visual/shaders/wgl1/triShader.frag @@ -0,0 +1,30 @@ +/** + * Triangle wave. + * https://en.wikipedia.org/wiki/Triangle_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d triangle wave image as if 1d triangle graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ + +precision mediump float; + +varying vec2 vUvs; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; +uniform float uPeriod; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vUvs; + float s = uFreq * uv.x + uPhase; + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + s = (2. * abs(s / uPeriod - floor(s / uPeriod + .5))) * 2. - 1.; + gl_FragColor = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; +} diff --git a/src/visual/survey/components/DropdownExtensions.js b/src/visual/survey/components/DropdownExtensions.js new file mode 100644 index 0000000..a5f8704 --- /dev/null +++ b/src/visual/survey/components/DropdownExtensions.js @@ -0,0 +1,48 @@ +/** + * @desc: Extensions for default dropdown component of SurveyJS to make it more nice to interact with on mobile devices. + * @type: SurveyJS component modification. + */ + +function handleValueChange (survey, options, e) +{ + options.question.value = e.currentTarget.value; +} + +function handleValueChangeForDOM (survey, options) +{ + options.htmlElement.querySelector("select").value = options.question.value; +} + +function handleDropdownRendering (survey, options) +{ + // Default SurveyJS drop down is actually an with customly built options list + // It works well on desktop, but not that convenient on mobile. + // Adding native ${optionsHTML}`; + options.htmlElement.querySelector('.sd-selectbase').insertAdjacentHTML("beforebegin", selectHTML); + + const selectDOM = options.htmlElement.querySelector("select"); + selectDOM.addEventListener("change", handleValueChange.bind(this, survey, options)); + + options.question.valueChangedCallback = handleValueChangeForDOM.bind(this, survey, options); +} + +export default { + registerModelCallbacks (surveyModel) + { + surveyModel.onAfterRenderQuestion.add((survey, options) => { + if (options.question.getType() === "dropdown") + { + handleDropdownRendering(survey, options); + } + }); + } +}; diff --git a/src/visual/survey/components/MatrixBipolar.js b/src/visual/survey/components/MatrixBipolar.js new file mode 100644 index 0000000..638ba09 --- /dev/null +++ b/src/visual/survey/components/MatrixBipolar.js @@ -0,0 +1,60 @@ +/** + * @desc: Bipolar Matrix custom component. + * @type: SurveyJS component modification. + * This component works differently from widgets because it modifies the behavior of native SurveyJS component. + * It's doing so by registering new properties in SurveyJS ecosystem and introducing custom afterRender callback. + * While such approach is not most efficient since it modifies existing DOM element it's quite fast to make + * when amount of changes to original component is minimal. + */ + +function handleBipolarMatrixRendering (survey, options) +{ + // Operation below takes on average abt .5ms for 4x3 matrix. + // If it would turn out taking unreasonably long for larger matrices + // this has to be implemented using widgets (see SideBySideMatrix). + + // REWORK TO WIDGET + + const surveyCSS = options.question.css; + // let tableDOM = options.htmlElement.querySelector("table.sv-table"); + let tableDOM = options.htmlElement.querySelector("table"); + tableDOM.classList.add("matrix-bipolar"); + let rowsDOM = options.htmlElement.querySelectorAll("tbody tr"); + // let rowCaptionsDOM = options.htmlElement.querySelectorAll("tbody tr td:nth-child(1) .sv-string-viewer"); + let rowCaptionsDOM = options.htmlElement.querySelectorAll("tbody tr td:nth-child(1) span"); + let captionsClassList = rowCaptionsDOM[0].classList; + let cellClassList = rowsDOM[0].children[0].classList; + let rowCaptions = new Array(options.question.rows.length); + let rowCaptionOppositeHTML = ""; + let i; + for (i = 0; i < rowCaptions.length; i++) + { + rowCaptions[i] = options.question.rows[i].text.split(":"); + rowCaptionsDOM[i].innerText = rowCaptions[i][0]; + rowCaptionOppositeHTML = `${rowCaptions[i][1]}`; + rowsDOM[i].insertAdjacentHTML("beforeend", rowCaptionOppositeHTML); + } +} + +export default { + registerSurveyProperties (Survey) + { + Survey.Serializer.addProperty("matrix", + { + name: "subType:text", + default: "", + category: "general" + }); + }, + + + registerModelCallbacks (surveyModel) + { + surveyModel.onAfterRenderQuestion.add((survey, options) => { + if (options.question.getType() === "matrix" && options.question.getPropertyValue("subType") === "bipolar") + { + handleBipolarMatrixRendering(survey, options); + } + }); + } +}; diff --git a/src/visual/survey/extensions/customExpressionFunctions.js b/src/visual/survey/extensions/customExpressionFunctions.js new file mode 100644 index 0000000..43bae5f --- /dev/null +++ b/src/visual/survey/extensions/customExpressionFunctions.js @@ -0,0 +1,89 @@ +// Wrapping everything in Class and defining as static methods to prevent esbuild from renaming when bundling. +// NOTE! Survey stim uses property .name of these methods on registering stage. +// Methods are available inside SurveyJS expressions using their actual names. +class ExpressionFunctions { + static rnd () + { + return Math.random(); + } + + static arrayContains (params) + { + if (params[0] instanceof Array) + { + let searchValue = params[1]; + let searchResult = params[0].indexOf(searchValue) !== -1; + + // If no results at first, trying conversion combinations, since array of values sometimes might + // contain both string and number data types. + if (searchResult === false) + { + if (typeof searchValue === "string") + { + searchValue = parseFloat(searchValue); + searchResult = params[0].indexOf(searchValue) !== -1; + } + else if (typeof searchValue === "number") + { + searchValue = searchValue.toString(); + searchResult = params[0].indexOf(searchValue) !== -1; + } + } + + return searchResult + } + return false; + } + + static stringContains (params) + { + if (typeof params[0] === "string") + { + return params[0].indexOf(params[1]) !== -1; + } + return false; + } + + static isEmpty (params) + { + let questionIsEmpty = false; + if (params[0] instanceof Array || typeof params[0] === "string") + { + questionIsEmpty = params[0].length === 0; + } + else + { + questionIsEmpty = params[0] === undefined || params[0] === null; + } + return questionIsEmpty; + } + + static isNotEmpty (params) + { + return !ExpressionFunctions.isEmpty(params); + } +} + + +export default [ + { + func: ExpressionFunctions.rnd, + isAsync: false + }, + { + func: ExpressionFunctions.arrayContains, + isAsync: false + }, + { + func: ExpressionFunctions.stringContains, + isAsync: false + }, + { + func: ExpressionFunctions.isEmpty, + isAsync: false + }, + { + func: ExpressionFunctions.isNotEmpty, + isAsync: false + } +]; diff --git a/src/visual/survey/widgets/MaxDiffMatrix.js b/src/visual/survey/widgets/MaxDiffMatrix.js new file mode 100644 index 0000000..a50c784 --- /dev/null +++ b/src/visual/survey/widgets/MaxDiffMatrix.js @@ -0,0 +1,304 @@ +/** +* @desc "MaxDiff" matrix. +* */ + +class MaxDiffMatrix +{ + constructor (cfg = {}) + { + // surveyCSS contains css class names provided by the applied theme + // INCLUDING those added/modified by application's code. + const surveyCSS = cfg.question.css; + this._CSS_CLASSES = { + WRAPPER: `${surveyCSS.matrix.tableWrapper} matrix-maxdiff`, + TABLE: surveyCSS.matrix.root, + TABLE_ROW: surveyCSS.matrixdropdown.row, + TABLE_HEADER_CELL: surveyCSS.matrix.headerCell, + TABLE_CELL: surveyCSS.matrix.cell, + INPUT_TEXT: surveyCSS.text.root, + LABEL: surveyCSS.matrix.label, + ITEM_CHECKED: surveyCSS.matrix.itemChecked, + ITEM_VALUE: surveyCSS.matrix.itemValue, + ITEM_DECORATOR: surveyCSS.matrix.materialDecorator, + RADIO: surveyCSS.radiogroup.item, + SELECT: surveyCSS.dropdown.control, + CHECKBOX: surveyCSS.checkbox.item + }; + + // const CSS_CLASSES = { + // WRAPPER: "sv-matrix matrix-maxdiff", + // TABLE: "sv-table sv-matrix-root", + // TABLE_ROW: "sv-table__row", + // TABLE_HEADER_CELL: "sv-table__cell sv-table__cell--header", + // TABLE_CELL: "sv-table__cell sv-matrix__cell", + // INPUT_TEXT: "sv-text", + // RADIO: "sv-radio", + // SELECT: "sv-dropdown", + // CHECKBOX: "sv-checkbox" + // }; + this._question = cfg.question; + this._DOM = cfg.el; + this._DOM.classList.add(...this._CSS_CLASSES.WRAPPER.split(" ")); + + this._bindedHandlers = + { + _handleInput: this._handleInput.bind(this) + }; + + this._init(this._question, this._DOM); + } + + _handleInput (e) + { + const valueCoordinates = e.currentTarget.name.split("-"); + const row = valueCoordinates[0]; + const col = parseInt(e.currentTarget.dataset.column, 10); + const colRadioDOMS = this._DOM.querySelectorAll(`input[data-column="${col}"]`); + + if (this._question.value === undefined) + { + this._question.value = {}; + } + + const oldVal = this._question.value; + const newVal = {[row]: col}; + + // Handle case when exclusiveAnswer option is false? + let inputRow; + let i; + for (i = 0; i < colRadioDOMS.length; i++) + { + if (colRadioDOMS[i] !== e.currentTarget) + { + colRadioDOMS[i].checked = false; + inputRow = colRadioDOMS[i].name; + // Preserving previously ticked columns within other rows + if (oldVal[inputRow] !== undefined && oldVal[inputRow] !== col) + { + newVal[inputRow] = oldVal[inputRow]; + } + } + } + + this._question.value = newVal; + console.log(row, col, this._question.value); + } + + _init (question, el) + { + let t = performance.now(); + const CSS_CLASSES = this._CSS_CLASSES; + if (question.css.matrix.mainRoot) + { + // Replacing default mainRoot class with those used in matrix type questions, to achieve proper styling and overflow behavior + const rootClass = `${question.css.matrix.mainRoot} ${question.cssClasses.withFrame || ""}`; + question.setCssRoot(rootClass); + question.cssClasses.mainRoot = rootClass; + } + + // Relying on a fact that there's always 2 columns. + // This is correct according current Qualtrics design for MaxDiff matrices. + // Header generation + let headerCells = + `${question.columns[0].text} + + + + ${question.columns[1].text}`; + + // Body generation + let bodyHTML = ""; + for (let i = 0; i < question.rows.length; i++) + { + const bodyCells = + ` + + + + ${question.rows[i].text} + + + + `; + bodyHTML += `${bodyCells}`; + } + + let html = ` + + ${headerCells} + + ${bodyHTML} +
    `; + + console.log("maxdiff matrix generation took", performance.now() - t); + el.insertAdjacentHTML("beforeend", html); + + let inputDOMS = el.querySelectorAll("input"); + + for (let i = 0; i < inputDOMS.length; i++) + { + inputDOMS[i].addEventListener("input", this._bindedHandlers._handleInput); + } + } +} + +export default function init (Survey) +{ + var widget = { + //the widget name. It should be unique and written in lowcase. + name: "maxdiffmatrix", + + //the widget title. It is how it will appear on the toolbox of the SurveyJS Editor/Builder + title: "MaxDiff matrix", + + //the name of the icon on the toolbox. We will leave it empty to use the standard one + iconName: "", + + //If the widgets depends on third-party library(s) then here you may check if this library(s) is loaded + widgetIsLoaded: function () { + //return typeof $ == "function" && !!$.fn.select2; //return true if jQuery and select2 widget are loaded on the page + return true; //we do not require anything so we just return true. + }, + + //SurveyJS library calls this function for every question to check, if it should use this widget instead of default rendering/behavior + isFit: function (question) { + //we return true if the type of question is maxdiffmatrix + return question.getType() === 'maxdiffmatrix'; + //the following code will activate the widget for a text question with inputType equals to date + //return question.getType() === 'text' && question.inputType === "date"; + }, + + //Use this function to create a new class or add new properties or remove unneeded properties from your widget + //activatedBy tells how your widget has been activated by: property, type or customType + //property - it means that it will activated if a property of the existing question type is set to particular value, for example inputType = "date" + //type - you are changing the behaviour of entire question type. For example render radiogroup question differently, have a fancy radio buttons + //customType - you are creating a new type, like in our example "maxdiffmatrix" + activatedByChanged: function (activatedBy) { + //we do not need to check acticatedBy parameter, since we will use our widget for customType only + //We are creating a new class and derived it from text question type. It means that text model (properties and fuctions) will be available to us + Survey.JsonObject.metaData.addClass("maxdiffmatrix", [], null, "text"); + //signaturepad is derived from "empty" class - basic question class + //Survey.JsonObject.metaData.addClass("signaturepad", [], null, "empty"); + + //Add new property(s) + //For more information go to https://surveyjs.io/Examples/Builder/?id=addproperties#content-docs + Survey.JsonObject.metaData.addProperties("maxdiffmatrix", [ + { + name: "rows", + isArray: true, + default: [] + }, + { + name: "columns", + isArray: true, + default: [] + } + ]); + }, + + //If you want to use the default question rendering then set this property to true. We do not need any default rendering, we will use our our htmlTemplate + isDefaultRender: false, + + //You should use it if your set the isDefaultRender to false + htmlTemplate: "
    ", + + //The main function, rendering and two-way binding + afterRender: function (question, el) { + console.log("MaxDiff mat", question.rows, question.columns); + new MaxDiffMatrix({ question, el }); + + // let containers = el.querySelectorAll(".srv-slider-container"); + // let inputDOMS = el.querySelectorAll(".srv-slider"); + // let sliderDisplayDOMS = el.querySelectorAll(".srv-slider-display"); + // if (!(question.value instanceof Array)) + // { + // question.value = new Array(inputDOMS.length); + // question.value.fill(0); + // } + + // for (i = 0; i < inputDOMS.length; i++) + // { + // inputDOMS[i].min = question.minVal; + // inputDOMS[i].max = question.maxVal; + // inputDOMS[i].addEventListener("input", (e) => { + // let idx = parseInt(e.currentTarget.dataset.idx, 10); + // question.value[idx] = parseFloat(e.currentTarget.value); + // // using .value setter to trigger update properly. + // // otherwise on survey competion it returns array of nulls. + // question.value = question.value; + // onValueChangedCallback(); + // }); + + // // Handle grid lines? + // } + + + // function positionSliderDisplay (v, min, max, displayDOM) + // { + // v = parseFloat(v); + // min = parseFloat(min); + // max = parseFloat(max); + // // Formula is (halfThumbWidth - v * (fullThumbWidth / 100)), taking into account that display has translate(-50%, 0). + // // Size of thumb is set in CSS. + // displayDOM.style.left = `calc(${(v - min) / (max - min) * 100}% + ${10 - v * 0.2}px)` + // } + + + // var onValueChangedCallback = function () { + // let i; + // let v; + // for (i = 0; i < question.choices.length; i++) + // { + // v = question.value[i] || 0; + // inputDOMS[i].value = v; + // sliderDisplayDOMS[i].innerText = v; + // positionSliderDisplay(v, question.minVal, question.maxVal, sliderDisplayDOMS[i]); + // } + // } + + // var onReadOnlyChangedCallback = function() { + // let i; + // if (question.isReadOnly) { + // for (i = 0; i < question.choices.length; i++) + // { + // inputDOMS[i].setAttribute('disabled', 'disabled'); + // } + // } else { + // for (i = 0; i < question.choices.length; i++) + // { + // inputDOMS[i].removeAttribute("disabled"); + // } + // } + // }; + + // if question becomes readonly/enabled add/remove disabled attribute + // question.readOnlyChangedCallback = onReadOnlyChangedCallback; + + // if the question value changed in the code, for example you have changed it in JavaScript + // question.valueChangedCallback = onValueChangedCallback; + + // set initial value + // onValueChangedCallback(); + + // make elements disabled if needed + // onReadOnlyChangedCallback(); + }, + + //Use it to destroy the widget. It is typically needed by jQuery widgets + willUnmount: function (question, el) { + //We do not need to clear anything in our simple example + //Here is the example to destroy the image picker + //var $el = $(el).find("select"); + //$el.data('picker').destroy(); + } + } + + //Register our widget in singleton custom widget collection + Survey.CustomWidgetCollection.Instance.addCustomWidget(widget, "customtype"); +} diff --git a/src/visual/survey/widgets/SelectBox.js b/src/visual/survey/widgets/SelectBox.js new file mode 100644 index 0000000..18c2bec --- /dev/null +++ b/src/visual/survey/widgets/SelectBox.js @@ -0,0 +1,135 @@ +/** +* @desc SelectBox widget for surveyJS. +* @type: SurveyJS widget. +*/ + +export default function init (Survey) { + var widget = { + //the widget name. It should be unique and written in lowcase. + name: "selectbox", + + //the widget title. It is how it will appear on the toolbox of the SurveyJS Editor/Builder + title: "My custom widg", + + //the name of the icon on the toolbox. We will leave it empty to use the standard one + iconName: "", + + //If the widgets depends on third-party library(s) then here you may check if this library(s) is loaded + widgetIsLoaded: function () { + //return typeof $ == "function" && !!$.fn.select2; //return true if jQuery and select2 widget are loaded on the page + return true; //we do not require anything so we just return true. + }, + + //SurveyJS library calls this function for every question to check, if it should use this widget instead of default rendering/behavior + isFit: function (question) { + //we return true if the type of question is selectbox + return question.getType() === 'selectbox'; + //the following code will activate the widget for a text question with inputType equals to date + //return question.getType() === 'text' && question.inputType === "date"; + }, + + //Use this function to create a new class or add new properties or remove unneeded properties from your widget + //activatedBy tells how your widget has been activated by: property, type or customType + //property - it means that it will activated if a property of the existing question type is set to particular value, for example inputType = "date" + //type - you are changing the behaviour of entire question type. For example render radiogroup question differently, have a fancy radio buttons + //customType - you are creating a new type, like in our example "selectbox" + activatedByChanged: function (activatedBy) { + //we do not need to check acticatedBy parameter, since we will use our widget for customType only + //We are creating a new class and derived it from text question type. It means that text model (properties and fuctions) will be available to us + Survey.JsonObject.metaData.addClass("selectbox", [], null, "text"); + //signaturepad is derived from "empty" class - basic question class + //Survey.JsonObject.metaData.addClass("signaturepad", [], null, "empty"); + + //Add new property(s) + //For more information go to https://surveyjs.io/Examples/Builder/?id=addproperties#content-docs + Survey.JsonObject.metaData.addProperties("selectbox", [ + { + name: "choices", + isArray: true, + default: [] + }, + { + name: "multipleAnswer", + default: true + } + ]); + }, + + //If you want to use the default question rendering then set this property to true. We do not need any default rendering, we will use our our htmlTemplate + isDefaultRender: false, + + //You should use it if your set the isDefaultRender to false + htmlTemplate: `
    `, + + //The main function, rendering and two-way binding + afterRender: function (question, el) { + let optionsHTML = ""; + let i; + for (i = 0; i < question.choices.length; i++) + { + optionsHTML += ``; + } + + let additionalAttr = ""; + if (question.multipleAnswer) + { + additionalAttr = "multiple"; + } + else + { + additionalAttr = "size=\"4\""; + } + let selectHTML = ``; + + el.insertAdjacentHTML("beforeend", selectHTML); + + let selectDOM = el.querySelector("select"); + selectDOM.addEventListener('input', (e) => { + let i; + let opts = new Array(e.currentTarget.selectedOptions.length); + for (i = 0; i < e.currentTarget.selectedOptions.length; i++) + { + opts[i] = e.currentTarget.selectedOptions[i].value; + } + question.value = opts; + }); + + // var onValueChangedCallback = function () { + // text.value = question.value ? question.value : ""; + // } + + // var onReadOnlyChangedCallback = function() { + // if (question.isReadOnly) { + // text.setAttribute('disabled', 'disabled'); + // button.setAttribute('disabled', 'disabled'); + // } else { + // text.removeAttribute("disabled"); + // button.removeAttribute("disabled"); + // } + // }; + + //if question becomes readonly/enabled add/remove disabled attribute + // question.readOnlyChangedCallback = onReadOnlyChangedCallback; + + //if the question value changed in the code, for example you have changed it in JavaScript + // question.valueChangedCallback = onValueChangedCallback; + + //set initial value + // onValueChangedCallback(); + + //make elements disabled if needed + // onReadOnlyChangedCallback(); + }, + + //Use it to destroy the widget. It is typically needed by jQuery widgets + willUnmount: function (question, el) { + //We do not need to clear anything in our simple example + //Here is the example to destroy the image picker + //var $el = $(el).find("select"); + //$el.data('picker').destroy(); + } + } + + //Register our widget in singleton custom widget collection + Survey.CustomWidgetCollection.Instance.addCustomWidget(widget, "customtype"); +} diff --git a/src/visual/survey/widgets/SideBySideMatrix.js b/src/visual/survey/widgets/SideBySideMatrix.js new file mode 100644 index 0000000..c389c95 --- /dev/null +++ b/src/visual/survey/widgets/SideBySideMatrix.js @@ -0,0 +1,429 @@ +/** +* @desc Side By Side matrix. +* */ + +const CELL_TYPES = { + DROP_DOWN: "dropdown", + RADIO: "radio", + CHECKBOX: "checkbox", + TEXT: "text" +}; + +class SideBySideMatrix +{ + constructor (cfg = {}) + { + // surveyCSS contains css class names provided by the applied theme + // INCLUDING those added/modified by application's code. + const surveyCSS = cfg.question.css; + this._CSS_CLASSES = { + WRAPPER: `${surveyCSS.matrix.tableWrapper} sbs-matrix`, + TABLE: surveyCSS.matrix.root, + TABLE_ROW: surveyCSS.matrixdropdown.row, + TABLE_HEADER_CELL: surveyCSS.matrix.headerCell, + TABLE_CELL: surveyCSS.matrix.cell, + INPUT_TEXT: surveyCSS.text.root, + LABEL: surveyCSS.matrix.label, + ITEM_CHECKED: surveyCSS.matrix.itemChecked, + ITEM_VALUE: surveyCSS.matrix.itemValue, + ITEM_DECORATOR: surveyCSS.matrix.materialDecorator, + RADIO: surveyCSS.radiogroup.item, + SELECT: surveyCSS.dropdown.control, + CHECKBOX: surveyCSS.checkbox.item, + CHECKBOX_CONTROL: surveyCSS.checkbox.itemControl, + CHECKBOX_DECORATOR: surveyCSS.checkbox.materialDecorator, + CHECKBOX_DECORATOR_SVG: surveyCSS.checkbox.itemDecorator + }; + this._question = cfg.question; + this._DOM = cfg.el; + this._DOM.classList.add(...this._CSS_CLASSES.WRAPPER.split(" ")); + + this._bindedHandlers = { + _handleInput: this._handleInput.bind(this), + _handleSelectChange: this._handleSelectChange.bind(this) + }; + + this._init(this._question, this._DOM); + } + + static CELL_GENERATORS = + { + [CELL_TYPES.DROP_DOWN]: "_generateDropdownCells", + [CELL_TYPES.RADIO]: "_generateRadioCells", + [CELL_TYPES.CHECKBOX]: "_generateCheckboxCells", + [CELL_TYPES.TEXT]: "_generateTextInputCells", + }; + + _generateDropdownCells (row, col, subColumns, CSS_CLASSES) + { + let bodyCells = ""; + let selectOptions = ""; + let i; + for (i = 0; i < subColumns.length; i++) + { + selectOptions += ``; + } + bodyCells = + ` + + `; + return bodyCells; + } + + _generateRadioCells (row, col, subColumns, CSS_CLASSES) + { + let bodyCells = ""; + let i; + for (i = 0; i < subColumns.length; i++) + { + bodyCells += + ` + + `; + } + return bodyCells; + } + + _generateCheckboxCells (row, col, subColumns, CSS_CLASSES) + { + let bodyCells = ""; + let i; + for (i = 0; i < subColumns.length; i++) + { + bodyCells += + ` + + `; + } + return bodyCells; + } + + _generateTextInputCells (row, col, subColumns, CSS_CLASSES) + { + let bodyCells = ""; + let i; + for (i = 0; i < subColumns.length; i++) + { + bodyCells += + ` + + `; + } + return bodyCells; + } + + _ensureQuestionValueFields (row, col) + { + if (this._question.value === undefined) + { + this._question.value = {}; + } + + if (this._question.value[row] === undefined) + { + this._question.value[row] = { + [col]: {} + } + } + + if (this._question.value[row][col] === undefined) + { + this._question.value[row][col] = {}; + } + } + + _handleInput (e) + { + const valueCoordinates = e.currentTarget.name.split("-"); + const row = valueCoordinates[0]; + const col = valueCoordinates[1]; + const subCol = valueCoordinates[2] !== undefined ? valueCoordinates[2] : e.currentTarget.value; + this._ensureQuestionValueFields(row, col); + + if (e.currentTarget.type === "text") + { + this._question.value[row][col][subCol] = e.currentTarget.value; + } + else if (e.currentTarget.type === "radio") + { + this._question.value[row][col] = e.currentTarget.value; + } + else if (e.currentTarget.type === "checkbox") + { + this._question.value[row][col][subCol] = e.currentTarget.checked; + } + + // Triggering internal SurveyJS mechanism for value update. + this._question.value = this._question.value; + } + + _handleSelectChange (e) + { + const valueCoordinates = e.currentTarget.name.split("-"); + const row = valueCoordinates[0]; + const col = valueCoordinates[1]; + this._ensureQuestionValueFields(row, col); + this._question.value[row][col]= e.currentTarget.value; + // Triggering internal SurveyJS mechanism for value update. + this._question.value = this._question.value; + } + + _init (question, el) + { + let t = performance.now(); + const CSS_CLASSES = this._CSS_CLASSES; + // TODO: Find out how it actually composed inside SurveyJS. + if (question.css.matrix.mainRoot) + { + // Replacing default mainRoot class with those used in matrix type questions, to achieve proper styling and overflow behavior + const rootClass = `${question.css.matrix.mainRoot} ${question.cssClasses.withFrame || ""}`; + question.setCssRoot(rootClass); + question.cssClasses.mainRoot = rootClass; + } + let html; + let headerCells = ""; + let subHeaderCells = ""; + let bodyCells = ""; + let bodyHTML = ""; + let cellGenerator; + let i, j; + + // Header generation + for (i = 0; i < question.columns.length; i++) + { + if (question.columns[i].cellType !== CELL_TYPES.DROP_DOWN) + { + headerCells += + ` + ${question.columns[i].title} + `; + for (j = 0; j < question.columns[i].subColumns.length; j++) + { + subHeaderCells += ` + ${question.columns[i].subColumns[j].text} + `; + } + } + else + { + headerCells += + ` + ${question.columns[i].title} + `; + subHeaderCells += ``; + } + headerCells += ""; + subHeaderCells += ""; + } + + // Body generation + for (i = 0; i < question.rows.length; i++) + { + bodyCells = ""; + for (j = 0; j < question.columns.length; j++) + { + cellGenerator = this[SideBySideMatrix.CELL_GENERATORS[question.columns[j].cellType]]; + if (typeof cellGenerator === "function") + { + // Passing rows, columns, subColumns as separate arguments + // to make generatorrs independent from table data-structure. + bodyCells += `${cellGenerator.call(this, question.rows[i], question.columns[j], question.columns[j].subColumns, CSS_CLASSES)}`; + } + else + { + console.log("No cell generator found for cellType", question.columns[j].cellType); + } + } + bodyHTML += `${question.rows[i].text}${bodyCells}`; + } + + html = ` + + ${headerCells} + ${subHeaderCells} + + ${bodyHTML} +
    `; + + // console.log("sbs matrix generation took", performance.now() - t); + el.insertAdjacentHTML("beforeend", html); + + let inputDOMS = el.querySelectorAll("input"); + let selectDOMS = el.querySelectorAll("select"); + + for (i = 0; i < inputDOMS.length; i++) + { + inputDOMS[i].addEventListener("input", this._bindedHandlers._handleInput); + } + + for (i = 0; i < selectDOMS.length; i++) + { + selectDOMS[i].addEventListener("change", this._bindedHandlers._handleSelectChange) + } + } +} + +export default function init (Survey) { + var widget = { + //the widget name. It should be unique and written in lowcase. + name: "sidebysidematrix", + + //the widget title. It is how it will appear on the toolbox of the SurveyJS Editor/Builder + title: "Side by side matrix", + + //the name of the icon on the toolbox. We will leave it empty to use the standard one + iconName: "", + + //If the widgets depends on third-party library(s) then here you may check if this library(s) is loaded + widgetIsLoaded: function () { + //return typeof $ == "function" && !!$.fn.select2; //return true if jQuery and select2 widget are loaded on the page + return true; //we do not require anything so we just return true. + }, + + //SurveyJS library calls this function for every question to check, if it should use this widget instead of default rendering/behavior + isFit: function (question) { + //we return true if the type of question is sidebysidematrix + return question.getType() === 'sidebysidematrix'; + //the following code will activate the widget for a text question with inputType equals to date + //return question.getType() === 'text' && question.inputType === "date"; + }, + + //Use this function to create a new class or add new properties or remove unneeded properties from your widget + //activatedBy tells how your widget has been activated by: property, type or customType + //property - it means that it will activated if a property of the existing question type is set to particular value, for example inputType = "date" + //type - you are changing the behaviour of entire question type. For example render radiogroup question differently, have a fancy radio buttons + //customType - you are creating a new type, like in our example "sidebysidematrix" + activatedByChanged: function (activatedBy) { + //we do not need to check acticatedBy parameter, since we will use our widget for customType only + //We are creating a new class and derived it from text question type. It means that text model (properties and fuctions) will be available to us + Survey.JsonObject.metaData.addClass("sidebysidematrix", [], null, "text"); + //signaturepad is derived from "empty" class - basic question class + //Survey.JsonObject.metaData.addClass("signaturepad", [], null, "empty"); + + //Add new property(s) + //For more information go to https://surveyjs.io/Examples/Builder/?id=addproperties#content-docs + Survey.JsonObject.metaData.addProperties("sidebysidematrix", [ + { + name: "rows", + isArray: true, + default: [] + }, + { + name: "columns", + isArray: true, + default: [] + } + ]); + }, + + //If you want to use the default question rendering then set this property to true. We do not need any default rendering, we will use our our htmlTemplate + isDefaultRender: false, + + //You should use it if your set the isDefaultRender to false + htmlTemplate: "
    ", + + //The main function, rendering and two-way binding + afterRender: function (question, el) { + new SideBySideMatrix({ question, el }); + // TODO: add readonly and enabled/disabled handlers. + + // let containers = el.querySelectorAll(".srv-slider-container"); + // let inputDOMS = el.querySelectorAll(".srv-slider"); + // let sliderDisplayDOMS = el.querySelectorAll(".srv-slider-display"); + // if (!(question.value instanceof Array)) + // { + // question.value = new Array(inputDOMS.length); + // question.value.fill(0); + // } + + // for (i = 0; i < inputDOMS.length; i++) + // { + // inputDOMS[i].min = question.minVal; + // inputDOMS[i].max = question.maxVal; + // inputDOMS[i].addEventListener("input", (e) => { + // let idx = parseInt(e.currentTarget.dataset.idx, 10); + // question.value[idx] = parseFloat(e.currentTarget.value); + // // using .value setter to trigger update properly. + // // otherwise on survey competion it returns array of nulls. + // question.value = question.value; + // onValueChangedCallback(); + // }); + + // // Handle grid lines? + // } + + + // function positionSliderDisplay (v, min, max, displayDOM) + // { + // v = parseFloat(v); + // min = parseFloat(min); + // max = parseFloat(max); + // // Formula is (halfThumbWidth - v * (fullThumbWidth / 100)), taking into account that display has translate(-50%, 0). + // // Size of thumb is set in CSS. + // displayDOM.style.left = `calc(${(v - min) / (max - min) * 100}% + ${10 - v * 0.2}px)` + // } + + + // var onValueChangedCallback = function () { + // let i; + // let v; + // for (i = 0; i < question.choices.length; i++) + // { + // v = question.value[i] || 0; + // inputDOMS[i].value = v; + // sliderDisplayDOMS[i].innerText = v; + // positionSliderDisplay(v, question.minVal, question.maxVal, sliderDisplayDOMS[i]); + // } + // } + + // var onReadOnlyChangedCallback = function() { + // let i; + // if (question.isReadOnly) { + // for (i = 0; i < question.choices.length; i++) + // { + // inputDOMS[i].setAttribute('disabled', 'disabled'); + // } + // } else { + // for (i = 0; i < question.choices.length; i++) + // { + // inputDOMS[i].removeAttribute("disabled"); + // } + // } + // }; + + // if question becomes readonly/enabled add/remove disabled attribute + // question.readOnlyChangedCallback = onReadOnlyChangedCallback; + + // if the question value changed in the code, for example you have changed it in JavaScript + // question.valueChangedCallback = onValueChangedCallback; + + // set initial value + // onValueChangedCallback(); + + // make elements disabled if needed + // onReadOnlyChangedCallback(); + }, + + //Use it to destroy the widget. It is typically needed by jQuery widgets + willUnmount: function (question, el) { + //We do not need to clear anything in our simple example + //Here is the example to destroy the image picker + //var $el = $(el).find("select"); + //$el.data('picker').destroy(); + } + } + + //Register our widget in singleton custom widget collection + Survey.CustomWidgetCollection.Instance.addCustomWidget(widget, "customtype"); +} diff --git a/src/visual/survey/widgets/SliderStar.js b/src/visual/survey/widgets/SliderStar.js new file mode 100644 index 0000000..8c6c223 --- /dev/null +++ b/src/visual/survey/widgets/SliderStar.js @@ -0,0 +1,290 @@ +/** +* @desc Slider Star. +* */ + +class SliderStar +{ + constructor (cfg = {}) + { + const surveyCSS = cfg.question.css; + this._CSS_CLASSES = { + // INPUT_TEXT: `${surveyCSS.text.root} slider-star-text-input` + INPUT_TEXT: `slider-star-text-input` + }; + this._question = cfg.question; + this._DOM = cfg.el; + this._engagedInputIdx = undefined; + this._pdowns = {}; + + this._bindedHandlers = + { + _handleInput: this._handleInput.bind(this), + _handlePointerDown: this._handlePointerDown.bind(this), + _handlePointerUp: this._handlePointerUp.bind(this), + _handlePointerMove: this._handlePointerMove.bind(this) + }; + + this._init(this._question, this._DOM); + } + + _markStarsActive (n, qIdx) + { + let stars = this._DOM.querySelectorAll(`.stars-container[data-idx="${qIdx}"] .star-slider-star-input`); + let i; + for (i = 0; i < stars.length; i++) + { + stars[i].classList.remove("active"); + if (i <= n - 1) + { + stars[i].classList.add("active"); + } + } + } + + _handleIndividualValueUpdate (v, qIdx) + { + if (this._question.value === undefined) + { + this._question.value = {}; + } + if (this._question.value[qIdx] !== v) + { + this._question.value[qIdx] = v; + this._DOM.querySelector(`.slider-star-text-input[name="${qIdx}"]`).value = v; + this._markStarsActive(v, qIdx); + // Triggering internal SurveyJS mechanism for value update. + this._question.value = this._question.value; + } + } + + _handleInput (e) + { + let v = parseInt(e.currentTarget.value, 10) || 0; + v = Math.max(0, Math.min(this._question.starCount, v)); + const qIdx = e.currentTarget.name; + this._handleIndividualValueUpdate(v, qIdx); + } + + _handlePointerDown (e) + { + e.preventDefault(); + this._engagedInputIdx = e.currentTarget.dataset.idx; + this._pdowns[this._engagedInputIdx] = true; + const starIdx = [].indexOf.call(e.target.parentElement.children, e.target); + this._handleIndividualValueUpdate(starIdx + 1, this._engagedInputIdx); + } + + _handlePointerUp (e) + { + if (this._engagedInputIdx !== undefined) + { + this._pdowns[this._engagedInputIdx] = false; + } + this._engagedInputIdx = undefined; + } + + _handlePointerMove (e) + { + if (this._pdowns[this._engagedInputIdx]) + { + e.preventDefault(); + const starIdx = [].indexOf.call(e.target.parentElement.children, e.target); + this._handleIndividualValueUpdate(starIdx + 1, this._engagedInputIdx); + } + } + + _init (question, el) + { + let t = performance.now(); + let starsHTML = new Array(question.starCount).fill(`
    `).join(""); + let html = ""; + let i; + for (i = 0; i < question.choices.length; i++) + { + html += + `
    +
    ${question.choices[i].text}
    +
    +
    ${starsHTML}
    + ${question.showValue ? + `` : + ""} +
    +
    `; + } + + el.insertAdjacentHTML("beforeend", html); + const inputDOMS = el.querySelectorAll(".slider-star-text-input"); + const starsContainers = el.querySelectorAll(".stars-container"); + + // Amount of inputDOMS and starsCointainer is the same. + // Also iterating over starContainers since text inputs might be absent. + for (i = 0; i < starsContainers.length; i++) + { + inputDOMS[i].addEventListener("input", this._bindedHandlers._handleInput); + starsContainers[i].addEventListener("pointerdown", this._bindedHandlers._handlePointerDown); + starsContainers[i].addEventListener("pointermove", this._bindedHandlers._handlePointerMove); + } + window.addEventListener("pointerup", this._bindedHandlers._handlePointerUp); + } +} + +export default function init (Survey) { + var widget = { + //the widget name. It should be unique and written in lowcase. + name: "sliderstar", + + //the widget title. It is how it will appear on the toolbox of the SurveyJS Editor/Builder + title: "Slider Star", + + //the name of the icon on the toolbox. We will leave it empty to use the standard one + iconName: "", + + //If the widgets depends on third-party library(s) then here you may check if this library(s) is loaded + widgetIsLoaded: function () { + //return typeof $ == "function" && !!$.fn.select2; //return true if jQuery and select2 widget are loaded on the page + return true; //we do not require anything so we just return true. + }, + + //SurveyJS library calls this function for every question to check, if it should use this widget instead of default rendering/behavior + isFit: function (question) { + //we return true if the type of question is sliderstar + return question.getType() === 'sliderstar'; + //the following code will activate the widget for a text question with inputType equals to date + //return question.getType() === 'text' && question.inputType === "date"; + }, + + //Use this function to create a new class or add new properties or remove unneeded properties from your widget + //activatedBy tells how your widget has been activated by: property, type or customType + //property - it means that it will activated if a property of the existing question type is set to particular value, for example inputType = "date" + //type - you are changing the behaviour of entire question type. For example render radiogroup question differently, have a fancy radio buttons + //customType - you are creating a new type, like in our example "sliderstar" + activatedByChanged: function (activatedBy) { + //we do not need to check acticatedBy parameter, since we will use our widget for customType only + //We are creating a new class and derived it from text question type. It means that text model (properties and fuctions) will be available to us + Survey.JsonObject.metaData.addClass("sliderstar", [], null, "text"); + //signaturepad is derived from "empty" class - basic question class + //Survey.JsonObject.metaData.addClass("signaturepad", [], null, "empty"); + + //Add new property(s) + //For more information go to https://surveyjs.io/Examples/Builder/?id=addproperties#content-docs + Survey.JsonObject.metaData.addProperties("sliderstar", [ + { + name: "choices", + isArray: true, + default: [] + }, + { + name: "starCount", + default: 5 + }, + { + name: "showValue", + default: true + }, + { + name: "starType", + default: "descrete" + } + ]); + }, + + //If you want to use the default question rendering then set this property to true. We do not need any default rendering, we will use our our htmlTemplate + isDefaultRender: false, + + //You should use it if your set the isDefaultRender to false + htmlTemplate: "
    ", + + //The main function, rendering and two-way binding + afterRender: function (question, el) { + new SliderStar({ question, el }); + + // let containers = el.querySelectorAll(".srv-slider-container"); + // let inputDOMS = el.querySelectorAll(".srv-slider"); + // let sliderDisplayDOMS = el.querySelectorAll(".srv-slider-display"); + // if (!(question.value instanceof Array)) + // { + // question.value = new Array(inputDOMS.length); + // question.value.fill(0); + // } + + // for (i = 0; i < inputDOMS.length; i++) + // { + // inputDOMS[i].min = question.minVal; + // inputDOMS[i].max = question.maxVal; + // inputDOMS[i].addEventListener("input", (e) => { + // let idx = parseInt(e.currentTarget.dataset.idx, 10); + // question.value[idx] = parseFloat(e.currentTarget.value); + // // using .value setter to trigger update properly. + // // otherwise on survey competion it returns array of nulls. + // question.value = question.value; + // onValueChangedCallback(); + // }); + + // // Handle grid lines? + // } + + + // function positionSliderDisplay (v, min, max, displayDOM) + // { + // v = parseFloat(v); + // min = parseFloat(min); + // max = parseFloat(max); + // // Formula is (halfThumbWidth - v * (fullThumbWidth / 100)), taking into account that display has translate(-50%, 0). + // // Size of thumb is set in CSS. + // displayDOM.style.left = `calc(${(v - min) / (max - min) * 100}% + ${10 - v * 0.2}px)` + // } + + + // var onValueChangedCallback = function () { + // let i; + // let v; + // for (i = 0; i < question.choices.length; i++) + // { + // v = question.value[i] || 0; + // inputDOMS[i].value = v; + // sliderDisplayDOMS[i].innerText = v; + // positionSliderDisplay(v, question.minVal, question.maxVal, sliderDisplayDOMS[i]); + // } + // } + + // var onReadOnlyChangedCallback = function() { + // let i; + // if (question.isReadOnly) { + // for (i = 0; i < question.choices.length; i++) + // { + // inputDOMS[i].setAttribute('disabled', 'disabled'); + // } + // } else { + // for (i = 0; i < question.choices.length; i++) + // { + // inputDOMS[i].removeAttribute("disabled"); + // } + // } + // }; + + // if question becomes readonly/enabled add/remove disabled attribute + // question.readOnlyChangedCallback = onReadOnlyChangedCallback; + + // if the question value changed in the code, for example you have changed it in JavaScript + // question.valueChangedCallback = onValueChangedCallback; + + // set initial value + // onValueChangedCallback(); + + // make elements disabled if needed + // onReadOnlyChangedCallback(); + }, + + //Use it to destroy the widget. It is typically needed by jQuery widgets + willUnmount: function (question, el) { + //We do not need to clear anything in our simple example + //Here is the example to destroy the image picker + //var $el = $(el).find("select"); + //$el.data('picker').destroy(); + } + } + + //Register our widget in singleton custom widget collection + Survey.CustomWidgetCollection.Instance.addCustomWidget(widget, "customtype"); +} diff --git a/src/visual/survey/widgets/SliderWidget.js b/src/visual/survey/widgets/SliderWidget.js new file mode 100644 index 0000000..1d71359 --- /dev/null +++ b/src/visual/survey/widgets/SliderWidget.js @@ -0,0 +1,192 @@ +/** +* @desc Slider widget for surveyJS. +* */ + +export default function init (Survey) { + var widget = { + //the widget name. It should be unique and written in lowcase. + name: "slider", + + //the widget title. It is how it will appear on the toolbox of the SurveyJS Editor/Builder + title: "Slider", + + //the name of the icon on the toolbox. We will leave it empty to use the standard one + iconName: "", + + //If the widgets depends on third-party library(s) then here you may check if this library(s) is loaded + widgetIsLoaded: function () { + //return typeof $ == "function" && !!$.fn.select2; //return true if jQuery and select2 widget are loaded on the page + return true; //we do not require anything so we just return true. + }, + + //SurveyJS library calls this function for every question to check, if it should use this widget instead of default rendering/behavior + isFit: function (question) { + //we return true if the type of question is slider + return question.getType() === 'slider'; + //the following code will activate the widget for a text question with inputType equals to date + //return question.getType() === 'text' && question.inputType === "date"; + }, + + //Use this function to create a new class or add new properties or remove unneeded properties from your widget + //activatedBy tells how your widget has been activated by: property, type or customType + //property - it means that it will activated if a property of the existing question type is set to particular value, for example inputType = "date" + //type - you are changing the behaviour of entire question type. For example render radiogroup question differently, have a fancy radio buttons + //customType - you are creating a new type, like in our example "slider" + activatedByChanged: function (activatedBy) { + //we do not need to check acticatedBy parameter, since we will use our widget for customType only + //We are creating a new class and derived it from text question type. It means that text model (properties and fuctions) will be available to us + Survey.JsonObject.metaData.addClass("slider", [], null, "text"); + //signaturepad is derived from "empty" class - basic question class + //Survey.JsonObject.metaData.addClass("signaturepad", [], null, "empty"); + + //Add new property(s) + //For more information go to https://surveyjs.io/Examples/Builder/?id=addproperties#content-docs + Survey.JsonObject.metaData.addProperties("slider", [ + { + name: "choices", + isArray: true, + default: [] + }, + { + name: "minVal", + default: 0 + }, + { + name: "maxVal", + default: 100 + }, + { + name: "showValue", + default: true + }, + { + name: "sliderType", + default: "regular" + }, + { + name: "snapToGrid", + default: false + }, + { + name: "gridStep", + default: 10 + } + ]); + }, + + //If you want to use the default question rendering then set this property to true. We do not need any default rendering, we will use our our htmlTemplate + isDefaultRender: false, + + //You should use it if your set the isDefaultRender to false + htmlTemplate: "
    ", + + //The main function, rendering and two-way binding + afterRender: function (question, el) { + // Native input range styling inspired by: + // https://css-tricks.com/value-bubbles-for-range-inputs/ + // https://codepen.io/ShadowShahriar/pen/zYPPYrQ + let html = ""; + let i; + for (i = 0; i < question.choices.length; i++) + { + html += + `
    + +
    ${question.choices[i]}
    +
    +
    `; + } + + el.insertAdjacentHTML("beforeend", html); + + let containers = el.querySelectorAll(".srv-slider-container"); + let inputDOMS = el.querySelectorAll(".srv-slider"); + let sliderDisplayDOMS = el.querySelectorAll(".srv-slider-display"); + if (!(question.value instanceof Array)) + { + question.value = new Array(inputDOMS.length); + question.value.fill(0); + } + + function handleInput (e) { + let idx = parseInt(e.currentTarget.dataset.idx, 10); + question.value[idx] = parseFloat(e.currentTarget.value); + // using .value setter to trigger update properly. + // otherwise on survey completion it returns array of nulls. + question.value = question.value; + onValueChangedCallback(); + } + + for (i = 0; i < inputDOMS.length; i++) + { + inputDOMS[i].min = question.minVal; + inputDOMS[i].max = question.maxVal; + inputDOMS[i].addEventListener("input", handleInput); + + // Handle grid lines? + } + + + function positionSliderDisplay (v, min, max, displayDOM) + { + v = parseFloat(v); + min = parseFloat(min); + max = parseFloat(max); + // Formula is (halfThumbWidth - v * (fullThumbWidth / 100)), taking into account that display has translate(-50%, 0). + // Size of thumb is set in CSS. + displayDOM.style.left = `calc(${(v - min) / (max - min) * 100}% + ${10 - v * 0.2}px)` + } + + + var onValueChangedCallback = function () { + let i; + let v; + for (i = 0; i < question.choices.length; i++) + { + v = question.value[i] || 0; + inputDOMS[i].value = v; + sliderDisplayDOMS[i].innerText = v; + positionSliderDisplay(v, question.minVal, question.maxVal, sliderDisplayDOMS[i]); + } + } + + var onReadOnlyChangedCallback = function() { + let i; + if (question.isReadOnly) { + for (i = 0; i < question.choices.length; i++) + { + inputDOMS[i].setAttribute('disabled', 'disabled'); + } + } else { + for (i = 0; i < question.choices.length; i++) + { + inputDOMS[i].removeAttribute("disabled"); + } + } + }; + + // if question becomes readonly/enabled add/remove disabled attribute + question.readOnlyChangedCallback = onReadOnlyChangedCallback; + + // if the question value changed in the code, for example you have changed it in JavaScript + question.valueChangedCallback = onValueChangedCallback; + + // set initial value + onValueChangedCallback(); + + // make elements disabled if needed + onReadOnlyChangedCallback(); + }, + + //Use it to destroy the widget. It is typically needed by jQuery widgets + willUnmount: function (question, el) { + //We do not need to clear anything in our simple example + //Here is the example to destroy the image picker + //var $el = $(el).find("select"); + //$el.data('picker').destroy(); + } + } + + //Register our widget in singleton custom widget collection + Survey.CustomWidgetCollection.Instance.addCustomWidget(widget, "customtype"); +}